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 +85 -3
- package/cli/board.test.ts +15 -21
- package/cli/browser.test.ts +3 -0
- package/cli/browser.ts +4 -2
- package/cli/build-web.ts +19 -9
- package/cli/notes.test.ts +4 -2
- package/cli/sources.test.ts +5 -2
- package/cli/sources.ts +13 -8
- package/cli/state.ts +1 -1
- package/cli/web.ts +11 -3
- package/package.json +5 -2
- package/skills/kaizen/spec.md +5 -0
- package/web/board/agent-term.ts +51 -0
- package/web/board/api.ts +102 -0
- package/web/board/approvals.ts +100 -0
- package/web/board/cards.ts +600 -0
- package/web/board/chat.ts +108 -0
- package/web/board/dictation.ts +300 -0
- package/web/board/dom.ts +5 -0
- package/web/board/drafts.ts +21 -0
- package/web/board/fixes.ts +90 -0
- package/web/board/git.ts +76 -0
- package/web/board/icebox.ts +869 -0
- package/web/board/keys.ts +126 -0
- package/web/board/labels.ts +40 -0
- package/web/board/live.ts +69 -0
- package/web/board/main.ts +19 -0
- package/web/board/markdown.ts +197 -0
- package/web/board/panel.ts +331 -0
- package/web/board/projects.ts +220 -0
- package/web/board/settings.ts +191 -0
- package/web/board/store.ts +33 -0
- package/web/board/toast.ts +32 -0
- package/web/board/update.ts +75 -0
- package/web/board/views.ts +65 -0
- package/web/board.html +5 -985
- package/web/board.js +30 -0
- package/web/notebook.js +1 -1
- package/web/notebook.ts +726 -180
- package/web/notes-lib.ts +51 -15
- package/web/notes.html +1 -1
- package/web/term.js +1 -1
- package/web/term.ts +17 -5
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>
|
|
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
|
-
|
|
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
|
-
|
|
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,
|
|
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
|
|
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()
|
|
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
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
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
|
|
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
|
|
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
|
|
112
|
-
const
|
|
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()
|
|
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.");
|
package/cli/browser.test.ts
CHANGED
|
@@ -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
|
-
|
|
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,
|
|
3
|
-
// stylesheet, web/term.css),
|
|
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
|
|
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
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
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(` ${
|
|
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/
|
|
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
|
});
|
package/cli/sources.test.ts
CHANGED
|
@@ -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
|
-
|
|
389
|
-
//
|
|
390
|
-
//
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
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.
|
|
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(
|
|
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.
|
|
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
|
}
|
package/skills/kaizen/spec.md
CHANGED
|
@@ -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
|
+
}
|
package/web/board/api.ts
ADDED
|
@@ -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
|
+
}
|