kaizen-agent 1.39.0 → 1.40.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/board.test.ts CHANGED
@@ -157,7 +157,7 @@ test("card menu items: appropriate actions and distinct icons across phases", ()
157
157
  const menuSnippet = (cards.slice(from, cards.indexOf('el.innerHTML += `<div class="menu-pop"', from)) + "}").replace("const items = [];", "items = [];");
158
158
  const a0 = cards.indexOf("const actionOf =");
159
159
  const actionOf = cards.slice(a0, cards.indexOf("\n\n", a0));
160
- const getItems = new Function("c", "archive", "S", `const ico = ${ico.toString()};\n${actionOf}\nconst store = { S, menuKey: 1 };\nlet k = 1, items = [];\n${menuSnippet}\nreturn items;`);
160
+ 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;`);
161
161
 
162
162
  // Phase 0: Ideas (idea card)
163
163
  const idea = { kind: "idea", status: "idea", column: 0 };
package/cli/pty.test.ts CHANGED
@@ -49,7 +49,7 @@ test.skipIf(process.platform === "win32")("the board runs the agent in the card:
49
49
  expect(r.ok).toBe(true);
50
50
  expect(r.pty).toMatch(/^[\w-]+$/);
51
51
  const card = ((await (await fetch(at + "/state")).json()) as any).cards.find((c: any) => c.text === "say hello");
52
- expect(card.pty).toEqual({ key: r.pty, alive: true });
52
+ expect(card.pty).toEqual({ key: r.pty, alive: true, asking: false });
53
53
 
54
54
  // G-01: no Origin, a foreign one, and another local port are all refused.
55
55
  const url = `${at}/pty?key=${r.pty}`, up = { upgrade: "websocket", connection: "Upgrade", "sec-websocket-version": "13", "sec-websocket-key": "dGhlIHNhbXBsZSBub25jZQ==" };
@@ -186,3 +186,11 @@ test.skipIf(process.platform === "win32")("the done sweep keeps a session the us
186
186
  expect(alive(used)).toBe(true);
187
187
  process.kill(used, "SIGTERM");
188
188
  });
189
+
190
+ test("asking: Claude Code and Codex prompts count as a question, ordinary output does not", async () => {
191
+ const { asking } = await import("./pty.ts");
192
+ expect(asking("\x1b[1mWould you like to run the following command?\x1b[0m")).toBe(true);
193
+ expect(asking("What behavior do you want?\r\n\x1b[36m❯ 1. Retry with backoff\x1b[0m\r\n 2. Fail\r\n 4. Type something.")).toBe(true);
194
+ expect(asking("Do you want to make this edit to cli/pty.ts?")).toBe(true);
195
+ expect(asking("Reading cli/pty.ts\r\n\x1b[2K✻ Thinking… 1. step one done")).toBe(false);
196
+ });
package/cli/pty.ts CHANGED
@@ -4,7 +4,7 @@
4
4
  // window ends the agent in it. Bun has no pseudo-terminal on Windows.
5
5
  import { writeFileSync } from "node:fs";
6
6
 
7
- type Pty = { proc: Bun.Subprocess; out: string[]; size: number; listeners: Set<(d: string | null) => void>; exit: number | null };
7
+ type Pty = { proc: Bun.Subprocess; out: string[]; size: number; listeners: Set<(d: string | null) => void>; exit: number | null; asking: boolean; quiet?: Timer; total: number; since: number; onChange?: () => void };
8
8
 
9
9
  // What a page attaching late gets replayed. A full-screen tool redraws often, so the
10
10
  // tail of its output is enough to rebuild the screen.
@@ -13,21 +13,38 @@ const CAP = 512 * 1024;
13
13
  const KEEP_MS = 10 * 60_000;
14
14
 
15
15
  const ptys = new Map<string, Pty>();
16
+
17
+ // A question the agent stopped on: Claude Code's and Codex's approval and choice prompts.
18
+ // ponytail: screen-text heuristic, misses a prompt worded otherwise; read agent hooks if that bites.
19
+ const ASK = /Would you like to|Do you want to|[❯›>] ?1\. |Type something|\(y\/n\)|\[y\/N\]|Press enter to/i;
20
+ // Escape sequences out; a cursor move becomes a space so words drawn apart stay apart.
21
+ export const asking = (screen: string) =>
22
+ ASK.test(screen.replace(/\x1b\[[0-9;?]*[A-Za-z]|\x1b\][^\x07\x1b]*(?:\x07|\x1b\\)|\x1b[()][0-9A-Za-z]/g, " "));
23
+ // How long output stays still before the screen is read for a question.
24
+ const QUIET_MS = 700;
16
25
  // Bun gained pseudo-terminals in 1.3.5; an older one spawns without a tty, so it
17
26
  // keeps opening terminal windows.
18
27
  export const ptySupported = process.platform !== "win32" && typeof (Bun as any).Terminal === "function";
19
28
 
20
29
  // Starts cmd in a new pseudo-terminal under `key` and returns its PID. pidFile gets
21
30
  // that PID, the agent's own, so closeSessions can end it like any launched session.
22
- // onChange: called when the agent ends, so the board can redraw.
31
+ // onChange: called when the agent ends or starts or stops asking, so the board can redraw.
23
32
  export function startPty(key: string, cmd: string[], cwd: string, pidFile?: string, onChange?: () => void): number {
24
33
  const dec = new TextDecoder();
25
- const p: Pty = { proc: null as unknown as Bun.Subprocess, out: [], size: 0, listeners: new Set(), exit: null };
34
+ const p: Pty = { proc: null as unknown as Bun.Subprocess, out: [], size: 0, listeners: new Set(), exit: null, asking: false, total: 0, since: 0, onChange };
35
+ const flag = (v: boolean) => { if (p.asking !== v) { p.asking = v; onChange?.(); } };
26
36
  const push = (d: string) => {
27
37
  p.out.push(d);
28
38
  p.size += d.length;
39
+ p.total += d.length;
29
40
  while (p.size > CAP && p.out.length > 1) p.size -= p.out.shift()!.length;
30
41
  for (const f of p.listeners) f(d);
42
+ clearTimeout(p.quiet);
43
+ p.quiet = setTimeout(() => {
44
+ // Only what came after the user's last keystroke: a question already answered may still be in the tail.
45
+ const n = Math.min(4096, p.total - p.since);
46
+ flag(p.exit === null && n > 0 && asking(p.out.join("").slice(-n)));
47
+ }, QUIET_MS);
31
48
  };
32
49
  p.proc = Bun.spawn(cmd, {
33
50
  cwd,
@@ -38,6 +55,8 @@ export function startPty(key: string, cmd: string[], cwd: string, pidFile?: stri
38
55
  if (pidFile) writeFileSync(pidFile, String(p.proc.pid));
39
56
  p.proc.exited.then((code) => {
40
57
  p.exit = code;
58
+ clearTimeout(p.quiet);
59
+ p.asking = false;
41
60
  for (const f of p.listeners) f(null);
42
61
  p.listeners.clear();
43
62
  try { p.proc.terminal?.close(); } catch { /* already closed */ }
@@ -60,7 +79,10 @@ export function attachPty(key: string, fn: (d: string | null) => void): (() => v
60
79
 
61
80
  export function writePty(key: string, d: string) {
62
81
  const p = ptys.get(key);
63
- if (p && p.exit === null) p.proc.terminal?.write(d);
82
+ if (!p || p.exit !== null) return;
83
+ p.since = p.total;
84
+ if (p.asking) { p.asking = false; p.onChange?.(); }
85
+ p.proc.terminal?.write(d);
64
86
  }
65
87
 
66
88
  export function resizePty(key: string, cols: number, rows: number) {
@@ -73,7 +95,7 @@ export const ptyKeys = () => [...ptys.keys()];
73
95
 
74
96
  export const ptyState = (key: string) => {
75
97
  const p = ptys.get(key);
76
- return p ? { alive: p.exit === null, exit: p.exit } : null;
98
+ return p ? { alive: p.exit === null, exit: p.exit, asking: p.asking } : null;
77
99
  };
78
100
 
79
101
  // The environment the user's login shell builds: PATH and anything exported in their
package/cli/settings.ts CHANGED
@@ -98,35 +98,35 @@ export function applyPreset(text: string, preset: PresetName, defaults: string):
98
98
  }
99
99
 
100
100
  const SETTINGS: Setting[] = [
101
- { key: "preset", label: "Preset", group: "Preset", values: ["low", "medium", "ultra", "custom"], help: "Sets every row below at once. low: you do it here, step by step. medium: balanced. ultra: hands-off. Change any row and it becomes custom" },
102
- { key: "mode", label: "Stop for approval", group: "Approvals", values: ["approve", "auto", "plan-only", "review-only"], help: "approve: pause and ask you at each enabled checkpoint. auto: never pause. plan-only: write the plan and stop. review-only: just audit existing work" },
103
- { key: "approvals.plan", label: "Approve the plan first", group: "Approvals", values: ["true", "false"], help: "Show the plan and wait for your OK before anything is built" },
104
- { key: "approvals.review", label: "Approve after review", group: "Approvals", values: ["true", "false"], help: "After the review, wait for your OK before the run is called done" },
105
- { key: "approvals.each_file", label: "Confirm every file edit", group: "Approvals", values: ["true", "false"], help: "Ask before each individual file change. Maximum control, slowest" },
106
- { key: "runner", label: "Stage isolation", group: "Agents", values: ["full", "lite"], help: "full: each stage is a fresh agent, so the reviewer never saw the work (costs more). lite: this one session does every stage, cheaper, reviewer may miss its own mistakes" },
107
- { key: "build.executor", label: "Who builds", group: "Agents", values: ["subagent", "inline", "ask"], help: "subagent: a separate agent builds and reports back. inline: this session builds while you watch. ask: choose at the plan approval" },
108
- { key: "agent.default", label: "Tool the board launches", group: "Agents", values: ["auto", "claude", "codex", "agy", "opencode", "gemini"], help: "Coding tool opened when you start a run from the board. auto: Claude Code if installed, else the first one found" },
109
- { key: "agent.launch", label: "Where the board runs agents", group: "Agents", values: ["card", "terminal"], help: "card: inside the board, in the card's Terminal tab (ends if the board stops). terminal: a new terminal window each launch. Windows always uses terminal" },
110
- { key: "subagents.model", label: "Model for stage agents", group: "Agents", values: ["inherit", "opus", "sonnet", "haiku"], help: "Which Claude model the planner, builder and reviewer run on. inherit: same as your main session. Claude Code only" },
111
- { key: "subagents.effort", label: "Effort for stage agents", group: "Agents", values: ["inherit", "low", "medium", "high"], help: "How hard the stage agents think. low is faster and cheaper; high finds more. Claude Code only" },
112
- { key: "auto_fix.enabled", label: "Fix findings automatically", group: "Auto-fix", values: ["true", "false"], help: "true: findings severe enough get fixed and re-reviewed without asking. false: every finding stops and asks you" },
113
- { key: "auto_fix.min_severity", label: "Auto-fix from severity", group: "Auto-fix", values: ["critical", "high", "medium", "low"], help: "The least severe finding still fixed automatically. low fixes everything; critical fixes almost nothing" },
114
- { key: "auto_fix.max_iterations", label: "Max fix rounds", group: "Auto-fix", values: ["1", "2", "3", "4", "5"], help: "Fix-then-recheck rounds before leftovers are handed to you. Each round is a full build plus a full review, the priciest knob here" },
115
- { key: "git.auto_commit", label: "Commit when done", group: "Git", values: ["false", "true"], help: "Commit the work when a run finishes. Never pushes" },
116
- { key: "git.branch_before_implement", label: "Branch before building", group: "Git", values: ["true", "false"], help: "Create a branch before building when you are on the default branch" },
117
- { key: "git.worktree_per_run", label: "Branch and folder per run", group: "Git", values: ["true", "false"], help: "Build each run on its own branch in its own git worktree, then land it by pull request. false: every run builds in the project folder" },
118
- { key: "track.default", label: "Kind of work", group: "Project", values: ["auto", "software", "writing", "communication", "operations", "research"], help: "Pin what runs in this project are (a docs repo is always writing). auto: the planner infers it per run" },
119
- { key: "review.write_memory", label: "Remember lessons", group: "Project", values: ["true", "false"], help: "Let the reviewer save what it learned to .kaizen/memory.md, which every future run reads" },
120
- { key: "review.memory_max_lines", label: "Memory size cap", group: "Project", values: ["50", "100", "200", "400"], help: "Lines memory.md may hold before old lessons are pruned. Every run reads it, so bigger costs more" },
121
- { key: "state.keep_runs", label: "Runs to keep", group: "Project", values: ["5", "10", "20", "50"], help: "Finished runs kept before the oldest is pruned. Open backlog items are rescued first" },
122
- { key: "dictation.model", label: "Dictation model", group: "Board", values: ["tiny", "base", "small", "large-v3-turbo"], help: "Whisper model the web board dictates with, in the browser. tiny ~60 MB rough; base ~165 MB English-leaning; small ~410 MB accurate across languages; large-v3-turbo ~1.5 GB best, needs a capable GPU. Downloaded once on the next mic press" },
123
- { key: "sources.every", label: "Gather requests", group: "Board", values: ["off", "1h", "6h", "24h"], help: "How often the board checks your request sources for new requests, into the icebox, while it is running. off: only when you press Gather now" },
124
- { key: "sources.agent", label: "Who reads social sources", group: "Board", values: ["ask", "chatgpt", "agent"], help: "Kaizen's browser reads the posts (sign it in with kaizen browser login); this picks who keeps the ones that fit the project. chatgpt: your ChatGPT plan, when you press Gather, no agent needed (connect it from the Icebox). agent: Gather opens your coding tool on /kaizen gather. ask: the Icebox offers to connect ChatGPT. Not connected, Gather always uses your agent" },
125
- { key: "sources.model", label: "ChatGPT model for gathering", group: "Board", values: [], text: true, help: "The model your ChatGPT plan gathers with. Connected, this lists the models your plan offers. Empty: the first one it lists" },
126
- { key: "sources.effort", label: "ChatGPT effort for gathering", group: "Board", values: ["low", "medium", "high"], help: "How hard the model thinks while gathering. low is fast and spends the least of your plan, and picking requests out of posts needs little more" },
127
- { key: "board.projects_dir", label: "Projects folder", group: "Board", values: [], text: true, folder: true, help: `A folder your projects live in, e.g. ${process.platform === "win32" ? join(homedir(), "Projects") : "~/Projects"}. Every folder inside it appears in the board's project list, ready to choose; choosing one sets it up. Empty: only projects kaizen already knows` },
128
- { key: "board.open", label: "Open the board in", group: "Board", values: ["browser", "app"], help: "browser: a tab in your default browser. app: a Chrome, Edge or Brave window with no tabs or address bar, like a desktop app; your default browser when none is installed" },
129
- { key: "ui.mouse", label: "Enable mouse", group: "Terminal", values: ["false", "true"], help: "Click to select, click again to act. While on, the terminal cannot select text with the mouse" },
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: "" },
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: "" },
108
+ { key: "agent.default", label: "Tool the board launches", group: "Agents", values: ["auto", "claude", "codex", "agy", "opencode", "gemini"], help: "" },
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: "" },
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: "" },
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
+ { 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
+ { key: "review.write_memory", label: "Remember lessons", group: "Project", values: ["true", "false"], help: "" },
120
+ { key: "review.memory_max_lines", label: "Memory size cap", group: "Project", values: ["50", "100", "200", "400"], help: "" },
121
+ { key: "state.keep_runs", label: "Runs to keep", group: "Project", values: ["5", "10", "20", "50"], help: "" },
122
+ { key: "dictation.model", label: "Dictation model", group: "Board", values: ["tiny", "base", "small", "large-v3-turbo"], help: "" },
123
+ { 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: "" },
127
+ { key: "board.projects_dir", label: "Projects folder", group: "Board", values: [], text: true, folder: true, help: "Empty: known projects only" },
128
+ { key: "board.open", label: "Open the board in", group: "Board", values: ["browser", "app"], help: "" },
129
+ { key: "ui.mouse", label: "Enable mouse", group: "Terminal", values: ["false", "true"], help: "" },
130
130
  ];
131
131
 
132
132
  // The same rows and the same write the TUI uses, for `kaizen web`'s settings panel:
@@ -281,7 +281,7 @@ test("hostile content becomes plain icebox items, and promoted, plain open ideas
281
281
  for (const l of open()) promote(state, [l.id]);
282
282
  const lines = readInbox(state);
283
283
  expect(lines.length).toBe(7);
284
- expect(lines[0]).toEqual({ status: "started", text: "already here" });
284
+ expect(lines[0]).toMatchObject({ status: "started", text: "already here" });
285
285
  const fresh = lines.slice(1);
286
286
  expect(fresh.every((l) => l.status === "open")).toBe(true);
287
287
  expect(fresh.map((l) => l.text)).toEqual([
package/cli/state.test.ts CHANGED
@@ -25,6 +25,12 @@ const run = (id: string, s: object, request = "") => {
25
25
  beforeAll(() => {
26
26
  state = join(mkdtempSync(join(tmpdir(), "kaizen-")), ".kaizen");
27
27
  mkdirSync(state, { recursive: true });
28
+ writeInbox(state, [
29
+ { status: "open", text: "add oauth login" },
30
+ { status: "open", text: "retry failed webhooks" },
31
+ { status: "started", text: "nightly digest" },
32
+ { status: "rejected", text: "gantt view | not this product" },
33
+ ]);
28
34
  run("2026-09-01-waiting", { stage: "plan", awaiting: "approvals.plan" }, "# Request\n\nAdd OAuth login\n");
29
35
  run("2026-09-02-done", { stage: "done", awaiting: null }, "# Request\n\nRename cli flags\n");
30
36
  const old = run("2026-09-03-stale", { stage: "build", awaiting: null });
@@ -33,12 +39,6 @@ beforeAll(() => {
33
39
  writeFileSync(join(state, "runs", "2026-09-02-done", "06-backlog.md"),
34
40
  "# Backlog\n\n- open: `cli/x.ts:12`: low: first thing. Fix: do it.\n- done: closed one | run y\n- open: plain item\n");
35
41
  writeFileSync(join(state, "backlog.md"), "- open: orphaned item\n");
36
- writeInbox(state, [
37
- { status: "open", text: "add oauth login" },
38
- { status: "open", text: "retry failed webhooks" },
39
- { status: "started", text: "nightly digest" },
40
- { status: "rejected", text: "gantt view | not this product" },
41
- ]);
42
42
  });
43
43
  afterAll(() => rmSync(join(state, ".."), { recursive: true, force: true }));
44
44
 
@@ -80,6 +80,48 @@ test("boardCards: idea retired once a run's request contains it; started idea si
80
80
  expect(runs).toEqual([["2026-09-03-stale", 2, "stalled"], ["2026-09-02-done", 4, "done"], ["2026-09-01-waiting", 1, "waiting"]]);
81
81
  });
82
82
 
83
+ test("boardCards: a started idea stays until its run has a state.json", () => {
84
+ const dir = join(mkdtempSync(join(tmpdir(), "kaizen-")), ".kaizen");
85
+ const r = join(dir, "runs", "2026-01-01-new");
86
+ mkdirSync(r, { recursive: true });
87
+ writeInbox(dir, [{ status: "started", text: "full release" }]);
88
+ writeFileSync(join(r, "00-request.md"), "full release\n");
89
+ const cards = () => boardCards([dir], Date.now()).map((k) => k.kind);
90
+ expect(cards()).toEqual(["idea"]);
91
+ writeFileSync(join(r, "state.json"), JSON.stringify({ id: "2026-01-01-new", stage: "plan", awaiting: null }));
92
+ expect(cards()).toEqual(["run"]);
93
+ rmSync(join(dir, ".."), { recursive: true, force: true });
94
+ });
95
+
96
+ test("boardCards: a run older than the idea does not retire it", () => {
97
+ const dir = join(mkdtempSync(join(tmpdir(), "kaizen-")), ".kaizen");
98
+ const r = join(dir, "runs", "2026-01-01-old");
99
+ mkdirSync(r, { recursive: true });
100
+ writeFileSync(join(r, "state.json"), JSON.stringify({ id: "2026-01-01-old", stage: "done", awaiting: null }));
101
+ writeFileSync(join(r, "00-request.md"), "fix this bugs\n");
102
+ const then = new Date(Date.now() - 3600_000);
103
+ utimesSync(join(r, "00-request.md"), then, then);
104
+ writeInbox(dir, [{ status: "open", text: "fix this bugs" }]);
105
+ expect(readFileSync(join(dir, "inbox.md"), "utf8")).toMatch(/- open: fix this bugs <!-- added:\d+ -->/);
106
+ expect(readInbox(dir)[0]!.text).toBe("fix this bugs");
107
+ const ideas = () => boardCards([dir], Date.now()).filter((k) => k.kind === "idea").map((k) => k.text);
108
+ expect(ideas()).toEqual(["fix this bugs"]);
109
+ // Rewriting keeps the stamp, so an edit to notes does not make it vanish.
110
+ writeInbox(dir, readInbox(dir).map((l) => ({ status: l.status, text: l.text, notes: "n" })));
111
+ expect(ideas()).toEqual(["fix this bugs"]);
112
+ // A run started after it retires it as before.
113
+ utimesSync(join(r, "00-request.md"), new Date(), new Date(Date.now() + 5000));
114
+ expect(ideas()).toEqual([]);
115
+ // Re-adding the same words while an old line holds them is a new idea with its
116
+ // own stamp, not the old line's (or no) stamp.
117
+ writeFileSync(join(dir, "inbox.md"), "- done: fix this bugs\n");
118
+ writeInbox(dir, [...readInbox(dir), { status: "open", text: "fix this bugs" }]);
119
+ utimesSync(join(r, "00-request.md"), then, then);
120
+ expect(ideas()).toEqual(["fix this bugs"]);
121
+
122
+ rmSync(join(dir, ".."), { recursive: true, force: true });
123
+ });
124
+
83
125
  test("replaceIdea edits, rejects, deletes by text", () => {
84
126
  replaceIdea(state, "retry failed webhooks", { status: "open", text: "retry failed webhooks twice" });
85
127
  expect(readInbox(state).some((l) => l.text === "retry failed webhooks twice")).toBe(true);
@@ -103,6 +145,20 @@ test("backlog: open items only, orphanage last, count matches", () => {
103
145
  expect(backlog(state)).toBe(3);
104
146
  });
105
147
 
148
+ test("backlog: an abandoned run's items are neither listed nor counted", () => {
149
+ const dir = join(state, "runs", "2026-09-03-dropped");
150
+ mkdirSync(dir, { recursive: true });
151
+ writeFileSync(join(dir, "state.json"), JSON.stringify({ id: "2026-09-03-dropped", stage: "abandoned" }));
152
+ writeFileSync(join(dir, "06-backlog.md"), "- open: never happening\n");
153
+ expect(allBacklog(state).some((g) => g.run === "2026-09-03-dropped")).toBe(false);
154
+ expect(backlog(state)).toBe(3);
155
+ rmSync(dir, { recursive: true });
156
+ });
157
+
158
+ test("abandonRun refuses a done run", () => {
159
+ expect(abandonRun(state, "2026-09-02-done", "late")).toMatch(/already done/);
160
+ });
161
+
106
162
  test("parseItem: finding line splits into where, severity, text", () => {
107
163
  const it = parseItem("`cli/x.ts:12`: low: first thing. Fix: do it.");
108
164
  expect([it.where, it.severity, it.text]).toEqual(["x.ts:12", "low", "first thing."]);
@@ -188,8 +244,8 @@ test("notes: indented lines under a bullet round-trip; a legacy file is untouche
188
244
  expect(readFileSync(join(state, "inbox.md"), "utf8")).toBe(legacy);
189
245
  writeInbox(state, [{ status: "open", text: "with notes", notes: "see https://x.y/z\ncli/state.ts:12 | not a reason" }, { status: "open", text: "plain zq8" }]);
190
246
  const [a, b] = readInbox(state);
191
- expect(a).toEqual({ status: "open", text: "with notes", notes: "see https://x.y/z\ncli/state.ts:12 | not a reason" });
192
- expect(b).toEqual({ status: "open", text: "plain zq8" });
247
+ expect(a).toMatchObject({ status: "open", text: "with notes", notes: "see https://x.y/z\ncli/state.ts:12 | not a reason" });
248
+ expect(b).toMatchObject({ status: "open", text: "plain zq8" });
193
249
  });
194
250
 
195
251
  test("notes: survive reject and started; edit can replace or clear them", () => {
@@ -334,6 +390,7 @@ console.log(JSON.stringify(out));`;
334
390
  test("boardCards: a short idea is not retired by a request that merely contains the word", () => {
335
391
  const dir = join(mkdtempSync(join(tmpdir(), "kz-")), ".kaizen");
336
392
  mkdirSync(join(dir, "runs", "2026-09-01-a"), { recursive: true });
393
+ writeFileSync(join(dir, "runs", "2026-09-01-a", "state.json"), JSON.stringify({ id: "2026-09-01-a", stage: "plan", awaiting: null }));
337
394
  writeFileSync(join(dir, "runs", "2026-09-01-a", "00-request.md"), "# Request\n\n> /kaizen lite add retry\n\nDone means the tests pass.\n");
338
395
  writeInbox(dir, [{ status: "open", text: "test" }, { status: "open", text: "add retry" }]);
339
396
  expect(boardCards([dir], Date.now()).filter((k) => k.kind === "idea").map((k) => k.text)).toEqual(["test"]);
@@ -986,7 +1043,7 @@ test("moveIdea carries notes and linked attachments, leaves the old inbox withou
986
1043
  expect(moveIdea(a, b, "nope")).toBe("no such idea");
987
1044
  expect(moveIdea(a, b, "go")).toBeNull();
988
1045
  expect(readInbox(a).map((l) => l.text)).toEqual(["keep"]);
989
- expect(readInbox(b)).toEqual([{ status: "open", text: "go", notes: "see [shot](attachments/x-shot.png)" }]);
1046
+ expect(readInbox(b)).toMatchObject([{ status: "open", text: "go", notes: "see [shot](attachments/x-shot.png)" }]);
990
1047
  expect(readFileSync(join(b, "attachments", "x-shot.png"), "utf8")).toBe("png");
991
1048
  writeInbox(a, [{ status: "open", text: "go" }]);
992
1049
  expect(moveIdea(a, b, "go")).toBe("that project already has this idea");
package/cli/state.ts CHANGED
@@ -435,7 +435,7 @@ export function unrechecked(state: string, id: string) {
435
435
  export function backlog(state: string) {
436
436
  const files = [join(state, "backlog.md")];
437
437
  const runs = join(state, "runs");
438
- if (existsSync(runs)) for (const id of readdirSync(runs)) files.push(join(runs, id, "06-backlog.md"));
438
+ if (existsSync(runs)) for (const id of readdirSync(runs)) if (!abandoned(join(runs, id))) files.push(join(runs, id, "06-backlog.md"));
439
439
  let n = 0;
440
440
  for (const f of files) {
441
441
  if (!existsSync(f)) continue;
@@ -581,11 +581,17 @@ export function readBacklog(file: string) {
581
581
  .map((l) => l.replace(/^-\s*open:\s*/, ""));
582
582
  }
583
583
 
584
+ // An abandoned run's backlog is not open work (spec-main.md).
585
+ function abandoned(run: string) {
586
+ try { return JSON.parse(readText(join(run, "state.json"))).stage === "abandoned"; } catch { return false; }
587
+ }
588
+
584
589
  export function allBacklog(state: string) {
585
590
  const out: { run: string; items: string[] }[] = [];
586
591
  const runs = join(state, "runs");
587
592
  if (existsSync(runs)) {
588
593
  for (const id of readdirSync(runs).sort().reverse()) {
594
+ if (abandoned(join(runs, id))) continue;
589
595
  const items = readBacklog(join(runs, id, "06-backlog.md"));
590
596
  if (items.length) out.push({ run: id, items });
591
597
  }
@@ -676,7 +682,8 @@ export function columnOf(stage: string, awaiting: string | null = null) {
676
682
 
677
683
  // notes: free text under the bullet -- links, path:line refs, background. Kept as
678
684
  // indented continuation lines so a one-line inbox from before notes reads unchanged.
679
- export type InboxLine = { status: string; text: string; notes?: string };
685
+ // `added` is when the line first appeared (ms), kept as a hidden comment on its bullet.
686
+ export type InboxLine = { status: string; text: string; notes?: string; added?: number };
680
687
 
681
688
  // Ideas with no run yet. Deliberately not backlog.md, which the spec defines as
682
689
  // an orphanage for items rescued from pruned runs, and not a run stub each.
@@ -686,7 +693,11 @@ export function readInbox(state: string): InboxLine[] {
686
693
  const out: InboxLine[] = [];
687
694
  for (const raw of readText(f).split("\n")) {
688
695
  const m = /^-\s*(open|started|done|rejected|archived):\s*(.+)$/.exec(raw.trim());
689
- if (m) { out.push({ status: m[1]!, text: m[2]!.trim() }); continue; }
696
+ if (m) {
697
+ const a = /\s*<!-- added:(\d+) -->$/.exec(m[2]!);
698
+ out.push(a ? { status: m[1]!, text: m[2]!.slice(0, a.index).trim(), added: +a[1]! } : { status: m[1]!, text: m[2]!.trim() });
699
+ continue;
700
+ }
690
701
  // An indented line belongs to the bullet above it; anything else is noise.
691
702
  const last = out[out.length - 1];
692
703
  if (last && /^\s{2,}\S/.test(raw)) last.notes = (last.notes ? last.notes + "\n" : "") + raw.trim();
@@ -699,12 +710,24 @@ export function readInbox(state: string): InboxLine[] {
699
710
  // needs a lock or an append-only journal.
700
711
  export function writeInbox(state: string, lines: InboxLine[]) {
701
712
  mkdirSync(state, { recursive: true });
713
+ // A line already in the file keeps its stamp; a text the file never had is new now.
714
+ // Lines from before stamps existed stay unstamped rather than looking new.
715
+ // Stamps queue per text and each is used once: re-adding words the file already
716
+ // holds (a done or rejected line, or an open duplicate) is a new idea, not the old
717
+ // one's stamp, which an older run with those words would retire.
718
+ const had = new Map<string, (number | undefined)[]>();
719
+ for (const l of readInbox(state)) had.set(l.text, [...(had.get(l.text) ?? []), l.added]);
720
+ const stamp = (l: InboxLine) => {
721
+ if (l.added) return l.added;
722
+ const q = had.get(l.text);
723
+ return q?.length ? q.shift() : Date.now();
724
+ };
702
725
  const body = [
703
726
  "# Inbox",
704
727
  "",
705
728
  "Ideas with no run yet. One line each, same format as a run's backlog.",
706
729
  "",
707
- ...lines.flatMap((l) => [`- ${l.status}: ${l.text}`, ...noteLines(l.notes)]),
730
+ ...lines.flatMap((l) => { const a = stamp(l); return [`- ${l.status}: ${l.text}${a ? ` <!-- added:${a} -->` : ""}`, ...noteLines(l.notes)]; }),
708
731
  ];
709
732
  writeFileSync(join(state, "inbox.md"), body.join("\n") + "\n");
710
733
  }
@@ -863,6 +886,7 @@ export function abandonRun(st: string, id: string, why: string): string | null {
863
886
  try {
864
887
  const run = JSON.parse(readText(file));
865
888
  if (run.stage === "abandoned") return "That run is already abandoned.";
889
+ if (run.stage === "done") return "That run is already done.";
866
890
  run.stage = "abandoned";
867
891
  run.awaiting = null;
868
892
  run.updated = new Date().toISOString();
@@ -961,15 +985,20 @@ export function boardCards(states: string[], now: number): Card[] {
961
985
  if (!bodies.has(f)) { try { bodies.set(f, readText(f)); } catch { bodies.set(f, ""); } }
962
986
  return bodies.get(f)!;
963
987
  };
964
- const requests: string[] = [];
988
+ // Each request with when it was written, so a run older than an idea cannot retire it.
989
+ const requests: { at: number; text: string }[] = [];
965
990
  // Each request line on its own, quote and list markers stripped: what an idea
966
991
  // nobody launched from the board is matched against.
967
- const lines: string[] = [];
992
+ const lines: { at: number; text: string }[] = [];
968
993
  for (const st of [...states, ...knownProjects().map((d) => join(d, ".kaizen")), join(home, ".kaizen")]
969
994
  .filter((d, i, all) => all.indexOf(d) === i && existsSync(join(d, "runs")))) {
970
- for (const id of readdirSync(join(st, "runs"))) { const b = body(st, id); if (!b) continue;
971
- requests.push(normalise(b));
972
- for (const l of b.split("\n")) lines.push(normalise(l.replace(/^[\s>*"'`-]+|[\s"'`.]+$/g, "")));
995
+ // A run with no state.json yet has no card either: retiring the idea against it
996
+ // leaves the board showing neither until the agent writes one.
997
+ for (const id of readdirSync(join(st, "runs"))) { const b = body(st, id); if (!b || !existsSync(join(st, "runs", id, "state.json"))) continue;
998
+ let at = 0;
999
+ try { at = statSync(join(st, "runs", id, "00-request.md")).mtimeMs; } catch {}
1000
+ requests.push({ at, text: normalise(b) });
1001
+ for (const l of b.split("\n")) lines.push({ at, text: normalise(l.replace(/^[\s>*"'`-]+|[\s"'`.]+$/g, "")) });
973
1002
  }
974
1003
  }
975
1004
  for (const st of states) {
@@ -986,7 +1015,11 @@ export function boardCards(states: string[], now: number): Card[] {
986
1015
  // prefix): a short idea like "test" is a substring of half the requests
987
1016
  // ever written, and would vanish the moment it was added.
988
1017
  const t = normalise(it.text);
989
- if (it.status === "started" ? requests.some((r) => r.includes(t)) : lines.some((l) => l === t || l.endsWith(" " + t))) continue;
1018
+ // A run written before the idea existed is not this idea's run: an old request
1019
+ // titled "fix this bugs" must not swallow a new idea with the same words.
1020
+ // ponytail: 2s slack for coarse filesystem clocks.
1021
+ const after = (r: { at: number }) => !it.added || r.at >= it.added - 2000;
1022
+ if (it.status === "started" ? requests.some((r) => after(r) && r.text.includes(t)) : lines.some((l) => after(l) && (l.text === t || l.text.endsWith(" " + t)))) continue;
990
1023
  next.push(it.status === "started"
991
1024
  ? { kind: "idea", status: "starting", text: it.text, state: st, where, column: 1, awaiting: null, dim: true, archived: false, notes: it.notes, parent: parentOf(it.notes) }
992
1025
  : { kind: "idea", status: "idea", text: it.text, state: st, where, column: 0, awaiting: null, dim: false, archived: it.status === "archived", notes: it.notes, parent: parentOf(it.notes) });
@@ -1392,7 +1425,7 @@ export function trustFolder(dir: string, cfg = join(process.env.CLAUDE_CONFIG_DI
1392
1425
 
1393
1426
  // run: the run a decision is for. Without it, an idea starting a new run.
1394
1427
  // pick: the tool, model and yolo chosen for a new run; a decision takes the run's own
1395
- // tool and model, and never yolo. card: run it inside the board (agent.launch: card),
1428
+ // tool and model, and stays in Yolo when the run was started in it. card: run it inside the board (agent.launch: card),
1396
1429
  // where a pseudo-terminal exists; onChange is told when that agent ends.
1397
1430
  const CHILD_ENV = /^(CLAUDECODE|CLAUDE_PID|CLAUDE_CODE_(SESSION_ID|CHILD_SESSION|SESSION_ATTENDED|MESSAGING_SOCKET|MESSAGING_TOKEN))$/;
1398
1431
 
@@ -1413,7 +1446,10 @@ export function launchRun(it: Item, from: string, run?: string, pick: { agent?:
1413
1446
  const wanted = pick.model ?? (own?.cmd === agent.cmd ? own.model : null) ?? undefined;
1414
1447
  const model = wanted && plainModel(wanted) ? wanted : undefined;
1415
1448
  // Yolo was picked for one tool; a fallback to another opens in its normal mode.
1416
- const yolo = !!pick.yolo && agent.cmd === pick.agent && !!YOLO_FLAGS[agent.cmd];
1449
+ // A decision on a run started in Yolo keeps it, or its next stage stops on every command.
1450
+ const st = join(projectDir, ".kaizen");
1451
+ const wasYolo = !!run && !pick.agent && readSessions(st).some((s) => s.yolo && s.cmd === agent.cmd && ownerOf(st, s) === run);
1452
+ const yolo = (wasYolo || (!!pick.yolo && agent.cmd === pick.agent)) && !!YOLO_FLAGS[agent.cmd];
1417
1453
  const flags = [...agentFlags(projectDir, agent.cmd, undefined, model), ...(yolo ? YOLO_FLAGS[agent.cmd] : [])];
1418
1454
  // The idea as its run's request will hold it: without the runner word in front.
1419
1455
  const s = sessionLaunch(projectDir, agent.cmd, [agentBin, ...flags], prompt, it.text.replace(/^(lite|full|auto|plan)\s+/i, ""), run, model, yolo);
package/cli/web.ts CHANGED
@@ -191,7 +191,9 @@ export async function web(repoDir: string, opts: Opts = {}) {
191
191
  const key = c.kind === "run"
192
192
  ? readSessions(c.state).filter((s) => live.has(s.key) && ownerOf(c.state, s) === c.id).pop()?.key
193
193
  : ideaPty.get(`${c.state}\0${c.text}`);
194
- return key && live.has(key) ? { key, alive: ptyState(key)!.alive } : undefined;
194
+ if (!key || !live.has(key)) return undefined;
195
+ const st = ptyState(key)!;
196
+ return { key, alive: st.alive, asking: st.asking };
195
197
  };
196
198
  // Dev: the page reloads itself when board.html or its bundle is saved. Server code needs
197
199
  // `bun --watch cli/install.ts web`; the page reconnects and reloads on hello.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "kaizen-agent",
3
- "version": "1.39.0",
3
+ "version": "1.40.1",
4
4
  "description": "Staged workflow for AI agents — code, writing, research, operations: plan, human approval, build, independent review, bounded fix loop.",
5
5
  "type": "module",
6
6
  "bin": {
@@ -108,8 +108,9 @@ rather than dispatching them, then stop after the review: report the findings an
108
108
  the user choose, rather than entering the fix loop. The loop
109
109
  exists because a fix to something intricate can be worse than the defect — it is not
110
110
  worth two more stages to confirm a one-line message now reads correctly. A finding at
111
- `high` or above pulls the run back onto the full path whatever its size, since that
112
- severity is the loop's whole reason for existing.
111
+ `high` or above still enters the fix loop whatever the run's size, since that
112
+ severity is the loop's whole reason for existing — on the same runner, which is fixed
113
+ at intake.
113
114
 
114
115
  **Short work still gets a run.** A one-line change is not skipped past the stages: the card on the board moves through plan, build and review like any other. Run it with `runner: lite` and the short path above — a brief plan, the build, a short review — so the cost stays small and the run stays visible.
115
116
 
@@ -119,7 +119,12 @@ library, an API — may give command output alone. A report with no screenshot i
119
119
  A headless browser does it: `playwright screenshot <url> runs/<id>/attachments/<name>.png`,
120
120
  or `chromium --headless --screenshot=<abs path>.png --window-size=1280,900 <url>` where
121
121
  playwright has no browser installed. A file (document, diff, output) opens as `file://<path>`.
122
- Where no proof can be produced, say why in one line under `## Preview`.
122
+ Where no proof can be produced, say why in one line under `## Preview` — but a missing
123
+ browser is never that reason. Before giving up, try each of: `playwright screenshot`,
124
+ `bunx playwright screenshot`, `chromium --headless`, `google-chrome --headless`,
125
+ and `bunx playwright install chromium` then retry. Start the app if it is not running.
126
+ Only a result with no screen, or a capture that failed after every one of these (quote
127
+ the error), may skip the screenshot.
123
128
 
124
129
  **A revise retakes the screenshots.** When the run being revised, or the run a follow-up
125
130
  names in `Follow-up of:`, already shows screenshots in its `## Preview`, the build that
@@ -89,7 +89,8 @@ Before setting `stage: "review"` after a build, in either runner, open `03-impl.
89
89
  check its `## Preview`: it has `### How to test` steps, and — when the result shows on a
90
90
  screen — at least one `![…](attachments/<name>.png)` embed whose file exists under
91
91
  `runs/<id>/attachments/`. A result with no screen needs one line under `## Preview`
92
- saying why there is no screenshot. Anything missing means the build is not finished:
92
+ saying why there is no screenshot; "no headless browser" for a result that shows on a
93
+ screen does not count — take it as missing. Anything missing means the build is not finished:
93
94
  under `lite`, take the screenshot and write the section now; under `full`, send the
94
95
  builder back to add it. Then advance. This is the step that keeps the board's Preview
95
96
  tab from staying empty; the reviewer flagging it later is a second net, not the first.
@@ -143,7 +143,7 @@ the config said the day someone came back to it.
143
143
  If the request remains ambiguous enough that a plan would be guesswork, ask now. One
144
144
  round of questions here is cheaper than a rejected plan.
145
145
 
146
- **Short work is not skipped.** Even a one-line change gets `01-plan.md` and `04-review.md`, so the run moves through every stage. Keep them brief and run under `runner: lite`.
146
+ **Short work is not skipped.** Even a one-line change gets `01-plan.md` and `04-review.md`, so the run moves through every stage. Keep them brief and run under `runner: lite`; a `high`+ finding still enters the fix loop, on that same runner.
147
147
 
148
148
  **Follow-ups are runs too.** Both runners record the same artifacts: `01-plan.md`, `03-impl.md`, `04-review.md`, and a final `state.json` — `lite` and `full` differ only in who executes the stages. A message the user sends later in the same session is judged by what it asks: a question, explanation or chat is answered, no run; a request to change or produce something is building, so it goes through the flow. While the previous run has not reached `done` (including awaiting its final approval), the change is a revise of that run, recorded in `02-approval.md`. After `done` or `abandoned`, open a new run whose `00-request.md` is the user's words verbatim, then a line `Follow-up of: <previous run id>`. Never reopen a finished run. When unsure whether a message asks for a change, ask once.
149
149
 
@@ -9,7 +9,7 @@ import { brief } from "./markdown.ts";
9
9
  import { open, toggleArchive } from "./panel.ts";
10
10
  import { ideaCombo, list, newDir, plogo, short, showList, showable } from "./projects.ts";
11
11
  import { askNotify, toast } from "./toast.ts";
12
- import { resumable, resume } from "./approvals.ts";
12
+ import { answer, decided, resumable, resume } from "./approvals.ts";
13
13
 
14
14
  let filter = null;
15
15
  let query = ""; // the header filter box, matched against a card's title and request
@@ -42,8 +42,11 @@ const nested = (c) =>
42
42
  const kidsOpen = new Set();
43
43
 
44
44
  // What opening a waiting run does, in words: the card's button and its menu say the same.
45
+ // One already answered from this page says so until the agent moves the run on.
45
46
  const actionOf = (c) =>
46
- c.status !== "waiting"
47
+ decided.has(answer(c))
48
+ ? "Sent to agent"
49
+ : c.status !== "waiting"
47
50
  ? "Open"
48
51
  : { "approvals.plan": "Review plan", findings: "See findings", "approvals.review": "Review work" }[c.awaiting] || "Open";
49
52
 
@@ -84,7 +87,9 @@ function card(c) {
84
87
  marks.push(`<span class="mk warn" title="Started in Yolo: the agent runs commands without asking">yolo</span>`);
85
88
  else chips.push(`<span class="chip">${esc(NAMES[t] || t)}</span>`);
86
89
  }
87
- if (c.pty && c.pty.alive)
90
+ if (c.pty && c.pty.alive && c.pty.asking)
91
+ marks.push(mk("i-ask", "The agent is asking you something: open it, terminal button at the top", " warn ask"));
92
+ else if (c.pty && c.pty.alive)
88
93
  marks.push(mk("i-term", "The agent is running in this card: open it, terminal button at the top", " run"));
89
94
  // A gathered idea's notes open with the stamp promote() writes (ingest() wrote it before
90
95
  // the icebox); its chip names the source.
@@ -112,7 +117,7 @@ function card(c) {
112
117
  const act =
113
118
  c.kind === "run" && !archive && !pick
114
119
  ? c.status === "waiting"
115
- ? `<button class="btn sm pri act" data-a="open">${actionOf(c)}</button>`
120
+ ? `<button class="btn sm${decided.has(answer(c)) ? "" : " pri"} act" data-a="open">${actionOf(c)}</button>`
116
121
  : c.status === "stalled" && resumable(c)
117
122
  ? `<button class="btn sm act" data-a="resume">Resume</button>`
118
123
  : ""