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 +52 -0
- 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 +2 -2
- package/cli/state.ts +1 -1
- package/cli/web.ts +16 -7
- 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 +6 -978
- 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
|
@@ -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,
|
|
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 }); });
|
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
|
|
@@ -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
|
|
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(
|
|
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.
|
|
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
|
}
|
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
|
+
}
|
|
@@ -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
|
+
}
|