kaizen-agent 1.7.2 → 1.7.4

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/settings.ts CHANGED
@@ -1,415 +1,416 @@
1
- // Full-screen settings browser for .kaizen/config.yml.
2
- // Arrow keys move and change; every change is written straight to the file.
3
- import { existsSync, readFileSync, writeFileSync, copyFileSync, mkdirSync } from "node:fs";
4
- import { join, dirname } from "node:path";
5
- import { homedir } from "node:os";
6
-
7
- type Setting = { key: string; label: string; group: string; values: string[]; help: string };
8
-
9
- export type PresetName = "low" | "medium" | "ultra";
10
-
11
- export const PRESETS: Record<PresetName, {
12
- label: string;
13
- hint: string;
14
- settings: Record<string, string>;
15
- }> = {
16
- low: {
17
- label: "Low",
18
- hint: "inline & manual — lite runner, inline builder, manual approvals, no auto-fix",
19
- settings: {
20
- mode: "approve",
21
- runner: "lite",
22
- "build.executor": "inline",
23
- "approvals.plan": "true",
24
- "approvals.review": "true",
25
- "approvals.each_file": "false",
26
- "auto_fix.enabled": "false",
27
- "auto_fix.min_severity": "high",
28
- "auto_fix.max_iterations": "1",
29
- "git.auto_commit": "false",
30
- "git.branch_before_implement": "false",
31
- },
32
- },
33
- medium: {
34
- label: "Medium",
35
- hint: "balanced (recommended) — full runner, subagents, plan approval, auto-fix high/critical",
36
- settings: {
37
- mode: "approve",
38
- runner: "full",
39
- "build.executor": "subagent",
40
- "approvals.plan": "true",
41
- "approvals.review": "false",
42
- "approvals.each_file": "false",
43
- "auto_fix.enabled": "true",
44
- "auto_fix.min_severity": "high",
45
- "auto_fix.max_iterations": "2",
46
- "git.auto_commit": "false",
47
- "git.branch_before_implement": "true",
48
- },
49
- },
50
- ultra: {
51
- label: "Ultra",
52
- hint: "autonomous — full runner, subagents, auto mode, auto-fix all findings (up to 4 rounds)",
53
- settings: {
54
- mode: "auto",
55
- runner: "full",
56
- "build.executor": "subagent",
57
- "approvals.plan": "false",
58
- "approvals.review": "false",
59
- "approvals.each_file": "false",
60
- "auto_fix.enabled": "true",
61
- "auto_fix.min_severity": "low",
62
- "auto_fix.max_iterations": "4",
63
- "git.auto_commit": "true",
64
- "git.branch_before_implement": "true",
65
- },
66
- },
67
- };
68
-
69
- export function detectPreset(text: string, defaults?: string): PresetName | "custom" {
70
- for (const name of ["low", "medium", "ultra"] as const) {
71
- const p = PRESETS[name];
72
- let match = true;
73
- for (const [k, expected] of Object.entries(p.settings)) {
74
- const actual = read(text, k) ?? (defaults ? read(defaults, k) : null);
75
- if (actual !== expected) {
76
- match = false;
77
- break;
78
- }
79
- }
80
- if (match) return name;
81
- }
82
- return "custom";
83
- }
84
-
85
- export function applyPreset(text: string, preset: PresetName, defaults: string): string {
86
- let updated = text;
87
- const p = PRESETS[preset];
88
- for (const [k, v] of Object.entries(p.settings)) {
89
- updated = write(updated, k, v, defaults);
90
- }
91
- updated = write(updated, "preset", preset, defaults);
92
- return updated;
93
- }
94
-
95
- const SETTINGS: Setting[] = [
96
- { 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" },
97
- { key: "mode", label: "Stop for approval", group: "Run", 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" },
98
- { key: "git.auto_commit", label: "Commit when done", group: "Run", values: ["false", "true"], help: "Commit the work when a run finishes. Never pushes" },
99
- { key: "state.keep_runs", label: "Runs to keep", group: "Run", values: ["5", "10", "20", "50"], help: "Finished runs kept before the oldest is pruned. Open backlog items are rescued first" },
100
- { key: "ui.mouse", label: "Enable mouse", group: "Run", values: ["false", "true"], help: "Click to select, click again to act. While on, the terminal cannot select text with the mouse" },
101
- { key: "approvals.plan", label: "Approve the plan first", group: "Planning", values: ["true", "false"], help: "Show the plan and wait for your OK before anything is built" },
102
- { key: "track.default", label: "Kind of work", group: "Planning", 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" },
103
- { key: "build.executor", label: "Who builds", group: "Building", 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" },
104
- { key: "approvals.each_file", label: "Confirm every file edit", group: "Building", values: ["true", "false"], help: "Ask before each individual file change. Maximum control, slowest" },
105
- { key: "git.branch_before_implement", label: "Branch before building", group: "Building", values: ["true", "false"], help: "Create a branch before building when you are on the default branch" },
106
- { key: "approvals.review", label: "Approve after review", group: "Review", values: ["true", "false"], help: "After the review, wait for your OK before the run is called done" },
107
- { key: "auto_fix.enabled", label: "Fix findings automatically", group: "Review", values: ["true", "false"], help: "true: findings severe enough get fixed and re-reviewed without asking. false: every finding stops and asks you" },
108
- { key: "auto_fix.min_severity", label: "Auto-fix from severity", group: "Review", values: ["critical", "high", "medium", "low"], help: "The least severe finding still fixed automatically. low fixes everything; critical fixes almost nothing" },
109
- { key: "auto_fix.max_iterations", label: "Max fix rounds", group: "Review", 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" },
110
- { key: "review.write_memory", label: "Remember lessons", group: "Review", values: ["true", "false"], help: "Let the reviewer save what it learned to .kaizen/memory.md, which every future run reads" },
111
- { key: "review.memory_max_lines", label: "Memory size cap", group: "Review", values: ["50", "100", "200", "400"], help: "Lines memory.md may hold before old lessons are pruned. Every run reads it, so bigger costs more" },
112
- { 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" },
113
- { 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" },
114
- { 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" },
115
- { 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" },
116
- ];
117
-
118
- // The same rows and the same write the TUI uses, for `kaizen web`'s settings panel:
119
- // one place decides what a setting is called, what it may be, and how it is saved.
120
- export function listSettings(repoRoot: string) {
121
- const file = locate();
122
- const defaults = readFileSync(join(repoRoot, "skills/kaizen/config.default.yml"), "utf8");
123
- const text = existsSync(file) ? readFileSync(file, "utf8") : "";
124
- const preset = detectPreset(text, defaults);
125
- return {
126
- file: tilde(file),
127
- rows: SETTINGS.map((s) => ({
128
- ...s,
129
- value: s.key === "preset" ? preset : (read(text, s.key) || read(defaults, s.key)) ?? s.values[0]!,
130
- })),
131
- };
132
- }
133
-
134
- export function setSetting(repoRoot: string, key: string, value: string) {
135
- const s = SETTINGS.find((x) => x.key === key);
136
- if (!s || !s.values.includes(value)) return { error: "unknown setting or value" };
137
- const file = locate();
138
- const defaultsPath = join(repoRoot, "skills/kaizen/config.default.yml");
139
- if (!existsSync(file)) { mkdirSync(dirname(file), { recursive: true }); copyFileSync(defaultsPath, file); }
140
- const defaults = readFileSync(defaultsPath, "utf8");
141
- const text = readFileSync(file, "utf8");
142
- let updated: string;
143
- if (key === "preset") {
144
- if (value === "custom") return { error: "custom is what any other change makes" };
145
- updated = applyPreset(text, value as PresetName, defaults);
146
- } else {
147
- updated = write(text, key, value, defaults);
148
- updated = write(updated, "preset", detectPreset(updated, defaults), defaults);
149
- if (read(updated, key) !== value) return { error: `${key} did not save; edit ${tilde(file)} by hand` };
150
- }
151
- writeFileSync(file, updated);
152
- return { ok: true };
153
- }
154
-
155
- const c = {
156
- dim: (s: string) => `\x1b[2m${s}\x1b[0m`,
157
- bold: (s: string) => `\x1b[1m${s}\x1b[0m`,
158
- cyan: (s: string) => `\x1b[36m${s}\x1b[0m`,
159
- green: (s: string) => `\x1b[32m${s}\x1b[0m`,
160
- inv: (s: string) => `\x1b[7m${s}\x1b[0m`,
161
- };
162
-
163
- // Registered once at module scope, not per call: opening settings, going back and
164
- // opening it again would otherwise stack a listener each time, and Node starts
165
- // printing warnings into the alternate screen at eleven.
166
- let mouseArmed = false;
167
- process.on("exit", () => { if (mouseArmed) process.stdout.write("\x1b[?1006l\x1b[?1000l"); });
168
-
169
- export async function settings(repoRoot: string, standalone = true) {
170
- const file = locate();
171
- if (!existsSync(file)) {
172
- mkdirSync(dirname(file), { recursive: true });
173
- copyFileSync(join(repoRoot, "skills/kaizen/config.default.yml"), file);
174
- }
175
-
176
- const { stdin, stdout } = process;
177
- if (!stdin.isTTY) { console.log(`Settings live in ${file}`); return; }
178
-
179
- const defaults = readFileSync(join(repoRoot, "skills/kaizen/config.default.yml"), "utf8");
180
- let active = 0, saved = "";
181
- // Screen line -> setting index, rebuilt on every draw; the mouse clicks by line.
182
- let rowAt: number[] = [];
183
- const draw = () => {
184
- const text = readFileSync(file, "utf8");
185
- const currentPreset = detectPreset(text, defaults);
186
- // Booleans as a checkbox, numeric scales as a level bar; the file still holds the words.
187
- const show = (s: Setting, v: string) => {
188
- if (s.values.length === 2 && s.values.includes("true")) return v === "true" ? "[✓] on" : "[ ] off";
189
- if (s.values.every((x) => /^\d+$/.test(x))) {
190
- const at = s.values.indexOf(v);
191
- return at < 0 ? v : "▮".repeat(at + 1) + "▯".repeat(s.values.length - at - 1) + " " + v;
192
- }
193
- return v;
194
- };
195
- const rows = SETTINGS.map((s) => ({
196
- s,
197
- // Older configs spell "infer the track" as an empty string; show what it means.
198
- value: s.key === "preset" ? currentPreset : show(s, (read(text, s.key) || (s.key === "track.default" ? "auto" : undefined)) ?? c.dim("(default)")),
199
- }));
200
- const width = Math.max(...SETTINGS.map((s) => s.label.length)) + 4;
201
-
202
- stdout.write("\x1b[H\x1b[2J"); // home, clear
203
- stdout.write(`\n ${c.bold("kaizen settings")} ${c.dim(tilde(file))}\n`);
204
- rowAt = [];
205
- let line = 2, group = "";
206
- for (const [i, { s, value }] of rows.entries()) {
207
- if (s.group !== group) {
208
- group = s.group;
209
- stdout.write(`\n ${c.dim(group.toUpperCase())}\n`);
210
- line += 2;
211
- }
212
- const on = i === active;
213
- const name = s.label.padEnd(width);
214
- stdout.write(on
215
- ? ` ${c.cyan("›")} ${c.bold(name)}${c.inv(` ${value} `)}\n`
216
- : ` ${c.dim(name)}${value}\n`);
217
- rowAt[line++] = i;
218
- }
219
- const cur = SETTINGS[active]!;
220
- stdout.write(`\n ${c.dim(cur.key)} ${c.dim("options:")} ${cur.values.map((v) => v || '""').join(c.dim(" / "))}\n`);
221
- stdout.write(` ${cur.help}\n`);
222
- stdout.write(`\n ${c.dim("↑↓ pick a setting · ←→ or enter to change it (saved instantly) · esc back")} ${saved}\n`);
223
- };
224
-
225
- stdout.write("\x1b[?1049h\x1b[?25l"); // alternate screen, hide cursor
226
- const mouse = /^\s*ui:\s*\n(?:\s*#.*\n)*\s*mouse:\s*(\S+)/m.exec(readFileSync(file, "utf8"));
227
- const mouseOn = mouse?.[1] === "true";
228
- if (mouseOn) { stdout.write("\x1b[?1000h\x1b[?1006h"); mouseArmed = true; }
229
- stdin.setRawMode(true);
230
- stdin.resume();
231
- draw();
232
-
233
- function cycle(step: number) {
234
- const s = SETTINGS[active]!;
235
- const wasMouse = s.key === "ui.mouse";
236
- const text = readFileSync(file, "utf8");
237
- if (s.key === "preset") {
238
- const current = detectPreset(text, defaults);
239
- const presets: PresetName[] = ["low", "medium", "ultra"];
240
- const at = presets.indexOf(current as PresetName);
241
- const next = presets[((at < 0 ? 0 : at) + step + presets.length) % presets.length]!;
242
- const updated = applyPreset(text, next, defaults);
243
- writeFileSync(file, updated);
244
- saved = c.green(`applied preset: ${next}`);
245
- } else {
246
- const now = read(text, s.key) ?? s.values[0]!;
247
- const at = s.values.indexOf(now);
248
- const next = s.values[((at < 0 ? 0 : at) + step + s.values.length) % s.values.length]!;
249
- let updated = write(text, s.key, next, defaults);
250
- const detected = detectPreset(updated, defaults);
251
- updated = write(updated, "preset", detected, defaults);
252
- writeFileSync(file, updated);
253
- // Only claim a save that happened. Reporting one that did not is worse
254
- // than failing loudly: the setting reads back unchanged and nobody knows why.
255
- saved = read(updated, s.key) === next
256
- ? c.green(`saved ${s.key} = ${next || '""'} (preset: ${detected})`)
257
- : `\x1b[33mcould not write ${s.key}\x1b[0m`;
258
- // Applied here rather than at the next launch: a toggle that needs a restart
259
- // reads as a toggle that does not work.
260
- if (wasMouse) {
261
- if (next === "true" && !mouseArmed) { stdout.write("\x1b[?1000h\x1b[?1006h"); mouseArmed = true; }
262
- if (next === "false" && mouseArmed) { stdout.write("\x1b[?1006l\x1b[?1000l"); mouseArmed = false; }
263
- }
264
- }
265
- }
266
-
267
- await new Promise<void>((resolve) => {
268
- const onData = (chunk: Buffer) => {
269
- const keys = chunk.toString();
270
- for (let i = 0; i < keys.length; i++) {
271
- const rest = keys.slice(i);
272
- const m = /^\x1b\[<(\d+);(\d+);(\d+)([Mm])/.exec(rest);
273
- if (m) {
274
- i += m[0].length - 1;
275
- const button = +m[1]!, y = +m[3]!, press = m[4] === "M";
276
- if (button === 64) active = (active - 1 + SETTINGS.length) % SETTINGS.length;
277
- else if (button === 65) active = (active + 1) % SETTINGS.length;
278
- else if (press && button === 0) {
279
- const row = rowAt[y - 1];
280
- if (row === undefined) continue;
281
- // Click to select; click the selected row to cycle it, which is what
282
- // the right arrow already does.
283
- if (row === active) { cycle(1); continue; }
284
- active = row;
285
- } else continue;
286
- draw();
287
- continue;
288
- }
289
- // esc, q, backspace, and left-at-the-edge all mean "back to where I came
290
- // from" -- the settings screen is always something you opened from
291
- // somewhere else.
292
- if (rest.startsWith("\x03") || rest.startsWith("q") || rest === "\x1b"
293
- || rest.startsWith("\x7f") || rest.startsWith("\b")) {
294
- stdin.off("data", onData); return resolve();
295
- }
296
- if (rest.startsWith("\x1b[A")) { active = (active - 1 + SETTINGS.length) % SETTINGS.length; i += 2; }
297
- else if (rest.startsWith("\x1b[B")) { active = (active + 1) % SETTINGS.length; i += 2; }
298
- else if (rest.startsWith("\x1b[C") || rest.startsWith("\x1b[D") || rest.startsWith("\r")) {
299
- cycle(rest.startsWith("\x1b[D") ? -1 : 1);
300
- if (!rest.startsWith("\r")) i += 2;
301
- } else if (rest.startsWith("k")) active = (active - 1 + SETTINGS.length) % SETTINGS.length;
302
- else if (rest.startsWith("j")) active = (active + 1) % SETTINGS.length;
303
- else continue;
304
- draw();
305
- }
306
- };
307
- stdin.on("data", onData);
308
- });
309
-
310
- stdin.setRawMode(false);
311
- stdin.pause();
312
- if (mouseArmed) { stdout.write("\x1b[?1006l\x1b[?1000l"); mouseArmed = false; }
313
- stdout.write("\x1b[?25h\x1b[?1049l"); // restore cursor and screen
314
- // Returning to the dashboard means returning to its alternate screen, where a
315
- // line printed here would never be seen.
316
- if (standalone) console.log(` ${c.green("+")} settings saved to ${tilde(file)}\n`);
317
- }
318
-
319
- // The nearest .kaizen/config.yml wins, exactly as the workflow resolves it. A
320
- // project that has not written one of its own is edited globally, so one setting
321
- // changed in ~/.kaizen/config.yml reaches every project.
322
- function locate() {
323
- let dir = process.cwd();
324
- for (;;) {
325
- const conf = join(dir, ".kaizen", "config.yml");
326
- if (existsSync(conf)) return conf;
327
- if (existsSync(join(dir, ".git"))) break;
328
- const up = dirname(dir);
329
- if (up === dir) break;
330
- dir = up;
331
- }
332
- return join(homedir(), ".kaizen", "config.yml");
333
- }
334
-
335
- // Read and write a dotted key by walking indentation, so the comment above every
336
- // key survives. Parsing to an object and dumping it back would delete all of them.
337
- function lineOf(text: string, key: string) {
338
- const parts = key.split(".");
339
- const lines = text.split("\n");
340
- let depth = 0, from = 0;
341
- for (const [pi, part] of parts.entries()) {
342
- const want = new RegExp(`^ {${depth}}${part}:`);
343
- let found = -1;
344
- for (let i = from; i < lines.length; i++) {
345
- if (want.test(lines[i]!)) { found = i; break; }
346
- if (pi > 0 && /^\S/.test(lines[i]!) && i > from) break; // left the parent block
347
- }
348
- if (found < 0) return { lines, index: -1, indent: depth };
349
- from = found + 1; depth += 2;
350
- }
351
- return { lines, index: from - 1, indent: depth - 2 };
352
- }
353
-
354
- function read(text: string, key: string) {
355
- const { lines, index } = lineOf(text, key);
356
- if (index < 0) return null;
357
- const v = lines[index]!.split(":").slice(1).join(":").split("#")[0]!.trim();
358
- return v.replace(/^["']|["']$/g, "");
359
- }
360
-
361
- function write(text: string, key: string, value: string, defaults: string) {
362
- const name = key.split(".").pop()!;
363
- const shown = value === "" ? '""' : value;
364
- const { lines, index, indent } = lineOf(text, key);
365
-
366
- if (index >= 0) {
367
- lines[index] = `${" ".repeat(indent)}${name}: ${shown}`;
368
- return lines.join("\n");
369
- }
370
-
371
- // A config written before this key existed does not have a line to change. Take
372
- // the key from the defaults, comment and all, rather than silently doing nothing
373
- // and reporting a save: an older config is the normal case for anyone who has had
374
- // kaizen installed for a while.
375
- return insert(text, key, shown, defaults);
376
- }
377
-
378
- function insert(text: string, key: string, shown: string, defaults: string) {
379
- const parts = key.split(".");
380
- const name = parts[parts.length - 1]!;
381
- const indent = (parts.length - 1) * 2;
382
- const src = defaults.split("\n");
383
- const { index: at } = lineOf(defaults, key);
384
-
385
- // The comment block above the key in the defaults explains it; carry it across.
386
- const block: string[] = [];
387
- if (at >= 0) {
388
- let i = at - 1;
389
- while (i >= 0 && /^\s*#/.test(src[i]!)) { block.unshift(src[i]!); i--; }
390
- }
391
- block.push(`${" ".repeat(indent)}${name}: ${shown}`);
392
-
393
- const lines = text.split("\n");
394
- if (parts.length === 1) {
395
- while (lines.length && !lines[lines.length - 1]!.trim()) lines.pop();
396
- return [...lines, "", ...block, ""].join("\n");
397
- }
398
-
399
- // Nested: put it under its parent if the parent is there, else write the parent too.
400
- const parent = parts.slice(0, -1).join(".");
401
- const { index: pi } = lineOf(text, parent);
402
- if (pi < 0) {
403
- while (lines.length && !lines[lines.length - 1]!.trim()) lines.pop();
404
- return [...lines, "", `${parts[0]}:`, ...block, ""].join("\n");
405
- }
406
- let end = pi + 1;
407
- while (end < lines.length && (/^\s+\S/.test(lines[end]!) || !lines[end]!.trim())) end++;
408
- lines.splice(end, 0, ...block);
409
- return lines.join("\n");
410
- }
411
-
412
- function tilde(p: string) {
413
- const h = homedir();
414
- return p.startsWith(h) ? "~" + p.slice(h.length) : p;
415
- }
1
+ // Full-screen settings browser for .kaizen/config.yml.
2
+ // Arrow keys move and change; every change is written straight to the file.
3
+ import { existsSync, readFileSync, writeFileSync, copyFileSync, mkdirSync } from "node:fs";
4
+ import { join, dirname } from "node:path";
5
+ import { homedir } from "node:os";
6
+
7
+ type Setting = { key: string; label: string; group: string; values: string[]; help: string };
8
+
9
+ export type PresetName = "low" | "medium" | "ultra";
10
+
11
+ export const PRESETS: Record<PresetName, {
12
+ label: string;
13
+ hint: string;
14
+ settings: Record<string, string>;
15
+ }> = {
16
+ low: {
17
+ label: "Low",
18
+ hint: "inline & manual — lite runner, inline builder, manual approvals, no auto-fix",
19
+ settings: {
20
+ mode: "approve",
21
+ runner: "lite",
22
+ "build.executor": "inline",
23
+ "approvals.plan": "true",
24
+ "approvals.review": "true",
25
+ "approvals.each_file": "false",
26
+ "auto_fix.enabled": "false",
27
+ "auto_fix.min_severity": "high",
28
+ "auto_fix.max_iterations": "1",
29
+ "git.auto_commit": "false",
30
+ "git.branch_before_implement": "false",
31
+ },
32
+ },
33
+ medium: {
34
+ label: "Medium",
35
+ hint: "balanced (recommended) — full runner, subagents, plan approval, auto-fix high/critical",
36
+ settings: {
37
+ mode: "approve",
38
+ runner: "full",
39
+ "build.executor": "subagent",
40
+ "approvals.plan": "true",
41
+ "approvals.review": "false",
42
+ "approvals.each_file": "false",
43
+ "auto_fix.enabled": "true",
44
+ "auto_fix.min_severity": "high",
45
+ "auto_fix.max_iterations": "2",
46
+ "git.auto_commit": "false",
47
+ "git.branch_before_implement": "true",
48
+ },
49
+ },
50
+ ultra: {
51
+ label: "Ultra",
52
+ hint: "autonomous — full runner, subagents, auto mode, auto-fix all findings (up to 4 rounds)",
53
+ settings: {
54
+ mode: "auto",
55
+ runner: "full",
56
+ "build.executor": "subagent",
57
+ "approvals.plan": "false",
58
+ "approvals.review": "false",
59
+ "approvals.each_file": "false",
60
+ "auto_fix.enabled": "true",
61
+ "auto_fix.min_severity": "low",
62
+ "auto_fix.max_iterations": "4",
63
+ "git.auto_commit": "true",
64
+ "git.branch_before_implement": "true",
65
+ },
66
+ },
67
+ };
68
+
69
+ export function detectPreset(text: string, defaults?: string): PresetName | "custom" {
70
+ for (const name of ["low", "medium", "ultra"] as const) {
71
+ const p = PRESETS[name];
72
+ let match = true;
73
+ for (const [k, expected] of Object.entries(p.settings)) {
74
+ const actual = read(text, k) ?? (defaults ? read(defaults, k) : null);
75
+ if (actual !== expected) {
76
+ match = false;
77
+ break;
78
+ }
79
+ }
80
+ if (match) return name;
81
+ }
82
+ return "custom";
83
+ }
84
+
85
+ export function applyPreset(text: string, preset: PresetName, defaults: string): string {
86
+ let updated = text;
87
+ const p = PRESETS[preset];
88
+ for (const [k, v] of Object.entries(p.settings)) {
89
+ updated = write(updated, k, v, defaults);
90
+ }
91
+ updated = write(updated, "preset", preset, defaults);
92
+ return updated;
93
+ }
94
+
95
+ const SETTINGS: Setting[] = [
96
+ { 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" },
97
+ { key: "mode", label: "Stop for approval", group: "Run", 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" },
98
+ { key: "git.auto_commit", label: "Commit when done", group: "Run", values: ["false", "true"], help: "Commit the work when a run finishes. Never pushes" },
99
+ { key: "state.keep_runs", label: "Runs to keep", group: "Run", values: ["5", "10", "20", "50"], help: "Finished runs kept before the oldest is pruned. Open backlog items are rescued first" },
100
+ { key: "ui.mouse", label: "Enable mouse", group: "Run", values: ["false", "true"], help: "Click to select, click again to act. While on, the terminal cannot select text with the mouse" },
101
+ { key: "approvals.plan", label: "Approve the plan first", group: "Planning", values: ["true", "false"], help: "Show the plan and wait for your OK before anything is built" },
102
+ { key: "track.default", label: "Kind of work", group: "Planning", 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" },
103
+ { key: "build.executor", label: "Who builds", group: "Building", 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" },
104
+ { key: "approvals.each_file", label: "Confirm every file edit", group: "Building", values: ["true", "false"], help: "Ask before each individual file change. Maximum control, slowest" },
105
+ { key: "git.branch_before_implement", label: "Branch before building", group: "Building", values: ["true", "false"], help: "Create a branch before building when you are on the default branch" },
106
+ { key: "approvals.review", label: "Approve after review", group: "Review", values: ["true", "false"], help: "After the review, wait for your OK before the run is called done" },
107
+ { key: "auto_fix.enabled", label: "Fix findings automatically", group: "Review", values: ["true", "false"], help: "true: findings severe enough get fixed and re-reviewed without asking. false: every finding stops and asks you" },
108
+ { key: "auto_fix.min_severity", label: "Auto-fix from severity", group: "Review", values: ["critical", "high", "medium", "low"], help: "The least severe finding still fixed automatically. low fixes everything; critical fixes almost nothing" },
109
+ { key: "auto_fix.max_iterations", label: "Max fix rounds", group: "Review", 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" },
110
+ { key: "review.write_memory", label: "Remember lessons", group: "Review", values: ["true", "false"], help: "Let the reviewer save what it learned to .kaizen/memory.md, which every future run reads" },
111
+ { key: "review.memory_max_lines", label: "Memory size cap", group: "Review", values: ["50", "100", "200", "400"], help: "Lines memory.md may hold before old lessons are pruned. Every run reads it, so bigger costs more" },
112
+ { 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" },
113
+ { 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" },
114
+ { 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" },
115
+ { 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" },
116
+ { 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" },
117
+ ];
118
+
119
+ // The same rows and the same write the TUI uses, for `kaizen web`'s settings panel:
120
+ // one place decides what a setting is called, what it may be, and how it is saved.
121
+ export function listSettings(repoRoot: string) {
122
+ const file = locate();
123
+ const defaults = readFileSync(join(repoRoot, "skills/kaizen/config.default.yml"), "utf8");
124
+ const text = existsSync(file) ? readFileSync(file, "utf8") : "";
125
+ const preset = detectPreset(text, defaults);
126
+ return {
127
+ file: tilde(file),
128
+ rows: SETTINGS.map((s) => ({
129
+ ...s,
130
+ value: s.key === "preset" ? preset : (read(text, s.key) || read(defaults, s.key)) ?? s.values[0]!,
131
+ })),
132
+ };
133
+ }
134
+
135
+ export function setSetting(repoRoot: string, key: string, value: string) {
136
+ const s = SETTINGS.find((x) => x.key === key);
137
+ if (!s || !s.values.includes(value)) return { error: "unknown setting or value" };
138
+ const file = locate();
139
+ const defaultsPath = join(repoRoot, "skills/kaizen/config.default.yml");
140
+ if (!existsSync(file)) { mkdirSync(dirname(file), { recursive: true }); copyFileSync(defaultsPath, file); }
141
+ const defaults = readFileSync(defaultsPath, "utf8");
142
+ const text = readFileSync(file, "utf8");
143
+ let updated: string;
144
+ if (key === "preset") {
145
+ if (value === "custom") return { error: "custom is what any other change makes" };
146
+ updated = applyPreset(text, value as PresetName, defaults);
147
+ } else {
148
+ updated = write(text, key, value, defaults);
149
+ updated = write(updated, "preset", detectPreset(updated, defaults), defaults);
150
+ if (read(updated, key) !== value) return { error: `${key} did not save; edit ${tilde(file)} by hand` };
151
+ }
152
+ writeFileSync(file, updated);
153
+ return { ok: true };
154
+ }
155
+
156
+ const c = {
157
+ dim: (s: string) => `\x1b[2m${s}\x1b[0m`,
158
+ bold: (s: string) => `\x1b[1m${s}\x1b[0m`,
159
+ cyan: (s: string) => `\x1b[36m${s}\x1b[0m`,
160
+ green: (s: string) => `\x1b[32m${s}\x1b[0m`,
161
+ inv: (s: string) => `\x1b[7m${s}\x1b[0m`,
162
+ };
163
+
164
+ // Registered once at module scope, not per call: opening settings, going back and
165
+ // opening it again would otherwise stack a listener each time, and Node starts
166
+ // printing warnings into the alternate screen at eleven.
167
+ let mouseArmed = false;
168
+ process.on("exit", () => { if (mouseArmed) process.stdout.write("\x1b[?1006l\x1b[?1000l"); });
169
+
170
+ export async function settings(repoRoot: string, standalone = true) {
171
+ const file = locate();
172
+ if (!existsSync(file)) {
173
+ mkdirSync(dirname(file), { recursive: true });
174
+ copyFileSync(join(repoRoot, "skills/kaizen/config.default.yml"), file);
175
+ }
176
+
177
+ const { stdin, stdout } = process;
178
+ if (!stdin.isTTY) { console.log(`Settings live in ${file}`); return; }
179
+
180
+ const defaults = readFileSync(join(repoRoot, "skills/kaizen/config.default.yml"), "utf8");
181
+ let active = 0, saved = "";
182
+ // Screen line -> setting index, rebuilt on every draw; the mouse clicks by line.
183
+ let rowAt: number[] = [];
184
+ const draw = () => {
185
+ const text = readFileSync(file, "utf8");
186
+ const currentPreset = detectPreset(text, defaults);
187
+ // Booleans as a checkbox, numeric scales as a level bar; the file still holds the words.
188
+ const show = (s: Setting, v: string) => {
189
+ if (s.values.length === 2 && s.values.includes("true")) return v === "true" ? "[✓] on" : "[ ] off";
190
+ if (s.values.every((x) => /^\d+$/.test(x))) {
191
+ const at = s.values.indexOf(v);
192
+ return at < 0 ? v : "▮".repeat(at + 1) + "▯".repeat(s.values.length - at - 1) + " " + v;
193
+ }
194
+ return v;
195
+ };
196
+ const rows = SETTINGS.map((s) => ({
197
+ s,
198
+ // Older configs spell "infer the track" as an empty string; show what it means.
199
+ value: s.key === "preset" ? currentPreset : show(s, (read(text, s.key) || (s.key === "track.default" ? "auto" : undefined)) ?? c.dim("(default)")),
200
+ }));
201
+ const width = Math.max(...SETTINGS.map((s) => s.label.length)) + 4;
202
+
203
+ stdout.write("\x1b[H\x1b[2J"); // home, clear
204
+ stdout.write(`\n ${c.bold("kaizen settings")} ${c.dim(tilde(file))}\n`);
205
+ rowAt = [];
206
+ let line = 2, group = "";
207
+ for (const [i, { s, value }] of rows.entries()) {
208
+ if (s.group !== group) {
209
+ group = s.group;
210
+ stdout.write(`\n ${c.dim(group.toUpperCase())}\n`);
211
+ line += 2;
212
+ }
213
+ const on = i === active;
214
+ const name = s.label.padEnd(width);
215
+ stdout.write(on
216
+ ? ` ${c.cyan("›")} ${c.bold(name)}${c.inv(` ${value} `)}\n`
217
+ : ` ${c.dim(name)}${value}\n`);
218
+ rowAt[line++] = i;
219
+ }
220
+ const cur = SETTINGS[active]!;
221
+ stdout.write(`\n ${c.dim(cur.key)} ${c.dim("options:")} ${cur.values.map((v) => v || '""').join(c.dim(" / "))}\n`);
222
+ stdout.write(` ${cur.help}\n`);
223
+ stdout.write(`\n ${c.dim("↑↓ pick a setting · ←→ or enter to change it (saved instantly) · esc back")} ${saved}\n`);
224
+ };
225
+
226
+ stdout.write("\x1b[?1049h\x1b[?25l"); // alternate screen, hide cursor
227
+ const mouse = /^\s*ui:\s*\n(?:\s*#.*\n)*\s*mouse:\s*(\S+)/m.exec(readFileSync(file, "utf8"));
228
+ const mouseOn = mouse?.[1] === "true";
229
+ if (mouseOn) { stdout.write("\x1b[?1000h\x1b[?1006h"); mouseArmed = true; }
230
+ stdin.setRawMode(true);
231
+ stdin.resume();
232
+ draw();
233
+
234
+ function cycle(step: number) {
235
+ const s = SETTINGS[active]!;
236
+ const wasMouse = s.key === "ui.mouse";
237
+ const text = readFileSync(file, "utf8");
238
+ if (s.key === "preset") {
239
+ const current = detectPreset(text, defaults);
240
+ const presets: PresetName[] = ["low", "medium", "ultra"];
241
+ const at = presets.indexOf(current as PresetName);
242
+ const next = presets[((at < 0 ? 0 : at) + step + presets.length) % presets.length]!;
243
+ const updated = applyPreset(text, next, defaults);
244
+ writeFileSync(file, updated);
245
+ saved = c.green(`applied preset: ${next}`);
246
+ } else {
247
+ const now = read(text, s.key) ?? s.values[0]!;
248
+ const at = s.values.indexOf(now);
249
+ const next = s.values[((at < 0 ? 0 : at) + step + s.values.length) % s.values.length]!;
250
+ let updated = write(text, s.key, next, defaults);
251
+ const detected = detectPreset(updated, defaults);
252
+ updated = write(updated, "preset", detected, defaults);
253
+ writeFileSync(file, updated);
254
+ // Only claim a save that happened. Reporting one that did not is worse
255
+ // than failing loudly: the setting reads back unchanged and nobody knows why.
256
+ saved = read(updated, s.key) === next
257
+ ? c.green(`saved ${s.key} = ${next || '""'} (preset: ${detected})`)
258
+ : `\x1b[33mcould not write ${s.key}\x1b[0m`;
259
+ // Applied here rather than at the next launch: a toggle that needs a restart
260
+ // reads as a toggle that does not work.
261
+ if (wasMouse) {
262
+ if (next === "true" && !mouseArmed) { stdout.write("\x1b[?1000h\x1b[?1006h"); mouseArmed = true; }
263
+ if (next === "false" && mouseArmed) { stdout.write("\x1b[?1006l\x1b[?1000l"); mouseArmed = false; }
264
+ }
265
+ }
266
+ }
267
+
268
+ await new Promise<void>((resolve) => {
269
+ const onData = (chunk: Buffer) => {
270
+ const keys = chunk.toString();
271
+ for (let i = 0; i < keys.length; i++) {
272
+ const rest = keys.slice(i);
273
+ const m = /^\x1b\[<(\d+);(\d+);(\d+)([Mm])/.exec(rest);
274
+ if (m) {
275
+ i += m[0].length - 1;
276
+ const button = +m[1]!, y = +m[3]!, press = m[4] === "M";
277
+ if (button === 64) active = (active - 1 + SETTINGS.length) % SETTINGS.length;
278
+ else if (button === 65) active = (active + 1) % SETTINGS.length;
279
+ else if (press && button === 0) {
280
+ const row = rowAt[y - 1];
281
+ if (row === undefined) continue;
282
+ // Click to select; click the selected row to cycle it, which is what
283
+ // the right arrow already does.
284
+ if (row === active) { cycle(1); continue; }
285
+ active = row;
286
+ } else continue;
287
+ draw();
288
+ continue;
289
+ }
290
+ // esc, q, backspace, and left-at-the-edge all mean "back to where I came
291
+ // from" -- the settings screen is always something you opened from
292
+ // somewhere else.
293
+ if (rest.startsWith("\x03") || rest.startsWith("q") || rest === "\x1b"
294
+ || rest.startsWith("\x7f") || rest.startsWith("\b")) {
295
+ stdin.off("data", onData); return resolve();
296
+ }
297
+ if (rest.startsWith("\x1b[A")) { active = (active - 1 + SETTINGS.length) % SETTINGS.length; i += 2; }
298
+ else if (rest.startsWith("\x1b[B")) { active = (active + 1) % SETTINGS.length; i += 2; }
299
+ else if (rest.startsWith("\x1b[C") || rest.startsWith("\x1b[D") || rest.startsWith("\r")) {
300
+ cycle(rest.startsWith("\x1b[D") ? -1 : 1);
301
+ if (!rest.startsWith("\r")) i += 2;
302
+ } else if (rest.startsWith("k")) active = (active - 1 + SETTINGS.length) % SETTINGS.length;
303
+ else if (rest.startsWith("j")) active = (active + 1) % SETTINGS.length;
304
+ else continue;
305
+ draw();
306
+ }
307
+ };
308
+ stdin.on("data", onData);
309
+ });
310
+
311
+ stdin.setRawMode(false);
312
+ stdin.pause();
313
+ if (mouseArmed) { stdout.write("\x1b[?1006l\x1b[?1000l"); mouseArmed = false; }
314
+ stdout.write("\x1b[?25h\x1b[?1049l"); // restore cursor and screen
315
+ // Returning to the dashboard means returning to its alternate screen, where a
316
+ // line printed here would never be seen.
317
+ if (standalone) console.log(` ${c.green("+")} settings saved to ${tilde(file)}\n`);
318
+ }
319
+
320
+ // The nearest .kaizen/config.yml wins, exactly as the workflow resolves it. A
321
+ // project that has not written one of its own is edited globally, so one setting
322
+ // changed in ~/.kaizen/config.yml reaches every project.
323
+ function locate() {
324
+ let dir = process.cwd();
325
+ for (;;) {
326
+ const conf = join(dir, ".kaizen", "config.yml");
327
+ if (existsSync(conf)) return conf;
328
+ if (existsSync(join(dir, ".git"))) break;
329
+ const up = dirname(dir);
330
+ if (up === dir) break;
331
+ dir = up;
332
+ }
333
+ return join(homedir(), ".kaizen", "config.yml");
334
+ }
335
+
336
+ // Read and write a dotted key by walking indentation, so the comment above every
337
+ // key survives. Parsing to an object and dumping it back would delete all of them.
338
+ function lineOf(text: string, key: string) {
339
+ const parts = key.split(".");
340
+ const lines = text.split("\n");
341
+ let depth = 0, from = 0;
342
+ for (const [pi, part] of parts.entries()) {
343
+ const want = new RegExp(`^ {${depth}}${part}:`);
344
+ let found = -1;
345
+ for (let i = from; i < lines.length; i++) {
346
+ if (want.test(lines[i]!)) { found = i; break; }
347
+ if (pi > 0 && /^\S/.test(lines[i]!) && i > from) break; // left the parent block
348
+ }
349
+ if (found < 0) return { lines, index: -1, indent: depth };
350
+ from = found + 1; depth += 2;
351
+ }
352
+ return { lines, index: from - 1, indent: depth - 2 };
353
+ }
354
+
355
+ function read(text: string, key: string) {
356
+ const { lines, index } = lineOf(text, key);
357
+ if (index < 0) return null;
358
+ const v = lines[index]!.split(":").slice(1).join(":").split("#")[0]!.trim();
359
+ return v.replace(/^["']|["']$/g, "");
360
+ }
361
+
362
+ function write(text: string, key: string, value: string, defaults: string) {
363
+ const name = key.split(".").pop()!;
364
+ const shown = value === "" ? '""' : value;
365
+ const { lines, index, indent } = lineOf(text, key);
366
+
367
+ if (index >= 0) {
368
+ lines[index] = `${" ".repeat(indent)}${name}: ${shown}`;
369
+ return lines.join("\n");
370
+ }
371
+
372
+ // A config written before this key existed does not have a line to change. Take
373
+ // the key from the defaults, comment and all, rather than silently doing nothing
374
+ // and reporting a save: an older config is the normal case for anyone who has had
375
+ // kaizen installed for a while.
376
+ return insert(text, key, shown, defaults);
377
+ }
378
+
379
+ function insert(text: string, key: string, shown: string, defaults: string) {
380
+ const parts = key.split(".");
381
+ const name = parts[parts.length - 1]!;
382
+ const indent = (parts.length - 1) * 2;
383
+ const src = defaults.split("\n");
384
+ const { index: at } = lineOf(defaults, key);
385
+
386
+ // The comment block above the key in the defaults explains it; carry it across.
387
+ const block: string[] = [];
388
+ if (at >= 0) {
389
+ let i = at - 1;
390
+ while (i >= 0 && /^\s*#/.test(src[i]!)) { block.unshift(src[i]!); i--; }
391
+ }
392
+ block.push(`${" ".repeat(indent)}${name}: ${shown}`);
393
+
394
+ const lines = text.split("\n");
395
+ if (parts.length === 1) {
396
+ while (lines.length && !lines[lines.length - 1]!.trim()) lines.pop();
397
+ return [...lines, "", ...block, ""].join("\n");
398
+ }
399
+
400
+ // Nested: put it under its parent if the parent is there, else write the parent too.
401
+ const parent = parts.slice(0, -1).join(".");
402
+ const { index: pi } = lineOf(text, parent);
403
+ if (pi < 0) {
404
+ while (lines.length && !lines[lines.length - 1]!.trim()) lines.pop();
405
+ return [...lines, "", `${parts[0]}:`, ...block, ""].join("\n");
406
+ }
407
+ let end = pi + 1;
408
+ while (end < lines.length && (/^\s+\S/.test(lines[end]!) || !lines[end]!.trim())) end++;
409
+ lines.splice(end, 0, ...block);
410
+ return lines.join("\n");
411
+ }
412
+
413
+ function tilde(p: string) {
414
+ const h = homedir();
415
+ return p.startsWith(h) ? "~" + p.slice(h.length) : p;
416
+ }