kaizen-agent 1.9.3 → 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/README.md +11 -6
- package/agents/kaizen-builder.md +4 -1
- package/cli/board.test.ts +48 -0
- package/cli/install.test.ts +85 -0
- package/cli/install.ts +46 -10
- package/cli/notes.test.ts +1 -1
- package/cli/settings.test.ts +50 -0
- package/cli/settings.ts +90 -35
- package/cli/sources.test.ts +44 -5
- package/cli/sources.ts +11 -6
- package/cli/state.test.ts +214 -2
- package/cli/state.ts +218 -16
- package/cli/web.ts +70 -14
- package/package.json +1 -1
- package/skills/kaizen/SKILL.md +1 -0
- package/skills/kaizen/config.default.yml +10 -4
- package/skills/kaizen/spec-build.md +17 -0
- package/skills/kaizen/spec-plan.md +6 -1
- package/web/board.html +214 -49
- package/web/notebook.js +2 -2
- package/web/notes-lib.ts +1 -1
- package/web/notes.html +1 -0
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
|
|
66
|
+
// them, and its own folders are offered instead: ~/Projects/<client>/<project>.
|
|
55
67
|
const fresh = async () => {
|
|
56
|
-
const
|
|
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] :
|
|
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:
|
|
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
|
-
|
|
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
package/skills/kaizen/SKILL.md
CHANGED
|
@@ -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
|
|
171
|
-
# offered in the web board's project list;
|
|
172
|
-
# moves the board there. A folder that is
|
|
173
|
-
# package.json) is read as a group, and the
|
|
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
|
|