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 +1 -1
- package/cli/install.ts +24 -5
- package/cli/pty.test.ts +1 -1
- package/cli/state.test.ts +9 -1
- package/cli/state.ts +24 -7
- package/cli/web.ts +67 -6
- package/package.json +1 -1
- package/skills/kaizen/README.md +1 -1
- package/skills/kaizen/config.default.yml +3 -1
- package/skills/kaizen/spec-main.md +11 -0
- package/skills/kaizen-help/SKILL.md +1 -1
- package/web/board/cards.ts +1 -0
- package/web/board/main.ts +2 -0
- package/web/board/projects.ts +40 -1
- package/web/board/update.ts +1 -0
- package/web/board/viewer.ts +47 -0
- package/web/board.html +2 -1
- package/web/board.js +18 -18
- package/web/notebook.js +1 -1
- package/web/term.js +1 -1
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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")
|
|
1301
|
-
|
|
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) =>
|
|
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
|
-
&& (
|
|
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
package/skills/kaizen/README.md
CHANGED
|
@@ -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` |
|
|
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
|
-
#
|
|
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` |
|
|
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) |
|
package/web/board/cards.ts
CHANGED
|
@@ -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
|
|
package/web/board/projects.ts
CHANGED
|
@@ -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(
|
package/web/board/update.ts
CHANGED
|
@@ -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)}
|