kaizen-agent 1.9.4 → 1.11.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 +24 -6
- package/agents/kaizen-builder.md +4 -1
- package/cli/board.test.ts +115 -0
- package/cli/dashboard.ts +1 -0
- package/cli/install.test.ts +85 -0
- package/cli/install.ts +60 -10
- package/cli/notes.test.ts +1 -1
- package/cli/settings.test.ts +50 -0
- package/cli/settings.ts +91 -36
- package/cli/sources.test.ts +44 -5
- package/cli/sources.ts +11 -6
- package/cli/state.test.ts +326 -3
- package/cli/state.ts +361 -47
- package/cli/web.ts +134 -16
- package/package.json +1 -1
- package/skills/kaizen/SKILL.md +5 -1
- package/skills/kaizen/config.default.yml +10 -4
- package/skills/kaizen/spec-build.md +17 -0
- package/skills/kaizen/spec-plan.md +6 -1
- package/web/board.html +363 -85
- package/web/notebook.js +2 -2
- package/web/notes-lib.ts +1 -1
- package/web/notes.html +1 -0
package/cli/state.test.ts
CHANGED
|
@@ -4,11 +4,12 @@ import { test, expect, beforeAll, afterAll } from "bun:test";
|
|
|
4
4
|
import { mkdtempSync, mkdirSync, writeFileSync, readFileSync, rmSync, utimesSync, existsSync, readdirSync } from "node:fs";
|
|
5
5
|
import { join, dirname } from "node:path";
|
|
6
6
|
import { tmpdir } from "node:os";
|
|
7
|
+
import { issueUrl, systemInfo, scrubHome, REPO_URL, home as realHome } from "./state.ts";
|
|
7
8
|
import {
|
|
8
9
|
readRuns, readInbox, writeInbox, replaceIdea, abandonRun, allBacklog, backlog,
|
|
9
10
|
statusOf, columnOf, parseItem, startedRuns, boardCards, readArchive, setArchived,
|
|
10
|
-
requestOf, agentFlags, readNotes, writeNotes, manualCommand, parseNotes, formatNote, appendNote, saveAttachment,
|
|
11
|
-
reviewFindings, pickFindings, fixPrompt, gitStatus, runGit, cardsGit, commitPush, readCommit, findTerminal, nativePath, pruneRuns, unrechecked, notice, notifier, notifyArgs, LOGO, type Card,
|
|
11
|
+
requestOf, agentFlags, plainModel, readNotes, writeNotes, manualCommand, parseNotes, formatNote, appendNote, saveAttachment,
|
|
12
|
+
reviewFindings, pickFindings, fixPrompt, approvalPrompt, sessionLaunch, closeSessions, sweepSessions, readSessions, ownerOf, CLOSE_GRACE_MS, gitStatus, runGit, cardsGit, commitPush, readCommit, findTerminal, nativePath, pruneRuns, unrechecked, notice, notifier, notifyArgs, LOGO, type Card,
|
|
12
13
|
} from "./state.ts";
|
|
13
14
|
|
|
14
15
|
let state: string;
|
|
@@ -129,6 +130,22 @@ test("reviewFindings: numbered lines under Findings, closed by the backlog; the
|
|
|
129
130
|
expect(fixPrompt("x", [2, 5])).toContain("fix findings 2, 5 from");
|
|
130
131
|
});
|
|
131
132
|
|
|
133
|
+
test("approvalPrompt: approve or revise, the comment kept on one line", () => {
|
|
134
|
+
expect(approvalPrompt("r", "approvals.plan")).toBe("run r: the plan is approved from the board. Record it in 02-approval.md and build it, first asking any of its open questions still unanswered.");
|
|
135
|
+
expect(approvalPrompt("r", "approvals.review", " ")).toContain("approved from the board at the final approval");
|
|
136
|
+
const p = approvalPrompt("r", "approvals.plan", " drop step 3,\n\tkeep \"the rest\" ");
|
|
137
|
+
expect(p).toContain('to revise: "drop step 3, keep "the rest"".');
|
|
138
|
+
expect(p).toContain("revise 01-plan.md");
|
|
139
|
+
expect(p).not.toContain("\n");
|
|
140
|
+
expect(approvalPrompt("r", "approvals.review", "rename it")).toContain('final approval: "rename it". Record it in 02-approval.md, change the work');
|
|
141
|
+
});
|
|
142
|
+
|
|
143
|
+
test("approvalPrompt: a plan's answers numbered on one line", () => {
|
|
144
|
+
const p = approvalPrompt("r", "approvals.plan", undefined, ["Where? — Footer", "Chip?\n — No; keep it\tsmall"]);
|
|
145
|
+
expect(p).toBe("run r: the plan is approved from the board, with answers to its open questions: 1) Where? — Footer; 2) Chip? — No; keep it small. Record them in 02-approval.md and build the plan with them.");
|
|
146
|
+
expect(approvalPrompt("r", "approvals.plan", "redo it", ["x"])).toContain("to revise");
|
|
147
|
+
});
|
|
148
|
+
|
|
132
149
|
test("startedRuns: normalised request bodies", () => {
|
|
133
150
|
expect(startedRuns(state).sort()).toEqual(["# request add oauth login", "# request rename cli flags"]);
|
|
134
151
|
});
|
|
@@ -230,6 +247,71 @@ test("agentFlags: per-tool model and effort from the agent block, inline or nest
|
|
|
230
247
|
expect(agentFlags("/p", "codex", "agent:\n default: codex\n")).toEqual([]);
|
|
231
248
|
});
|
|
232
249
|
|
|
250
|
+
test("plainModel: a typed model reaches launch lines only as a plain name", () => {
|
|
251
|
+
for (const m of ["opus", "gpt-5.1", "gemini-2.5-pro", "anthropic/claude-sonnet-4", "o3:high", "x@y"]) expect(plainModel(m)).toBe(true);
|
|
252
|
+
for (const m of ["", "x; rm -rf ~", "$(id)", "`id`", "a b", "'q'", "\"q\"", "opus[1m]", "-rf", "x".repeat(81)]) expect(plainModel(m)).toBe(false);
|
|
253
|
+
});
|
|
254
|
+
|
|
255
|
+
test("each tool gets the opening prompt the way it takes one: agy -i, opencode --prompt, the rest bare", () => {
|
|
256
|
+
const p = mkdtempSync(join(tmpdir(), "kz-")); // no .kaizen: no session record
|
|
257
|
+
const argv = (cmd: string) => sessionLaunch(p, cmd, [cmd], "/kaizen x", "x").fullCmd;
|
|
258
|
+
expect(argv("agy")).toEqual(["agy", "-i", "/kaizen x"]);
|
|
259
|
+
expect(argv("opencode")).toEqual(["opencode", "--prompt", "/kaizen x"]);
|
|
260
|
+
expect(argv("codex")).toEqual(["codex", "/kaizen x"]);
|
|
261
|
+
expect(argv("gemini")).toEqual(["gemini", "/kaizen x"]);
|
|
262
|
+
if (process.platform !== "win32") expect(manualCommand("/p", "agy", "/kaizen x", ["--model", "m"])).toBe("cd '/p' && agy --model m -i '/kaizen x'");
|
|
263
|
+
});
|
|
264
|
+
|
|
265
|
+
test.skipIf(process.platform === "win32")("agent choice: project then global config, a note for a missing tool, a pick, and a run keeps its own", () => {
|
|
266
|
+
// The global config sits at the home bound at import, so this runs in a child with a scratch HOME.
|
|
267
|
+
const home = mkdtempSync(join(tmpdir(), "kz-home-")), bin = mkdtempSync(join(tmpdir(), "kz-bin-"));
|
|
268
|
+
for (const t of ["claude", "codex"]) writeFileSync(join(bin, t), "#!/bin/sh\nexit 0\n", { mode: 0o755 });
|
|
269
|
+
const script = `const S = await import(${JSON.stringify(join(dirname(import.meta.path), "state.ts"))});
|
|
270
|
+
const fs = require("node:fs"), { join } = require("node:path");
|
|
271
|
+
const P = join(process.env.HOME, "proj"), st = join(P, ".kaizen"), G = join(process.env.HOME, ".kaizen");
|
|
272
|
+
fs.mkdirSync(join(st, "runs", "r1"), { recursive: true }); fs.mkdirSync(G, { recursive: true });
|
|
273
|
+
fs.writeFileSync(join(G, "config.yml"), "agent:\\n default: codex\\n codex: { model: gpt-5, effort: low }\\n");
|
|
274
|
+
fs.writeFileSync(join(st, "config.yml"), "mode: approve\\n");
|
|
275
|
+
const out = { chain: [S.detectDefaultAgent(P).cmd, S.agentFlags(P, "codex")] };
|
|
276
|
+
fs.writeFileSync(join(st, "config.yml"), "agent:\\n default: claude\\n codex:\\n model: o3\\n");
|
|
277
|
+
out.project = [S.detectDefaultAgent(P).cmd, S.agentFlags(P, "codex")];
|
|
278
|
+
out.missing = S.detectDefaultAgent(P, "gemini");
|
|
279
|
+
out.pick = [S.detectDefaultAgent(P, "codex").cmd, S.agentFlags(P, "codex", undefined, "gpt-5.1")];
|
|
280
|
+
out.choices = S.agentChoices(P);
|
|
281
|
+
// No terminal on PATH or display in env: the launch fails and hands back its manual line.
|
|
282
|
+
const idea = { severity: null, where: null, text: "x", raw: "x" };
|
|
283
|
+
// macOS always drives Terminal.app, which would open a real window: Linux only.
|
|
284
|
+
if (process.platform === "linux") out.yolo = [S.launchRun(idea, P, undefined, { agent: "codex", yolo: true }), S.launchRun(idea, P, undefined, { agent: "codex" }), S.launchRun(idea, P, undefined, { agent: "gemini", yolo: true })].map((r) => [r.ok, r.agent.cmd, /--dangerously-bypass|--dangerously-skip|--yolo/.test(r.manual)]);
|
|
285
|
+
fs.writeFileSync(join(st, "runs", "r1", "state.json"), JSON.stringify({ agent: "codex" }));
|
|
286
|
+
out.fromState = S.runAgent(st, "r1");
|
|
287
|
+
S.sessionLaunch(P, "codex", ["codex"], "/kaizen x", "x", "r1", "gpt-5.1");
|
|
288
|
+
out.fromRecord = S.runAgent(st, "r1");
|
|
289
|
+
out.tags = [S.boardCards([st], Date.now()).find((c) => c.id === "r1")?.tags];
|
|
290
|
+
S.sessionLaunch(P, "codex", ["codex"], "/kaizen x", "x", "r1", undefined, true);
|
|
291
|
+
out.tags.push(S.boardCards([st], Date.now()).find((c) => c.id === "r1")?.tags);
|
|
292
|
+
console.log(JSON.stringify(out));`;
|
|
293
|
+
try {
|
|
294
|
+
const p = Bun.spawnSync(["bun", "-e", script], { cwd: home, env: { ...process.env, HOME: home, PATH: bin + ":" + dirname(process.execPath), DISPLAY: "", WAYLAND_DISPLAY: "", TERMINAL: "", TMUX: "", HERDR_ENV: "" } });
|
|
295
|
+
if (p.exitCode !== 0) throw new Error(p.stderr.toString());
|
|
296
|
+
expect(JSON.parse(p.stdout.toString())).toEqual({
|
|
297
|
+
// A project config.yml with no agent block leaves the global one in force.
|
|
298
|
+
chain: ["codex", ["-m", "gpt-5", "-c", "model_reasoning_effort=low"]],
|
|
299
|
+
project: ["claude", ["-m", "o3"]],
|
|
300
|
+
missing: { name: "Claude Code", cmd: "claude", note: "Gemini CLI is not installed; opened Claude Code instead" },
|
|
301
|
+
pick: ["codex", ["-m", "gpt-5.1"]],
|
|
302
|
+
choices: { default: "claude", agents: [
|
|
303
|
+
{ name: "Claude Code", cmd: "claude", model: "", models: true, yolo: true },
|
|
304
|
+
{ name: "Codex", cmd: "codex", model: "o3", models: true, yolo: true },
|
|
305
|
+
] },
|
|
306
|
+
// Yolo adds the tool's bypass flag only when asked for, and not to a fallback tool.
|
|
307
|
+
...(process.platform === "linux" ? { yolo: [[false, "codex", true], [false, "codex", false], [false, "claude", false]] } : {}),
|
|
308
|
+
tags: [["codex"], ["codex", "yolo"]],
|
|
309
|
+
fromState: { cmd: "codex", model: null },
|
|
310
|
+
fromRecord: { cmd: "codex", model: "gpt-5.1" },
|
|
311
|
+
});
|
|
312
|
+
} finally { rmSync(home, { recursive: true, force: true }); rmSync(bin, { recursive: true, force: true }); }
|
|
313
|
+
});
|
|
314
|
+
|
|
233
315
|
test("boardCards: a short idea is not retired by a request that merely contains the word", () => {
|
|
234
316
|
const dir = join(mkdtempSync(join(tmpdir(), "kz-")), ".kaizen");
|
|
235
317
|
mkdirSync(join(dir, "runs", "2026-09-01-a"), { recursive: true });
|
|
@@ -346,6 +428,8 @@ test.skipIf(process.platform === "win32")("findTerminal: a running herdr wins, t
|
|
|
346
428
|
stub("herdr", 0);
|
|
347
429
|
const h = findTerminal("/p", full)!;
|
|
348
430
|
expect([h.cmd[0], h.cmd[1], h.cmd[4], h.detached]).toEqual(["sh", "-c", "/p", false]);
|
|
431
|
+
expect(h.cmd[2]).toContain(`pane run "$p" "exec sh $f"`); // exec: the tab closes with the agent
|
|
432
|
+
expect(h.cmd[2]).toContain("[ $s = 127 ] || exit $s"); // ...unless it never started
|
|
349
433
|
expect(t.cmd.slice(4)).toEqual(full); // argv: tmux runs it without its default-shell
|
|
350
434
|
const back = Bun.spawnSync(["/bin/sh", "-c", `printf '%s\\n' ${h.cmd[5]}`]).stdout.toString();
|
|
351
435
|
expect(back).toBe(full.join("\n") + "\n");
|
|
@@ -388,7 +472,8 @@ test("findTerminal on Windows names cmd and PowerShell by full path, so a PATH w
|
|
|
388
472
|
expect(launch).toContain("tab create --cwd 'C:\\p' --label kaizen --focus");
|
|
389
473
|
const typed = /pane run \$Matches\[1\] '(.*)' \}/.exec(launch)![1].replace(/''/g, "'");
|
|
390
474
|
expect(typed).not.toContain("\n");
|
|
391
|
-
|
|
475
|
+
expect(typed).toEndWith("; exit"); // the tab closes with the run
|
|
476
|
+
const run = Buffer.from(typed.split(" -EncodedCommand ")[1].replace(/; exit$/, ""), "base64").toString("utf16le");
|
|
392
477
|
expect(run).toBe("Set-Location 'C:\\p'; & 'claude' '/kaizen it''s\nnote'");
|
|
393
478
|
} finally {
|
|
394
479
|
Object.defineProperty(process, "platform", platform);
|
|
@@ -475,3 +560,241 @@ test("files written by Windows PowerShell, with a byte order mark, still read: t
|
|
|
475
560
|
expect(readRuns(st).map((r) => [r.id, r.stage])).toEqual([["r1", "plan"]]);
|
|
476
561
|
expect(readInbox(st)).toEqual([{ status: "open", text: "first idea" }]);
|
|
477
562
|
});
|
|
563
|
+
|
|
564
|
+
test("remember on Windows stores one spelling per folder, the one knownProjects returns", () => {
|
|
565
|
+
// REGISTRY is bound to the home at import, so this runs in a child with a scratch HOME.
|
|
566
|
+
const home = mkdtempSync(join(tmpdir(), "kz-home-"));
|
|
567
|
+
const script = `Object.defineProperty(process, "platform", { value: "win32" });
|
|
568
|
+
const { remember, knownProjects, REGISTRY } = await import(${JSON.stringify(join(dirname(import.meta.path), "state.ts"))});
|
|
569
|
+
remember("c:\\\\p"); remember("C:\\\\p"); remember("/c/p");
|
|
570
|
+
console.log(JSON.stringify([require("node:fs").readFileSync(REGISTRY, "utf8"), knownProjects()]));`;
|
|
571
|
+
try {
|
|
572
|
+
const p = Bun.spawnSync(["bun", "-e", script], { cwd: home, env: { ...process.env, HOME: home, USERPROFILE: home } });
|
|
573
|
+
if (p.exitCode !== 0) throw new Error(p.stderr.toString());
|
|
574
|
+
expect(JSON.parse(p.stdout.toString())).toEqual(["C:\\p\n", ["C:\\p"]]);
|
|
575
|
+
} finally { rmSync(home, { recursive: true, force: true }); }
|
|
576
|
+
});
|
|
577
|
+
|
|
578
|
+
test("files saved with CRLF (PowerShell, autocrlf clones) read the same as LF", () => {
|
|
579
|
+
const lf = join(mkdtempSync(join(tmpdir(), "kz-")), ".kaizen"), crlf = join(mkdtempSync(join(tmpdir(), "kz-")), ".kaizen");
|
|
580
|
+
const files = {
|
|
581
|
+
"config.yml": "agent:\n default: claude\n claude: { model: opus, effort: high }\n",
|
|
582
|
+
"inbox.md": "# Inbox\n\n- open: add retry\n- rejected: drop cache\n",
|
|
583
|
+
"runs/2026-10-01-a/state.json": JSON.stringify({ stage: "build", status: "running" }, null, 2) + "\n",
|
|
584
|
+
"runs/2026-10-01-a/00-request.md": "# Request\n\n> /kaizen add retry\n",
|
|
585
|
+
};
|
|
586
|
+
for (const [dir, eol] of [[lf, "\n"], [crlf, "\r\n"]] as const)
|
|
587
|
+
for (const [f, text] of Object.entries(files)) {
|
|
588
|
+
mkdirSync(dirname(join(dir, f)), { recursive: true });
|
|
589
|
+
writeFileSync(join(dir, f), text.replace(/\n/g, eol));
|
|
590
|
+
}
|
|
591
|
+
expect(agentFlags(dirname(crlf), "claude")).toEqual(["--model", "opus", "--effort", "high"]);
|
|
592
|
+
expect(readInbox(crlf)).toEqual(readInbox(lf));
|
|
593
|
+
const runs = (s: string) => readRuns(s).map(({ moved, ...r }) => ({ ...r, dir: undefined }));
|
|
594
|
+
expect(runs(crlf)).toEqual(runs(lf));
|
|
595
|
+
});
|
|
596
|
+
|
|
597
|
+
// A project whose agent sessions are recorded: close on, one run asked for "tidy logs".
|
|
598
|
+
const sessionProject = () => {
|
|
599
|
+
const p = mkdtempSync(join(tmpdir(), "kz-sess-"));
|
|
600
|
+
mkdirSync(join(p, ".kaizen", "runs"), { recursive: true });
|
|
601
|
+
writeFileSync(join(p, ".kaizen", "config.yml"), "agent:\n default: claude\n close: on\n");
|
|
602
|
+
return p;
|
|
603
|
+
};
|
|
604
|
+
const newRun = (p: string, id: string, request: string, s: object = { stage: "plan", awaiting: "approvals.plan" }) => {
|
|
605
|
+
mkdirSync(join(p, ".kaizen", "runs", id), { recursive: true });
|
|
606
|
+
writeFileSync(join(p, ".kaizen", "runs", id, "00-request.md"), `# Request (verbatim)\n\n${request}\n`);
|
|
607
|
+
writeFileSync(join(p, ".kaizen", "runs", id, "state.json"), JSON.stringify(s));
|
|
608
|
+
};
|
|
609
|
+
|
|
610
|
+
test("sessionLaunch: claude gets a conversation id, a decision resumes it while its transcript exists, codex starts fresh", () => {
|
|
611
|
+
const p = sessionProject(), st = join(p, ".kaizen");
|
|
612
|
+
const saved = process.env.CLAUDE_CONFIG_DIR;
|
|
613
|
+
process.env.CLAUDE_CONFIG_DIR = mkdtempSync(join(tmpdir(), "kz-claude-"));
|
|
614
|
+
try {
|
|
615
|
+
const idea = sessionLaunch(p, "claude", ["/bin/claude", "--model", "opus"], "/kaizen tidy logs", "tidy logs");
|
|
616
|
+
const [rec] = readSessions(st);
|
|
617
|
+
expect(rec.sessionId).toMatch(/^[0-9a-f-]{36}$/);
|
|
618
|
+
expect(idea.fullCmd).toEqual(["/bin/claude", "--model", "opus", "--session-id", rec.sessionId!, "/kaizen tidy logs"]);
|
|
619
|
+
expect(idea.pidFile).toBe(join(st, "sessions", rec.key + ".pid"));
|
|
620
|
+
newRun(p, "2026-10-01-tidy-logs", "/kaizen tidy logs");
|
|
621
|
+
expect(ownerOf(st, rec)).toBe("2026-10-01-tidy-logs");
|
|
622
|
+
|
|
623
|
+
// No transcript on disk: a fresh session, with an id of its own.
|
|
624
|
+
const fresh = sessionLaunch(p, "claude", ["/bin/claude"], "/kaizen run x: approved", "run x: approved", "2026-10-01-tidy-logs");
|
|
625
|
+
expect(fresh.fullCmd[1]).toBe("--session-id");
|
|
626
|
+
expect(fresh.fullCmd[2]).not.toBe(rec.sessionId);
|
|
627
|
+
|
|
628
|
+
const t = join(process.env.CLAUDE_CONFIG_DIR, "projects", p.replace(/[^A-Za-z0-9]/g, "-"));
|
|
629
|
+
mkdirSync(t, { recursive: true });
|
|
630
|
+
writeFileSync(join(t, `${rec.sessionId}.jsonl`), "{}\n");
|
|
631
|
+
const back = sessionLaunch(p, "claude", ["/bin/claude"], "/kaizen run x: approved", "run x: approved", "2026-10-01-tidy-logs");
|
|
632
|
+
expect(back.fullCmd).toEqual(["/bin/claude", "--resume", rec.sessionId!, "/kaizen run x: approved"]);
|
|
633
|
+
expect(readSessions(st).filter((r) => r.run === "2026-10-01-tidy-logs" && r.sessionId === rec.sessionId)).toHaveLength(1);
|
|
634
|
+
|
|
635
|
+
// Another tool's record never resumes; codex takes no id at all.
|
|
636
|
+
expect(sessionLaunch(p, "codex", ["/bin/codex"], "/kaizen run x: approved", "x", "2026-10-01-tidy-logs").fullCmd).toEqual(["/bin/codex", "/kaizen run x: approved"]);
|
|
637
|
+
} finally {
|
|
638
|
+
saved === undefined ? delete process.env.CLAUDE_CONFIG_DIR : (process.env.CLAUDE_CONFIG_DIR = saved);
|
|
639
|
+
}
|
|
640
|
+
// A folder with no state dir gets no record, and no .kaizen made for one.
|
|
641
|
+
const bare = mkdtempSync(join(tmpdir(), "kz-bare-"));
|
|
642
|
+
expect(sessionLaunch(bare, "claude", ["claude"], "/kaizen x", "x")).toMatchObject({ fullCmd: ["claude", "/kaizen x"], pidFile: undefined });
|
|
643
|
+
expect(existsSync(join(bare, ".kaizen"))).toBe(false);
|
|
644
|
+
});
|
|
645
|
+
|
|
646
|
+
test("ownerOf: an idea's session belongs to the first run created after it, not an older one with the same words", () => {
|
|
647
|
+
const p = sessionProject(), st = join(p, ".kaizen");
|
|
648
|
+
newRun(p, "2026-09-01-old", "/kaizen fix the login page");
|
|
649
|
+
const rec = { key: "k", run: null, text: "fix the login page", cmd: "claude", sessionId: null, started: Date.now() + 5000 };
|
|
650
|
+
expect(ownerOf(st, rec)).toBe(null);
|
|
651
|
+
expect(ownerOf(st, { ...rec, started: 0 })).toBe("2026-09-01-old");
|
|
652
|
+
expect(ownerOf(st, { ...rec, run: "2026-10-01-x" })).toBe("2026-10-01-x");
|
|
653
|
+
});
|
|
654
|
+
|
|
655
|
+
test("findTerminal records the agent's PID: a POSIX wrapper that execs it, a PowerShell prefix on Windows", () => {
|
|
656
|
+
const keys = ["PATH", "HERDR_ENV", "TMUX", "TERMINAL", "SystemRoot", "windir", "ComSpec"];
|
|
657
|
+
const saved = keys.map((k) => process.env[k]);
|
|
658
|
+
const platform = Object.getOwnPropertyDescriptor(process, "platform")!;
|
|
659
|
+
const bin = mkdtempSync(join(tmpdir(), "kz-bin-"));
|
|
660
|
+
try {
|
|
661
|
+
process.env.PATH = bin;
|
|
662
|
+
for (const k of keys.slice(1)) delete process.env[k];
|
|
663
|
+
writeFileSync(join(bin, "tmux"), "#!/bin/sh\nexit 0\n", { mode: 0o755 });
|
|
664
|
+
if (process.platform !== "win32") {
|
|
665
|
+
const t = findTerminal("/p", ["claude", "/kaizen x"], "/s/k.pid")!;
|
|
666
|
+
expect(t.cmd.slice(4)).toEqual(["sh", "-c", 'echo $$ > "$0"; exec "$@"', "/s/k.pid", "claude", "/kaizen x"]);
|
|
667
|
+
}
|
|
668
|
+
Object.defineProperty(process, "platform", { value: "win32" });
|
|
669
|
+
process.env.SystemRoot = "D:\\Win";
|
|
670
|
+
const w = findTerminal("C:\\p", ["claude", "/kaizen x"], "C:\\p\\.kaizen\\sessions\\k.pid")!;
|
|
671
|
+
const ps = Buffer.from(w.cmd.at(-1)!, "base64").toString("utf16le");
|
|
672
|
+
expect(ps).toBe("$PID | Set-Content -LiteralPath 'C:\\p\\.kaizen\\sessions\\k.pid'; Set-Location 'C:\\p'; & 'claude' '/kaizen x'");
|
|
673
|
+
} finally {
|
|
674
|
+
Object.defineProperty(process, "platform", platform);
|
|
675
|
+
keys.forEach((k, i) => (saved[i] === undefined ? delete process.env[k] : (process.env[k] = saved[i])));
|
|
676
|
+
rmSync(bin, { recursive: true, force: true });
|
|
677
|
+
}
|
|
678
|
+
});
|
|
679
|
+
|
|
680
|
+
test.skipIf(process.platform === "win32")("closeSessions ends only the run's own live sessions launched before the decision", async () => {
|
|
681
|
+
const p = sessionProject(), st = join(p, ".kaizen"), dir = join(st, "sessions");
|
|
682
|
+
newRun(p, "2026-10-01-a", "/kaizen a");
|
|
683
|
+
mkdirSync(dir, { recursive: true });
|
|
684
|
+
// A stand-in agent through the real wrapper: `kzagent <id>` in its arguments, as
|
|
685
|
+
// `claude --session-id <id>` would be. `; :` keeps sh from exec'ing sleep itself.
|
|
686
|
+
const start = (key: string, rec: object) => {
|
|
687
|
+
writeFileSync(join(dir, key + ".json"), JSON.stringify({ run: "2026-10-01-a", text: "a", cmd: "kzagent", sessionId: "sid-" + key, started: Date.now() - 5000, ...rec }));
|
|
688
|
+
return Bun.spawn(["sh", "-c", 'echo $$ > "$0"; exec "$@"', join(dir, key + ".pid"), "sh", "-c", "sleep 30; :", "kzagent", "sid-" + key], { stdout: "ignore", stderr: "ignore" });
|
|
689
|
+
};
|
|
690
|
+
// Killed by a signal, a process keeps exitCode null; signalCode says it died.
|
|
691
|
+
const alive = (pr: ReturnType<typeof Bun.spawn>) => pr.exitCode === null && pr.signalCode === null;
|
|
692
|
+
const procs: Record<string, ReturnType<typeof Bun.spawn>> = {
|
|
693
|
+
mine: start("mine", {}),
|
|
694
|
+
later: start("later", { started: Date.now() + 60_000 }), // the decision's own successor
|
|
695
|
+
stranger: start("stranger", { cmd: "notkzagent" }), // a PID that is not this agent
|
|
696
|
+
other: start("other", { run: "2026-10-01-b" }), // another run's
|
|
697
|
+
};
|
|
698
|
+
for (let i = 0; i < 100 && Object.keys(procs).some((k) => !existsSync(join(dir, k + ".pid"))); i++) await Bun.sleep(20);
|
|
699
|
+
writeFileSync(join(dir, "dead.json"), JSON.stringify({ run: "2026-10-01-a", text: "a", cmd: "kzagent", sessionId: null, started: 0 }));
|
|
700
|
+
writeFileSync(join(dir, "dead.pid"), "999999\n");
|
|
701
|
+
try {
|
|
702
|
+
expect(closeSessions(st, "2026-10-01-a", { before: Date.now() })).toBe(1);
|
|
703
|
+
expect(await procs.mine.exited).not.toBe(0);
|
|
704
|
+
for (const k of ["later", "stranger", "other"] as const) expect(alive(procs[k])).toBe(true);
|
|
705
|
+
expect(existsSync(join(dir, "mine.pid"))).toBe(false);
|
|
706
|
+
expect(existsSync(join(dir, "mine.json"))).toBe(true); // its conversation id is still needed
|
|
707
|
+
expect(existsSync(join(dir, "dead.pid"))).toBe(false);
|
|
708
|
+
expect(existsSync(join(dir, "later.pid"))).toBe(true);
|
|
709
|
+
|
|
710
|
+
// Off: nothing is ended.
|
|
711
|
+
writeFileSync(join(st, "config.yml"), "agent:\n default: claude\n close: off\n");
|
|
712
|
+
expect(closeSessions(st, "2026-10-01-b")).toBe(0);
|
|
713
|
+
expect(alive(procs.other)).toBe(true);
|
|
714
|
+
writeFileSync(join(st, "config.yml"), "agent:\n default: claude\n close: on\n");
|
|
715
|
+
|
|
716
|
+
// The sweep: run b done within the grace keeps its window, past it loses it.
|
|
717
|
+
newRun(p, "2026-10-01-b", "/kaizen b", { stage: "done", awaiting: null });
|
|
718
|
+
sweepSessions(st, Date.now());
|
|
719
|
+
await Bun.sleep(100);
|
|
720
|
+
expect(alive(procs.other)).toBe(true);
|
|
721
|
+
sweepSessions(st, Date.now() + CLOSE_GRACE_MS);
|
|
722
|
+
expect(await procs.other.exited).not.toBe(0);
|
|
723
|
+
expect(alive(procs.later)).toBe(true); // run a is still waiting
|
|
724
|
+
|
|
725
|
+
// A fix launched on a done run, after it last moved, is doing the work: kept.
|
|
726
|
+
const fix = start("fix", { run: "2026-10-01-b", started: Date.now() + 1000 });
|
|
727
|
+
procs.fix = fix;
|
|
728
|
+
for (let i = 0; i < 100 && !existsSync(join(dir, "fix.pid")); i++) await Bun.sleep(20);
|
|
729
|
+
sweepSessions(st, Date.now() + 2 * CLOSE_GRACE_MS);
|
|
730
|
+
await Bun.sleep(100);
|
|
731
|
+
expect(alive(fix)).toBe(true);
|
|
732
|
+
|
|
733
|
+
// An abandoned run is left to abandonRun; the sweep does not close it, even a
|
|
734
|
+
// session launched long before the run last moved.
|
|
735
|
+
const gone = start("gone", { started: 0 });
|
|
736
|
+
procs.gone = gone;
|
|
737
|
+
for (let i = 0; i < 100 && !existsSync(join(dir, "gone.pid")); i++) await Bun.sleep(20);
|
|
738
|
+
writeFileSync(join(st, "runs", "2026-10-01-a", "state.json"), JSON.stringify({ stage: "abandoned", awaiting: null }));
|
|
739
|
+
sweepSessions(st, Date.now() + 2 * CLOSE_GRACE_MS);
|
|
740
|
+
await Bun.sleep(100);
|
|
741
|
+
expect(alive(gone)).toBe(true);
|
|
742
|
+
} finally {
|
|
743
|
+
for (const pr of Object.values(procs)) pr.kill();
|
|
744
|
+
}
|
|
745
|
+
});
|
|
746
|
+
|
|
747
|
+
test("abandonRun closes the run's recorded sessions", () => {
|
|
748
|
+
const p = sessionProject(), st = join(p, ".kaizen");
|
|
749
|
+
newRun(p, "2026-10-01-z", "/kaizen z");
|
|
750
|
+
mkdirSync(join(st, "sessions"));
|
|
751
|
+
writeFileSync(join(st, "sessions", "k.json"), JSON.stringify({ run: "2026-10-01-z", text: "z", cmd: "kzagent", sessionId: null, started: 0 }));
|
|
752
|
+
writeFileSync(join(st, "sessions", "k.pid"), "999999\n");
|
|
753
|
+
expect(abandonRun(st, "2026-10-01-z", "no")).toBe(null);
|
|
754
|
+
expect(existsSync(join(st, "sessions", "k.pid"))).toBe(false);
|
|
755
|
+
});
|
|
756
|
+
|
|
757
|
+
// The folder dialog cannot be opened in a test, but its script can be checked without
|
|
758
|
+
// showing it: compiled on a Mac, parsed on Windows. Elsewhere only the shape is pinned.
|
|
759
|
+
test("folder dialog: each platform's script is well formed", async () => {
|
|
760
|
+
const { folderDialog } = await import("./web.ts");
|
|
761
|
+
const mac = folderDialog("darwin")!, win = folderDialog("win32")!;
|
|
762
|
+
expect(mac.slice(0, 3)).toEqual(["osascript", "-e", "activate"]);
|
|
763
|
+
expect(win.at(-1)).toContain("FolderBrowserDialog");
|
|
764
|
+
if (process.platform === "darwin") {
|
|
765
|
+
const out = join(mkdtempSync(join(tmpdir(), "kaizen-osa-")), "x.scpt");
|
|
766
|
+
expect(Bun.spawnSync(["osacompile", ...mac.slice(1), "-o", out]).exitCode).toBe(0);
|
|
767
|
+
}
|
|
768
|
+
if (process.platform === "win32") {
|
|
769
|
+
const check = `$e=$null;[void][System.Management.Automation.Language.Parser]::ParseInput($env:KZ_PS,[ref]$null,[ref]$e);exit $e.Count`;
|
|
770
|
+
expect(Bun.spawnSync([win[0], "-NoProfile", "-Command", check], { env: { ...process.env, KZ_PS: win.at(-1)! } }).exitCode).toBe(0);
|
|
771
|
+
}
|
|
772
|
+
});
|
|
773
|
+
|
|
774
|
+
test("issueUrl prefills the bug form's fields, without the home path", () => {
|
|
775
|
+
const u = new URL(issueUrl({ title: "boom", what: `ENOENT ${realHome}/Projects/x/.kaizen/state.json` }));
|
|
776
|
+
expect(`${u.origin}${u.pathname}`).toBe(`${REPO_URL}/issues/new`);
|
|
777
|
+
expect(u.searchParams.get("what")).toBe("ENOENT ~/Projects/x/.kaizen/state.json");
|
|
778
|
+
expect(u.searchParams.get("env")).toBe(systemInfo());
|
|
779
|
+
// Every key but GitHub's own must be a field id in the form, or it is dropped silently.
|
|
780
|
+
const ids = [...readFileSync(join(import.meta.dir, "..", ".github", "ISSUE_TEMPLATE", "bug.yml"), "utf8").matchAll(/^\s+id: (\S+)/gm)].map((m) => m[1]);
|
|
781
|
+
for (const k of u.searchParams.keys()) if (!["template", "title", "body"].includes(k)) expect(ids).toContain(k);
|
|
782
|
+
// Without the form on GitHub only `body` is read, so it must hold the machine too.
|
|
783
|
+
expect(u.searchParams.get("body")).toContain(systemInfo());
|
|
784
|
+
expect(u.searchParams.get("body")).toContain("ENOENT ~/Projects/x/.kaizen/state.json");
|
|
785
|
+
expect(systemInfo()).toMatch(/^kaizen: .+\nOS: .+\nCPU: .+\nRAM: \d+ GB\nbun: /);
|
|
786
|
+
});
|
|
787
|
+
|
|
788
|
+
test("issueUrl stays under GitHub's URL limit for a huge error", () => {
|
|
789
|
+
const u = issueUrl({ title: "x".repeat(500), what: "é".repeat(20000) });
|
|
790
|
+
expect(u.length).toBeLessThan(8000);
|
|
791
|
+
expect(new URL(u).searchParams.get("what")).toContain("…(cut)");
|
|
792
|
+
});
|
|
793
|
+
|
|
794
|
+
test("scrubHome hides a Windows home however the error spells it", () => {
|
|
795
|
+
const h = ["C:\\Users\\Firman"];
|
|
796
|
+
expect(scrubHome(`open C:\\Users\\Firman\\x, "c:\\\\users\\\\firman\\\\y", C:/Users/Firman/z`, h, true))
|
|
797
|
+
.toBe(`open ~\\x, "~\\\\y", ~/z`);
|
|
798
|
+
// Case matters off Windows: /home/Ann is not /home/ann.
|
|
799
|
+
expect(scrubHome("/home/ann/a /home/Ann/b", ["/home/ann"], false)).toBe("~/a /home/Ann/b");
|
|
800
|
+
});
|