kaizen-agent 1.9.1 → 1.9.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/cli/install.ts CHANGED
@@ -244,7 +244,7 @@ async function noAgent() {
244
244
  async function openUrl(url: string) {
245
245
  // `start` is a cmd builtin, not a program, so it has to go through cmd.
246
246
  if (process.platform === "win32") {
247
- try { await Bun.$`cmd /c start "" ${url}`.quiet(); return true; } catch { return false; }
247
+ try { const { cmdExe } = await import("./state.ts"); await Bun.$`${cmdExe()} /c start "" ${url}`.quiet(); return true; } catch { return false; }
248
248
  }
249
249
  const opener = process.platform === "darwin" ? "open" : "xdg-open";
250
250
  if (!Bun.which(opener)) return false;
package/cli/notes.ts CHANGED
@@ -1,9 +1,9 @@
1
1
  // The notebook's files: plain markdown under <state>/notes/, folders and all, so any
2
2
  // editor (Obsidian included) can open the same folder. Every name that arrives from
3
3
  // the page goes through safeName before it becomes a path.
4
- import { existsSync, mkdirSync, readdirSync, readFileSync, realpathSync, renameSync, rmdirSync, statSync, writeFileSync } from "node:fs";
4
+ import { existsSync, mkdirSync, readdirSync, realpathSync, renameSync, rmdirSync, statSync, writeFileSync } from "node:fs";
5
5
  import { join, dirname, resolve, sep } from "node:path";
6
- import { boardCards, reviewFindings } from "./state.ts";
6
+ import { boardCards, reviewFindings, readText } from "./state.ts";
7
7
  import { wikilinks, boardLink, resolveRun } from "../web/notes-lib.ts";
8
8
 
9
9
  export type Note = { name: string; mtime: number; text: string };
@@ -49,7 +49,7 @@ export function listNotes(state: string): Note[] {
49
49
  try { st = statSync(p); } catch { continue; }
50
50
  if (st.isDirectory()) walk(p, prefix + n + "/");
51
51
  else if (n.toLowerCase().endsWith(".md") && safeName(prefix + n) === prefix + n.slice(0, -3)) {
52
- try { out.push({ name: prefix + n.slice(0, -3), mtime: st.mtimeMs, text: readFileSync(p, "utf8") }); } catch { /* vanished */ }
52
+ try { out.push({ name: prefix + n.slice(0, -3), mtime: st.mtimeMs, text: readText(p) }); } catch { /* vanished */ }
53
53
  }
54
54
  }
55
55
  };
@@ -65,7 +65,7 @@ export function saveNote(state: string, name: unknown, text: string, base: numbe
65
65
  if (!t) return { ok: false, why: "bad name" };
66
66
  if (existsSync(t.file)) {
67
67
  const now = statSync(t.file).mtimeMs;
68
- if (now !== base) return { ok: false, why: "conflict", text: readFileSync(t.file, "utf8"), mtime: now };
68
+ if (now !== base) return { ok: false, why: "conflict", text: readText(t.file), mtime: now };
69
69
  } else if (base) return { ok: false, why: "conflict", text: "", mtime: 0 };
70
70
  mkdirSync(dirname(t.file), { recursive: true });
71
71
  writeFileSync(t.file, text);
@@ -116,7 +116,7 @@ export function boardIndex(state: string) {
116
116
  const cards = boardCards([state], Date.now());
117
117
  const backlog = (id: string) => {
118
118
  let text = "";
119
- try { text = readFileSync(join(state, "runs", id, "06-backlog.md"), "utf8"); } catch { /* none */ }
119
+ try { text = readText(join(state, "runs", id, "06-backlog.md")); } catch { /* none */ }
120
120
  return [...text.matchAll(/^\s*-\s*(open|done|rejected):\s*(.*)$/gm)].map((m, i) => ({ n: i + 1, status: m[1]!, text: m[2]!.trim() }));
121
121
  };
122
122
  return {
package/cli/settings.ts CHANGED
@@ -1,8 +1,9 @@
1
1
  // Full-screen settings browser for .kaizen/config.yml.
2
2
  // Arrow keys move and change; every change is written straight to the file.
3
- import { existsSync, readFileSync, writeFileSync, copyFileSync, mkdirSync } from "node:fs";
3
+ import { existsSync, writeFileSync, copyFileSync, mkdirSync } from "node:fs";
4
4
  import { join, dirname } from "node:path";
5
5
  import { homedir } from "node:os";
6
+ import { readText } from "./state.ts";
6
7
 
7
8
  // `text` is a value typed rather than chosen: a path. It has no values to cycle.
8
9
  type Setting = { key: string; label: string; group: string; values: string[]; help: string; text?: boolean };
@@ -123,8 +124,8 @@ const SETTINGS: Setting[] = [
123
124
  // one place decides what a setting is called, what it may be, and how it is saved.
124
125
  export function listSettings(repoRoot: string) {
125
126
  const file = locate();
126
- const defaults = readFileSync(join(repoRoot, "skills/kaizen/config.default.yml"), "utf8");
127
- const text = existsSync(file) ? readFileSync(file, "utf8") : "";
127
+ const defaults = readText(join(repoRoot, "skills/kaizen/config.default.yml"));
128
+ const text = existsSync(file) ? readText(file) : "";
128
129
  const preset = detectPreset(text, defaults);
129
130
  return {
130
131
  file: tilde(file),
@@ -141,8 +142,8 @@ export function setSetting(repoRoot: string, key: string, value: string) {
141
142
  const file = locate();
142
143
  const defaultsPath = join(repoRoot, "skills/kaizen/config.default.yml");
143
144
  if (!existsSync(file)) { mkdirSync(dirname(file), { recursive: true }); copyFileSync(defaultsPath, file); }
144
- const defaults = readFileSync(defaultsPath, "utf8");
145
- const text = readFileSync(file, "utf8");
145
+ const defaults = readText(defaultsPath);
146
+ const text = readText(file);
146
147
  let updated: string;
147
148
  if (key === "preset") {
148
149
  if (value === "custom") return { error: "custom is what any other change makes" };
@@ -180,12 +181,12 @@ export async function settings(repoRoot: string, standalone = true) {
180
181
  const { stdin, stdout } = process;
181
182
  if (!stdin.isTTY) { console.log(`Settings live in ${file}`); return; }
182
183
 
183
- const defaults = readFileSync(join(repoRoot, "skills/kaizen/config.default.yml"), "utf8");
184
+ const defaults = readText(join(repoRoot, "skills/kaizen/config.default.yml"));
184
185
  let active = 0, saved = "";
185
186
  // Screen line -> setting index, rebuilt on every draw; the mouse clicks by line.
186
187
  let rowAt: number[] = [];
187
188
  const draw = () => {
188
- const text = readFileSync(file, "utf8");
189
+ const text = readText(file);
189
190
  const currentPreset = detectPreset(text, defaults);
190
191
  // Booleans as a checkbox, numeric scales as a level bar; the file still holds the words.
191
192
  const show = (s: Setting, v: string) => {
@@ -227,7 +228,7 @@ export async function settings(repoRoot: string, standalone = true) {
227
228
  };
228
229
 
229
230
  stdout.write("\x1b[?1049h\x1b[?25l"); // alternate screen, hide cursor
230
- const mouse = /^\s*ui:\s*\n(?:\s*#.*\n)*\s*mouse:\s*(\S+)/m.exec(readFileSync(file, "utf8"));
231
+ const mouse = /^\s*ui:\s*\n(?:\s*#.*\n)*\s*mouse:\s*(\S+)/m.exec(readText(file));
231
232
  const mouseOn = mouse?.[1] === "true";
232
233
  if (mouseOn) { stdout.write("\x1b[?1000h\x1b[?1006h"); mouseArmed = true; }
233
234
  stdin.setRawMode(true);
@@ -238,7 +239,7 @@ export async function settings(repoRoot: string, standalone = true) {
238
239
  const s = SETTINGS[active]!;
239
240
  const wasMouse = s.key === "ui.mouse";
240
241
  if (s.text) { saved = c.dim(`type ${s.key} in the board's settings, or in ${tilde(file)}`); return; }
241
- const text = readFileSync(file, "utf8");
242
+ const text = readText(file);
242
243
  if (s.key === "preset") {
243
244
  const current = detectPreset(text, defaults);
244
245
  const presets: PresetName[] = ["low", "medium", "ultra"];
package/cli/state.test.ts CHANGED
@@ -8,7 +8,7 @@ import {
8
8
  readRuns, readInbox, writeInbox, replaceIdea, abandonRun, allBacklog, backlog,
9
9
  statusOf, columnOf, parseItem, startedRuns, boardCards, readArchive, setArchived,
10
10
  requestOf, agentFlags, readNotes, writeNotes, manualCommand, parseNotes, formatNote, appendNote, saveAttachment,
11
- reviewFindings, pickFindings, fixPrompt, gitStatus, runGit, cardsGit, commitPush, readCommit, findTerminal, pruneRuns, unrechecked, notice, notifier, notifyArgs, LOGO, type Card,
11
+ reviewFindings, pickFindings, fixPrompt, gitStatus, runGit, cardsGit, commitPush, readCommit, findTerminal, nativePath, pruneRuns, unrechecked, notice, notifier, notifyArgs, LOGO, type Card,
12
12
  } from "./state.ts";
13
13
 
14
14
  let state: string;
@@ -355,6 +355,47 @@ test.skipIf(process.platform === "win32")("findTerminal: a running herdr wins, t
355
355
  }
356
356
  });
357
357
 
358
+ test("nativePath on Windows reads the spellings Git Bash writes into the projects registry as one folder", () => {
359
+ const platform = Object.getOwnPropertyDescriptor(process, "platform")!;
360
+ try {
361
+ Object.defineProperty(process, "platform", { value: "win32" });
362
+ for (const p of ["/c/Users/me/proj", "C:/Users/me/proj", "c:\\Users\\me\\proj\\", "C:\\Users\\me\\proj"])
363
+ expect(nativePath(p)).toBe("C:\\Users\\me\\proj");
364
+ expect(nativePath("/d")).toBe("D:\\");
365
+ } finally { Object.defineProperty(process, "platform", platform); }
366
+ expect(nativePath("/c/Users/me")).toBe("/c/Users/me"); // elsewhere a path is left alone
367
+ });
368
+
369
+ test("findTerminal on Windows names cmd and PowerShell by full path, so a PATH without System32 still launches", () => {
370
+ const keys = ["PATH", "ComSpec", "SystemRoot", "windir"];
371
+ const saved = keys.map((k) => process.env[k]);
372
+ const platform = Object.getOwnPropertyDescriptor(process, "platform")!;
373
+ try {
374
+ Object.defineProperty(process, "platform", { value: "win32" });
375
+ process.env.PATH = mkdtempSync(join(tmpdir(), "kz-empty-"));
376
+ for (const k of keys.slice(1)) delete process.env[k];
377
+ process.env.SystemRoot = "D:\\Win";
378
+ const t = findTerminal("C:\\p", ["claude", "/kaizen x"])!;
379
+ expect(t.cmd[0]).toBe("D:\\Win\\System32\\cmd.exe");
380
+ expect(t.cmd[6]).toBe("D:\\Win\\System32\\WindowsPowerShell\\v1.0\\powershell.exe");
381
+ process.env.ComSpec = "E:\\cmd.exe";
382
+ expect(findTerminal("C:\\p", ["claude"])!.cmd[0]).toBe("E:\\cmd.exe");
383
+ // A running herdr: a tab, and the run typed into it as one encoded line.
384
+ writeFileSync(join(process.env.PATH, "herdr"), "#!/bin/sh\nexit 0\n", { mode: 0o755 });
385
+ const h = findTerminal("C:\\p", ["claude", "/kaizen it's\nnote"])!;
386
+ expect([h.cmd[0], h.cmd[1], h.cmd[2], h.detached]).toEqual(["D:\\Win\\System32\\WindowsPowerShell\\v1.0\\powershell.exe", "-NoProfile", "-EncodedCommand", false]);
387
+ const launch = Buffer.from(h.cmd[3], "base64").toString("utf16le");
388
+ expect(launch).toContain("tab create --cwd 'C:\\p' --label kaizen --focus");
389
+ const typed = /pane run \$Matches\[1\] '(.*)' \}/.exec(launch)![1].replace(/''/g, "'");
390
+ expect(typed).not.toContain("\n");
391
+ const run = Buffer.from(typed.split(" -EncodedCommand ")[1], "base64").toString("utf16le");
392
+ expect(run).toBe("Set-Location 'C:\\p'; & 'claude' '/kaizen it''s\nnote'");
393
+ } finally {
394
+ Object.defineProperty(process, "platform", platform);
395
+ keys.forEach((k, i) => (saved[i] === undefined ? delete process.env[k] : (process.env[k] = saved[i])));
396
+ }
397
+ });
398
+
358
399
  test("prune: only finished runs beyond keep go, open items are rescued, a dry run deletes nothing", () => {
359
400
  const st = join(mkdtempSync(join(tmpdir(), "kaizen-prune-")), ".kaizen");
360
401
  const mk = (id: string, stage: string, backlog = "") => {
@@ -425,3 +466,12 @@ test("notifier: only a run seen before, saying something new, and not a done the
425
466
  expect(notifier.toString()).toStartWith("function notifier(");
426
467
  expect(readFileSync(join(import.meta.dir, "..", "web", "board.html"), "utf8")).toContain("(/*notifier*/)()");
427
468
  });
469
+
470
+ test("files written by Windows PowerShell, with a byte order mark, still read: the run shows, the first idea is an idea", () => {
471
+ const st = join(mkdtempSync(join(tmpdir(), "kaizen-bom-")), ".kaizen");
472
+ mkdirSync(join(st, "runs", "r1"), { recursive: true });
473
+ writeFileSync(join(st, "runs", "r1", "state.json"), "" + JSON.stringify({ id: "r1", stage: "plan", awaiting: "approvals.plan" }));
474
+ writeFileSync(join(st, "inbox.md"), "- open: first idea\n");
475
+ expect(readRuns(st).map((r) => [r.id, r.stage])).toEqual([["r1", "plan"]]);
476
+ expect(readInbox(st)).toEqual([{ status: "open", text: "first idea" }]);
477
+ });
package/cli/state.ts CHANGED
@@ -2,7 +2,7 @@
2
2
  // `.kaizen/` directory. No terminal, no HTTP: the TUI (`dashboard.ts`) and the web
3
3
  // board (`web.ts`) both import this and draw it their own way.
4
4
  import { existsSync, readFileSync, readdirSync, appendFileSync, writeFileSync, mkdirSync, statSync, realpathSync, rmSync } from "node:fs";
5
- import { join, dirname } from "node:path";
5
+ import { join, dirname, win32 } from "node:path";
6
6
  import { homedir } from "node:os";
7
7
 
8
8
  // The real path: process.cwd() is always physical, so a raw $HOME that crosses a
@@ -25,7 +25,7 @@ export function detectDefaultAgent(projectDir: string): { name: string; cmd: str
25
25
  ? join(projectDir, ".kaizen", "config.yml")
26
26
  : join(home, ".kaizen", "config.yml");
27
27
  if (existsSync(cfgFile)) {
28
- const text = readFileSync(cfgFile, "utf8");
28
+ const text = readText(cfgFile);
29
29
  const m = /^\s*agent:\s*\n\s*default:\s*(\S+)/m.exec(text) || /^\s*agent\.default:\s*(\S+)/m.exec(text);
30
30
  if (m && m[1] && m[1] !== "auto") {
31
31
  const found = KNOWN_AGENTS.find((a) => a.cmd === m[1] || a.name.toLowerCase() === m[1].toLowerCase());
@@ -59,7 +59,7 @@ const EFFORT_FLAGS: Record<string, (e: string) => string[]> = {
59
59
  export function agentFlags(projectDir: string, cmd: string, configText?: string): string[] {
60
60
  if (configText === undefined) {
61
61
  const f = [join(projectDir, ".kaizen", "config.yml"), join(home, ".kaizen", "config.yml")].find(existsSync);
62
- configText = f ? readFileSync(f, "utf8") : "";
62
+ configText = f ? readText(f) : "";
63
63
  }
64
64
  // ponytail: regex over the yaml, like detectDefaultAgent; a parser when a third key needs it
65
65
  const block = new RegExp(`^agent:\\n(?:[ \\t]+.*\\n)*?[ \\t]+${cmd}:[ \\t]*(\\{[^}]*\\}|\\n(?:[ \\t]+[a-z]+:.*\\n?)+)`, "m").exec(configText);
@@ -77,8 +77,21 @@ export function agentFlags(projectDir: string, cmd: string, configText?: string)
77
77
  const shq = (a: string) => `'${a.replace(/'/g, `'\\''`)}'`;
78
78
 
79
79
  // Bun.which reads the PATH the process started with unless handed the current one.
80
+ // Windows PowerShell 5.1 writes `Set-Content -Encoding utf8` with a byte order mark,
81
+ // and agents on Windows write run files that way. JSON.parse refuses a leading BOM
82
+ // (the run vanished from the board) and it hides a first line from every ^ regex.
83
+ export const readText = (file: string) => readFileSync(file, "utf8").replace(/^\uFEFF/, "");
84
+
80
85
  const onPath = (bin: string) => Bun.which(bin, { PATH: process.env.PATH });
81
86
 
87
+ // Windows' own programs by full path: a board started from the shortcut or a
88
+ // stripped-down environment can have a PATH without System32, and a bare "cmd.exe"
89
+ // then fails with "Executable not found in $PATH".
90
+ const sysRoot = () => process.env.SystemRoot ?? process.env.windir ?? "C:\\Windows";
91
+ export const sysExe = (name: string) => `${sysRoot()}\\System32\\${name}`;
92
+ export const cmdExe = () => process.env.ComSpec ?? sysExe("cmd.exe");
93
+ export const powershellExe = () => onPath("pwsh.exe") ?? onPath("powershell.exe") ?? sysExe("WindowsPowerShell\\v1.0\\powershell.exe");
94
+
82
95
  // Whether a multiplexer's server is up: its own CLI says so by exit code. spawnSync
83
96
  // throws on a missing binary, so the path is resolved first.
84
97
  function answers(cmd: string[]): boolean {
@@ -102,16 +115,28 @@ export function findTerminal(cwd: string, fullCmd: string[]): { cmd: string[]; d
102
115
  // Terminal hosts the shell when installed; otherwise `start` gives it a window.
103
116
  if (process.platform === "win32") {
104
117
  const q = (a: string) => `'${a.replace(/'/g, "''")}'`;
105
- const shell = Bun.which("pwsh.exe") ? "pwsh.exe" : "powershell.exe";
118
+ const shell = powershellExe();
106
119
  const ps = `Set-Location ${q(cwd)}; & ${fullCmd.map(q).join(" ")}`;
107
120
  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 };
121
+ // herdr (beta on Windows) first, as elsewhere: a new tab, and the run typed into
122
+ // its PowerShell. Typed text ends at a newline, so what is typed is one line, the
123
+ // same encoded command; -EncodedCommand is also not a script file, so an
124
+ // execution policy that blocks .ps1 files does not stop it.
125
+ const herdr = onPath("herdr");
126
+ if (herdr && answers([herdr, "workspace", "list"])) {
127
+ const typed = `& ${q(shell)} -NoProfile -EncodedCommand ${enc}`;
128
+ const launch = `$o = & ${q(herdr)} tab create --cwd ${q(cwd)} --label kaizen --focus | Out-String
129
+ if ($o -match '"pane_id":"([^"]*)"') { & ${q(herdr)} pane run $Matches[1] ${q(typed)} } else { exit 1 }`;
130
+ return { cmd: [shell, "-NoProfile", "-EncodedCommand", Buffer.from(launch, "utf16le").toString("base64")], detached: false };
131
+ }
132
+ const wt = onPath("wt.exe");
133
+ if (wt) {
134
+ return { cmd: [wt, "-d", cwd, shell, "-NoExit", "-EncodedCommand", enc], detached: true };
110
135
  }
111
136
  // `start` reads a title only when quoted; an unquoted word is the command, so
112
137
  // "kaizen" here ran kaizen's own launcher. The empty title is what Bun quotes.
113
138
  return {
114
- cmd: ["cmd.exe", "/c", "start", "", "/D", cwd, shell, "-NoExit", "-EncodedCommand", enc],
139
+ cmd: [cmdExe(), "/c", "start", "", "/D", cwd, shell, "-NoExit", "-EncodedCommand", enc],
115
140
  detached: true,
116
141
  };
117
142
  }
@@ -191,9 +216,20 @@ export function searchRoots(extra: string[] = []): string[] {
191
216
 
192
217
  export function knownProjects(): string[] {
193
218
  if (!existsSync(REGISTRY)) return [];
194
- return readFileSync(REGISTRY, "utf8").split("\n")
219
+ return readText(REGISTRY).split("\n")
195
220
  .map((l) => l.trim())
196
- .filter((l) => l && !l.startsWith("#"));
221
+ .filter((l) => l && !l.startsWith("#"))
222
+ .map(nativePath)
223
+ .filter((d, i, all) => all.indexOf(d) === i);
224
+ }
225
+
226
+ // On Windows the registry is also written by agents running Git Bash, which says
227
+ // /c/Users/me/proj or C:/Users/me/proj. Read as is, the first is C:\c\Users\..., a
228
+ // folder that does not exist, and the project drops off the board. One spelling
229
+ // per folder, drive letter upper case, so the same project is not listed twice.
230
+ export function nativePath(p: string) {
231
+ if (process.platform !== "win32") return p;
232
+ return win32.resolve(p.replace(/^\/([a-zA-Z])(?=\/|$)/, "$1:/")).replace(/^[a-z]:/, (d) => d.toUpperCase());
197
233
  }
198
234
 
199
235
  // Never fails the command that triggered it: a read-only home is not a reason for a
@@ -258,7 +294,7 @@ export function readRuns(state: string): Run[] {
258
294
  return readdirSync(dir).sort().flatMap((id) => {
259
295
  try {
260
296
  const file = join(dir, id, "state.json");
261
- const s = JSON.parse(readFileSync(file, "utf8"));
297
+ const s = JSON.parse(readText(file));
262
298
  // When the run last moved. Kaizen records no liveness signal, so the file's
263
299
  // own mtime is the only evidence that anything is still working on it.
264
300
  let moved = 0;
@@ -279,7 +315,7 @@ export function pruneRuns(state: string, keep: number, apply = false): { id: str
279
315
  const open = r.stage === "done" ? readBacklog(join(dir, "06-backlog.md")) : [];
280
316
  if (apply) {
281
317
  const orphanage = join(state, "backlog.md");
282
- const gap = existsSync(orphanage) && !readFileSync(orphanage, "utf8").endsWith("\n") ? "\n" : "";
318
+ const gap = existsSync(orphanage) && !readText(orphanage).endsWith("\n") ? "\n" : "";
283
319
  if (open.length) appendFileSync(orphanage, gap + open.map((i) => `- open: ${i} | from ${r.id}\n`).join(""));
284
320
  rmSync(dir, { recursive: true, force: true });
285
321
  }
@@ -305,13 +341,13 @@ export function backlog(state: string) {
305
341
  let n = 0;
306
342
  for (const f of files) {
307
343
  if (!existsSync(f)) continue;
308
- n += (readFileSync(f, "utf8").match(/^\s*-\s*open:/gm) ?? []).length;
344
+ n += (readText(f).match(/^\s*-\s*open:/gm) ?? []).length;
309
345
  }
310
346
  return n;
311
347
  }
312
348
 
313
349
  export function version(repo: string) {
314
- try { return JSON.parse(readFileSync(join(repo, "package.json"), "utf8")).version; } catch { return ""; }
350
+ try { return JSON.parse(readText(join(repo, "package.json"))).version; } catch { return ""; }
315
351
  }
316
352
  // The global state directory is not a project and has no parent worth naming.
317
353
  export function label(stateDir: string) {
@@ -323,7 +359,7 @@ export function tilde(p: string) { return p.startsWith(home) ? "~" + p.slice(hom
323
359
  // The first few real lines of a file's body, blank lines and headings dropped.
324
360
  export function section(file: string, n: number) {
325
361
  if (!existsSync(file)) return [];
326
- return readFileSync(file, "utf8").split("\n")
362
+ return readText(file).split("\n")
327
363
  .map((l) => l.trim())
328
364
  .filter((l) => l && !l.startsWith("#"))
329
365
  .slice(0, n);
@@ -331,7 +367,7 @@ export function section(file: string, n: number) {
331
367
 
332
368
  export function readFindings(file: string) {
333
369
  if (!existsSync(file)) return [];
334
- return readFileSync(file, "utf8").split("\n")
370
+ return readText(file).split("\n")
335
371
  .map((l) => l.trim())
336
372
  .filter((l) => /^\d+\.\s/.test(l) || /\b(critical|high|medium|low)\b:/.test(l));
337
373
  }
@@ -346,7 +382,7 @@ export function reviewFindings(state: string, id: string) {
346
382
  const base = join(state, "runs", id);
347
383
  const read = (f: string) => {
348
384
  const p = join(base, f);
349
- return existsSync(p) ? readFileSync(p, "utf8").split("\n").map((l) => l.trim()) : [];
385
+ return existsSync(p) ? readText(p).split("\n").map((l) => l.trim()) : [];
350
386
  };
351
387
  let lines = read("04-review.md");
352
388
  const at = lines.findIndex((l) => /^#+\s*findings/i.test(l));
@@ -359,7 +395,7 @@ export function reviewFindings(state: string, id: string) {
359
395
  const closed = read("06-backlog.md").flatMap((l) => /^-\s*(?:done|rejected):\s*(.*)$/.exec(l)?.[1] ?? []);
360
396
  const iter = join(base, "05-iterations");
361
397
  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]))));
398
+ .flatMap((f) => [...readText(join(iter, f)).matchAll(/finding\s+(\d+)\W{1,8}closed/gi)].map((m) => Number(m[1]))));
363
399
  return lines.flatMap((l) => {
364
400
  const m = /^(\d+)\.\s+(.+)$/.exec(l);
365
401
  if (!m) return [];
@@ -408,7 +444,7 @@ export function tidy(text: string) {
408
444
 
409
445
  export function readBacklog(file: string) {
410
446
  if (!existsSync(file)) return [];
411
- return readFileSync(file, "utf8").split("\n")
447
+ return readText(file).split("\n")
412
448
  .map((l) => l.trim())
413
449
  .filter((l) => /^-\s*open:/.test(l))
414
450
  .map((l) => l.replace(/^-\s*open:\s*/, ""));
@@ -462,7 +498,7 @@ export function startedRuns(state: string): string[] {
462
498
  if (!existsSync(dir)) return [];
463
499
  const out: string[] = [];
464
500
  for (const id of readdirSync(dir)) {
465
- try { out.push(normalise(readFileSync(join(dir, id, "00-request.md"), "utf8"))); }
501
+ try { out.push(normalise(readText(join(dir, id, "00-request.md")))); }
466
502
  catch { /* no request file, or unreadable */ }
467
503
  }
468
504
  return out;
@@ -508,7 +544,7 @@ export function readInbox(state: string): InboxLine[] {
508
544
  const f = join(state, "inbox.md");
509
545
  if (!existsSync(f)) return [];
510
546
  const out: InboxLine[] = [];
511
- for (const raw of readFileSync(f, "utf8").split("\n")) {
547
+ for (const raw of readText(f).split("\n")) {
512
548
  const m = /^-\s*(open|started|done|rejected|archived):\s*(.+)$/.exec(raw.trim());
513
549
  if (m) { out.push({ status: m[1]!, text: m[2]!.trim() }); continue; }
514
550
  // An indented line belongs to the bullet above it; anything else is noise.
@@ -548,7 +584,7 @@ export function requestOf(text: string, notes?: string, state?: string) {
548
584
  // Free text beside a run -- what the user learned after the idea became a run.
549
585
  // Its own file, never state.json: that shape is the resume contract.
550
586
  export function readNotes(state: string, id: string) {
551
- try { return readFileSync(join(state, "runs", id, "notes.md"), "utf8"); } catch { return ""; }
587
+ try { return readText(join(state, "runs", id, "notes.md")); } catch { return ""; }
552
588
  }
553
589
 
554
590
  export function writeNotes(state: string, id: string, text: string): string | null {
@@ -670,7 +706,7 @@ export function short(id: string) { return id.replace(/^\d{4}-\d{2}-\d{2}-/, "")
670
706
  export function abandonRun(st: string, id: string, why: string): string | null {
671
707
  const file = join(st, "runs", id, "state.json");
672
708
  try {
673
- const run = JSON.parse(readFileSync(file, "utf8"));
709
+ const run = JSON.parse(readText(file));
674
710
  if (run.stage === "abandoned") return "That run is already abandoned.";
675
711
  run.stage = "abandoned";
676
712
  run.awaiting = null;
@@ -700,7 +736,7 @@ export function replaceIdea(state: string, text: string, next: InboxLine | null)
700
736
  export function readArchive(state: string): Set<string> {
701
737
  const f = join(state, "archive.md");
702
738
  if (!existsSync(f)) return new Set();
703
- return new Set(readFileSync(f, "utf8").split("\n")
739
+ return new Set(readText(f).split("\n")
704
740
  .map((l) => /^-\s*(\S+)/.exec(l.trim())?.[1])
705
741
  .filter((x): x is string => !!x));
706
742
  }
@@ -741,7 +777,7 @@ export function boardCards(states: string[], now: number): Card[] {
741
777
  const bodies = new Map<string, string>();
742
778
  const body = (st: string, id: string) => {
743
779
  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, ""); } }
780
+ if (!bodies.has(f)) { try { bodies.set(f, readText(f)); } catch { bodies.set(f, ""); } }
745
781
  return bodies.get(f)!;
746
782
  };
747
783
  const requests: string[] = [];
@@ -854,7 +890,7 @@ export function launchRun(it: Item, from: string): Launch {
854
890
  export async function pidOnPort(port: number): Promise<number | null> {
855
891
  try {
856
892
  if (process.platform === "win32") {
857
- const out = await Bun.$`netstat -ano -p tcp`.text();
893
+ const out = await Bun.$`${sysExe("netstat.exe")} -ano -p tcp`.text();
858
894
  const m = new RegExp(`127\\.0\\.0\\.1:${port}\\s+\\S+\\s+LISTENING\\s+(\\d+)`).exec(out);
859
895
  return m ? Number(m[1]) : null;
860
896
  }
@@ -913,7 +949,7 @@ function runReport(state: string, id: string) {
913
949
  const base = join(state, "runs", id), its = join(base, "05-iterations");
914
950
  const fixes = existsSync(its) ? readdirSync(its).filter((n) => n.endsWith("-fix.md")).map((n) => join(its, n)) : [];
915
951
  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 {}
952
+ for (const f of [join(base, "03-impl.md"), ...fixes]) try { text += readText(f) + "\n"; at = Math.max(at, statSync(f).mtimeMs); } catch {}
917
953
  return { text, at };
918
954
  }
919
955
  const names = (report: string, path: string) => report.includes("`" + path + "`") || report.includes("`" + path + ":");
@@ -956,7 +992,7 @@ export function runGit(state: string, id: string): GitStatus {
956
992
  // mtime is the liveness signal.
957
993
  export function readCommit(state: string, id: string): { sha: string; pushed: boolean } | null {
958
994
  try {
959
- const t = readFileSync(join(state, "runs", id, "07-commit.md"), "utf8");
995
+ const t = readText(join(state, "runs", id, "07-commit.md"));
960
996
  return { sha: /^commit: (\S+)/m.exec(t)?.[1] ?? "", pushed: /^pushed: yes/m.test(t) };
961
997
  } catch { return null; }
962
998
  }
package/cli/web.ts CHANGED
@@ -1,11 +1,11 @@
1
1
  // `kaizen web`: the board in a browser. A loopback HTTP server over state.ts and
2
2
  // one HTML page; the page never sees the filesystem, only JSON.
3
- import { existsSync, readFileSync, readdirSync, statSync, watch, appendFileSync, mkdirSync, type FSWatcher } from "node:fs";
3
+ import { existsSync, readdirSync, statSync, watch, appendFileSync, mkdirSync, type FSWatcher } from "node:fs";
4
4
  import { join, dirname } from "node:path";
5
5
  import {
6
6
  home, type Item, type Card, boardCards, knownProjects, remember, locate, label, tilde, searchRoots, findProjects,
7
7
  readInbox, writeInbox, replaceIdea, abandonRun, launchRun, parseItem, short, setArchived, writeNotes,
8
- appendNote, saveAttachment, pidOnPort, runGit, cardsGit, readCommit, commitPush, notice, notifier, LOGO,
8
+ appendNote, saveAttachment, pidOnPort, readText, cmdExe, sysExe, powershellExe, runGit, cardsGit, readCommit, commitPush, notice, notifier, LOGO,
9
9
  reviewFindings, pickFindings, fixPrompt,
10
10
  } from "./state.ts";
11
11
  import { readSources, addSource, removeSource, moveSource, gatherSource, gatherAll, gatherDirs, due } from "./sources.ts";
@@ -19,7 +19,7 @@ const DEFAULT_PORT = 7420;
19
19
  // Read once at start: a board keeps the code it started with, so this is what it
20
20
  // runs, not what is installed now. `/` sends it; `current()` compares the two.
21
21
  const VERSION = (() => {
22
- try { return JSON.parse(readFileSync(join(dirname(import.meta.dir), "package.json"), "utf8")).version as string; }
22
+ try { return JSON.parse(readText(join(dirname(import.meta.dir), "package.json"))).version as string; }
23
23
  catch { return "unknown"; }
24
24
  })();
25
25
 
@@ -188,6 +188,10 @@ export async function web(repoDir: string, opts: Opts = {}) {
188
188
  function serve(port: number) { return Bun.serve({
189
189
  hostname: "127.0.0.1",
190
190
  port,
191
+ // Bun drops a request after 10s idle by default. A slow /state (git status in a
192
+ // big repo, on Windows) then comes back as nothing, and the page shows an empty
193
+ // board. Loopback only, so nothing waits on us but our own page.
194
+ idleTimeout: 0,
191
195
  async fetch(req) {
192
196
  // Host first: a hostname rebound to 127.0.0.1 by an attacker's DNS still
193
197
  // arrives with that hostname in Host, and gets nothing.
@@ -199,7 +203,7 @@ export async function web(repoDir: string, opts: Opts = {}) {
199
203
  if (req.method === "GET" && path === "/") {
200
204
  if (!existsSync(PAGE)) return new Response("web/board.html missing", { status: 500 });
201
205
  // The page gets the notify rule from state.ts rather than keeping its own copy.
202
- return new Response(readFileSync(PAGE, "utf8").replace("/*notifier*/", () => notifier.toString()), {
206
+ return new Response(readText(PAGE).replace("/*notifier*/", () => notifier.toString()), {
203
207
  headers: { "content-type": "text/html; charset=utf-8", "cache-control": "no-store", "x-kaizen-version": VERSION },
204
208
  });
205
209
  }
@@ -256,7 +260,7 @@ export async function web(repoDir: string, opts: Opts = {}) {
256
260
  if (!dir || !states(true).includes(dir) || !/^[\w.-]+$/.test(id)) return bad("no such run", 404);
257
261
  const base = join(dir, "runs", id);
258
262
  if (!existsSync(base)) return bad("no such run", 404);
259
- const read = (f: string) => existsSync(join(base, f)) ? readFileSync(join(base, f), "utf8") : null;
263
+ const read = (f: string) => existsSync(join(base, f)) ? readText(join(base, f)) : null;
260
264
  return json({
261
265
  id, short: short(id), state: read("state.json"),
262
266
  request: read("00-request.md"), plan: read("01-plan.md"), approval: read("02-approval.md"),
@@ -504,7 +508,7 @@ export async function web(repoDir: string, opts: Opts = {}) {
504
508
  if (!/^[\w.-]+$/.test(id)) return bad("bad run id");
505
509
  if (dir === join(home, ".kaizen")) return bad("no project to commit", 409);
506
510
  let run: any;
507
- try { run = JSON.parse(readFileSync(join(dir, "runs", id, "state.json"), "utf8")); } catch { return bad("no such run", 404); }
511
+ try { run = JSON.parse(readText(join(dir, "runs", id, "state.json"))); } catch { return bad("no such run", 404); }
508
512
  if (run.stage !== "done" && run.awaiting !== "approvals.review") return bad("this run is not finished", 409);
509
513
  const r = await commitPush(dir, id, String(body.message ?? ""), Array.isArray(body.files) ? body.files.map(String) : []);
510
514
  changed();
@@ -519,7 +523,7 @@ export async function web(repoDir: string, opts: Opts = {}) {
519
523
  if (!/^[\w.-]+$/.test(id)) return bad("bad run id");
520
524
  if (dir === join(home, ".kaizen")) return bad("no project to fix in", 409);
521
525
  let run: any;
522
- try { run = JSON.parse(readFileSync(join(dir, "runs", id, "state.json"), "utf8")); } catch { return bad("no such run", 404); }
526
+ try { run = JSON.parse(readText(join(dir, "runs", id, "state.json"))); } catch { return bad("no such run", 404); }
523
527
  if (run.stage !== "done" && run.awaiting !== "approvals.review" && run.awaiting !== "findings") return bad("this run is still being worked on", 409);
524
528
  const nums = pickFindings(dir, id, body.nums);
525
529
  if (!nums) return bad("pick open findings of this review");
@@ -583,7 +587,7 @@ export async function openApp(url: string) {
583
587
  } catch { /* fall through to a plain tab */ }
584
588
  // `start` is a cmd builtin, not a program, so it has to go through cmd.
585
589
  if (process.platform === "win32") {
586
- try { await Bun.$`cmd /c start "" ${url}`.quiet(); return true; } catch { return false; }
590
+ try { await Bun.$`${cmdExe()} /c start "" ${url}`.quiet(); return true; } catch { return false; }
587
591
  }
588
592
  const opener = process.platform === "darwin" ? "open" : "xdg-open";
589
593
  if (!Bun.which(opener)) return false;
@@ -644,8 +648,8 @@ export async function shortcut() {
644
648
  mkdirSync(dirname(vbs), { recursive: true });
645
649
  const vq = (s: string) => `""${s}""`; // a quoted arg inside a VBScript string
646
650
  writeFileSync(vbs, `CreateObject("WScript.Shell").Run "${vq(bun)} ${vq(script)} web --daemon", 0, False\r\n`);
647
- const ps = `$s=(New-Object -ComObject WScript.Shell).CreateShortcut('${lnk}');$s.TargetPath='wscript.exe';$s.Arguments='"${vbs}"';$s.WorkingDirectory='${home}';${existsSync(ico) ? `$s.IconLocation='${ico}';` : ""}$s.Save()`;
648
- await Bun.$`powershell -NoProfile -Command ${ps}`.quiet();
651
+ const ps = `$s=(New-Object -ComObject WScript.Shell).CreateShortcut('${lnk}');$s.TargetPath='${sysExe("wscript.exe")}';$s.Arguments='"${vbs}"';$s.WorkingDirectory='${home}';${existsSync(ico) ? `$s.IconLocation='${ico}';` : ""}$s.Save()`;
652
+ await Bun.$`${powershellExe()} -NoProfile -Command ${ps}`.quiet();
649
653
  return `${lnk} — double-click starts the board in the background and opens it; kaizen web --stop ends it`;
650
654
  }
651
655
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "kaizen-agent",
3
- "version": "1.9.1",
3
+ "version": "1.9.3",
4
4
  "description": "Staged workflow for AI agents — code, writing, research, operations: plan, human approval, build, independent review, bounded fix loop.",
5
5
  "type": "module",
6
6
  "bin": {