kaizen-agent 1.0.0 → 1.2.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 +29 -2
- package/cli/dashboard.ts +730 -29
- package/cli/settings.ts +57 -25
- package/package.json +1 -1
- package/skills/kaizen/SKILL.md +1 -0
- package/skills/kaizen/config.default.yml +7 -0
- package/skills/kaizen/spec-build.md +9 -0
- package/skills/kaizen/spec-plan.md +20 -4
- package/skills/kaizen/spec-review.md +15 -0
package/README.md
CHANGED
|
@@ -5,6 +5,7 @@
|
|
|
5
5
|
<p align="center"><i>Your AI agent shows you the plan before it does the work,<br>and a second agent checks what it did.</i></p>
|
|
6
6
|
|
|
7
7
|
<p align="center">
|
|
8
|
+
<a href="https://github.com/hfadhlullah/kaizen/stargazers"><img alt="stars" src="https://img.shields.io/github/stars/hfadhlullah/kaizen?style=social"></a>
|
|
8
9
|
<a href="https://www.npmjs.com/package/kaizen-agent"><img alt="npm" src="https://img.shields.io/npm/v/kaizen-agent?color=2d50a5&label=npm"></a>
|
|
9
10
|
<a href="https://github.com/hfadhlullah/kaizen/blob/main/LICENSE"><img alt="license" src="https://img.shields.io/npm/l/kaizen-agent?color=2d50a5"></a>
|
|
10
11
|
<img alt="dependencies" src="https://img.shields.io/badge/dependencies-0-2d50a5">
|
|
@@ -13,6 +14,16 @@
|
|
|
13
14
|
|
|
14
15
|
---
|
|
15
16
|
|
|
17
|
+
```bash
|
|
18
|
+
# 1. Install Bun (prerequisite)
|
|
19
|
+
curl -fsSL https://bun.sh/install | bash
|
|
20
|
+
|
|
21
|
+
# 2. Install Kaizen
|
|
22
|
+
bunx kaizen-agent
|
|
23
|
+
```
|
|
24
|
+
|
|
25
|
+
---
|
|
26
|
+
|
|
16
27
|
## The problem
|
|
17
28
|
|
|
18
29
|
You ask an AI to do something real — write the launch email, draft the policy,
|
|
@@ -217,12 +228,28 @@ kaizen Done, and reviewed. Two things found:
|
|
|
217
228
|
|
|
218
229
|
## Install
|
|
219
230
|
|
|
231
|
+
### Prerequisites: Bun
|
|
232
|
+
|
|
233
|
+
Kaizen's installer and interactive dashboard require [Bun](https://bun.sh). If you don't have Bun installed yet:
|
|
234
|
+
|
|
235
|
+
```bash
|
|
236
|
+
# macOS, Linux, WSL
|
|
237
|
+
curl -fsSL https://bun.sh/install | bash
|
|
238
|
+
|
|
239
|
+
# or via Homebrew
|
|
240
|
+
brew install oven-sh/bun/bun
|
|
241
|
+
|
|
242
|
+
# or via npm
|
|
243
|
+
npm install -g bun
|
|
244
|
+
```
|
|
245
|
+
|
|
246
|
+
### Install Kaizen
|
|
247
|
+
|
|
220
248
|
```bash
|
|
221
249
|
bunx kaizen-agent
|
|
222
250
|
```
|
|
223
251
|
|
|
224
|
-
It walks you through where to install, then tells you what to type next.
|
|
225
|
-
`npx` too. Restart your editor afterwards.
|
|
252
|
+
It walks you through where to install, prompts for your workflow preset (`medium`, `low`, or `ultra`), then tells you what to type next. Restart your editor afterwards.
|
|
226
253
|
|
|
227
254
|
Run from inside a project it asks whether to install globally or for that project
|
|
228
255
|
alone; anywhere else — your home directory, a plain folder, a script — it installs
|
package/cli/dashboard.ts
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
// What `kaizen` shows once it is installed: the state of this project's runs, and
|
|
2
2
|
// the few things you would have opened a terminal to do.
|
|
3
|
-
import { existsSync, readFileSync, readdirSync, appendFileSync, mkdirSync, statSync } from "node:fs";
|
|
3
|
+
import { existsSync, readFileSync, readdirSync, appendFileSync, writeFileSync, mkdirSync, statSync, watch } from "node:fs";
|
|
4
4
|
import { join, dirname, basename } from "node:path";
|
|
5
5
|
import { homedir } from "node:os";
|
|
6
6
|
|
|
@@ -14,9 +14,11 @@ const c = {
|
|
|
14
14
|
cyan: (s: string) => `\x1b[36m${s}\x1b[0m`,
|
|
15
15
|
green: (s: string) => `\x1b[32m${s}\x1b[0m`,
|
|
16
16
|
amber: (s: string) => `\x1b[33m${s}\x1b[0m`,
|
|
17
|
+
// Red is spent on one thing only: a run that cannot move until the user acts.
|
|
18
|
+
red: (s: string) => rgb(232, 80, 80, s),
|
|
17
19
|
};
|
|
18
20
|
|
|
19
|
-
type Run = { id: string; stage: string; awaiting: string | null };
|
|
21
|
+
type Run = { id: string; stage: string; awaiting: string | null; moved: number };
|
|
20
22
|
|
|
21
23
|
// The mascot, rendered from assets/kaizen.jpg. Braille packs 2x4 dots into one
|
|
22
24
|
// character, which is the only way outline art survives a downsample this far.
|
|
@@ -116,6 +118,56 @@ function findTerminal(cwd: string, fullCmd: string[]): { cmd: string[]; detached
|
|
|
116
118
|
return null;
|
|
117
119
|
}
|
|
118
120
|
|
|
121
|
+
|
|
122
|
+
// ---------------------------------------------------------------- mouse
|
|
123
|
+
|
|
124
|
+
// SGR reporting (1006), not X10: X10 encodes coordinates as single bytes and gives
|
|
125
|
+
// up past column 223, which a five-column board reaches on any wide terminal.
|
|
126
|
+
type MouseEv = { button: number; x: number; y: number; press: boolean };
|
|
127
|
+
|
|
128
|
+
let mouseWanted = false; // what config.yml asked for
|
|
129
|
+
let mouseArmed = false; // what the terminal is actually in
|
|
130
|
+
|
|
131
|
+
function readMouse(rest: string): { ev: MouseEv; len: number } | null {
|
|
132
|
+
const m = /^\x1b\[<(\d+);(\d+);(\d+)([Mm])/.exec(rest);
|
|
133
|
+
if (!m) return null;
|
|
134
|
+
return {
|
|
135
|
+
ev: { button: +m[1]!, x: +m[2]!, y: +m[3]!, press: m[4] === "M" },
|
|
136
|
+
len: m[0].length,
|
|
137
|
+
};
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
const WHEEL_UP = 64, WHEEL_DOWN = 65;
|
|
141
|
+
const isClick = (e: MouseEv) => e.press && e.button === 0;
|
|
142
|
+
|
|
143
|
+
function mouseOn() {
|
|
144
|
+
if (!mouseWanted || mouseArmed) return;
|
|
145
|
+
process.stdout.write("\x1b[?1000h\x1b[?1006h");
|
|
146
|
+
mouseArmed = true;
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
function mouseOff() {
|
|
150
|
+
if (!mouseArmed) return;
|
|
151
|
+
process.stdout.write("\x1b[?1006l\x1b[?1000l");
|
|
152
|
+
mouseArmed = false;
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
// Ctrl-C calls process.exit, which unwinds no finally block anywhere. A terminal
|
|
156
|
+
// left reporting spits escape junk at every click until `reset`, so this guard has
|
|
157
|
+
// to survive an abrupt exit.
|
|
158
|
+
process.on("exit", () => { if (mouseArmed) process.stdout.write("\x1b[?1006l\x1b[?1000l"); });
|
|
159
|
+
|
|
160
|
+
// Off unless the config says otherwise: while reporting is on, the terminal cannot
|
|
161
|
+
// select text with the mouse.
|
|
162
|
+
function mouseFromConfig(state: string | null) {
|
|
163
|
+
for (const f of [state ? join(state, "config.yml") : null, join(home, ".kaizen", "config.yml")]) {
|
|
164
|
+
if (!f || !existsSync(f)) continue;
|
|
165
|
+
const m = /^\s*ui:\s*\n(?:\s*#.*\n)*\s*mouse:\s*(\S+)/m.exec(readFileSync(f, "utf8"));
|
|
166
|
+
if (m) return m[1] === "true";
|
|
167
|
+
}
|
|
168
|
+
return false;
|
|
169
|
+
}
|
|
170
|
+
|
|
119
171
|
export async function dashboard(
|
|
120
172
|
repo: string,
|
|
121
173
|
agents: string[],
|
|
@@ -123,6 +175,7 @@ export async function dashboard(
|
|
|
123
175
|
) {
|
|
124
176
|
const { stdin, stdout } = process;
|
|
125
177
|
const state = locate();
|
|
178
|
+
mouseWanted = mouseFromConfig(state);
|
|
126
179
|
// Opening a project is what makes it known; nothing else asks the user to register.
|
|
127
180
|
if (state && dirname(state) !== home) remember(dirname(state));
|
|
128
181
|
|
|
@@ -150,6 +203,7 @@ export async function dashboard(
|
|
|
150
203
|
{ key: "projects", label: "All projects", hint: "every project kaizen knows about" },
|
|
151
204
|
] : []),
|
|
152
205
|
...(state ? [
|
|
206
|
+
{ key: "board", label: "Board", hint: "ideas and runs, by stage" },
|
|
153
207
|
{ key: "runs", label: "Runs", hint: "every run, and what each is waiting on" },
|
|
154
208
|
{ key: "backlog", label: "Backlog", hint: "what runs noticed and did not do" },
|
|
155
209
|
] : []),
|
|
@@ -162,15 +216,21 @@ export async function dashboard(
|
|
|
162
216
|
];
|
|
163
217
|
|
|
164
218
|
let active = 0;
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
219
|
+
mouseOn();
|
|
220
|
+
try {
|
|
221
|
+
for (;;) {
|
|
222
|
+
const chosen = await menu();
|
|
223
|
+
if (chosen === "quit") return;
|
|
224
|
+
if (chosen === "projects") { await projectsView(); continue; }
|
|
225
|
+
if (chosen === "board") { await boardView(); continue; }
|
|
226
|
+
if (chosen === "runs") { await runsView(); continue; }
|
|
227
|
+
if (chosen === "backlog") { await backlogView(); continue; }
|
|
228
|
+
const lines = await run(chosen);
|
|
229
|
+
if (lines?.length) await report(lines);
|
|
230
|
+
survey(); // an action may have changed what exists
|
|
231
|
+
}
|
|
232
|
+
} finally {
|
|
233
|
+
mouseOff();
|
|
174
234
|
}
|
|
175
235
|
|
|
176
236
|
// An action that has something to say says it here, on its own screen, rather
|
|
@@ -187,7 +247,11 @@ export async function dashboard(
|
|
|
187
247
|
const once = (chunk: Buffer) => {
|
|
188
248
|
const s = chunk.toString();
|
|
189
249
|
if (s.includes("\x03")) process.exit(130);
|
|
190
|
-
|
|
250
|
+
const mouse = readMouse(s);
|
|
251
|
+
// A click is a keypress on an any-key screen; the release that follows it
|
|
252
|
+
// must not dismiss the next screen too.
|
|
253
|
+
if (mouse && !isClick(mouse.ev)) return;
|
|
254
|
+
key = mouse ? "" : s;
|
|
191
255
|
stdin.off("data", once);
|
|
192
256
|
resolve();
|
|
193
257
|
};
|
|
@@ -247,6 +311,26 @@ export async function dashboard(
|
|
|
247
311
|
for (let i = 0; i < keys.length; i++) {
|
|
248
312
|
const rest = keys.slice(i);
|
|
249
313
|
if (rest.startsWith("\x03")) process.exit(130);
|
|
314
|
+
// Mouse reports are consumed here whether or not they mean anything, so
|
|
315
|
+
// an unexpected one is never read as a burst of keystrokes.
|
|
316
|
+
const mouse = readMouse(rest);
|
|
317
|
+
if (mouse) {
|
|
318
|
+
i += mouse.len - 1;
|
|
319
|
+
const { ev } = mouse;
|
|
320
|
+
if (ev.button === WHEEL_UP) move(-3);
|
|
321
|
+
else if (ev.button === WHEEL_DOWN) move(3);
|
|
322
|
+
else if (isClick(ev)) {
|
|
323
|
+
// Rows begin on the fourth line: blank, title, blank, then the list.
|
|
324
|
+
const real = top + (ev.y - 4);
|
|
325
|
+
const at = pickable.indexOf(real);
|
|
326
|
+
if (at < 0) continue; // a heading, or empty space
|
|
327
|
+
// Click to select; click the selected one to act.
|
|
328
|
+
if (at === cursor) { chosen = real; stdin.off("data", onData); return resolve(); }
|
|
329
|
+
cursor = at;
|
|
330
|
+
} else continue;
|
|
331
|
+
draw();
|
|
332
|
+
continue;
|
|
333
|
+
}
|
|
250
334
|
if (rest.startsWith("q") || rest === "\x1b" || rest.startsWith("\x7f") || rest.startsWith("\b")) {
|
|
251
335
|
stdin.off("data", onData); return resolve();
|
|
252
336
|
}
|
|
@@ -443,6 +527,466 @@ export async function dashboard(
|
|
|
443
527
|
}
|
|
444
528
|
}
|
|
445
529
|
|
|
530
|
+
|
|
531
|
+
// ------------------------------------------------------------------ board
|
|
532
|
+
|
|
533
|
+
// Ideas and runs on one screen, in stage columns. A run's stage belongs to
|
|
534
|
+
// kaizen and cannot be edited here -- a board that could drag one backwards
|
|
535
|
+
// would be lying about what happened. The one exception is abandoning a run,
|
|
536
|
+
// which is the user's decision either way.
|
|
537
|
+
async function boardView() {
|
|
538
|
+
let allProjects = false;
|
|
539
|
+
let cards: Card[] = [];
|
|
540
|
+
let col = 0, row = 0;
|
|
541
|
+
let top = 0; // first visible line, board-wide
|
|
542
|
+
// Where each card actually landed on screen, rebuilt by whichever draw ran.
|
|
543
|
+
// Recording the frame beats recomputing its geometry: the two layouts place
|
|
544
|
+
// cards differently, and a stale guess after a resize clicks the wrong card.
|
|
545
|
+
type Hit = { y: number; x0: number; x1: number; col: number; row: number };
|
|
546
|
+
let hits: Hit[] = [];
|
|
547
|
+
// What each run was awaiting last time we looked, so a redraw can tell the
|
|
548
|
+
// difference between "still blocked" and "just became blocked".
|
|
549
|
+
const wasAwaiting = new Map<string, string | null>();
|
|
550
|
+
let first = true;
|
|
551
|
+
|
|
552
|
+
const states = () => (allProjects
|
|
553
|
+
? [...knownProjects().map((d) => join(d, ".kaizen")), join(home, ".kaizen")]
|
|
554
|
+
: [state!]
|
|
555
|
+
).filter((d, i, all) => all.indexOf(d) === i && existsSync(d));
|
|
556
|
+
|
|
557
|
+
const refresh = () => {
|
|
558
|
+
const now = Date.now();
|
|
559
|
+
const next: Card[] = [];
|
|
560
|
+
// Every project's requests, not just this state dir's: an idea in the global
|
|
561
|
+
// inbox becomes a run in whichever project the agent was started in.
|
|
562
|
+
const requests = [...knownProjects().map((d) => join(d, ".kaizen")), join(home, ".kaizen")]
|
|
563
|
+
.filter((d, i, all) => all.indexOf(d) === i && existsSync(d))
|
|
564
|
+
.flatMap(startedRuns);
|
|
565
|
+
for (const st of states()) {
|
|
566
|
+
const where = label(st);
|
|
567
|
+
for (const it of readInbox(st)) {
|
|
568
|
+
if (it.status !== "open" && it.status !== "started") continue;
|
|
569
|
+
// Retired once any run's request contains this text -- whoever started it.
|
|
570
|
+
// Checking `started` items only would leave every idea acted on outside the
|
|
571
|
+
// board sitting in IDEA forever, and typing `/kaizen ...` in a terminal is
|
|
572
|
+
// the common way a run begins.
|
|
573
|
+
if (requests.some((r) => r.includes(normalise(it.text)))) continue;
|
|
574
|
+
next.push(it.status === "started"
|
|
575
|
+
? { kind: "idea", dot: c.dim("◌"), status: "starting", text: it.text, state: st, where, column: 1, awaiting: null, dim: true }
|
|
576
|
+
: { kind: "idea", dot: c.dim("·"), status: "idea", text: it.text, state: st, where, column: 0, awaiting: null, dim: false });
|
|
577
|
+
}
|
|
578
|
+
for (const r of readRuns(st)) {
|
|
579
|
+
const shown = dotFor(r, now);
|
|
580
|
+
const key = `${st}/${r.id}`;
|
|
581
|
+
const before = wasAwaiting.get(key);
|
|
582
|
+
if (!first && before === null && r.awaiting) {
|
|
583
|
+
notify("kaizen — waiting on you", `${short(r.id)} · ${r.awaiting}`);
|
|
584
|
+
}
|
|
585
|
+
wasAwaiting.set(key, r.awaiting);
|
|
586
|
+
next.push({
|
|
587
|
+
kind: "run", id: r.id, dot: shown.dot, status: shown.status, text: short(r.id), state: st, where, column: columnOf(r.stage),
|
|
588
|
+
awaiting: r.stage === "abandoned" ? null : r.awaiting,
|
|
589
|
+
dim: r.stage === "abandoned",
|
|
590
|
+
});
|
|
591
|
+
}
|
|
592
|
+
}
|
|
593
|
+
cards = next;
|
|
594
|
+
first = false;
|
|
595
|
+
};
|
|
596
|
+
|
|
597
|
+
const inColumn = (n: number) => cards.filter((k) => k.column === n);
|
|
598
|
+
const current = () => inColumn(col)[row];
|
|
599
|
+
|
|
600
|
+
const draw = () => {
|
|
601
|
+
const width = stdout.columns ?? 80;
|
|
602
|
+
// Five columns need room to say anything. Below that the board is worse
|
|
603
|
+
// than the list it replaces, so it becomes one.
|
|
604
|
+
if (width < 100) { drawNarrow(); return; }
|
|
605
|
+
const inner = Math.floor((width - 4) / COLUMNS.length) - 2;
|
|
606
|
+
const cols = COLUMNS.map((meta, n) => {
|
|
607
|
+
const lines: string[] = [];
|
|
608
|
+
const starts: number[] = [];
|
|
609
|
+
for (const k of inColumn(n)) {
|
|
610
|
+
starts.push(lines.length);
|
|
611
|
+
const chosen = n === col && inColumn(n)[row] === k;
|
|
612
|
+
const body = wrapTo(k.text, inner - 4);
|
|
613
|
+
for (const [i, line] of body.entries()) {
|
|
614
|
+
const marker = i === 0 ? (chosen ? c.cyan("›") : " ") : " ";
|
|
615
|
+
const dot = i === 0 ? k.dot : " ";
|
|
616
|
+
const painted = k.awaiting ? c.red(line) : k.dim ? c.dim(line) : line;
|
|
617
|
+
lines.push(`${marker} ${dot} ${painted}`);
|
|
618
|
+
}
|
|
619
|
+
if (allProjects && k.where !== "global") lines.push(" " + c.dim(cut(basename(k.where), inner - 4)));
|
|
620
|
+
lines.push("");
|
|
621
|
+
}
|
|
622
|
+
return { meta, lines, starts };
|
|
623
|
+
});
|
|
624
|
+
|
|
625
|
+
stdout.write("\x1b[H\x1b[2J");
|
|
626
|
+
const scope = allProjects ? "all projects" : label(state!);
|
|
627
|
+
stdout.write(`\n ${c.bold("Board")} ${c.dim(scope)}\n\n`);
|
|
628
|
+
stdout.write(" " + cols.map(({ meta }, n) =>
|
|
629
|
+
pad(n === col ? c.cyan(meta.title) : c.dim(meta.title), inner)).join(" ") + "\n");
|
|
630
|
+
stdout.write(" " + cols.map(() => c.dim("─".repeat(inner))).join(" ") + "\n");
|
|
631
|
+
const height = Math.max(4, (stdout.rows ?? 24) - 11);
|
|
632
|
+
const deep = Math.max(0, ...cols.map((x) => x.lines.length));
|
|
633
|
+
// The selected card drags the viewport with it; a cursor that can leave the
|
|
634
|
+
// screen is a cursor that deletes cards the user cannot see.
|
|
635
|
+
const sel = cols[col]!.starts[row] ?? 0;
|
|
636
|
+
if (sel < top) top = sel;
|
|
637
|
+
if (sel >= top + height) top = sel - height + 1;
|
|
638
|
+
top = Math.max(0, Math.min(top, Math.max(0, deep - height)));
|
|
639
|
+
hits = [];
|
|
640
|
+
for (let i = top; i < Math.min(deep, top + height); i++) {
|
|
641
|
+
// Cards begin on the sixth line: blank, title, blank, headings, rule.
|
|
642
|
+
const y = 6 + (i - top);
|
|
643
|
+
for (const [n, col_] of cols.entries()) {
|
|
644
|
+
const x0 = 3 + n * (inner + 2);
|
|
645
|
+
const owns = col_.starts.reduce((found, at, idx) => (at <= i ? idx : found), -1);
|
|
646
|
+
if (owns < 0) continue;
|
|
647
|
+
const ends = col_.starts[owns + 1] ?? col_.lines.length;
|
|
648
|
+
if (i >= ends || !(col_.lines[i] ?? "").trim()) continue;
|
|
649
|
+
hits.push({ y, x0, x1: x0 + inner - 1, col: n, row: owns });
|
|
650
|
+
}
|
|
651
|
+
stdout.write(" " + cols.map((x) => pad(x.lines[i] ?? "", inner)).join(" ") + "\n");
|
|
652
|
+
}
|
|
653
|
+
const above = top > 0, below = top + height < deep;
|
|
654
|
+
if (above || below) {
|
|
655
|
+
stdout.write(` ${c.dim(`${above ? "↑" : " "} ${below ? "↓ more" : ""}`)}\n`);
|
|
656
|
+
}
|
|
657
|
+
stdout.write("\n" + footer(current()) + legend());
|
|
658
|
+
};
|
|
659
|
+
|
|
660
|
+
// Keys grouped by what they are for -- moving, acting, leaving -- separated by
|
|
661
|
+
// space rather than by middots, which made one long undifferentiated line.
|
|
662
|
+
const footer = (k?: Card) => {
|
|
663
|
+
const acts = k?.kind === "idea"
|
|
664
|
+
? "n new e edit x reject d delete r run"
|
|
665
|
+
: k ? "n new idea x abandon this run" : "n new idea";
|
|
666
|
+
const scope = allProjects ? "a this project" : "a all projects";
|
|
667
|
+
return ` ${c.dim("↑↓←→ move")} ${c.dim(acts)} ${c.dim(scope)} ${c.dim("q back")}\n`;
|
|
668
|
+
};
|
|
669
|
+
|
|
670
|
+
// Only the states actually on the board: a legend for dots nothing is using is
|
|
671
|
+
// six items of noise under every screen.
|
|
672
|
+
const legend = () => {
|
|
673
|
+
const present = new Set(cards.map((k) => k.status));
|
|
674
|
+
const all: [string, string][] = [
|
|
675
|
+
["running", `${c.cyan("●")} ${c.dim("running")}`],
|
|
676
|
+
["stalled", c.dim("◐ stalled")],
|
|
677
|
+
["starting", c.dim("◌ starting")],
|
|
678
|
+
["waiting", `${c.amber("●")} ${c.red("waiting on you")}`],
|
|
679
|
+
["done", c.dim("○ done")],
|
|
680
|
+
["abandoned", c.dim("○ abandoned")],
|
|
681
|
+
["idea", c.dim("· idea")],
|
|
682
|
+
];
|
|
683
|
+
const parts = all.filter(([key]) => present.has(key)).map(([, text]) => text);
|
|
684
|
+
return parts.length ? "\n " + parts.join(" ") + "\n" : "\n";
|
|
685
|
+
};
|
|
686
|
+
|
|
687
|
+
// Under 100 columns the board stacks: same cards, same keys, one list.
|
|
688
|
+
const drawNarrow = () => {
|
|
689
|
+
stdout.write("\x1b[H\x1b[2J");
|
|
690
|
+
stdout.write(`\n ${c.bold("Board")} ${c.dim("narrow terminal — stacked")}\n\n`);
|
|
691
|
+
hits = [];
|
|
692
|
+
// The header writes three newlines, so the first heading lands on line 4.
|
|
693
|
+
let y = 4;
|
|
694
|
+
for (const [n, meta] of COLUMNS.entries()) {
|
|
695
|
+
const items = inColumn(n);
|
|
696
|
+
if (!items.length) continue;
|
|
697
|
+
stdout.write(` ${n === col ? c.cyan(meta.title) : c.dim(meta.title)}\n`);
|
|
698
|
+
y++;
|
|
699
|
+
for (const [idx, k] of items.entries()) {
|
|
700
|
+
const chosen = n === col && items[row] === k;
|
|
701
|
+
const text = k.awaiting ? c.red(k.text) : k.dim ? c.dim(k.text) : k.text;
|
|
702
|
+
stdout.write(` ${chosen ? c.cyan("›") : " "} ${k.dot} ${cut(text, (stdout.columns ?? 80) - 8)}\n`);
|
|
703
|
+
hits.push({ y, x0: 1, x1: stdout.columns ?? 80, col: n, row: idx });
|
|
704
|
+
y++;
|
|
705
|
+
}
|
|
706
|
+
stdout.write("\n");
|
|
707
|
+
y++;
|
|
708
|
+
}
|
|
709
|
+
stdout.write("\n" + footer(current()) + legend());
|
|
710
|
+
};
|
|
711
|
+
|
|
712
|
+
const cardAt = (x: number, y: number) =>
|
|
713
|
+
hits.find((h) => h.y === y && x >= h.x0 && x <= h.x1) ?? null;
|
|
714
|
+
|
|
715
|
+
const clamp = () => {
|
|
716
|
+
col = Math.min(COLUMNS.length - 1, Math.max(0, col));
|
|
717
|
+
row = Math.min(Math.max(0, inColumn(col).length - 1), Math.max(0, row));
|
|
718
|
+
};
|
|
719
|
+
|
|
720
|
+
refresh();
|
|
721
|
+
stdout.write("\x1b[?1049h\x1b[?25l");
|
|
722
|
+
stdin.setRawMode(true);
|
|
723
|
+
stdin.resume();
|
|
724
|
+
|
|
725
|
+
// Kaizen writes these files from other processes; watching them is what makes
|
|
726
|
+
// the board move on its own.
|
|
727
|
+
let timer: ReturnType<typeof setTimeout> | null = null;
|
|
728
|
+
let watchers: ReturnType<typeof watch>[] = [];
|
|
729
|
+
const onChange = () => {
|
|
730
|
+
if (timer) clearTimeout(timer);
|
|
731
|
+
// A state.json being rewritten is briefly unparseable; readRuns already
|
|
732
|
+
// drops what it cannot parse, and the delay keeps that window off screen.
|
|
733
|
+
timer = setTimeout(() => { refresh(); clamp(); draw(); arm(); }, 150);
|
|
734
|
+
};
|
|
735
|
+
|
|
736
|
+
// fs.watch is not recursive here, and the file that actually changes is
|
|
737
|
+
// runs/<id>/state.json -- two levels down. Watching only the state dir and
|
|
738
|
+
// runs/ sees a new run appear and never sees an existing one move, so every
|
|
739
|
+
// run directory is watched too, and the set is rebuilt whenever anything
|
|
740
|
+
// fires in case the change was a run being created.
|
|
741
|
+
function arm() {
|
|
742
|
+
for (const w of watchers) { try { w.close(); } catch { /* already gone */ } }
|
|
743
|
+
watchers = [];
|
|
744
|
+
for (const st of states()) {
|
|
745
|
+
const runs = join(st, "runs");
|
|
746
|
+
const targets = [st, runs];
|
|
747
|
+
try { for (const id of readdirSync(runs)) targets.push(join(runs, id)); } catch { /* no runs yet */ }
|
|
748
|
+
for (const target of targets) {
|
|
749
|
+
try { if (existsSync(target)) watchers.push(watch(target, { persistent: false }, onChange)); }
|
|
750
|
+
catch { /* unwatchable path */ }
|
|
751
|
+
}
|
|
752
|
+
}
|
|
753
|
+
}
|
|
754
|
+
arm();
|
|
755
|
+
|
|
756
|
+
try {
|
|
757
|
+
draw();
|
|
758
|
+
for (;;) {
|
|
759
|
+
const key = await nextKey();
|
|
760
|
+
if (key === "quit") break;
|
|
761
|
+
if (key === "a") { allProjects = !allProjects; refresh(); clamp(); draw(); arm(); continue; }
|
|
762
|
+
if (key === "left") { col--; row = 0; clamp(); draw(); continue; }
|
|
763
|
+
if (key === "right") { col++; row = 0; clamp(); draw(); continue; }
|
|
764
|
+
if (key === "up") { row--; clamp(); draw(); continue; }
|
|
765
|
+
if (key === "down") { row++; clamp(); draw(); continue; }
|
|
766
|
+
|
|
767
|
+
const target = current();
|
|
768
|
+
if (key === "n") {
|
|
769
|
+
const text = await promptLine("New idea", "");
|
|
770
|
+
if (text) {
|
|
771
|
+
const st = target?.state ?? state!;
|
|
772
|
+
writeInbox(st, [...readInbox(st), { status: "open", text }]);
|
|
773
|
+
}
|
|
774
|
+
refresh(); col = 0; clamp(); draw(); continue;
|
|
775
|
+
}
|
|
776
|
+
if (!target) { draw(); continue; }
|
|
777
|
+
// Runs are otherwise read-only -- their stage belongs to kaizen -- but
|
|
778
|
+
// abandoning one is the user's decision to make, and /kaizen abort is the
|
|
779
|
+
// same single transition this writes.
|
|
780
|
+
if (target.kind === "run") {
|
|
781
|
+
if (key === "x" && target.id) {
|
|
782
|
+
const why = await promptLine(`Abandon ${target.text}? Reason`, "");
|
|
783
|
+
if (why) {
|
|
784
|
+
const failed = abandonRun(target.state, target.id, why);
|
|
785
|
+
if (failed) await outside(() => report([c.bold("Could not abandon"), "", c.dim(failed)]));
|
|
786
|
+
}
|
|
787
|
+
refresh(); clamp();
|
|
788
|
+
}
|
|
789
|
+
draw(); continue;
|
|
790
|
+
}
|
|
791
|
+
if (key === "e") {
|
|
792
|
+
const text = await promptLine("Edit idea", target.text);
|
|
793
|
+
if (text) replaceIdea(target, { status: "open", text });
|
|
794
|
+
} else if (key === "x") {
|
|
795
|
+
const why = await promptLine(`Reject: ${target.text}`, "");
|
|
796
|
+
// The spec requires a reason on a rejected item; no reason, no rejection.
|
|
797
|
+
if (why) replaceIdea(target, { status: "rejected", text: `${target.text} | ${why}` });
|
|
798
|
+
} else if (key === "d") {
|
|
799
|
+
if (await confirm(`Delete "${cut(target.text, 40)}"?`)) replaceIdea(target, null);
|
|
800
|
+
} else if (key === "r") {
|
|
801
|
+
const mode = await pickMode();
|
|
802
|
+
// "" is the default-mode choice, and must not leave a double space in
|
|
803
|
+
// the prompt the agent is launched with.
|
|
804
|
+
if (mode !== null) {
|
|
805
|
+
const request = `${mode} ${target.text}`.trim();
|
|
806
|
+
await outside(() => startBacklogItem(parseItem(request), target.state));
|
|
807
|
+
// Without this the idea stays open forever and the board shows the
|
|
808
|
+
// same work twice: once as an idea, once as the run it became.
|
|
809
|
+
replaceIdea(target, { status: "started", text: target.text });
|
|
810
|
+
}
|
|
811
|
+
}
|
|
812
|
+
refresh(); clamp(); draw();
|
|
813
|
+
}
|
|
814
|
+
} finally {
|
|
815
|
+
for (const w of watchers) { try { w.close(); } catch { /* already gone */ } }
|
|
816
|
+
watchers = [];
|
|
817
|
+
if (timer) clearTimeout(timer);
|
|
818
|
+
stdin.setRawMode(false);
|
|
819
|
+
stdin.pause();
|
|
820
|
+
stdout.write("\x1b[?25h\x1b[?1049l");
|
|
821
|
+
}
|
|
822
|
+
|
|
823
|
+
// The only run state the board writes, and only this transition: stage becomes
|
|
824
|
+
// abandoned, awaiting clears. Every other field is preserved, so a run abandoned
|
|
825
|
+
// here still resumes and reads like one abandoned by /kaizen abort.
|
|
826
|
+
function abandonRun(st: string, id: string, why: string): string | null {
|
|
827
|
+
const file = join(st, "runs", id, "state.json");
|
|
828
|
+
try {
|
|
829
|
+
const run = JSON.parse(readFileSync(file, "utf8"));
|
|
830
|
+
if (run.stage === "abandoned") return "That run is already abandoned.";
|
|
831
|
+
run.stage = "abandoned";
|
|
832
|
+
run.awaiting = null;
|
|
833
|
+
run.updated = new Date().toISOString();
|
|
834
|
+
writeFileSync(file, JSON.stringify(run, null, 2) + "\n");
|
|
835
|
+
// The reason belongs in the record, not in state.json, whose shape is the
|
|
836
|
+
// resume contract every kaizen tool reads.
|
|
837
|
+
appendFileSync(join(st, "runs", id, "02-approval.md"),
|
|
838
|
+
`\n---\n\n## Abandoned\n\n${new Date().toISOString()} — abandoned from the board.\n\n${why}\n`);
|
|
839
|
+
return null;
|
|
840
|
+
} catch (err: any) {
|
|
841
|
+
return err?.message ?? String(err);
|
|
842
|
+
}
|
|
843
|
+
}
|
|
844
|
+
|
|
845
|
+
function replaceIdea(card: Card, next: InboxLine | null) {
|
|
846
|
+
const lines = readInbox(card.state);
|
|
847
|
+
const at = lines.findIndex((l) => (l.status === "open" || l.status === "started") && l.text === card.text);
|
|
848
|
+
if (at < 0) return; // changed underneath us; the redraw will show why
|
|
849
|
+
if (next) lines[at] = next; else lines.splice(at, 1);
|
|
850
|
+
writeInbox(card.state, lines);
|
|
851
|
+
}
|
|
852
|
+
|
|
853
|
+
// startBacklogItem paints its own screens, so the board steps out of the way
|
|
854
|
+
// and takes the terminal back afterwards.
|
|
855
|
+
async function outside(fn: () => Promise<void>) {
|
|
856
|
+
stdin.setRawMode(false);
|
|
857
|
+
stdout.write("\x1b[?25h\x1b[?1049l");
|
|
858
|
+
try {
|
|
859
|
+
await fn();
|
|
860
|
+
} finally {
|
|
861
|
+
// Without this the board's own finally restores a terminal that was never
|
|
862
|
+
// put back into raw mode, and the user is left with no echo.
|
|
863
|
+
stdout.write("\x1b[?1049h\x1b[?25l");
|
|
864
|
+
stdin.setRawMode(true);
|
|
865
|
+
stdin.resume();
|
|
866
|
+
}
|
|
867
|
+
}
|
|
868
|
+
|
|
869
|
+
// One chunk can carry several keys -- a held arrow, a paste, fast typing --
|
|
870
|
+
// so it is walked rather than compared whole.
|
|
871
|
+
function nextKey(): Promise<string> {
|
|
872
|
+
return new Promise((resolve) => {
|
|
873
|
+
const onData = (chunk: Buffer) => {
|
|
874
|
+
const keys = chunk.toString();
|
|
875
|
+
if (keys.includes("\x03")) process.exit(130);
|
|
876
|
+
const done = (v: string) => { stdin.off("data", onData); resolve(v); };
|
|
877
|
+
for (let i = 0; i < keys.length; i++) {
|
|
878
|
+
const rest = keys.slice(i);
|
|
879
|
+
const mouse = readMouse(rest);
|
|
880
|
+
if (mouse) {
|
|
881
|
+
i += mouse.len - 1;
|
|
882
|
+
const { ev } = mouse;
|
|
883
|
+
if (ev.button === WHEEL_UP) return done("up");
|
|
884
|
+
if (ev.button === WHEEL_DOWN) return done("down");
|
|
885
|
+
if (!isClick(ev)) continue;
|
|
886
|
+
const target = cardAt(ev.x, ev.y);
|
|
887
|
+
if (!target) continue;
|
|
888
|
+
// Click to select; click the selected card to act, which on an idea
|
|
889
|
+
// means the same thing r does.
|
|
890
|
+
if (target.col === col && target.row === row) return done("r");
|
|
891
|
+
col = target.col; row = target.row;
|
|
892
|
+
draw();
|
|
893
|
+
continue;
|
|
894
|
+
}
|
|
895
|
+
if (rest.startsWith("\x1b[D")) return done("left");
|
|
896
|
+
if (rest.startsWith("\x1b[C")) return done("right");
|
|
897
|
+
if (rest.startsWith("\x1b[A")) return done("up");
|
|
898
|
+
if (rest.startsWith("\x1b[B")) return done("down");
|
|
899
|
+
const ch = keys[i]!;
|
|
900
|
+
if (ch === "h") return done("left");
|
|
901
|
+
if (ch === "l") return done("right");
|
|
902
|
+
if (ch === "k") return done("up");
|
|
903
|
+
if (ch === "j") return done("down");
|
|
904
|
+
if (ch === "q" || ch === "\x1b" || ch === "\x7f" || ch === "\b") return done("quit");
|
|
905
|
+
if ("nexdra".includes(ch)) return done(ch);
|
|
906
|
+
}
|
|
907
|
+
};
|
|
908
|
+
stdin.on("data", onData);
|
|
909
|
+
});
|
|
910
|
+
}
|
|
911
|
+
|
|
912
|
+
async function promptLine(title: string, initial: string): Promise<string | null> {
|
|
913
|
+
let buf = initial;
|
|
914
|
+
const paint = () => {
|
|
915
|
+
stdout.write("\x1b[H\x1b[2J");
|
|
916
|
+
stdout.write(`\n ${c.bold(title)}\n\n ${buf}${c.cyan("█")}\n\n ${c.dim("enter save · esc cancel")}\n`);
|
|
917
|
+
};
|
|
918
|
+
paint();
|
|
919
|
+
return new Promise((resolve) => {
|
|
920
|
+
const onData = (chunk: Buffer) => {
|
|
921
|
+
const keys = chunk.toString();
|
|
922
|
+
if (keys.includes("\x03")) process.exit(130);
|
|
923
|
+
const done = (v: string | null) => { stdin.off("data", onData); resolve(v); };
|
|
924
|
+
// Character by character: a held backspace or a paste arrives as one
|
|
925
|
+
// chunk, and comparing the whole chunk to "\x7f" writes the raw bytes
|
|
926
|
+
// into the file instead of deleting anything.
|
|
927
|
+
for (let i = 0; i < keys.length; i++) {
|
|
928
|
+
// Skipped whole, or its digits and semicolons land in the text.
|
|
929
|
+
const mouse = readMouse(keys.slice(i));
|
|
930
|
+
if (mouse) { i += mouse.len - 1; continue; }
|
|
931
|
+
const ch = keys[i]!;
|
|
932
|
+
if (ch === "\x1b") return done(null);
|
|
933
|
+
if (ch === "\r" || ch === "\n") return done(buf.trim() || null);
|
|
934
|
+
if (ch === "\x7f" || ch === "\b") { buf = buf.slice(0, -1); continue; }
|
|
935
|
+
// Anything else unprintable is dropped rather than stored: control
|
|
936
|
+
// bytes in an inbox line survive every later read of that file.
|
|
937
|
+
if (ch >= " " && ch !== "\x7f") buf += ch;
|
|
938
|
+
}
|
|
939
|
+
paint();
|
|
940
|
+
};
|
|
941
|
+
stdin.on("data", onData);
|
|
942
|
+
});
|
|
943
|
+
}
|
|
944
|
+
|
|
945
|
+
async function confirm(question: string): Promise<boolean> {
|
|
946
|
+
stdout.write("\x1b[H\x1b[2J");
|
|
947
|
+
stdout.write(`\n ${c.bold(question)}\n\n ${c.dim("y delete · any other key keep")}\n`);
|
|
948
|
+
const key = await nextRaw();
|
|
949
|
+
return key === "y" || key === "Y";
|
|
950
|
+
}
|
|
951
|
+
|
|
952
|
+
// full or lite, asked at the moment a run starts rather than fixed in config.
|
|
953
|
+
async function pickMode(): Promise<string | null> {
|
|
954
|
+
stdout.write("\x1b[H\x1b[2J");
|
|
955
|
+
stdout.write(`\n ${c.bold("Start this run how?")}\n\n`);
|
|
956
|
+
stdout.write(` ${c.cyan("f")} full ${c.dim("every stage its own cold agent — the reviewer starts blind")}\n`);
|
|
957
|
+
stdout.write(` ${c.cyan("l")} lite ${c.dim("one session runs every stage — cheaper, review has seen the work")}\n`);
|
|
958
|
+
stdout.write(` ${c.cyan("d")} default${c.dim(" whatever config.yml says")}\n\n ${c.dim("click a line, or any other key to cancel")}\n`);
|
|
959
|
+
// Options start on the fourth line: blank, title, blank, then f, l, d.
|
|
960
|
+
const key = await nextRaw((y) => ["f", "l", "d"][y - 4] ?? null);
|
|
961
|
+
return key === "f" ? "full" : key === "l" ? "lite" : key === "d" ? "" : null;
|
|
962
|
+
}
|
|
963
|
+
|
|
964
|
+
// A click arrives as two reports, press and release. The press is what opened
|
|
965
|
+
// this screen, so the release must not be read as its answer -- and a click on
|
|
966
|
+
// one of the offered lines answers it properly, via `clickable`.
|
|
967
|
+
function nextRaw(clickable?: (y: number) => string | null): Promise<string> {
|
|
968
|
+
return new Promise((resolve) => {
|
|
969
|
+
const onData = (chunk: Buffer) => {
|
|
970
|
+
const keys = chunk.toString();
|
|
971
|
+
if (keys.includes("\x03")) process.exit(130);
|
|
972
|
+
const done = (v: string) => { stdin.off("data", onData); resolve(v); };
|
|
973
|
+
for (let i = 0; i < keys.length; i++) {
|
|
974
|
+
const mouse = readMouse(keys.slice(i));
|
|
975
|
+
if (mouse) {
|
|
976
|
+
i += mouse.len - 1;
|
|
977
|
+
if (!clickable || !isClick(mouse.ev)) continue;
|
|
978
|
+
const answer = clickable(mouse.ev.y);
|
|
979
|
+
if (answer) return done(answer);
|
|
980
|
+
continue;
|
|
981
|
+
}
|
|
982
|
+
return done(keys.slice(i));
|
|
983
|
+
}
|
|
984
|
+
};
|
|
985
|
+
stdin.on("data", onData);
|
|
986
|
+
});
|
|
987
|
+
}
|
|
988
|
+
}
|
|
989
|
+
|
|
446
990
|
async function startBacklogItem(it: Item, from: string) {
|
|
447
991
|
const projectDir = from === join(home, ".kaizen")
|
|
448
992
|
? process.cwd()
|
|
@@ -511,9 +1055,13 @@ export async function dashboard(
|
|
|
511
1055
|
}
|
|
512
1056
|
|
|
513
1057
|
async function menu() {
|
|
1058
|
+
let actsTop = 0; // screen line of the first action row
|
|
1059
|
+
let printed = 0; // lines this draw has written
|
|
1060
|
+
const w = (text: string) => { printed += (text.match(/\n/g) ?? []).length; stdout.write(text); };
|
|
514
1061
|
const draw = () => {
|
|
1062
|
+
printed = 0;
|
|
515
1063
|
const acts = actions();
|
|
516
|
-
|
|
1064
|
+
w("\x1b[H\x1b[2J");
|
|
517
1065
|
|
|
518
1066
|
// The mascot sits beside the header rather than above it; stacked, it pushes
|
|
519
1067
|
// the runs -- the thing you opened this for -- below the fold on a short window.
|
|
@@ -552,7 +1100,7 @@ export async function dashboard(
|
|
|
552
1100
|
beside[at + 1] = c.dim(state ? tilde(dirname(state)) : "no project here");
|
|
553
1101
|
beside[at + 3] = c.dim(agents.join(", "));
|
|
554
1102
|
}
|
|
555
|
-
|
|
1103
|
+
w("\n");
|
|
556
1104
|
if (wide) {
|
|
557
1105
|
// Cut what sits beside the mascot to the room left over. The agent list is
|
|
558
1106
|
// long and grows with every agent installed, and it wrapped here before the
|
|
@@ -562,11 +1110,11 @@ export async function dashboard(
|
|
|
562
1110
|
const r = Math.round(235 - t * 135);
|
|
563
1111
|
const g = Math.round(245 - t * 110);
|
|
564
1112
|
const b = Math.round(255 - t * 20);
|
|
565
|
-
|
|
1113
|
+
w(" " + rgb(r, g, b, line.padEnd(gutter)) + cut(beside[i] ?? "", cols - gutter - 2) + "\n");
|
|
566
1114
|
}
|
|
567
1115
|
} else {
|
|
568
|
-
|
|
569
|
-
|
|
1116
|
+
w(` ${c.bold("kaizen")} ${c.dim(version(repo))} ${c.dim(state ? tilde(dirname(state)) : "no project here")}\n`);
|
|
1117
|
+
w(` ${c.dim(agents.join(", "))}\n`);
|
|
570
1118
|
}
|
|
571
1119
|
|
|
572
1120
|
if (state) {
|
|
@@ -606,28 +1154,31 @@ export async function dashboard(
|
|
|
606
1154
|
while (backLines.length < tall) backLines.push("");
|
|
607
1155
|
const left = panel("Runs", runLines, inner);
|
|
608
1156
|
const right = panel(`Backlog — ${items.length} open`, backLines, inner);
|
|
609
|
-
|
|
1157
|
+
w("\n");
|
|
610
1158
|
for (let i = 0; i < Math.max(left.length, right.length); i++) {
|
|
611
|
-
|
|
1159
|
+
w(" " + (left[i] ?? " ".repeat(inner + 2)) + " " + (right[i] ?? "") + "\n");
|
|
612
1160
|
}
|
|
613
1161
|
} else {
|
|
614
|
-
|
|
615
|
-
for (const line of panel("Runs", runLines, cols - 8))
|
|
616
|
-
for (const line of panel(`Backlog — ${items.length} open`, backLines, cols - 8))
|
|
1162
|
+
w("\n");
|
|
1163
|
+
for (const line of panel("Runs", runLines, cols - 8)) w(" " + line + "\n");
|
|
1164
|
+
for (const line of panel(`Backlog — ${items.length} open`, backLines, cols - 8)) w(" " + line + "\n");
|
|
617
1165
|
}
|
|
618
1166
|
} else {
|
|
619
|
-
|
|
620
|
-
|
|
1167
|
+
w(`\n ${c.dim("This folder has no .kaizen/. Set it up, or just ask your agent for something —")}\n`);
|
|
1168
|
+
w(` ${c.dim("the first run creates it.")}\n`);
|
|
621
1169
|
}
|
|
622
1170
|
|
|
623
|
-
|
|
1171
|
+
w("\n");
|
|
1172
|
+
// Counted, not derived: the panels above change height with the terminal and
|
|
1173
|
+
// with what exists, so nothing else can know where the actions start.
|
|
1174
|
+
actsTop = printed + 1;
|
|
624
1175
|
for (const [i, a] of acts.entries()) {
|
|
625
1176
|
const on = i === active;
|
|
626
|
-
|
|
1177
|
+
w(on
|
|
627
1178
|
? ` ${c.cyan("›")} ${c.bold(a.label.padEnd(20))}${c.dim(a.hint)}\n`
|
|
628
1179
|
: ` ${c.dim(a.label)}\n`);
|
|
629
1180
|
}
|
|
630
|
-
|
|
1181
|
+
w(`\n ${c.dim("↑↓ move · enter choose · q quit")} ${c.dim("⭐ github.com/hfadhlullah/kaizen")}\n`);
|
|
631
1182
|
};
|
|
632
1183
|
|
|
633
1184
|
stdout.write("\x1b[?1049h\x1b[?25l");
|
|
@@ -650,6 +1201,21 @@ export async function dashboard(
|
|
|
650
1201
|
chosen = acts[active]!.key;
|
|
651
1202
|
stdin.off("data", onData); return resolve();
|
|
652
1203
|
}
|
|
1204
|
+
const mouse = readMouse(rest);
|
|
1205
|
+
if (mouse) {
|
|
1206
|
+
i += mouse.len - 1;
|
|
1207
|
+
const { ev } = mouse;
|
|
1208
|
+
if (ev.button === WHEEL_UP) active = (active - 1 + acts.length) % acts.length;
|
|
1209
|
+
else if (ev.button === WHEEL_DOWN) active = (active + 1) % acts.length;
|
|
1210
|
+
else if (isClick(ev)) {
|
|
1211
|
+
const row = ev.y - actsTop;
|
|
1212
|
+
if (row < 0 || row >= acts.length) continue;
|
|
1213
|
+
if (row === active) { chosen = acts[active]!.key; stdin.off("data", onData); return resolve(); }
|
|
1214
|
+
active = row;
|
|
1215
|
+
} else continue;
|
|
1216
|
+
draw();
|
|
1217
|
+
continue;
|
|
1218
|
+
}
|
|
653
1219
|
if (rest.startsWith("\x1b[A")) { active = (active - 1 + acts.length) % acts.length; i += 2; }
|
|
654
1220
|
else if (rest.startsWith("\x1b[B")) { active = (active + 1) % acts.length; i += 2; }
|
|
655
1221
|
else if (rest.startsWith("k")) active = (active - 1 + acts.length) % acts.length;
|
|
@@ -728,8 +1294,13 @@ function readRuns(state: string): Run[] {
|
|
|
728
1294
|
if (!existsSync(dir)) return [];
|
|
729
1295
|
return readdirSync(dir).flatMap((id) => {
|
|
730
1296
|
try {
|
|
731
|
-
const
|
|
732
|
-
|
|
1297
|
+
const file = join(dir, id, "state.json");
|
|
1298
|
+
const s = JSON.parse(readFileSync(file, "utf8"));
|
|
1299
|
+
// When the run last moved. Kaizen records no liveness signal, so the file's
|
|
1300
|
+
// own mtime is the only evidence that anything is still working on it.
|
|
1301
|
+
let moved = 0;
|
|
1302
|
+
try { moved = statSync(file).mtimeMs; } catch { /* vanished mid-read */ }
|
|
1303
|
+
return [{ id, stage: s.stage ?? "?", awaiting: s.awaiting ?? null, moved }];
|
|
733
1304
|
} catch { return []; }
|
|
734
1305
|
}).reverse();
|
|
735
1306
|
}
|
|
@@ -818,6 +1389,136 @@ function allBacklog(state: string) {
|
|
|
818
1389
|
return out;
|
|
819
1390
|
}
|
|
820
1391
|
|
|
1392
|
+
|
|
1393
|
+
// ---------------------------------------------------------------- board data
|
|
1394
|
+
|
|
1395
|
+
type Card = {
|
|
1396
|
+
kind: "idea" | "run";
|
|
1397
|
+
id?: string; // runs only: the directory name
|
|
1398
|
+
dot: string;
|
|
1399
|
+
status: string; // what the legend calls this card
|
|
1400
|
+
text: string;
|
|
1401
|
+
state: string;
|
|
1402
|
+
where: string;
|
|
1403
|
+
column: number;
|
|
1404
|
+
awaiting: string | null;
|
|
1405
|
+
dim: boolean;
|
|
1406
|
+
};
|
|
1407
|
+
|
|
1408
|
+
const COLUMNS = [
|
|
1409
|
+
{ key: "idea", title: "IDEA" },
|
|
1410
|
+
{ key: "plan", title: "PLANNING" },
|
|
1411
|
+
{ key: "build", title: "BUILDING" },
|
|
1412
|
+
{ key: "review", title: "REVIEW" },
|
|
1413
|
+
{ key: "done", title: "DONE" },
|
|
1414
|
+
];
|
|
1415
|
+
|
|
1416
|
+
|
|
1417
|
+
// A run is called running on the evidence that it moved recently; there is no PID
|
|
1418
|
+
// to ask. Anything in flight and older than this is stalled, not running -- the
|
|
1419
|
+
// difference the board exists to show.
|
|
1420
|
+
|
|
1421
|
+
// The verbatim request of every run, normalised, so an idea that has become a run
|
|
1422
|
+
// can be retired from the board.
|
|
1423
|
+
// ponytail: reads each run's 00-request.md on every refresh; fine at tens of runs,
|
|
1424
|
+
// cache by mtime if a project ever has hundreds.
|
|
1425
|
+
function startedRuns(state: string): string[] {
|
|
1426
|
+
const dir = join(state, "runs");
|
|
1427
|
+
if (!existsSync(dir)) return [];
|
|
1428
|
+
const out: string[] = [];
|
|
1429
|
+
for (const id of readdirSync(dir)) {
|
|
1430
|
+
try { out.push(normalise(readFileSync(join(dir, id, "00-request.md"), "utf8"))); }
|
|
1431
|
+
catch { /* no request file, or unreadable */ }
|
|
1432
|
+
}
|
|
1433
|
+
return out;
|
|
1434
|
+
}
|
|
1435
|
+
|
|
1436
|
+
function normalise(text: string) {
|
|
1437
|
+
return text.toLowerCase().replace(/\s+/g, " ").trim();
|
|
1438
|
+
}
|
|
1439
|
+
|
|
1440
|
+
const RUNNING_WITHIN_MS = 30 * 60_000;
|
|
1441
|
+
|
|
1442
|
+
// The status is what the legend lists; the dot is how it is drawn. Deriving one
|
|
1443
|
+
// from the other by comparing coloured strings breaks the moment a colour moves.
|
|
1444
|
+
function dotFor(r: Run, now: number): { dot: string; status: string } {
|
|
1445
|
+
if (r.stage === "abandoned") return { dot: DIM + "○" + OFF, status: "abandoned" };
|
|
1446
|
+
if (r.awaiting) return { dot: c.amber("●"), status: "waiting" };
|
|
1447
|
+
if (r.stage === "done") return { dot: c.dim("○"), status: "done" };
|
|
1448
|
+
return now - r.moved < RUNNING_WITHIN_MS
|
|
1449
|
+
? { dot: c.cyan("●"), status: "running" }
|
|
1450
|
+
: { dot: c.dim("◐"), status: "stalled" };
|
|
1451
|
+
}
|
|
1452
|
+
|
|
1453
|
+
// A stage this build has never heard of still belongs somewhere visible, and
|
|
1454
|
+
// PLANNING is the honest guess: the run has started and has not finished.
|
|
1455
|
+
function columnOf(stage: string) {
|
|
1456
|
+
if (stage === "build") return 2;
|
|
1457
|
+
if (stage === "review") return 3;
|
|
1458
|
+
if (stage === "done" || stage === "abandoned") return 4;
|
|
1459
|
+
return 1;
|
|
1460
|
+
}
|
|
1461
|
+
|
|
1462
|
+
type InboxLine = { status: string; text: string };
|
|
1463
|
+
|
|
1464
|
+
// Ideas with no run yet. Deliberately not backlog.md, which the spec defines as
|
|
1465
|
+
// an orphanage for items rescued from pruned runs, and not a run stub each.
|
|
1466
|
+
function readInbox(state: string): InboxLine[] {
|
|
1467
|
+
const f = join(state, "inbox.md");
|
|
1468
|
+
if (!existsSync(f)) return [];
|
|
1469
|
+
return readFileSync(f, "utf8").split("\n")
|
|
1470
|
+
.map((l) => /^-\s*(open|started|done|rejected):\s*(.+)$/.exec(l.trim()))
|
|
1471
|
+
.filter((m): m is RegExpExecArray => !!m)
|
|
1472
|
+
.map((m) => ({ status: m[1]!, text: m[2]!.trim() }));
|
|
1473
|
+
}
|
|
1474
|
+
|
|
1475
|
+
// Whole-file rewrite. The board is the only writer today.
|
|
1476
|
+
// ponytail: single writer assumed; if a kaizen stage ever appends here too, this
|
|
1477
|
+
// needs a lock or an append-only journal.
|
|
1478
|
+
function writeInbox(state: string, lines: InboxLine[]) {
|
|
1479
|
+
mkdirSync(state, { recursive: true });
|
|
1480
|
+
const body = [
|
|
1481
|
+
"# Inbox",
|
|
1482
|
+
"",
|
|
1483
|
+
"Ideas with no run yet. One line each, same format as a run's backlog.",
|
|
1484
|
+
"",
|
|
1485
|
+
...lines.map((l) => `- ${l.status}: ${l.text}`),
|
|
1486
|
+
];
|
|
1487
|
+
writeFileSync(join(state, "inbox.md"), body.join("\n") + "\n");
|
|
1488
|
+
}
|
|
1489
|
+
|
|
1490
|
+
// Best effort: a machine without notify-send loses the notification, not the board.
|
|
1491
|
+
function notify(title: string, body: string) {
|
|
1492
|
+
try {
|
|
1493
|
+
if (!Bun.which("notify-send")) return;
|
|
1494
|
+
Bun.spawn(["notify-send", title, body], {
|
|
1495
|
+
stdin: "ignore", stdout: "ignore", stderr: "ignore", detached: true,
|
|
1496
|
+
}).unref();
|
|
1497
|
+
} catch { /* notifications are not worth an exception */ }
|
|
1498
|
+
}
|
|
1499
|
+
|
|
1500
|
+
// wrap() above fills the terminal; a board column has its own width.
|
|
1501
|
+
function wrapTo(text: string, room: number) {
|
|
1502
|
+
const out: string[] = [];
|
|
1503
|
+
let line = "";
|
|
1504
|
+
// A run id is a single word to any whitespace split, so without breaking on
|
|
1505
|
+
// hyphens too it can never wrap and is always cut. Kept in the same order the
|
|
1506
|
+
// text arrived, hyphen retained on the line it ends.
|
|
1507
|
+
const words = text.split(/\s+/).flatMap((w) =>
|
|
1508
|
+
w.length <= room ? [w] : w.split(/(?<=-)/));
|
|
1509
|
+
for (const word of words) {
|
|
1510
|
+
if (line && line.length + word.length + (line.endsWith("-") ? 0 : 1) > room) { out.push(line); line = word; }
|
|
1511
|
+
else line = line ? (line.endsWith("-") ? line + word : `${line} ${word}`) : word;
|
|
1512
|
+
}
|
|
1513
|
+
if (line) out.push(line);
|
|
1514
|
+
return out.length ? out.map((l) => cut(l, room)) : [""];
|
|
1515
|
+
}
|
|
1516
|
+
|
|
1517
|
+
function pad(s: string, room: number) {
|
|
1518
|
+
const body = cut(s, room);
|
|
1519
|
+
return body + " ".repeat(Math.max(0, room - vis(body)));
|
|
1520
|
+
}
|
|
1521
|
+
|
|
821
1522
|
// Printable width: a row is mostly colour codes by the time it is drawn, and they
|
|
822
1523
|
// occupy no columns.
|
|
823
1524
|
function vis(s: string) { return s.replace(/\x1b\[[0-9;]*m/g, "").length; }
|
package/cli/settings.ts
CHANGED
|
@@ -109,6 +109,7 @@ const SETTINGS: Setting[] = [
|
|
|
109
109
|
{ key: "state.keep_runs", values: ["5", "10", "20", "50"], help: "Completed runs kept before the oldest is pruned" },
|
|
110
110
|
{ key: "git.auto_commit", values: ["false", "true"], help: "Commit the work when a run finishes" },
|
|
111
111
|
{ key: "git.branch_before_implement", values: ["true", "false"], help: "Branch before building when on the default branch" },
|
|
112
|
+
{ key: "ui.mouse", values: ["false", "true"], help: "Click to select, click again to act. While on, the terminal cannot select text with the mouse" },
|
|
112
113
|
{ key: "agent.default", values: ["auto", "claude", "codex", "agy", "opencode", "gemini"], help: "Default coding agent to launch from dashboard backlog" },
|
|
113
114
|
];
|
|
114
115
|
|
|
@@ -120,6 +121,12 @@ const c = {
|
|
|
120
121
|
inv: (s: string) => `\x1b[7m${s}\x1b[0m`,
|
|
121
122
|
};
|
|
122
123
|
|
|
124
|
+
// Registered once at module scope, not per call: opening settings, going back and
|
|
125
|
+
// opening it again would otherwise stack a listener each time, and Node starts
|
|
126
|
+
// printing warnings into the alternate screen at eleven.
|
|
127
|
+
let mouseArmed = false;
|
|
128
|
+
process.on("exit", () => { if (mouseArmed) process.stdout.write("\x1b[?1006l\x1b[?1000l"); });
|
|
129
|
+
|
|
123
130
|
export async function settings(repoRoot: string, standalone = true) {
|
|
124
131
|
const file = locate();
|
|
125
132
|
if (!existsSync(file)) {
|
|
@@ -155,15 +162,63 @@ export async function settings(repoRoot: string, standalone = true) {
|
|
|
155
162
|
};
|
|
156
163
|
|
|
157
164
|
stdout.write("\x1b[?1049h\x1b[?25l"); // alternate screen, hide cursor
|
|
165
|
+
const mouse = /^\s*ui:\s*\n(?:\s*#.*\n)*\s*mouse:\s*(\S+)/m.exec(readFileSync(file, "utf8"));
|
|
166
|
+
const mouseOn = mouse?.[1] === "true";
|
|
167
|
+
if (mouseOn) { stdout.write("\x1b[?1000h\x1b[?1006h"); mouseArmed = true; }
|
|
158
168
|
stdin.setRawMode(true);
|
|
159
169
|
stdin.resume();
|
|
160
170
|
draw();
|
|
161
171
|
|
|
172
|
+
function cycle(step: number) {
|
|
173
|
+
const s = SETTINGS[active]!;
|
|
174
|
+
const text = readFileSync(file, "utf8");
|
|
175
|
+
if (s.key === "preset") {
|
|
176
|
+
const current = detectPreset(text, defaults);
|
|
177
|
+
const presets: PresetName[] = ["low", "medium", "ultra"];
|
|
178
|
+
const at = presets.indexOf(current as PresetName);
|
|
179
|
+
const next = presets[((at < 0 ? 0 : at) + step + presets.length) % presets.length]!;
|
|
180
|
+
const updated = applyPreset(text, next, defaults);
|
|
181
|
+
writeFileSync(file, updated);
|
|
182
|
+
saved = c.green(`applied preset: ${next}`);
|
|
183
|
+
} else {
|
|
184
|
+
const now = read(text, s.key) ?? s.values[0]!;
|
|
185
|
+
const at = s.values.indexOf(now);
|
|
186
|
+
const next = s.values[((at < 0 ? 0 : at) + step + s.values.length) % s.values.length]!;
|
|
187
|
+
let updated = write(text, s.key, next, defaults);
|
|
188
|
+
const detected = detectPreset(updated, defaults);
|
|
189
|
+
updated = write(updated, "preset", detected, defaults);
|
|
190
|
+
writeFileSync(file, updated);
|
|
191
|
+
// Only claim a save that happened. Reporting one that did not is worse
|
|
192
|
+
// than failing loudly: the setting reads back unchanged and nobody knows why.
|
|
193
|
+
saved = read(updated, s.key) === next
|
|
194
|
+
? c.green(`saved ${s.key} = ${next || '""'} (preset: ${detected})`)
|
|
195
|
+
: `\x1b[33mcould not write ${s.key}\x1b[0m`;
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
|
|
162
199
|
await new Promise<void>((resolve) => {
|
|
163
200
|
const onData = (chunk: Buffer) => {
|
|
164
201
|
const keys = chunk.toString();
|
|
165
202
|
for (let i = 0; i < keys.length; i++) {
|
|
166
203
|
const rest = keys.slice(i);
|
|
204
|
+
// Rows begin on the fourth line: blank, title, blank, then the settings.
|
|
205
|
+
const m = /^\x1b\[<(\d+);(\d+);(\d+)([Mm])/.exec(rest);
|
|
206
|
+
if (m) {
|
|
207
|
+
i += m[0].length - 1;
|
|
208
|
+
const button = +m[1]!, y = +m[3]!, press = m[4] === "M";
|
|
209
|
+
if (button === 64) active = (active - 1 + SETTINGS.length) % SETTINGS.length;
|
|
210
|
+
else if (button === 65) active = (active + 1) % SETTINGS.length;
|
|
211
|
+
else if (press && button === 0) {
|
|
212
|
+
const row = y - 4;
|
|
213
|
+
if (row < 0 || row >= SETTINGS.length) continue;
|
|
214
|
+
// Click to select; click the selected row to cycle it, which is what
|
|
215
|
+
// the right arrow already does.
|
|
216
|
+
if (row === active) { cycle(1); continue; }
|
|
217
|
+
active = row;
|
|
218
|
+
} else continue;
|
|
219
|
+
draw();
|
|
220
|
+
continue;
|
|
221
|
+
}
|
|
167
222
|
// esc, q, backspace, and left-at-the-edge all mean "back to where I came
|
|
168
223
|
// from" -- the settings screen is always something you opened from
|
|
169
224
|
// somewhere else.
|
|
@@ -174,31 +229,7 @@ export async function settings(repoRoot: string, standalone = true) {
|
|
|
174
229
|
if (rest.startsWith("\x1b[A")) { active = (active - 1 + SETTINGS.length) % SETTINGS.length; i += 2; }
|
|
175
230
|
else if (rest.startsWith("\x1b[B")) { active = (active + 1) % SETTINGS.length; i += 2; }
|
|
176
231
|
else if (rest.startsWith("\x1b[C") || rest.startsWith("\x1b[D") || rest.startsWith("\r")) {
|
|
177
|
-
|
|
178
|
-
const s = SETTINGS[active]!;
|
|
179
|
-
const text = readFileSync(file, "utf8");
|
|
180
|
-
if (s.key === "preset") {
|
|
181
|
-
const current = detectPreset(text, defaults);
|
|
182
|
-
const cycle: PresetName[] = ["low", "medium", "ultra"];
|
|
183
|
-
const at = cycle.indexOf(current as PresetName);
|
|
184
|
-
const next = cycle[((at < 0 ? 0 : at) + step + cycle.length) % cycle.length]!;
|
|
185
|
-
const updated = applyPreset(text, next, defaults);
|
|
186
|
-
writeFileSync(file, updated);
|
|
187
|
-
saved = c.green(`applied preset: ${next}`);
|
|
188
|
-
} else {
|
|
189
|
-
const now = read(text, s.key) ?? s.values[0]!;
|
|
190
|
-
const at = s.values.indexOf(now);
|
|
191
|
-
const next = s.values[((at < 0 ? 0 : at) + step + s.values.length) % s.values.length]!;
|
|
192
|
-
let updated = write(text, s.key, next, defaults);
|
|
193
|
-
const detected = detectPreset(updated, defaults);
|
|
194
|
-
updated = write(updated, "preset", detected, defaults);
|
|
195
|
-
writeFileSync(file, updated);
|
|
196
|
-
// Only claim a save that happened. Reporting one that did not is worse
|
|
197
|
-
// than failing loudly: the setting reads back unchanged and nobody knows why.
|
|
198
|
-
saved = read(updated, s.key) === next
|
|
199
|
-
? c.green(`saved ${s.key} = ${next || '""'} (preset: ${detected})`)
|
|
200
|
-
: `\x1b[33mcould not write ${s.key}\x1b[0m`;
|
|
201
|
-
}
|
|
232
|
+
cycle(rest.startsWith("\x1b[D") ? -1 : 1);
|
|
202
233
|
if (!rest.startsWith("\r")) i += 2;
|
|
203
234
|
} else if (rest.startsWith("k")) active = (active - 1 + SETTINGS.length) % SETTINGS.length;
|
|
204
235
|
else if (rest.startsWith("j")) active = (active + 1) % SETTINGS.length;
|
|
@@ -211,6 +242,7 @@ export async function settings(repoRoot: string, standalone = true) {
|
|
|
211
242
|
|
|
212
243
|
stdin.setRawMode(false);
|
|
213
244
|
stdin.pause();
|
|
245
|
+
if (mouseArmed) { stdout.write("\x1b[?1006l\x1b[?1000l"); mouseArmed = false; }
|
|
214
246
|
stdout.write("\x1b[?25h\x1b[?1049l"); // restore cursor and screen
|
|
215
247
|
// Returning to the dashboard means returning to its alternate screen, where a
|
|
216
248
|
// line printed here would never be seen.
|
package/package.json
CHANGED
package/skills/kaizen/SKILL.md
CHANGED
|
@@ -94,6 +94,7 @@ whichever the user picks.
|
|
|
94
94
|
| `review.write_memory` | `true`, `false` | Let the reviewer append lessons to `memory.md` |
|
|
95
95
|
| `git.auto_commit` | `true`, `false` | Commit the work when a run finishes |
|
|
96
96
|
| `git.branch_before_implement` | `true`, `false` | Branch before building when on the default branch |
|
|
97
|
+
| `ui.mouse` | `false`, `true` | Click to select, click again to act; while on, the terminal cannot select text |
|
|
97
98
|
| `agent.default` | `auto`, `claude`, `codex`, `agy`, `opencode`, `gemini` | Coding agent to launch from dashboard backlog |
|
|
98
99
|
|
|
99
100
|
Rules for writing the file:
|
|
@@ -129,6 +129,13 @@ git:
|
|
|
129
129
|
# Ignored when the project is not a git repository.
|
|
130
130
|
branch_before_implement: true
|
|
131
131
|
|
|
132
|
+
ui:
|
|
133
|
+
# Mouse in the TUI: click a row or card to select it, click it again to open or
|
|
134
|
+
# run it, wheel to scroll. Off by default, and the reason is a real trade: while
|
|
135
|
+
# the TUI holds the mouse, the terminal cannot select text with it, so copying a
|
|
136
|
+
# run id out of the dashboard stops working until you leave.
|
|
137
|
+
mouse: false
|
|
138
|
+
|
|
132
139
|
agent:
|
|
133
140
|
# Default coding agent to launch from dashboard backlog (auto | claude | codex | agy | opencode | gemini).
|
|
134
141
|
# auto picks Claude Code if installed, otherwise the first agent found on PATH.
|
|
@@ -47,6 +47,15 @@ change works" into "nothing else broke". A check already failing before the chan
|
|
|
47
47
|
not this run's finding; a check that passed before and fails after is, and it stops
|
|
48
48
|
the stage.
|
|
49
49
|
|
|
50
|
+
**Keep the working transcript small.** Everything the builder reads stays in its
|
|
51
|
+
context and is re-read on every request that follows, so a full test-suite dump at the
|
|
52
|
+
start of the stage is paid for again at every step after it. Pipe long output through
|
|
53
|
+
a filter that keeps the decisive lines — the failure count, the failing test names, the
|
|
54
|
+
first error — and read the full output only when a check actually fails. The same holds
|
|
55
|
+
for reading material: open the manifest's paths at the ranges the plan names, not whole
|
|
56
|
+
files, and never re-read a file this stage already changed. `03-impl.md` records the
|
|
57
|
+
result, not the transcript.
|
|
58
|
+
|
|
50
59
|
**Read the plan's File manifest instead of searching.** The planner already located
|
|
51
60
|
every path this run touches. Search the material only for what the manifest does not
|
|
52
61
|
cover, and note in `03-impl.md` what was missing from it.
|
|
@@ -66,11 +66,27 @@ The planner reads code but writes none. Its plan must contain:
|
|
|
66
66
|
item; "every new query path has a covering test" is. A tier fixes the severity
|
|
67
67
|
before the reviewer has an opinion, which is what stops the same class of problem
|
|
68
68
|
being `high` in one run and `medium` in the next.
|
|
69
|
-
7. **
|
|
69
|
+
7. **Design direction, for any work with a visual surface** — a website, a landing
|
|
70
|
+
page, a UI, a deck, a printed piece. The direction is settled before the builder
|
|
71
|
+
starts, because it is the one decision that cannot be changed afterwards without
|
|
72
|
+
redoing the work: copy survives a re-skin, markup does not.
|
|
73
|
+
|
|
74
|
+
Look for it in the material first — a `design.md`, a design system, a brand or style
|
|
75
|
+
guide, an existing stylesheet with established tokens. Where one exists, the plan
|
|
76
|
+
names it and follows it, and asks nothing; the answer is already in the repository.
|
|
77
|
+
|
|
78
|
+
Where none exists, the direction is an *Open question* with 3-4 concrete options,
|
|
79
|
+
each named for what it signals rather than for its palette, so the user chooses a
|
|
80
|
+
positioning rather than a color. Bringing one direction and building it is the
|
|
81
|
+
failure this item exists to prevent: the planner has an opinion here as everywhere
|
|
82
|
+
else, but a visual direction chosen silently is a decision taken from the user, not
|
|
83
|
+
a proposal made to them.
|
|
84
|
+
|
|
85
|
+
8. **Risks** — what could break, what is irreversible, what needs a migration or a
|
|
70
86
|
backup taken first.
|
|
71
|
-
|
|
87
|
+
9. **Open questions** — assumptions that survived the interview. If this list is
|
|
72
88
|
non-empty, the approval must ask the user about it rather than defaulting.
|
|
73
|
-
|
|
89
|
+
10. **Out of scope suggestions** — worthwhile things noticed but not asked for, each
|
|
74
90
|
its own proposal. Never folded into a step.
|
|
75
91
|
|
|
76
92
|
The plan is a proposal, not a decision. It never edits files, runs migrations, or
|
|
@@ -80,7 +96,7 @@ installs packages.
|
|
|
80
96
|
fix loop reads it again per iteration, so a line written once is read five or six
|
|
81
97
|
times. Sections 3 to 6 — the material to reuse, the file manifest, the steps, the
|
|
82
98
|
verification — earn their length: they are what stop every later stage searching the
|
|
83
|
-
material again, and cutting them costs more than it saves. Sections 0, 1,
|
|
99
|
+
material again, and cutting them costs more than it saves. Sections 0, 1, 8 and 10 are
|
|
84
100
|
read once by a human at the approval and should be as short as they can be and still
|
|
85
101
|
be correct: a paragraph of framing, a paragraph of goal, the risks that are real, the
|
|
86
102
|
questions that are open. An alternative rejected gets a line saying what and why, not
|
|
@@ -48,6 +48,21 @@ operational work: missing prerequisites, absent rollback, steps that cannot be
|
|
|
48
48
|
executed as written. The plan's Verification section says what was promised; the
|
|
49
49
|
reviewer checks that promise was kept.
|
|
50
50
|
|
|
51
|
+
**Keep the working transcript small.** Everything the reviewer reads stays in its
|
|
52
|
+
context and is re-read on every request that follows, so the re-run suite dumped in
|
|
53
|
+
full at check 4 is paid for again at every check after it. Pipe long output through a
|
|
54
|
+
filter that keeps the decisive lines — the failure count, the failing test names, the
|
|
55
|
+
first error — and read the full output only where a check actually fails, which is the
|
|
56
|
+
only place the detail becomes a finding. The same holds for the work under review: open
|
|
57
|
+
the manifest's paths at the ranges the plan names, and read the diff rather than the
|
|
58
|
+
whole file wherever the finding would cite a changed line. `04-review.md` records the
|
|
59
|
+
findings, not the transcript.
|
|
60
|
+
|
|
61
|
+
This does not apply to check 3. Blast radius is the check whose whole point is what
|
|
62
|
+
the diff does not contain, so it reads as widely as it needs to — every caller, every
|
|
63
|
+
reader, whole files where that is what it takes. A cheaper blast-radius pass is not a
|
|
64
|
+
cheaper review, it is the check not happening.
|
|
65
|
+
|
|
51
66
|
Each finding is one line, numbered from 1 within the review:
|
|
52
67
|
`<n>. <where>: <severity>: <problem>. <fix>. [G-nn]` Where is a `path:line` for code,
|
|
53
68
|
and a section, page, or step reference otherwise. Severity is one of `critical`,
|