kaizen-agent 1.9.4 → 1.10.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
7
  readInbox, writeInbox, replaceIdea, abandonRun, launchRun, parseItem, 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,
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,7 +309,7 @@ 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() });
@@ -528,7 +554,37 @@ export async function web(repoDir: string, opts: Opts = {}) {
528
554
  const nums = pickFindings(dir, id, body.nums);
529
555
  if (!nums) return bad("pick open findings of this review");
530
556
  const text = fixPrompt(id, nums);
531
- const r = launchRun({ severity: null, where: null, text, raw: text }, dir);
557
+ // The run's conversation picks the fix up, and once that window is open the
558
+ // waiting one ends. A failed launch leaves it: the decision has nowhere else.
559
+ const before = Date.now();
560
+ const r = launchRun({ severity: null, where: null, text, raw: text }, dir, id);
561
+ if (r.ok) closeSessions(dir, id, { before });
562
+ changed();
563
+ return json(r.ok
564
+ ? { ok: true, agent: r.agent }
565
+ : { ok: false, why: r.why, manual: r.manual, agent: r.agent }, r.ok ? 200 : 500);
566
+ }
567
+
568
+ // Approve or send back what a run is waiting on, from its panel. The page names
569
+ // the approval it saw: one already answered elsewhere is not answered twice.
570
+ if (path === "/decide") {
571
+ const id = String(body.id ?? "");
572
+ if (!/^[\w.-]+$/.test(id)) return bad("bad run id");
573
+ if (dir === join(home, ".kaizen")) return bad("no project to run in", 409);
574
+ let run: any;
575
+ try { run = JSON.parse(readText(join(dir, "runs", id, "state.json"))); } catch { return bad("no such run", 404); }
576
+ if (run.awaiting !== "approvals.plan" && run.awaiting !== "approvals.review" || run.awaiting !== body.awaiting) return bad("this run is not waiting on that approval", 409);
577
+ if (body.why !== undefined && !String(body.why).trim()) return bad("a revise needs a comment");
578
+ const answers = body.answers;
579
+ if (answers !== undefined && (run.awaiting !== "approvals.plan" || body.why !== undefined || !Array.isArray(answers)
580
+ || !answers.length || answers.length > 20 || answers.some((a: unknown) => typeof a !== "string" || !a.trim()))) return bad("bad answers");
581
+ const text = approvalPrompt(id, run.awaiting, body.why === undefined ? undefined : String(body.why), answers);
582
+ // The run is stopped at this approval, so its window is idle: the same
583
+ // conversation continues in one new window, and then the idle one ends. A
584
+ // failed launch leaves it open, since the decision went nowhere.
585
+ const before = Date.now();
586
+ const r = launchRun({ severity: null, where: null, text, raw: text }, dir, id);
587
+ if (r.ok) closeSessions(dir, id, { before });
532
588
  changed();
533
589
  return json(r.ok
534
590
  ? { ok: true, agent: r.agent }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "kaizen-agent",
3
- "version": "1.9.4",
3
+ "version": "1.10.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": {
@@ -299,6 +299,7 @@ shown automatically.
299
299
  01-recheck.md
300
300
  06-backlog.md # what this run deliberately did not do
301
301
  notes.md # context attached from the board; the planner reads it
302
+ progress.md # the Work list as todo/doing/done, kept by the builder
302
303
  state.json
303
304
  ```
304
305
 
@@ -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