kaizen-agent 1.9.4 → 1.11.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/cli/web.ts CHANGED
@@ -1,12 +1,12 @@
1
1
  // `kaizen web`: the board in a browser. A loopback HTTP server over state.ts and
2
2
  // one HTML page; the page never sees the filesystem, only JSON.
3
3
  import { existsSync, readdirSync, statSync, watch, appendFileSync, mkdirSync, type FSWatcher } from "node:fs";
4
- import { join, dirname } from "node:path";
4
+ import { join, dirname, resolve, sep } from "node:path";
5
5
  import {
6
6
  home, type Item, type Card, boardCards, knownProjects, remember, locate, label, tilde, searchRoots, findProjects,
7
- readInbox, writeInbox, replaceIdea, abandonRun, launchRun, parseItem, short, setArchived, writeNotes,
7
+ readInbox, writeInbox, replaceIdea, abandonRun, launchRun, parseItem, agentChoices, projectOf, KNOWN_AGENTS, plainModel, short, setArchived, writeNotes,
8
8
  appendNote, saveAttachment, pidOnPort, readText, cmdExe, sysExe, powershellExe, runGit, cardsGit, readCommit, commitPush, notice, notifier, LOGO,
9
- reviewFindings, pickFindings, fixPrompt,
9
+ reviewFindings, pickFindings, fixPrompt, approvalPrompt, nativePath, closeSessions, sweepSessions, issueUrl,
10
10
  } from "./state.ts";
11
11
  import { readSources, addSource, removeSource, moveSource, gatherSource, gatherAll, gatherDirs, due } from "./sources.ts";
12
12
  import { listNotes, saveNote, renameNote, deleteNote, boardIndex, linksTo } from "./notes.ts";
@@ -48,14 +48,24 @@ export async function web(repoDir: string, opts: Opts = {}) {
48
48
  let state = locate();
49
49
  if (state && dirname(state) !== home) remember(dirname(state));
50
50
  let project = state ? label(state) : null;
51
+ // The "Projects folder" setting, resolved; empty when unset.
52
+ const projectsRoot = async () => {
53
+ const { listSettings } = await import("./settings.ts");
54
+ let root = listSettings(repoDir).rows.find((r) => r.key === "board.projects_dir")?.value ?? "";
55
+ if (root.startsWith("~")) root = home + root.slice(1);
56
+ // Typed on Windows as d:\work, D:/work or Git Bash /d/work: one spelling, the one
57
+ // remember() and the known-project check use, or /d/work reads as C:\d\work.
58
+ return root ? resolve(nativePath(root)) : "";
59
+ };
60
+ // .kaizen is not a sign of one: a group folder that was chosen once has it, and
61
+ // must keep showing what is inside.
62
+ const isProject = (d: string) => [".git", "package.json"].some((m) => existsSync(join(d, m)));
51
63
  // Folders under the "Projects folder" setting that have no .kaizen yet, offered in
52
64
  // the project list so one can be chosen without a terminal. A folder that does not
53
65
  // look like a project itself (no .git, no package.json) is taken for a group of
54
- // them, and its own folders are offered too: ~/Projects/<client>/<project>.
66
+ // them, and its own folders are offered instead: ~/Projects/<client>/<project>.
55
67
  const fresh = async () => {
56
- const { listSettings } = await import("./settings.ts");
57
- let root = listSettings(repoDir).rows.find((r) => r.key === "board.projects_dir")?.value ?? "";
58
- if (root.startsWith("~")) root = home + root.slice(1);
68
+ const root = await projectsRoot();
59
69
  if (!root) return [];
60
70
  const dirs = (d: string) => {
61
71
  try {
@@ -63,11 +73,8 @@ export async function web(repoDir: string, opts: Opts = {}) {
63
73
  .map((n) => join(d, n)).filter((p) => { try { return statSync(p).isDirectory(); } catch { return false; } });
64
74
  } catch { return []; }
65
75
  };
66
- // .kaizen is not a sign of one: a group folder that was chosen once has it, and
67
- // must keep showing what is inside.
68
- const isProject = (d: string) => [".git", "package.json"].some((m) => existsSync(join(d, m)));
69
76
  // One already set up but never opened joins the known projects instead.
70
- return dirs(root).flatMap((d) => isProject(d) ? [d] : [d, ...dirs(d)])
77
+ return dirs(root).flatMap((d) => isProject(d) ? [d] : dirs(d))
71
78
  .filter((d) => existsSync(join(d, ".kaizen")) ? (remember(d), false) : true).sort();
72
79
  };
73
80
 
@@ -129,7 +136,10 @@ export async function web(repoDir: string, opts: Opts = {}) {
129
136
  };
130
137
  let last = fingerprint();
131
138
  let seen = states(true).join("\n");
139
+ let ticks = 0;
132
140
  const poll = setInterval(() => {
141
+ // Once a minute: windows of runs finished or abandoned past the grace are closed.
142
+ if (++ticks % 30 === 0) for (const dir of states(true)) sweepSessions(dir, Date.now());
133
143
  // A project registered since start (a run launched into a fresh dir) gets a watcher too.
134
144
  const now = states(true).join("\n"); if (now !== seen) { seen = now; watchAll(); }
135
145
  const fp = fingerprint(); if (fp !== last) { last = fp; changed(); }
@@ -239,6 +249,22 @@ export async function web(repoDir: string, opts: Opts = {}) {
239
249
  // Before the project list: it registers folders that turn out to be set up.
240
250
  const unset = await fresh();
241
251
  const dirs = states(all);
252
+ // With a projects folder set, the picker offers only real projects inside it,
253
+ // and always the one open now so it never hides where the board is.
254
+ const root = await projectsRoot();
255
+ // Windows paths compare without case: c:\users\me\projects holds C:\Users\me\Projects\app.
256
+ // A group folder is what fresh() takes for one, a folder right inside the projects
257
+ // folder with no .git or package.json; with an empty .kaizen it was chosen once and
258
+ // is hidden. Anything else is a project, a writing one included, even before its
259
+ // first run. A drive or filesystem root already ends in a separator.
260
+ const fold = (p: string) => process.platform === "win32" ? p.toLowerCase() : p;
261
+ const filled = (d: string) => { try { return readdirSync(d).length > 0; } catch { return false; } };
262
+ const inside = fold(root.endsWith(sep) ? root : root + sep);
263
+ const picker = states(true).filter((d) => {
264
+ const p = dirname(d);
265
+ return !root || d === state || (fold(p).startsWith(inside)
266
+ && (isProject(p) || filled(d) || fold(dirname(p)) !== fold(root)));
267
+ });
242
268
  const cards = boardCards(dirs, Date.now());
243
269
  // The git mark, for the cards whose panel offers commit and push: one status per project.
244
270
  const marks = new Map<string, Record<string, string>>();
@@ -247,7 +273,7 @@ export async function web(repoDir: string, opts: Opts = {}) {
247
273
  marks.set(d, cardsGit(d, cards.filter((c) => markable(c) && c.state === d).map((c) => c.id!)));
248
274
  return json({
249
275
  project, all, dir: state,
250
- projects: states(true).map((d) => ({ dir: d, label: label(d) })),
276
+ projects: picker.map((d) => ({ dir: d, label: label(d) })),
251
277
  fresh: unset.map((d) => ({ dir: join(d, ".kaizen"), label: tilde(d) })),
252
278
  cards: cards.map((c) => ({ ...c, notice: notice(c), git: markable(c) ? marks.get(c.state)?.[c.id!] : undefined })),
253
279
  now: Date.now(),
@@ -265,7 +291,7 @@ export async function web(repoDir: string, opts: Opts = {}) {
265
291
  id, short: short(id), state: read("state.json"),
266
292
  request: read("00-request.md"), plan: read("01-plan.md"), approval: read("02-approval.md"),
267
293
  impl: read("03-impl.md"), review: read("04-review.md"), backlog: read("06-backlog.md"),
268
- notes: read("notes.md"),
294
+ notes: read("notes.md"), progress: read("progress.md"),
269
295
  // Notebook notes whose [[run:…]] links name this run.
270
296
  linkedFrom: linksTo(dir, id),
271
297
  // Nothing to pick in the global state dir: /fix refuses it.
@@ -283,12 +309,25 @@ export async function web(repoDir: string, opts: Opts = {}) {
283
309
  const sources = [], failed = [];
284
310
  for (const d of dir ? [dir] : states(true)) {
285
311
  // The seen list is bookkeeping, not something the page shows.
286
- try { sources.push(...readSources(d).map(({ seen, ...s }) => ({ ...s, dir: d }))); }
312
+ try { sources.push(...readSources(d).map(({ seen, ...s }) => ({ ...s, dir: d, project: label(d) }))); }
287
313
  catch { failed.push(label(d)); }
288
314
  }
289
315
  return json({ sources, failed, every: await every() });
290
316
  }
291
317
 
318
+ // The Run form's choices for one project: installed tools and their models.
319
+ // A prefilled GitHub bug form. A redirect, so a plain link opens it with no script
320
+ // and no popup blocker in the way.
321
+ if (req.method === "GET" && path === "/issue") {
322
+ return Response.redirect(issueUrl({ title: url.searchParams.get("title") ?? "", what: url.searchParams.get("what") ?? "" }), 302);
323
+ }
324
+
325
+ if (req.method === "GET" && path === "/agents") {
326
+ const dir = url.searchParams.get("dir");
327
+ if (!dir || !states(true).includes(dir)) return bad("unknown state dir", 404);
328
+ return json(agentChoices(projectOf(dir)));
329
+ }
330
+
292
331
  if (req.method === "GET" && path === "/settings") {
293
332
  const { listSettings } = await import("./settings.ts");
294
333
  return json(listSettings(repoDir));
@@ -303,6 +342,28 @@ export async function web(repoDir: string, opts: Opts = {}) {
303
342
  return "error" in r ? bad(r.error) : json(r);
304
343
  }
305
344
 
345
+ // A page cannot learn a real path from a file input, so the board, which runs
346
+ // on the user's machine, opens the system's own folder dialog and answers it.
347
+ if (req.method === "POST" && path === "/pick-folder") {
348
+ if (!local(req)) return bad("cross-origin write refused", 403);
349
+ if (picking) return bad("a folder dialog is already open", 409);
350
+ const cmd = folderDialog();
351
+ if (!cmd) return bad("No folder dialog found: install zenity or kdialog, or type the path");
352
+ picking = true;
353
+ try {
354
+ const p = Bun.spawn(cmd, { stdin: "ignore", stdout: "pipe", stderr: "pipe" });
355
+ const [out, err, code] = await Promise.all([new Response(p.stdout).text(), new Response(p.stderr).text(), p.exited]);
356
+ // osascript ends a folder with "/"; a bare root (/, C:\) keeps its own.
357
+ const picked = /^([A-Za-z]:)?[\\/]$/.test(out.trim()) ? out.trim() : out.trim().replace(/[\\/]+$/, "");
358
+ if (code === 0 && picked) return json({ path: picked });
359
+ // Cancel: exit 1 with nothing said (zenity, kdialog), -128 (osascript), or exit 0 with no path (PowerShell).
360
+ if (code === 0 || !err.trim() || err.includes("-128")) return json({ cancelled: true });
361
+ return bad(err.trim().split("\n")[0]);
362
+ } catch (e) {
363
+ return bad(e instanceof Error ? e.message : String(e));
364
+ } finally { picking = false; }
365
+ }
366
+
306
367
  // Attachments only, never a run's own files: the path must sit under an
307
368
  // attachments/ directory of a known state dir, with no way back up.
308
369
  if (req.method === "GET" && path === "/file") {
@@ -426,11 +487,15 @@ export async function web(repoDir: string, opts: Opts = {}) {
426
487
  const text = String(body.text ?? "").trim();
427
488
  const kind = body.kind === "full" || body.kind === "lite" ? body.kind : "";
428
489
  if (!text) return bad("empty idea");
490
+ const agent = typeof body.agent === "string" && body.agent ? body.agent : undefined;
491
+ if (agent && !KNOWN_AGENTS.some((a) => a.cmd === agent)) return bad("unknown agent");
492
+ const model = typeof body.model === "string" ? body.model.trim() : "";
493
+ if (model && !plainModel(model)) return bad("A model name is letters, digits and . : / @ _ - only");
429
494
  const it: Item = parseItem(`${kind} ${text}`.trim());
430
495
  // A `started` idea may be run again: the terminal the first launch opened
431
496
  // can come up empty, and nothing else tells the board a run never began.
432
497
  it.notes = readInbox(dir).find((l) => (l.status === "open" || l.status === "started") && l.text === text)?.notes;
433
- const r = launchRun(it, dir);
498
+ const r = launchRun(it, dir, undefined, { agent, model: model || undefined, yolo: body.yolo === true });
434
499
  if (r.ok) replaceIdea(dir, text, { status: "started", text });
435
500
  changed();
436
501
  return json(r.ok
@@ -528,7 +593,37 @@ export async function web(repoDir: string, opts: Opts = {}) {
528
593
  const nums = pickFindings(dir, id, body.nums);
529
594
  if (!nums) return bad("pick open findings of this review");
530
595
  const text = fixPrompt(id, nums);
531
- const r = launchRun({ severity: null, where: null, text, raw: text }, dir);
596
+ // The run's conversation picks the fix up, and once that window is open the
597
+ // waiting one ends. A failed launch leaves it: the decision has nowhere else.
598
+ const before = Date.now();
599
+ const r = launchRun({ severity: null, where: null, text, raw: text }, dir, id);
600
+ if (r.ok) closeSessions(dir, id, { before });
601
+ changed();
602
+ return json(r.ok
603
+ ? { ok: true, agent: r.agent }
604
+ : { ok: false, why: r.why, manual: r.manual, agent: r.agent }, r.ok ? 200 : 500);
605
+ }
606
+
607
+ // Approve or send back what a run is waiting on, from its panel. The page names
608
+ // the approval it saw: one already answered elsewhere is not answered twice.
609
+ if (path === "/decide") {
610
+ const id = String(body.id ?? "");
611
+ if (!/^[\w.-]+$/.test(id)) return bad("bad run id");
612
+ if (dir === join(home, ".kaizen")) return bad("no project to run in", 409);
613
+ let run: any;
614
+ try { run = JSON.parse(readText(join(dir, "runs", id, "state.json"))); } catch { return bad("no such run", 404); }
615
+ if (run.awaiting !== "approvals.plan" && run.awaiting !== "approvals.review" || run.awaiting !== body.awaiting) return bad("this run is not waiting on that approval", 409);
616
+ if (body.why !== undefined && !String(body.why).trim()) return bad("a revise needs a comment");
617
+ const answers = body.answers;
618
+ if (answers !== undefined && (run.awaiting !== "approvals.plan" || body.why !== undefined || !Array.isArray(answers)
619
+ || !answers.length || answers.length > 20 || answers.some((a: unknown) => typeof a !== "string" || !a.trim()))) return bad("bad answers");
620
+ const text = approvalPrompt(id, run.awaiting, body.why === undefined ? undefined : String(body.why), answers);
621
+ // The run is stopped at this approval, so its window is idle: the same
622
+ // conversation continues in one new window, and then the idle one ends. A
623
+ // failed launch leaves it open, since the decision went nowhere.
624
+ const before = Date.now();
625
+ const r = launchRun({ severity: null, where: null, text, raw: text }, dir, id);
626
+ if (r.ok) closeSessions(dir, id, { before });
532
627
  changed();
533
628
  return json(r.ok
534
629
  ? { ok: true, agent: r.agent }
@@ -595,6 +690,29 @@ export async function openApp(url: string) {
595
690
  }
596
691
 
597
692
  // Windows keeps browsers out of PATH; the usual install roots are the next best guess.
693
+ // One dialog at a time: a second click while one is open would stack another.
694
+ let picking = false;
695
+
696
+ // The system folder picker for this platform, printing the chosen path on stdout.
697
+ // macOS and Windows always have one; on Linux it is whichever desktop tool exists.
698
+ export function folderDialog(platform: string = process.platform): string[] | null {
699
+ const prompt = "Choose your projects folder";
700
+ // osascript is a background process: without `activate` its dialog can open behind
701
+ // the browser. Plain `activate` targets osascript itself, so no Automation prompt.
702
+ if (platform === "darwin") return ["osascript", "-e", "activate", "-e", `POSIX path of (choose folder with prompt "${prompt}")`];
703
+ if (platform === "win32") {
704
+ // An owner form kept on top, or the dialog opens behind the browser.
705
+ const ps = `[Console]::OutputEncoding=[Text.Encoding]::UTF8;Add-Type -AssemblyName System.Windows.Forms;`
706
+ + `$o=New-Object System.Windows.Forms.Form -Property @{TopMost=$true};$d=New-Object System.Windows.Forms.FolderBrowserDialog;`
707
+ + `$d.Description='${prompt}';if($d.ShowDialog($o) -eq 'OK'){[Console]::Out.Write($d.SelectedPath)}`;
708
+ return [powershellExe(), "-NoProfile", "-STA", "-Command", ps];
709
+ }
710
+ const zenity = Bun.which("zenity", { PATH: process.env.PATH });
711
+ if (zenity) return [zenity, "--file-selection", "--directory", `--title=${prompt}`];
712
+ const kdialog = Bun.which("kdialog", { PATH: process.env.PATH });
713
+ return kdialog ? [kdialog, "--getexistingdirectory", home, "--title", prompt] : null;
714
+ }
715
+
598
716
  function winChrome(exe: string) {
599
717
  const roots = [process.env["ProgramFiles"], process.env["ProgramFiles(x86)"], process.env["LOCALAPPDATA"]].filter(Boolean) as string[];
600
718
  const sub: Record<string, string> = {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "kaizen-agent",
3
- "version": "1.9.4",
3
+ "version": "1.11.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": {
@@ -62,7 +62,10 @@ If the user typed `/kaizen` with no argument and a run is in progress, treat it
62
62
  ## Before doing anything
63
63
 
64
64
  1. Locate the state directory: `.kaizen/` in the project folder's root, else
65
- `~/.kaizen/` keyed by working directory. If neither exists and the user is starting
65
+ `~/.kaizen/` keyed by working directory. The project folder is the working
66
+ directory this session started in — never the folder this skill was read from, nor
67
+ the repository it lives in, even when that has a `.kaizen/` of its own. A run
68
+ written there is invisible to the board the user launched it from. If neither exists and the user is starting
66
69
  work, run `/kaizen init` first: it creates `.kaizen/` with a copy of the `spec*.md`
67
70
  files and — where the folder is a git repository — the gitignore entry
68
71
  (see [`adapters.md`](adapters.md)). Also append the project folder's absolute path
@@ -299,6 +302,7 @@ shown automatically.
299
302
  01-recheck.md
300
303
  06-backlog.md # what this run deliberately did not do
301
304
  notes.md # context attached from the board; the planner reads it
305
+ progress.md # the Work list as todo/doing/done, kept by the builder
302
306
  state.json
303
307
  ```
304
308
 
@@ -155,6 +155,11 @@ agent:
155
155
  # Default coding agent to launch from dashboard backlog (auto | claude | codex | agy | opencode | gemini).
156
156
  # auto picks Claude Code if installed, otherwise the first agent found on PATH.
157
157
  default: auto
158
+ # on: a board decision ends the run's waiting session before continuing its
159
+ # conversation, and abandoned or done runs (after 2 minutes) have theirs ended.
160
+ # Only sessions the board or dashboard launched are touched. off: nothing is
161
+ # ended, and a resumed conversation opens beside the window still waiting.
162
+ close: on
158
163
  # Per-tool model and effort for the session the board launches, passed as that
159
164
  # tool's own flags (claude --model/--effort, agy --model/--effort, codex -m and
160
165
  # model_reasoning_effort, gemini -m, opencode -m). Model names are the tool's own:
@@ -167,10 +172,11 @@ agent:
167
172
  # gemini: { model: gemini-2.5-pro }
168
173
 
169
174
  board:
170
- # A folder your projects live in, e.g. ~/Projects. Every folder inside it is
171
- # offered in the web board's project list; choosing one creates its .kaizen and
172
- # moves the board there. A folder that is not itself a project (no .git, no
173
- # package.json) is read as a group, and the folders inside it are offered too.
175
+ # A folder your projects live in, e.g. ~/Projects, or C:\Users\you\Projects on
176
+ # Windows. Every folder inside it is offered in the web board's project list;
177
+ # choosing one creates its .kaizen and moves the board there. A folder that is
178
+ # not itself a project (no .git, no package.json) is read as a group, and the
179
+ # folders inside it are offered too.
174
180
  projects_dir: ""
175
181
 
176
182
  sources:
@@ -39,6 +39,14 @@ Rules:
39
39
  folder there is no branch to make and no commit to skip, and that absence is not a
40
40
  finding.
41
41
 
42
+ **Keep `progress.md` while you work.** Before the first change, write
43
+ `runs/<id>/progress.md` with one line per bullet of the plan's Work list, in order:
44
+ `- todo: <bullet text>`. Change a line's prefix to `- doing:` when you start that item
45
+ and to `- done:` when it is finished, with exactly one `doing` at a time. Rewrite the
46
+ prefix only, never the text or the order. The board shows this file live on a building
47
+ run, so it is how a person sees where the build is. It is a progress report, not the
48
+ record — `03-impl.md` still is — and fix iterations do not touch it.
49
+
42
50
  **Baseline first, in any track with an existing suite of checks.** Before changing
43
51
  anything, run whatever already proves the existing material correct — the test suite,
44
52
  the linter, the link checker, the fact-check pass — and record the result in
@@ -74,6 +82,15 @@ where one exists. For anything else — a document, copy, a runbook, a memo —
74
82
  to open, or the result itself when it fits in a screen. The board shows this section on
75
83
  the run's card, so a person can try the work without reading the rest of the report.
76
84
 
85
+ It also says how to test the result, under a `### How to test` subhead: numbered steps a
86
+ person follows to check the run did what was asked, each an action and what they should
87
+ see — "open Settings → Board, clear the field: it shows `not set`". Derive the steps
88
+ from the plan's gate, covering what the request asked for rather than every internal
89
+ check. An automated check goes in too, as its command and the result it should give,
90
+ but never alone: a test command tells a person the code passes, not that the work does
91
+ what they wanted. Use `###` subheads inside the section, never `##`: the board ends the
92
+ section at the next `##`.
93
+
77
94
  **A `## Commit` section is the commit message for the work**, where the work sits in a
78
95
  git repository. The first line is a Conventional Commits subject,
79
96
  `<type>(<scope>): <what changed>`, with the type one of `feat`, `fix`, `refactor`,
@@ -99,7 +99,12 @@ The planner reads code but writes none. Its plan must contain:
99
99
  8. **Risks** — what could break, what is irreversible, what needs a migration or a
100
100
  backup taken first.
101
101
  9. **Open questions** — assumptions that survived the interview. If this list is
102
- non-empty, the approval must ask the user about it rather than defaulting.
102
+ non-empty, the approval must ask the user about it rather than defaulting. The
103
+ board shows a waiting plan's questions as a pick list and sends the answers with
104
+ its approval, so keep this shape: each question on its own line as
105
+ `Q: <question>`, its options beneath as `- <label> (recommended) — <why>`, the
106
+ label short since it is what is sent back, `(multi-select)` in the question when
107
+ several may be picked, and `None.` when there are none.
103
108
  10. **Out of scope suggestions** — worthwhile things noticed but not asked for, each
104
109
  its own proposal. Never folded into a step.
105
110