kaizen-agent 1.41.3 → 1.42.1

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/conpty.ts CHANGED
@@ -95,5 +95,5 @@ export const resizeSeq = (cols: number, rows: number) => `\x1b]kzr;${cols};${row
95
95
  export function innerCommand(shell: string, cmd: string[]): string {
96
96
  const q = (a: string) => `'${a.replace(/'/g, "''")}'`;
97
97
  const enc = Buffer.from(`& ${cmd.map(q).join(" ")}`, "utf16le").toString("base64");
98
- return `"${shell}" -NoProfile -EncodedCommand ${enc}`;
98
+ return `"${shell}" -NoProfile -ExecutionPolicy Bypass -EncodedCommand ${enc}`;
99
99
  }
package/cli/install.ts CHANGED
@@ -540,6 +540,15 @@ if (interactive && !upgrade && Bun.argv.length === 2) {
540
540
  }
541
541
  }
542
542
 
543
+ // A board on Windows hands its Update here and exits: Windows will not rename
544
+ // ~/kaizen while that board holds files in it. Wait for it to go, and bring a board
545
+ // back on its port however the upgrade ends (finish()).
546
+ const argAt = (flag: string) => { const i = Bun.argv.indexOf(flag); return i === -1 ? NaN : Number(Bun.argv[i + 1]); };
547
+ if (upgrade && argAt("--wait") > 0) {
548
+ for (let i = 0; i < 100; i++) {
549
+ try { process.kill(argAt("--wait"), 0); await Bun.sleep(200); } catch { break; }
550
+ }
551
+ }
543
552
  if (upgrade) await clearBunxCache();
544
553
 
545
554
  // Upgrade means the newest release on npm, whatever the install on disk is: a clone
@@ -551,16 +560,16 @@ if (upgrade) {
551
560
  const why = await devCheckout(dest);
552
561
  if (why) {
553
562
  console.log(`\n ${c.bold("Not upgraded")} ${c.dim(`${tilde(dest)} is ${why}; update it with git`)}\n`);
554
- process.exit(1);
563
+ finish(1);
555
564
  }
556
565
  const latest = await npmLatest();
557
566
  if (!latest) {
558
567
  console.log(`\n ${c.bold("Not upgraded")} ${c.dim(`could not reach npm (kaizen ${now})`)}\n`);
559
- process.exit(1);
568
+ finish(1);
560
569
  }
561
570
  if (latest === now) {
562
571
  console.log(`\n ${c.bold("Up to date")}${c.dim(` — kaizen ${now}`)}\n`);
563
- process.exit(0);
572
+ finish(0);
564
573
  }
565
574
  step(`fetching kaizen ${latest} from npm`);
566
575
  // --force so a stale package manifest cannot resolve the version away, and the
@@ -578,10 +587,10 @@ if (upgrade) {
578
587
  console.log(`\n ${c.bold(`could not install kaizen ${latest}`)}`);
579
588
  // npm names a new version before it serves it (its review takes minutes).
580
589
  console.log(` ${c.dim("npm may still be publishing it: try again in a few minutes, or run")} ${c.cyan(`bunx kaizen-agent@${latest}`)}\n`);
581
- process.exit(1);
590
+ finish(1);
582
591
  }
583
592
  console.log(`\n ${c.bold("Upgraded")} ${c.dim(now)} → ${c.green(after)}\n ${c.dim("Restart Claude Code to pick up any new slash commands.")}\n`);
584
- process.exit(0);
593
+ finish(0);
585
594
  }
586
595
  if (!check && !upgrade) await welcome();
587
596
 
@@ -1065,6 +1074,16 @@ if (blocked) process.exit(1);
1065
1074
 
1066
1075
  // ---------------------------------------------------------------- helpers
1067
1076
 
1077
+ function finish(code: number): never {
1078
+ const port = argAt("--restart");
1079
+ if (port > 0) {
1080
+ Bun.spawn([process.execPath, join(installDir(), "cli", "install.ts"), "web", "--port", String(port), "--no-open", "--foreground"], {
1081
+ cwd: home, detached: true, stdio: ["ignore", "ignore", "ignore"], windowsHide: true,
1082
+ }).unref();
1083
+ }
1084
+ process.exit(code);
1085
+ }
1086
+
1068
1087
  function step(msg: string) { console.log(` ${c.green("+")} ${msg}`); }
1069
1088
 
1070
1089
  // Runs the upgrade and reports what it actually did, version to version, so the
package/cli/pty.test.ts CHANGED
@@ -200,7 +200,7 @@ test("Windows ConPTY host: resize travels in-band, the agent command survives qu
200
200
  expect(resizeSeq(100, 40)).toBe("\x1b]kzr;100;40\x07");
201
201
  const line = innerCommand("C:\\ps.exe", ["claude", "it's\nhere"]);
202
202
  const enc = line.split(" ").pop()!;
203
- expect(line.startsWith('"C:\\ps.exe" -NoProfile -EncodedCommand ')).toBe(true);
203
+ expect(line.startsWith('"C:\\ps.exe" -NoProfile -ExecutionPolicy Bypass -EncodedCommand ')).toBe(true);
204
204
  expect(Buffer.from(enc, "base64").toString("utf16le")).toBe("& 'claude' 'it''s\nhere'");
205
205
  // The host script decodes to PowerShell carrying the C# whole.
206
206
  expect(Buffer.from(conptyHost(), "base64").toString("utf16le")).toContain("CreatePseudoConsole");
package/cli/state.test.ts CHANGED
@@ -796,7 +796,7 @@ test("findTerminal records the agent's PID: a POSIX wrapper that execs it, a Pow
796
796
  process.env.SystemRoot = "D:\\Win";
797
797
  const w = findTerminal("C:\\p", ["claude", "/kaizen x"], "C:\\p\\.kaizen\\sessions\\k.pid")!;
798
798
  const ps = Buffer.from(w.cmd.at(-1)!, "base64").toString("utf16le");
799
- expect(ps).toBe("$PID | Set-Content -LiteralPath 'C:\\p\\.kaizen\\sessions\\k.pid'; Set-Location 'C:\\p'; & 'claude' '/kaizen x'");
799
+ expect(ps).toBe("$PID | Set-Content -LiteralPath 'C:\\p\\.kaizen\\sessions\\k.pid'; Set-Location 'C:\\p'; & 'claude' '/kaizen x'; if (-not (Test-Path -LiteralPath 'C:\\p\\.kaizen\\sessions\\k.pid')) { exit 0 }");
800
800
  } finally {
801
801
  Object.defineProperty(process, "platform", platform);
802
802
  keys.forEach((k, i) => (saved[i] === undefined ? delete process.env[k] : (process.env[k] = saved[i])));
@@ -1148,3 +1148,11 @@ test("parent/child: a Parent line links a run and an idea to the run that filed
1148
1148
  expect(boardCards([st], Date.now()).find((k) => k.text === "next thing")!.parent).toBe("2026-01-02-b");
1149
1149
  rmSync(dirname(st), { recursive: true, force: true });
1150
1150
  });
1151
+
1152
+ test("winShim swaps an npm .cmd shim for the .ps1 beside it, so a multi-line prompt survives", () => {
1153
+ const { winShim } = stateModule;
1154
+ const has = (p: string) => p === "C:\\npm\\claude.ps1";
1155
+ expect(winShim("C:\\npm\\claude.cmd", has)).toBe("C:\\npm\\claude.ps1");
1156
+ expect(winShim("C:\\npm\\codex.CMD", () => false)).toBe("C:\\npm\\codex.CMD");
1157
+ expect(winShim("C:\\bin\\claude.exe", () => true)).toBe("C:\\bin\\claude.exe");
1158
+ });
package/cli/state.ts CHANGED
@@ -130,6 +130,14 @@ export const readText = (file: string) => readFileSync(file, "utf8").replace(/^\
130
130
 
131
131
  const onPath = (bin: string) => Bun.which(bin, { PATH: process.env.PATH });
132
132
 
133
+ // npm installs a Windows tool as x.cmd with x.ps1 beside it. PowerShell hands a .cmd to
134
+ // cmd.exe, which ends the command line at the first newline -- a prompt with notes lost
135
+ // everything after its first line. The .ps1 runs in PowerShell itself, argument intact.
136
+ export const winShim = (bin: string, exists = existsSync) => {
137
+ const ps1 = bin.replace(/\.cmd$/i, ".ps1");
138
+ return ps1 !== bin && exists(ps1) ? ps1 : bin;
139
+ };
140
+
133
141
  // Windows' own programs by full path: a board started from the shortcut or a
134
142
  // stripped-down environment can have a PATH without System32, and a bare "cmd.exe"
135
143
  // then fails with "Executable not found in $PATH".
@@ -164,7 +172,10 @@ export function findTerminal(cwd: string, fullCmd: string[], pidFile?: string):
164
172
  const q = (a: string) => `'${a.replace(/'/g, "''")}'`;
165
173
  const shell = powershellExe();
166
174
  // This PowerShell's PID: ending its tree closes the -NoExit tab or window.
167
- const ps = `${pidFile ? `$PID | Set-Content -LiteralPath ${q(pidFile)}; ` : ""}Set-Location ${q(cwd)}; & ${fullCmd.map(q).join(" ")}`;
175
+ // The pid file gone after the agent means closeSessions ended it: exit 0, which closes
176
+ // the tab (Windows Terminal keeps one open after a non-zero exit). An agent that ends
177
+ // by itself leaves the window open, -NoExit, so what it printed can still be read.
178
+ const ps = `${pidFile ? `$PID | Set-Content -LiteralPath ${q(pidFile)}; ` : ""}Set-Location ${q(cwd)}; & ${fullCmd.map(q).join(" ")}${pidFile ? `; if (-not (Test-Path -LiteralPath ${q(pidFile)})) { exit 0 }` : ""}`;
168
179
  const enc = Buffer.from(ps, "utf16le").toString("base64");
169
180
  // herdr (beta on Windows) first, as elsewhere: a new tab, and the run typed into
170
181
  // its PowerShell. Typed text ends at a newline, so what is typed is one line, the
@@ -173,19 +184,19 @@ export function findTerminal(cwd: string, fullCmd: string[], pidFile?: string):
173
184
  // closes the tab once the run's PowerShell ends.
174
185
  const herdr = onPath("herdr");
175
186
  if (herdr && answers([herdr, "workspace", "list"])) {
176
- const typed = `& ${q(shell)} -NoProfile -EncodedCommand ${enc}; exit`;
187
+ const typed = `& ${q(shell)} -NoProfile -ExecutionPolicy Bypass -EncodedCommand ${enc}; exit`;
177
188
  const launch = `$o = & ${q(herdr)} tab create --cwd ${q(cwd)} --label kaizen --focus | Out-String
178
189
  if ($o -match '"pane_id":"([^"]*)"') { & ${q(herdr)} pane run $Matches[1] ${q(typed)} } else { exit 1 }`;
179
190
  return { cmd: [shell, "-NoProfile", "-EncodedCommand", Buffer.from(launch, "utf16le").toString("base64")], detached: false };
180
191
  }
181
192
  const wt = onPath("wt.exe");
182
193
  if (wt) {
183
- return { cmd: [wt, "-d", cwd, shell, "-NoExit", "-EncodedCommand", enc], detached: true };
194
+ return { cmd: [wt, "-d", cwd, shell, "-NoExit", "-ExecutionPolicy", "Bypass", "-EncodedCommand", enc], detached: true };
184
195
  }
185
196
  // `start` reads a title only when quoted; an unquoted word is the command, so
186
197
  // "kaizen" here ran kaizen's own launcher. The empty title is what Bun quotes.
187
198
  return {
188
- cmd: [cmdExe(), "/c", "start", "", "/D", cwd, shell, "-NoExit", "-EncodedCommand", enc],
199
+ cmd: [cmdExe(), "/c", "start", "", "/D", cwd, shell, "-NoExit", "-ExecutionPolicy", "Bypass", "-EncodedCommand", enc],
189
200
  detached: true,
190
201
  };
191
202
  }
@@ -1297,8 +1308,14 @@ function closeOne(st: string, s: Session): boolean {
1297
1308
  try {
1298
1309
  const pid = parseInt(readText(pidFile));
1299
1310
  if (pid > 0 && isSession(pid, s)) {
1300
- if (process.platform === "win32") Bun.spawnSync([sysExe("taskkill.exe"), "/T", "/F", "/PID", String(pid)], { stdin: "ignore", stdout: "ignore", stderr: "ignore", timeout: 5000, windowsHide: true });
1301
- else process.kill(pid, "SIGTERM");
1311
+ if (process.platform === "win32") {
1312
+ // The pid file first, then only the PowerShell's children: the PowerShell sees the
1313
+ // file gone and exits 0 by itself, closing its tab. Killed, it exits 1 and the tab stays.
1314
+ rmSync(pidFile, { force: true });
1315
+ const kids = Bun.spawnSync([powershellExe(), "-NoProfile", "-NonInteractive", "-Command", `(Get-CimInstance Win32_Process -Filter 'ParentProcessId=${pid}').ProcessId`], { stdin: "ignore", stderr: "ignore", timeout: 10000, windowsHide: true }).stdout.toString().match(/\d+/g) ?? [];
1316
+ // No children (a launch from before this): the PowerShell itself, as before.
1317
+ for (const k of kids.length ? kids : [String(pid)]) Bun.spawnSync([sysExe("taskkill.exe"), "/T", "/F", "/PID", k], { stdin: "ignore", stdout: "ignore", stderr: "ignore", timeout: 5000, windowsHide: true });
1318
+ } else process.kill(pid, "SIGTERM");
1302
1319
  closed = true;
1303
1320
  }
1304
1321
  } catch { /* already gone */ }
@@ -1444,7 +1461,7 @@ export function launchRun(it: Item, from: string, run?: string, pick: { agent?:
1444
1461
  // agent inheriting it runs as its child and keeps no transcript, so nothing to resume.
1445
1462
  for (const k of Object.keys(process.env)) if (CHILD_ENV.test(k)) delete process.env[k];
1446
1463
  // onPath, not Bun.which: the board may have taken a login shell's PATH after it started.
1447
- const agentBin = onPath(agent.cmd) ?? agent.cmd;
1464
+ const agentBin = winShim(onPath(agent.cmd) ?? agent.cmd);
1448
1465
  // No text: a run's conversation reopened as it was, with nothing sent to it.
1449
1466
  const prompt = !it.text ? "" : requestOf(it.where ? `/kaizen ${it.text} (${it.where})` : `/kaizen ${it.text}`, it.notes, from);
1450
1467
  const wanted = pick.model ?? (own?.cmd === agent.cmd ? own.model : null) ?? undefined;
package/cli/web.ts CHANGED
@@ -1,7 +1,7 @@
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, readdirSync, statSync, watch, appendFileSync, mkdirSync, type FSWatcher } from "node:fs";
4
- import { join, dirname, resolve, sep } from "node:path";
3
+ import { existsSync, readdirSync, statSync, watch, appendFileSync, mkdirSync, cpSync, rmSync, type FSWatcher } from "node:fs";
4
+ import { join, dirname, resolve, sep, basename } 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, moveIdea, abandonRun, launchRun, parseItem, agentChoices, projectOf, KNOWN_AGENTS, plainModel, short, setArchived, writeNotes,
@@ -127,6 +127,13 @@ export async function web(repoDir: string, opts: Opts = {}) {
127
127
  // .kaizen is not a sign of one: a group folder that was chosen once has it, and
128
128
  // must keep showing what is inside.
129
129
  const isProject = (d: string) => [".git", "package.json"].some((m) => existsSync(join(d, m)));
130
+ // A group holds projects: no project marker of its own, and folders inside. An empty
131
+ // folder is a project not started yet (New project… makes one), never a group.
132
+ const isGroup = (d: string) => {
133
+ if (isProject(d)) return false;
134
+ try { return readdirSync(d, { withFileTypes: true }).some((e) => e.isDirectory() && !e.name.startsWith(".") && e.name !== "node_modules"); }
135
+ catch { return false; }
136
+ };
130
137
  // Folders under the "Projects folder" setting that have no .kaizen yet, offered in
131
138
  // the project list so one can be chosen without a terminal. A folder that does not
132
139
  // look like a project itself (no .git, no package.json) is taken for a group of
@@ -141,7 +148,7 @@ export async function web(repoDir: string, opts: Opts = {}) {
141
148
  } catch { return []; }
142
149
  };
143
150
  // One already set up but never opened joins the known projects instead.
144
- return dirs(root).flatMap((d) => isProject(d) ? [d] : dirs(d))
151
+ return dirs(root).flatMap((d) => isGroup(d) ? dirs(d) : [d])
145
152
  .filter((d) => existsSync(join(d, ".kaizen")) ? (remember(d), false) : true).sort();
146
153
  };
147
154
 
@@ -425,7 +432,7 @@ export async function web(repoDir: string, opts: Opts = {}) {
425
432
  const root = await projectsRoot();
426
433
  // Windows paths compare without case: c:\users\me\projects holds C:\Users\me\Projects\app.
427
434
  // A group folder is what fresh() takes for one, a folder right inside the projects
428
- // folder with no .git or package.json; with an empty .kaizen it was chosen once and
435
+ // folder with no .git or package.json and folders inside; with an empty .kaizen it was chosen once and
429
436
  // is hidden. Anything else is a project, a writing one included, even before its
430
437
  // first run. A drive or filesystem root already ends in a separator.
431
438
  const fold = (p: string) => process.platform === "win32" ? p.toLowerCase() : p;
@@ -434,7 +441,7 @@ export async function web(repoDir: string, opts: Opts = {}) {
434
441
  const picker = states(true).filter((d) => {
435
442
  const p = dirname(d);
436
443
  return !root || d === state || (fold(p).startsWith(inside)
437
- && (isProject(p) || filled(d) || fold(dirname(p)) !== fold(root)));
444
+ && (!isGroup(p) || filled(d) || fold(dirname(p)) !== fold(root)));
438
445
  });
439
446
  const cards = boardCards(dirs, Date.now());
440
447
  // The git mark, for the cards whose panel offers commit and push: one status per project.
@@ -550,7 +557,7 @@ export async function web(repoDir: string, opts: Opts = {}) {
550
557
  const v = await npmLatest(url.searchParams.has("fresh"));
551
558
  const update = !!v && newer(v, VERSION);
552
559
  const notes = update ? await releaseNotes(VERSION, url.searchParams.has("fresh")) : [];
553
- return json({ version: VERSION, latest: v, update, notes });
560
+ return json({ version: VERSION, latest: v, update, notes, failed: upgradeFailed });
554
561
  }
555
562
 
556
563
  // `kaizen upgrade`, run here with no window, then a new board on this port: it
@@ -560,6 +567,21 @@ export async function web(repoDir: string, opts: Opts = {}) {
560
567
  if (req.method === "POST" && path === "/upgrade") {
561
568
  if (!local(req)) return bad("cross-origin write refused", 403);
562
569
  if (upgrading) return bad("an upgrade is already running", 409);
570
+ // Windows will not rename ~/kaizen while this board holds files in it, so the
571
+ // upgrade waits in its own process for this one to exit, then starts the board
572
+ // again; ~/.kaizen/upgrade.log keeps what it said. It runs from a copy of cli/,
573
+ // so nothing runs out of ~/kaizen while it is renamed.
574
+ if (process.platform === "win32") {
575
+ const helper = join(home, ".kaizen", "upgrader");
576
+ rmSync(helper, { recursive: true, force: true });
577
+ cpSync(join(repoDir, "cli"), join(helper, "cli"), { recursive: true });
578
+ const log = Bun.file(UPGRADE_LOG);
579
+ Bun.spawn([process.execPath, join(helper, "cli", "install.ts"), "upgrade", "--wait", String(process.pid), "--restart", String(srv.port)], {
580
+ cwd: home, detached: true, stdio: ["ignore", log, log], windowsHide: true,
581
+ }).unref();
582
+ setTimeout(() => process.exit(0), 300);
583
+ return json({ ok: true });
584
+ }
563
585
  upgrading = true;
564
586
  try {
565
587
  const p = Bun.spawn([process.execPath, join(repoDir, "cli", "install.ts"), "upgrade"], { stdin: "ignore", stdout: "pipe", stderr: "pipe", windowsHide: true });
@@ -688,6 +710,33 @@ export async function web(repoDir: string, opts: Opts = {}) {
688
710
  return json({ ok: true, added: knownProjects().length - before });
689
711
  }
690
712
 
713
+ // A new, empty project: a folder in the projects folder (else ~/Projects), or
714
+ // where the page names, set up at once. /find only sees folders that already
715
+ // have a .kaizen, so a folder made by hand never shows there.
716
+ if (path === "/new-project") {
717
+ const name = typeof body.name === "string" ? body.name.trim() : "";
718
+ // Windows' rules on every platform, so a project made here also opens there:
719
+ // no reserved device names (CON, NUL, COM1…), no trailing dot or space.
720
+ const winBad = (n: string) => !n || /[\\/:*?"<>|\x00-\x1f]/.test(n) || /[. ]$/.test(n)
721
+ || /^(con|prn|aux|nul|com[1-9]|lpt[1-9])(\..*)?$/i.test(n);
722
+ const t = typeof body.to === "string" ? body.to.trim() : "";
723
+ const to = t ? resolve(home, nativePath(t.startsWith("~") ? home + t.slice(1) : t))
724
+ : join((await projectsRoot()) || join(home, "Projects"), name);
725
+ // The page asks while the name is typed: "con" on the way to "config" is no error yet.
726
+ if (body.check) return json({ ok: true, to });
727
+ if (winBad(name)) return bad("name the project: a folder name, without / \\ : * ? \" < > |, not ending in a dot or space, and not CON, NUL, COM1 or the like");
728
+ if (winBad(basename(to))) return bad(`${basename(to)} cannot be a folder name on Windows`);
729
+ let used = false;
730
+ try { used = readdirSync(to).length > 0; } catch (e) { used = existsSync(to); }
731
+ if (used) return bad(`${to} already exists and is not empty`, 409);
732
+ // A drive that is not there, a read-only folder, a path too long: said in a toast, not a crash.
733
+ try { mkdirSync(join(to, ".kaizen"), { recursive: true }); }
734
+ catch (e) { return bad(`could not create ${to}: ${(e as Error).message}`); }
735
+ remember(to);
736
+ changed();
737
+ return json({ ok: true, dir: join(to, ".kaizen") });
738
+ }
739
+
691
740
  // Clone a GitHub repository into the projects folder (else ~/Projects) and
692
741
  // set it up as a project. Arguments go to git as an array: no shell.
693
742
  if (path === "/clone") {
@@ -1124,6 +1173,18 @@ async function openDefault(url: string) {
1124
1173
  let picking = false;
1125
1174
  // One `kaizen upgrade` at a time, from any page.
1126
1175
  let upgrading = false;
1176
+ // What a Windows hand-off upgrade said (see POST /upgrade). Read once at start: no
1177
+ // "Upgraded" or "Up to date" line means it failed, and its last lines say why.
1178
+ const UPGRADE_LOG = join(home, ".kaizen", "upgrade.log");
1179
+ const upgradeFailed = (() => {
1180
+ if (!existsSync(UPGRADE_LOG)) return null;
1181
+ const lines = readText(UPGRADE_LOG).replace(/\x1b\[[0-9;]*m/g, "").split("\n").map((l) => l.trim()).filter(Boolean);
1182
+ try { rmSync(UPGRADE_LOG, { force: true }); } catch { /* shown again next start */ }
1183
+ if (lines.some((l) => /^(Upgraded|Up to date)\b/.test(l))) return null;
1184
+ // "kept ~/kaizen (in use …)" is the reason when there is one; else the closing lines.
1185
+ const kept = lines.find((l) => /^\+?\s*kept\b/.test(l));
1186
+ return kept?.replace(/^\+\s*/, "") ?? (lines.filter((l) => !l.startsWith("+")).slice(-2).join(" — ") || "kaizen upgrade said nothing");
1187
+ })();
1127
1188
 
1128
1189
  // The system folder picker for this platform, printing the chosen path on stdout.
1129
1190
  // macOS and Windows always have one; on Linux it is whichever desktop tool exists.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "kaizen-agent",
3
- "version": "1.41.3",
3
+ "version": "1.42.1",
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": {
@@ -206,7 +206,7 @@ the defaults.
206
206
  | `auto_fix.min_severity` | `high` | Lower to `medium` or `low` to have more auto-fixed; raise findings you see instead of auto-fixing |
207
207
  | `auto_fix.max_iterations` | `2` | Fix/recheck rounds before whatever is left is escalated to you |
208
208
  | `review.memory_max_lines` | `100` | The cap on `memory.md`, which every stage of every run reads. Raise only if the lessons are genuinely all load-bearing |
209
- | `git.auto_commit` | `false` | Leave it. Kaizen does not commit or push unless you ask |
209
+ | `git.auto_commit` | `false` | `true` commits the files a run changed when it is done, by path. Never pushes |
210
210
  | `track.default` | `auto` (inferred) | A repo that is always one kind of work, e.g. a docs repo |
211
211
  | `backlog.enabled` | `true` | Set `false` to skip both backlog capture points; the run still executes, just without a `06-backlog.md` |
212
212
  | `backlog.file` | `06-backlog.md` | The per-run file name. Rarely. |
@@ -125,7 +125,9 @@ state:
125
125
  keep_runs: 20
126
126
 
127
127
  git:
128
- # Never commit, push, or open a PR unless the user explicitly asks.
128
+ # true: when a run reaches done (approved at review, or an auto run finishing),
129
+ # commit the files that run changed, by path, with its ## Commit message. Never
130
+ # pushes. false: never commit, push, or open a PR unless the user explicitly asks.
129
131
  # Ignored when the project is not a git repository.
130
132
  auto_commit: false
131
133
  # Create a branch before the implement stage when on the default branch.
@@ -131,6 +131,17 @@ change is that it is written down rather than spoken and discarded.
131
131
 
132
132
  Set `stage: "done"` in `state.json` once the user accepts.
133
133
 
134
+ **Commit at done, when `git.auto_commit` is `true`.** Setting `stage: "done"` — the
135
+ user approving at review, or an `auto` run finishing — is then the ask to commit, under
136
+ either runner. Commit only the files this run changed (the paths `03-impl.md` names,
137
+ that `git status` shows changed), by path: `git add -A -- <paths>` then
138
+ `git commit -m <message> -- <paths>`, never the whole tree, since other sessions edit
139
+ the same checkout. The message is `03-impl.md`'s `## Commit`. In a run with a
140
+ `worktree`, commit there. Then write `runs/<id>/07-commit.md` as `commit: <short sha>`
141
+ and `pushed: no`, so the board shows it committed and offers the push. Never push. A
142
+ failed commit is reported as printed; the run is still `done`. With the setting
143
+ `false`, nothing is committed unless the user asks.
144
+
134
145
  ---
135
146
 
136
147
  ## Child cards
@@ -95,7 +95,7 @@ Each iteration is written to `runs/<id>/05-iterations/`.
95
95
  | `backlog.orphan_file` | `backlog.md` | Where a pruned run's open items are rescued to |
96
96
  | `state.dir` | `.kaizen` | State directory |
97
97
  | `state.keep_runs` | `20` | Completed runs kept before pruning oldest (rescues open backlog first) |
98
- | `git.auto_commit` | `false` | Never commit/push/PR unless the user explicitly asks |
98
+ | `git.auto_commit` | `false` | `true`: commit the run's changed files when it is done; never pushes |
99
99
  | `git.branch_before_implement` | `true` | Branch before implement stage when on the default branch |
100
100
  | `subagents.model` | `inherit` | Model the stage subagents run on (opus/sonnet/haiku, Claude Code only) |
101
101
  | `subagents.effort` | `inherit` | Reasoning effort for the stage subagents (low/medium/high, Claude Code only) |
@@ -467,6 +467,7 @@ export function render() {
467
467
  ["All projects", "all"],
468
468
  ...store.S.projects.map((p) => [p.label, p.dir]),
469
469
  ...store.S.fresh.map((p) => [p.label, p.dir, 1]),
470
+ ["New project…", "__new"],
470
471
  ["Find projects on this PC…", "__find"],
471
472
  ["Clone from GitHub…", "__clone"],
472
473
  ];
package/web/board/main.ts CHANGED
@@ -12,6 +12,7 @@ import { initKeys } from "./keys.ts";
12
12
  import { filterBox, statusSheet } from "./cards.ts";
13
13
  import { initLive } from "./live.ts";
14
14
  import { store } from "./store.ts";
15
+ import { initViewer } from "./viewer.ts";
15
16
 
16
17
  initPanel();
17
18
  initViews();
@@ -19,6 +20,7 @@ initProjects();
19
20
  initUpdate();
20
21
  initKeys();
21
22
  initLive();
23
+ initViewer();
22
24
  filterBox();
23
25
  statusSheet();
24
26
 
@@ -35,7 +35,7 @@ export function list() {
35
35
  store.PL.map((p, i) =>
36
36
  p[0].toLowerCase().includes(q)
37
37
  ? (p[2] && !g++ ? `<div class="grp">In your projects folder</div>` : "") +
38
- `<button role="option" data-i="${i}" title="${esc(p[0])}">${p[1] === "all" ? `<span class="plogo" aria-hidden="true">${ico("board")}</span><span>${esc(p[0])}</span>` : p[1] === "__find" || p[1] === "__clone" ? `<span class="plogo" aria-hidden="true">${ico("plus")}</span><span>${esc(p[0])}</span>` : prow(p[1], p[0])}</button>`
38
+ `<button role="option" data-i="${i}" title="${esc(p[0])}">${p[1] === "all" ? `<span class="plogo" aria-hidden="true">${ico("board")}</span><span>${esc(p[0])}</span>` : p[1] === "__new" || p[1] === "__find" || p[1] === "__clone" ? `<span class="plogo" aria-hidden="true">${ico("plus")}</span><span>${esc(p[0])}</span>` : prow(p[1], p[0])}</button>`
39
39
  : "",
40
40
  ).join("") || `<div class="grp">No match</div>`;
41
41
  const f = $("projs").querySelector("button");
@@ -55,6 +55,45 @@ async function pick(v) {
55
55
  toast("Searching…");
56
56
  const j = await post("/find", {});
57
57
  if (j.ok) toast(j.added ? `Found ${j.added} new project${j.added === 1 ? "" : "s"}` : "No new projects found");
58
+ } else if (v === "__new") {
59
+ // A name, and where it goes: the projects folder unless typed or picked.
60
+ const dl = modal(
61
+ `<b>New project</b><input name="name" placeholder="Project name" aria-label="Project name" spellcheck="false" autocomplete="off"><div class="row"><input name="to" placeholder="Create in (a new folder is made)" aria-label="Create in" spellcheck="false" autocomplete="off"><button type="button" class="btn ghost" data-a="pick">Pick…</button></div><div class="row"><button type="button" class="btn ghost" data-a="x">Cancel</button><button class="btn pri">Create</button></div>`,
62
+ );
63
+ const f = dl.querySelector("form"),
64
+ name = f.querySelector("[name=name]"),
65
+ to = f.querySelector("[name=to]");
66
+ let auto = ""; // the folder this form filled in; one the user typed is left alone
67
+ name.oninput = async () => {
68
+ const n = name.value.trim();
69
+ if (!n || /[\\/:*?"<>|]/.test(n) || (to.value.trim() && to.value !== auto)) return; // a bad name is reported once, at Create
70
+ const d = await post("/new-project", { name: n, check: true });
71
+ if (d.ok && name.value.trim() === n) to.value = auto = d.to;
72
+ };
73
+ f.querySelector("[data-a=x]").onclick = () => dl.close();
74
+ f.querySelector("[data-a=pick]").onclick = async () => {
75
+ const p = await post("/pick-folder", {});
76
+ if (p.path)
77
+ to.value = p.path.replace(/[\\/]+$/, "") + (p.path.includes("\\") ? "\\" : "/") + (name.value.trim() || "");
78
+ };
79
+ name.focus();
80
+ const dir = await new Promise((done) => {
81
+ dl.addEventListener("close", () => done(null));
82
+ f.onsubmit = async (e) => {
83
+ e.preventDefault();
84
+ if (!name.value.trim()) return name.focus();
85
+ const j = await post("/new-project", { name: name.value.trim(), to: to.value.trim() || undefined });
86
+ if (!j.ok) return;
87
+ done(j.dir);
88
+ dl.close();
89
+ };
90
+ });
91
+ if (!dir) return;
92
+ await post("/cd", { dir });
93
+ store.all = false;
94
+ store.dir = dir;
95
+ keep(dir);
96
+ toast("Project created");
58
97
  } else if (v === "__clone") {
59
98
  // One form: the link, then where it goes. The folder fills in once the link names a repo.
60
99
  const dl = modal(
@@ -30,6 +30,7 @@ export function updBar() {
30
30
  : "";
31
31
  d.innerHTML = `<span>kaizen <b>${esc(upd.latest)}</b> is available. You are running ${esc(upd.version)}.</span><span class="updact">${news}<button class="btn" id="upd-chk">Check for updates</button><button class="btn pri" id="upd-go">Update kaizen</button></span>`;
32
32
  }
33
+ if (ver && ver.failed) d.insertAdjacentHTML("afterbegin", `<span class="updfail" role="alert">Last update failed: ${esc(ver.failed)}</span>`);
33
34
  $("pbody").prepend(d);
34
35
  const check = $("upd-chk");
35
36
  check.onclick = async () => {
@@ -0,0 +1,47 @@
1
+ // Files the board links to (attachments, Preview screenshots, icebox images) open in a
2
+ // popup over the board rather than a new tab. Ctrl/Cmd/Shift or a middle click still
3
+ // gets the browser's own tab; external links are not files and are left alone.
4
+ const IMG = /\.(png|jpe?g|gif|webp|svg|bmp|avif|ico)$/i;
5
+
6
+ export function initViewer() {
7
+ const dlg = document.createElement("dialog");
8
+ dlg.className = "kz viewer";
9
+ document.body.append(dlg);
10
+ // A click on the backdrop lands on the dialog itself, not its contents.
11
+ dlg.addEventListener("click", (e) => e.target === dlg && dlg.close());
12
+ dlg.addEventListener("close", () => dlg.replaceChildren());
13
+
14
+ document.addEventListener("click", (e) => {
15
+ if (e.defaultPrevented || e.button !== 0 || e.ctrlKey || e.metaKey || e.shiftKey || e.altKey) return;
16
+ const a = (e.target as HTMLElement).closest?.("a[href]") as HTMLAnchorElement | null;
17
+ const href = a?.getAttribute("href") ?? "";
18
+ if (!/^\/(file|ice-image)\?/.test(href)) return;
19
+ e.preventDefault();
20
+ const name = new URLSearchParams(href.split("?")[1]).get("p")?.split("/").pop() ?? "Image";
21
+ const head = document.createElement("div");
22
+ head.className = "viewer-hd";
23
+ const title = document.createElement("span");
24
+ title.textContent = name;
25
+ const tab = document.createElement("a");
26
+ tab.href = href;
27
+ tab.target = "_blank";
28
+ tab.rel = "noopener";
29
+ tab.textContent = "Open in new tab";
30
+ const x = document.createElement("button");
31
+ x.className = "btn sm";
32
+ x.textContent = "Close";
33
+ x.onclick = () => dlg.close();
34
+ head.append(title, tab, x);
35
+ const body =
36
+ href.startsWith("/ice-image") || IMG.test(name)
37
+ ? document.createElement("img")
38
+ : document.createElement("iframe");
39
+ body.src = href;
40
+ if (body instanceof HTMLImageElement) {
41
+ body.alt = name;
42
+ body.referrerPolicy = "no-referrer";
43
+ }
44
+ dlg.replaceChildren(head, body);
45
+ dlg.showModal();
46
+ });
47
+ }
package/web/board.html CHANGED
@@ -219,7 +219,7 @@ section li.hint{display:block;padding:14px;border:1px dashed var(--line);border-
219
219
  .inl input::placeholder,.inl textarea::placeholder,.notes textarea::placeholder{color:var(--dim)}
220
220
  .inl .row,.notes .row{display:flex;gap:6px;justify-content:flex-end;align-items:center}
221
221
  .inl .row .mic{margin-right:0}.inl .row #newpick{margin-right:auto}.inl .row .btn{white-space:nowrap}.inl .att{font-size:12px;color:var(--dim)}.inl .att{font-size:12px;color:var(--dim)}.inl .att:empty{display:none}
222
- dialog.kz{border:0;padding:0;background:none;width:min(480px,calc(100vw - 32px));max-height:calc(100dvh - 32px);overflow:visible;color:var(--ink)}dialog.kz::backdrop{background:rgb(0 0 0/.45)}dialog.kz .row input{flex:1;min-width:0}dialog.kz #ndc{align-self:flex-start}
222
+ dialog.kz{border:0;padding:0;background:none;width:min(480px,calc(100vw - 32px));max-height:calc(100dvh - 32px);overflow:visible;color:var(--ink)}dialog.kz::backdrop{background:rgb(0 0 0/.45)}dialog.kz .row input{flex:1;min-width:0}dialog.kz #ndc{align-self:flex-start}dialog.viewer{width:min(1100px,calc(100vw - 32px));height:calc(100dvh - 32px);background:var(--bg);border-radius:var(--r);display:none;flex-direction:column;overflow:hidden}dialog.viewer[open]{display:flex}.viewer-hd{display:flex;gap:12px;align-items:center;padding:8px 12px;border-bottom:1px solid var(--line)}.viewer-hd span{flex:1;min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;font-weight:600}dialog.viewer img{flex:1;min-height:0;width:100%;object-fit:contain}dialog.viewer iframe{flex:1;border:0;width:100%;background:#fff}
223
223
  .inl .run-actions{display:grid;gap:8px}.inl .run-actions .row{display:grid;grid-template-columns:repeat(2,minmax(0,1fr))}.inl .run-actions .pri{grid-column:1/-1}
224
224
  .notes{display:flex;flex-direction:column;gap:8px}
225
225
  #pbody img{max-width:100%;border-radius:var(--r-s)}
@@ -338,6 +338,7 @@ aside .foot .more .menu-pop{left:0;right:auto;top:auto;bottom:calc(100% + 4px)}.
338
338
  .updnew:hover .updpop,.updnew:focus-within .updpop{display:block}
339
339
  .updpop ul{margin:0 0 8px;padding-left:18px}.updpop li{margin:2px 0}.updpop a{color:var(--accent)}
340
340
  .updbar.quiet{border:0;background:none;padding:0;margin-bottom:32px;color:var(--dim)}#psub:empty{display:none}
341
+ .updfail{flex-basis:100%;color:var(--warn)}
341
342
  .toast.on.rep{pointer-events:auto}.toast a{color:inherit;margin-left:8px;text-decoration:underline;text-underline-offset:2px}
342
343
  .help{position:fixed;inset:0;z-index:12;display:none;align-items:center;justify-content:center;background:color-mix(in srgb,var(--bg) 60%,transparent);backdrop-filter:blur(3px)}
343
344
  .help.on{display:flex}.help div{background:var(--bg-2);border:1px solid var(--line);border-radius:var(--r);padding:24px 28px;box-shadow:var(--shadow-2)}