kaizen-agent 1.22.0 → 1.23.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -49,6 +49,20 @@ Then, in any project:
49
49
 
50
50
  That's it. No init and no config.
51
51
 
52
+ kaizen needs an AI coding agent to run it, such as [Claude Code](https://docs.claude.com/en/docs/claude-code); install one first. Git is needed for the board's commit and push.
53
+
54
+ ## Use it
55
+
56
+ There are three ways in, all on the same runs:
57
+
58
+ | | How | What you do there |
59
+ |---|---|---|
60
+ | **Your agent** | `/kaizen <request>` in Claude Code, Codex, Cursor, … | Start a run and answer it in the chat. All commands are under [Commands](#commands). |
61
+ | **The board** | `kaizen web` opens it in the browser; `kaizen` in a terminal. `kaizen web --shortcut` once adds a launcher: Spotlight on macOS, the app menu on Linux, a Desktop shortcut on Windows | Every project's ideas and runs. Start a run, approve or revise a plan, answer its questions, pick findings to fix, then commit and push. |
62
+ | **Kaizen Bot** | The [desktop app](#download-the-app), or `bun start` in `bot/` | Chat with the board: ask what's waiting on you, hand teammates their asks. The app starts the board itself; from `bot/`, run `kaizen web` first. See [Kaizen Bot](#kaizen-bot). |
63
+
64
+ Settings for a project or for all of them: `kaizen settings`. New version: `kaizen upgrade`.
65
+
52
66
  ## See it
53
67
 
54
68
  ```
@@ -90,7 +90,7 @@ const fromPackage = (make: (k: string) => void, custom = false) => {
90
90
  const root = mkdtempSync(join(tmpdir(), "kz-replace-"));
91
91
  const home = join(root, "home"), pkg = join(root, "pkg");
92
92
  const k = custom ? join(root, "kaizen") : join(home, "kaizen");
93
- mkdirSync(home);
93
+ mkdirSync(join(home, ".claude"), { recursive: true }); // an agent: the installer stops on a box with none
94
94
  const src = dirname(dirname(import.meta.path));
95
95
  for (const d of ["cli", "skills", "agents", "commands", "web", "package.json"]) {
96
96
  cpSync(join(src, d), join(pkg, d), { recursive: true, filter: (p) => !p.includes("node_modules") });
@@ -129,7 +129,7 @@ it.skipIf(!Bun.which("git"))("an npm package never replaces a development checko
129
129
  it("upgrade with npm unreachable says not upgraded, never up to date", () => {
130
130
  const root = mkdtempSync(join(tmpdir(), "kz-offline-"));
131
131
  const home = join(root, "home");
132
- mkdirSync(home);
132
+ mkdirSync(join(home, ".claude"), { recursive: true }); // an agent: the installer stops on a box with none
133
133
  kaizenFiles(join(home, "kaizen"));
134
134
  const env = { PATH: process.env.PATH ?? "", HOME: home, TMPDIR: root, HTTPS_PROXY: "http://127.0.0.1:9", https_proxy: "http://127.0.0.1:9" };
135
135
  const p = Bun.spawnSync([process.execPath, script, "upgrade"], { cwd: home, env });
package/cli/notes.test.ts CHANGED
@@ -71,11 +71,11 @@ test("list: recursive, newest first, dot folders skipped", () => {
71
71
  test("list: only names the notebook can save back; a symlinked folder once, never a loop", () => {
72
72
  const n = join(state, "notes");
73
73
  saveNote(state, "ok", "x", 0);
74
- writeFileSync(join(n, "What?.md"), "x");
75
- writeFileSync(join(n, "a: b.md"), "x");
74
+ // Windows refuses these names outright, so there is nothing to list.
75
+ if (process.platform !== "win32") { writeFileSync(join(n, "What?.md"), "x"); writeFileSync(join(n, "a: b.md"), "x"); }
76
76
  mkdirSync(join(n, "1/2/3/4/5/6"), { recursive: true }); writeFileSync(join(n, "1/2/3/4/5/6/deep.md"), "x");
77
77
  mkdirSync(join(n, "trail ")); writeFileSync(join(n, "trail ", "x.md"), "x");
78
- symlinkSync("..", join(n, "up"));
78
+ symlinkSync(process.platform === "win32" ? dirname(n) : "..", join(n, "up"), "junction"); // a junction needs no admin
79
79
  expect(listNotes(state).map((x) => x.name)).toEqual(["ok"]);
80
80
  });
81
81
 
package/cli/settings.ts CHANGED
@@ -117,6 +117,7 @@ const SETTINGS: Setting[] = [
117
117
  { key: "dictation.model", label: "Dictation model", group: "Board", values: ["tiny", "base", "small", "large-v3-turbo"], help: "Whisper model the web board dictates with, in the browser. tiny ~60 MB rough; base ~165 MB English-leaning; small ~410 MB accurate across languages; large-v3-turbo ~1.5 GB best, needs a capable GPU. Downloaded once on the next mic press" },
118
118
  { key: "sources.every", label: "Gather requests", group: "Board", values: ["off", "1h", "6h", "24h"], help: "How often the board checks your request sources for new ideas while it is running. off: only when you press Gather now" },
119
119
  { key: "board.projects_dir", label: "Projects folder", group: "Board", values: [], text: true, folder: true, help: `A folder your projects live in, e.g. ${process.platform === "win32" ? join(homedir(), "Projects") : "~/Projects"}. Every folder inside it appears in the board's project list, ready to choose; choosing one sets it up. Empty: only projects kaizen already knows` },
120
+ { key: "board.open", label: "Open the board in", group: "Board", values: ["browser", "app"], help: "browser: a tab in your default browser. app: a Chrome, Edge or Brave window with no tabs or address bar, like a desktop app; your default browser when none is installed" },
120
121
  { key: "ui.mouse", label: "Enable mouse", group: "Terminal", values: ["false", "true"], help: "Click to select, click again to act. While on, the terminal cannot select text with the mouse" },
121
122
  ];
122
123
 
@@ -498,11 +498,12 @@ test("gather everything: each source's ideas land in its own project and no othe
498
498
  // The real server, in a scratch home on its own port. Nothing is fetched: only add, list and move.
499
499
  test("the board refuses to add a source without a project, and moves one between two", async () => {
500
500
  const homeDir = join(root, "home" + n), a = join(homeDir, "a", ".kaizen"), b = join(homeDir, "b", ".kaizen");
501
- for (const d of [a, b, join(homeDir, ".kaizen")]) mkdirSync(d, { recursive: true });
501
+ // ~/.claude: kaizen will not start on a machine with no agent, and a CI box has none.
502
+ for (const d of [a, b, join(homeDir, ".kaizen"), join(homeDir, ".claude")]) mkdirSync(d, { recursive: true });
502
503
  writeFileSync(join(homeDir, ".kaizen", "projects"), [join(homeDir, "a"), join(homeDir, "b")].join("\n") + "\n");
503
504
  const port = 20000 + Math.floor(Math.random() * 20000);
504
505
  const repo = join(import.meta.dir, "..");
505
- const proc = Bun.spawn([process.execPath, join(repo, "cli", "install.ts"), "web", "--port", String(port), "--no-open"], {
506
+ const proc = Bun.spawn([process.execPath, join(repo, "cli", "install.ts"), "web", "--port", String(port), "--no-open", "--foreground"], {
506
507
  cwd: join(homeDir, "a"), env: { ...process.env, HOME: homeDir, USERPROFILE: homeDir, KAIZEN_HOME: repo }, stdout: "ignore", stderr: "ignore",
507
508
  });
508
509
  try {
@@ -530,4 +531,4 @@ test("the board refuses to add a source without a project, and moves one between
530
531
  expect(await post({ op: "add", dir: b, url: SHEET })).toMatchObject({ status: 200 });
531
532
  expect(await post({ op: "move", dir: b, to: a, url: SHEET })).toMatchObject({ status: 409, error: "that project already has this link" });
532
533
  } finally { proc.kill(); await proc.exited; }
533
- });
534
+ }, 30_000); // a cold board start is slow on Windows
package/cli/state.test.ts CHANGED
@@ -399,6 +399,8 @@ test("commitPush: commits the run's files only, pushes, and records it; the mess
399
399
  expect(readCommit(st, "r1")).toEqual({ sha: r.sha, pushed: true });
400
400
  expect(cardsGit(st, ["r1", "r0", "nope"])).toEqual({ r1: "pushed", r0: "pushed" }); // r0: no record, but its file is in; c.txt still changed is not its business
401
401
 
402
+ // Windows has no file named b*.txt, so the rest is for everywhere else.
403
+ if (process.platform === "win32") return;
402
404
  // A push that fails leaves a record that says so.
403
405
  sh(proj, "remote", "set-url", "origin", join(root, "gone.git"));
404
406
  // A fix round's report names files too, and a name with a star in it is not a pattern.
@@ -415,7 +417,7 @@ test("commitPush: commits the run's files only, pushes, and records it; the mess
415
417
  expect(runGit(st, "r1")).toMatchObject({ files: [], ahead: 1, others: 2 });
416
418
  expect(cardsGit(st, ["r1"])).toEqual({ r1: "pending" }); // committed, not pushed
417
419
  expect(cardsGit(join(root, ".kaizen"), ["r1"])).toEqual({}); // not a repo
418
- });
420
+ }, 60_000); // dozens of git calls, ~250ms each on Windows
419
421
 
420
422
  test.skipIf(process.platform === "win32")("findTerminal: a running herdr wins, then tmux, else neither; the prompt stays one literal argument", () => {
421
423
  const bin = mkdtempSync(join(tmpdir(), "kz-bin-"));
@@ -427,11 +429,11 @@ test.skipIf(process.platform === "win32")("findTerminal: a running herdr wins, t
427
429
  for (const k of keys.slice(1)) delete process.env[k];
428
430
  const prompt = `/kaizen it's "q" $HOME \`id\`\n\nnote`;
429
431
  const full = ["/usr/bin/claude", "--model", "opus", prompt];
430
- expect(findTerminal("/p", full)?.cmd[0]).not.toMatch(/^(sh|tmux)$/); // neither installed
432
+ expect(findTerminal("/p", full)?.cmd[0] ?? "none").not.toMatch(/^(sh|tmux)$/); // neither installed (none: no terminal, as on CI)
431
433
  stub("herdr", 1); stub("tmux", 1);
432
- expect(findTerminal("/p", full)?.cmd[0]).not.toMatch(/^(sh|tmux)$/); // installed, no server
434
+ expect(findTerminal("/p", full)?.cmd[0] ?? "none").not.toMatch(/^(sh|tmux)$/); // installed, no server
433
435
  process.env.TMUX = "/tmp/x,1,0"; process.env.HERDR_ENV = "1";
434
- expect(findTerminal("/p", full)?.cmd[0]).not.toMatch(/^(sh|tmux)$/); // stale env from a server since stopped
436
+ expect(findTerminal("/p", full)?.cmd[0] ?? "none").not.toMatch(/^(sh|tmux)$/); // stale env from a server since stopped
435
437
  delete process.env.TMUX; delete process.env.HERDR_ENV; stub("tmux", 0);
436
438
  const t = findTerminal("/p", full)!; // outside it, server up
437
439
  expect(t.cmd.slice(0, 4)).toEqual(["tmux", "new-window", "-c", "/p"]);
@@ -456,8 +458,29 @@ test("nativePath on Windows reads the spellings Git Bash writes into the project
456
458
  for (const p of ["/c/Users/me/proj", "C:/Users/me/proj", "c:\\Users\\me\\proj\\", "C:\\Users\\me\\proj"])
457
459
  expect(nativePath(p)).toBe("C:\\Users\\me\\proj");
458
460
  expect(nativePath("/d")).toBe("D:\\");
461
+ Object.defineProperty(process, "platform", { value: "linux" });
462
+ expect(nativePath("/c/Users/me")).toBe("/c/Users/me"); // elsewhere a path is left alone
459
463
  } finally { Object.defineProperty(process, "platform", platform); }
460
- expect(nativePath("/c/Users/me")).toBe("/c/Users/me"); // elsewhere a path is left alone
464
+ });
465
+
466
+ test("findTerminal on macOS opens $TERMINAL as an app; iTerm through a script", () => {
467
+ const keys = ["PATH", "TERMINAL", "TMUX", "HERDR_ENV"];
468
+ const saved = keys.map((k) => process.env[k]);
469
+ const platform = Object.getOwnPropertyDescriptor(process, "platform")!;
470
+ try {
471
+ Object.defineProperty(process, "platform", { value: "darwin" });
472
+ process.env.PATH = mkdtempSync(join(tmpdir(), "kz-empty-"));
473
+ delete process.env.TMUX; delete process.env.HERDR_ENV;
474
+ process.env.TERMINAL = "Ghostty";
475
+ expect(findTerminal("/p", ["claude", "x"])!.cmd).toEqual(["open", "-na", "Ghostty", "--args", "--working-directory=/p", "-e", "claude", "x"]);
476
+ process.env.TERMINAL = "iTerm";
477
+ const i = findTerminal("/p", ["claude", "x"])!;
478
+ expect(i.cmd[2]).toContain('create window with default profile command');
479
+ expect(i.cmd.slice(4)).toEqual(["'/p'", "'claude' 'x'"]);
480
+ } finally {
481
+ Object.defineProperty(process, "platform", platform);
482
+ keys.forEach((k, i) => (saved[i] === undefined ? delete process.env[k] : (process.env[k] = saved[i])));
483
+ }
461
484
  });
462
485
 
463
486
  test("findTerminal on Windows names cmd and PowerShell by full path, so a PATH without System32 still launches", () => {
@@ -474,7 +497,9 @@ test("findTerminal on Windows names cmd and PowerShell by full path, so a PATH w
474
497
  expect(t.cmd[6]).toBe("D:\\Win\\System32\\WindowsPowerShell\\v1.0\\powershell.exe");
475
498
  process.env.ComSpec = "E:\\cmd.exe";
476
499
  expect(findTerminal("C:\\p", ["claude"])!.cmd[0]).toBe("E:\\cmd.exe");
477
- // A running herdr: a tab, and the run typed into it as one encoded line.
500
+ // A running herdr: a tab, and the run typed into it as one encoded line. The fake
501
+ // is a shell script, which real Windows cannot run with ComSpec pointed elsewhere.
502
+ if (platform.value === "win32") return;
478
503
  writeFileSync(join(process.env.PATH, "herdr"), "#!/bin/sh\nexit 0\n", { mode: 0o755 });
479
504
  const h = findTerminal("C:\\p", ["claude", "/kaizen it's\nnote"])!;
480
505
  expect([h.cmd[0], h.cmd[1], h.cmd[2], h.detached]).toEqual(["D:\\Win\\System32\\WindowsPowerShell\\v1.0\\powershell.exe", "-NoProfile", "-EncodedCommand", false]);
@@ -815,13 +840,14 @@ test("trustFolder: marks the repo root trusted in a temp config, keeps the rest,
815
840
  mkdirSync(sub, { recursive: true }); mkdirSync(plain);
816
841
  Bun.spawnSync(["git", "init", "-q", repo]);
817
842
  const cfg = join(t, ".claude.json");
818
- writeFileSync(cfg, JSON.stringify({ keep: 1, projects: { [plain]: { x: 2 } } }));
843
+ const key = (p: string) => process.platform === "win32" ? p.replace(/\\/g, "/") : p; // Claude Code's spelling
844
+ writeFileSync(cfg, JSON.stringify({ keep: 1, projects: { [key(plain)]: { x: 2 } } }));
819
845
  trustFolder(sub, cfg);
820
846
  trustFolder(plain, cfg);
821
847
  const d = JSON.parse(readFileSync(cfg, "utf8"));
822
848
  expect(d.keep).toBe(1);
823
- expect(d.projects[realpathSync(repo)]).toEqual({ hasTrustDialogAccepted: true });
824
- expect(d.projects[plain]).toEqual({ x: 2, hasTrustDialogAccepted: true });
849
+ expect(d.projects[key(realpathSync.native(repo))]).toEqual({ hasTrustDialogAccepted: true });
850
+ expect(d.projects[key(plain)]).toEqual({ x: 2, hasTrustDialogAccepted: true });
825
851
  writeFileSync(cfg, "{bad");
826
852
  trustFolder(plain, cfg);
827
853
  expect(readFileSync(cfg, "utf8")).toBe("{bad");
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, renameSync, copyFileSync } from "node:fs";
5
- import { join, dirname, win32 } from "node:path";
5
+ import { join, dirname, win32, posix } from "node:path";
6
6
  import { homedir, arch, cpus, release, totalmem, version as osVersion } from "node:os";
7
7
 
8
8
  // The real path: process.cwd() is always physical, so a raw $HOME that crosses a
@@ -210,13 +210,26 @@ if ($o -match '"pane_id":"([^"]*)"') { & ${q(herdr)} pane run $Matches[1] ${q(ty
210
210
  return { cmd: ["tmux", "new-window", "-c", cwd, ...fullCmd], detached: false };
211
211
  }
212
212
 
213
- if (process.env.TERMINAL && Bun.which(process.env.TERMINAL)) {
214
- const term = process.env.TERMINAL;
215
- if (term.includes("kitty")) return { cmd: [term, "--directory", cwd, ...fullCmd], detached: true };
216
- if (term.includes("alacritty")) return { cmd: [term, "--working-directory", cwd, "-e", ...fullCmd], detached: true };
217
- if (term.includes("ghostty")) return { cmd: [term, `--working-directory=${cwd}`, "-e", ...fullCmd], detached: true };
218
- if (term.includes("foot")) return { cmd: [term, "-D", cwd, ...fullCmd], detached: true };
219
- return { cmd: [term, "-e", ...fullCmd], detached: true };
213
+ // $TERMINAL: a command on PATH, or on macOS an app's name (TERMINAL=Ghostty, iTerm).
214
+ const term = process.env.TERMINAL;
215
+ if (term) {
216
+ const t = term.toLowerCase();
217
+ const args = t.includes("kitty") ? ["--directory", cwd, ...fullCmd]
218
+ : t.includes("alacritty") ? ["--working-directory", cwd, "-e", ...fullCmd]
219
+ : t.includes("ghostty") ? [`--working-directory=${cwd}`, "-e", ...fullCmd]
220
+ : t.includes("wezterm") ? ["start", "--cwd", cwd, "--", ...fullCmd]
221
+ : t.includes("foot") ? ["-D", cwd, ...fullCmd]
222
+ : ["-e", ...fullCmd];
223
+ if (process.platform !== "darwin") { if (Bun.which(term)) return { cmd: [term, ...args], detached: true }; }
224
+ else if (t.includes("iterm")) {
225
+ // iTerm takes a command line, not argv: the line goes into a one-shot script, as
226
+ // with Terminal below, and iTerm runs `sh <path>`.
227
+ const script = `f=$(mktemp) && printf 'rm -f "$0"\\n{ cd %s || (exit 127); } && %s\\n${hold}\\n' "$1" "$2" > "$f" && osascript -e "tell application \\"iTerm\\" to create window with default profile command \\"sh $f\\"" -e 'tell application "iTerm" to activate'`;
228
+ return { cmd: ["/bin/sh", "-c", script, "sh", shq(cwd), line], detached: true };
229
+ }
230
+ // On macOS a terminal is an app, started through open (Ghostty's binary cannot open
231
+ // a window itself). -n: a new instance, so the args reach it when it is already open.
232
+ else return { cmd: ["open", "-na", term, "--args", ...args], detached: true };
220
233
  }
221
234
 
222
235
  if (hasDisplay && Bun.which("xdg-terminal-exec")) {
@@ -730,11 +743,12 @@ export function appendNote(state: string, target: { id: string } | { text: strin
730
743
  // ponytail: no dedupe, no size accounting beyond the request cap in web.ts.
731
744
  export function saveAttachment(state: string, id: string | null, name: string, bytes: Uint8Array) {
732
745
  const safe = name.replace(/[^\w.-]+/g, "_").replace(/^\.+/, "") || "file";
733
- const rel = id ? join("runs", id, "attachments") : "attachments";
746
+ // posix: the note links it as attachments/..., and a backslash on Windows breaks that.
747
+ const rel = id ? posix.join("runs", id, "attachments") : "attachments";
734
748
  mkdirSync(join(state, rel), { recursive: true });
735
749
  const file = `${Date.now().toString(36)}-${safe}`;
736
750
  writeFileSync(join(state, rel, file), bytes);
737
- return join(rel, file);
751
+ return posix.join(rel, file);
738
752
  }
739
753
 
740
754
  // What a card is worth interrupting someone for, in the words both UIs send: a run
@@ -1133,7 +1147,9 @@ export function sessionLaunch(projectDir: string, cmd: string, base: string[], p
1133
1147
  export function trustFolder(dir: string, cfg = join(process.env.CLAUDE_CONFIG_DIR ?? homedir(), ".claude.json")) {
1134
1148
  try {
1135
1149
  const top = Bun.spawnSync(["git", "-C", dir, "rev-parse", "--show-toplevel"], { stderr: "ignore" });
1136
- const root = top.success ? top.stdout.toString().trim() || dir : dir;
1150
+ // Claude Code keys its projects with forward slashes on Windows, as git prints them.
1151
+ const found = top.success ? top.stdout.toString().trim() || dir : dir;
1152
+ const root = process.platform === "win32" ? found.replace(/\\/g, "/") : found;
1137
1153
  const d = JSON.parse(readFileSync(cfg, "utf8"));
1138
1154
  if (d.projects?.[root]?.hasTrustDialogAccepted) return;
1139
1155
  d.projects ??= {};
package/cli/web.ts CHANGED
@@ -676,6 +676,11 @@ export async function web(repoDir: string, opts: Opts = {}) {
676
676
  // A profile folder gives the window a browser instance of its own (Kaizen Bot's app);
677
677
  // on macOS that takes -n, as `open` otherwise hands the args to a running browser.
678
678
  export async function openApp(url: string, profile?: string) {
679
+ // Kaizen Bot's window (a profile) is always an app; the board asks the setting.
680
+ if (!profile) {
681
+ const { listSettings } = await import("./settings.ts");
682
+ if (listSettings(join(import.meta.dir, "..")).rows.find((r) => r.key === "board.open")?.value !== "app") return openDefault(url);
683
+ }
679
684
  const args = [`--app=${url}`, ...(profile ? [`--user-data-dir=${profile}`] : [])];
680
685
  const chromes = process.platform === "darwin"
681
686
  ? ["Google Chrome", "Chromium", "Microsoft Edge", "Brave Browser"]
@@ -697,6 +702,10 @@ export async function openApp(url: string, profile?: string) {
697
702
  }
698
703
  }
699
704
  } catch { /* fall through to a plain tab */ }
705
+ return openDefault(url);
706
+ }
707
+
708
+ async function openDefault(url: string) {
700
709
  // `start` is a cmd builtin, not a program, so it has to go through cmd.
701
710
  if (process.platform === "win32") {
702
711
  try { await Bun.$`${cmdExe()} /c start "" ${url}`.quiet(); return true; } catch { return false; }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "kaizen-agent",
3
- "version": "1.22.0",
3
+ "version": "1.23.0",
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": {
@@ -178,6 +178,9 @@ board:
178
178
  # not itself a project (no .git, no package.json) is read as a group, and the
179
179
  # folders inside it are offered too.
180
180
  projects_dir: ""
181
+ # Where `kaizen web` opens the board: browser (a tab in your default browser) or
182
+ # app (a Chrome, Edge or Brave window with no tabs or address bar).
183
+ open: browser
181
184
 
182
185
  sources:
183
186
  # How often the web board checks your request sources (links added in the board's