kaizen-agent 1.39.0 → 1.40.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/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);
@@ -188,8 +230,8 @@ test("notes: indented lines under a bullet round-trip; a legacy file is untouche
188
230
  expect(readFileSync(join(state, "inbox.md"), "utf8")).toBe(legacy);
189
231
  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
232
  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" });
233
+ expect(a).toMatchObject({ status: "open", text: "with notes", notes: "see https://x.y/z\ncli/state.ts:12 | not a reason" });
234
+ expect(b).toMatchObject({ status: "open", text: "plain zq8" });
193
235
  });
194
236
 
195
237
  test("notes: survive reject and started; edit can replace or clear them", () => {
@@ -334,6 +376,7 @@ console.log(JSON.stringify(out));`;
334
376
  test("boardCards: a short idea is not retired by a request that merely contains the word", () => {
335
377
  const dir = join(mkdtempSync(join(tmpdir(), "kz-")), ".kaizen");
336
378
  mkdirSync(join(dir, "runs", "2026-09-01-a"), { recursive: true });
379
+ writeFileSync(join(dir, "runs", "2026-09-01-a", "state.json"), JSON.stringify({ id: "2026-09-01-a", stage: "plan", awaiting: null }));
337
380
  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
381
  writeInbox(dir, [{ status: "open", text: "test" }, { status: "open", text: "add retry" }]);
339
382
  expect(boardCards([dir], Date.now()).filter((k) => k.kind === "idea").map((k) => k.text)).toEqual(["test"]);
@@ -986,7 +1029,7 @@ test("moveIdea carries notes and linked attachments, leaves the old inbox withou
986
1029
  expect(moveIdea(a, b, "nope")).toBe("no such idea");
987
1030
  expect(moveIdea(a, b, "go")).toBeNull();
988
1031
  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)" }]);
1032
+ expect(readInbox(b)).toMatchObject([{ status: "open", text: "go", notes: "see [shot](attachments/x-shot.png)" }]);
990
1033
  expect(readFileSync(join(b, "attachments", "x-shot.png"), "utf8")).toBe("png");
991
1034
  writeInbox(a, [{ status: "open", text: "go" }]);
992
1035
  expect(moveIdea(a, b, "go")).toBe("that project already has this idea");
package/cli/state.ts CHANGED
@@ -676,7 +676,8 @@ export function columnOf(stage: string, awaiting: string | null = null) {
676
676
 
677
677
  // notes: free text under the bullet -- links, path:line refs, background. Kept as
678
678
  // indented continuation lines so a one-line inbox from before notes reads unchanged.
679
- export type InboxLine = { status: string; text: string; notes?: string };
679
+ // `added` is when the line first appeared (ms), kept as a hidden comment on its bullet.
680
+ export type InboxLine = { status: string; text: string; notes?: string; added?: number };
680
681
 
681
682
  // Ideas with no run yet. Deliberately not backlog.md, which the spec defines as
682
683
  // an orphanage for items rescued from pruned runs, and not a run stub each.
@@ -686,7 +687,11 @@ export function readInbox(state: string): InboxLine[] {
686
687
  const out: InboxLine[] = [];
687
688
  for (const raw of readText(f).split("\n")) {
688
689
  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; }
690
+ if (m) {
691
+ const a = /\s*<!-- added:(\d+) -->$/.exec(m[2]!);
692
+ out.push(a ? { status: m[1]!, text: m[2]!.slice(0, a.index).trim(), added: +a[1]! } : { status: m[1]!, text: m[2]!.trim() });
693
+ continue;
694
+ }
690
695
  // An indented line belongs to the bullet above it; anything else is noise.
691
696
  const last = out[out.length - 1];
692
697
  if (last && /^\s{2,}\S/.test(raw)) last.notes = (last.notes ? last.notes + "\n" : "") + raw.trim();
@@ -699,12 +704,24 @@ export function readInbox(state: string): InboxLine[] {
699
704
  // needs a lock or an append-only journal.
700
705
  export function writeInbox(state: string, lines: InboxLine[]) {
701
706
  mkdirSync(state, { recursive: true });
707
+ // A line already in the file keeps its stamp; a text the file never had is new now.
708
+ // Lines from before stamps existed stay unstamped rather than looking new.
709
+ // Stamps queue per text and each is used once: re-adding words the file already
710
+ // holds (a done or rejected line, or an open duplicate) is a new idea, not the old
711
+ // one's stamp, which an older run with those words would retire.
712
+ const had = new Map<string, (number | undefined)[]>();
713
+ for (const l of readInbox(state)) had.set(l.text, [...(had.get(l.text) ?? []), l.added]);
714
+ const stamp = (l: InboxLine) => {
715
+ if (l.added) return l.added;
716
+ const q = had.get(l.text);
717
+ return q?.length ? q.shift() : Date.now();
718
+ };
702
719
  const body = [
703
720
  "# Inbox",
704
721
  "",
705
722
  "Ideas with no run yet. One line each, same format as a run's backlog.",
706
723
  "",
707
- ...lines.flatMap((l) => [`- ${l.status}: ${l.text}`, ...noteLines(l.notes)]),
724
+ ...lines.flatMap((l) => { const a = stamp(l); return [`- ${l.status}: ${l.text}${a ? ` <!-- added:${a} -->` : ""}`, ...noteLines(l.notes)]; }),
708
725
  ];
709
726
  writeFileSync(join(state, "inbox.md"), body.join("\n") + "\n");
710
727
  }
@@ -961,15 +978,20 @@ export function boardCards(states: string[], now: number): Card[] {
961
978
  if (!bodies.has(f)) { try { bodies.set(f, readText(f)); } catch { bodies.set(f, ""); } }
962
979
  return bodies.get(f)!;
963
980
  };
964
- const requests: string[] = [];
981
+ // Each request with when it was written, so a run older than an idea cannot retire it.
982
+ const requests: { at: number; text: string }[] = [];
965
983
  // Each request line on its own, quote and list markers stripped: what an idea
966
984
  // nobody launched from the board is matched against.
967
- const lines: string[] = [];
985
+ const lines: { at: number; text: string }[] = [];
968
986
  for (const st of [...states, ...knownProjects().map((d) => join(d, ".kaizen")), join(home, ".kaizen")]
969
987
  .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, "")));
988
+ // A run with no state.json yet has no card either: retiring the idea against it
989
+ // leaves the board showing neither until the agent writes one.
990
+ for (const id of readdirSync(join(st, "runs"))) { const b = body(st, id); if (!b || !existsSync(join(st, "runs", id, "state.json"))) continue;
991
+ let at = 0;
992
+ try { at = statSync(join(st, "runs", id, "00-request.md")).mtimeMs; } catch {}
993
+ requests.push({ at, text: normalise(b) });
994
+ for (const l of b.split("\n")) lines.push({ at, text: normalise(l.replace(/^[\s>*"'`-]+|[\s"'`.]+$/g, "")) });
973
995
  }
974
996
  }
975
997
  for (const st of states) {
@@ -986,7 +1008,11 @@ export function boardCards(states: string[], now: number): Card[] {
986
1008
  // prefix): a short idea like "test" is a substring of half the requests
987
1009
  // ever written, and would vanish the moment it was added.
988
1010
  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;
1011
+ // A run written before the idea existed is not this idea's run: an old request
1012
+ // titled "fix this bugs" must not swallow a new idea with the same words.
1013
+ // ponytail: 2s slack for coarse filesystem clocks.
1014
+ const after = (r: { at: number }) => !it.added || r.at >= it.added - 2000;
1015
+ 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
1016
  next.push(it.status === "started"
991
1017
  ? { 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
1018
  : { 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 +1418,7 @@ export function trustFolder(dir: string, cfg = join(process.env.CLAUDE_CONFIG_DI
1392
1418
 
1393
1419
  // run: the run a decision is for. Without it, an idea starting a new run.
1394
1420
  // 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),
1421
+ // tool and model, and stays in Yolo when the run was started in it. card: run it inside the board (agent.launch: card),
1396
1422
  // where a pseudo-terminal exists; onChange is told when that agent ends.
1397
1423
  const CHILD_ENV = /^(CLAUDECODE|CLAUDE_PID|CLAUDE_CODE_(SESSION_ID|CHILD_SESSION|SESSION_ATTENDED|MESSAGING_SOCKET|MESSAGING_TOKEN))$/;
1398
1424
 
@@ -1413,7 +1439,10 @@ export function launchRun(it: Item, from: string, run?: string, pick: { agent?:
1413
1439
  const wanted = pick.model ?? (own?.cmd === agent.cmd ? own.model : null) ?? undefined;
1414
1440
  const model = wanted && plainModel(wanted) ? wanted : undefined;
1415
1441
  // 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];
1442
+ // A decision on a run started in Yolo keeps it, or its next stage stops on every command.
1443
+ const st = join(projectDir, ".kaizen");
1444
+ const wasYolo = !!run && !pick.agent && readSessions(st).some((s) => s.yolo && s.cmd === agent.cmd && ownerOf(st, s) === run);
1445
+ const yolo = (wasYolo || (!!pick.yolo && agent.cmd === pick.agent)) && !!YOLO_FLAGS[agent.cmd];
1417
1446
  const flags = [...agentFlags(projectDir, agent.cmd, undefined, model), ...(yolo ? YOLO_FLAGS[agent.cmd] : [])];
1418
1447
  // The idea as its run's request will hold it: without the runner word in front.
1419
1448
  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.0",
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": {
@@ -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.
@@ -84,7 +84,9 @@ function card(c) {
84
84
  marks.push(`<span class="mk warn" title="Started in Yolo: the agent runs commands without asking">yolo</span>`);
85
85
  else chips.push(`<span class="chip">${esc(NAMES[t] || t)}</span>`);
86
86
  }
87
- if (c.pty && c.pty.alive)
87
+ if (c.pty && c.pty.alive && c.pty.asking)
88
+ marks.push(mk("i-ask", "The agent is asking you something: open it, terminal button at the top", " warn ask"));
89
+ else if (c.pty && c.pty.alive)
88
90
  marks.push(mk("i-term", "The agent is running in this card: open it, terminal button at the top", " run"));
89
91
  // A gathered idea's notes open with the stamp promote() writes (ingest() wrote it before
90
92
  // the icebox); its chip names the source.