kaizen-agent 0.4.0 → 0.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/cli/install.ts CHANGED
@@ -1,7 +1,7 @@
1
1
  #!/usr/bin/env bun
2
2
  import {
3
3
  symlinkSync, mkdirSync, readdirSync, lstatSync, readlinkSync, unlinkSync,
4
- existsSync, copyFileSync, readFileSync, appendFileSync,
4
+ existsSync, copyFileSync, readFileSync, appendFileSync, rmSync, writeFileSync,
5
5
  } from "node:fs";
6
6
  import { join, dirname, basename } from "node:path";
7
7
  import { homedir } from "node:os";
@@ -9,10 +9,12 @@ import { homedir } from "node:os";
9
9
  const home = homedir();
10
10
  const REPO_URL = "https://github.com/hfadhlullah/kaizen.git";
11
11
  const args = new Set(Bun.argv.slice(2));
12
+ const upgrade = args.has("upgrade") || args.has("--upgrade");
13
+ const wantSettings = args.has("settings") || args.has("config");
12
14
  const check = args.has("--check");
13
15
  const force = args.has("--force");
14
16
  const verbose = args.has("--verbose");
15
- const interactive = process.stdin.isTTY && !check && !args.has("--yes");
17
+ const interactive = process.stdin.isTTY && !check && !upgrade && !args.has("--yes");
16
18
 
17
19
  const c = {
18
20
  dim: (s: string) => `\x1b[2m${s}\x1b[0m`,
@@ -21,19 +23,187 @@ const c = {
21
23
  green: (s: string) => `\x1b[32m${s}\x1b[0m`,
22
24
  };
23
25
 
24
- if (!check) {
26
+ // Where each agent reads skills from. The skill folders follow the open Agent
27
+ // Skills standard, so every one of these can run the workflow; agents/ and
28
+ // commands/ are Claude Code's own formats and go nowhere else.
29
+ const AGENTS = [
30
+ { name: "Claude Code", dir: ".claude", full: true },
31
+ { name: "Codex", dir: ".codex", full: false },
32
+ { name: "Antigravity", dir: ".agents", full: false },
33
+ { name: "OpenCode", dir: ".opencode", full: false },
34
+ { name: "Cursor", dir: ".cursor", full: false },
35
+ { name: "Gemini CLI", dir: ".gemini", full: false },
36
+ ];
37
+
38
+ // Install into an agent that is actually present. Creating ~/.opencode for someone
39
+ // who does not use OpenCode leaves litter that looks like configuration. Claude Code
40
+ // is always a target: it is the one this is installed from.
41
+ const found = (r: string) => AGENTS.filter((a) => a.dir === ".claude" || existsSync(join(r, a.dir)));
42
+
43
+ if (wantSettings) {
44
+ const { settings } = await import("./settings.ts");
45
+ await settings(await resolveRepoQuietly());
46
+ process.exit(0);
47
+ }
48
+
49
+ // Bare `kaizen` on a machine where it is already installed is not an install --
50
+ // show what the project is doing instead, and offer the things you would have
51
+ // opened a terminal for.
52
+ if (interactive && !upgrade && Bun.argv.length === 2) {
53
+ const repoDir = await resolveRepoQuietly();
54
+ const here = found(home);
55
+ const linked = here.every((a) =>
56
+ ["kaizen", "kaizen-help"].every((n) => existsSync(join(home, a.dir, "skills", n))));
57
+ if (linked) {
58
+ const { dashboard } = await import("./dashboard.ts");
59
+ await dashboard(repoDir, here.map((a) => a.name), async (action) => {
60
+ if (action === "settings") {
61
+ const { settings } = await import("./settings.ts");
62
+ await settings(repoDir, false);
63
+ } else if (action === "upgrade") {
64
+ return await runUpgrade(repoDir);
65
+ } else if (action === "init") {
66
+ const out = await Bun.$`bun run ${join(repoDir, "cli/install.ts")} --yes`.text();
67
+ return clean(out);
68
+ }
69
+ });
70
+ process.exit(0);
71
+ }
72
+ }
73
+
74
+ if (upgrade) await clearBunxCache();
75
+ if (!check && !upgrade) await welcome();
76
+
77
+ async function welcome() {
78
+ const rgb = (r: number, g: number, b: number, s: string) =>
79
+ `\x1b[38;2;${r};${g};${b}m${s}\x1b[0m`;
80
+
81
+ // Fuji: snow on the peak, deepening blue down the slope.
82
+ const WORD = [
83
+ "██╗ ██╗ █████╗ ██╗███████╗███████╗███╗ ██╗",
84
+ "██║ ██╔╝██╔══██╗██║╚══███╔╝██╔════╝████╗ ██║",
85
+ "█████╔╝ ███████║██║ ███╔╝ █████╗ ██╔██╗ ██║",
86
+ "██╔═██╗ ██╔══██║██║ ███╔╝ ██╔══╝ ██║╚██╗██║",
87
+ "██║ ██╗██║ ██║██║███████╗███████╗██║ ╚████║",
88
+ "╚═╝ ╚═╝╚═╝ ╚═╝╚═╝╚══════╝╚══════╝╚═╝ ╚═══╝",
89
+ ];
90
+ const W = 69, C = 34, SNOW = 3;
91
+ // One constant slope, three columns per row, all the way down. The rows behind
92
+ // the wordmark keep the same rate, so the edge that goes in above the letters
93
+ // comes out below them on the same line -- vary it and the cone reads broken.
94
+ const H = [1, 4, 7, 10, 13, 16, 19, 22, 25, 28, 31, 34];
95
+
96
+ // Anything that would wrap is worse than the smaller thing that fits: the
97
+ // mountain needs the full width, the wordmark needs 47, below that use words.
98
+ const cols = process.stdout.columns || 80;
99
+ const wide = cols >= W + 4;
100
+ if (cols < 49) {
101
+ console.log(`\n ${c.bold("kaizen")}`);
102
+ } else {
103
+
104
+ const cell: string[][] = H.map(() => Array(W).fill(" "));
105
+ const isWord: boolean[][] = H.map(() => Array(W).fill(false));
106
+
107
+ if (wide) {
108
+ for (const [i, h] of H.entries()) {
109
+ const l = C - h, r = C + h;
110
+ if (i < SNOW) { // solid cap
111
+ cell[i]![l] = "▄"; cell[i]![r] = "▄";
112
+ for (let x = l + 1; x < r; x++) cell[i]![x] = "█";
113
+ } else { // slope, drawn as two edges
114
+ // Below the wordmark's top row the letters own the middle, so draw an
115
+ // edge only where all three of its characters clear them.
116
+ const band = i >= H.length - WORD.length;
117
+ const gap = Math.floor((W - 45) / 2) - 2;
118
+ if (!band || l + 2 < gap) {
119
+ cell[i]![l] = "▄"; cell[i]![l + 1] = "█"; cell[i]![l + 2] = "▀";
120
+ cell[i]![r - 2] = "▀"; cell[i]![r - 1] = "█"; cell[i]![r] = "▄";
121
+ }
122
+ }
123
+ }
124
+ }
125
+
126
+ const top = H.length - WORD.length, left = Math.floor((W - 45) / 2);
127
+ for (let r = top; r < H.length; r++) // air around the letters
128
+ for (let x = left - 2; x < left + 47; x++) if (x >= 0 && x < W) cell[r]![x] = " ";
129
+ for (const [r, line] of WORD.entries())
130
+ for (const [x, ch] of [...line].entries())
131
+ if (ch !== " ") { cell[top + r]![left + x] = ch; isWord[top + r]![left + x] = true; }
132
+
133
+ console.log();
134
+ for (const [i, row] of cell.entries()) {
135
+ if (!wide && i < top) continue; // no mountain to draw, no blank rows
136
+ const slope = Math.max(0, i - SNOW) / (H.length - SNOW);
137
+ const mountain: [number, number, number] = i < SNOW
138
+ ? [255 - i * 8, 255 - i * 5, 255]
139
+ : [Math.round(150 - slope * 105), Math.round(190 - slope * 110), Math.round(245 - slope * 80)];
140
+
141
+ // With no mountain there is nothing to sit under, so drop its left margin.
142
+ const off = wide ? 0 : left;
143
+ const chars = row.slice(off), flags = isWord[i]!.slice(off);
144
+
145
+ let out = " ", run = "", runWord = flags[0]!;
146
+ const flush = () => {
147
+ if (!run) return;
148
+ const t = (i - top) / (WORD.length - 1);
149
+ const col: [number, number, number] = runWord
150
+ ? [Math.round(214 - t * 150), Math.round(232 - t * 130), Math.round(255 - t * 45)]
151
+ : mountain;
152
+ out += rgb(col[0], col[1], col[2], run);
153
+ run = "";
154
+ };
155
+ for (const [x, ch] of chars.entries()) {
156
+ if (flags[x] !== runWord) { flush(); runWord = flags[x]!; }
157
+ run += ch;
158
+ }
159
+ flush();
160
+ console.log(out.replace(/\s+$/, ""));
161
+ }
162
+ if (wide) console.log(" " + rgb(38, 66, 130, "▀".repeat(W)));
163
+ }
164
+
25
165
  console.log(`
26
- ${c.bold("kaizen")} ${c.dim("改善")}
27
- ${c.dim("plan -> approve -> build -> review -> fix")}
166
+ ${c.bold("改善")} ${c.dim("kaizen — continuous improvement")}
167
+
168
+ ${c.dim("A staged workflow for AI agents — code, writing, research, operations:")}
169
+ ${c.dim("plan -> you approve -> build -> independent review -> bounded fix loop")}
28
170
  `);
171
+
172
+ if (interactive) await anyKey();
173
+ }
174
+
175
+ // A pause before anything touches the filesystem, and the place to back out.
176
+ async function anyKey() {
177
+ const { stdin, stdout } = process;
178
+ stdout.write(` ${c.dim("press")} ${c.cyan("enter")} ${c.dim("to install, or")} ${c.cyan("ctrl-c")} ${c.dim("to leave")}`);
179
+ stdin.setRawMode(true);
180
+ stdin.resume();
181
+ await new Promise<void>((resolve) => {
182
+ const onData = (chunk: Buffer) => {
183
+ if (chunk.toString().includes("\x03")) { stdout.write("\n\n nothing installed\n"); process.exit(130); }
184
+ stdin.off("data", onData);
185
+ resolve();
186
+ };
187
+ stdin.on("data", onData);
188
+ });
189
+ stdin.setRawMode(false);
190
+ stdin.pause();
191
+ stdout.write("\r\x1b[2K");
29
192
  }
30
193
 
31
194
  const repo = await resolveRepo();
32
195
  const root = await chooseRoot();
33
- const base = join(root, ".claude");
196
+ const targets = found(root);
34
197
 
35
198
  // ---------------------------------------------------------------- repo
36
199
 
200
+ // Settings only needs the clone's default config, not a pull.
201
+ async function resolveRepoQuietly() {
202
+ const here = dirname(import.meta.dir);
203
+ if (existsSync(join(here, ".git"))) return here;
204
+ return process.env.KAIZEN_HOME ?? join(home, "kaizen");
205
+ }
206
+
37
207
  async function resolveRepo() {
38
208
  const here = dirname(import.meta.dir);
39
209
  if (existsSync(join(here, ".git"))) return here;
@@ -59,9 +229,16 @@ async function resolveRepo() {
59
229
  async function chooseRoot() {
60
230
  if (args.has("--project")) return process.cwd();
61
231
  if (args.has("--global") || !interactive) return home;
232
+ // A project install only means something in a project. Standing in the home
233
+ // directory it is the global install under another name, and outside a
234
+ // repository there is nothing for it to belong to.
235
+ const cwd = process.cwd();
236
+ if (cwd === home || !existsSync(join(cwd, ".git"))) return home;
237
+
238
+ const names = (r: string) => found(r).map((a) => a.name).join(", ");
62
239
  return select("Install kaizen for", [
63
- { label: "Every project", hint: tilde(join(home, ".claude")), value: home },
64
- { label: "This project only", hint: join(".claude"), value: process.cwd() },
240
+ { label: "Globally", hint: `every project \u2014 ${names(home)}`, value: home },
241
+ { label: "This project only", hint: `${basename(cwd)}/ \u2014 ${names(cwd)}`, value: cwd },
65
242
  ]);
66
243
  }
67
244
 
@@ -73,11 +250,13 @@ function dirEntries(sub: string) {
73
250
  .map((f) => join(repo, sub, f));
74
251
  }
75
252
 
76
- const links = [
77
- ...["kaizen", "kaizen-help"].map((n) => [join(repo, "skills", n), "skills"] as const),
78
- ...dirEntries("agents").map((f) => [f, "agents"] as const),
79
- ...dirEntries("commands").map((f) => [f, "commands"] as const),
80
- ];
253
+ const links = targets.flatMap((a) => [
254
+ ...["kaizen", "kaizen-help"].map((n) => [join(repo, "skills", n), join(a.dir, "skills")] as const),
255
+ ...(a.full ? [
256
+ ...dirEntries("agents").map((f) => [f, join(a.dir, "agents")] as const),
257
+ ...dirEntries("commands").map((f) => [f, join(a.dir, "commands")] as const),
258
+ ] : []),
259
+ ]);
81
260
 
82
261
  function linkState(src: string, dest: string) {
83
262
  try {
@@ -91,7 +270,7 @@ function linkState(src: string, dest: string) {
91
270
 
92
271
  let blocked = 0, wrote = 0, already = 0;
93
272
  for (const [src, sub] of links) {
94
- const dir = join(base, sub);
273
+ const dir = join(root, sub);
95
274
  const dest = join(dir, basename(src));
96
275
  const state = linkState(src, dest);
97
276
  const name = `${sub}/${basename(src)}`;
@@ -119,22 +298,49 @@ if (check) {
119
298
  process.exit(blocked ? 1 : 0);
120
299
  }
121
300
 
301
+ const into = targets.map((a) => a.name).join(", ");
122
302
  step(
123
- wrote && already ? `linked ${wrote} new, ${already} already in place`
124
- : wrote ? `linked ${wrote} skills, agents, and commands into ${tilde(base)}`
125
- : `already installed in ${tilde(base)} ${c.dim("(nothing to change)")}`,
303
+ wrote && already ? `linked ${wrote} new, ${already} already in place — ${into}`
304
+ : wrote ? `linked ${wrote} files into ${into}`
305
+ : `already installed for ${into} ${c.dim("(nothing to change)")}`,
126
306
  );
307
+ if (targets.length === 1) {
308
+ console.log(` ${c.dim("Codex, Antigravity, OpenCode, Cursor and Gemini CLI get it too,")}`);
309
+ console.log(` ${c.dim("if their directory exists when you install.")}`);
310
+ }
127
311
 
128
312
  // ---------------------------------------------------------------- repo setup
129
313
 
314
+ if (!args.has("--project")) installLauncher();
315
+
316
+ // bunx runs from a temp copy and leaves nothing behind, so there is no `kaizen` to
317
+ // type afterwards. A launcher pointed at the clone gives one, and keeps working
318
+ // after the clone updates.
319
+ function installLauncher() {
320
+ const binDir = join(home, ".local", "bin");
321
+ const bin = join(binDir, "kaizen");
322
+ const script = `#!/bin/sh\n# kaizen launcher -- installed by kaizen-agent\nexec bun run ${join(repo, "cli/install.ts")} "$@"\n`;
323
+ try {
324
+ if (existsSync(bin) && readFileSync(bin, "utf8") === script) return;
325
+ mkdirSync(binDir, { recursive: true });
326
+ writeFileSync(bin, script, { mode: 0o755 });
327
+ const onPath = (process.env.PATH ?? "").split(":").includes(binDir);
328
+ step(onPath
329
+ ? `${c.cyan("kaizen upgrade")} is now on your PATH`
330
+ : `wrote ${tilde(bin)} ${c.dim("— add ~/.local/bin to PATH to use it")}`);
331
+ } catch {
332
+ /* a read-only home is not worth failing the install over */
333
+ }
334
+ }
335
+
130
336
  const inGitRepo = existsSync(join(process.cwd(), ".git"));
131
337
  const kaizenDir = join(process.cwd(), ".kaizen");
132
338
 
133
339
  if (inGitRepo && !existsSync(kaizenDir)) {
134
340
  const now = interactive
135
341
  ? await select(`Set up ${basename(process.cwd())}/ for kaizen now`, [
136
- { label: "Yes", hint: "writes .kaizen/ — spec, config, gitignore entry", value: true },
137
- { label: "Not now", hint: "run /kaizen-init in the repo later", value: false },
342
+ { label: "Yes", hint: "writes .kaizen/ and a CLAUDE.md line so it runs by default", value: true },
343
+ { label: "Not now", hint: "the first /kaizen run will do it", value: false },
138
344
  ])
139
345
  : false;
140
346
  if (now) initRepo();
@@ -157,23 +363,112 @@ function initRepo() {
157
363
  appendFileSync(gi, (current && !current.endsWith("\n") ? "\n" : "") + "\n# kaizen run state\n" + rules);
158
364
  }
159
365
  step("wrote .kaizen/ and the gitignore entry");
366
+ writePointer();
367
+ }
368
+
369
+ // Claude Code loads a skill by matching its description, so an ordinary request
370
+ // ("add a discount cap") does not reach kaizen unless the project says it should.
371
+ // One line in the agent instructions is what makes it the default here.
372
+ function writePointer() {
373
+ const block = `\n## Kaizen workflow\n\nNon-trivial work in this repository follows the staged workflow in\n\`.kaizen/spec.md\`: plan, human approval, build, independent review, bounded fix\nloop. Invoke the \`kaizen\` skill for it — do not carry the stages out inline.\n\nNon-trivial means work worth a plan: a change across more than one file, anything\nwith a migration or a rollback, or a document someone else will act on. Answering a\nquestion, reading code, or a one-line fix is not, and should not start a run.\n\nRun state lives in \`.kaizen/runs/<id>/\`. Read \`state.json\` first and continue from\nthe stage it names; never advance a run whose \`awaiting\` field is non-null without a\nrecorded human decision.\n`;
374
+
375
+ for (const name of ["CLAUDE.md", "AGENTS.md"]) {
376
+ const f = join(process.cwd(), name);
377
+ const current = existsSync(f) ? readFileSync(f, "utf8") : "";
378
+ if (current.includes("## Kaizen workflow")) continue;
379
+ if (!current && name === "AGENTS.md") continue; // do not create one that was never there
380
+ const text = current ? (current.endsWith("\n") ? "" : "\n") + block : block.slice(1);
381
+ appendFileSync(f, text);
382
+ step(`${existsSync(f) && current ? "added to" : "wrote"} ${name} ${c.dim("— kaizen now runs without being asked for")}`);
383
+ }
160
384
  }
161
385
 
162
386
  // ---------------------------------------------------------------- done
163
387
 
164
- console.log(`
388
+ if (upgrade) {
389
+ let version = "";
390
+ try { version = JSON.parse(readFileSync(join(repo, "package.json"), "utf8")).version; } catch {}
391
+ console.log(`
392
+ ${c.bold("Up to date")}${version ? c.dim(` — kaizen ${version}`) : ""}
393
+ ${c.dim("Restart Claude Code to pick up any new slash commands.")}
394
+ `);
395
+ } else {
396
+ console.log(`
165
397
  ${c.bold("Next")}
166
398
  1 restart Claude Code ${c.dim("— skills load live, slash commands only at session start")}
167
- 2 ${c.cyan("/kaizen-init")} ${c.dim("in any repo you want to use it on")}
168
- 3 ${c.cyan("/kaizen-plan <what you want built>")}
399
+ 2 ${c.cyan("/kaizen <what you want done>")} ${c.dim("in any project — it sets itself up on first run")}
169
400
 
170
- ${c.dim("/kaizen-help lists every command.")}
401
+ ${c.dim("/kaizen-help lists every command. kaizen upgrade updates all of this.")}
171
402
  `);
403
+ }
172
404
  if (blocked) process.exit(1);
173
405
 
174
406
  // ---------------------------------------------------------------- helpers
175
407
 
176
408
  function step(msg: string) { console.log(` ${c.green("+")} ${msg}`); }
409
+
410
+ // Runs the upgrade and reports what it actually did, version to version, so the
411
+ // dashboard can show a result rather than the user wondering whether it worked.
412
+ async function runUpgrade(repoDir: string) {
413
+ const before = versionOf(repoDir);
414
+ const out = await Bun.$`bun run ${join(repoDir, "cli/install.ts")} upgrade`.text().catch(() => "");
415
+ const after = versionOf(repoDir);
416
+ const latest = await npmLatest();
417
+
418
+ const lines = [
419
+ before === after
420
+ ? `${c.bold("Already up to date")} ${c.dim(after)}`
421
+ : `${c.bold("Upgraded")} ${c.dim(before)} → ${c.green(after)}`,
422
+ "",
423
+ // Only the step lines: the captured run's own heading and restart notice
424
+ // repeat what this panel already says above them.
425
+ ...clean(out)
426
+ .filter((l) => l.trim().startsWith("+") || l.trim().startsWith("skip"))
427
+ .map((l) => c.dim(l.trim())),
428
+ ];
429
+ if (latest && latest !== after) {
430
+ lines.push("", c.dim(`npm publishes ${latest}; this install follows the git clone.`));
431
+ }
432
+ lines.push("", c.dim("Restart your agent to pick up new slash commands."));
433
+ return lines;
434
+ }
435
+
436
+ function versionOf(dir: string) {
437
+ try { return JSON.parse(readFileSync(join(dir, "package.json"), "utf8")).version as string; }
438
+ catch { return "?"; }
439
+ }
440
+
441
+ async function npmLatest() {
442
+ try {
443
+ const r = await fetch("https://registry.npmjs.org/kaizen-agent/latest", {
444
+ signal: AbortSignal.timeout(4000),
445
+ });
446
+ return ((await r.json()) as { version?: string }).version ?? null;
447
+ } catch { return null; } // offline is not an upgrade failure
448
+ }
449
+
450
+ // Captured output still carries its own colour codes, which stop any test on the
451
+ // text from matching and would nest inside this panel's own styling.
452
+ function clean(out: string) {
453
+ return out.split("\n").map((l) => l.replace(/\x1b\[[0-9;]*m/g, "").replace(/\r$/, "").trimEnd());
454
+ }
455
+
456
+ // bunx keeps an extracted copy per package under the temp directory and reuses it
457
+ // without re-resolving, so an upgrade that only pulls the clone still leaves the
458
+ // next `bunx kaizen-agent` running whatever was cached the first time.
459
+ async function clearBunxCache() {
460
+ const tmp = process.env.TMPDIR ?? "/tmp";
461
+ let removed = 0;
462
+ try {
463
+ for (const name of readdirSync(tmp)) {
464
+ if (/^bunx-.*kaizen-agent/.test(name)) {
465
+ rmSync(join(tmp, name), { recursive: true, force: true });
466
+ removed++;
467
+ }
468
+ }
469
+ } catch { /* an unreadable temp directory is not worth failing an upgrade over */ }
470
+ step(removed ? `cleared ${removed} cached copy of the installer` : "no installer cache to clear");
471
+ }
177
472
  function tilde(p: string) { return p.startsWith(home) ? "~" + p.slice(home.length) : p; }
178
473
 
179
474
  // Arrow-key picker. Raw mode delivers keys unbuffered; the alternative is a
@@ -0,0 +1,160 @@
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; values: string[]; help: string };
8
+
9
+ const SETTINGS: Setting[] = [
10
+ { key: "mode", values: ["approve", "auto", "plan-only", "review-only"], help: "Where a run stops" },
11
+ { key: "build.executor", values: ["subagent", "inline", "ask"], help: "Who carries out the approved plan" },
12
+ { key: "approvals.plan", values: ["true", "false"], help: "Stop and show the plan before anything is built" },
13
+ { key: "approvals.review", values: ["true", "false"], help: "Stop after the review, before the run is done" },
14
+ { key: "approvals.each_file", values: ["true", "false"], help: "Confirm every individual edit" },
15
+ { key: "auto_fix.enabled", values: ["true", "false"], help: "Fix findings without asking" },
16
+ { key: "auto_fix.min_severity", values: ["critical", "high", "medium", "low"], help: "How bad a finding must be to be fixed automatically" },
17
+ { key: "auto_fix.max_iterations", values: ["1", "2", "3", "4", "5"], help: "Fix and recheck rounds before escalating" },
18
+ { key: "review.write_memory", values: ["true", "false"], help: "Let the reviewer append lessons to memory.md" },
19
+ { key: "review.memory_max_lines", values: ["50", "100", "200", "400"], help: "Size cap on memory.md, a tax on every run" },
20
+ { key: "track.default", values: ["", "software", "writing", "communication", "operations", "research"], help: "Pin a track, or leave empty to infer per run" },
21
+ { key: "state.keep_runs", values: ["5", "10", "20", "50"], help: "Completed runs kept before the oldest is pruned" },
22
+ { key: "git.auto_commit", values: ["false", "true"], help: "Commit the work when a run finishes" },
23
+ { key: "git.branch_before_implement", values: ["true", "false"], help: "Branch before building when on the default branch" },
24
+ ];
25
+
26
+ const c = {
27
+ dim: (s: string) => `\x1b[2m${s}\x1b[0m`,
28
+ bold: (s: string) => `\x1b[1m${s}\x1b[0m`,
29
+ cyan: (s: string) => `\x1b[36m${s}\x1b[0m`,
30
+ green: (s: string) => `\x1b[32m${s}\x1b[0m`,
31
+ inv: (s: string) => `\x1b[7m${s}\x1b[0m`,
32
+ };
33
+
34
+ export async function settings(repoRoot: string, standalone = true) {
35
+ const file = locate();
36
+ if (!existsSync(file)) {
37
+ mkdirSync(dirname(file), { recursive: true });
38
+ copyFileSync(join(repoRoot, "skills/kaizen/config.default.yml"), file);
39
+ }
40
+
41
+ const { stdin, stdout } = process;
42
+ if (!stdin.isTTY) { console.log(`Settings live in ${file}`); return; }
43
+
44
+ let active = 0, saved = "";
45
+ const draw = () => {
46
+ const text = readFileSync(file, "utf8");
47
+ const rows = SETTINGS.map((s) => ({ s, value: read(text, s.key) ?? c.dim("(default)") }));
48
+ const width = Math.max(...SETTINGS.map((s) => s.key.length)) + 4;
49
+
50
+ stdout.write("\x1b[H\x1b[2J"); // home, clear
51
+ stdout.write(`\n ${c.bold("kaizen settings")} ${c.dim(tilde(file))}\n\n`);
52
+ for (const [i, { s, value }] of rows.entries()) {
53
+ const on = i === active;
54
+ const name = s.key.padEnd(width);
55
+ stdout.write(on
56
+ ? ` ${c.cyan("›")} ${c.bold(name)}${c.inv(` ${value} `)}\n`
57
+ : ` ${c.dim(name)}${value}\n`);
58
+ }
59
+ stdout.write(`\n ${c.dim(SETTINGS[active]!.help)}\n`);
60
+ stdout.write(`\n ${c.dim("↑↓ move · ←→ change · backspace back")} ${saved}\n`);
61
+ };
62
+
63
+ stdout.write("\x1b[?1049h\x1b[?25l"); // alternate screen, hide cursor
64
+ stdin.setRawMode(true);
65
+ stdin.resume();
66
+ draw();
67
+
68
+ await new Promise<void>((resolve) => {
69
+ const onData = (chunk: Buffer) => {
70
+ const keys = chunk.toString();
71
+ for (let i = 0; i < keys.length; i++) {
72
+ const rest = keys.slice(i);
73
+ // esc, q, backspace, and left-at-the-edge all mean "back to where I came
74
+ // from" -- the settings screen is always something you opened from
75
+ // somewhere else.
76
+ if (rest.startsWith("\x03") || rest.startsWith("q") || rest === "\x1b"
77
+ || rest.startsWith("\x7f") || rest.startsWith("\b")) {
78
+ stdin.off("data", onData); return resolve();
79
+ }
80
+ if (rest.startsWith("\x1b[A")) { active = (active - 1 + SETTINGS.length) % SETTINGS.length; i += 2; }
81
+ else if (rest.startsWith("\x1b[B")) { active = (active + 1) % SETTINGS.length; i += 2; }
82
+ else if (rest.startsWith("\x1b[C") || rest.startsWith("\x1b[D") || rest.startsWith("\r")) {
83
+ const step = rest.startsWith("\x1b[D") ? -1 : 1;
84
+ const s = SETTINGS[active]!;
85
+ const text = readFileSync(file, "utf8");
86
+ const now = read(text, s.key) ?? s.values[0]!;
87
+ const at = s.values.indexOf(now);
88
+ const next = s.values[((at < 0 ? 0 : at) + step + s.values.length) % s.values.length]!;
89
+ writeFileSync(file, write(text, s.key, next));
90
+ saved = c.green(`saved ${s.key} = ${next || '""'}`);
91
+ if (!rest.startsWith("\r")) i += 2;
92
+ } else if (rest.startsWith("k")) active = (active - 1 + SETTINGS.length) % SETTINGS.length;
93
+ else if (rest.startsWith("j")) active = (active + 1) % SETTINGS.length;
94
+ else continue;
95
+ draw();
96
+ }
97
+ };
98
+ stdin.on("data", onData);
99
+ });
100
+
101
+ stdin.setRawMode(false);
102
+ stdin.pause();
103
+ stdout.write("\x1b[?25h\x1b[?1049l"); // restore cursor and screen
104
+ // Returning to the dashboard means returning to its alternate screen, where a
105
+ // line printed here would never be seen.
106
+ if (standalone) console.log(` ${c.green("+")} settings saved to ${tilde(file)}\n`);
107
+ }
108
+
109
+ // The nearest .kaizen wins, exactly as the workflow resolves it.
110
+ function locate() {
111
+ let dir = process.cwd();
112
+ for (;;) {
113
+ if (existsSync(join(dir, ".kaizen"))) return join(dir, ".kaizen", "config.yml");
114
+ if (existsSync(join(dir, ".git"))) break;
115
+ const up = dirname(dir);
116
+ if (up === dir) break;
117
+ dir = up;
118
+ }
119
+ return join(homedir(), ".kaizen", "config.yml");
120
+ }
121
+
122
+ // Read and write a dotted key by walking indentation, so the comment above every
123
+ // key survives. Parsing to an object and dumping it back would delete all of them.
124
+ function lineOf(text: string, key: string) {
125
+ const parts = key.split(".");
126
+ const lines = text.split("\n");
127
+ let depth = 0, from = 0;
128
+ for (const [pi, part] of parts.entries()) {
129
+ const want = new RegExp(`^ {${depth}}${part}:`);
130
+ let found = -1;
131
+ for (let i = from; i < lines.length; i++) {
132
+ if (want.test(lines[i]!)) { found = i; break; }
133
+ if (pi > 0 && /^\S/.test(lines[i]!) && i > from) break; // left the parent block
134
+ }
135
+ if (found < 0) return { lines, index: -1, indent: depth };
136
+ from = found + 1; depth += 2;
137
+ }
138
+ return { lines, index: from - 1, indent: depth - 2 };
139
+ }
140
+
141
+ function read(text: string, key: string) {
142
+ const { lines, index } = lineOf(text, key);
143
+ if (index < 0) return null;
144
+ const v = lines[index]!.split(":").slice(1).join(":").split("#")[0]!.trim();
145
+ return v.replace(/^["']|["']$/g, "");
146
+ }
147
+
148
+ function write(text: string, key: string, value: string) {
149
+ const { lines, index, indent } = lineOf(text, key);
150
+ if (index < 0) return text;
151
+ const name = key.split(".").pop()!;
152
+ const shown = value === "" ? '""' : value;
153
+ lines[index] = `${" ".repeat(indent)}${name}: ${shown}`;
154
+ return lines.join("\n");
155
+ }
156
+
157
+ function tilde(p: string) {
158
+ const h = homedir();
159
+ return p.startsWith(h) ? "~" + p.slice(h.length) : p;
160
+ }
@@ -0,0 +1,5 @@
1
+ ---
2
+ description: Show the kaizen settings for this project and change them interactively
3
+ ---
4
+
5
+ Invoke the `kaizen` skill with argument `config`.
package/package.json CHANGED
@@ -1,10 +1,11 @@
1
1
  {
2
2
  "name": "kaizen-agent",
3
- "version": "0.4.0",
4
- "description": "Staged AI development workflow: plan, human approval, implement, independent review, bounded fix loop.",
3
+ "version": "0.5.0",
4
+ "description": "Staged workflow for AI agents \u2014 code, writing, research, operations: plan, human approval, build, independent review, bounded fix loop.",
5
5
  "type": "module",
6
6
  "bin": {
7
- "kaizen-agent": "./cli/install.ts"
7
+ "kaizen-agent": "./cli/install.ts",
8
+ "kaizen": "./cli/install.ts"
8
9
  },
9
10
  "files": [
10
11
  "cli",
@@ -30,6 +31,8 @@
30
31
  "skill",
31
32
  "codex",
32
33
  "antigravity",
33
- "code-review"
34
+ "code-review",
35
+ "writing",
36
+ "research"
34
37
  ]
35
38
  }