kaizen-agent 1.5.3 → 1.5.5

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/cli/web.ts CHANGED
@@ -4,7 +4,8 @@ import { existsSync, readFileSync, readdirSync, statSync, watch, type FSWatcher
4
4
  import { join, dirname } from "node:path";
5
5
  import {
6
6
  home, type Item, boardCards, knownProjects, remember, locate, label, tilde,
7
- readInbox, writeInbox, replaceIdea, abandonRun, launchRun, parseItem, short,
7
+ readInbox, writeInbox, replaceIdea, abandonRun, launchRun, parseItem, short, setArchived, writeNotes,
8
+ appendNote, saveAttachment,
8
9
  } from "./state.ts";
9
10
 
10
11
  const PAGE = join(dirname(import.meta.dir), "web", "board.html");
@@ -47,6 +48,9 @@ export async function web(repoDir: string, opts: Opts = {}) {
47
48
  for (const c of clients) { try { c.enqueue("data: changed\n\n"); } catch { clients.delete(c); } }
48
49
  }, 200);
49
50
  };
51
+ // Dev: the page reloads itself when board.html is saved. Server code needs
52
+ // `bun --watch cli/install.ts web`; the page reconnects and reloads on hello.
53
+ try { watch(PAGE, () => { for (const c of clients) { try { c.enqueue("data: reload\n\n"); } catch { clients.delete(c); } } }); } catch { /* no dev reload */ }
50
54
  const watchers: FSWatcher[] = [];
51
55
  const watchAll = () => {
52
56
  for (const w of watchers) { try { w.close(); } catch { /* gone */ } }
@@ -61,7 +65,7 @@ export async function web(repoDir: string, opts: Opts = {}) {
61
65
  const fingerprint = () => {
62
66
  let sum = 0;
63
67
  for (const dir of states(true)) {
64
- for (const f of ["inbox.md", "backlog.md"]) { try { sum += statSync(join(dir, f)).mtimeMs; } catch { /* absent */ } }
68
+ for (const f of ["inbox.md", "backlog.md", "archive.md"]) { try { sum += statSync(join(dir, f)).mtimeMs; } catch { /* absent */ } }
65
69
  const runs = join(dir, "runs");
66
70
  if (!existsSync(runs)) continue;
67
71
  for (const id of readdirSync(runs)) { try { sum += statSync(join(runs, id, "state.json")).mtimeMs; } catch { /* absent */ } }
@@ -69,11 +73,26 @@ export async function web(repoDir: string, opts: Opts = {}) {
69
73
  return sum;
70
74
  };
71
75
  let last = fingerprint();
72
- const poll = setInterval(() => { const now = fingerprint(); if (now !== last) { last = now; changed(); } }, 2000);
76
+ let seen = states(true).join("\n");
77
+ const poll = setInterval(() => {
78
+ // A project registered since start (a run launched into a fresh dir) gets a watcher too.
79
+ const now = states(true).join("\n"); if (now !== seen) { seen = now; watchAll(); }
80
+ const fp = fingerprint(); if (fp !== last) { last = fp; changed(); }
81
+ }, 2000);
73
82
 
74
- const server = Bun.serve({
83
+ const BOOT = Date.now().toString(36);
84
+ const ping = setInterval(() => { for (const c of clients) { try { c.enqueue(": ping\n\n"); } catch { clients.delete(c); } } }, 8000);
85
+ // A launcher clicked while a board is already up should show that board, not die.
86
+ const port = opts.port ?? DEFAULT_PORT;
87
+ const server = (() => { try { return serve(); } catch (e) {
88
+ if ((e as { code?: string }).code !== "EADDRINUSE") throw e;
89
+ return null;
90
+ } })();
91
+ if (!server) { const url = `http://127.0.0.1:${port}/`; console.log(`\n kaizen web already at ${url}\n`); if (opts.open !== false) await openApp(url); return; }
92
+
93
+ function serve() { return Bun.serve({
75
94
  hostname: "127.0.0.1",
76
- port: opts.port ?? DEFAULT_PORT,
95
+ port,
77
96
  async fetch(req) {
78
97
  // Host first: a hostname rebound to 127.0.0.1 by an attacker's DNS still
79
98
  // arrives with that hostname in Host, and gets nothing.
@@ -111,13 +130,43 @@ export async function web(repoDir: string, opts: Opts = {}) {
111
130
  id, short: short(id), state: read("state.json"),
112
131
  request: read("00-request.md"), plan: read("01-plan.md"), approval: read("02-approval.md"),
113
132
  impl: read("03-impl.md"), review: read("04-review.md"), backlog: read("06-backlog.md"),
133
+ notes: read("notes.md"),
114
134
  });
115
135
  }
116
136
 
137
+ if (req.method === "GET" && path === "/settings") {
138
+ const { listSettings } = await import("./settings.ts");
139
+ return json(listSettings(repoDir));
140
+ }
141
+
142
+ if (req.method === "POST" && path === "/settings") {
143
+ if (!local(req)) return bad("cross-origin write refused", 403);
144
+ let body: any;
145
+ try { body = await req.json(); } catch { return bad("json body expected"); }
146
+ const { setSetting } = await import("./settings.ts");
147
+ const r = setSetting(repoDir, String(body.key ?? ""), String(body.value ?? ""));
148
+ return "error" in r ? bad(r.error) : json(r);
149
+ }
150
+
151
+ // Attachments only, never a run's own files: the path must sit under an
152
+ // attachments/ directory of a known state dir, with no way back up.
153
+ if (req.method === "GET" && path === "/file") {
154
+ const dir = url.searchParams.get("dir") ?? state;
155
+ const p = url.searchParams.get("p") ?? "";
156
+ if (!dir || !states(true).includes(dir)) return bad("unknown state dir", 404);
157
+ if (!/^(runs\/[\w.-]+\/)?attachments\/[\w.-]+$/.test(p)) return bad("bad path");
158
+ const f = join(dir, p);
159
+ if (!existsSync(f)) return bad("no such file", 404);
160
+ return new Response(Bun.file(f), { headers: { "cache-control": "private, max-age=3600" } });
161
+ }
162
+
117
163
  if (req.method === "GET" && path === "/events") {
118
164
  let ctrl: ReadableStreamDefaultController;
119
165
  const stream = new ReadableStream({
120
- start(c) { ctrl = c; clients.add(c); c.enqueue("data: hello\n\n"); },
166
+ // The boot id lets the page tell a server restart from a dropped
167
+ // connection; only the former means new code. The ping keeps Bun's
168
+ // idle timeout from closing the stream every ten seconds.
169
+ start(c) { ctrl = c; clients.add(c); c.enqueue(`data: hello ${BOOT}\n\n`); },
121
170
  cancel() { clients.delete(ctrl); },
122
171
  });
123
172
  return new Response(stream, {
@@ -135,13 +184,16 @@ export async function web(repoDir: string, opts: Opts = {}) {
135
184
 
136
185
  if (path === "/inbox") {
137
186
  const text = String(body.text ?? "").trim();
187
+ // Absent means untouched; an empty string clears. replaceIdea keeps the
188
+ // old notes for any op that does not send them.
189
+ const notes = typeof body.notes === "string" ? body.notes.trim() : undefined;
138
190
  if (body.op === "add") {
139
191
  if (!text) return bad("empty idea");
140
- writeInbox(dir, [...readInbox(dir), { status: "open", text }]);
192
+ writeInbox(dir, [...readInbox(dir), { status: "open", text, notes }]);
141
193
  } else if (body.op === "edit") {
142
194
  const next = String(body.next ?? "").trim();
143
195
  if (!text || !next) return bad("empty idea");
144
- replaceIdea(dir, text, { status: "open", text: next });
196
+ replaceIdea(dir, text, notes === undefined ? { status: "open", text: next } : { status: "open", text: next, notes });
145
197
  } else if (body.op === "reject") {
146
198
  const why = String(body.reason ?? "").trim();
147
199
  if (!why) return bad("a rejection needs a reason");
@@ -158,6 +210,7 @@ export async function web(repoDir: string, opts: Opts = {}) {
158
210
  const kind = body.kind === "full" || body.kind === "lite" ? body.kind : "";
159
211
  if (!text) return bad("empty idea");
160
212
  const it: Item = parseItem(`${kind} ${text}`.trim());
213
+ it.notes = readInbox(dir).find((l) => l.status === "open" && l.text === text)?.notes;
161
214
  const r = launchRun(it, dir);
162
215
  if (r.ok) replaceIdea(dir, text, { status: "started", text });
163
216
  changed();
@@ -166,6 +219,54 @@ export async function web(repoDir: string, opts: Opts = {}) {
166
219
  : { ok: false, why: r.why, manual: r.manual, agent: r.agent }, r.ok ? 200 : 500);
167
220
  }
168
221
 
222
+ // Archive is a list beside the runs, never a move: a run keeps its directory
223
+ // and its state, and the board simply stops showing it.
224
+ if (path === "/archive") {
225
+ const archived = body.archived !== false;
226
+ if (typeof body.id === "string") {
227
+ if (!/^[\w.-]+$/.test(body.id)) return bad("bad run id");
228
+ setArchived(dir, body.id, archived);
229
+ } else if (typeof body.text === "string") {
230
+ const text = body.text.trim();
231
+ const lines = readInbox(dir);
232
+ const at = lines.findIndex((l) => l.text === text && (archived ? l.status === "open" : l.status === "archived"));
233
+ if (at < 0) return bad("no such idea", 404);
234
+ lines[at] = { ...lines[at]!, status: archived ? "archived" : "open" };
235
+ writeInbox(dir, lines);
236
+ } else return bad("id or text required");
237
+ changed();
238
+ return json({ ok: true });
239
+ }
240
+
241
+ // One chat message: text plus files (base64), each file saved and linked
242
+ // from the message so the note carries its own attachments.
243
+ if (path === "/note") {
244
+ const id = typeof body.id === "string" ? body.id : null;
245
+ if (id !== null && !/^[\w.-]+$/.test(id)) return bad("bad run id");
246
+ const text = typeof body.text === "string" ? body.text.trim() : "";
247
+ const files: { name: string; data: string }[] = Array.isArray(body.files) ? body.files : [];
248
+ let total = 0;
249
+ for (const f of files) total += (f.data?.length ?? 0);
250
+ if (total > 20_000_000) return bad("attachments over 15MB");
251
+ const links = files.map((f) => {
252
+ const bytes = Buffer.from(String(f.data).replace(/^data:[^,]*,/, ""), "base64");
253
+ const rel = saveAttachment(dir, id, String(f.name ?? "file"), bytes).replace(/\\/g, "/");
254
+ return (/\.(png|jpe?g|gif|webp|svg)$/i.test(rel) ? "!" : "") + `[${String(f.name ?? "file")}](${rel.replace(/^runs\/[^/]+\//, "")})`;
255
+ });
256
+ const note = [text, ...links].filter(Boolean).join("\n");
257
+ const failed = appendNote(dir, id !== null ? { id } : { text: String(body.for ?? "").trim() }, note);
258
+ changed();
259
+ return failed ? bad(failed, 404) : json({ ok: true });
260
+ }
261
+
262
+ if (path === "/notes") {
263
+ const id = String(body.id ?? "");
264
+ if (!/^[\w.-]+$/.test(id)) return bad("bad run id");
265
+ const failed = writeNotes(dir, id, String(body.text ?? ""));
266
+ changed();
267
+ return failed ? bad(failed, 404) : json({ ok: true });
268
+ }
269
+
169
270
  if (path === "/abort") {
170
271
  const id = String(body.id ?? "");
171
272
  const why = String(body.why ?? "").trim() || "abandoned from the web board";
@@ -178,7 +279,7 @@ export async function web(repoDir: string, opts: Opts = {}) {
178
279
 
179
280
  return new Response("not found", { status: 404 });
180
281
  },
181
- });
282
+ }); }
182
283
 
183
284
  const url = `http://127.0.0.1:${server.port}/`;
184
285
  console.log(`\n kaizen web ${url}`);
@@ -187,7 +288,7 @@ export async function web(repoDir: string, opts: Opts = {}) {
187
288
  if (opts.open !== false) await openApp(url);
188
289
 
189
290
  await new Promise<void>((resolve) => {
190
- const stop = () => { clearInterval(poll); for (const w of watchers) { try { w.close(); } catch { /* gone */ } } server.stop(true); resolve(); };
291
+ const stop = () => { clearInterval(poll); clearInterval(ping); for (const w of watchers) { try { w.close(); } catch { /* gone */ } } server.stop(true); resolve(); };
191
292
  process.on("SIGINT", stop);
192
293
  process.on("SIGTERM", stop);
193
294
  });
@@ -233,3 +334,55 @@ function winChrome(exe: string) {
233
334
  for (const r of roots) { const p = join(r, sub[exe] ?? exe); if (existsSync(p)) return p; }
234
335
  return null;
235
336
  }
337
+
338
+ // `kaizen web --shortcut`: a launcher the OS can find — Spotlight on macOS, the app
339
+ // menu / super-space on Linux, the Desktop on Windows. Each one runs `kaizen web` with
340
+ // the bun and script that ran this command, so it works for a git checkout and a
341
+ // bunx install alike.
342
+ export async function shortcut() {
343
+ const bun = process.execPath;
344
+ const script = Bun.argv[1]!;
345
+ const icon = join(dirname(import.meta.dir), "assets", "kaizen-logo.png");
346
+ const hasIcon = existsSync(icon);
347
+ const { mkdirSync, writeFileSync, chmodSync } = await import("node:fs");
348
+
349
+ if (process.platform === "darwin") {
350
+ const app = join(home, "Applications", "Kaizen.app", "Contents");
351
+ mkdirSync(join(app, "MacOS"), { recursive: true });
352
+ writeFileSync(join(app, "MacOS", "kaizen"), `#!/bin/sh\nexec ${q(bun)} ${q(script)} web --daemon\n`);
353
+ chmodSync(join(app, "MacOS", "kaizen"), 0o755);
354
+ writeFileSync(join(app, "Info.plist"), `<?xml version="1.0" encoding="UTF-8"?>
355
+ <!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
356
+ <plist version="1.0"><dict>
357
+ <key>CFBundleName</key><string>Kaizen</string>
358
+ <key>CFBundleIdentifier</key><string>dev.kaizen.web</string>
359
+ <key>CFBundleExecutable</key><string>kaizen</string>
360
+ <key>CFBundlePackageType</key><string>APPL</string>
361
+ <key>LSUIElement</key><true/>
362
+ </dict></plist>
363
+ `);
364
+ return `${tilde(join(home, "Applications", "Kaizen.app"))} — Spotlight finds it once indexed`;
365
+ }
366
+
367
+ if (process.platform === "win32") {
368
+ const lnk = join(home, "Desktop", "Kaizen.lnk");
369
+ const ps = `$s=(New-Object -ComObject WScript.Shell).CreateShortcut('${lnk}');$s.TargetPath='${bun}';$s.Arguments='"${script}" web --daemon';$s.WorkingDirectory='${home}';${hasIcon ? `$s.IconLocation='${icon}';` : ""}$s.Save()`;
370
+ await Bun.$`powershell -NoProfile -Command ${ps}`.quiet();
371
+ return lnk;
372
+ }
373
+
374
+ const dir = join(process.env["XDG_DATA_HOME"] ?? join(home, ".local", "share"), "applications");
375
+ mkdirSync(dir, { recursive: true });
376
+ const file = join(dir, "kaizen.desktop");
377
+ writeFileSync(file, `[Desktop Entry]
378
+ Type=Application
379
+ Name=Kaizen
380
+ Comment=Kaizen board
381
+ Exec=${q(bun)} ${q(script)} web --daemon
382
+ ${hasIcon ? `Icon=${icon}\n` : ""}Terminal=false
383
+ Categories=Development;
384
+ `);
385
+ return tilde(file);
386
+ }
387
+
388
+ const q = (s: string) => (/[\s"']/.test(s) ? `"${s.replace(/"/g, '\\"')}"` : s);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "kaizen-agent",
3
- "version": "1.5.3",
3
+ "version": "1.5.5",
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": {
@@ -61,7 +61,9 @@ If the user typed `/kaizen` with no argument and a run is in progress, treat it
61
61
  `~/.kaizen/` keyed by working directory. If neither exists and the user is starting
62
62
  work, run `/kaizen init` first: it creates `.kaizen/` with a copy of the `spec*.md`
63
63
  files and — where the folder is a git repository — the gitignore entry
64
- (see [`adapters.md`](adapters.md)).
64
+ (see [`adapters.md`](adapters.md)). Also append the project folder's absolute path
65
+ to `~/.kaizen/projects` if not already listed: the board only retires an idea when
66
+ it can see the run, and it only looks in projects it knows.
65
67
  2. Read the config in this order, each file filling in the keys the one before it
66
68
  does not set: `.kaizen/config.yml`, then `~/.kaizen/config.yml`, then
67
69
  `config.default.yml` in this skill. A project usually has no `config.yml` of its
@@ -359,10 +361,11 @@ shown automatically.
359
361
  01-fix.md
360
362
  01-recheck.md
361
363
  06-backlog.md # what this run deliberately did not do
364
+ notes.md # context attached from the board; the planner reads it
362
365
  state.json
363
366
  ```
364
367
 
365
- `state.json` is the resume point, and records the runner the run started with, so a
368
+ `state.json` is the resume point, and records the runner and the agent the run started with, so a
366
369
  run picked up later keeps the shape it was reviewed under rather than silently
367
370
  changing because a setting moved:
368
371
 
@@ -371,6 +374,7 @@ changing because a setting moved:
371
374
  "id": "2026-09-02-add-oauth",
372
375
  "mode": "approve",
373
376
  "runner": "full",
377
+ "agent": "claude",
374
378
  "stage": "review",
375
379
  "awaiting": "approvals.review",
376
380
  "iteration": 1,
@@ -65,6 +65,12 @@ deviations from the plan and why, verification carried out with its actual resul
65
65
  and anything deliberately left undone. Report failures honestly; a false claim of
66
66
  success poisons the review stage, which trusts this report.
67
67
 
68
+ **A `## Preview` section says how to see the result.** For an app or service: the
69
+ commands to start it locally and the URL to open, copied from the material's own README
70
+ where one exists. For anything else — a document, copy, a runbook, a memo — the path
71
+ to open, or the result itself when it fits in a screen. The board shows this section on
72
+ the run's card, so a person can try the work without reading the rest of the report.
73
+
68
74
  **The gate block closes the stage.** `03-impl.md` ends with one line per gate item
69
75
  from the plan's Verification section:
70
76
 
@@ -6,9 +6,11 @@ planner's and the plan approval's contract. The builder and reviewer do not read
6
6
  ---
7
7
  ## Stage 1 — Plan
8
8
 
9
- **Agent:** planner. **Input:** `00-request.md` (including the track), `memory.md`, and
10
- the existing material — the codebase, the manuscript, the current campaign, whatever
11
- this run builds on. **Output:** `01-plan.md`.
9
+ **Agent:** planner. **Input:** `00-request.md` (including the track), `notes.md` in
10
+ the run directory when present — links, paths, and background the user attached from
11
+ the board, binding like the request itself — `memory.md`, and the existing material —
12
+ the codebase, the manuscript, the current campaign, whatever this run builds on.
13
+ **Output:** `01-plan.md`.
12
14
 
13
15
  The request is usually one sentence. The planner's first job is to expand it — but by
14
16
  proposing, not by interrogating. It restates the problem, brings the nearest good
@@ -23,6 +25,18 @@ different work behind each, nothing available to break the tie — and even then
23
25
  its recommendation with the question. Questions the existing material can answer are
24
26
  never asked; that is the planner's own work.
25
27
 
28
+ **Building from nothing is the exception: settle the foundation first.** When the
29
+ track is a new product or app, there is no material to answer the questions that
30
+ decide everything after them, and "wrong" for this track is a wrong foundation — the
31
+ one thing a review cannot fix, because fixing it is a rebuild. Before the steps are
32
+ written, the plan states a decision for each of: platform (web, mobile, PWA, native,
33
+ CLI), who and how many will use it, stack, how it is distributed and reached, access
34
+ (open, invite, accounts), what data is kept and where, and hosting. Each is either
35
+ read from the request, proposed with its reason, or — where two answers lead to
36
+ materially different builds — put to the user as an *Open question* with options,
37
+ all in one round, before anything is built. Three quick questions and a default for
38
+ the rest is how a run ends up writing the second app instead of the first.
39
+
26
40
  The goals stay the user's. The planner may propose a better solution to what was
27
41
  asked, or argue that the request as stated will not achieve what the user wants, but
28
42
  it may not widen the goal on its own. Anything beyond the request goes in *Out of
@@ -101,9 +101,14 @@ sentence and either nods or says "no, this is a blog post". They are never asked
101
101
  categorize their own request up front.
102
102
 
103
103
  Write into `00-request.md`: the verbatim request, the deliverable, what done means,
104
- and what wrong means. Record in `state.json`: `id`, `mode`, `runner`, `track`,
104
+ and what wrong means. Record in `state.json`: `id`, `mode`, `runner`, `agent`, `track`,
105
105
  `stage: "plan"`, `awaiting: null`.
106
106
 
107
+ `agent` is the tool this run is happening in — `claude`, `codex`, `agy`, `opencode`,
108
+ `gemini`, or `cursor` — written once at intake and never changed, so a board can say
109
+ which agent to look for when a run is waiting. The tool running this stage knows what
110
+ it is; no detection is needed.
111
+
107
112
  `runner` is read from config at intake and written down here, because it decides
108
113
  whether the stages that follow are separate agents or this one. A run resumed later
109
114
  keeps the runner it started with: a review done in the same session as the work is a