kaizen-agent 1.43.1 → 1.43.3
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/cli/board.test.ts +43 -0
- package/cli/install.ts +6 -4
- package/cli/pty.test.ts +10 -0
- package/cli/pty.ts +13 -5
- package/cli/state.test.ts +2 -0
- package/cli/state.ts +10 -5
- package/cli/web.ts +2 -0
- package/package.json +1 -1
- package/skills/kaizen/SKILL.md +6 -0
- package/skills/kaizen/config.default.yml +1 -1
- package/skills/kaizen/docs.md +16 -3
- package/skills/kaizen/spec-main.md +32 -1
- package/skills/kaizen/spec-plan.md +95 -1
- package/skills/kaizen-help/SKILL.md +1 -1
- package/web/board/chat.ts +29 -6
- package/web/board/markdown.ts +8 -3
- package/web/board/rich.ts +160 -0
- package/web/board/terms.ts +27 -9
- package/web/board.html +4 -3
- package/web/board.js +41 -26
- package/web/notebook.js +1 -1
- package/web/term.js +1 -1
package/cli/board.test.ts
CHANGED
|
@@ -254,3 +254,46 @@ test("a capitalised phrase mid-sentence is prose, not a label", () => {
|
|
|
254
254
|
test("criteria not numbered from 1 keep their own numbers", () => {
|
|
255
255
|
expect(brief("X Goal: g. Criteria: (2) two. (3) three.").parts[1].body).toBe("- (2) two.\n- (3) three.");
|
|
256
256
|
});
|
|
257
|
+
|
|
258
|
+
// pretty(): a one-line idea note splits into one paragraph per label.
|
|
259
|
+
test("pretty splits a labelled plain line in a note by its labels", async () => {
|
|
260
|
+
const { pretty } = await import("../web/board/chat.ts");
|
|
261
|
+
const t = "Parent: run-x add a timezone. OPEN QUESTION (waiting on Johan): recalc old rows? Context: PR #452.";
|
|
262
|
+
expect(pretty(t)).toBe("**Parent:** run-x add a timezone.\n\n**OPEN QUESTION (waiting on Johan):** recalc old rows?\n\n**Context:** PR #452.");
|
|
263
|
+
expect(pretty("just a note")).toBe("just a note");
|
|
264
|
+
expect(pretty("Goal: a\nScope: b")).toBe("Goal: a\nScope: b");
|
|
265
|
+
expect(pretty("Parent: x\ndo y. NOTE: a. Context: b.")).toBe("Parent: x\n\ndo y.\n\n**NOTE:** a.\n\n**Context:** b.");
|
|
266
|
+
expect(pretty("- Goal: a. Scope: b.")).toBe("- Goal: a. Scope: b.");
|
|
267
|
+
});
|
|
268
|
+
|
|
269
|
+
test("the note editor turns its DOM back into the markdown md() rendered it from", async () => {
|
|
270
|
+
const { toMd } = await import("../web/board/rich.ts");
|
|
271
|
+
// A minimal DOM: element nodes from md()'s tag soup, enough for toMd.
|
|
272
|
+
const parse = (h) => {
|
|
273
|
+
const root = { nodeType: 1, nodeName: "DIV", childNodes: [], getAttribute: () => null };
|
|
274
|
+
const stack = [root];
|
|
275
|
+
for (const m of h.matchAll(/<(\/?)(\w+)([^>]*)>|([^<]+)/g)) {
|
|
276
|
+
const top = stack[stack.length - 1];
|
|
277
|
+
if (m[4]) top.childNodes.push({ nodeType: 3, nodeName: "#text", textContent: m[4].replace(/</g, "<").replace(/>/g, ">").replace(/&/g, "&") });
|
|
278
|
+
else if (m[1]) stack.pop();
|
|
279
|
+
else {
|
|
280
|
+
const attrs = Object.fromEntries([...m[3].matchAll(/(\w+)="([^"]*)"/g)].map((a) => [a[1], a[2]]));
|
|
281
|
+
const el = { nodeType: 1, nodeName: m[2].toUpperCase(), childNodes: [], getAttribute: (k) => attrs[k] ?? null };
|
|
282
|
+
Object.defineProperty(el, "textContent", { get: () => el.childNodes.map((c) => c.textContent).join("") });
|
|
283
|
+
top.childNodes.push(el);
|
|
284
|
+
if (!/^(img|br)$/.test(m[2])) stack.push(el);
|
|
285
|
+
}
|
|
286
|
+
}
|
|
287
|
+
return root;
|
|
288
|
+
};
|
|
289
|
+
const src = "# Title\n\nsome **bold**, *em* and `code` here \nnext\n\n- one\n- two\n\n1. first\n2. second\n\n [link](http://x)";
|
|
290
|
+
expect(toMd(parse(md(src)))).toBe(src);
|
|
291
|
+
// A fresh line typed straight into the box, then one Chrome wrapped in a div.
|
|
292
|
+
expect(toMd(parse("plain <b>b</b><div>next line</div>"))).toBe("plain **b**\n\nnext line");
|
|
293
|
+
});
|
|
294
|
+
|
|
295
|
+
test("md() renders italic and two-space hard breaks, and leaves globs in code alone", () => {
|
|
296
|
+
expect(md("an *em* word")).toBe("<p>an <i>em</i> word</p>");
|
|
297
|
+
expect(md("`*.ts` and `*.md`, a * b * c")).toBe("<p><code>*.ts</code> and <code>*.md</code>, a * b * c</p>");
|
|
298
|
+
expect(md("one \ntwo\nthree")).toBe("<p>one<br>two three</p>");
|
|
299
|
+
});
|
package/cli/install.ts
CHANGED
|
@@ -96,15 +96,17 @@ if (Bun.argv[2] === "browser") {
|
|
|
96
96
|
process.exit(0);
|
|
97
97
|
}
|
|
98
98
|
|
|
99
|
-
// `kaizen card add "<
|
|
100
|
-
// to the run it came from.
|
|
99
|
+
// `kaizen card add "<title>" [--parent <run id>] [--notes "<details>"]`: what a run
|
|
100
|
+
// files as new work, linked to the run it came from.
|
|
101
101
|
if (Bun.argv[2] === "card") {
|
|
102
102
|
const { locate, label, addCard } = await import("./state.ts");
|
|
103
103
|
const state = locate() ?? join(home, ".kaizen");
|
|
104
104
|
const args = Bun.argv.slice(4), at = args.indexOf("--parent");
|
|
105
105
|
const parent = at >= 0 ? args.splice(at, 2)[1] : undefined;
|
|
106
|
-
|
|
107
|
-
const
|
|
106
|
+
const nt = args.indexOf("--notes");
|
|
107
|
+
const details = nt >= 0 ? args.splice(nt, 2)[1] : undefined;
|
|
108
|
+
if (Bun.argv[3] !== "add" || !args.length || (at >= 0 && !parent) || (nt >= 0 && !details)) { console.log(`\n kaizen card add "<title>" [--parent <run id>] [--notes "<details>"]\n`); process.exit(1); }
|
|
109
|
+
const r = addCard(state, args.join(" "), parent, details);
|
|
108
110
|
if (typeof r === "string") { console.log(`\n ${r}\n`); process.exit(1); }
|
|
109
111
|
console.log(`\n ${c.green("added")} ${args.join(" ")}\n ${c.dim(`parent ${r.parent} · project ${label(state)}`)}\n`);
|
|
110
112
|
process.exit(0);
|
package/cli/pty.test.ts
CHANGED
|
@@ -186,6 +186,16 @@ test.skipIf(process.platform === "win32")("the done sweep keeps a session the us
|
|
|
186
186
|
await Bun.sleep(200);
|
|
187
187
|
expect(alive(used)).toBe(true);
|
|
188
188
|
process.kill(used, "SIGTERM");
|
|
189
|
+
|
|
190
|
+
// Only looked at: the page's focus and query answers reach the tty but are not typing.
|
|
191
|
+
done(Date.now() - 60_000);
|
|
192
|
+
const looked = launch("looked");
|
|
193
|
+
await Bun.sleep(200);
|
|
194
|
+
writePty("looked", "\x1b[I\x1b[O\x1b[?0u\x1b[<64;10;5M\x1bP>|xterm.js(6.0)\x1b\\");
|
|
195
|
+
await Bun.sleep(200);
|
|
196
|
+
expect(ptyState("looked")!.typed).toBe(0);
|
|
197
|
+
sweepSessions(st, Date.now() + CLOSE_GRACE_MS);
|
|
198
|
+
expect(await gone(looked)).toBe(true);
|
|
189
199
|
});
|
|
190
200
|
|
|
191
201
|
test("asking: Claude Code and Codex prompts count as a question, ordinary output does not", async () => {
|
package/cli/pty.ts
CHANGED
|
@@ -11,7 +11,7 @@ const WIN = process.platform === "win32";
|
|
|
11
11
|
// Windows PowerShell, which every Windows has, both hosts the console and runs the agent in it.
|
|
12
12
|
const winPs = () => join(process.env.SystemRoot ?? "C:\\Windows", "System32", "WindowsPowerShell", "v1.0", "powershell.exe");
|
|
13
13
|
|
|
14
|
-
type Pty = { proc: Bun.Subprocess; out: string[]; size: number; listeners: Set<(d: string | null) => void>; exit: number | null; asking: boolean; quiet?: Timer; total: number; since: number; onChange?: () => void };
|
|
14
|
+
type Pty = { proc: Bun.Subprocess; out: string[]; size: number; listeners: Set<(d: string | null) => void>; exit: number | null; asking: boolean; quiet?: Timer; total: number; since: number; typed: number; onChange?: () => void };
|
|
15
15
|
|
|
16
16
|
// What a page attaching late gets replayed. A full-screen tool redraws often, so the
|
|
17
17
|
// tail of its output is enough to rebuild the screen.
|
|
@@ -38,7 +38,7 @@ export const ptySupported = WIN || typeof (Bun as any).Terminal === "function";
|
|
|
38
38
|
// onChange: called when the agent ends or starts or stops asking, so the board can redraw.
|
|
39
39
|
export function startPty(key: string, cmd: string[], cwd: string, pidFile?: string, onChange?: () => void): number {
|
|
40
40
|
const dec = new TextDecoder();
|
|
41
|
-
const p: Pty = { proc: null as unknown as Bun.Subprocess, out: [], size: 0, listeners: new Set(), exit: null, asking: false, total: 0, since: 0, onChange };
|
|
41
|
+
const p: Pty = { proc: null as unknown as Bun.Subprocess, out: [], size: 0, listeners: new Set(), exit: null, asking: false, total: 0, since: 0, typed: 0, onChange };
|
|
42
42
|
const flag = (v: boolean) => { if (p.asking !== v) { p.asking = v; onChange?.(); } };
|
|
43
43
|
const push = (d: string) => {
|
|
44
44
|
p.out.push(d);
|
|
@@ -99,11 +99,19 @@ export function attachPty(key: string, fn: (d: string | null) => void): (() => v
|
|
|
99
99
|
return () => p.listeners.delete(fn);
|
|
100
100
|
}
|
|
101
101
|
|
|
102
|
+
// What the page's terminal sends by itself: focus in/out (Claude Code turns focus
|
|
103
|
+
// reporting on), mouse and wheel reports, and answers to the agent's queries (cursor,
|
|
104
|
+
// device, mode, colour, version). None of it is the user typing.
|
|
105
|
+
const REPORT = /\x1b\[[IO]|\x1b\[<[\d;]*[Mm]|\x1b\[\?[\d;]*(?:[cnu]|\$y)|\x1b\[>[\d;]*c|\x1b\[\d+;\d+R|\x1b\[\d*n|\x1b\][^\x07\x1b]*(?:\x07|\x1b\\)|\x1bP[^\x1b]*\x1b\\/g;
|
|
106
|
+
|
|
102
107
|
export function writePty(key: string, d: string) {
|
|
103
108
|
const p = ptys.get(key);
|
|
104
109
|
if (!p || p.exit !== null) return;
|
|
105
|
-
|
|
106
|
-
|
|
110
|
+
if (d.replace(REPORT, "")) {
|
|
111
|
+
p.typed = Date.now();
|
|
112
|
+
p.since = p.total;
|
|
113
|
+
if (p.asking) { p.asking = false; p.onChange?.(); }
|
|
114
|
+
}
|
|
107
115
|
send(p, d);
|
|
108
116
|
}
|
|
109
117
|
|
|
@@ -122,7 +130,7 @@ export const ptyKeys = () => [...ptys.keys()];
|
|
|
122
130
|
|
|
123
131
|
export const ptyState = (key: string) => {
|
|
124
132
|
const p = ptys.get(key);
|
|
125
|
-
return p ? { alive: p.exit === null, exit: p.exit, asking: p.asking } : null;
|
|
133
|
+
return p ? { alive: p.exit === null, exit: p.exit, asking: p.asking, typed: p.typed } : null;
|
|
126
134
|
};
|
|
127
135
|
|
|
128
136
|
// The environment the user's login shell builds: PATH and anything exported in their
|
package/cli/state.test.ts
CHANGED
|
@@ -1228,6 +1228,8 @@ test("parent/child: a Parent line links a run and an idea to the run that filed
|
|
|
1228
1228
|
expect(addCard(st, "next thing")).toEqual({ parent: "2026-01-02-b" });
|
|
1229
1229
|
expect(addCard(st, "next thing")).toMatch(/already/);
|
|
1230
1230
|
expect(addCard(st, "other", "nope")).toMatch(/no such run/);
|
|
1231
|
+
addCard(st, "titled", "2026-01-02-b", "the details\nline two");
|
|
1232
|
+
expect(readInbox(st).find((l) => l.text === "titled")?.notes).toContain("Parent: 2026-01-02-b\nthe details");
|
|
1231
1233
|
expect(boardCards([st], Date.now()).find((k) => k.text === "next thing")!.parent).toBe("2026-01-02-b");
|
|
1232
1234
|
rmSync(dirname(st), { recursive: true, force: true });
|
|
1233
1235
|
});
|
package/cli/state.ts
CHANGED
|
@@ -5,7 +5,7 @@ import { existsSync, readFileSync, readdirSync, appendFileSync, writeFileSync, m
|
|
|
5
5
|
import { Database } from "bun:sqlite";
|
|
6
6
|
import { join, dirname, win32, posix } from "node:path";
|
|
7
7
|
import { homedir, arch, cpus, release, totalmem, version as osVersion } from "node:os";
|
|
8
|
-
import { startPty, ptySupported } from "./pty.ts";
|
|
8
|
+
import { startPty, ptySupported, ptyState } from "./pty.ts";
|
|
9
9
|
import { codeDir, projectFromWorktree } from "./worktree.ts";
|
|
10
10
|
|
|
11
11
|
// The real path: process.cwd() is always physical, so a raw $HOME that crosses a
|
|
@@ -915,8 +915,9 @@ export function appendNote(state: string, target: { id: string } | { text: strin
|
|
|
915
915
|
}
|
|
916
916
|
|
|
917
917
|
// A card filed by a run: an open idea whose note names the run as its parent. The
|
|
918
|
-
// parent is the one named, else the newest run still in flight.
|
|
919
|
-
|
|
918
|
+
// parent is the one named, else the newest run still in flight. `details` go in the
|
|
919
|
+
// idea's notes below the Parent line, so the title stays a title.
|
|
920
|
+
export function addCard(state: string, text: string, parent?: string, details?: string): { parent: string } | string {
|
|
920
921
|
text = text.trim().replace(/\s+/g, " ");
|
|
921
922
|
if (!text) return "empty card";
|
|
922
923
|
const id = parent ?? readRuns(state).find((r) => r.stage !== "done" && r.stage !== "abandoned")?.id;
|
|
@@ -924,7 +925,7 @@ export function addCard(state: string, text: string, parent?: string): { parent:
|
|
|
924
925
|
if (!existsSync(join(state, "runs", id, "state.json"))) return `no such run: ${id}`;
|
|
925
926
|
const lines = readInbox(state);
|
|
926
927
|
if (lines.some((l) => l.status === "open" && l.text === text)) return "that card is already on the board";
|
|
927
|
-
writeInbox(state, [...lines, { status: "open", text, notes: `Parent: ${id}
|
|
928
|
+
writeInbox(state, [...lines, { status: "open", text, notes: [`Parent: ${id}`, details?.trim()].filter(Boolean).join("\n") }]);
|
|
928
929
|
return { parent: id };
|
|
929
930
|
}
|
|
930
931
|
|
|
@@ -1343,7 +1344,7 @@ function opencodeRows(sql: string, ...args: (string | number)[]): string[] {
|
|
|
1343
1344
|
} catch { return []; }
|
|
1344
1345
|
}
|
|
1345
1346
|
|
|
1346
|
-
export const CLOSE_GRACE_MS =
|
|
1347
|
+
export const CLOSE_GRACE_MS = 60_000;
|
|
1347
1348
|
const sessionsDir = (st: string) => join(st, "sessions");
|
|
1348
1349
|
|
|
1349
1350
|
export function readSessions(st: string): Session[] {
|
|
@@ -1509,7 +1510,11 @@ export function sweepSessions(st: string, now: number) {
|
|
|
1509
1510
|
// Whether the user typed into the session's terminal after `t`: its tty's access time
|
|
1510
1511
|
// moves when input is read. Such a session is the user's now, not the run's to close.
|
|
1511
1512
|
// No tty (Windows, a stranger's PID): false, so the sweep closes as before.
|
|
1513
|
+
// A board card's session is judged by its keystrokes: the page's terminal answers the
|
|
1514
|
+
// agent's focus and query reports on that tty too, which moved its time on every look.
|
|
1512
1515
|
function typedSince(st: string, s: Session, t: number): boolean {
|
|
1516
|
+
const card = ptyState(s.key);
|
|
1517
|
+
if (card) return card.typed > t;
|
|
1513
1518
|
try {
|
|
1514
1519
|
if (process.platform === "win32") return false;
|
|
1515
1520
|
const pid = parseInt(readText(join(sessionsDir(st), s.key + ".pid")));
|
package/cli/web.ts
CHANGED
|
@@ -209,6 +209,8 @@ export async function web(repoDir: string, opts: Opts = {}) {
|
|
|
209
209
|
const key = c.kind === "run" ? runs.get(`${c.state}\0${c.id}`) : ideaPty.get(`${c.state}\0${c.text}`);
|
|
210
210
|
if (!key || !live.has(key)) return undefined;
|
|
211
211
|
const st = ptyState(key)!;
|
|
212
|
+
// A finished run's agent that has ended has nothing left to show or resume.
|
|
213
|
+
if (!st.alive && (c.status === "done" || c.status === "abandoned")) return undefined;
|
|
212
214
|
return { key, alive: st.alive, asking: st.asking };
|
|
213
215
|
};
|
|
214
216
|
};
|
package/package.json
CHANGED
package/skills/kaizen/SKILL.md
CHANGED
|
@@ -245,6 +245,12 @@ it. Everything else in this file holds unchanged.
|
|
|
245
245
|
yourself, in that order, reading only each stage's declared inputs and writing its
|
|
246
246
|
artifact before starting the next. The stage table below still applies; you are the
|
|
247
247
|
agent for every row.
|
|
248
|
+
Read `spec-build.md` before building and `spec-main.md` before moving the run to
|
|
249
|
+
`review`: being the main thread does not excuse a stage its contract. Its *Preview
|
|
250
|
+
gate* holds in full — a result that shows on a screen needs a real screenshot under
|
|
251
|
+
`runs/<id>/attachments/` embedded in `## Preview`, taken by driving a headless browser
|
|
252
|
+
against the running app; "no browser session" or "same renderer as before" is not a
|
|
253
|
+
reason, it is the gate failing. Short path or not, no screenshot means not built.
|
|
248
254
|
- `runner: full` — dispatch each stage as its own subagent, as follows.
|
|
249
255
|
|
|
250
256
|
Under `full`, each stage runs as a subagent via the Agent tool, with the agent type
|
|
@@ -114,7 +114,7 @@ agent:
|
|
|
114
114
|
# auto picks Claude Code if installed, otherwise the first agent found on PATH.
|
|
115
115
|
default: auto
|
|
116
116
|
# on: a board decision ends the run's waiting session before continuing its
|
|
117
|
-
# conversation, and abandoned or done runs (after
|
|
117
|
+
# conversation, and abandoned or done runs (after 1 minute) have theirs ended.
|
|
118
118
|
# Only sessions the board or dashboard launched are touched. off: nothing is
|
|
119
119
|
# ended, and a resumed conversation opens beside the window still waiting.
|
|
120
120
|
close: on
|
package/skills/kaizen/docs.md
CHANGED
|
@@ -108,7 +108,9 @@ updated: YYYY-MM-DD
|
|
|
108
108
|
2. Split it into 5–12 areas a newcomer would name — by what the parts do, not by
|
|
109
109
|
folder alone. Fewer for a small project.
|
|
110
110
|
3. Write one note per area in the shape above, then `Index.md`, then `Log.md` with the
|
|
111
|
-
first line.
|
|
111
|
+
first line. Every source file lands in some note's `sources` — the area's helpers,
|
|
112
|
+
small screens and scripts too, not only its main files — named directly or through a
|
|
113
|
+
folder that holds it (see *Coverage* below).
|
|
112
114
|
4. Write the pointer (below).
|
|
113
115
|
5. Lint, and report.
|
|
114
116
|
|
|
@@ -124,8 +126,9 @@ updated: YYYY-MM-DD
|
|
|
124
126
|
rewrite the sections they affect — never `## Notes` — and set `updated`.
|
|
125
127
|
3. Every `sources` path is gone: move the note to `Project/Archive/` and drop it from
|
|
126
128
|
the Index. Never delete a note.
|
|
127
|
-
4. A
|
|
128
|
-
|
|
129
|
+
4. A source file no note covers (see *Coverage*): add it to the `sources` of the note
|
|
130
|
+
whose area it belongs to, and mention it there; where it fits no area (a new top-level
|
|
131
|
+
folder, a new entry point), write a note for it.
|
|
129
132
|
5. Refresh the Index lines whose description changed, append one Log line naming every
|
|
130
133
|
note touched, refresh the pointer, lint, and report.
|
|
131
134
|
|
|
@@ -138,10 +141,20 @@ Checks, reported and not silently fixed:
|
|
|
138
141
|
- every note has `description`, `tags`, `sources` and `updated` (the Index and Log need
|
|
139
142
|
no `sources`), and is listed in the Index;
|
|
140
143
|
- every `sources` path exists;
|
|
144
|
+
- **uncovered**: source files no note's `sources` names or holds, listed one per line;
|
|
141
145
|
- **stale**: notes whose `sources` changed after their `updated` day, found as in
|
|
142
146
|
*Update* step 1;
|
|
143
147
|
- `Index.md` is under 60 lines.
|
|
144
148
|
|
|
149
|
+
## Coverage
|
|
150
|
+
|
|
151
|
+
A **source file** is one the repository tracks (`git ls-files`; outside git, every file
|
|
152
|
+
not ignored), leaving out lockfiles, generated or built output, vendored code, binaries,
|
|
153
|
+
and bulk data folders. A note covers a file when its `sources` names the file or a folder
|
|
154
|
+
that holds it, so a data folder such as `public/quran` is covered by naming the folder.
|
|
155
|
+
Lint's *uncovered* list is what makes this checkable: a file no note covers is also a file
|
|
156
|
+
whose changes *stale* never sees, so the vault falls behind without saying so.
|
|
157
|
+
|
|
145
158
|
## The pointer
|
|
146
159
|
|
|
147
160
|
What makes an agent open the Index first. One block, identical everywhere:
|
|
@@ -183,7 +183,10 @@ failed commit is reported as printed; the run is still `done`. With the setting
|
|
|
183
183
|
|
|
184
184
|
A run may file new work as its own card instead of growing its scope — under either
|
|
185
185
|
runner. Under `full`, subagents only suggest it in their report; the main thread files it,
|
|
186
|
-
from the project folder: `kaizen card add "<
|
|
186
|
+
from the project folder: `kaizen card add "<short title>" --notes "<details>"`. The title
|
|
187
|
+
is what the card shows — a few words, like a commit subject; everything else (scope,
|
|
188
|
+
context, open questions, where it came from) goes in `--notes`, never in the title.
|
|
189
|
+
It links the card to
|
|
187
190
|
the run in flight (`--parent <run id>` names another), and the link follows the card into
|
|
188
191
|
the run it becomes. The board shows the parent's name on the child.
|
|
189
192
|
|
|
@@ -191,3 +194,31 @@ When a child run reaches `done`, the board appends `Child <child id> done: <titl
|
|
|
191
194
|
the parent's `notes.md` by itself. The parent documents it from there: if it is still in
|
|
192
195
|
flight its next stage reads `notes.md`; if it is already `done`, the entry is the record.
|
|
193
196
|
Never reopen a finished parent.
|
|
197
|
+
|
|
198
|
+
**A roadmap's increments are filed at its approval.** When the approved plan has a
|
|
199
|
+
`## Roadmap` (see *A whole application is a roadmap* in `spec-plan.md`), the same write
|
|
200
|
+
that records the approval files increments 2 to N, in order, from the project folder:
|
|
201
|
+
`kaizen card add "<title> (roadmap #N · after #a, #b)" --parent <run id>`, with
|
|
202
|
+
`any time` in place of `after …` where it has no prerequisite and its `blocked: <reason>`
|
|
203
|
+
kept in the text where it has one. Increment 1 is this run's own build. Nothing starts
|
|
204
|
+
by itself: the user starts an increment from its card when they choose, and the parent's
|
|
205
|
+
card lists its children with their status. An increment cut at the approval is not
|
|
206
|
+
filed; it goes to the backlog like anything else cut there.
|
|
207
|
+
|
|
208
|
+
## Discovery rounds
|
|
209
|
+
|
|
210
|
+
A roadmap plan's Discovery and any run's REA offer reach the user through the main
|
|
211
|
+
thread, under every runner: each round's questions are asked with the tool's structured
|
|
212
|
+
question mechanism (AskUserQuestion in Claude Code), all of a round's questions in one
|
|
213
|
+
call, each with the planner's recommended option first. Under `runner: full`, the
|
|
214
|
+
planner stops after writing a round; dispatch it again with the answers, recorded in
|
|
215
|
+
`02-approval.md` as that round's decisions, until it writes a plan with no round left
|
|
216
|
+
to ask, then present that plan at the plan approval as usual. Answers are not an
|
|
217
|
+
approval: the run stays at `stage: "plan"` with `awaiting: null` throughout, and
|
|
218
|
+
`awaiting: "approvals.plan"` is set only once `01-plan.md` has a `## Roadmap` — a round
|
|
219
|
+
written to that file is not a plan to approve, and a tool resuming the run re-asks its
|
|
220
|
+
questions. Where the user says yes to REA before the plan exists, run the study here,
|
|
221
|
+
write what it found to the run's `notes.md`, and dispatch the planner with it. Where
|
|
222
|
+
REA has to be installed first, the install in *Offer REA* (`spec-plan.md`) is the main
|
|
223
|
+
thread's too: doctor, the dry-run shown to the user, setup only on their approval,
|
|
224
|
+
then the restart, after which the run resumes at `stage: "plan"` from `state.json`.
|
|
@@ -27,6 +27,39 @@ different work behind each, nothing available to break the tie — and even then
|
|
|
27
27
|
its recommendation with the question. Questions the existing material can answer are
|
|
28
28
|
never asked; that is the planner's own work.
|
|
29
29
|
|
|
30
|
+
**Offer REA for what exists but is not here.** In every run and every track: when the
|
|
31
|
+
request or what came with it names something that already exists but whose source is
|
|
32
|
+
not at hand — an app or site to copy, replace or integrate with, an APK or IPA, a
|
|
33
|
+
binary, a package, a file format, a device or its firmware, a protocol or a capture —
|
|
34
|
+
and REA's tools are connected in the session, the plan asks once:
|
|
35
|
+
`Q: Study <the thing> with REA first?`, with `yes` (recommended where how it works
|
|
36
|
+
inside decides the design) and `no`. Under `runner: lite` or an inline planner, a yes
|
|
37
|
+
given before the plan is written lets the planner run REA itself and plan from what it
|
|
38
|
+
found. Under `full`, where the planner has no REA tools, the main thread runs the study
|
|
39
|
+
on a yes, writes what it found to the run's `notes.md`, and dispatches the planner
|
|
40
|
+
again; a yes given at the plan approval instead makes the study the build's first
|
|
41
|
+
step. What REA
|
|
42
|
+
returns is cited with its limitations, as *the track* in `spec.md` already requires.
|
|
43
|
+
REA not connected: ask instead
|
|
44
|
+
`Q: REA isn't installed — set it up first?`, with `help me install it` (recommended
|
|
45
|
+
where how it works inside decides the design), `I'll install it myself` and `skip
|
|
46
|
+
REA`. On *help me install it* the main thread follows REA's own setup: it runs
|
|
47
|
+
`npx -y rea-agents@<version> doctor --client <client> --json` — the version pinned,
|
|
48
|
+
as REA's own guide pins it, taken from `npm view rea-agents version` and shown to the
|
|
49
|
+
user; the client from the run's `agent` (`claude` → `claude_code`, `codex` → `codex`,
|
|
50
|
+
`agy` → `antigravity`, `opencode` → `opencode`, `gemini` → `gemini_cli`, `cursor` →
|
|
51
|
+
`cursor`) — then `setup --client <client> --dry-run --json` with the same version, shows the user the exact files it
|
|
52
|
+
would change, and runs `setup --client <client> --yes` only after they approve it —
|
|
53
|
+
setup writes the agent's own configuration, so it is never run unseen, in any mode.
|
|
54
|
+
On *I'll install it myself* it gives that same command and waits. Either way the new
|
|
55
|
+
tools appear only after the agent restarts: say so, and the run resumes from its
|
|
56
|
+
`state.json`, the yes to REA recorded beforehand in `02-approval.md` so the resumed
|
|
57
|
+
session studies the thing rather than asking again. REA's prerequisites (Node, and
|
|
58
|
+
Java, Ghidra or JADX for some targets) are named where doctor reports them missing,
|
|
59
|
+
never installed by the run. On *skip REA*, or when setup fails, the plan says so
|
|
60
|
+
once and claims about that thing's internals are marked unverified. Nothing of the
|
|
61
|
+
kind named: REA is not mentioned.
|
|
62
|
+
|
|
30
63
|
**Building from nothing is the exception: settle the foundation first.** When the
|
|
31
64
|
track is a new product or app, there is no material to answer the questions that
|
|
32
65
|
decide everything after them, and "wrong" for this track is a wrong foundation — the
|
|
@@ -39,6 +72,67 @@ materially different builds — put to the user as an *Open question* with optio
|
|
|
39
72
|
all in one round, before anything is built. Three quick questions and a default for
|
|
40
73
|
the rest is how a run ends up writing the second app instead of the first.
|
|
41
74
|
|
|
75
|
+
**A whole application is a roadmap, not a run.** When the track is a new product or
|
|
76
|
+
app — web, mobile, PWA, desktop, anything — and it covers more than one independent
|
|
77
|
+
module, one run cannot hold it: the plan, the diff and the review all outgrow what a
|
|
78
|
+
reader can check. A single screen or a single feature stays one run. The Framing says
|
|
79
|
+
in one line that the plan is a roadmap, so the user can refuse it. A roadmap plan adds
|
|
80
|
+
three sections right after the Goal, each written for a person, and the
|
|
81
|
+
foundation round above becomes up to three rounds:
|
|
82
|
+
|
|
83
|
+
- **`## Discovery`** — the planner goes deep before it designs. It reads everything the
|
|
84
|
+
user brought first (decks, forms, sheets, screenshots, an existing repo, REA's
|
|
85
|
+
findings), then asks only what is still open, in up to three rounds of `Q:` pick
|
|
86
|
+
lists, each question with a recommended option: (1) purpose, who uses it and in what
|
|
87
|
+
roles, what it replaces; (2) the flows of each module and their domain rules;
|
|
88
|
+
(3) the foundation choices, among them how it ships, as a multi-select — full CI/CD
|
|
89
|
+
(tests and deploy on every merge), Docker (an image and a compose file that run
|
|
90
|
+
anywhere), a managed platform (Vercel, Fly, Railway or similar), mobile app ready
|
|
91
|
+
(store builds with signing, for example Capacitor or Expo/EAS), or a simple manual
|
|
92
|
+
deploy — and, where the app has a screen and no design system exists, its look and
|
|
93
|
+
feel, asked as item 7 *Design direction* below says: the feel (for example rich and
|
|
94
|
+
animated, calm and simple, enterprise and dense, gen-z and playful) and how much
|
|
95
|
+
motion. A round with nothing left to ask is skipped. Under `runner: full` the
|
|
96
|
+
planner writes `01-plan.md` with the Framing, `## Discovery` so far and the round's
|
|
97
|
+
questions as *Open questions*, and no `## Roadmap`, then stops; that file is a
|
|
98
|
+
round, not a plan. The main thread asks them and dispatches it again with the
|
|
99
|
+
answers. Under
|
|
100
|
+
`lite` or inline, the session asks them directly. The section records the roles, the
|
|
101
|
+
main flows, the things the app keeps track of and how they relate, a glossary of
|
|
102
|
+
the domain's words, and every answer given.
|
|
103
|
+
- **`## Foundation`** — a decision with its reason, derived from Discovery, for each
|
|
104
|
+
of: platform; frontend (framework, UI kit, state, the design direction chosen);
|
|
105
|
+
backend (runtime, framework, API style); the contract between them (REST, RPC or
|
|
106
|
+
realtime, and where its types live); hosting and deploy, as chosen in discovery;
|
|
107
|
+
database (engine, where it runs, the main tables or collections and their
|
|
108
|
+
relations, how migrations are done); auth and access; repo layout and conventions.
|
|
109
|
+
Every increment builds on it.
|
|
110
|
+
- **`## Roadmap`** — the increments, numbered, one line each:
|
|
111
|
+
`N. <title> — <what it delivers> — after: #a, #b` (or `any time`), with
|
|
112
|
+
`— blocked: <reason>` when something outside the work holds it. Increment 1 is always
|
|
113
|
+
the skeleton: the scaffold, the stack wired, the database schema and migration setup,
|
|
114
|
+
an auth stub, and a deploy of the empty app the way the Foundation ships (in its
|
|
115
|
+
Docker image, through its pipeline, as a store test build). Every feature increment
|
|
116
|
+
after it is one feature end to end — its screens, its API, its tables and migration,
|
|
117
|
+
and its tests — so each can be tried on its own; splitting by layer ("all the APIs", then "all the screens")
|
|
118
|
+
is only for an app with no UI or no backend. Each increment is sized to one ordinary
|
|
119
|
+
run. Where real people will use the app, the last increment is going live: what
|
|
120
|
+
the deploy choice still needs (the full pipeline, the production image, store
|
|
121
|
+
listings and release builds), backups, monitoring and error tracking, and the
|
|
122
|
+
production deploy. A last line says which must go in order and which can start any
|
|
123
|
+
time.
|
|
124
|
+
|
|
125
|
+
The rest of a roadmap plan — Work list, Steps, File manifest, gate — covers increment 1
|
|
126
|
+
only; it is this run's build. The others become child cards at the approval (see
|
|
127
|
+
*Child cards* in `spec-main.md`).
|
|
128
|
+
|
|
129
|
+
**An increment run builds inside its parent's decisions.** A request with a `Parent:`
|
|
130
|
+
line whose parent run's `01-plan.md` has a `## Roadmap` is an increment. Its planner
|
|
131
|
+
reads that plan's Discovery, Foundation and its own Roadmap line before anything else.
|
|
132
|
+
They bind it: a deviation from the Foundation is an *Open question* to the user, and a
|
|
133
|
+
change to the roadmap is an *Out of scope suggestion*, never a silent edit of the
|
|
134
|
+
parent's plan.
|
|
135
|
+
|
|
42
136
|
The goals stay the user's. The planner may propose a better solution to what was
|
|
43
137
|
asked, or argue that the request as stated will not achieve what the user wants, but
|
|
44
138
|
it may not widen the goal on its own. Anything beyond the request goes in *Out of
|
|
@@ -132,7 +226,7 @@ questions that are open. An alternative rejected gets a line saying what and why
|
|
|
132
226
|
a case against it.
|
|
133
227
|
|
|
134
228
|
**Write the parts a person reads for a person.** Framing, the Work list, Goal, Scope,
|
|
135
|
-
Risks and Out of scope suggestions are read by someone deciding whether to approve,
|
|
229
|
+
Discovery, Foundation, Roadmap, Risks and Out of scope suggestions are read by someone deciding whether to approve,
|
|
136
230
|
often on a narrow panel. Write them in everyday words: short sentences, one idea per
|
|
137
231
|
bullet, things named by what the user sees ("the chat bubble", "the Plan tab") rather
|
|
138
232
|
than by function, file, CSS value or gate id. Code names, `path:line` and gate ids
|
|
@@ -31,7 +31,7 @@ anything.
|
|
|
31
31
|
| `/kaizen backlog` | Print open backlog items across all runs, grouped by source run, rescued items last under `Orphaned` |
|
|
32
32
|
| `/kaizen gather` | Read request sources kaizen cannot read by itself; their requests land in the icebox, nothing starts |
|
|
33
33
|
| `/kaizen discover` | Group what is in the icebox and promote the ideas you pick; nothing starts |
|
|
34
|
-
| `/kaizen docs [lint]` | Write or refresh the project's docs vault in the notebook, which agents read first; `lint` only reports what is stale or
|
|
34
|
+
| `/kaizen docs [lint]` | Write or refresh the project's docs vault in the notebook, which agents read first; `lint` only reports what is stale, broken or uncovered |
|
|
35
35
|
| `/kaizen approve` | Approve whatever the current run is waiting on |
|
|
36
36
|
| `/kaizen reject <reason>` | Reject it; the reason decides revise (back to planner) vs kill (abandon) |
|
|
37
37
|
| `/kaizen abort` | Mark the current run abandoned |
|
package/web/board/chat.ts
CHANGED
|
@@ -4,6 +4,7 @@ import { mic, stopDictation } from "./dictation.ts";
|
|
|
4
4
|
import { $, html, raw as rawHtml } from "./dom.ts";
|
|
5
5
|
import { DK, draft } from "./drafts.ts";
|
|
6
6
|
import { attach, brief, md } from "./markdown.ts";
|
|
7
|
+
import { rich } from "./rich.ts";
|
|
7
8
|
import { toast } from "./toast.ts";
|
|
8
9
|
|
|
9
10
|
// Notes are a chat: one entry per message, files attached by paste or picker.
|
|
@@ -21,6 +22,23 @@ function parseNotes(src) {
|
|
|
21
22
|
}
|
|
22
23
|
return out.map((n) => ({ ...n, text: n.text.trim() })).filter((n) => n.text);
|
|
23
24
|
}
|
|
25
|
+
// A plain line written as "Label: … Label: …" reads as one paragraph per label.
|
|
26
|
+
// Markdown lines and fenced code are left as written.
|
|
27
|
+
export function pretty(t) {
|
|
28
|
+
let fence = false;
|
|
29
|
+
return t
|
|
30
|
+
.split("\n")
|
|
31
|
+
.map((l) => {
|
|
32
|
+
if (/^```/.test(l)) fence = !fence;
|
|
33
|
+
if (fence || /^\s*(#|```|\||[-*]\s|\d+\.\s|>)/.test(l)) return l;
|
|
34
|
+
const b = brief(l);
|
|
35
|
+
if (!b.parts.length) return l;
|
|
36
|
+
const head = l.startsWith(b.parts[0].label + ":") ? [] : [b.head];
|
|
37
|
+
return "\n" + [...head, ...b.parts.map((p) => `**${p.label}:** ${p.body}`)].join("\n\n") + "\n";
|
|
38
|
+
})
|
|
39
|
+
.join("\n")
|
|
40
|
+
.trim();
|
|
41
|
+
}
|
|
24
42
|
let nfocus = null;
|
|
25
43
|
export function chat(c, src, ro) {
|
|
26
44
|
const fix = (t) => attach(t, c);
|
|
@@ -28,9 +46,13 @@ export function chat(c, src, ro) {
|
|
|
28
46
|
const b = c.kind === "idea" ? brief(c.text) : { parts: [] };
|
|
29
47
|
let h =
|
|
30
48
|
(b.parts.length
|
|
31
|
-
? html`<section class="desc"
|
|
49
|
+
? html`<section class="desc">
|
|
50
|
+
${rawHtml(md(b.parts.map((p) => `### ${p.label}\n${p.body}`).join("\n\n")))}
|
|
51
|
+
</section>`
|
|
32
52
|
: "") +
|
|
33
|
-
html`<div class="chat"
|
|
53
|
+
html`<div class="chat">
|
|
54
|
+
${entries.map((n) => html`<div class="msg">${n.at ? html`<time>${n.at}</time>` : ""}${rawHtml(md(fix(pretty(n.text))))}</div>`)}${entries.length ? "" : html`<p style="color:var(--dim)">No notes yet.</p>`}
|
|
55
|
+
</div>`;
|
|
34
56
|
if (!ro)
|
|
35
57
|
h += `<div class="notes"><textarea placeholder="Add a note. Paste a screenshot, or attach files."></textarea><div class="att" id="natt"></div><div class="tools"><input type="file" id="nfile" multiple hidden><button class="btn ghost" id="npick">Attach</button><span>Enter sends, Shift+Enter newline</span><button class="btn pri" id="nsend">Send</button></div></div>`;
|
|
36
58
|
$("pbody").innerHTML = rawHtml(h);
|
|
@@ -38,11 +60,12 @@ export function chat(c, src, ro) {
|
|
|
38
60
|
const ta = $("pbody").querySelector("textarea"),
|
|
39
61
|
files = [];
|
|
40
62
|
draft(ta, "n:" + key(c));
|
|
63
|
+
const ed = rich(ta);
|
|
41
64
|
// Enter leaves focus in the box, and a focused box blocks the redraw: send blurs it
|
|
42
65
|
// so the note shows, and the new box takes focus back for the next note.
|
|
43
66
|
if (nfocus === key(c)) {
|
|
44
67
|
nfocus = null;
|
|
45
|
-
|
|
68
|
+
ed.focus();
|
|
46
69
|
}
|
|
47
70
|
{
|
|
48
71
|
const m = mic([ta]);
|
|
@@ -60,7 +83,7 @@ export function chat(c, src, ro) {
|
|
|
60
83
|
[...e.target.files].forEach(add);
|
|
61
84
|
e.target.value = "";
|
|
62
85
|
};
|
|
63
|
-
|
|
86
|
+
ed.onpaste = (e) => {
|
|
64
87
|
for (const it of e.clipboardData.items)
|
|
65
88
|
if (it.kind === "file") {
|
|
66
89
|
const f = it.getAsFile();
|
|
@@ -93,12 +116,12 @@ export function chat(c, src, ro) {
|
|
|
93
116
|
} catch (e) {}
|
|
94
117
|
toast("Noted");
|
|
95
118
|
nfocus = key(c);
|
|
96
|
-
|
|
119
|
+
ed.blur();
|
|
97
120
|
load();
|
|
98
121
|
}
|
|
99
122
|
};
|
|
100
123
|
$("nsend").onclick = send;
|
|
101
|
-
|
|
124
|
+
ed.onkeydown = (e) => {
|
|
102
125
|
if (e.key === "Enter" && !e.shiftKey) {
|
|
103
126
|
e.preventDefault();
|
|
104
127
|
send();
|
package/web/board/markdown.ts
CHANGED
|
@@ -64,7 +64,8 @@ export function planQuestions(src) {
|
|
|
64
64
|
export function brief(t) {
|
|
65
65
|
// A label opens a sentence; only the first may follow the head without punctuation,
|
|
66
66
|
// and then only as one word ("… previews Problem:"), so "the Board shows: x" is prose.
|
|
67
|
-
const re =
|
|
67
|
+
const re =
|
|
68
|
+
/(^|[.!?)]\s+|\s)((?:[A-Z][A-Z-]+(?: [A-Z][A-Z-]+){1,3}|[A-Z][\w-]*(?: [a-z][\w-]*){0,3})(?: \([^)]*\))?):\s+/g,
|
|
68
69
|
cut = [];
|
|
69
70
|
let m;
|
|
70
71
|
while ((m = re.exec(t)))
|
|
@@ -88,7 +89,7 @@ export function brief(t) {
|
|
|
88
89
|
}
|
|
89
90
|
|
|
90
91
|
// Enough markdown for kaizen's own files: headings, lists, fences, tables,
|
|
91
|
-
// inline code, bold. Everything is escaped first. With `tick`, a backlog item
|
|
92
|
+
// inline code, bold, italic, hard line breaks. Everything is escaped first. With `tick`, a backlog item
|
|
92
93
|
// (`- open: …`, `- done: …`, `- rejected: …`) shows its status as a checkbox.
|
|
93
94
|
export function md(src, tick) {
|
|
94
95
|
const lines = src.split("\n"),
|
|
@@ -99,6 +100,7 @@ export function md(src, tick) {
|
|
|
99
100
|
esc(s)
|
|
100
101
|
.replace(/`([^`]+)`/g, "<code>$1</code>")
|
|
101
102
|
.replace(/\*\*([^*]+)\*\*/g, "<b>$1</b>")
|
|
103
|
+
.replace(/(^|[^*])\*([^*\s<][^*<]*?)\*(?!\*)/g, "$1<i>$2</i>")
|
|
102
104
|
.replace(/!\[([^\]]*)\]\(([^)\s]+)\)/g, '<a href="$2" target="_blank"><img src="$2" alt="$1"></a>')
|
|
103
105
|
.replace(/\[([^\]]+)\]\(([^)\s]+)\)/g, '<a href="$2" target="_blank">$1</a>');
|
|
104
106
|
const endList = () => {
|
|
@@ -182,7 +184,10 @@ export function md(src, tick) {
|
|
|
182
184
|
i++;
|
|
183
185
|
while (i < lines.length && lines[i].trim() && !/^(#|```|\||\s*[-*]\s|\s*\d+\.\s|---)/.test(lines[i]))
|
|
184
186
|
buf.push(lines[i++]);
|
|
185
|
-
|
|
187
|
+
// A line ending in two spaces is a hard line break.
|
|
188
|
+
out.push(
|
|
189
|
+
`<p>${inl(buf.map((l, k) => (k < buf.length - 1 && / {2}$/.test(l) ? l.trimEnd() + "\0" : l)).join(" ")).replace(/\0 /g, "<br>")}</p>`,
|
|
190
|
+
);
|
|
186
191
|
}
|
|
187
192
|
endList();
|
|
188
193
|
return out.join("");
|