kaizen-agent 1.9.2 → 1.9.4

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/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
@@ -466,3 +466,12 @@ test("notifier: only a run seen before, saying something new, and not a done the
466
466
  expect(notifier.toString()).toStartWith("function notifier(");
467
467
  expect(readFileSync(join(import.meta.dir, "..", "web", "board.html"), "utf8")).toContain("(/*notifier*/)()");
468
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
@@ -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,6 +77,11 @@ 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
 
82
87
  // Windows' own programs by full path: a board started from the shortcut or a
@@ -93,7 +98,7 @@ function answers(cmd: string[]): boolean {
93
98
  const bin = onPath(cmd[0]);
94
99
  if (!bin) return false;
95
100
  try {
96
- return Bun.spawnSync([bin, ...cmd.slice(1)], { stdin: "ignore", stdout: "ignore", stderr: "ignore", timeout: 2000 }).exitCode === 0;
101
+ return Bun.spawnSync([bin, ...cmd.slice(1)], { stdin: "ignore", stdout: "ignore", stderr: "ignore", timeout: 2000, windowsHide: true }).exitCode === 0;
97
102
  } catch {
98
103
  return false;
99
104
  }
@@ -211,7 +216,7 @@ export function searchRoots(extra: string[] = []): string[] {
211
216
 
212
217
  export function knownProjects(): string[] {
213
218
  if (!existsSync(REGISTRY)) return [];
214
- return readFileSync(REGISTRY, "utf8").split("\n")
219
+ return readText(REGISTRY).split("\n")
215
220
  .map((l) => l.trim())
216
221
  .filter((l) => l && !l.startsWith("#"))
217
222
  .map(nativePath)
@@ -289,7 +294,7 @@ export function readRuns(state: string): Run[] {
289
294
  return readdirSync(dir).sort().flatMap((id) => {
290
295
  try {
291
296
  const file = join(dir, id, "state.json");
292
- const s = JSON.parse(readFileSync(file, "utf8"));
297
+ const s = JSON.parse(readText(file));
293
298
  // When the run last moved. Kaizen records no liveness signal, so the file's
294
299
  // own mtime is the only evidence that anything is still working on it.
295
300
  let moved = 0;
@@ -310,7 +315,7 @@ export function pruneRuns(state: string, keep: number, apply = false): { id: str
310
315
  const open = r.stage === "done" ? readBacklog(join(dir, "06-backlog.md")) : [];
311
316
  if (apply) {
312
317
  const orphanage = join(state, "backlog.md");
313
- const gap = existsSync(orphanage) && !readFileSync(orphanage, "utf8").endsWith("\n") ? "\n" : "";
318
+ const gap = existsSync(orphanage) && !readText(orphanage).endsWith("\n") ? "\n" : "";
314
319
  if (open.length) appendFileSync(orphanage, gap + open.map((i) => `- open: ${i} | from ${r.id}\n`).join(""));
315
320
  rmSync(dir, { recursive: true, force: true });
316
321
  }
@@ -336,13 +341,13 @@ export function backlog(state: string) {
336
341
  let n = 0;
337
342
  for (const f of files) {
338
343
  if (!existsSync(f)) continue;
339
- n += (readFileSync(f, "utf8").match(/^\s*-\s*open:/gm) ?? []).length;
344
+ n += (readText(f).match(/^\s*-\s*open:/gm) ?? []).length;
340
345
  }
341
346
  return n;
342
347
  }
343
348
 
344
349
  export function version(repo: string) {
345
- 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 ""; }
346
351
  }
347
352
  // The global state directory is not a project and has no parent worth naming.
348
353
  export function label(stateDir: string) {
@@ -354,7 +359,7 @@ export function tilde(p: string) { return p.startsWith(home) ? "~" + p.slice(hom
354
359
  // The first few real lines of a file's body, blank lines and headings dropped.
355
360
  export function section(file: string, n: number) {
356
361
  if (!existsSync(file)) return [];
357
- return readFileSync(file, "utf8").split("\n")
362
+ return readText(file).split("\n")
358
363
  .map((l) => l.trim())
359
364
  .filter((l) => l && !l.startsWith("#"))
360
365
  .slice(0, n);
@@ -362,7 +367,7 @@ export function section(file: string, n: number) {
362
367
 
363
368
  export function readFindings(file: string) {
364
369
  if (!existsSync(file)) return [];
365
- return readFileSync(file, "utf8").split("\n")
370
+ return readText(file).split("\n")
366
371
  .map((l) => l.trim())
367
372
  .filter((l) => /^\d+\.\s/.test(l) || /\b(critical|high|medium|low)\b:/.test(l));
368
373
  }
@@ -377,7 +382,7 @@ export function reviewFindings(state: string, id: string) {
377
382
  const base = join(state, "runs", id);
378
383
  const read = (f: string) => {
379
384
  const p = join(base, f);
380
- return existsSync(p) ? readFileSync(p, "utf8").split("\n").map((l) => l.trim()) : [];
385
+ return existsSync(p) ? readText(p).split("\n").map((l) => l.trim()) : [];
381
386
  };
382
387
  let lines = read("04-review.md");
383
388
  const at = lines.findIndex((l) => /^#+\s*findings/i.test(l));
@@ -390,7 +395,7 @@ export function reviewFindings(state: string, id: string) {
390
395
  const closed = read("06-backlog.md").flatMap((l) => /^-\s*(?:done|rejected):\s*(.*)$/.exec(l)?.[1] ?? []);
391
396
  const iter = join(base, "05-iterations");
392
397
  const rechecked = new Set((existsSync(iter) ? readdirSync(iter) : []).filter((f) => f.endsWith("-recheck.md"))
393
- .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]))));
394
399
  return lines.flatMap((l) => {
395
400
  const m = /^(\d+)\.\s+(.+)$/.exec(l);
396
401
  if (!m) return [];
@@ -439,7 +444,7 @@ export function tidy(text: string) {
439
444
 
440
445
  export function readBacklog(file: string) {
441
446
  if (!existsSync(file)) return [];
442
- return readFileSync(file, "utf8").split("\n")
447
+ return readText(file).split("\n")
443
448
  .map((l) => l.trim())
444
449
  .filter((l) => /^-\s*open:/.test(l))
445
450
  .map((l) => l.replace(/^-\s*open:\s*/, ""));
@@ -493,7 +498,7 @@ export function startedRuns(state: string): string[] {
493
498
  if (!existsSync(dir)) return [];
494
499
  const out: string[] = [];
495
500
  for (const id of readdirSync(dir)) {
496
- try { out.push(normalise(readFileSync(join(dir, id, "00-request.md"), "utf8"))); }
501
+ try { out.push(normalise(readText(join(dir, id, "00-request.md")))); }
497
502
  catch { /* no request file, or unreadable */ }
498
503
  }
499
504
  return out;
@@ -539,7 +544,7 @@ export function readInbox(state: string): InboxLine[] {
539
544
  const f = join(state, "inbox.md");
540
545
  if (!existsSync(f)) return [];
541
546
  const out: InboxLine[] = [];
542
- for (const raw of readFileSync(f, "utf8").split("\n")) {
547
+ for (const raw of readText(f).split("\n")) {
543
548
  const m = /^-\s*(open|started|done|rejected|archived):\s*(.+)$/.exec(raw.trim());
544
549
  if (m) { out.push({ status: m[1]!, text: m[2]!.trim() }); continue; }
545
550
  // An indented line belongs to the bullet above it; anything else is noise.
@@ -579,7 +584,7 @@ export function requestOf(text: string, notes?: string, state?: string) {
579
584
  // Free text beside a run -- what the user learned after the idea became a run.
580
585
  // Its own file, never state.json: that shape is the resume contract.
581
586
  export function readNotes(state: string, id: string) {
582
- try { return readFileSync(join(state, "runs", id, "notes.md"), "utf8"); } catch { return ""; }
587
+ try { return readText(join(state, "runs", id, "notes.md")); } catch { return ""; }
583
588
  }
584
589
 
585
590
  export function writeNotes(state: string, id: string, text: string): string | null {
@@ -701,7 +706,7 @@ export function short(id: string) { return id.replace(/^\d{4}-\d{2}-\d{2}-/, "")
701
706
  export function abandonRun(st: string, id: string, why: string): string | null {
702
707
  const file = join(st, "runs", id, "state.json");
703
708
  try {
704
- const run = JSON.parse(readFileSync(file, "utf8"));
709
+ const run = JSON.parse(readText(file));
705
710
  if (run.stage === "abandoned") return "That run is already abandoned.";
706
711
  run.stage = "abandoned";
707
712
  run.awaiting = null;
@@ -731,7 +736,7 @@ export function replaceIdea(state: string, text: string, next: InboxLine | null)
731
736
  export function readArchive(state: string): Set<string> {
732
737
  const f = join(state, "archive.md");
733
738
  if (!existsSync(f)) return new Set();
734
- return new Set(readFileSync(f, "utf8").split("\n")
739
+ return new Set(readText(f).split("\n")
735
740
  .map((l) => /^-\s*(\S+)/.exec(l.trim())?.[1])
736
741
  .filter((x): x is string => !!x));
737
742
  }
@@ -772,7 +777,7 @@ export function boardCards(states: string[], now: number): Card[] {
772
777
  const bodies = new Map<string, string>();
773
778
  const body = (st: string, id: string) => {
774
779
  const f = join(st, "runs", id, "00-request.md");
775
- 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, ""); } }
776
781
  return bodies.get(f)!;
777
782
  };
778
783
  const requests: string[] = [];
@@ -873,6 +878,9 @@ export function launchRun(it: Item, from: string): Launch {
873
878
  stdout: "ignore",
874
879
  stderr: "ignore",
875
880
  detached: term.detached,
881
+ // A detached launch is the terminal window itself; anything else (herdr's
882
+ // launcher) is plumbing, and must not flash a console on Windows.
883
+ windowsHide: !term.detached,
876
884
  });
877
885
  if (term.detached) proc.unref();
878
886
  return { ok: true, agent, prompt, projectDir, cmd: term.cmd };
@@ -897,7 +905,9 @@ export async function pidOnPort(port: number): Promise<number | null> {
897
905
  // ---- git, for the board's commit and push. Always argv, never a shell: the commit
898
906
  // message is whatever the user typed. A credential prompt has no terminal to appear
899
907
  // in, so git is told not to ask and is given a minute at most.
900
- const gitOpts = () => ({ env: { ...process.env, GIT_TERMINAL_PROMPT: "0" }, stdin: "ignore" as const, timeout: 60_000 });
908
+ // windowsHide: a board started from the Windows shortcut has no console, so each
909
+ // git it runs would open one of its own -- a window flashing on every refresh.
910
+ const gitOpts = () => ({ env: { ...process.env, GIT_TERMINAL_PROMPT: "0" }, stdin: "ignore" as const, timeout: 60_000, windowsHide: true });
901
911
  const gitDone = (code: number | null, stdout: string, stderr: string) => {
902
912
  const out = stdout.trimEnd(), err = stderr.trim();
903
913
  return { ok: code === 0, out, why: (err || out).split("\n").pop() || "git did not finish" };
@@ -944,7 +954,7 @@ function runReport(state: string, id: string) {
944
954
  const base = join(state, "runs", id), its = join(base, "05-iterations");
945
955
  const fixes = existsSync(its) ? readdirSync(its).filter((n) => n.endsWith("-fix.md")).map((n) => join(its, n)) : [];
946
956
  let text = "", at = 0;
947
- for (const f of [join(base, "03-impl.md"), ...fixes]) try { text += readFileSync(f, "utf8") + "\n"; at = Math.max(at, statSync(f).mtimeMs); } catch {}
957
+ for (const f of [join(base, "03-impl.md"), ...fixes]) try { text += readText(f) + "\n"; at = Math.max(at, statSync(f).mtimeMs); } catch {}
948
958
  return { text, at };
949
959
  }
950
960
  const names = (report: string, path: string) => report.includes("`" + path + "`") || report.includes("`" + path + ":");
@@ -987,7 +997,7 @@ export function runGit(state: string, id: string): GitStatus {
987
997
  // mtime is the liveness signal.
988
998
  export function readCommit(state: string, id: string): { sha: string; pushed: boolean } | null {
989
999
  try {
990
- const t = readFileSync(join(state, "runs", id, "07-commit.md"), "utf8");
1000
+ const t = readText(join(state, "runs", id, "07-commit.md"));
991
1001
  return { sha: /^commit: (\S+)/m.exec(t)?.[1] ?? "", pushed: /^pushed: yes/m.test(t) };
992
1002
  } catch { return null; }
993
1003
  }
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, cmdExe, sysExe, powershellExe, 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
 
@@ -203,7 +203,7 @@ export async function web(repoDir: string, opts: Opts = {}) {
203
203
  if (req.method === "GET" && path === "/") {
204
204
  if (!existsSync(PAGE)) return new Response("web/board.html missing", { status: 500 });
205
205
  // The page gets the notify rule from state.ts rather than keeping its own copy.
206
- return new Response(readFileSync(PAGE, "utf8").replace("/*notifier*/", () => notifier.toString()), {
206
+ return new Response(readText(PAGE).replace("/*notifier*/", () => notifier.toString()), {
207
207
  headers: { "content-type": "text/html; charset=utf-8", "cache-control": "no-store", "x-kaizen-version": VERSION },
208
208
  });
209
209
  }
@@ -260,7 +260,7 @@ export async function web(repoDir: string, opts: Opts = {}) {
260
260
  if (!dir || !states(true).includes(dir) || !/^[\w.-]+$/.test(id)) return bad("no such run", 404);
261
261
  const base = join(dir, "runs", id);
262
262
  if (!existsSync(base)) return bad("no such run", 404);
263
- 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;
264
264
  return json({
265
265
  id, short: short(id), state: read("state.json"),
266
266
  request: read("00-request.md"), plan: read("01-plan.md"), approval: read("02-approval.md"),
@@ -508,7 +508,7 @@ export async function web(repoDir: string, opts: Opts = {}) {
508
508
  if (!/^[\w.-]+$/.test(id)) return bad("bad run id");
509
509
  if (dir === join(home, ".kaizen")) return bad("no project to commit", 409);
510
510
  let run: any;
511
- 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); }
512
512
  if (run.stage !== "done" && run.awaiting !== "approvals.review") return bad("this run is not finished", 409);
513
513
  const r = await commitPush(dir, id, String(body.message ?? ""), Array.isArray(body.files) ? body.files.map(String) : []);
514
514
  changed();
@@ -523,7 +523,7 @@ export async function web(repoDir: string, opts: Opts = {}) {
523
523
  if (!/^[\w.-]+$/.test(id)) return bad("bad run id");
524
524
  if (dir === join(home, ".kaizen")) return bad("no project to fix in", 409);
525
525
  let run: any;
526
- 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); }
527
527
  if (run.stage !== "done" && run.awaiting !== "approvals.review" && run.awaiting !== "findings") return bad("this run is still being worked on", 409);
528
528
  const nums = pickFindings(dir, id, body.nums);
529
529
  if (!nums) return bad("pick open findings of this review");
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "kaizen-agent",
3
- "version": "1.9.2",
3
+ "version": "1.9.4",
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": {