kaizen-agent 1.42.2 → 1.43.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.
Files changed (47) hide show
  1. package/agents/kaizen-reviewer.md +1 -1
  2. package/cli/backlog.test.ts +133 -0
  3. package/cli/board.test.ts +10 -4
  4. package/cli/browser.test.ts +1 -1
  5. package/cli/browser.ts +12 -7
  6. package/cli/html.test.ts +19 -0
  7. package/cli/notes.ts +2 -6
  8. package/cli/pty.ts +2 -1
  9. package/cli/settings.test.ts +38 -0
  10. package/cli/settings.ts +37 -30
  11. package/cli/state.test.ts +86 -3
  12. package/cli/state.ts +245 -45
  13. package/cli/web.ts +67 -16
  14. package/package.json +1 -1
  15. package/skills/kaizen/README.md +0 -4
  16. package/skills/kaizen/SKILL.md +4 -1
  17. package/skills/kaizen/config.default.yml +1 -50
  18. package/skills/kaizen/docs.md +3 -2
  19. package/skills/kaizen/settings.md +10 -3
  20. package/skills/kaizen/spec-build.md +3 -2
  21. package/skills/kaizen/spec-main.md +40 -5
  22. package/skills/kaizen/spec-review.md +2 -1
  23. package/skills/kaizen-help/SKILL.md +0 -4
  24. package/web/board/agent-term.ts +1 -1
  25. package/web/board/api.ts +2 -0
  26. package/web/board/approvals.ts +3 -3
  27. package/web/board/cards.ts +284 -108
  28. package/web/board/chat.ts +4 -4
  29. package/web/board/dom.ts +17 -5
  30. package/web/board/fixes.ts +6 -8
  31. package/web/board/git.ts +3 -3
  32. package/web/board/icebox.ts +52 -70
  33. package/web/board/keys.ts +9 -6
  34. package/web/board/live.ts +3 -2
  35. package/web/board/main.ts +1 -0
  36. package/web/board/panel.ts +22 -16
  37. package/web/board/projects.ts +11 -16
  38. package/web/board/settings.ts +60 -29
  39. package/web/board/store.ts +2 -0
  40. package/web/board/terms.ts +279 -0
  41. package/web/board/update.ts +7 -7
  42. package/web/board/views.ts +19 -5
  43. package/web/board.html +40 -2
  44. package/web/board.js +25 -24
  45. package/web/notebook.js +1 -1
  46. package/web/notes.html +2 -0
  47. package/web/term.js +1 -1
@@ -142,7 +142,7 @@ open human check never reads as a clean pass.
142
142
 
143
143
  ## Memory
144
144
 
145
- When memory writing is enabled, append to `.kaizen/memory.md` any durable lesson
145
+ When `review.write_memory` is true (the default), append to `.kaizen/memory.md` any durable lesson
146
146
  about this codebase: a recurring bug pattern, a non-obvious constraint, a convention
147
147
  worth preserving. One line each. Nothing run-specific, nothing already obvious from
148
148
  reading the code.
@@ -0,0 +1,133 @@
1
+ // Backlog items as cards in the board's Ideas column: numbering, hiding while a run
2
+ // started from one lives, closing in place, and the board routes that drive them.
3
+ import { test, expect } from "bun:test";
4
+ import { mkdtempSync, mkdirSync, writeFileSync, readFileSync, statSync } from "node:fs";
5
+ import { join, dirname } from "node:path";
6
+ import { tmpdir } from "node:os";
7
+ import { backlogLines, backlogCards, startedBacklog, closeBacklog, markBacklogDone, backlogItem } from "./state.ts";
8
+ import { boardIndex } from "./notes.ts";
9
+
10
+ const scratch = () => {
11
+ const state = join(mkdtempSync(join(tmpdir(), "kz-bl-")), ".kaizen");
12
+ const run = (id: string, stage: string, files: Record<string, string> = {}) => {
13
+ const dir = join(state, "runs", id);
14
+ mkdirSync(dir, { recursive: true });
15
+ writeFileSync(join(dir, "state.json"), JSON.stringify({ id, stage, awaiting: null }));
16
+ for (const [f, t] of Object.entries(files)) writeFileSync(join(dir, f), t);
17
+ return dir;
18
+ };
19
+ return { state, run };
20
+ };
21
+
22
+ const BACKLOG = "# Backlog\n\n- open: `cli/x.ts:12`: low: first thing. Fix: do it.\n- done: closed one | run y\n- open: high: urgent thing\n a note under it\n- rejected: no | why\n- open: plain item\n";
23
+
24
+ test("backlogLines numbers every open, done and rejected line as boardIndex does", () => {
25
+ const { state, run } = scratch();
26
+ run("2026-10-01-src", "done", { "06-backlog.md": BACKLOG });
27
+ const lines = backlogLines(join(state, "runs", "2026-10-01-src", "06-backlog.md"));
28
+ expect(lines.map((l) => [l.n, l.status])).toEqual([[1, "open"], [2, "done"], [3, "open"], [4, "rejected"], [5, "open"]]);
29
+ const idx = boardIndex(state).runs.find((r) => r.id === "2026-10-01-src")!.backlog;
30
+ expect(idx).toEqual(lines.map(({ n, status, text }) => ({ n, status, text })));
31
+ expect(backlogLines(join(state, "nope.md"))).toEqual([]);
32
+ });
33
+
34
+ test("backlogCards: open items of live runs and the orphanage, most severe first; abandoned runs skipped", () => {
35
+ const { state, run } = scratch();
36
+ run("2026-10-01-src", "done", { "06-backlog.md": BACKLOG });
37
+ run("2026-10-02-gone", "abandoned", { "06-backlog.md": "- open: critical: from an abandoned run\n" });
38
+ writeFileSync(join(state, "backlog.md"), "- open: rescued item | from 2026-09-01-old\n");
39
+ const cards = backlogCards([state]);
40
+ expect(cards.map((c) => [c.source, c.n, c.severity])).toEqual([
41
+ ["2026-10-01-src", 3, "high"], ["2026-10-01-src", 1, "low"], ["2026-10-01-src", 5, null], ["orphan", 1, null],
42
+ ]);
43
+ expect(cards[1]).toMatchObject({ text: "first thing.", at: "x.ts:12", raw: "`cli/x.ts:12`: low: first thing. Fix: do it." });
44
+ });
45
+
46
+ test("a run started from an item hides its card; an abandoned one gives it back", () => {
47
+ const { state, run } = scratch();
48
+ run("2026-10-01-src", "done", { "06-backlog.md": BACKLOG });
49
+ const kid = run("2026-10-03-kid", "plan", { "00-request.md": "/kaizen urgent thing\n\nParent: 2026-10-01-src\nBacklog: 2026-10-01-src#3\n" });
50
+ expect(startedBacklog(state).get("2026-10-01-src#3")).toEqual([{ id: "2026-10-03-kid", stage: "plan" }]);
51
+ expect(backlogCards([state]).some((c) => c.n === 3)).toBe(false);
52
+ writeFileSync(join(kid, "state.json"), JSON.stringify({ stage: "abandoned" }));
53
+ expect(backlogCards([state]).some((c) => c.n === 3)).toBe(true);
54
+ });
55
+
56
+ test("markBacklogDone closes only the line its done run names, once, and touches no state.json", () => {
57
+ const { state, run } = scratch();
58
+ const src = run("2026-10-01-src", "done", { "06-backlog.md": BACKLOG });
59
+ run("2026-10-03-kid", "build", { "00-request.md": "x\n\nBacklog: 2026-10-01-src#3\n" });
60
+ run("2026-10-04-orph", "done", { "00-request.md": "y\n\nBacklog: orphan#1\n" });
61
+ writeFileSync(join(state, "backlog.md"), "- open: rescued item | from 2026-09-01-old\n");
62
+ markBacklogDone(state);
63
+ expect(readFileSync(join(src, "06-backlog.md"), "utf8")).toBe(BACKLOG); // still building
64
+ expect(readFileSync(join(state, "backlog.md"), "utf8")).toBe("- open: rescued item | from 2026-09-01-old | 2026-10-04-orph\n".replace("open", "done"));
65
+ const kidState = join(state, "runs", "2026-10-03-kid", "state.json");
66
+ writeFileSync(kidState, JSON.stringify({ stage: "done" }));
67
+ const before = statSync(kidState).mtimeMs;
68
+ markBacklogDone(state);
69
+ markBacklogDone(state);
70
+ expect(readFileSync(join(src, "06-backlog.md"), "utf8")).toBe(BACKLOG.replace("- open: high: urgent thing", "- done: high: urgent thing | 2026-10-03-kid"));
71
+ expect(statSync(kidState).mtimeMs).toBe(before);
72
+ expect(backlogCards([state]).map((c) => c.n)).toEqual([1, 5]);
73
+ });
74
+
75
+ test("closeBacklog rewrites one open line in place and keeps CRLF; refuses closed or missing lines", () => {
76
+ const { state, run } = scratch();
77
+ const src = run("2026-10-01-src", "done", { "06-backlog.md": BACKLOG.replace(/\n/g, "\r\n") });
78
+ expect(closeBacklog(state, "2026-10-01-src", 5, "rejected", "not worth it")).toBeNull();
79
+ expect(readFileSync(join(src, "06-backlog.md"), "utf8")).toBe(BACKLOG.replace("- open: plain item", "- rejected: plain item | not worth it").replace(/\n/g, "\r\n"));
80
+ expect(closeBacklog(state, "2026-10-01-src", 2, "rejected", "x")).toBe("that item is already closed");
81
+ expect(closeBacklog(state, "2026-10-01-src", 9, "rejected", "x")).toBe("no such backlog item");
82
+ expect(closeBacklog(state, "../../etc", 1, "rejected", "x")).toBe("no such backlog");
83
+ expect(backlogItem(state, "2026-10-01-src", 5)?.status).toBe("rejected");
84
+ });
85
+
86
+ // The real server in a scratch home. Linux only: launching is made to fail before any
87
+ // terminal or agent opens (no display, no terminal on PATH, terminal launch mode), and
88
+ // the manual command it hands back is the request a run would have been given.
89
+ test.skipIf(process.platform !== "linux")("board routes: /state lists backlog cards, /backlog rejects, /run carries the item's source", async () => {
90
+ const homeDir = mkdtempSync(join(tmpdir(), "kz-bl-home-")), proj = join(homeDir, "p"), state = join(proj, ".kaizen");
91
+ for (const d of [state, join(homeDir, ".kaizen"), join(homeDir, ".claude")]) mkdirSync(d, { recursive: true });
92
+ writeFileSync(join(homeDir, ".kaizen", "projects"), proj + "\n");
93
+ writeFileSync(join(homeDir, ".kaizen", "config.yml"), "agent:\n launch: terminal\n");
94
+ const src = join(state, "runs", "2026-10-01-src");
95
+ mkdirSync(src, { recursive: true });
96
+ writeFileSync(join(src, "state.json"), JSON.stringify({ stage: "done", awaiting: null }));
97
+ writeFileSync(join(src, "06-backlog.md"), BACKLOG);
98
+ const port = 20000 + Math.floor(Math.random() * 20000);
99
+ const repo = join(import.meta.dir, "..");
100
+ const env: Record<string, string> = { HOME: homeDir, USERPROFILE: homeDir, KAIZEN_HOME: repo, PATH: dirname(process.execPath), DISPLAY: "", WAYLAND_DISPLAY: "", TERMINAL: "", TMUX: "" };
101
+ const proc = Bun.spawn([process.execPath, join(repo, "cli", "install.ts"), "web", "--port", String(port), "--no-open", "--foreground"], { cwd: proj, env, stdout: "ignore", stderr: "ignore" });
102
+ try {
103
+ const at = `http://127.0.0.1:${port}`;
104
+ for (let i = 0; ; i++) { try { await fetch(at + "/state"); break; } catch { if (i > 100) throw new Error("board did not start"); await Bun.sleep(50); } }
105
+ const post = async (path: string, body: object) => { const r = await fetch(at + path, { method: "POST", body: JSON.stringify({ dir: state, ...body }) }); return { status: r.status, ...(await r.json() as any) }; };
106
+ const listed = async () => ((await (await fetch(at + "/state?all=1")).json()) as any).backlog.map((b: any) => [b.source, b.n]);
107
+ expect(await listed()).toEqual([["2026-10-01-src", 3], ["2026-10-01-src", 1], ["2026-10-01-src", 5]]);
108
+
109
+ expect(await post("/backlog", { op: "reject", source: "2026-10-01-src", n: 5, reason: "" })).toMatchObject({ status: 400 });
110
+ expect(await post("/backlog", { op: "reject", source: "2026-10-01-src", n: 5, reason: "not now" })).toMatchObject({ status: 200 });
111
+ expect(await post("/backlog", { op: "reject", source: "2026-10-01-src", n: 5, reason: "again" })).toMatchObject({ status: 409 });
112
+ expect(await post("/backlog", { op: "reject", source: "2026-10-01-src", n: 42, reason: "x" })).toMatchObject({ status: 404 });
113
+ expect(readFileSync(join(src, "06-backlog.md"), "utf8")).toContain("- rejected: plain item | not now\n");
114
+ expect(await listed()).toEqual([["2026-10-01-src", 3], ["2026-10-01-src", 1]]);
115
+
116
+ expect(await post("/run", { backlog: { source: "2026-10-01-src", n: 2 } })).toMatchObject({ status: 409 });
117
+ expect(await post("/run", { backlog: { source: "2026-10-01-src", n: 42 } })).toMatchObject({ status: 404 });
118
+ const r = await post("/run", { kind: "lite", backlog: { source: "2026-10-01-src", n: 1 } });
119
+ expect(r).toMatchObject({ status: 500, ok: false });
120
+ expect(r.manual).toContain("/kaizen lite first thing. (x.ts:12)");
121
+ expect(r.manual).toContain("Parent: 2026-10-01-src");
122
+ expect(r.manual).toContain("Backlog: 2026-10-01-src#1");
123
+ expect(r.manual).toContain("Backlog item: `cli/x.ts:12`: low: first thing. Fix: do it.");
124
+
125
+ // Once a run carries the line, a second start is refused and the card is gone.
126
+ const kid = join(state, "runs", "2026-10-05-kid");
127
+ mkdirSync(kid, { recursive: true });
128
+ writeFileSync(join(kid, "state.json"), JSON.stringify({ stage: "plan", awaiting: null }));
129
+ writeFileSync(join(kid, "00-request.md"), "first thing\n\nParent: 2026-10-01-src\nBacklog: 2026-10-01-src#1\n");
130
+ expect(await post("/run", { backlog: { source: "2026-10-01-src", n: 1 } })).toMatchObject({ status: 409 });
131
+ expect(await listed()).toEqual([["2026-10-01-src", 3]]);
132
+ } finally { proc.kill(); await proc.exited; }
133
+ }, 30_000);
package/cli/board.test.ts CHANGED
@@ -22,7 +22,8 @@ test("Settings rechecks an existing update alert and clears or refreshes it", as
22
22
  getElementById: (id: string) => id === "pbody" ? body : elements.get(id),
23
23
  createElement: () => ({
24
24
  isConnected: false,
25
- set innerHTML(html: string) {
25
+ set innerHTML(v: unknown) {
26
+ const html = String(v);
26
27
  this.html = html;
27
28
  elements.clear();
28
29
  for (const [, id] of html.matchAll(/id="([^"]+)"/g)) elements.set(id, { textContent: "", disabled: false, focus() {} });
@@ -154,7 +155,7 @@ test("card menu items: appropriate actions and distinct icons across phases", ()
154
155
 
155
156
  const ico = (n: string) => `<svg><use href="#i-${n}"/></svg>`;
156
157
  const from = cards.indexOf("if (k === store.menuKey) {");
157
- const menuSnippet = (cards.slice(from, cards.indexOf('el.innerHTML += `<div class="menu-pop"', from)) + "}").replace("const items = [];", "items = [];");
158
+ const menuSnippet = (cards.slice(from, cards.indexOf('el.innerHTML += html`<div class="menu-pop"', from)) + "}").replace("const items = [];", "items = [];");
158
159
  const a0 = cards.indexOf("const actionOf =");
159
160
  const actionOf = cards.slice(a0, cards.indexOf("\n\n", a0));
160
161
  const getItems = new Function("c", "archive", "S", `const ico = ${ico.toString()};\nconst decided = new Set(), answer = () => "";\n${actionOf}\nconst store = { S, menuKey: 1 };\nlet k = 1, items = [];\n${menuSnippet}\nreturn items;`);
@@ -178,13 +179,18 @@ test("card menu items: appropriate actions and distinct icons across phases", ()
178
179
  const startItems = getItems(startingIdea, false);
179
180
  expect(startItems.map((i: any) => i ? i[1] : null)).toEqual(["Open", "Run again", "Archive", null, "Remove"]);
180
181
 
182
+ // Ideas column, Backlog tab: a run's leftover, run like an idea, rejected with a reason.
183
+ expect(getItems({ kind: "backlog", status: "idea", column: 0 }, false).map((i: any) => i ? i[1] : null)).toEqual(["Run", "Open its run", null, "Reject"]);
184
+ expect(getItems({ kind: "backlog", status: "starting", column: 0 }, false)[0][1]).toBe("Run again");
185
+
181
186
  // Phase 1: Planning (run waiting for plan approval)
182
187
  const planWaiting = { kind: "run", status: "waiting", awaiting: "approvals.plan", column: 1 };
183
188
  const planItems = getItems(planWaiting, false);
184
- expect(planItems.map((i: any) => i ? i[1] : null)).toEqual(["Review plan", "Archive", null, "Abandon"]);
189
+ expect(planItems.map((i: any) => i ? i[1] : null)).toEqual(["Review plan", "Archive", "Back to idea", null, "Abandon"]);
185
190
  expect(planItems[0][2]).toBe(ico("open"));
186
191
  expect(planItems[1][2]).toBe(ico("archive"));
187
- expect(planItems[3][2]).toBe(ico("x"));
192
+ expect(planItems[2][2]).toBe(ico("restore"));
193
+ expect(planItems[4][2]).toBe(ico("x"));
188
194
 
189
195
  // Phase 2: Building (running run)
190
196
  const buildingRun = { kind: "run", status: "running", column: 2 };
@@ -18,7 +18,7 @@ const run = async (code: string, env: Record<string, string> = {}) => {
18
18
  const script = `import * as br from ${JSON.stringify(join(repo, "cli/browser.ts"))};
19
19
  Object.assign(process.env, ${JSON.stringify({ HOME: process.env.HOME ?? "", USERPROFILE: process.env.USERPROFILE ?? "" })});
20
20
  console.log(JSON.stringify(await (async () => { ${code} })()));`;
21
- const p = Bun.spawn(["bun", "-e", script], { cwd: home, env: { ...process.env, HOME: home, USERPROFILE: home, ...env }, stdout: "pipe", stderr: "pipe" });
21
+ const p = Bun.spawn(["bun", "-e", script], { cwd: home, env: { ...process.env, HOME: home, USERPROFILE: home, KAIZEN_BROWSER_PACE: "0.1", ...env }, stdout: "pipe", stderr: "pipe" });
22
22
  const [out, err, code_] = [await new Response(p.stdout).text(), await new Response(p.stderr).text(), await p.exited];
23
23
  if (code_ !== 0) throw new Error(err);
24
24
  return JSON.parse(out.trim().split("\n").at(-1)!);
package/cli/browser.ts CHANGED
@@ -30,6 +30,8 @@ export const POST: Record<string, RegExp> = {
30
30
  };
31
31
  // Grids show thumbnails, not text: each post page is opened for its caption.
32
32
  const GRID = new Set(["instagram"]);
33
+ // The fixed waits below are sized for real platforms; KAIZEN_BROWSER_PACE scales them (the tests run at 0.1).
34
+ const nap = (ms: number) => Bun.sleep(ms * (Number(process.env.KAIZEN_BROWSER_PACE) || 1));
33
35
 
34
36
  // KAIZEN_BROWSER, else the usual names on PATH, else the usual install paths.
35
37
  export function findBrowser(): string | null {
@@ -97,9 +99,9 @@ export class Page {
97
99
  async go(url: string) {
98
100
  await this.call("Page.navigate", { url });
99
101
  const end = Date.now() + 20_000;
100
- await Bun.sleep(1500);
101
- while (Date.now() < end && (await this.js("document.readyState")) !== "complete") await Bun.sleep(250);
102
- await Bun.sleep(2000); // the posts arrive after the page itself
102
+ await nap(1500);
103
+ while (Date.now() < end && (await this.js("document.readyState")) !== "complete") await nap(250);
104
+ await nap(2000); // the posts arrive after the page itself
103
105
  }
104
106
  close() { try { this.ws.close(); } catch { /* already gone */ } }
105
107
  }
@@ -200,7 +202,10 @@ export async function browse<T>(use: (page: Page) => Promise<T>): Promise<T | {
200
202
  } finally {
201
203
  page?.close();
202
204
  proc.kill();
203
- await Promise.race([proc.exited, Bun.sleep(5000)]);
205
+ // A cleared timer, not Bun.sleep: a pending sleep keeps the process alive 5 s after Chrome is gone.
206
+ let t: Timer | undefined;
207
+ await Promise.race([proc.exited, new Promise((r) => { t = setTimeout(r, 5000); })]);
208
+ clearTimeout(t);
204
209
  if (proc.exitCode === null) proc.kill(9);
205
210
  }
206
211
  }
@@ -230,7 +235,7 @@ export async function readPage(page: Page, url: string, platform: string, max =
230
235
  // A page that renders its posts late is not empty: stop early only once some arrived.
231
236
  still = seen.size && seen.size === before ? still + 1 : 0;
232
237
  await page.js("window.scrollBy(0, window.innerHeight * 1.5)");
233
- await Bun.sleep(1500);
238
+ await nap(1500);
234
239
  }
235
240
  let found = [...seen.values()].slice(0, max);
236
241
  if (GRID.has(platform)) {
@@ -310,12 +315,12 @@ export async function replyOn(page: Page, link: string, platform: string, text:
310
315
  if (!(await page.js<boolean>(FIND(how.box, "focus"))))
311
316
  return { ok: false, note: (await page.js<boolean>(OFFERS)) ? signInNote(platform) : `Could not find the reply box on ${name}. Nothing was posted.` };
312
317
  await page.call("Input.insertText", { text });
313
- await Bun.sleep(800);
318
+ await nap(800);
314
319
  if (!(await here())) return { ok: false, note: `The page left ${name}. Nothing was posted.` };
315
320
  if (!(await page.js<boolean>(FIND(how.send, "click")))) return { ok: false, note: `Could not find ${name}'s send button. Nothing was posted.` };
316
321
  // Sent once the box is gone or emptied; still holding the text means it did not go.
317
322
  for (let i = 0; i < 20; i++) {
318
- await Bun.sleep(500);
323
+ await nap(500);
319
324
  const left = await page.js<string | null>(BOX);
320
325
  if (left === null || !left.trim()) return { ok: true };
321
326
  }
@@ -0,0 +1,19 @@
1
+ import { expect, test } from "bun:test";
2
+ import { html, ico, raw } from "../web/board/dom.ts";
3
+
4
+ test("html escapes interpolations once and keeps trusted markup", () => {
5
+ const bad = '<img src=x onerror=1> & "q"';
6
+ const out = String(html`<b title="${bad}">${bad}</b>`);
7
+ expect(out).not.toContain("<img");
8
+ expect(out).toContain("&lt;img src=x onerror=1&gt; &amp; &quot;q&quot;");
9
+ expect(String(html`${"a&b"}`)).toBe("a&amp;b");
10
+ expect(String(html`${raw("<i>")}${ico("x")}`)).toBe('<i><svg><use href="#i-x"/></svg>');
11
+ expect(String(html`<ul>${[1, 2].map((n) => html`<li>${n}</li>`)}</ul>`)).toBe("<ul><li>1</li><li>2</li></ul>");
12
+ expect(String(html`${["<a>"]}`)).toBe("&lt;a&gt;");
13
+ expect(String(html`[${null}${undefined}${false}]`)).toBe("[]");
14
+ expect(String(html`${html`<u>${"&"}</u>`}`)).toBe("<u>&amp;</u>");
15
+ });
16
+
17
+ test("aria states render false explicitly when stringified", () => {
18
+ expect(String(html`<b aria-pressed="${String(false)}" aria-checked="${String(true)}">`)).toBe('<b aria-pressed="false" aria-checked="true">');
19
+ });
package/cli/notes.ts CHANGED
@@ -3,7 +3,7 @@
3
3
  // the page goes through safeName before it becomes a path.
4
4
  import { existsSync, mkdirSync, readdirSync, realpathSync, renameSync, rmdirSync, statSync, writeFileSync } from "node:fs";
5
5
  import { join, dirname, resolve, sep } from "node:path";
6
- import { boardCards, reviewFindings, readText } from "./state.ts";
6
+ import { backlogLines, boardCards, reviewFindings, readText } from "./state.ts";
7
7
  import { wikilinks, boardLink, resolveRun } from "../web/notes-lib.ts";
8
8
 
9
9
  export type Note = { name: string; mtime: number; text: string };
@@ -114,11 +114,7 @@ function prune(state: string, dir: string) {
114
114
  // project keeps hundreds of runs.
115
115
  export function boardIndex(state: string) {
116
116
  const cards = boardCards([state], Date.now());
117
- const backlog = (id: string) => {
118
- let text = "";
119
- try { text = readText(join(state, "runs", id, "06-backlog.md")); } catch { /* none */ }
120
- return [...text.matchAll(/^\s*-\s*(open|done|rejected):\s*(.*)$/gm)].map((m, i) => ({ n: i + 1, status: m[1]!, text: m[2]!.trim() }));
121
- };
117
+ const backlog = (id: string) => backlogLines(join(state, "runs", id, "06-backlog.md")).map(({ n, status, text }) => ({ n, status, text }));
122
118
  return {
123
119
  runs: cards.filter((c) => c.kind === "run").map((c) => ({
124
120
  id: c.id!, short: c.id!.replace(/^\d{4}-\d{2}-\d{2}-/, ""), title: c.title ?? c.text, status: c.status,
package/cli/pty.ts CHANGED
@@ -81,7 +81,8 @@ export function startPty(key: string, cmd: string[], cwd: string, pidFile?: stri
81
81
  for (const f of p.listeners) f(null);
82
82
  p.listeners.clear();
83
83
  try { p.proc.terminal?.close(); } catch { /* already closed */ }
84
- setTimeout(() => { if (ptys.get(key) === p) ptys.delete(key); }, KEEP_MS).unref();
84
+ // Dropped: pages showing it (the Terminal page) refresh, or its tile lingers until some other change.
85
+ setTimeout(() => { if (ptys.get(key) === p) { ptys.delete(key); onChange?.(); } }, KEEP_MS).unref();
85
86
  onChange?.();
86
87
  });
87
88
  return p.proc.pid;
@@ -6,6 +6,7 @@ import { test, expect, beforeAll, afterAll } from "bun:test";
6
6
  import { mkdtempSync, mkdirSync, writeFileSync, readFileSync, copyFileSync, rmSync } from "node:fs";
7
7
  import { join, dirname } from "node:path";
8
8
  import { tmpdir } from "node:os";
9
+ import { applyPreset, detectPreset } from "./settings.ts";
9
10
 
10
11
  const repo = join(dirname(import.meta.path), "..");
11
12
  const defaults = join(repo, "skills/kaizen/config.default.yml");
@@ -48,3 +49,40 @@ test("board settings save to the global config, project settings to the project"
48
49
  expect(inProject(`return setSetting(repo, "git.auto_commit", "true")`)).toEqual({ ok: true });
49
50
  expect(readFileSync(join(project, ".kaizen", "config.yml"), "utf8")).toMatch(/auto_commit: true/);
50
51
  });
52
+
53
+ test("every row is a key in config.default.yml, and every `when` names a row and one of its values", () => {
54
+ const rows = inProject(`return listSettings(repo).rows`);
55
+ const text = readFileSync(defaults, "utf8");
56
+ for (const r of rows) {
57
+ const [top, leaf] = r.key.split(".");
58
+ const block = leaf ? new RegExp(`^${top}:\\n(?:(?: .*|\\s*)\\n)*? ${leaf}:`, "m") : new RegExp(`^${top}:`, "m");
59
+ expect(block.test(text) ? r.key : `${r.key} missing`).toBe(r.key);
60
+ if (!r.when) continue;
61
+ const dep = rows.find((x) => x.key === r.when[0]);
62
+ expect(dep?.values).toContain(r.when[1]);
63
+ }
64
+ });
65
+
66
+ test("a row dims while the setting it depends on has it switched off, and a leftover dead key changes nothing", () => {
67
+ const conf = join(project, ".kaizen", "config.yml");
68
+ // The low preset as an older config holds it, with keys kaizen no longer reads.
69
+ writeFileSync(conf, readFileSync(defaults, "utf8")
70
+ .replace("enabled: true\n # critical", "enabled: false\n # critical")
71
+ .replace("git:\n", "git:\n branch_before_implement: false\n")
72
+ + "\ntrack:\n default: software\n");
73
+ const idle = (k: string) => inProject(`return listSettings(repo).rows.find((r) => r.key === ${JSON.stringify(k)}).idle`);
74
+ expect(idle("auto_fix.min_severity")).toBe("No effect unless Fix findings automatically is on");
75
+ expect(idle("auto_fix.enabled")).toBe("");
76
+ expect(inProject(`return setSetting(repo, "auto_fix.max_iterations", "3")`)).toEqual({ ok: true });
77
+ expect(inProject(`return setSetting(repo, "auto_fix.enabled", "true")`)).toEqual({ ok: true });
78
+ expect(idle("auto_fix.min_severity")).toBe("");
79
+ expect(inProject(`return value("auto_fix.max_iterations")`)).toBe("3");
80
+ copyFileSync(defaults, conf);
81
+ });
82
+
83
+ test("a config still holding git.branch_before_implement keeps its preset", () => {
84
+ const text = readFileSync(defaults, "utf8");
85
+ const low = applyPreset(text, "low", text).replace("git:\n", "git:\n branch_before_implement: true\n");
86
+ expect(detectPreset(low, text)).toBe("low");
87
+ expect(detectPreset(text, text)).toBe("medium");
88
+ });
package/cli/settings.ts CHANGED
@@ -6,7 +6,10 @@ import { homedir } from "node:os";
6
6
  import { readText } from "./state.ts";
7
7
 
8
8
  // `text` is a value typed rather than chosen: a path. It has no values to cycle.
9
- type Setting = { key: string; label: string; group: string; values: string[]; help: string; text?: boolean; folder?: boolean };
9
+ // `when`: the row only has an effect while that other key holds that value; both
10
+ // settings screens dim it otherwise, and it stays editable (`/kaizen full` overrides
11
+ // the runner for one run, so a subagent model still matters under `runner: lite`).
12
+ type Setting = { key: string; label: string; group: string; values: string[]; help: string; text?: boolean; folder?: boolean; when?: [string, string] };
10
13
 
11
14
  export type PresetName = "low" | "medium" | "ultra";
12
15
 
@@ -29,7 +32,6 @@ export const PRESETS: Record<PresetName, {
29
32
  "auto_fix.min_severity": "high",
30
33
  "auto_fix.max_iterations": "1",
31
34
  "git.auto_commit": "false",
32
- "git.branch_before_implement": "false",
33
35
  "git.worktree_per_run": "false",
34
36
  },
35
37
  },
@@ -47,7 +49,6 @@ export const PRESETS: Record<PresetName, {
47
49
  "auto_fix.min_severity": "high",
48
50
  "auto_fix.max_iterations": "2",
49
51
  "git.auto_commit": "false",
50
- "git.branch_before_implement": "true",
51
52
  "git.worktree_per_run": "true",
52
53
  },
53
54
  },
@@ -65,7 +66,6 @@ export const PRESETS: Record<PresetName, {
65
66
  "auto_fix.min_severity": "low",
66
67
  "auto_fix.max_iterations": "4",
67
68
  "git.auto_commit": "true",
68
- "git.branch_before_implement": "true",
69
69
  "git.worktree_per_run": "true",
70
70
  },
71
71
  },
@@ -99,31 +99,29 @@ export function applyPreset(text: string, preset: PresetName, defaults: string):
99
99
 
100
100
  const SETTINGS: Setting[] = [
101
101
  { key: "preset", label: "Preset", group: "Preset", values: ["low", "medium", "ultra", "custom"], help: "" },
102
- { key: "mode", label: "Stop for approval", group: "Approvals", values: ["approve", "auto", "plan-only", "review-only"], help: "" },
103
- { key: "approvals.plan", label: "Approve the plan first", group: "Approvals", values: ["true", "false"], help: "" },
104
- { key: "approvals.review", label: "Approve after review", group: "Approvals", values: ["true", "false"], help: "" },
105
- { key: "approvals.each_file", label: "Confirm every file edit", group: "Approvals", values: ["true", "false"], help: "" },
102
+ { key: "mode", label: "Run mode", group: "Approvals", values: ["approve", "auto", "plan-only", "review-only"], help: "" },
103
+ { key: "approvals.plan", label: "Approve the plan first", group: "Approvals", values: ["true", "false"], help: "", when: ["mode", "approve"] },
104
+ { key: "approvals.review", label: "Approve after review", group: "Approvals", values: ["true", "false"], help: "", when: ["mode", "approve"] },
105
+ { key: "approvals.each_file", label: "Confirm every file edit", group: "Approvals", values: ["true", "false"], help: "", when: ["mode", "approve"] },
106
106
  { key: "runner", label: "Stage isolation", group: "Agents", values: ["full", "lite"], help: "full: separate agent per stage. lite: one session" },
107
- { key: "build.executor", label: "Who builds", group: "Agents", values: ["subagent", "inline", "ask"], help: "" },
107
+ { key: "build.executor", label: "Who builds", group: "Agents", values: ["subagent", "inline", "ask"], help: "subagent: its own agent · inline: this session · ask: pick at plan approval", when: ["runner", "full"] },
108
108
  { key: "agent.default", label: "Tool the board launches", group: "Agents", values: ["auto", "claude", "codex", "agy", "opencode", "gemini"], help: "" },
109
109
  { key: "agent.launch", label: "Where the board runs agents", group: "Agents", values: ["card", "terminal"], help: "" },
110
- { key: "subagents.model", label: "Model for stage agents", group: "Agents", values: ["inherit", "opus", "sonnet", "haiku"], help: "" },
111
- { key: "subagents.effort", label: "Effort for stage agents", group: "Agents", values: ["inherit", "low", "medium", "high"], help: "" },
110
+ { key: "subagents.model", label: "Model for subagents", group: "Agents", values: ["inherit", "opus", "sonnet", "haiku"], help: "Planner, builder, reviewer", when: ["runner", "full"] },
111
+ { key: "subagents.effort", label: "Effort for subagents", group: "Agents", values: ["inherit", "low", "medium", "high"], help: "Planner, builder, reviewer", when: ["runner", "full"] },
112
112
  { key: "auto_fix.enabled", label: "Fix findings automatically", group: "Auto-fix", values: ["true", "false"], help: "" },
113
- { key: "auto_fix.min_severity", label: "Auto-fix from severity", group: "Auto-fix", values: ["critical", "high", "medium", "low"], help: "" },
114
- { key: "auto_fix.max_iterations", label: "Max fix rounds", group: "Auto-fix", values: ["1", "2", "3", "4", "5"], help: "" },
113
+ { key: "auto_fix.min_severity", label: "Lowest severity to fix", group: "Auto-fix", values: ["critical", "high", "medium", "low"], help: "", when: ["auto_fix.enabled", "true"] },
114
+ { key: "auto_fix.max_iterations", label: "Max fix rounds", group: "Auto-fix", values: ["1", "2", "3", "4", "5"], help: "", when: ["auto_fix.enabled", "true"] },
115
115
  { key: "git.auto_commit", label: "Commit when done", group: "Git", values: ["false", "true"], help: "Never pushes" },
116
- { key: "git.branch_before_implement", label: "Branch before building", group: "Git", values: ["true", "false"], help: "" },
117
116
  { key: "git.worktree_per_run", label: "Branch and folder per run", group: "Git", values: ["true", "false"], help: "" },
118
- { key: "track.default", label: "Kind of work", group: "Project", values: ["auto", "software", "writing", "communication", "operations", "research"], help: "" },
119
117
  { key: "review.write_memory", label: "Remember lessons", group: "Project", values: ["true", "false"], help: "" },
120
118
  { key: "review.memory_max_lines", label: "Memory size cap", group: "Project", values: ["50", "100", "200", "400"], help: "" },
121
119
  { key: "state.keep_runs", label: "Runs to keep", group: "Project", values: ["5", "10", "20", "50"], help: "" },
122
120
  { key: "dictation.model", label: "Dictation model", group: "Board", values: ["tiny", "base", "small", "large-v3-turbo"], help: "" },
123
121
  { key: "sources.every", label: "Gather requests", group: "Board", values: ["off", "1h", "6h", "24h"], help: "" },
124
- { key: "sources.agent", label: "Who reads social sources", group: "Board", values: ["ask", "chatgpt", "agent"], help: "" },
125
- { key: "sources.model", label: "ChatGPT model for gathering", group: "Board", values: [], text: true, help: "Empty: plan default" },
126
- { key: "sources.effort", label: "ChatGPT effort for gathering", group: "Board", values: ["low", "medium", "high"], help: "" },
122
+ { key: "sources.agent", label: "Who picks social posts", group: "Board", values: ["ask", "chatgpt", "agent"], help: "X, TikTok, Instagram, Threads" },
123
+ { key: "sources.model", label: "ChatGPT model for gathering", group: "Board", values: [], text: true, help: "Empty: plan default", when: ["sources.agent", "chatgpt"] },
124
+ { key: "sources.effort", label: "ChatGPT effort for gathering", group: "Board", values: ["low", "medium", "high"], help: "", when: ["sources.agent", "chatgpt"] },
127
125
  { key: "board.projects_dir", label: "Projects folder", group: "Board", values: [], text: true, folder: true, help: "Empty: known projects only" },
128
126
  { key: "board.open", label: "Open the board in", group: "Board", values: ["browser", "app"], help: "" },
129
127
  { key: "ui.mouse", label: "Enable mouse", group: "Terminal", values: ["false", "true"], help: "" },
@@ -137,13 +135,18 @@ export function listSettings(repoRoot: string) {
137
135
  const load = (f: string) => existsSync(f) ? readText(f) : "";
138
136
  const text = load(file), board = load(globalFile());
139
137
  const preset = detectPreset(text, defaults);
140
- return {
141
- file: tilde(file),
142
- rows: SETTINGS.map((s) => ({
143
- ...s,
144
- value: s.key === "preset" ? preset : (read(s.group === "Board" ? board : text, s.key) || read(defaults, s.key)) ?? s.values[0] ?? "",
145
- })),
146
- };
138
+ const rows = SETTINGS.map((s) => ({
139
+ ...s,
140
+ value: s.key === "preset" ? preset : (read(s.group === "Board" ? board : text, s.key) || read(defaults, s.key)) ?? s.values[0] ?? "",
141
+ }));
142
+ const valueOf = (k: string) => rows.find((r) => r.key === k)?.value ?? null;
143
+ return { file: tilde(file), rows: rows.map((r) => ({ ...r, idle: idle(r, valueOf) })) };
144
+ }
145
+
146
+ // Why a row has no effect right now, or "" while it has one: its `when` is not met.
147
+ function idle(s: Setting, valueOf: (key: string) => string | null) {
148
+ if (!s.when || valueOf(s.when[0]) === s.when[1]) return "";
149
+ return `No effect unless ${SETTINGS.find((x) => x.key === s.when![0])!.label} is ${s.when[1] === "true" ? "on" : s.when[1]}`;
147
150
  }
148
151
 
149
152
  export function setSetting(repoRoot: string, key: string, value: string) {
@@ -217,13 +220,17 @@ export async function settings(repoRoot: string, standalone = true) {
217
220
  }
218
221
  return v;
219
222
  };
223
+ const valueOf = (k: string) => {
224
+ const d = SETTINGS.find((x) => x.key === k)!;
225
+ return read(fileFor(d) === file ? text : readText(fileFor(d)), k) || read(defaults, k);
226
+ };
220
227
  const rows = SETTINGS.map((s) => ({
221
228
  s,
222
- // Older configs spell "infer the track" as an empty string; show what it means.
229
+ idle: idle(s, valueOf),
223
230
  // The preset as a game's quick preset: arrows either side, the tier lit after it. No colour codes: the selected row wraps this in inverse, which a reset would end.
224
231
  value: s.key === "preset"
225
232
  ? `◁ ${currentPreset.padEnd(6)} ▷ ${(["low", "medium", "ultra"] as const).map((p) => p === currentPreset ? "━" : "─").join("")}`
226
- : show(s, (read(fileFor(s) === file ? text : readText(fileFor(s)), s.key) || (s.key === "track.default" ? "auto" : undefined)) ?? c.dim("(default)")),
233
+ : show(s, read(fileFor(s) === file ? text : readText(fileFor(s)), s.key) ?? c.dim("(default)")),
227
234
  }));
228
235
  const width = Math.max(...SETTINGS.map((s) => s.label.length)) + 4;
229
236
 
@@ -234,7 +241,7 @@ export async function settings(repoRoot: string, standalone = true) {
234
241
  rowAt = [];
235
242
  let line = 2, group = "";
236
243
  const list = shown();
237
- for (const [i, { s, value }] of rows.entries()) {
244
+ for (const [i, { s, value, idle: off }] of rows.entries()) {
238
245
  if (!list.includes(i)) continue;
239
246
  if (s.group !== group) {
240
247
  group = s.group;
@@ -245,14 +252,14 @@ export async function settings(repoRoot: string, standalone = true) {
245
252
  const name = s.label.padEnd(width);
246
253
  stdout.write(on
247
254
  ? ` ${c.cyan("›")} ${c.bold(name)}${c.inv(` ${value} `)}\n`
248
- : ` ${c.dim(name)}${value}\n`);
255
+ : ` ${c.dim(name)}${off ? c.dim(value) : value}\n`);
249
256
  rowAt[line++] = i;
250
257
  }
251
258
  const cur = SETTINGS[active]!;
252
259
  if (!list.length) stdout.write(`\n ${c.dim(`no setting matches "${query}"`)}\n\n`);
253
260
  else {
254
261
  stdout.write(`\n ${c.dim(cur.key)} ${c.dim("options:")} ${cur.values.map((v) => v || '""').join(c.dim(" / "))}\n`);
255
- stdout.write(` ${cur.help}\n`);
262
+ stdout.write(` ${[cur.help, rows[active]!.idle].filter(Boolean).join(" · ")}\n`);
256
263
  }
257
264
  stdout.write(`\n ${c.dim(typing
258
265
  ? "type to filter · enter or ↑↓ keep it · esc clear"