kaizen-agent 1.43.0 → 1.43.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -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.ts CHANGED
@@ -81,7 +81,8 @@ export function startPty(key: string, cmd: string[], cwd: string, pidFile?: stri
81
81
  for (const f of p.listeners) f(null);
82
82
  p.listeners.clear();
83
83
  try { p.proc.terminal?.close(); } catch { /* already closed */ }
84
- setTimeout(() => { if (ptys.get(key) === p) ptys.delete(key); }, KEEP_MS).unref();
84
+ // Dropped: pages showing it (the Terminal page) refresh, or its tile lingers until some other change.
85
+ setTimeout(() => { if (ptys.get(key) === p) { ptys.delete(key); onChange?.(); } }, KEEP_MS).unref();
85
86
  onChange?.();
86
87
  });
87
88
  return p.proc.pid;
@@ -6,6 +6,7 @@ import { test, expect, beforeAll, afterAll } from "bun:test";
6
6
  import { mkdtempSync, mkdirSync, writeFileSync, readFileSync, copyFileSync, rmSync } from "node:fs";
7
7
  import { join, dirname } from "node:path";
8
8
  import { tmpdir } from "node:os";
9
+ import { applyPreset, detectPreset } from "./settings.ts";
9
10
 
10
11
  const repo = join(dirname(import.meta.path), "..");
11
12
  const defaults = join(repo, "skills/kaizen/config.default.yml");
@@ -48,3 +49,40 @@ test("board settings save to the global config, project settings to the project"
48
49
  expect(inProject(`return setSetting(repo, "git.auto_commit", "true")`)).toEqual({ ok: true });
49
50
  expect(readFileSync(join(project, ".kaizen", "config.yml"), "utf8")).toMatch(/auto_commit: true/);
50
51
  });
52
+
53
+ test("every row is a key in config.default.yml, and every `when` names a row and one of its values", () => {
54
+ const rows = inProject(`return listSettings(repo).rows`);
55
+ const text = readFileSync(defaults, "utf8");
56
+ for (const r of rows) {
57
+ const [top, leaf] = r.key.split(".");
58
+ const block = leaf ? new RegExp(`^${top}:\\n(?:(?: .*|\\s*)\\n)*? ${leaf}:`, "m") : new RegExp(`^${top}:`, "m");
59
+ expect(block.test(text) ? r.key : `${r.key} missing`).toBe(r.key);
60
+ if (!r.when) continue;
61
+ const dep = rows.find((x) => x.key === r.when[0]);
62
+ expect(dep?.values).toContain(r.when[1]);
63
+ }
64
+ });
65
+
66
+ test("a row dims while the setting it depends on has it switched off, and a leftover dead key changes nothing", () => {
67
+ const conf = join(project, ".kaizen", "config.yml");
68
+ // The low preset as an older config holds it, with keys kaizen no longer reads.
69
+ writeFileSync(conf, readFileSync(defaults, "utf8")
70
+ .replace("enabled: true\n # critical", "enabled: false\n # critical")
71
+ .replace("git:\n", "git:\n branch_before_implement: false\n")
72
+ + "\ntrack:\n default: software\n");
73
+ const idle = (k: string) => inProject(`return listSettings(repo).rows.find((r) => r.key === ${JSON.stringify(k)}).idle`);
74
+ expect(idle("auto_fix.min_severity")).toBe("No effect unless Fix findings automatically is on");
75
+ expect(idle("auto_fix.enabled")).toBe("");
76
+ expect(inProject(`return setSetting(repo, "auto_fix.max_iterations", "3")`)).toEqual({ ok: true });
77
+ expect(inProject(`return setSetting(repo, "auto_fix.enabled", "true")`)).toEqual({ ok: true });
78
+ expect(idle("auto_fix.min_severity")).toBe("");
79
+ expect(inProject(`return value("auto_fix.max_iterations")`)).toBe("3");
80
+ copyFileSync(defaults, conf);
81
+ });
82
+
83
+ test("a config still holding git.branch_before_implement keeps its preset", () => {
84
+ const text = readFileSync(defaults, "utf8");
85
+ const low = applyPreset(text, "low", text).replace("git:\n", "git:\n branch_before_implement: true\n");
86
+ expect(detectPreset(low, text)).toBe("low");
87
+ expect(detectPreset(text, text)).toBe("medium");
88
+ });
package/cli/settings.ts CHANGED
@@ -6,7 +6,10 @@ import { homedir } from "node:os";
6
6
  import { readText } from "./state.ts";
7
7
 
8
8
  // `text` is a value typed rather than chosen: a path. It has no values to cycle.
9
- type Setting = { key: string; label: string; group: string; values: string[]; help: string; text?: boolean; folder?: boolean };
9
+ // `when`: the row only has an effect while that other key holds that value; both
10
+ // settings screens dim it otherwise, and it stays editable (`/kaizen full` overrides
11
+ // the runner for one run, so a subagent model still matters under `runner: lite`).
12
+ type Setting = { key: string; label: string; group: string; values: string[]; help: string; text?: boolean; folder?: boolean; when?: [string, string] };
10
13
 
11
14
  export type PresetName = "low" | "medium" | "ultra";
12
15
 
@@ -29,7 +32,6 @@ export const PRESETS: Record<PresetName, {
29
32
  "auto_fix.min_severity": "high",
30
33
  "auto_fix.max_iterations": "1",
31
34
  "git.auto_commit": "false",
32
- "git.branch_before_implement": "false",
33
35
  "git.worktree_per_run": "false",
34
36
  },
35
37
  },
@@ -47,7 +49,6 @@ export const PRESETS: Record<PresetName, {
47
49
  "auto_fix.min_severity": "high",
48
50
  "auto_fix.max_iterations": "2",
49
51
  "git.auto_commit": "false",
50
- "git.branch_before_implement": "true",
51
52
  "git.worktree_per_run": "true",
52
53
  },
53
54
  },
@@ -65,7 +66,6 @@ export const PRESETS: Record<PresetName, {
65
66
  "auto_fix.min_severity": "low",
66
67
  "auto_fix.max_iterations": "4",
67
68
  "git.auto_commit": "true",
68
- "git.branch_before_implement": "true",
69
69
  "git.worktree_per_run": "true",
70
70
  },
71
71
  },
@@ -99,31 +99,29 @@ export function applyPreset(text: string, preset: PresetName, defaults: string):
99
99
 
100
100
  const SETTINGS: Setting[] = [
101
101
  { key: "preset", label: "Preset", group: "Preset", values: ["low", "medium", "ultra", "custom"], help: "" },
102
- { key: "mode", label: "Stop for approval", group: "Approvals", values: ["approve", "auto", "plan-only", "review-only"], help: "" },
103
- { key: "approvals.plan", label: "Approve the plan first", group: "Approvals", values: ["true", "false"], help: "" },
104
- { key: "approvals.review", label: "Approve after review", group: "Approvals", values: ["true", "false"], help: "" },
105
- { key: "approvals.each_file", label: "Confirm every file edit", group: "Approvals", values: ["true", "false"], help: "" },
102
+ { key: "mode", label: "Run mode", group: "Approvals", values: ["approve", "auto", "plan-only", "review-only"], help: "" },
103
+ { key: "approvals.plan", label: "Approve the plan first", group: "Approvals", values: ["true", "false"], help: "", when: ["mode", "approve"] },
104
+ { key: "approvals.review", label: "Approve after review", group: "Approvals", values: ["true", "false"], help: "", when: ["mode", "approve"] },
105
+ { key: "approvals.each_file", label: "Confirm every file edit", group: "Approvals", values: ["true", "false"], help: "", when: ["mode", "approve"] },
106
106
  { key: "runner", label: "Stage isolation", group: "Agents", values: ["full", "lite"], help: "full: separate agent per stage. lite: one session" },
107
- { key: "build.executor", label: "Who builds", group: "Agents", values: ["subagent", "inline", "ask"], help: "" },
107
+ { key: "build.executor", label: "Who builds", group: "Agents", values: ["subagent", "inline", "ask"], help: "subagent: its own agent · inline: this session · ask: pick at plan approval", when: ["runner", "full"] },
108
108
  { key: "agent.default", label: "Tool the board launches", group: "Agents", values: ["auto", "claude", "codex", "agy", "opencode", "gemini"], help: "" },
109
109
  { key: "agent.launch", label: "Where the board runs agents", group: "Agents", values: ["card", "terminal"], help: "" },
110
- { key: "subagents.model", label: "Model for stage agents", group: "Agents", values: ["inherit", "opus", "sonnet", "haiku"], help: "" },
111
- { key: "subagents.effort", label: "Effort for stage agents", group: "Agents", values: ["inherit", "low", "medium", "high"], help: "" },
110
+ { key: "subagents.model", label: "Model for subagents", group: "Agents", values: ["inherit", "opus", "sonnet", "haiku"], help: "Planner, builder, reviewer", when: ["runner", "full"] },
111
+ { key: "subagents.effort", label: "Effort for subagents", group: "Agents", values: ["inherit", "low", "medium", "high"], help: "Planner, builder, reviewer", when: ["runner", "full"] },
112
112
  { key: "auto_fix.enabled", label: "Fix findings automatically", group: "Auto-fix", values: ["true", "false"], help: "" },
113
- { key: "auto_fix.min_severity", label: "Auto-fix from severity", group: "Auto-fix", values: ["critical", "high", "medium", "low"], help: "" },
114
- { key: "auto_fix.max_iterations", label: "Max fix rounds", group: "Auto-fix", values: ["1", "2", "3", "4", "5"], help: "" },
113
+ { key: "auto_fix.min_severity", label: "Lowest severity to fix", group: "Auto-fix", values: ["critical", "high", "medium", "low"], help: "", when: ["auto_fix.enabled", "true"] },
114
+ { key: "auto_fix.max_iterations", label: "Max fix rounds", group: "Auto-fix", values: ["1", "2", "3", "4", "5"], help: "", when: ["auto_fix.enabled", "true"] },
115
115
  { key: "git.auto_commit", label: "Commit when done", group: "Git", values: ["false", "true"], help: "Never pushes" },
116
- { key: "git.branch_before_implement", label: "Branch before building", group: "Git", values: ["true", "false"], help: "" },
117
116
  { key: "git.worktree_per_run", label: "Branch and folder per run", group: "Git", values: ["true", "false"], help: "" },
118
- { key: "track.default", label: "Kind of work", group: "Project", values: ["auto", "software", "writing", "communication", "operations", "research"], help: "" },
119
117
  { key: "review.write_memory", label: "Remember lessons", group: "Project", values: ["true", "false"], help: "" },
120
118
  { key: "review.memory_max_lines", label: "Memory size cap", group: "Project", values: ["50", "100", "200", "400"], help: "" },
121
119
  { key: "state.keep_runs", label: "Runs to keep", group: "Project", values: ["5", "10", "20", "50"], help: "" },
122
120
  { key: "dictation.model", label: "Dictation model", group: "Board", values: ["tiny", "base", "small", "large-v3-turbo"], help: "" },
123
121
  { key: "sources.every", label: "Gather requests", group: "Board", values: ["off", "1h", "6h", "24h"], help: "" },
124
- { key: "sources.agent", label: "Who reads social sources", group: "Board", values: ["ask", "chatgpt", "agent"], help: "" },
125
- { key: "sources.model", label: "ChatGPT model for gathering", group: "Board", values: [], text: true, help: "Empty: plan default" },
126
- { key: "sources.effort", label: "ChatGPT effort for gathering", group: "Board", values: ["low", "medium", "high"], help: "" },
122
+ { key: "sources.agent", label: "Who picks social posts", group: "Board", values: ["ask", "chatgpt", "agent"], help: "X, TikTok, Instagram, Threads" },
123
+ { key: "sources.model", label: "ChatGPT model for gathering", group: "Board", values: [], text: true, help: "Empty: plan default", when: ["sources.agent", "chatgpt"] },
124
+ { key: "sources.effort", label: "ChatGPT effort for gathering", group: "Board", values: ["low", "medium", "high"], help: "", when: ["sources.agent", "chatgpt"] },
127
125
  { key: "board.projects_dir", label: "Projects folder", group: "Board", values: [], text: true, folder: true, help: "Empty: known projects only" },
128
126
  { key: "board.open", label: "Open the board in", group: "Board", values: ["browser", "app"], help: "" },
129
127
  { key: "ui.mouse", label: "Enable mouse", group: "Terminal", values: ["false", "true"], help: "" },
@@ -137,13 +135,18 @@ export function listSettings(repoRoot: string) {
137
135
  const load = (f: string) => existsSync(f) ? readText(f) : "";
138
136
  const text = load(file), board = load(globalFile());
139
137
  const preset = detectPreset(text, defaults);
140
- return {
141
- file: tilde(file),
142
- rows: SETTINGS.map((s) => ({
143
- ...s,
144
- value: s.key === "preset" ? preset : (read(s.group === "Board" ? board : text, s.key) || read(defaults, s.key)) ?? s.values[0] ?? "",
145
- })),
146
- };
138
+ const rows = SETTINGS.map((s) => ({
139
+ ...s,
140
+ value: s.key === "preset" ? preset : (read(s.group === "Board" ? board : text, s.key) || read(defaults, s.key)) ?? s.values[0] ?? "",
141
+ }));
142
+ const valueOf = (k: string) => rows.find((r) => r.key === k)?.value ?? null;
143
+ return { file: tilde(file), rows: rows.map((r) => ({ ...r, idle: idle(r, valueOf) })) };
144
+ }
145
+
146
+ // Why a row has no effect right now, or "" while it has one: its `when` is not met.
147
+ function idle(s: Setting, valueOf: (key: string) => string | null) {
148
+ if (!s.when || valueOf(s.when[0]) === s.when[1]) return "";
149
+ return `No effect unless ${SETTINGS.find((x) => x.key === s.when![0])!.label} is ${s.when[1] === "true" ? "on" : s.when[1]}`;
147
150
  }
148
151
 
149
152
  export function setSetting(repoRoot: string, key: string, value: string) {
@@ -217,13 +220,17 @@ export async function settings(repoRoot: string, standalone = true) {
217
220
  }
218
221
  return v;
219
222
  };
223
+ const valueOf = (k: string) => {
224
+ const d = SETTINGS.find((x) => x.key === k)!;
225
+ return read(fileFor(d) === file ? text : readText(fileFor(d)), k) || read(defaults, k);
226
+ };
220
227
  const rows = SETTINGS.map((s) => ({
221
228
  s,
222
- // Older configs spell "infer the track" as an empty string; show what it means.
229
+ idle: idle(s, valueOf),
223
230
  // The preset as a game's quick preset: arrows either side, the tier lit after it. No colour codes: the selected row wraps this in inverse, which a reset would end.
224
231
  value: s.key === "preset"
225
232
  ? `◁ ${currentPreset.padEnd(6)} ▷ ${(["low", "medium", "ultra"] as const).map((p) => p === currentPreset ? "━" : "─").join("")}`
226
- : show(s, (read(fileFor(s) === file ? text : readText(fileFor(s)), s.key) || (s.key === "track.default" ? "auto" : undefined)) ?? c.dim("(default)")),
233
+ : show(s, read(fileFor(s) === file ? text : readText(fileFor(s)), s.key) ?? c.dim("(default)")),
227
234
  }));
228
235
  const width = Math.max(...SETTINGS.map((s) => s.label.length)) + 4;
229
236
 
@@ -234,7 +241,7 @@ export async function settings(repoRoot: string, standalone = true) {
234
241
  rowAt = [];
235
242
  let line = 2, group = "";
236
243
  const list = shown();
237
- for (const [i, { s, value }] of rows.entries()) {
244
+ for (const [i, { s, value, idle: off }] of rows.entries()) {
238
245
  if (!list.includes(i)) continue;
239
246
  if (s.group !== group) {
240
247
  group = s.group;
@@ -245,14 +252,14 @@ export async function settings(repoRoot: string, standalone = true) {
245
252
  const name = s.label.padEnd(width);
246
253
  stdout.write(on
247
254
  ? ` ${c.cyan("›")} ${c.bold(name)}${c.inv(` ${value} `)}\n`
248
- : ` ${c.dim(name)}${value}\n`);
255
+ : ` ${c.dim(name)}${off ? c.dim(value) : value}\n`);
249
256
  rowAt[line++] = i;
250
257
  }
251
258
  const cur = SETTINGS[active]!;
252
259
  if (!list.length) stdout.write(`\n ${c.dim(`no setting matches "${query}"`)}\n\n`);
253
260
  else {
254
261
  stdout.write(`\n ${c.dim(cur.key)} ${c.dim("options:")} ${cur.values.map((v) => v || '""').join(c.dim(" / "))}\n`);
255
- stdout.write(` ${cur.help}\n`);
262
+ stdout.write(` ${[cur.help, rows[active]!.idle].filter(Boolean).join(" · ")}\n`);
256
263
  }
257
264
  stdout.write(`\n ${c.dim(typing
258
265
  ? "type to filter · enter or ↑↓ keep it · esc clear"
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
@@ -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);
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,
@@ -1140,6 +1140,14 @@ export async function web(repoDir: string, opts: Opts = {}) {
1140
1140
  : { ok: false, why: r.why, manual: r.manual, agent: r.agent }, r.ok ? 200 : 500);
1141
1141
  }
1142
1142
 
1143
+ if (path === "/to-idea") {
1144
+ const id = String(body.id ?? "");
1145
+ if (!/^[\w.-]+$/.test(id)) return bad("bad run id");
1146
+ const failed = runToIdea(dir, id);
1147
+ changed();
1148
+ return failed ? bad(failed, 409) : json({ ok: true });
1149
+ }
1150
+
1143
1151
  if (path === "/abort") {
1144
1152
  const id = String(body.id ?? "");
1145
1153
  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.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": {
@@ -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:
@@ -58,45 +58,7 @@ auto_fix:
58
58
  # knob in the file. A third round rarely closes what two could not.
59
59
  max_iterations: 2
60
60
 
61
- track:
62
- # The kind of work a run is doing, which decides what "deliverable",
63
- # "verification", and "finding" mean for every stage. It is INFERRED from the
64
- # request by the planner and stated back in the plan's Framing for the user to
65
- # correct at the approval — the user is never asked to categorize their own request.
66
- # Set this only to pin a default for a repo that is always one kind of work
67
- # (e.g. a docs repo). auto (or empty) infers per run.
68
- default: auto
69
-
70
61
  review:
71
- # Checks that run in every track.
72
- checks:
73
- - correctness
74
- - plan_conformance
75
- # Additional checks per track, chosen by what the run actually is. The planner may
76
- # name a track not listed here; the reviewer then derives its checks from the
77
- # track's "what would make it wrong".
78
- # Each check below is one question the reviewer asks; the line beside it says which.
79
- track_checks:
80
- # security - no new way in, no secret or user data exposed
81
- # reuse_simplification - nothing rebuilt that the codebase already had
82
- # test_coverage - the changed behaviour has a check that fails when it breaks
83
- software: [security, reuse_simplification, test_coverage]
84
- # blast_radius and regression are universal — see spec-review.md — not listed here.
85
- # completeness - every part the piece promises is actually present
86
- # claim_accuracy - every checkable claim traced to a source
87
- # reader_usability - its intended reader can act on it without asking
88
- writing: [completeness, claim_accuracy, reader_usability]
89
- # audience_fit - written for the people who will actually read it
90
- # voice_consistency - reads as the same voice as the surrounding material
91
- communication: [claim_accuracy, audience_fit, voice_consistency]
92
- # prerequisites - everything needed before step one is stated
93
- # rollback - a way back exists for every step that changes something
94
- # executability - someone else can run it start to finish unaided
95
- operations: [prerequisites, rollback, executability]
96
- # evidence_support - the conclusion follows from evidence shown, not asserted
97
- # selection_bias - the evidence was not picked to fit the answer
98
- # alternatives - the readings that were rejected are named and why
99
- research: [evidence_support, selection_bias, alternatives]
100
62
  # Let the reviewer append durable lessons to .kaizen/memory.md.
101
63
  write_memory: true
102
64
  # Every stage of every future run reads memory.md, so cap it. Over this, the reviewer
@@ -108,20 +70,12 @@ backlog:
108
70
  # not take, findings below the fix threshold, and debts deferred at an approval.
109
71
  # Written by the main thread at the plan approval and again at the final approval.
110
72
  enabled: true
111
- # Per run, beside its plan and review — provenance without a central file to sync.
112
- file: 06-backlog.md
113
- # Items are open | done | rejected. Only open ones are printed by /kaizen backlog;
114
- # closed ones stay in their run's file as record.
115
- statuses: [open, done, rejected]
116
- # Before state.keep_runs prunes a run, its still-open items move here, relative to
117
- # state.dir. Done and rejected items are pruned with the run.
118
- orphan_file: backlog.md
119
73
 
120
74
  state:
121
75
  dir: .kaizen
122
76
  # Keep this many completed runs before pruning the oldest. `kaizen prune` lists
123
77
  # what would go and `kaizen prune --yes` deletes it, after rescuing open backlog
124
- # items into backlog.orphan_file, so it never destroys open work.
78
+ # items into backlog.md beside memory.md, so it never destroys open work.
125
79
  keep_runs: 20
126
80
 
127
81
  git:
@@ -130,9 +84,6 @@ git:
130
84
  # pushes. false: never commit, push, or open a PR unless the user explicitly asks.
131
85
  # Ignored when the project is not a git repository.
132
86
  auto_commit: false
133
- # Create a branch before the implement stage when on the default branch.
134
- # Ignored when the project is not a git repository.
135
- branch_before_implement: true
136
87
  # Build each run on its own branch (kaizen/<run-id>) in its own git worktree under
137
88
  # .kaizen/wt/, so runs and people working at the same time never share a folder. Done
138
89
  # lands the branch by pull request (gh) or a rebase and push. Ignored when the
@@ -1,8 +1,9 @@
1
1
  # Kaizen — docs
2
2
 
3
3
  Read for `/kaizen docs` and `/kaizen docs lint`. Nothing else needs this file, except
4
- the two paragraphs in `spec-build.md` and `spec-review.md` that keep the vault current
5
- during a run.
4
+ the paragraphs in `spec-build.md` and `spec-review.md` that keep the vault current
5
+ during a run, and the *Docs gate* in `spec-main.md`, which reads *Update* step 4 and
6
+ *Lint* before a run is done.
6
7
 
7
8
  `/kaizen docs` writes a project's documentation into the notebook as a small vault an
8
9
  agent reads before it plans or builds: an **Index** listing every note with one line on
@@ -23,17 +23,24 @@ nearest first, so a change lands globally unless this project keeps its own conf
23
23
  | `auto_fix.max_iterations` | `1`–`5` | Fix and recheck rounds before what is left is escalated |
24
24
  | `review.write_memory` | `true`, `false` | Let the reviewer append lessons to `memory.md` |
25
25
  | `git.auto_commit` | `true`, `false` | Commit the work when a run finishes |
26
- | `git.branch_before_implement` | `true`, `false` | Branch before building when on the default branch |
27
26
  | `git.worktree_per_run` | `true`, `false` | Build each run on its own branch in its own worktree and land it by pull request |
28
27
  | `ui.mouse` | `false`, `true` | Click to select, click again to act; while on, the terminal cannot select text |
29
28
  | `subagents.model` | `inherit`, `opus`, `sonnet`, `haiku` | Model the stage subagents run on (Claude Code only) |
30
29
  | `subagents.effort` | `inherit`, `low`, `medium`, `high` | Reasoning effort for the stage subagents (Claude Code only) |
31
30
  | `sources.every` | `off`, `1h`, `6h`, `24h` | How often the web board checks request sources for new requests, into the icebox, while it runs |
32
- | `sources.agent` | `ask`, `chatgpt`, `agent` | Who reads social sources when Gather is pressed: the ChatGPT plan signed in from the Icebox, or your agent on `/kaizen gather` |
31
+ | `sources.agent` | `ask`, `chatgpt`, `agent` | Who picks the posts that fit from social sources when Gather is pressed: the ChatGPT plan signed in from the Icebox, or your agent on `/kaizen gather` |
33
32
  | `sources.model` | text | Model the ChatGPT plan gathers with; empty takes the first it lists |
34
33
  | `sources.effort` | `low`, `medium`, `high` | Reasoning effort of that model while gathering |
35
34
  | `agent.default` | `auto`, `claude`, `codex`, `agy`, `opencode`, `gemini` | Coding agent to launch from dashboard backlog |
36
35
 
36
+ Some settings only have an effect while another stands a certain way: the approvals
37
+ while `mode` is `approve`, `build.executor` and `subagents.*` while `runner` is `full`,
38
+ `auto_fix.min_severity` and `auto_fix.max_iterations` while `auto_fix.enabled` is
39
+ `true`, `sources.model` and `sources.effort` while `sources.agent` is `chatgpt`. When
40
+ printing one of these that has no effect now, say so beside its value. Still offer it:
41
+ a value set now applies once the other setting changes, and `/kaizen full` overrides
42
+ the runner for one run.
43
+
37
44
  Rules for writing the file:
38
45
 
39
46
  - **Change the value in place.** `config.yml` ships with a comment above every key
@@ -46,7 +53,7 @@ Rules for writing the file:
46
53
  - After writing, print the setting and its new value, and nothing else. No summary of
47
54
  the whole file.
48
55
 
49
- Settings not in the table — the review checks, backlog and state paths, and the per-tool
56
+ Settings not in the table — such as `state.dir` and the per-tool
50
57
  `agent.<cmd>.model` / `agent.<cmd>.effort` the board launches a session with — are edited by
51
58
  hand in `config.yml`, which is commented throughout. Say so rather than offering a
52
59
  picker with thirty options.
@@ -86,8 +86,9 @@ the note, correct what the change made untrue or left out — never the `## Note
86
86
  section, which is the user's — and set its `updated` to today. Refresh the note's line
87
87
  in `Index.md` if its `description` changed, and append one line to `Log.md`:
88
88
  `- YYYY-MM-DD run <id> — <notes touched>`. Name the notes touched in `03-impl.md`. A
89
- change no note covers is not written up here: say "suggest `/kaizen docs`" in
90
- `03-impl.md` and leave the vault alone. No Index, nothing to do.
89
+ change no note covers is not written up here: list it in `03-impl.md` as "no note
90
+ covers `<path>`" — the main thread's *Docs gate* in `spec-main.md` documents it before
91
+ the run is done. No Index, nothing to do.
91
92
 
92
93
  **Read the plan's File manifest instead of searching.** The planner already located
93
94
  every path this run touches. Search the material only for what the manifest does not