kaizen-agent 0.3.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 +441 -50
- package/cli/dashboard.ts +539 -0
- package/cli/install.ts +448 -82
- 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/dashboard.ts
ADDED
|
@@ -0,0 +1,539 @@
|
|
|
1
|
+
// What `kaizen` shows once it is installed: the state of this project's runs, and
|
|
2
|
+
// the few things you would have opened a terminal to do.
|
|
3
|
+
import { existsSync, readFileSync, readdirSync } from "node:fs";
|
|
4
|
+
import { join, dirname, basename } from "node:path";
|
|
5
|
+
import { homedir } from "node:os";
|
|
6
|
+
|
|
7
|
+
const home = homedir();
|
|
8
|
+
const c = {
|
|
9
|
+
dim: (s: string) => `\x1b[2m${s}\x1b[0m`,
|
|
10
|
+
bold: (s: string) => `\x1b[1m${s}\x1b[0m`,
|
|
11
|
+
cyan: (s: string) => `\x1b[36m${s}\x1b[0m`,
|
|
12
|
+
green: (s: string) => `\x1b[32m${s}\x1b[0m`,
|
|
13
|
+
amber: (s: string) => `\x1b[33m${s}\x1b[0m`,
|
|
14
|
+
};
|
|
15
|
+
|
|
16
|
+
type Run = { id: string; stage: string; awaiting: string | null };
|
|
17
|
+
|
|
18
|
+
// The mascot, rendered from assets/kaizen.jpg. Braille packs 2x4 dots into one
|
|
19
|
+
// character, which is the only way outline art survives a downsample this far;
|
|
20
|
+
// sampling brightness onto a character ramp gives grey mush, having no midtones
|
|
21
|
+
// to sample. Regenerate with the snippet in assets/tanuki.txt's history.
|
|
22
|
+
const TANUKI = [
|
|
23
|
+
"⠀⠀⠀⠀⢀⡶⢶⣖⡒⠒⠛⠛⠛⠛⣛⡿⠟⢛⡟⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀",
|
|
24
|
+
"⠀⠀⠀⢀⣾⡇⠀⠀⠉⠛⠲⣤⠴⠛⠁⢀⡴⠋⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀",
|
|
25
|
+
"⠀⠀⠀⡾⢹⠇⠀⠀⣠⠴⠋⠁⠀⣠⠖⠋⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀",
|
|
26
|
+
"⠀⠀⣼⠁⣸⣠⡴⠋⢁⡴⠦⣄⡚⢁⣀⣀⣀⣀⡀⢀⣠⠴⢦⡀⠀⠀⠀⠀⠀⠀⠀⠀",
|
|
27
|
+
"⠀⣸⢃⡴⠟⠁⠀⣀⢸⡇⠀⠈⠙⠉⠉⠀⠀⠉⠉⠋⠁⠀⢨⡇⠀⠀⠀⠀⠀⠀⠀⠀",
|
|
28
|
+
"⣰⡿⠋⢰⡏⢹⠋⠁⠈⣧⠀⠀⢀⡀⠀⠀⠀⠀⢀⡀⠀⠀⣸⠃⠀⠀⠀⠀⠀⠀⠀⠀",
|
|
29
|
+
"⠻⠖⠛⢹⡇⠘⣇⠀⢰⠇⣠⠞⢉⡉⢳⡀⢀⡞⢉⡉⠳⣄⠘⣆⠀⠀⢠⡤⣤⢀⡀⠀",
|
|
30
|
+
"⠀⠀⠀⠀⣧⠀⠉⣰⣋⡴⠃⠀⠈⠁⡞⣀⣀⢳⠈⠁⠀⠘⢦⣙⣆⠀⣰⠃⠹⡇⣸⣻",
|
|
31
|
+
"⠀⠀⠀⠀⠘⣆⠀⠈⠻⣄⠀⠀⠀⠀⠳⢬⡥⠞⠀⠀⠀⠀⣠⠝⠁⢀⡇⠀⠀⣷⢳⠃",
|
|
32
|
+
"⠀⠀⠀⠀⠀⠈⢧⡀⠀⠈⠙⠲⠦⣤⣤⣀⣀⣤⣤⠴⠖⠿⠷⠶⠚⠉⣳⠤⠴⠯⠏⠀",
|
|
33
|
+
"⠀⠀⠀⠀⠀⠀⠀⠙⠲⣄⠀⠀⠀⣀⡤⠶⠶⢤⣀⠀⠀⠀⠀⣀⣤⠞⣡⠶⠚⠒⢦⡀",
|
|
34
|
+
"⠀⠀⠀⠀⠀⠀⠀⠀⠀⣿⠀⠀⡼⠁⠀⠀⠀⠀⠈⢧⠀⠀⢾⠉⢀⡾⠻⣄⡀⠀⠈⡇",
|
|
35
|
+
"⠀⠀⠀⠀⠀⠀⠀⠀⠀⡇⠀⠐⡇⠀⠀⠀⠀⠀⠀⢸⠆⠀⢸⠚⠉⠀⠀⠀⢯⣀⡼⠃",
|
|
36
|
+
"⠀⠀⠀⠀⠀⠀⠀⠀⠀⢷⠀⠀⠻⣄⠀⠀⠀⠀⣠⠟⠀⠀⡾⢤⣤⣀⣤⡤⠶⠋⠀⠀",
|
|
37
|
+
"⠀⠀⠀⠀⠀⠀⠀⠀⠀⠈⢧⣄⣠⠤⠶⠒⠒⠻⠧⣄⣠⡼⠃⠀⠀⠀⠀⠀⠀⠀⠀⠀",
|
|
38
|
+
];
|
|
39
|
+
|
|
40
|
+
// The same, for windows that cannot spare fifteen rows before the runs begin.
|
|
41
|
+
const TANUKI_SMALL = [
|
|
42
|
+
"⠀⠀⠀⣰⠖⠾⣟⣛⠋⢉⣩⠽⢛⡽⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀",
|
|
43
|
+
"⠀⠀⢰⢻⠀⠀⢀⡬⠟⠉⢀⠴⠋⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀",
|
|
44
|
+
"⠀⢠⠇⣾⡠⠞⢩⠤⢄⡚⣁⣀⣀⣀⢀⣠⠤⡄⠀⠀⠀⠀⠀⠀",
|
|
45
|
+
"⢠⣏⠔⣫⣀⡤⢸⠀⠀⠉⠁⠀⠀⠈⠉⠀⠀⡇⠀⠀⠀⠀⠀⠀",
|
|
46
|
+
"⢾⠥⢾⡅⢧⠀⢸⠃⡤⠖⠢⡀⢀⠔⠲⢤⠘⡇⠀⠀⣀⣀⠀⠀",
|
|
47
|
+
"⠀⠀⠈⣇⠈⢠⣫⠞⠀⠘⢱⢣⡜⡎⠃⠀⠳⣝⡄⢠⠇⢻⢉⣷",
|
|
48
|
+
"⠀⠀⠀⠘⢆⠀⠑⢦⣀⡀⠈⠓⠚⠁⢀⣀⣤⣊⣠⢾⡀⢠⣿⠃",
|
|
49
|
+
"⠀⠀⠀⠀⠈⠓⢄⠀⠀⠉⣉⣭⣭⣉⠉⠀⠈⢀⣠⢔⡩⠥⢥⡀",
|
|
50
|
+
"⠀⠀⠀⠀⠀⠀⢠⡇⠀⡞⠁⠀⠀⠈⢳⠀⢰⣍⣠⠚⠧⡀⠀⡷",
|
|
51
|
+
"⠀⠀⠀⠀⠀⠀⠸⡇⠀⣇⠀⠀⠀⠀⣸⠀⢠⡏⠀⠀⢀⣳⠞⠁",
|
|
52
|
+
"⠀⠀⠀⠀⠀⠀⠀⠳⣀⣨⠥⠤⠶⠾⣅⣀⠞⠉⠉⠉⠉⠀⠀⠀",
|
|
53
|
+
];
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
export async function dashboard(
|
|
57
|
+
repo: string,
|
|
58
|
+
agents: string[],
|
|
59
|
+
run: (a: string) => Promise<string[] | void>,
|
|
60
|
+
) {
|
|
61
|
+
const { stdin, stdout } = process;
|
|
62
|
+
const state = locate();
|
|
63
|
+
|
|
64
|
+
const actions = () => [
|
|
65
|
+
...(state ? [
|
|
66
|
+
{ key: "runs", label: "Runs", hint: "every run, and what each is waiting on" },
|
|
67
|
+
{ key: "backlog", label: "Backlog", hint: "what runs noticed and did not do" },
|
|
68
|
+
] : []),
|
|
69
|
+
{ key: "settings", label: "Settings", hint: "modes, approvals, who builds" },
|
|
70
|
+
...(state && !existsSync(join(state, "config.yml"))
|
|
71
|
+
? [{ key: "init", label: "Set up this project", hint: "write .kaizen/ here" }] : []),
|
|
72
|
+
...(!state ? [{ key: "init", label: "Set up this project", hint: "write .kaizen/ here" }] : []),
|
|
73
|
+
{ key: "upgrade", label: "Upgrade", hint: "pull, relink, clear the installer cache" },
|
|
74
|
+
{ key: "quit", label: "Quit", hint: "" },
|
|
75
|
+
];
|
|
76
|
+
|
|
77
|
+
let active = 0;
|
|
78
|
+
for (;;) {
|
|
79
|
+
const chosen = await menu();
|
|
80
|
+
if (chosen === "quit") return;
|
|
81
|
+
if (chosen === "runs") { await runsView(); continue; }
|
|
82
|
+
if (chosen === "backlog") { await backlogView(); continue; }
|
|
83
|
+
const lines = await run(chosen);
|
|
84
|
+
if (lines?.length) await report(lines);
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
// An action that has something to say says it here, on its own screen, rather
|
|
88
|
+
// than printing to a terminal the dashboard is about to paint over.
|
|
89
|
+
async function report(lines: string[]) {
|
|
90
|
+
stdout.write("\x1b[?1049h\x1b[?25l\x1b[H\x1b[2J");
|
|
91
|
+
stdout.write("\n");
|
|
92
|
+
for (const line of lines) stdout.write(` ${line}\n`);
|
|
93
|
+
stdout.write(`\n ${c.dim("any key to go back")}\n`);
|
|
94
|
+
stdin.setRawMode(true);
|
|
95
|
+
stdin.resume();
|
|
96
|
+
await new Promise<void>((resolve) => {
|
|
97
|
+
const once = (chunk: Buffer) => {
|
|
98
|
+
if (chunk.toString().includes("\x03")) process.exit(130);
|
|
99
|
+
stdin.off("data", once);
|
|
100
|
+
resolve();
|
|
101
|
+
};
|
|
102
|
+
stdin.on("data", once);
|
|
103
|
+
});
|
|
104
|
+
stdin.setRawMode(false);
|
|
105
|
+
stdin.pause();
|
|
106
|
+
stdout.write("\x1b[?25h\x1b[?1049l");
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
// ---------------------------------------------------------------- views
|
|
110
|
+
|
|
111
|
+
// One scrolling list, used by every view. Returns the chosen index, or null
|
|
112
|
+
// when the user backs out.
|
|
113
|
+
async function pick(
|
|
114
|
+
title: string,
|
|
115
|
+
rows: string[],
|
|
116
|
+
footer = "enter open · backspace back",
|
|
117
|
+
skip: (i: number) => boolean = () => false,
|
|
118
|
+
) {
|
|
119
|
+
if (!rows.length) { await report([c.bold(title), "", c.dim("nothing here yet")]); return null; }
|
|
120
|
+
|
|
121
|
+
// Headings are rows too, but they are not items: they are not counted, and
|
|
122
|
+
// moving passes over them rather than landing on them.
|
|
123
|
+
const pickable = rows.map((_, i) => i).filter((i) => !skip(i));
|
|
124
|
+
if (!pickable.length) { await report([c.bold(title), "", c.dim("nothing here yet")]); return null; }
|
|
125
|
+
let cursor = 0; // index into pickable
|
|
126
|
+
const move = (d: number) => { cursor = Math.min(pickable.length - 1, Math.max(0, cursor + d)); };
|
|
127
|
+
let top = 0;
|
|
128
|
+
const height = () => Math.max(5, (stdout.rows ?? 24) - 8);
|
|
129
|
+
|
|
130
|
+
const draw = () => {
|
|
131
|
+
const h = height();
|
|
132
|
+
const at = pickable[cursor]!;
|
|
133
|
+
if (at < top) top = at;
|
|
134
|
+
if (at >= top + h) top = at - h + 1;
|
|
135
|
+
stdout.write("\x1b[H\x1b[2J");
|
|
136
|
+
stdout.write(`\n ${c.bold(title)} ${c.dim(`${cursor + 1}/${pickable.length}`)}\n\n`);
|
|
137
|
+
for (const [i, row] of rows.slice(top, top + h).entries()) {
|
|
138
|
+
const real = top + i;
|
|
139
|
+
stdout.write(real === at ? ` ${c.cyan("›")} ${fit(row)}\n` : ` ${fit(row)}\n`);
|
|
140
|
+
}
|
|
141
|
+
if (rows.length > h) stdout.write(`\n ${c.dim(top + h < rows.length ? "↓ more" : "")}`);
|
|
142
|
+
stdout.write(`\n\n ${c.dim("↑↓ move · " + footer)}\n`);
|
|
143
|
+
};
|
|
144
|
+
|
|
145
|
+
stdout.write("\x1b[?1049h\x1b[?25l");
|
|
146
|
+
stdin.setRawMode(true);
|
|
147
|
+
stdin.resume();
|
|
148
|
+
draw();
|
|
149
|
+
let chosen: number | null = null;
|
|
150
|
+
await new Promise<void>((resolve) => {
|
|
151
|
+
const onData = (chunk: Buffer) => {
|
|
152
|
+
const keys = chunk.toString();
|
|
153
|
+
for (let i = 0; i < keys.length; i++) {
|
|
154
|
+
const rest = keys.slice(i);
|
|
155
|
+
if (rest.startsWith("\x03")) process.exit(130);
|
|
156
|
+
if (rest.startsWith("q") || rest === "\x1b" || rest.startsWith("\x7f") || rest.startsWith("\b")) {
|
|
157
|
+
stdin.off("data", onData); return resolve();
|
|
158
|
+
}
|
|
159
|
+
if (rest.startsWith("\r") || rest.startsWith("\n")) {
|
|
160
|
+
chosen = pickable[cursor]!; stdin.off("data", onData); return resolve();
|
|
161
|
+
}
|
|
162
|
+
if (rest.startsWith("\x1b[A")) { move(-1); i += 2; }
|
|
163
|
+
else if (rest.startsWith("\x1b[B")) { move(1); i += 2; }
|
|
164
|
+
else if (rest.startsWith("k")) move(-1);
|
|
165
|
+
else if (rest.startsWith("j")) move(1);
|
|
166
|
+
else continue;
|
|
167
|
+
draw();
|
|
168
|
+
}
|
|
169
|
+
};
|
|
170
|
+
stdin.on("data", onData);
|
|
171
|
+
});
|
|
172
|
+
stdin.setRawMode(false);
|
|
173
|
+
stdin.pause();
|
|
174
|
+
stdout.write("\x1b[?25h\x1b[?1049l");
|
|
175
|
+
return chosen;
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
async function runsView() {
|
|
179
|
+
for (;;) {
|
|
180
|
+
const runs = readRuns(state!);
|
|
181
|
+
const width = Math.max(...runs.map((r) => r.id.length));
|
|
182
|
+
const rows = runs.map((r) => {
|
|
183
|
+
const mark = r.stage === "done" ? c.dim("done ")
|
|
184
|
+
: r.stage === "abandoned" ? c.dim("dropped")
|
|
185
|
+
: r.awaiting ? c.amber("waiting") : c.cyan("running");
|
|
186
|
+
// The mark already says done or dropped; repeating the stage beside it is noise.
|
|
187
|
+
const tail = r.stage === "done" || r.stage === "abandoned" ? "" : (r.awaiting ?? r.stage);
|
|
188
|
+
return `${mark} ${r.id.padEnd(width)} ${c.dim(tail)}`;
|
|
189
|
+
});
|
|
190
|
+
const i = await pick("Runs", rows);
|
|
191
|
+
if (i === null) return;
|
|
192
|
+
await runDetail(runs[i]!);
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
// What a run is, read off its own files rather than summarised from memory.
|
|
197
|
+
async function runDetail(r: Run) {
|
|
198
|
+
const dir = join(state!, "runs", r.id);
|
|
199
|
+
const lines: string[] = [c.bold(r.id), ""];
|
|
200
|
+
lines.push(` stage ${r.stage}`);
|
|
201
|
+
lines.push(` waiting ${r.awaiting ?? c.dim("nothing — it can carry on")}`);
|
|
202
|
+
|
|
203
|
+
const request = section(join(dir, "00-request.md"), 6);
|
|
204
|
+
if (request.length) lines.push("", c.bold(" Request"), ...request.map((l) => " " + l));
|
|
205
|
+
|
|
206
|
+
const findings = readFindings(join(dir, "04-review.md"));
|
|
207
|
+
if (findings.length) {
|
|
208
|
+
lines.push("", c.bold(` Findings (${findings.length})`));
|
|
209
|
+
for (const f of findings.slice(0, 8)) lines.push(" " + f);
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
const items = readBacklog(join(dir, "06-backlog.md"));
|
|
213
|
+
if (items.length) {
|
|
214
|
+
lines.push("", c.bold(` Backlog (${items.length} open)`));
|
|
215
|
+
for (const it of items.slice(0, 8)) lines.push(" " + it);
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
lines.push("", c.dim(` files in ${tilde(dir)}`));
|
|
219
|
+
await report(lines);
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
// Cut to the window, counting printable characters only -- a row is mostly colour
|
|
223
|
+
// codes by the time it gets here, and they take no space on screen.
|
|
224
|
+
function fit(row: string) {
|
|
225
|
+
const room = (stdout.columns ?? 80) - 8;
|
|
226
|
+
const plain = row.replace(/\x1b\[[0-9;]*m/g, "");
|
|
227
|
+
if (plain.length <= room) return row;
|
|
228
|
+
let out = "", seen = 0, i = 0;
|
|
229
|
+
while (i < row.length && seen < room - 1) {
|
|
230
|
+
const esc = /^\x1b\[[0-9;]*m/.exec(row.slice(i));
|
|
231
|
+
if (esc) { out += esc[0]; i += esc[0].length; continue; }
|
|
232
|
+
out += row[i]; i++; seen++;
|
|
233
|
+
}
|
|
234
|
+
return out + "…\x1b[0m";
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
async function backlogView() {
|
|
238
|
+
const groups = allBacklog(state!);
|
|
239
|
+
const parsed = groups.map((g) => ({ run: g.run, items: g.items.map(parseItem) }));
|
|
240
|
+
const sevWidth = Math.max(0, ...parsed.flatMap((g) => g.items.map((i) => i.severity?.length ?? 0)));
|
|
241
|
+
const whereWidth = Math.max(0, ...parsed.flatMap((g) => g.items.map((i) => i.where?.length ?? 0)));
|
|
242
|
+
|
|
243
|
+
const rows: string[] = [];
|
|
244
|
+
const full: (Item | null)[] = []; // null where the row is a heading
|
|
245
|
+
for (const g of parsed) {
|
|
246
|
+
rows.push(c.bold(g.run.replace(/^\d{4}-\d{2}-\d{2}-/, ""))); full.push(null);
|
|
247
|
+
for (const it of g.items) {
|
|
248
|
+
// Columns only where there is something to put in them: an item written as
|
|
249
|
+
// prose should not be pushed across the screen by other items' severities.
|
|
250
|
+
const cols: string[] = [];
|
|
251
|
+
if (sevWidth) {
|
|
252
|
+
const sev = (it.severity ?? "").padEnd(sevWidth);
|
|
253
|
+
cols.push(it.severity === "critical" || it.severity === "high" ? c.amber(sev) : c.dim(sev));
|
|
254
|
+
}
|
|
255
|
+
if (whereWidth) cols.push(c.dim((it.where ?? "").padEnd(whereWidth)));
|
|
256
|
+
rows.push(" " + [...cols, it.text].join(" "));
|
|
257
|
+
full.push(it);
|
|
258
|
+
}
|
|
259
|
+
}
|
|
260
|
+
for (;;) {
|
|
261
|
+
const i = await pick("Backlog — open items", rows, "enter read · backspace back",
|
|
262
|
+
(n) => full[n] === null);
|
|
263
|
+
if (i === null) return;
|
|
264
|
+
// Rows are cut to the window, so reading one means opening it.
|
|
265
|
+
const it = full[i];
|
|
266
|
+
if (!it) continue;
|
|
267
|
+
await report([
|
|
268
|
+
c.bold(it.where ?? "Backlog item"),
|
|
269
|
+
it.severity ? c.dim(` ${it.severity}`) : "",
|
|
270
|
+
"",
|
|
271
|
+
...wrap(it.raw.replace(/`/g, ""), 2),
|
|
272
|
+
]);
|
|
273
|
+
}
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
function wrap(text: string, indent = 0) {
|
|
277
|
+
const room = (stdout.columns ?? 80) - 6 - indent;
|
|
278
|
+
const out: string[] = [];
|
|
279
|
+
let line = "";
|
|
280
|
+
for (const word of text.split(/\s+/)) {
|
|
281
|
+
if (line && line.length + word.length + 1 > room) { out.push(" ".repeat(indent) + line); line = word; }
|
|
282
|
+
else line = line ? `${line} ${word}` : word;
|
|
283
|
+
}
|
|
284
|
+
if (line) out.push(" ".repeat(indent) + line);
|
|
285
|
+
return out;
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
async function menu() {
|
|
289
|
+
const draw = () => {
|
|
290
|
+
const acts = actions();
|
|
291
|
+
const runs = state ? readRuns(state) : [];
|
|
292
|
+
const waiting = runs.filter((r) => r.awaiting && r.stage !== "abandoned");
|
|
293
|
+
const flight = runs.filter((r) => !r.awaiting && !["done", "abandoned"].includes(r.stage));
|
|
294
|
+
const done = runs.filter((r) => r.stage === "done");
|
|
295
|
+
|
|
296
|
+
stdout.write("\x1b[H\x1b[2J");
|
|
297
|
+
|
|
298
|
+
// The mascot sits beside the header rather than above it; stacked, it pushes
|
|
299
|
+
// the runs -- the thing you opened this for -- below the fold on a short window.
|
|
300
|
+
const cols = stdout.columns ?? 80, rows = stdout.rows ?? 24;
|
|
301
|
+
const wide = cols >= 64;
|
|
302
|
+
const art = rows >= 30 ? TANUKI : TANUKI_SMALL;
|
|
303
|
+
const at = art === TANUKI ? 6 : 2; // where the text sits against it
|
|
304
|
+
|
|
305
|
+
const beside = Array(art.length).fill("");
|
|
306
|
+
beside[at] = `${c.bold("kaizen")} ${c.dim(version(repo))}`;
|
|
307
|
+
beside[at + 1] = c.dim(state ? tilde(dirname(state)) : "no project here");
|
|
308
|
+
beside[at + 3] = c.dim(agents.join(", "));
|
|
309
|
+
stdout.write("\n");
|
|
310
|
+
if (wide) {
|
|
311
|
+
const gutter = Math.max(...art.map((l) => l.length)) + 4;
|
|
312
|
+
for (const [i, line] of art.entries()) {
|
|
313
|
+
stdout.write(" " + c.cyan(line.padEnd(gutter)) + (beside[i] ?? "") + "\n");
|
|
314
|
+
}
|
|
315
|
+
} else {
|
|
316
|
+
stdout.write(` ${c.bold("kaizen")} ${c.dim(version(repo))} ${c.dim(state ? tilde(dirname(state)) : "no project here")}\n`);
|
|
317
|
+
stdout.write(` ${c.dim(agents.join(", "))}\n`);
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
if (state) {
|
|
321
|
+
const runLines = [
|
|
322
|
+
...waiting.map((r) => `${c.amber("●")} ${short(r.id)} ${c.dim(r.awaiting ?? "")}`),
|
|
323
|
+
...flight.map((r) => `${c.cyan("●")} ${short(r.id)} ${c.dim(r.stage)}`),
|
|
324
|
+
...(done.length ? [c.dim(`● ${done.length} done`)] : []),
|
|
325
|
+
];
|
|
326
|
+
if (!runLines.length) runLines.push(c.dim("none yet"));
|
|
327
|
+
|
|
328
|
+
const items = allBacklog(state).flatMap((g) => g.items.map(parseItem));
|
|
329
|
+
const backLines = items.slice(0, 6).map((it) => {
|
|
330
|
+
const sev = it.severity ? (it.severity === "critical" || it.severity === "high"
|
|
331
|
+
? c.amber(it.severity) : c.dim(it.severity)) + " " : "";
|
|
332
|
+
return sev + (it.where ? c.dim(it.where) + " " : "") + it.text;
|
|
333
|
+
});
|
|
334
|
+
if (!backLines.length) backLines.push(c.dim("nothing open"));
|
|
335
|
+
else if (items.length > 6) backLines.push(c.dim(`… ${items.length - 6} more`));
|
|
336
|
+
|
|
337
|
+
// Side by side while there is room for two readable columns; stacked below
|
|
338
|
+
// that, since a panel squeezed under forty columns shows nothing useful.
|
|
339
|
+
const inner = Math.floor((cols - 9) / 2);
|
|
340
|
+
if (cols >= 96) {
|
|
341
|
+
// Both panels get the same body height so their bottom edges meet.
|
|
342
|
+
const tall = Math.max(runLines.length, backLines.length);
|
|
343
|
+
while (runLines.length < tall) runLines.push("");
|
|
344
|
+
while (backLines.length < tall) backLines.push("");
|
|
345
|
+
const left = panel("Runs", runLines, inner);
|
|
346
|
+
const right = panel(`Backlog — ${items.length} open`, backLines, inner);
|
|
347
|
+
stdout.write("\n");
|
|
348
|
+
for (let i = 0; i < Math.max(left.length, right.length); i++) {
|
|
349
|
+
stdout.write(" " + (left[i] ?? " ".repeat(inner + 2)) + " " + (right[i] ?? "") + "\n");
|
|
350
|
+
}
|
|
351
|
+
} else {
|
|
352
|
+
stdout.write("\n");
|
|
353
|
+
for (const line of panel("Runs", runLines, cols - 8)) stdout.write(" " + line + "\n");
|
|
354
|
+
for (const line of panel(`Backlog — ${items.length} open`, backLines, cols - 8)) stdout.write(" " + line + "\n");
|
|
355
|
+
}
|
|
356
|
+
} else {
|
|
357
|
+
stdout.write(`\n ${c.dim("This folder has no .kaizen/. Set it up, or just ask your agent for something —")}\n`);
|
|
358
|
+
stdout.write(` ${c.dim("the first run creates it.")}\n`);
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
stdout.write("\n");
|
|
362
|
+
for (const [i, a] of acts.entries()) {
|
|
363
|
+
const on = i === active;
|
|
364
|
+
stdout.write(on
|
|
365
|
+
? ` ${c.cyan("›")} ${c.bold(a.label.padEnd(20))}${c.dim(a.hint)}\n`
|
|
366
|
+
: ` ${c.dim(a.label)}\n`);
|
|
367
|
+
}
|
|
368
|
+
stdout.write(`\n ${c.dim("↑↓ move · enter choose · q or backspace quit")}\n`);
|
|
369
|
+
};
|
|
370
|
+
|
|
371
|
+
stdout.write("\x1b[?1049h\x1b[?25l");
|
|
372
|
+
stdin.setRawMode(true);
|
|
373
|
+
stdin.resume();
|
|
374
|
+
draw();
|
|
375
|
+
|
|
376
|
+
let chosen = "quit";
|
|
377
|
+
await new Promise<void>((resolve) => {
|
|
378
|
+
const onData = (chunk: Buffer) => {
|
|
379
|
+
const acts = actions();
|
|
380
|
+
const keys = chunk.toString();
|
|
381
|
+
for (let i = 0; i < keys.length; i++) {
|
|
382
|
+
const rest = keys.slice(i);
|
|
383
|
+
if (rest.startsWith("\x03") || rest.startsWith("q") || rest === "\x1b"
|
|
384
|
+
|| rest.startsWith("\x7f") || rest.startsWith("\b")) {
|
|
385
|
+
stdin.off("data", onData); return resolve();
|
|
386
|
+
}
|
|
387
|
+
if (rest.startsWith("\r") || rest.startsWith("\n")) {
|
|
388
|
+
chosen = acts[active]!.key;
|
|
389
|
+
stdin.off("data", onData); return resolve();
|
|
390
|
+
}
|
|
391
|
+
if (rest.startsWith("\x1b[A")) { active = (active - 1 + acts.length) % acts.length; i += 2; }
|
|
392
|
+
else if (rest.startsWith("\x1b[B")) { active = (active + 1) % acts.length; i += 2; }
|
|
393
|
+
else if (rest.startsWith("k")) active = (active - 1 + acts.length) % acts.length;
|
|
394
|
+
else if (rest.startsWith("j")) active = (active + 1) % acts.length;
|
|
395
|
+
else continue;
|
|
396
|
+
draw();
|
|
397
|
+
}
|
|
398
|
+
};
|
|
399
|
+
stdin.on("data", onData);
|
|
400
|
+
});
|
|
401
|
+
|
|
402
|
+
stdin.setRawMode(false);
|
|
403
|
+
stdin.pause();
|
|
404
|
+
stdout.write("\x1b[?25h\x1b[?1049l");
|
|
405
|
+
return chosen;
|
|
406
|
+
}
|
|
407
|
+
}
|
|
408
|
+
|
|
409
|
+
function locate() {
|
|
410
|
+
let dir = process.cwd();
|
|
411
|
+
for (;;) {
|
|
412
|
+
if (existsSync(join(dir, ".kaizen"))) return join(dir, ".kaizen");
|
|
413
|
+
if (existsSync(join(dir, ".git"))) return null;
|
|
414
|
+
const up = dirname(dir);
|
|
415
|
+
if (up === dir) return existsSync(join(home, ".kaizen")) ? join(home, ".kaizen") : null;
|
|
416
|
+
dir = up;
|
|
417
|
+
}
|
|
418
|
+
}
|
|
419
|
+
|
|
420
|
+
function readRuns(state: string): Run[] {
|
|
421
|
+
const dir = join(state, "runs");
|
|
422
|
+
if (!existsSync(dir)) return [];
|
|
423
|
+
return readdirSync(dir).flatMap((id) => {
|
|
424
|
+
try {
|
|
425
|
+
const s = JSON.parse(readFileSync(join(dir, id, "state.json"), "utf8"));
|
|
426
|
+
return [{ id, stage: s.stage ?? "?", awaiting: s.awaiting ?? null }];
|
|
427
|
+
} catch { return []; }
|
|
428
|
+
}).reverse();
|
|
429
|
+
}
|
|
430
|
+
|
|
431
|
+
// Open items are the ones worth a number; done and rejected stay as record.
|
|
432
|
+
function backlog(state: string) {
|
|
433
|
+
const files = [join(state, "backlog.md")];
|
|
434
|
+
const runs = join(state, "runs");
|
|
435
|
+
if (existsSync(runs)) for (const id of readdirSync(runs)) files.push(join(runs, id, "06-backlog.md"));
|
|
436
|
+
let n = 0;
|
|
437
|
+
for (const f of files) {
|
|
438
|
+
if (!existsSync(f)) continue;
|
|
439
|
+
n += (readFileSync(f, "utf8").match(/^\s*-\s*open:/gm) ?? []).length;
|
|
440
|
+
}
|
|
441
|
+
return n;
|
|
442
|
+
}
|
|
443
|
+
|
|
444
|
+
function version(repo: string) {
|
|
445
|
+
try { return JSON.parse(readFileSync(join(repo, "package.json"), "utf8")).version; } catch { return ""; }
|
|
446
|
+
}
|
|
447
|
+
function tilde(p: string) { return p.startsWith(home) ? "~" + p.slice(home.length) : p; }
|
|
448
|
+
|
|
449
|
+
// The first few real lines of a file's body, blank lines and headings dropped.
|
|
450
|
+
function section(file: string, n: number) {
|
|
451
|
+
if (!existsSync(file)) return [];
|
|
452
|
+
return readFileSync(file, "utf8").split("\n")
|
|
453
|
+
.map((l) => l.trim())
|
|
454
|
+
.filter((l) => l && !l.startsWith("#"))
|
|
455
|
+
.slice(0, n);
|
|
456
|
+
}
|
|
457
|
+
|
|
458
|
+
function readFindings(file: string) {
|
|
459
|
+
if (!existsSync(file)) return [];
|
|
460
|
+
return readFileSync(file, "utf8").split("\n")
|
|
461
|
+
.map((l) => l.trim())
|
|
462
|
+
.filter((l) => /^\d+\.\s/.test(l) || /\b(critical|high|medium|low)\b:/.test(l));
|
|
463
|
+
}
|
|
464
|
+
|
|
465
|
+
// Backlog items are often a reviewer's finding pasted verbatim -- a backticked
|
|
466
|
+
// path, a severity, then the sentence. Split those apart so a list can show the
|
|
467
|
+
// severity and the place as columns and leave the prose to speak for itself.
|
|
468
|
+
type Item = { severity: string | null; where: string | null; text: string; raw: string };
|
|
469
|
+
|
|
470
|
+
function parseItem(raw: string): Item {
|
|
471
|
+
const m = /^`?([^`:]+(?::\d+)?)`?\s*[:—-]\s*(critical|high|medium|low)\s*[:—-]\s*(.+)$/i.exec(raw);
|
|
472
|
+
if (m) {
|
|
473
|
+
const where = m[1]!.trim().split("/").pop()!;
|
|
474
|
+
return { severity: m[2]!.toLowerCase(), where, text: tidy(m[3]!), raw };
|
|
475
|
+
}
|
|
476
|
+
const sev = /^(critical|high|medium|low)\s*[:—-]\s*(.+)$/i.exec(raw);
|
|
477
|
+
if (sev) return { severity: sev[1]!.toLowerCase(), where: null, text: tidy(sev[2]!), raw };
|
|
478
|
+
return { severity: null, where: null, text: tidy(raw), raw };
|
|
479
|
+
}
|
|
480
|
+
|
|
481
|
+
// One readable sentence: no markdown, and the fix belongs in the full view. The
|
|
482
|
+
// first word is left alone -- capitalising it turns request_for_id into
|
|
483
|
+
// Request_for_id, which is a different identifier.
|
|
484
|
+
function tidy(text: string) {
|
|
485
|
+
return text.replace(/`/g, "").split(/\s+Fix:\s+/)[0]!.trim();
|
|
486
|
+
}
|
|
487
|
+
|
|
488
|
+
function readBacklog(file: string) {
|
|
489
|
+
if (!existsSync(file)) return [];
|
|
490
|
+
return readFileSync(file, "utf8").split("\n")
|
|
491
|
+
.map((l) => l.trim())
|
|
492
|
+
.filter((l) => /^-\s*open:/.test(l))
|
|
493
|
+
.map((l) => l.replace(/^-\s*open:\s*/, ""));
|
|
494
|
+
}
|
|
495
|
+
|
|
496
|
+
function allBacklog(state: string) {
|
|
497
|
+
const out: { run: string; items: string[] }[] = [];
|
|
498
|
+
const runs = join(state, "runs");
|
|
499
|
+
if (existsSync(runs)) {
|
|
500
|
+
for (const id of readdirSync(runs).reverse()) {
|
|
501
|
+
const items = readBacklog(join(runs, id, "06-backlog.md"));
|
|
502
|
+
if (items.length) out.push({ run: id, items });
|
|
503
|
+
}
|
|
504
|
+
}
|
|
505
|
+
const orphan = readBacklog(join(state, "backlog.md"));
|
|
506
|
+
if (orphan.length) out.push({ run: "Orphaned", items: orphan });
|
|
507
|
+
return out;
|
|
508
|
+
}
|
|
509
|
+
|
|
510
|
+
// Printable width: a row is mostly colour codes by the time it is drawn, and they
|
|
511
|
+
// occupy no columns.
|
|
512
|
+
function vis(s: string) { return s.replace(/\x1b\[[0-9;]*m/g, "").length; }
|
|
513
|
+
|
|
514
|
+
function cut(s: string, room: number) {
|
|
515
|
+
if (vis(s) <= room) return s;
|
|
516
|
+
let out = "", seen = 0, i = 0;
|
|
517
|
+
while (i < s.length && seen < room - 1) {
|
|
518
|
+
const esc = /^\x1b\[[0-9;]*m/.exec(s.slice(i));
|
|
519
|
+
if (esc) { out += esc[0]; i += esc[0].length; continue; }
|
|
520
|
+
out += s[i]; i++; seen++;
|
|
521
|
+
}
|
|
522
|
+
return out + "…\x1b[0m";
|
|
523
|
+
}
|
|
524
|
+
|
|
525
|
+
const DIM = "\x1b[2m", OFF = "\x1b[0m";
|
|
526
|
+
|
|
527
|
+
function panel(title: string, lines: string[], inner: number) {
|
|
528
|
+
const bar = "─".repeat(Math.max(0, inner - vis(title) - 3));
|
|
529
|
+
const out = [`${DIM}╭─${OFF} ${title} ${DIM}${bar}╮${OFF}`];
|
|
530
|
+
for (const line of lines) {
|
|
531
|
+
const body = cut(line, inner - 2);
|
|
532
|
+
out.push(`${DIM}│${OFF} ${body}${" ".repeat(Math.max(0, inner - 2 - vis(body)))} ${DIM}│${OFF}`);
|
|
533
|
+
}
|
|
534
|
+
out.push(`${DIM}╰${"─".repeat(inner)}╯${OFF}`);
|
|
535
|
+
return out;
|
|
536
|
+
}
|
|
537
|
+
|
|
538
|
+
// Run ids carry the date they started; the list is already in date order.
|
|
539
|
+
function short(id: string) { return id.replace(/^\d{4}-\d{2}-\d{2}-/, ""); }
|