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 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(/&lt;/g, "<").replace(/&gt;/g, ">").replace(/&amp;/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![shot](/f/a.png) [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 "<text>" [--parent <run id>]`: what a run files as new work, linked
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
- if (Bun.argv[3] !== "add" || !args.length || (at >= 0 && !parent)) { console.log(`\n kaizen card add "<text>" [--parent <run id>]\n`); process.exit(1); }
107
- const r = addCard(state, args.join(" "), parent);
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
- p.since = p.total;
106
- if (p.asking) { p.asking = false; p.onChange?.(); }
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
- export function addCard(state: string, text: string, parent?: string): { parent: string } | string {
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 = 2 * 60_000;
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "kaizen-agent",
3
- "version": "1.43.1",
3
+ "version": "1.43.3",
4
4
  "description": "Staged workflow for AI agents — code, writing, research, operations: plan, human approval, build, independent review, bounded fix loop.",
5
5
  "type": "module",
6
6
  "bin": {
@@ -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 2 minutes) have theirs ended.
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
@@ -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 part of the repository no note covers (a new top-level folder, a new entry point):
128
- write a note for it.
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 "<the work, one line>"`. It links the card to
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 broken |
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">${rawHtml(md(b.parts.map((p) => `### ${p.label}\n${p.body}`).join("\n\n")))}</section>`
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">${entries.map((n) => html`<div class="msg">${n.at ? html`<time>${n.at}</time>` : ""}${rawHtml(md(fix(n.text)))}</div>`)}${entries.length ? "" : html`<p style="color:var(--dim)">No notes yet.</p>`}</div>`;
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
- ta.focus();
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
- ta.onpaste = (e) => {
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
- ta.blur();
119
+ ed.blur();
97
120
  load();
98
121
  }
99
122
  };
100
123
  $("nsend").onclick = send;
101
- ta.onkeydown = (e) => {
124
+ ed.onkeydown = (e) => {
102
125
  if (e.key === "Enter" && !e.shiftKey) {
103
126
  e.preventDefault();
104
127
  send();
@@ -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 = /(^|[.!?)]\s+|\s)([A-Z][\w-]*(?: [a-z][\w-]*){0,3}):\s+/g,
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
- out.push(`<p>${inl(buf.join(" "))}</p>`);
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("");