kaizen-agent 1.32.0 → 1.33.1

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
@@ -2,11 +2,41 @@
2
2
  <img src="https://raw.githubusercontent.com/hfadhlullah/kaizen/main/assets/banner.png" alt="kaizen" width="760">
3
3
  </div>
4
4
 
5
- <p align="center"><b>See the plan before your AI starts. Get a second opinion when it is done.</b></p>
5
+ <p align="center"><b>One tool to build and maintain your product with AI agents.</b><br>Requests in, a plan you approve, the work, an independent review, done.</p>
6
6
 
7
- **The problem:** an AI agent starts writing before you agree on what it should do, then grades its own work.
7
+ <p align="center"><img src="assets/hero.gif" alt="Animated walkthrough of the real kaizen board with sample data. 1: a request gathered from a support sheet waits in the Icebox and is promoted. 2: it is an idea on the board, and Run opens Claude Code. 3: the agent works in the card's own terminal and writes a plan. 4: the plan panel shows what done and wrong mean, and it is approved. 5: it builds, then a reviewer that never saw the work reports a high finding, a double charge. 6: only that finding is ticked and fixed. 7: the reviewer re-checks it. 8: the run lands in Done, with the low finding left in the backlog." width="760"></p>
8
8
 
9
- **Kaizen:** it plans first and waits for your approval, then an agent that never saw the work reviews it.
9
+ ## The problem
10
+
11
+ You ask an AI coding agent to retry a payment webhook when the bank times out. It starts editing at once, then says *"Done, tests pass."* The tests do pass. They were written by the same agent, from the same assumption: that a timeout means the charge failed. Sometimes the bank said yes and the reply got lost. Now a retry charges the customer twice, and nothing in the agent's report would make you look.
12
+
13
+ None of this is a lack of intelligence. It is a missing workflow:
14
+
15
+ | What goes wrong | What kaizen does |
16
+ | :--- | :--- |
17
+ | The agent decides what "done" means on its own, and you find out from the diff | It writes a plan first, including what *wrong* would look like, and waits for your approve or change |
18
+ | The author grades its own work, so its blind spot is in the code *and* in the check | A second agent that never saw the work reviews it, from the request, the plan and the diff only |
19
+ | Findings get fixed in an endless loop, or silently dropped | Serious findings are fixed and re-checked, at most twice; the rest come to you, and what is left goes to a backlog |
20
+ | Requests live in sheets, docs and social posts; the plan in one chat, the review in another, the commit somewhere else | One board, from an icebox that gathers those requests to an idea, a plan, the build, the review, the fix loop and the commit, every step on file |
21
+
22
+ Many assistants now ask for approval before they act, and some run a checker on their own draft. The difference here is *who* checks: a reviewer that shares the author's context shares its blind spots. Kaizen's reviewer starts cold, on purpose.
23
+
24
+ Every stage is a file in `.kaizen/runs/<id>/`, so a run can be resumed later, by another session or another tool (Claude Code, Codex, Antigravity), and read back when someone asks why the code is the way it is.
25
+
26
+ ## Who it is for
27
+
28
+ **Good fit if you:**
29
+
30
+ - Build and maintain a product, and want one place for it: requests gathered from where they live, then plan, build, review and commit, without switching tools.
31
+ - Already use an AI coding agent, and have caught it confidently wrong more than once.
32
+ - Change things where a mistake is expensive: money, data, migrations, anything a customer sees.
33
+ - Want to see and steer the plan before code is written, not reverse-engineer it from a diff.
34
+
35
+ **Not for you if:**
36
+
37
+ - Your changes are typos and one-liners. A run costs several times one prompt; kaizen takes a short path for small work, but it is not free.
38
+ - You want the agent to ship without you. Kaizen is built to stop and ask.
39
+ - You do not use an AI coding agent. Kaizen runs inside one; it is not one.
10
40
 
11
41
  ## Install
12
42
 
@@ -42,6 +72,58 @@ The board opens in your browser. The side menu holds the board, notes, the icebo
42
72
 
43
73
  To add a launcher (Spotlight on macOS, the app menu on Linux, a Desktop shortcut on Windows), run `kaizen web --shortcut` once.
44
74
 
75
+ ## Development
76
+
77
+ 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.
78
+
79
+ ```bash
80
+ git clone https://github.com/hfadhlullah/kaizen.git && cd kaizen
81
+ bun install # dev dependencies only
82
+ bun test # the suite; what CI runs
83
+ ```
84
+
85
+ To work on kaizen, install from your checkout so every link points at it:
86
+
87
+ ```bash
88
+ bun run cli/install.ts
89
+ ```
90
+
91
+ 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.
92
+
93
+ After editing `web/notebook.ts` or `web/notes-lib.ts`, rebuild the committed bundle:
94
+
95
+ ```bash
96
+ bun run build:web # rewrites web/notebook.js; a test fails if it goes stale
97
+ ```
98
+
99
+ ### Layout
100
+
101
+ | Path | What it is |
102
+ |---|---|
103
+ | `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. |
104
+ | `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/`. |
105
+ | `agents/` | The Claude Code subagents: `kaizen-planner`, `kaizen-builder`, `kaizen-reviewer`. |
106
+ | `commands/` | The `/kaizen-*` slash commands, one file each. |
107
+ | `web/` | The board and notebook front end: `board.html`, `notes.html`, `notebook.ts`. |
108
+ | `bot/` | Kaizen Bot — a separate private package with its own tests and build. See [its README](bot/README.md). |
109
+ | `docs/` | The guide and internal notes, indexed in [`docs/README.md`](docs/README.md). |
110
+ | `design.md` | Design notes for the repo. |
111
+ | `.kaizen/` | This repo's own runs — kaizen is built with kaizen. Gitignored: a fresh clone gets it on its first run. |
112
+
113
+ ### Kaizen Bot
114
+
115
+ ```bash
116
+ cd bot
117
+ cp .env.example .env # PROVIDER, MODEL, that provider's key
118
+ bun start # http://127.0.0.1:7430 — run `kaizen web` first
119
+ bun test
120
+ bun run build # the desktop app, into dist/
121
+ ```
122
+
123
+ ### Contributing
124
+
125
+ 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.
126
+
45
127
  ## License
46
128
 
47
129
  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 }); });
@@ -101,6 +101,9 @@ test("a sheet row whose status is Done or Finish is skipped, not marked seen, an
101
101
  expect(split("sheet", "no\tdetail\tnotes\n1\tDark mode\tDone\n2\tExport\tfinish\n").map((i) => i.text)).toEqual(["Dark mode", "Export"]);
102
102
  // The whole cell names the column, not a word inside it.
103
103
  expect(split("sheet", "detail\tstatus notes\nDark mode\tDone\n")).toEqual([{ text: "Dark mode", notes: "Done" }]);
104
+ // A named column of page names loses to the column holding the requests.
105
+ expect(split("sheet", "NO\tPAGE\tDETAIL\tSTATUS\tNOTES\n1\tDash\tList\tDone\tAdd a chart to the list\n2\tPlan\tTimeline\t\tDropdown closes before an item can be picked\n3\tPlan\t\t\tRename the statuses to proposal and tender\n").map((i) => i.text))
106
+ .toEqual(["Dropdown closes before an item can be picked", "Rename the statuses to proposal and tender"]);
104
107
 
105
108
  addSource(state, SHEET);
106
109
  expect(await gatherSource(state, SHEET, text(sheet17(statuses)), pub)).toMatchObject({ ok: true, added: 5, note: "5 new, 4 done skipped" });
package/cli/sources.ts CHANGED
@@ -385,14 +385,19 @@ export function sift(kind: Kind, body: string): { items: Item[]; done: number }
385
385
  const head = filled.slice(0, 5).find((row) => !/^\d{1,3}[.)]?$/.test(row.find(Boolean) ?? "") && (named(row, HEADER) >= 0 || named(row, STATUS) >= 0));
386
386
  const data = head ? filled.slice(filled.indexOf(head) + 1) : filled;
387
387
  const status = head ? named(head, STATUS) : -1;
388
- const known = head ? named(head, HEADER) : -1;
389
- // No request column named: one column for the whole sheet, the one holding the most
390
- // text that reads like a request. Never a cell per row, which takes `Open` or a name.
391
- let col = known;
392
- if (col < 0) for (let i = 0, most = 0; i < Math.max(0, ...data.map((row) => row.length)); i++) {
393
- const n = i === status ? 0 : data.reduce((sum, row) => sum + (wordy(row[i] ?? "") ? row[i]!.length : 0), 0);
394
- if (n > most) { most = n; col = i; }
395
- }
388
+ // One column for the whole sheet, the one holding the most text that reads like a
389
+ // request. Never a cell per row, which takes `Open` or a name.
390
+ // Finished rows do not vote: their replies outweigh the requests still open.
391
+ const open = data.filter((row) => status < 0 || !DONE.test(row[status] ?? ""));
392
+ const text = (i: number) => i === status ? 0 : open.reduce((sum, row) => sum + (wordy(row[i] ?? "") ? row[i]!.length : 0), 0);
393
+ let wordiest = -1;
394
+ for (let i = 0, most = 0; i < Math.max(0, ...data.map((row) => row.length)); i++) if (text(i) > most) { most = text(i); wordiest = i; }
395
+ // A named column loses to one holding far more text: `DETAIL` naming the page while
396
+ // `NOTES` holds the request.
397
+ let known = head ? named(head, HEADER) : -1;
398
+ // ponytail: a named column with no wordy text at all keeps it (one-cell title rows, pinned by test).
399
+ if (known >= 0 && wordiest >= 0 && text(known) > 0 && text(wordiest) > 3 * text(known)) known = -1;
400
+ const col = known >= 0 ? known : wordiest;
396
401
  let done = 0;
397
402
  const items = data.flatMap((row) => {
398
403
  const text = row[col] ?? "";
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
@@ -167,9 +169,9 @@ export async function web(repoDir: string, opts: Opts = {}) {
167
169
  : ideaPty.get(`${c.state}\0${c.text}`);
168
170
  return key && live.has(key) ? { key, alive: ptyState(key)!.alive } : undefined;
169
171
  };
170
- // 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
171
173
  // `bun --watch cli/install.ts web`; the page reconnects and reloads on hello.
172
- 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 */ }
173
175
  const watchers: FSWatcher[] = [];
174
176
  const watchAll = () => {
175
177
  for (const w of watchers) { try { w.close(); } catch { /* gone */ } }
@@ -293,6 +295,12 @@ export async function web(repoDir: string, opts: Opts = {}) {
293
295
  });
294
296
  }
295
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
+
296
304
  if (req.method === "GET" && (path === "/notebook" || path === "/notebook.js")) {
297
305
  const [f, type] = path === "/notebook" ? [NOTES_PAGE, "text/html; charset=utf-8"] : [NOTES_JS, "application/javascript; charset=utf-8"];
298
306
  const body = served(f);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "kaizen-agent",
3
- "version": "1.32.0",
3
+ "version": "1.33.1",
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
+ }