kaizen-agent 1.31.3 → 1.33.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 CHANGED
@@ -42,6 +42,58 @@ The board opens in your browser. The side menu holds the board, notes, the icebo
42
42
 
43
43
  To add a launcher (Spotlight on macOS, the app menu on Linux, a Desktop shortcut on Windows), run `kaizen web --shortcut` once.
44
44
 
45
+ ## Development
46
+
47
+ TypeScript on [Bun](https://bun.sh) 1.1 or newer, zero runtime dependencies. No build step for the CLI — `cli/*.ts` runs as-is — and the web front end ships prebuilt.
48
+
49
+ ```bash
50
+ git clone https://github.com/hfadhlullah/kaizen.git && cd kaizen
51
+ bun install # dev dependencies only
52
+ bun test # the suite; what CI runs
53
+ ```
54
+
55
+ To work on kaizen, install from your checkout so every link points at it:
56
+
57
+ ```bash
58
+ bun run cli/install.ts
59
+ ```
60
+
61
+ Skills, agents and commands are symlinked into every AI agent it finds; re-running is safe. From a checkout the workflow updates on any run, and `kaizen upgrade` leaves your local edits alone — update them with git.
62
+
63
+ After editing `web/notebook.ts` or `web/notes-lib.ts`, rebuild the committed bundle:
64
+
65
+ ```bash
66
+ bun run build:web # rewrites web/notebook.js; a test fails if it goes stale
67
+ ```
68
+
69
+ ### Layout
70
+
71
+ | Path | What it is |
72
+ |---|---|
73
+ | `cli/` | The installer and the runtime behind every `kaizen` command: `install.ts` (the CLI), `state.ts` (runs), `web.ts` (board server), `dashboard.ts` (terminal board), `sources.ts`, `settings.ts`, `notes.ts`. Tests beside them. |
74
+ | `skills/` | The workflow as Agent Skills folders: `kaizen/` (`SKILL.md`, the `spec-*.md` stage contracts, `adapters.md`, `config.default.yml`), `kaizen-help/`, `story-deck/`, `story-video/`. |
75
+ | `agents/` | The Claude Code subagents: `kaizen-planner`, `kaizen-builder`, `kaizen-reviewer`. |
76
+ | `commands/` | The `/kaizen-*` slash commands, one file each. |
77
+ | `web/` | The board and notebook front end: `board.html`, `notes.html`, `notebook.ts`. |
78
+ | `bot/` | Kaizen Bot — a separate private package with its own tests and build. See [its README](bot/README.md). |
79
+ | `docs/` | The guide and internal notes, indexed in [`docs/README.md`](docs/README.md). |
80
+ | `design.md` | Design notes for the repo. |
81
+ | `.kaizen/` | This repo's own runs — kaizen is built with kaizen. Gitignored: a fresh clone gets it on its first run. |
82
+
83
+ ### Kaizen Bot
84
+
85
+ ```bash
86
+ cd bot
87
+ cp .env.example .env # PROVIDER, MODEL, that provider's key
88
+ bun start # http://127.0.0.1:7430 — run `kaizen web` first
89
+ bun test
90
+ bun run build # the desktop app, into dist/
91
+ ```
92
+
93
+ ### Contributing
94
+
95
+ Pull requests; CI runs `bun test` on Ubuntu and Windows and it must pass. For non-trivial work, dogfood: `/kaizen <change>` in this repo plans, builds and reviews it — [`skills/kaizen/spec.md`](skills/kaizen/spec.md) is the contract the stages follow.
96
+
45
97
  ## License
46
98
 
47
99
  MIT
package/cli/board.test.ts CHANGED
@@ -1,12 +1,12 @@
1
- // The board's markdown renderer, lifted out of web/board.html and run as it ships.
1
+ // The board's markdown renderer and helpers, imported from the board's modules as they ship.
2
2
  import { test, expect } from "bun:test";
3
3
  import { readFileSync } from "node:fs";
4
4
  import { join, dirname } from "node:path";
5
+ import { md, progress, planQuestions, brief } from "../web/board/markdown.ts";
6
+ import { draft, undraft } from "../web/board/drafts.ts";
5
7
 
6
8
  const html = readFileSync(join(dirname(import.meta.dir), "web", "board.html"), "utf8");
7
- const esc = html.match(/^const esc=.*$/m)![0];
8
- const at = html.indexOf("function md(");
9
- const md = new Function(`${esc}\n${html.slice(at, html.indexOf("\n\n", at))}\nreturn md`)() as (src: string, tick?: boolean) => string;
9
+ const cards = readFileSync(join(dirname(import.meta.dir), "web", "board", "cards.ts"), "utf8");
10
10
 
11
11
  test("backlog items: a checkbox each, ticked only when done", () => {
12
12
  const out = md('- open: one | why\n- done: two <b>x</b> | fixed\n- rejected: three | no\n- plain\n', true);
@@ -23,16 +23,16 @@ test("without the flag a backlog line is an ordinary list item", () => {
23
23
  expect(md("- done: two\n")).toBe("<ul><li>done: two</li></ul>");
24
24
  });
25
25
 
26
- // draft()/undraft() lifted the same way, against a stub sessionStorage whose keys are its own properties.
26
+ // draft()/undraft(), against a stub sessionStorage whose keys are its own properties.
27
27
  const store = () => Object.defineProperties({} as Record<string, string>, {
28
28
  getItem: { value(k: string) { return k in this ? this[k] : null; } },
29
29
  setItem: { value(k: string, v: string) { this[k] = String(v); } },
30
30
  removeItem: { value(k: string) { delete this[k]; } },
31
31
  });
32
- const line = (re: RegExp) => html.match(re)![0];
33
- const drafts = (ss: object) => new Function("sessionStorage",
34
- `${line(/^const DK=.*$/m)}\n${line(/^function draft\(.*$/m)}\n${line(/^function undraft\(.*$/m)}\nreturn {draft,undraft}`)(ss) as
35
- { draft: (el: any, k: string) => void; undraft: (p: string) => void };
32
+ const drafts = (ss: object) => {
33
+ (globalThis as any).sessionStorage = ss;
34
+ return { draft, undraft } as { draft: (el: any, k: string) => void; undraft: (p: string) => void };
35
+ };
36
36
  const field = (value = "") => ({ value, on: () => {}, addEventListener(_: string, f: () => void) { this.on = f; } });
37
37
 
38
38
  test("a draft is saved on input and restored into a rebuilt field", () => {
@@ -54,9 +54,7 @@ test("undraft removes by prefix and leaves the rest", () => {
54
54
  expect(Object.keys(ss).sort()).toEqual(["kz-d:n:x", "kz-form"]);
55
55
  });
56
56
 
57
- // A building run's progress list, lifted the same way.
58
- const pat = html.indexOf("function progress(");
59
- const progress = new Function(`${esc}\n${html.slice(pat, html.indexOf("\n\n", pat))}\nreturn progress`)() as (src: string, prev?: string[]) => string;
57
+ // A building run's progress list.
60
58
 
61
59
  test("progress: each item marked done, working on or to do, with the count", () => {
62
60
  const out = progress("# Progress\n- done: one\n- doing: two <b>x</b>\n- todo: `three`\n- open: not an item\n");
@@ -77,10 +75,7 @@ test("progress: a file with no items draws nothing", () => {
77
75
  expect(progress("# Progress\n\nnothing yet\n")).toBe("");
78
76
  });
79
77
 
80
- // A plan's open questions, lifted the same way: the function ends at the first line not indented.
81
- const pq = html.indexOf("function planQuestions(");
82
- type Q = { q: string; multi: boolean; options: { text: string; label: string; rec: boolean }[] };
83
- const planQuestions = new Function(`${html.slice(pq).split(/\n(?! )/)[0]}\nreturn planQuestions`)() as (src: string) => Q[] | null;
78
+ // A plan's open questions.
84
79
 
85
80
  test("planQuestions: Q lines with options, recommended and multi-select read", () => {
86
81
  const qs = planQuestions(`# Plan\n## 9. Open questions\n\nQ: Which improvements to build? (multi-select)\n - Own project in row (recommended) — fixes Move; XS\n - Gather on add — instant result\nQ: Where the progress shows\n - Top of the Backlog tab (recommended): what you asked for.\n - Its own tab: adds an eighth tab\n\n- Note: not an option\n## 10. Out of scope suggestions\n- not a question\n`);
@@ -108,8 +103,9 @@ test("card menu items: appropriate actions and distinct icons across phases", ()
108
103
  expect(html).toContain('<symbol id="i-restore"');
109
104
 
110
105
  const ico = (n: string) => `<svg><use href="#i-${n}"/></svg>`;
111
- const menuSnippet = (html.slice(html.indexOf("if(k===menuKey){"), html.indexOf("el.innerHTML+=`<div class=\"menu-pop\"", html.indexOf("if(k===menuKey){"))) + "}").replace("const items=[];", "items=[];");
112
- const getItems = new Function("c", "archive", "S", `const ico = ${ico.toString()};\nlet k = 1, menuKey = 1, items = [];\n${menuSnippet}\nreturn items;`);
106
+ const from = cards.indexOf("if (k === store.menuKey) {");
107
+ const menuSnippet = (cards.slice(from, cards.indexOf('el.innerHTML += `<div class="menu-pop"', from)) + "}").replace("const items = [];", "items = [];");
108
+ const getItems = new Function("c", "archive", "S", `const ico = ${ico.toString()};\nconst store = { S, menuKey: 1 };\nlet k = 1, items = [];\n${menuSnippet}\nreturn items;`);
113
109
 
114
110
  // Phase 0: Ideas (idea card)
115
111
  const idea = { kind: "idea", status: "idea", column: 0 };
@@ -173,9 +169,7 @@ test("card menu items: appropriate actions and distinct icons across phases", ()
173
169
  expect(archIdea.map((i: any) => i ? i[1] : null)).toEqual(["Restore", "Open", null, "Delete"]);
174
170
  });
175
171
 
176
- // brief() lifted the same way: a one-line structured idea split for display only.
177
- const bat = html.indexOf("function brief(");
178
- const brief = new Function(`${html.slice(bat, html.indexOf("\n\n", bat))}\nreturn brief`)() as (t: string) => { head: string; parts: { label: string; body: string }[] };
172
+ // brief(): a one-line structured idea split for display only.
179
173
 
180
174
  test("a labelled idea splits into a head and one part per label", () => {
181
175
  const b = brief("Improve previews Problem: one dense block. Non-goals: no rewrites. Acceptance criteria: (1) scannable. (2) full text kept.");
@@ -13,7 +13,10 @@ afterAll(() => rmSync(home, { recursive: true, force: true }));
13
13
 
14
14
  // Async, so the pages this process serves can answer the browser meanwhile.
15
15
  const run = async (code: string, env: Record<string, string> = {}) => {
16
+ // The profile lands in the throwaway HOME, but Chrome itself gets the real one: under
17
+ // a scratch HOME it cannot reach the keychain (macOS) or start at all (Windows).
16
18
  const script = `import * as br from ${JSON.stringify(join(repo, "cli/browser.ts"))};
19
+ Object.assign(process.env, ${JSON.stringify({ HOME: process.env.HOME ?? "", USERPROFILE: process.env.USERPROFILE ?? "" })});
17
20
  console.log(JSON.stringify(await (async () => { ${code} })()));`;
18
21
  const p = Bun.spawn(["bun", "-e", script], { cwd: home, env: { ...process.env, HOME: home, USERPROFILE: home, ...env }, stdout: "pipe", stderr: "pipe" });
19
22
  const [out, err, code_] = [await new Response(p.stdout).text(), await new Response(p.stderr).text(), await p.exited];
package/cli/browser.ts CHANGED
@@ -169,12 +169,14 @@ export async function browse<T>(use: (page: Page) => Promise<T>): Promise<T | {
169
169
  if (!exe) return { ok: false, note: NO_BROWSER };
170
170
  const dir = profile();
171
171
  rmSync(join(dir, "DevToolsActivePort"), { force: true });
172
+ // env passed explicitly: without it Bun hands the child its startup env, not process.env as it is now.
172
173
  const proc = Bun.spawn([exe, "--headless=new", `--user-data-dir=${dir}`, "--remote-debugging-address=127.0.0.1", "--remote-debugging-port=0",
173
- "--no-first-run", "--no-default-browser-check", "--window-size=1280,2000", "about:blank"], { stdout: "ignore", stderr: "ignore" });
174
+ "--no-first-run", "--no-default-browser-check", "--window-size=1280,2000", "about:blank"], { env: process.env, stdout: "ignore", stderr: "ignore" });
174
175
  let page: Page | null = null;
175
176
  try {
176
177
  let port = "";
177
- for (let i = 0; i < 100 && !port; i++) {
178
+ // A first start on a fresh profile took over 10 s on Windows.
179
+ for (let i = 0; i < 300 && !port; i++) {
178
180
  await Bun.sleep(100);
179
181
  try { port = readFileSync(join(dir, "DevToolsActivePort"), "utf8").split("\n")[0]!.trim(); } catch { /* not yet */ }
180
182
  if (proc.exitCode !== null) break;
package/cli/build-web.ts CHANGED
@@ -1,16 +1,20 @@
1
1
  // `bun run build:web`: bundles the notebook's script and CodeMirror into
2
- // web/notebook.js, and the in-card terminal and xterm.js into web/term.js (+ its
3
- // stylesheet, web/term.css), all committed, so an install or a checkout serves it with no
2
+ // web/notebook.js, the in-card terminal and xterm.js into web/term.js (+ its
3
+ // stylesheet, web/term.css), the board's modules (web/board/) into web/board.js and
4
+ // Kaizen Bot's page modules (bot/web/app/) into bot/web/app.js, all committed, so an install or a checkout serves it with no
4
5
  // install step and no network. The first line is a hash of what it was built from;
5
6
  // cli/notes.test.ts fails when a committed file no longer matches.
6
7
  import { createHash } from "node:crypto";
7
- import { copyFileSync, readFileSync, writeFileSync } from "node:fs";
8
+ import { copyFileSync, readdirSync, readFileSync, writeFileSync } from "node:fs";
8
9
  import { join, dirname } from "node:path";
9
10
 
10
11
  const root = dirname(import.meta.dir);
11
- const SOURCES = ["web/notebook.ts", "web/notes-lib.ts", "web/term.ts"];
12
+ const modules = (dir: string) => readdirSync(join(root, dir)).filter((f) => f.endsWith(".ts")).sort().map((f) => `${dir}/${f}`);
13
+ const SOURCES = ["web/notebook.ts", "web/notes-lib.ts", "web/term.ts", ...modules("web/board"), ...modules("bot/web/app")];
12
14
  export const OUT = join(root, "web", "notebook.js");
13
15
  export const TERM_OUT = join(root, "web", "term.js");
16
+ export const BOARD_OUT = join(root, "web", "board.js");
17
+ export const BOT_OUT = join(root, "bot", "web", "app.js");
14
18
 
15
19
  // The sources and the exact dependency versions: a CodeMirror bump is a rebuild too.
16
20
  export function sourceHash() {
@@ -22,13 +26,19 @@ export function sourceHash() {
22
26
  export const banner = (what = "notebook", from = "web/notebook.ts") => `/* kaizen ${what} ${sourceHash()} -- generated by bun run build:web from ${from}; do not edit */`;
23
27
 
24
28
  if (import.meta.main) {
25
- for (const [src, out, what] of [["web/notebook.ts", OUT, "notebook"], ["web/term.ts", TERM_OUT, "terminal"]] as const) {
26
- // The terminal is a classic script on the board's page: wrapped, or its top-level
27
- // names collide with the board's own globals and the whole file is refused.
28
- const r = await Bun.build({ entrypoints: [join(root, src)], target: "browser", minify: true, format: what === "terminal" ? "iife" : "esm" });
29
+ const bundles = [
30
+ ["web/notebook.ts", OUT, "notebook"],
31
+ ["web/term.ts", TERM_OUT, "terminal"],
32
+ ["web/board/main.ts", BOARD_OUT, "board"],
33
+ ["bot/web/app/main.ts", BOT_OUT, "bot page"],
34
+ ] as const;
35
+ for (const [src, out, what] of bundles) {
36
+ // The terminal, the board and the bot page are classic scripts: wrapped, or their
37
+ // top-level names collide with each other's globals and the whole file is refused.
38
+ const r = await Bun.build({ entrypoints: [join(root, src)], target: "browser", minify: true, format: what === "notebook" ? "esm" : "iife" });
29
39
  if (!r.success) { for (const l of r.logs) console.error(l); process.exit(1); }
30
40
  writeFileSync(out, banner(what, src) + "\n" + await r.outputs[0]!.text());
31
- console.log(` ${src.replace(".ts", ".js")} ${(Bun.file(out).size / 1024).toFixed(0)} KB`);
41
+ console.log(` ${out.slice(root.length + 1)} ${(Bun.file(out).size / 1024).toFixed(0)} KB`);
32
42
  }
33
43
  copyFileSync(join(root, "node_modules", "@xterm", "xterm", "css", "xterm.css"), join(root, "web", "term.css"));
34
44
  }
package/cli/notes.test.ts CHANGED
@@ -6,7 +6,7 @@ import { join, dirname } from "node:path";
6
6
  import { tmpdir } from "node:os";
7
7
  import { safeName, listNotes, saveNote, renameNote, deleteNote, boardIndex, linksTo } from "./notes.ts";
8
8
  import { wikilinks, tags, resolve, safeUrl, tree, uncode, freshName, parseTable, boardLink, resolveRun, anchorWords } from "../web/notes-lib.ts";
9
- import { sourceHash, OUT, TERM_OUT } from "./build-web.ts";
9
+ import { sourceHash, OUT, TERM_OUT, BOARD_OUT, BOT_OUT } from "./build-web.ts";
10
10
 
11
11
  let state = "";
12
12
  beforeEach(() => { state = join(mkdtempSync(join(tmpdir(), "kaizen-notes-")), ".kaizen"); mkdirSync(state); });
@@ -229,7 +229,9 @@ test("the side menu is the board's on the notebook, line for line", () => {
229
229
  expect(syms("board.html").every(Boolean)).toBe(true);
230
230
  });
231
231
 
232
- test("web/notebook.js and web/term.js were built from the sources as they are (bun run build:web)", () => {
232
+ test("web/notebook.js, web/term.js, web/board.js and bot/web/app.js were built from the sources as they are (bun run build:web)", () => {
233
233
  expect(readFileSync(OUT, "utf8").split("\n", 1)[0]!).toContain(`kaizen notebook ${sourceHash()} `);
234
234
  expect(readFileSync(TERM_OUT, "utf8").split("\n", 1)[0]!).toContain(`kaizen terminal ${sourceHash()} `);
235
+ expect(readFileSync(BOARD_OUT, "utf8").split("\n", 1)[0]!).toContain(`kaizen board ${sourceHash()} `);
236
+ expect(readFileSync(BOT_OUT, "utf8").split("\n", 1)[0]!).toContain(`kaizen bot page ${sourceHash()} `);
235
237
  });
@@ -1,7 +1,7 @@
1
1
  // Request sources against a throwaway .kaizen/. No network: the fetch and the DNS
2
2
  // lookup are both injected. Nothing here launches an agent or a terminal.
3
3
  import { test, expect, beforeEach, afterAll } from "bun:test";
4
- import { mkdtempSync, mkdirSync, writeFileSync, readFileSync, rmSync, existsSync, chmodSync } from "node:fs";
4
+ import { mkdtempSync, mkdirSync, writeFileSync, readFileSync, rmSync, existsSync, chmodSync, realpathSync } from "node:fs";
5
5
  import { join } from "node:path";
6
6
  import { tmpdir } from "node:os";
7
7
  import { readInbox, writeInbox, replaceIdea, parseItem } from "./state.ts";
@@ -11,7 +11,7 @@ import {
11
11
  readIcebox, writeIcebox, promote, dismiss, markReplied, setCategory, socialUrl, socialOf, AGENT_ONLY, PLATFORMS,
12
12
  } from "./sources.ts";
13
13
 
14
- const root = mkdtempSync(join(tmpdir(), "kaizen-src-"));
14
+ const root = realpathSync(mkdtempSync(join(tmpdir(), "kaizen-src-"))); // macOS: tmpdir is a symlink into /private
15
15
  let state: string;
16
16
  let n = 0;
17
17
  beforeEach(() => { state = join(root, String(n++), ".kaizen"); mkdirSync(state, { recursive: true }); });
package/cli/state.ts CHANGED
@@ -1481,7 +1481,7 @@ export function readCommit(state: string, id: string): { sha: string; pushed: bo
1481
1481
  }
1482
1482
 
1483
1483
  // The card's git mark for each run asked about, by the rules of the panel's button
1484
- // (`gitBtn` in web/board.html): one project status and one read of the reports for
1484
+ // (`gitBtn` in web/board/git.ts): one project status and one read of the reports for
1485
1485
  // the lot. A run with nothing to say either way gets no entry.
1486
1486
  export function cardsGit(state: string, ids: string[]): Record<string, "pending" | "pushed"> {
1487
1487
  const s = gitStatus(dirname(state)), out: Record<string, "pending" | "pushed"> = {};
package/cli/web.ts CHANGED
@@ -16,6 +16,8 @@ import { postLink, sendReply } from "./browser.ts";
16
16
  import { listNotes, saveNote, renameNote, deleteNote, boardIndex, linksTo } from "./notes.ts";
17
17
 
18
18
  const PAGE = join(dirname(import.meta.dir), "web", "board.html");
19
+ // The board's script: web/board/*.ts, bundled into one file by `bun run build:web`.
20
+ const BOARD_JS = join(dirname(import.meta.dir), "web", "board.js");
19
21
  // The notebook: its page, and the CodeMirror bundle `bun run build:web` commits beside it.
20
22
  const NOTES_PAGE = join(dirname(import.meta.dir), "web", "notes.html");
21
23
  const ICO = join(dirname(import.meta.dir), "assets", "kaizen.ico");
@@ -38,7 +40,7 @@ const VERSION = installed();
38
40
  // page newer than its server sends ops the server does not know ("unknown op").
39
41
  // So the pages are kept as they were at start, and read live (dev edits) only while
40
42
  // the installed version is still the one this board runs.
41
- const AT_START = new Map([PAGE, NOTES_PAGE, NOTES_JS, TERM_JS, TERM_CSS].map((f) => [f, existsSync(f) ? readText(f) : null]));
43
+ const AT_START = new Map([PAGE, BOARD_JS, NOTES_PAGE, NOTES_JS, TERM_JS, TERM_CSS].map((f) => [f, existsSync(f) ? readText(f) : null]));
42
44
  const served = (f: string) => installed() === VERSION && existsSync(f) ? readText(f) : AT_START.get(f) ?? null;
43
45
 
44
46
  // A board answers at url and runs the installed version. A board from before the
@@ -49,10 +51,11 @@ export async function current(url: string) {
49
51
  }
50
52
 
51
53
  // The newest kaizen on npm, asked at most every six hours; a failed ask is retried
52
- // on the next one. Same registry call as npmLatest() in install.ts, which exports nothing.
54
+ // on the next one; fresh (Settings' Check for updates) skips the cache. Same registry
55
+ // call as npmLatest() in install.ts, which exports nothing.
53
56
  let latest: { v: string | null; at: number } = { v: null, at: 0 };
54
- async function npmLatest() {
55
- if (latest.v && Date.now() - latest.at < 6 * 3600_000) return latest.v;
57
+ async function npmLatest(fresh = false) {
58
+ if (!fresh && latest.v && Date.now() - latest.at < 6 * 3600_000) return latest.v;
56
59
  try {
57
60
  const r = await fetch("https://registry.npmjs.org/kaizen-agent/latest", { signal: AbortSignal.timeout(10000) });
58
61
  const v = ((await r.json()) as { version?: string }).version ?? null;
@@ -166,9 +169,9 @@ export async function web(repoDir: string, opts: Opts = {}) {
166
169
  : ideaPty.get(`${c.state}\0${c.text}`);
167
170
  return key && live.has(key) ? { key, alive: ptyState(key)!.alive } : undefined;
168
171
  };
169
- // Dev: the page reloads itself when board.html is saved. Server code needs
172
+ // Dev: the page reloads itself when board.html or its bundle is saved. Server code needs
170
173
  // `bun --watch cli/install.ts web`; the page reconnects and reloads on hello.
171
- try { watch(PAGE, () => { for (const c of clients) { try { c.enqueue("data: reload\n\n"); } catch { clients.delete(c); } } }); } catch { /* no dev reload */ }
174
+ for (const f of [PAGE, BOARD_JS]) try { watch(f, () => { for (const c of clients) { try { c.enqueue("data: reload\n\n"); } catch { clients.delete(c); } } }); } catch { /* no dev reload */ }
172
175
  const watchers: FSWatcher[] = [];
173
176
  const watchAll = () => {
174
177
  for (const w of watchers) { try { w.close(); } catch { /* gone */ } }
@@ -292,6 +295,12 @@ export async function web(repoDir: string, opts: Opts = {}) {
292
295
  });
293
296
  }
294
297
 
298
+ if (req.method === "GET" && path === "/board.js") {
299
+ const body = served(BOARD_JS);
300
+ if (body === null) return new Response(`${path} missing; run bun run build:web`, { status: 500 });
301
+ return new Response(body, { headers: { "content-type": "application/javascript; charset=utf-8", "cache-control": "no-store" } });
302
+ }
303
+
295
304
  if (req.method === "GET" && (path === "/notebook" || path === "/notebook.js")) {
296
305
  const [f, type] = path === "/notebook" ? [NOTES_PAGE, "text/html; charset=utf-8"] : [NOTES_JS, "application/javascript; charset=utf-8"];
297
306
  const body = served(f);
@@ -461,7 +470,7 @@ export async function web(repoDir: string, opts: Opts = {}) {
461
470
 
462
471
  // What this board runs and what npm has: the page shows an Update button on a newer one.
463
472
  if (req.method === "GET" && path === "/version") {
464
- const v = await npmLatest();
473
+ const v = await npmLatest(url.searchParams.has("fresh"));
465
474
  return json({ version: VERSION, latest: v, update: !!v && newer(v, VERSION) });
466
475
  }
467
476
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "kaizen-agent",
3
- "version": "1.31.3",
3
+ "version": "1.33.0",
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": {
@@ -22,6 +22,8 @@
22
22
  "install-links": "bun run cli/install.ts",
23
23
  "check": "bun run cli/install.ts --check",
24
24
  "build:web": "bun run cli/build-web.ts",
25
+ "format": "prettier --write web/*.ts web/board bot/web/app",
26
+ "format:check": "prettier --check web/*.ts web/board bot/web/app",
25
27
  "test": "bun test",
26
28
  "prepublishOnly": "bun test",
27
29
  "postpublish": "sh cli/release.sh",
@@ -53,6 +55,7 @@
53
55
  "@codemirror/view": "6.43.13",
54
56
  "@lezer/highlight": "1.2.5",
55
57
  "@xterm/addon-fit": "0.11.0",
56
- "@xterm/xterm": "6.0.0"
58
+ "@xterm/xterm": "6.0.0",
59
+ "prettier": "3.9.10"
57
60
  }
58
61
  }
@@ -68,6 +68,11 @@ Common shapes, as illustration only — never as a menu to force a request into:
68
68
  A run may be a mix. Record it as a mix — the honest description beats the nearest
69
69
  label.
70
70
 
71
+ Evidence about software whose source is not at hand — a shipped binary, an app
72
+ bundle, an APK, a package — comes from REA (`rea-agents`) when its tools are
73
+ connected in the session: cite what it returns, limitations included. When they are
74
+ not connected, say the claim is unverified rather than guess at internals.
75
+
71
76
  Record the track in `state.json` as `track`, with the three answers written into
72
77
  `00-request.md`. Every subsequent stage reads them before starting, and the words
73
78
  *build*, *verify*, and *finding* below are always to be read in the track's terms.
@@ -0,0 +1,51 @@
1
+ import { load } from "./api.ts";
2
+ import { $ } from "./dom.ts";
3
+
4
+ // again: something changed on the server, so the doc is read afresh: a note sent, a plan
5
+ // written or a commit made elsewhere shows in nothing the page already holds. One /run
6
+ // fetch per change while a run panel is open; a tab switch reuses the doc.
7
+ // ---- the in-card agent (agent.launch: card). xterm.js is loaded the first time a
8
+ // terminal is shown. One terminal is mounted at a time, in #pbody; a refresh that
9
+ // finds it already showing this session leaves it alone, so it never reconnects.
10
+ let termKey = null,
11
+ termOff = null,
12
+ termLib = null;
13
+ export const where = (r) => (r.pty ? "running in its card" : "opened in your terminal");
14
+ const termLoad = () =>
15
+ (termLib ||= new Promise((ok, no) => {
16
+ const l = document.createElement("link");
17
+ l.rel = "stylesheet";
18
+ l.href = "/term.css";
19
+ document.head.append(l);
20
+ const sc = document.createElement("script");
21
+ sc.src = "/term.js";
22
+ sc.onload = () => ok(window.kzTerm);
23
+ sc.onerror = () => {
24
+ termLib = null;
25
+ no();
26
+ };
27
+ document.head.append(sc);
28
+ }));
29
+ export function termStop() {
30
+ if (termOff) termOff();
31
+ termOff = null;
32
+ termKey = null;
33
+ $("panel").classList.remove("wide");
34
+ }
35
+ export async function showTerm(c) {
36
+ const k = c.pty.key;
37
+ if (termKey === k && $("pbody").querySelector(".term")) return;
38
+ termStop();
39
+ termKey = k;
40
+ $("panel").classList.add("wide");
41
+ $("pbody").innerHTML = `<div class="term" aria-label="Agent terminal"></div>`;
42
+ let mount;
43
+ try {
44
+ mount = await termLoad();
45
+ } catch {
46
+ $("pbody").innerHTML = `<p style="color:var(--dim)">The terminal could not load.</p>`;
47
+ return;
48
+ }
49
+ if (termKey !== k) return;
50
+ termOff = mount($("pbody").querySelector(".term"), k, () => load(true));
51
+ }
@@ -0,0 +1,102 @@
1
+ import { store } from "./store.ts";
2
+ import { key, render } from "./cards.ts";
3
+ import { $ } from "./dom.ts";
4
+ import { close, ideaBody, open, openRun } from "./panel.ts";
5
+ import { notifyUser, toast } from "./toast.ts";
6
+
7
+ // The server writes notifier() from state.ts over a marker in board.html: one rule for both UIs.
8
+ declare const due: (key: string, say: string | null, done: boolean, now: number) => boolean;
9
+
10
+ // quiet: the server said something changed, nobody asked for a redraw.
11
+ export async function load(quiet) {
12
+ let r;
13
+ try {
14
+ r = await fetch("/state" + (store.all ? "?all=1" : ""));
15
+ } catch {
16
+ toast(GONE);
17
+ return;
18
+ }
19
+ const was = store.openKey && store.S.cards.find((x) => key(x) === store.openKey);
20
+ store.S = await r.json();
21
+ {
22
+ const seen = new Map();
23
+ for (const c of store.S.cards) {
24
+ const b = key(c),
25
+ n = seen.get(b) || 0;
26
+ seen.set(b, n + 1);
27
+ c._k = n ? b + "#" + n : b;
28
+ }
29
+ }
30
+ for (const c of store.S.cards) {
31
+ if (c.kind !== "run" || c.archived) continue;
32
+ if (due(key(c), c.notice, c.status === "done", store.S.now)) notifyUser(c.notice);
33
+ }
34
+ // A redraw under a form being typed in would reset its fields and its focus.
35
+ if (!(quiet && store.mode && $("board").querySelector(".inl input"))) render();
36
+ // Run on an idea closed the panel: open the card on its agent once the board knows the session.
37
+ if (store.wantTerm && !store.openKey && !store.mode) {
38
+ const n = store.S.cards.find((x) => key(x) === store.wantTerm && x.pty);
39
+ if (n) {
40
+ store.wantTerm = null;
41
+ open(n);
42
+ return;
43
+ }
44
+ }
45
+ if (store.openKey) {
46
+ const c = store.S.cards.find((x) => key(x) === store.openKey),
47
+ ta = $("pbody").querySelector(".notes textarea");
48
+ if (ta && document.activeElement === ta) return;
49
+ if (!c) {
50
+ if (was && was.kind === "idea") follow(was);
51
+ return;
52
+ }
53
+ if (store.runDoc) openRun(c, true, true);
54
+ else if (c.kind === "idea") ideaBody(c);
55
+ }
56
+ }
57
+ // An idea's card goes when its run starts. Follow it to that run rather than keep
58
+ // offering "Run again": the newest run in its project whose request holds the idea's
59
+ // text, the rule boardCards() retires it by. No such run: close the panel.
60
+ // ponytail: checks the five newest runs, one /run fetch each; a run that took the idea
61
+ // further back than that closes the panel instead.
62
+ async function follow(idea) {
63
+ const k = key(idea),
64
+ norm = (s) => s.toLowerCase().replace(/\s+/g, " ").trim(),
65
+ t = norm(idea.text);
66
+ for (const r of store.S.cards
67
+ .filter((x) => x.kind === "run" && x.state === idea.state)
68
+ .sort((a, b) => (b.moved ?? 0) - (a.moved ?? 0))
69
+ .slice(0, 5)) {
70
+ const d = await fetch(`/run/${encodeURIComponent(r.id)}?dir=${encodeURIComponent(r.state)}`)
71
+ .then((x) => x.json())
72
+ .catch(() => null);
73
+ if (store.openKey !== k) return;
74
+ if (norm(d?.request || "").includes(t)) {
75
+ open(r);
76
+ return;
77
+ }
78
+ }
79
+ if (store.openKey === k) close();
80
+ }
81
+ // A tab outliving its server would otherwise swallow every click: say so.
82
+ const GONE = "The board server is not running — start it with kaizen web (or the shortcut), then reload.";
83
+ export async function post(path, body) {
84
+ let r;
85
+ try {
86
+ r = await fetch(path, {
87
+ method: "POST",
88
+ headers: { "content-type": "application/json" },
89
+ body: JSON.stringify(body),
90
+ });
91
+ } catch {
92
+ toast(GONE);
93
+ return { ok: false };
94
+ }
95
+ const j = await r.json().catch(() => ({}));
96
+ if (!r.ok) {
97
+ const m = j.error || j.why || "That did not work";
98
+ toast(m, { title: m.slice(0, 80), what: `POST ${path} failed (HTTP ${r.status}): ${m}` });
99
+ if (j.manual) console.log("run manually:", j.manual);
100
+ }
101
+ return j;
102
+ }
@@ -0,0 +1,100 @@
1
+ import { store } from "./store.ts";
2
+ import { where } from "./agent-term.ts";
3
+ import { post } from "./api.ts";
4
+ import { key, via } from "./cards.ts";
5
+ import { $, esc } from "./dom.ts";
6
+ import { openRun } from "./panel.ts";
7
+ import { askNotify, toast } from "./toast.ts";
8
+
9
+ // Resume: a run whose in-card agent ended, or one stalled with no progress, gets its agent
10
+ // back on the run (its conversation, where the tool can resume one), in its card.
11
+ export const resumable = (c) => c.status === "stalled" || !!(c.pty && !c.pty.alive);
12
+ export async function resume(c, b) {
13
+ b.disabled = true;
14
+ askNotify();
15
+ const r = await post("/resume", { dir: c.state, id: c.id });
16
+ if (!r.ok) {
17
+ b.disabled = false;
18
+ return;
19
+ }
20
+ toast("Resumed, " + where(r) + ": " + via(r.agent));
21
+ if (r.pty) store.wantTerm = key(c);
22
+ if (store.openKey === key(c)) {
23
+ $("pfoot").innerHTML = "";
24
+ openRun(c, true);
25
+ }
26
+ }
27
+ export const APPROVAL = {
28
+ "approvals.plan": "The plan is ready. Read it in the Plan tab, then approve or send it back.",
29
+ "approvals.review": "The work is ready to check. Approve to finish it, or revise with what to change.",
30
+ findings: "The review has open findings. Approve as-is to accept them and finish, or fix selected findings first.",
31
+ },
32
+ decided = new Set(),
33
+ answer = (c) => key(c) + ":" + c.awaiting + ":" + c.moved;
34
+ export async function decide(c, b, why, answers) {
35
+ b.disabled = true;
36
+ askNotify();
37
+ const r = await post("/decide", {
38
+ dir: c.state,
39
+ id: c.id,
40
+ awaiting: c.awaiting,
41
+ ...(why === undefined ? {} : { why }),
42
+ ...(answers ? { answers } : {}),
43
+ });
44
+ // A failed send keeps the footer, and with it a typed comment.
45
+ if (!r.ok) {
46
+ b.disabled = false;
47
+ return;
48
+ }
49
+ decided.add(answer(c));
50
+ toast((why === undefined ? "Approved" : "Sent back") + ", " + where(r) + ": " + via(r.agent));
51
+ if (r.pty) store.wantTerm = key(c);
52
+ if (store.openKey === key(c)) {
53
+ $("pfoot").innerHTML = "";
54
+ openRun(c, true);
55
+ }
56
+ }
57
+ export function confirmRevise(c) {
58
+ const foot = $("pfoot"),
59
+ plan = c.awaiting === "approvals.plan";
60
+ foot.innerHTML = `<div class="inl" style="flex:1"><div class="q">${plan ? "What should the plan do differently?" : "What should change in the work?"}</div><div class="q dim">Opens your agent on this run with your comment.${plan ? " It revises the plan and comes back here for approval." : " It changes the work, rechecks it and comes back here."}</div><textarea aria-label="Revise comment"></textarea><div class="row"><button class="btn ghost" data-a="c">Cancel</button><button class="btn pri" data-a="s">Send back</button></div></div>`;
61
+ const inp = foot.querySelector("textarea");
62
+ inp.focus();
63
+ foot.querySelector("[data-a=c]").onclick = () => {
64
+ foot.innerHTML = "";
65
+ openRun(c, true);
66
+ };
67
+ foot.querySelector("[data-a=s]").onclick = (e) => {
68
+ const why = inp.value.trim();
69
+ if (!why) {
70
+ toast("Say what should change");
71
+ inp.focus();
72
+ return;
73
+ }
74
+ decide(c, e.target, why);
75
+ };
76
+ }
77
+ // The plan's questions, answered with the approval: the recommended option preselected,
78
+ // a note for what the options miss. Each answer reaches the agent as one line.
79
+ export function confirmAnswers(c, qs) {
80
+ const foot = $("pfoot");
81
+ foot.innerHTML = `<div class="inl qa" style="flex:1"><div class="q">The plan asks ${qs.length === 1 ? "one question" : qs.length + " questions"}.</div><div class="q dim">Your answers go to your agent with the approval. It records them and builds the plan.</div>${qs.map((x, i) => `<fieldset><legend>${esc(x.q)}</legend>${x.options.map((o, j) => `<label><input type="${x.multi ? "checkbox" : "radio"}" name="q${i}" value="${j}"${o.rec || (!x.multi && !j && !x.options.some((p) => p.rec)) ? " checked" : ""}> ${esc(o.text)}</label>`).join("")}<input type="text" data-n="${i}" placeholder="Note (optional)" aria-label="Note: ${esc(x.q)}"></fieldset>`).join("")}<div class="row"><button class="btn ghost" data-a="c">Cancel</button><button class="btn pri" data-a="s">Approve with these answers</button></div></div>`;
82
+ foot.querySelector("[data-a=c]").onclick = () => {
83
+ foot.innerHTML = "";
84
+ openRun(c, true);
85
+ };
86
+ foot.querySelector("[data-a=s]").onclick = (e) =>
87
+ decide(
88
+ c,
89
+ e.target,
90
+ undefined,
91
+ qs.map((x, i) => {
92
+ const picked =
93
+ [...foot.querySelectorAll(`[name=q${i}]:checked`)]
94
+ .map((n) => `"${x.options[+n.value].label}"`)
95
+ .join(", ") || "none of the options",
96
+ note = foot.querySelector(`[data-n="${i}"]`).value.trim();
97
+ return `${x.q} — ${picked}${note ? "; " + note : ""}`;
98
+ }),
99
+ );
100
+ }