kaizen-agent 1.43.0 → 1.43.2

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.
@@ -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.
package/cli/board.test.ts CHANGED
@@ -186,10 +186,11 @@ test("card menu items: appropriate actions and distinct icons across phases", ()
186
186
  // Phase 1: Planning (run waiting for plan approval)
187
187
  const planWaiting = { kind: "run", status: "waiting", awaiting: "approvals.plan", column: 1 };
188
188
  const planItems = getItems(planWaiting, false);
189
- 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"]);
190
190
  expect(planItems[0][2]).toBe(ico("open"));
191
191
  expect(planItems[1][2]).toBe(ico("archive"));
192
- expect(planItems[3][2]).toBe(ico("x"));
192
+ expect(planItems[2][2]).toBe(ico("restore"));
193
+ expect(planItems[4][2]).toBe(ico("x"));
193
194
 
194
195
  // Phase 2: Building (running run)
195
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
  }
package/cli/pty.test.ts CHANGED
@@ -186,6 +186,16 @@ test.skipIf(process.platform === "win32")("the done sweep keeps a session the us
186
186
  await Bun.sleep(200);
187
187
  expect(alive(used)).toBe(true);
188
188
  process.kill(used, "SIGTERM");
189
+
190
+ // Only looked at: the page's focus and query answers reach the tty but are not typing.
191
+ done(Date.now() - 60_000);
192
+ const looked = launch("looked");
193
+ await Bun.sleep(200);
194
+ writePty("looked", "\x1b[I\x1b[O\x1b[?0u\x1b[<64;10;5M\x1bP>|xterm.js(6.0)\x1b\\");
195
+ await Bun.sleep(200);
196
+ expect(ptyState("looked")!.typed).toBe(0);
197
+ sweepSessions(st, Date.now() + CLOSE_GRACE_MS);
198
+ expect(await gone(looked)).toBe(true);
189
199
  });
190
200
 
191
201
  test("asking: Claude Code and Codex prompts count as a question, ordinary output does not", async () => {
package/cli/pty.ts CHANGED
@@ -11,7 +11,7 @@ const WIN = process.platform === "win32";
11
11
  // Windows PowerShell, which every Windows has, both hosts the console and runs the agent in it.
12
12
  const winPs = () => join(process.env.SystemRoot ?? "C:\\Windows", "System32", "WindowsPowerShell", "v1.0", "powershell.exe");
13
13
 
14
- 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 };
14
+ 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; typed: number; onChange?: () => void };
15
15
 
16
16
  // What a page attaching late gets replayed. A full-screen tool redraws often, so the
17
17
  // tail of its output is enough to rebuild the screen.
@@ -38,7 +38,7 @@ export const ptySupported = WIN || typeof (Bun as any).Terminal === "function";
38
38
  // onChange: called when the agent ends or starts or stops asking, so the board can redraw.
39
39
  export function startPty(key: string, cmd: string[], cwd: string, pidFile?: string, onChange?: () => void): number {
40
40
  const dec = new TextDecoder();
41
- 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 };
41
+ const p: Pty = { proc: null as unknown as Bun.Subprocess, out: [], size: 0, listeners: new Set(), exit: null, asking: false, total: 0, since: 0, typed: 0, onChange };
42
42
  const flag = (v: boolean) => { if (p.asking !== v) { p.asking = v; onChange?.(); } };
43
43
  const push = (d: string) => {
44
44
  p.out.push(d);
@@ -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;
@@ -98,11 +99,19 @@ export function attachPty(key: string, fn: (d: string | null) => void): (() => v
98
99
  return () => p.listeners.delete(fn);
99
100
  }
100
101
 
102
+ // What the page's terminal sends by itself: focus in/out (Claude Code turns focus
103
+ // reporting on), mouse and wheel reports, and answers to the agent's queries (cursor,
104
+ // device, mode, colour, version). None of it is the user typing.
105
+ const REPORT = /\x1b\[[IO]|\x1b\[<[\d;]*[Mm]|\x1b\[\?[\d;]*(?:[cnu]|\$y)|\x1b\[>[\d;]*c|\x1b\[\d+;\d+R|\x1b\[\d*n|\x1b\][^\x07\x1b]*(?:\x07|\x1b\\)|\x1bP[^\x1b]*\x1b\\/g;
106
+
101
107
  export function writePty(key: string, d: string) {
102
108
  const p = ptys.get(key);
103
109
  if (!p || p.exit !== null) return;
104
- p.since = p.total;
105
- if (p.asking) { p.asking = false; p.onChange?.(); }
110
+ if (d.replace(REPORT, "")) {
111
+ p.typed = Date.now();
112
+ p.since = p.total;
113
+ if (p.asking) { p.asking = false; p.onChange?.(); }
114
+ }
106
115
  send(p, d);
107
116
  }
108
117
 
@@ -121,7 +130,7 @@ export const ptyKeys = () => [...ptys.keys()];
121
130
 
122
131
  export const ptyState = (key: string) => {
123
132
  const p = ptys.get(key);
124
- return p ? { alive: p.exit === null, exit: p.exit, asking: p.asking } : null;
133
+ return p ? { alive: p.exit === null, exit: p.exit, asking: p.asking, typed: p.typed } : null;
125
134
  };
126
135
 
127
136
  // The environment the user's login shell builds: PATH and anything exported in their
@@ -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"
package/cli/state.test.ts CHANGED
@@ -7,7 +7,7 @@ import { tmpdir } from "node:os";
7
7
  import { issueUrl, systemInfo, scrubHome, REPO_URL, home as realHome, trustFolder } from "./state.ts";
8
8
  import * as stateModule from "./state.ts";
9
9
  import {
10
- readRuns, readInbox, writeInbox, replaceIdea, moveIdea, abandonRun, allBacklog, backlog,
10
+ readRuns, readInbox, writeInbox, replaceIdea, moveIdea, abandonRun, runToIdea, allBacklog, backlog,
11
11
  requestTitle, statusOf, columnOf, parseItem, startedRuns, boardCards, parentOf, addCard, readArchive, setArchived,
12
12
  requestOf, agentFlags, plainModel, readNotes, writeNotes, manualCommand, parseNotes, formatNote, appendNote, saveAttachment,
13
13
  reviewFindings, pickFindings, fixPrompt, approvalPrompt, sessionLaunch, closeSessions, sweepSessions, readSessions, ownerOf, CLOSE_GRACE_MS, gitStatus, runGit, cardsGit, commitPush, readCommit, findTerminal, nativePath, pruneRuns, unrechecked, notice, notifier, notifyArgs, LOGO, type Card,
@@ -176,6 +176,30 @@ test("backlog: an abandoned run's items are neither listed nor counted", () => {
176
176
  rmSync(dir, { recursive: true });
177
177
  });
178
178
 
179
+ test("runToIdea: a planning run goes back to its idea, newer than the run", () => {
180
+ const id = "2026-09-04-planning", dir = join(state, "runs", id);
181
+ mkdirSync(dir, { recursive: true });
182
+ writeFileSync(join(dir, "state.json"), JSON.stringify({ id, stage: "plan", awaiting: "approvals.plan" }));
183
+ writeFileSync(join(dir, "00-request.md"), "/kaizen lite export invoices as csv\n");
184
+ const then = new Date(Date.now() - 30_000);
185
+ utimesSync(join(dir, "00-request.md"), then, then);
186
+ writeInbox(state, [...readInbox(state), { status: "started", text: "export invoices", added: Date.now() - 60_000 }, { status: "started", text: "export invoices as csv", notes: "see #12", added: Date.now() - 60_000 }]);
187
+ expect(runToIdea(state, id)).toBeNull();
188
+ expect(JSON.parse(readFileSync(join(dir, "state.json"), "utf8")).stage).toBe("abandoned");
189
+ const cards = boardCards([state], Date.now());
190
+ expect(cards.find((c) => c.kind === "idea" && c.text === "export invoices as csv")).toMatchObject({ status: "idea", notes: "see #12" });
191
+ expect(cards.find((c) => c.kind === "run" && c.id === id)?.archived).toBe(true);
192
+ expect(readInbox(state).filter((l) => l.text === "export invoices as csv").length).toBe(1);
193
+ expect(readInbox(state).find((l) => l.text === "export invoices")?.status).toBe("started");
194
+ expect(runToIdea(state, id)).toMatch(/planning/);
195
+ writeFileSync(join(dir, "state.json"), JSON.stringify({ id, stage: "plan" }));
196
+ rmSync(join(dir, "00-request.md"));
197
+ expect(runToIdea(state, id)).toMatch(/no request/);
198
+ replaceIdea(state, "export invoices", null);
199
+ setArchived(state, id, false);
200
+ rmSync(dir, { recursive: true });
201
+ });
202
+
179
203
  test("abandonRun refuses a done run", () => {
180
204
  expect(abandonRun(state, "2026-09-02-done", "late")).toMatch(/already done/);
181
205
  });
package/cli/state.ts CHANGED
@@ -5,7 +5,7 @@ import { existsSync, readFileSync, readdirSync, appendFileSync, writeFileSync, m
5
5
  import { Database } from "bun:sqlite";
6
6
  import { join, dirname, win32, posix } from "node:path";
7
7
  import { homedir, arch, cpus, release, totalmem, version as osVersion } from "node:os";
8
- import { startPty, ptySupported } from "./pty.ts";
8
+ import { startPty, ptySupported, ptyState } from "./pty.ts";
9
9
  import { codeDir, projectFromWorktree } from "./worktree.ts";
10
10
 
11
11
  // The real path: process.cwd() is always physical, so a raw $HOME that crosses a
@@ -1015,6 +1015,32 @@ export function abandonRun(st: string, id: string, why: string): string | null {
1015
1015
  }
1016
1016
  }
1017
1017
 
1018
+ // A run still planning, back to the idea it came from: abandoned and archived (the
1019
+ // record stays), and the idea open again. Its fresh stamp is newer than the run's
1020
+ // request, so the old run no longer retires it.
1021
+ export function runToIdea(st: string, id: string): string | null {
1022
+ let stage = "";
1023
+ try { stage = JSON.parse(readText(join(st, "runs", id, "state.json"))).stage; } catch {}
1024
+ if (stage !== "plan") return "Only a run still in planning goes back to an idea.";
1025
+ let req = "";
1026
+ try { req = readText(join(st, "runs", id, "00-request.md")).trim(); } catch {}
1027
+ const lines = readInbox(st);
1028
+ // The longest started idea the request holds: "test" must not stand in for "test login".
1029
+ let at = -1;
1030
+ lines.forEach((l, i) => { if (l.status === "started" && normalise(req).includes(normalise(l.text)) && (at < 0 || l.text.length > lines[at]!.text.length)) at = i; });
1031
+ const [first = "", ...rest] = req.split("\n");
1032
+ const idea: InboxLine = at >= 0
1033
+ ? { ...lines[at]!, status: "open", added: Date.now() }
1034
+ : { status: "open", text: first.replace(/^\/kaizen(?:-\w+)?\s+(?:(?:lite|full|auto|plan)\s+)?/i, "").trim(), notes: rest.join("\n").trim() || undefined, added: Date.now() };
1035
+ if (!idea.text) return "That run has no request to make an idea of.";
1036
+ const failed = abandonRun(st, id, "Sent back to ideas from the board.");
1037
+ if (failed) return failed;
1038
+ setArchived(st, id, true);
1039
+ if (at >= 0) lines.splice(at, 1);
1040
+ writeInbox(st, [...lines, idea]);
1041
+ return null;
1042
+ }
1043
+
1018
1044
  export function replaceIdea(state: string, text: string, next: InboxLine | null) {
1019
1045
  const lines = readInbox(state);
1020
1046
  const at = lines.findIndex((l) => (l.status === "open" || l.status === "started") && l.text === text);
@@ -1317,7 +1343,7 @@ function opencodeRows(sql: string, ...args: (string | number)[]): string[] {
1317
1343
  } catch { return []; }
1318
1344
  }
1319
1345
 
1320
- export const CLOSE_GRACE_MS = 2 * 60_000;
1346
+ export const CLOSE_GRACE_MS = 60_000;
1321
1347
  const sessionsDir = (st: string) => join(st, "sessions");
1322
1348
 
1323
1349
  export function readSessions(st: string): Session[] {
@@ -1483,7 +1509,11 @@ export function sweepSessions(st: string, now: number) {
1483
1509
  // Whether the user typed into the session's terminal after `t`: its tty's access time
1484
1510
  // moves when input is read. Such a session is the user's now, not the run's to close.
1485
1511
  // No tty (Windows, a stranger's PID): false, so the sweep closes as before.
1512
+ // A board card's session is judged by its keystrokes: the page's terminal answers the
1513
+ // agent's focus and query reports on that tty too, which moved its time on every look.
1486
1514
  function typedSince(st: string, s: Session, t: number): boolean {
1515
+ const card = ptyState(s.key);
1516
+ if (card) return card.typed > t;
1487
1517
  try {
1488
1518
  if (process.platform === "win32") return false;
1489
1519
  const pid = parseInt(readText(join(sessionsDir(st), s.key + ".pid")));
package/cli/web.ts CHANGED
@@ -4,7 +4,7 @@ import { existsSync, readdirSync, statSync, watch, appendFileSync, mkdirSync, cp
4
4
  import { join, dirname, resolve, sep, basename } from "node:path";
5
5
  import {
6
6
  home, type Item, type Card, boardCards, knownProjects, remember, locate, label, tilde, searchRoots, findProjects,
7
- readInbox, writeInbox, replaceIdea, moveIdea, abandonRun, launchRun, parseItem, agentChoices, projectOf, KNOWN_AGENTS, plainModel, short, setArchived, writeNotes,
7
+ readInbox, writeInbox, replaceIdea, moveIdea, abandonRun, runToIdea, launchRun, parseItem, agentChoices, projectOf, KNOWN_AGENTS, plainModel, short, setArchived, writeNotes,
8
8
  appendNote, saveAttachment, pidOnPort, readText, cmdExe, sysExe, powershellExe, runGit, cardsGit, readCommit, commitPush, notice, notifier, LOGO,
9
9
  reviewFindings, pickFindings, backlogCards, backlogItem, startedBacklog, closeBacklog, markBacklogDone, fixPrompt, approvalPrompt, canApproveAwaiting, nativePath, closeSessions, sweepSessions, issueUrl,
10
10
  launchMode, readSessions, ownerOf, runIndex, resumePrompt, runAgent, firstSession, webHosts,
@@ -209,6 +209,8 @@ export async function web(repoDir: string, opts: Opts = {}) {
209
209
  const key = c.kind === "run" ? runs.get(`${c.state}\0${c.id}`) : ideaPty.get(`${c.state}\0${c.text}`);
210
210
  if (!key || !live.has(key)) return undefined;
211
211
  const st = ptyState(key)!;
212
+ // A finished run's agent that has ended has nothing left to show or resume.
213
+ if (!st.alive && (c.status === "done" || c.status === "abandoned")) return undefined;
212
214
  return { key, alive: st.alive, asking: st.asking };
213
215
  };
214
216
  };
@@ -1140,6 +1142,14 @@ export async function web(repoDir: string, opts: Opts = {}) {
1140
1142
  : { ok: false, why: r.why, manual: r.manual, agent: r.agent }, r.ok ? 200 : 500);
1141
1143
  }
1142
1144
 
1145
+ if (path === "/to-idea") {
1146
+ const id = String(body.id ?? "");
1147
+ if (!/^[\w.-]+$/.test(id)) return bad("bad run id");
1148
+ const failed = runToIdea(dir, id);
1149
+ changed();
1150
+ return failed ? bad(failed, 409) : json({ ok: true });
1151
+ }
1152
+
1143
1153
  if (path === "/abort") {
1144
1154
  const id = String(body.id ?? "");
1145
1155
  const why = String(body.why ?? "").trim() || "abandoned from the web board";
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "kaizen-agent",
3
- "version": "1.43.0",
3
+ "version": "1.43.2",
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": {
@@ -207,11 +207,7 @@ the defaults.
207
207
  | `auto_fix.max_iterations` | `2` | Fix/recheck rounds before whatever is left is escalated to you |
208
208
  | `review.memory_max_lines` | `100` | The cap on `memory.md`, which every stage of every run reads. Raise only if the lessons are genuinely all load-bearing |
209
209
  | `git.auto_commit` | `false` | `true` commits the files a run changed when it is done, by path. Never pushes |
210
- | `track.default` | `auto` (inferred) | A repo that is always one kind of work, e.g. a docs repo |
211
210
  | `backlog.enabled` | `true` | Set `false` to skip both backlog capture points; the run still executes, just without a `06-backlog.md` |
212
- | `backlog.file` | `06-backlog.md` | The per-run file name. Rarely. |
213
- | `backlog.statuses` | `[open, done, rejected]` | Rarely. Only `open` items are printed |
214
- | `backlog.orphan_file` | `backlog.md` | Where open items go when their run is pruned |
215
211
  | `state.keep_runs` | `20` | You want a longer or shorter run history |
216
212
 
217
213
  Mode never overrides a refusal: destructive or irreversible actions still get confirmed
@@ -276,7 +276,10 @@ After a subagent returns, update `state.json` yourself in the main thread. Subag
276
276
  report; the main thread owns the state machine.
277
277
  Before moving any run from `build` to `review`, under either runner, apply the
278
278
  *Preview gate* in [`spec-main.md`](spec-main.md): no `## Preview` with a screenshot
279
- (or a one-line reason there is none) means the build is not done.
279
+ (or a one-line reason there is none) means the build is not done. Before the final
280
+ report and before `stage: "done"`, under either runner and in every mode, apply the
281
+ *Docs gate* there too: the project's docs vault is brought up to date with what the
282
+ run changed.
280
283
 
281
284
  **Name files, do not narrate them.** The prompt says which paths to read, which
282
285
  decisions bind the stage, and what to report back. It does not summarize those files: