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/README.md +438 -53
- package/cli/dashboard.ts +539 -0
- package/cli/install.ts +318 -23
- package/cli/settings.ts +160 -0
- package/commands/kaizen-config.md +5 -0
- package/package.json +7 -4
- package/skills/kaizen/SKILL.md +62 -4
- package/skills/kaizen/config.default.yml +13 -0
- package/skills/kaizen/spec-build.md +11 -1
- package/skills/kaizen/spec-plan.md +17 -0
- package/skills/kaizen-help/SKILL.md +4 -2
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
|
-
|
|
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("
|
|
27
|
-
|
|
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
|
|
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: "
|
|
64
|
-
{ label: "This project only", hint:
|
|
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
|
-
...
|
|
79
|
-
|
|
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(
|
|
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}
|
|
125
|
-
: `already installed
|
|
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/
|
|
137
|
-
{ label: "Not now", hint: "
|
|
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
|
-
|
|
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
|
|
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
|
package/cli/settings.ts
ADDED
|
@@ -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
|
+
}
|
package/package.json
CHANGED
|
@@ -1,10 +1,11 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "kaizen-agent",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"description": "Staged AI
|
|
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
|
}
|