kaizen-agent 1.42.2 → 1.43.1
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/agents/kaizen-reviewer.md +1 -1
- package/cli/backlog.test.ts +133 -0
- package/cli/board.test.ts +10 -4
- package/cli/browser.test.ts +1 -1
- package/cli/browser.ts +12 -7
- package/cli/html.test.ts +19 -0
- package/cli/notes.ts +2 -6
- package/cli/pty.ts +2 -1
- package/cli/settings.test.ts +38 -0
- package/cli/settings.ts +37 -30
- package/cli/state.test.ts +86 -3
- package/cli/state.ts +245 -45
- package/cli/web.ts +67 -16
- package/package.json +1 -1
- package/skills/kaizen/README.md +0 -4
- package/skills/kaizen/SKILL.md +4 -1
- package/skills/kaizen/config.default.yml +1 -50
- package/skills/kaizen/docs.md +3 -2
- package/skills/kaizen/settings.md +10 -3
- package/skills/kaizen/spec-build.md +3 -2
- package/skills/kaizen/spec-main.md +40 -5
- package/skills/kaizen/spec-review.md +2 -1
- package/skills/kaizen-help/SKILL.md +0 -4
- package/web/board/agent-term.ts +1 -1
- package/web/board/api.ts +2 -0
- package/web/board/approvals.ts +3 -3
- package/web/board/cards.ts +284 -108
- package/web/board/chat.ts +4 -4
- package/web/board/dom.ts +17 -5
- package/web/board/fixes.ts +6 -8
- package/web/board/git.ts +3 -3
- package/web/board/icebox.ts +52 -70
- package/web/board/keys.ts +9 -6
- package/web/board/live.ts +3 -2
- package/web/board/main.ts +1 -0
- package/web/board/panel.ts +22 -16
- package/web/board/projects.ts +11 -16
- package/web/board/settings.ts +60 -29
- package/web/board/store.ts +2 -0
- package/web/board/terms.ts +279 -0
- package/web/board/update.ts +7 -7
- package/web/board/views.ts +19 -5
- package/web/board.html +40 -2
- package/web/board.js +25 -24
- package/web/notebook.js +1 -1
- package/web/notes.html +2 -0
- package/web/term.js +1 -1
|
@@ -142,7 +142,7 @@ open human check never reads as a clean pass.
|
|
|
142
142
|
|
|
143
143
|
## Memory
|
|
144
144
|
|
|
145
|
-
When
|
|
145
|
+
When `review.write_memory` is true (the default), append to `.kaizen/memory.md` any durable lesson
|
|
146
146
|
about this codebase: a recurring bug pattern, a non-obvious constraint, a convention
|
|
147
147
|
worth preserving. One line each. Nothing run-specific, nothing already obvious from
|
|
148
148
|
reading the code.
|
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
// Backlog items as cards in the board's Ideas column: numbering, hiding while a run
|
|
2
|
+
// started from one lives, closing in place, and the board routes that drive them.
|
|
3
|
+
import { test, expect } from "bun:test";
|
|
4
|
+
import { mkdtempSync, mkdirSync, writeFileSync, readFileSync, statSync } from "node:fs";
|
|
5
|
+
import { join, dirname } from "node:path";
|
|
6
|
+
import { tmpdir } from "node:os";
|
|
7
|
+
import { backlogLines, backlogCards, startedBacklog, closeBacklog, markBacklogDone, backlogItem } from "./state.ts";
|
|
8
|
+
import { boardIndex } from "./notes.ts";
|
|
9
|
+
|
|
10
|
+
const scratch = () => {
|
|
11
|
+
const state = join(mkdtempSync(join(tmpdir(), "kz-bl-")), ".kaizen");
|
|
12
|
+
const run = (id: string, stage: string, files: Record<string, string> = {}) => {
|
|
13
|
+
const dir = join(state, "runs", id);
|
|
14
|
+
mkdirSync(dir, { recursive: true });
|
|
15
|
+
writeFileSync(join(dir, "state.json"), JSON.stringify({ id, stage, awaiting: null }));
|
|
16
|
+
for (const [f, t] of Object.entries(files)) writeFileSync(join(dir, f), t);
|
|
17
|
+
return dir;
|
|
18
|
+
};
|
|
19
|
+
return { state, run };
|
|
20
|
+
};
|
|
21
|
+
|
|
22
|
+
const BACKLOG = "# Backlog\n\n- open: `cli/x.ts:12`: low: first thing. Fix: do it.\n- done: closed one | run y\n- open: high: urgent thing\n a note under it\n- rejected: no | why\n- open: plain item\n";
|
|
23
|
+
|
|
24
|
+
test("backlogLines numbers every open, done and rejected line as boardIndex does", () => {
|
|
25
|
+
const { state, run } = scratch();
|
|
26
|
+
run("2026-10-01-src", "done", { "06-backlog.md": BACKLOG });
|
|
27
|
+
const lines = backlogLines(join(state, "runs", "2026-10-01-src", "06-backlog.md"));
|
|
28
|
+
expect(lines.map((l) => [l.n, l.status])).toEqual([[1, "open"], [2, "done"], [3, "open"], [4, "rejected"], [5, "open"]]);
|
|
29
|
+
const idx = boardIndex(state).runs.find((r) => r.id === "2026-10-01-src")!.backlog;
|
|
30
|
+
expect(idx).toEqual(lines.map(({ n, status, text }) => ({ n, status, text })));
|
|
31
|
+
expect(backlogLines(join(state, "nope.md"))).toEqual([]);
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
test("backlogCards: open items of live runs and the orphanage, most severe first; abandoned runs skipped", () => {
|
|
35
|
+
const { state, run } = scratch();
|
|
36
|
+
run("2026-10-01-src", "done", { "06-backlog.md": BACKLOG });
|
|
37
|
+
run("2026-10-02-gone", "abandoned", { "06-backlog.md": "- open: critical: from an abandoned run\n" });
|
|
38
|
+
writeFileSync(join(state, "backlog.md"), "- open: rescued item | from 2026-09-01-old\n");
|
|
39
|
+
const cards = backlogCards([state]);
|
|
40
|
+
expect(cards.map((c) => [c.source, c.n, c.severity])).toEqual([
|
|
41
|
+
["2026-10-01-src", 3, "high"], ["2026-10-01-src", 1, "low"], ["2026-10-01-src", 5, null], ["orphan", 1, null],
|
|
42
|
+
]);
|
|
43
|
+
expect(cards[1]).toMatchObject({ text: "first thing.", at: "x.ts:12", raw: "`cli/x.ts:12`: low: first thing. Fix: do it." });
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
test("a run started from an item hides its card; an abandoned one gives it back", () => {
|
|
47
|
+
const { state, run } = scratch();
|
|
48
|
+
run("2026-10-01-src", "done", { "06-backlog.md": BACKLOG });
|
|
49
|
+
const kid = run("2026-10-03-kid", "plan", { "00-request.md": "/kaizen urgent thing\n\nParent: 2026-10-01-src\nBacklog: 2026-10-01-src#3\n" });
|
|
50
|
+
expect(startedBacklog(state).get("2026-10-01-src#3")).toEqual([{ id: "2026-10-03-kid", stage: "plan" }]);
|
|
51
|
+
expect(backlogCards([state]).some((c) => c.n === 3)).toBe(false);
|
|
52
|
+
writeFileSync(join(kid, "state.json"), JSON.stringify({ stage: "abandoned" }));
|
|
53
|
+
expect(backlogCards([state]).some((c) => c.n === 3)).toBe(true);
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
test("markBacklogDone closes only the line its done run names, once, and touches no state.json", () => {
|
|
57
|
+
const { state, run } = scratch();
|
|
58
|
+
const src = run("2026-10-01-src", "done", { "06-backlog.md": BACKLOG });
|
|
59
|
+
run("2026-10-03-kid", "build", { "00-request.md": "x\n\nBacklog: 2026-10-01-src#3\n" });
|
|
60
|
+
run("2026-10-04-orph", "done", { "00-request.md": "y\n\nBacklog: orphan#1\n" });
|
|
61
|
+
writeFileSync(join(state, "backlog.md"), "- open: rescued item | from 2026-09-01-old\n");
|
|
62
|
+
markBacklogDone(state);
|
|
63
|
+
expect(readFileSync(join(src, "06-backlog.md"), "utf8")).toBe(BACKLOG); // still building
|
|
64
|
+
expect(readFileSync(join(state, "backlog.md"), "utf8")).toBe("- open: rescued item | from 2026-09-01-old | 2026-10-04-orph\n".replace("open", "done"));
|
|
65
|
+
const kidState = join(state, "runs", "2026-10-03-kid", "state.json");
|
|
66
|
+
writeFileSync(kidState, JSON.stringify({ stage: "done" }));
|
|
67
|
+
const before = statSync(kidState).mtimeMs;
|
|
68
|
+
markBacklogDone(state);
|
|
69
|
+
markBacklogDone(state);
|
|
70
|
+
expect(readFileSync(join(src, "06-backlog.md"), "utf8")).toBe(BACKLOG.replace("- open: high: urgent thing", "- done: high: urgent thing | 2026-10-03-kid"));
|
|
71
|
+
expect(statSync(kidState).mtimeMs).toBe(before);
|
|
72
|
+
expect(backlogCards([state]).map((c) => c.n)).toEqual([1, 5]);
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
test("closeBacklog rewrites one open line in place and keeps CRLF; refuses closed or missing lines", () => {
|
|
76
|
+
const { state, run } = scratch();
|
|
77
|
+
const src = run("2026-10-01-src", "done", { "06-backlog.md": BACKLOG.replace(/\n/g, "\r\n") });
|
|
78
|
+
expect(closeBacklog(state, "2026-10-01-src", 5, "rejected", "not worth it")).toBeNull();
|
|
79
|
+
expect(readFileSync(join(src, "06-backlog.md"), "utf8")).toBe(BACKLOG.replace("- open: plain item", "- rejected: plain item | not worth it").replace(/\n/g, "\r\n"));
|
|
80
|
+
expect(closeBacklog(state, "2026-10-01-src", 2, "rejected", "x")).toBe("that item is already closed");
|
|
81
|
+
expect(closeBacklog(state, "2026-10-01-src", 9, "rejected", "x")).toBe("no such backlog item");
|
|
82
|
+
expect(closeBacklog(state, "../../etc", 1, "rejected", "x")).toBe("no such backlog");
|
|
83
|
+
expect(backlogItem(state, "2026-10-01-src", 5)?.status).toBe("rejected");
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
// The real server in a scratch home. Linux only: launching is made to fail before any
|
|
87
|
+
// terminal or agent opens (no display, no terminal on PATH, terminal launch mode), and
|
|
88
|
+
// the manual command it hands back is the request a run would have been given.
|
|
89
|
+
test.skipIf(process.platform !== "linux")("board routes: /state lists backlog cards, /backlog rejects, /run carries the item's source", async () => {
|
|
90
|
+
const homeDir = mkdtempSync(join(tmpdir(), "kz-bl-home-")), proj = join(homeDir, "p"), state = join(proj, ".kaizen");
|
|
91
|
+
for (const d of [state, join(homeDir, ".kaizen"), join(homeDir, ".claude")]) mkdirSync(d, { recursive: true });
|
|
92
|
+
writeFileSync(join(homeDir, ".kaizen", "projects"), proj + "\n");
|
|
93
|
+
writeFileSync(join(homeDir, ".kaizen", "config.yml"), "agent:\n launch: terminal\n");
|
|
94
|
+
const src = join(state, "runs", "2026-10-01-src");
|
|
95
|
+
mkdirSync(src, { recursive: true });
|
|
96
|
+
writeFileSync(join(src, "state.json"), JSON.stringify({ stage: "done", awaiting: null }));
|
|
97
|
+
writeFileSync(join(src, "06-backlog.md"), BACKLOG);
|
|
98
|
+
const port = 20000 + Math.floor(Math.random() * 20000);
|
|
99
|
+
const repo = join(import.meta.dir, "..");
|
|
100
|
+
const env: Record<string, string> = { HOME: homeDir, USERPROFILE: homeDir, KAIZEN_HOME: repo, PATH: dirname(process.execPath), DISPLAY: "", WAYLAND_DISPLAY: "", TERMINAL: "", TMUX: "" };
|
|
101
|
+
const proc = Bun.spawn([process.execPath, join(repo, "cli", "install.ts"), "web", "--port", String(port), "--no-open", "--foreground"], { cwd: proj, env, stdout: "ignore", stderr: "ignore" });
|
|
102
|
+
try {
|
|
103
|
+
const at = `http://127.0.0.1:${port}`;
|
|
104
|
+
for (let i = 0; ; i++) { try { await fetch(at + "/state"); break; } catch { if (i > 100) throw new Error("board did not start"); await Bun.sleep(50); } }
|
|
105
|
+
const post = async (path: string, body: object) => { const r = await fetch(at + path, { method: "POST", body: JSON.stringify({ dir: state, ...body }) }); return { status: r.status, ...(await r.json() as any) }; };
|
|
106
|
+
const listed = async () => ((await (await fetch(at + "/state?all=1")).json()) as any).backlog.map((b: any) => [b.source, b.n]);
|
|
107
|
+
expect(await listed()).toEqual([["2026-10-01-src", 3], ["2026-10-01-src", 1], ["2026-10-01-src", 5]]);
|
|
108
|
+
|
|
109
|
+
expect(await post("/backlog", { op: "reject", source: "2026-10-01-src", n: 5, reason: "" })).toMatchObject({ status: 400 });
|
|
110
|
+
expect(await post("/backlog", { op: "reject", source: "2026-10-01-src", n: 5, reason: "not now" })).toMatchObject({ status: 200 });
|
|
111
|
+
expect(await post("/backlog", { op: "reject", source: "2026-10-01-src", n: 5, reason: "again" })).toMatchObject({ status: 409 });
|
|
112
|
+
expect(await post("/backlog", { op: "reject", source: "2026-10-01-src", n: 42, reason: "x" })).toMatchObject({ status: 404 });
|
|
113
|
+
expect(readFileSync(join(src, "06-backlog.md"), "utf8")).toContain("- rejected: plain item | not now\n");
|
|
114
|
+
expect(await listed()).toEqual([["2026-10-01-src", 3], ["2026-10-01-src", 1]]);
|
|
115
|
+
|
|
116
|
+
expect(await post("/run", { backlog: { source: "2026-10-01-src", n: 2 } })).toMatchObject({ status: 409 });
|
|
117
|
+
expect(await post("/run", { backlog: { source: "2026-10-01-src", n: 42 } })).toMatchObject({ status: 404 });
|
|
118
|
+
const r = await post("/run", { kind: "lite", backlog: { source: "2026-10-01-src", n: 1 } });
|
|
119
|
+
expect(r).toMatchObject({ status: 500, ok: false });
|
|
120
|
+
expect(r.manual).toContain("/kaizen lite first thing. (x.ts:12)");
|
|
121
|
+
expect(r.manual).toContain("Parent: 2026-10-01-src");
|
|
122
|
+
expect(r.manual).toContain("Backlog: 2026-10-01-src#1");
|
|
123
|
+
expect(r.manual).toContain("Backlog item: `cli/x.ts:12`: low: first thing. Fix: do it.");
|
|
124
|
+
|
|
125
|
+
// Once a run carries the line, a second start is refused and the card is gone.
|
|
126
|
+
const kid = join(state, "runs", "2026-10-05-kid");
|
|
127
|
+
mkdirSync(kid, { recursive: true });
|
|
128
|
+
writeFileSync(join(kid, "state.json"), JSON.stringify({ stage: "plan", awaiting: null }));
|
|
129
|
+
writeFileSync(join(kid, "00-request.md"), "first thing\n\nParent: 2026-10-01-src\nBacklog: 2026-10-01-src#1\n");
|
|
130
|
+
expect(await post("/run", { backlog: { source: "2026-10-01-src", n: 1 } })).toMatchObject({ status: 409 });
|
|
131
|
+
expect(await listed()).toEqual([["2026-10-01-src", 3]]);
|
|
132
|
+
} finally { proc.kill(); await proc.exited; }
|
|
133
|
+
}, 30_000);
|
package/cli/board.test.ts
CHANGED
|
@@ -22,7 +22,8 @@ test("Settings rechecks an existing update alert and clears or refreshes it", as
|
|
|
22
22
|
getElementById: (id: string) => id === "pbody" ? body : elements.get(id),
|
|
23
23
|
createElement: () => ({
|
|
24
24
|
isConnected: false,
|
|
25
|
-
set innerHTML(
|
|
25
|
+
set innerHTML(v: unknown) {
|
|
26
|
+
const html = String(v);
|
|
26
27
|
this.html = html;
|
|
27
28
|
elements.clear();
|
|
28
29
|
for (const [, id] of html.matchAll(/id="([^"]+)"/g)) elements.set(id, { textContent: "", disabled: false, focus() {} });
|
|
@@ -154,7 +155,7 @@ test("card menu items: appropriate actions and distinct icons across phases", ()
|
|
|
154
155
|
|
|
155
156
|
const ico = (n: string) => `<svg><use href="#i-${n}"/></svg>`;
|
|
156
157
|
const from = cards.indexOf("if (k === store.menuKey) {");
|
|
157
|
-
const menuSnippet = (cards.slice(from, cards.indexOf('el.innerHTML += `<div class="menu-pop"', from)) + "}").replace("const items = [];", "items = [];");
|
|
158
|
+
const menuSnippet = (cards.slice(from, cards.indexOf('el.innerHTML += html`<div class="menu-pop"', from)) + "}").replace("const items = [];", "items = [];");
|
|
158
159
|
const a0 = cards.indexOf("const actionOf =");
|
|
159
160
|
const actionOf = cards.slice(a0, cards.indexOf("\n\n", a0));
|
|
160
161
|
const getItems = new Function("c", "archive", "S", `const ico = ${ico.toString()};\nconst decided = new Set(), answer = () => "";\n${actionOf}\nconst store = { S, menuKey: 1 };\nlet k = 1, items = [];\n${menuSnippet}\nreturn items;`);
|
|
@@ -178,13 +179,18 @@ test("card menu items: appropriate actions and distinct icons across phases", ()
|
|
|
178
179
|
const startItems = getItems(startingIdea, false);
|
|
179
180
|
expect(startItems.map((i: any) => i ? i[1] : null)).toEqual(["Open", "Run again", "Archive", null, "Remove"]);
|
|
180
181
|
|
|
182
|
+
// Ideas column, Backlog tab: a run's leftover, run like an idea, rejected with a reason.
|
|
183
|
+
expect(getItems({ kind: "backlog", status: "idea", column: 0 }, false).map((i: any) => i ? i[1] : null)).toEqual(["Run", "Open its run", null, "Reject"]);
|
|
184
|
+
expect(getItems({ kind: "backlog", status: "starting", column: 0 }, false)[0][1]).toBe("Run again");
|
|
185
|
+
|
|
181
186
|
// Phase 1: Planning (run waiting for plan approval)
|
|
182
187
|
const planWaiting = { kind: "run", status: "waiting", awaiting: "approvals.plan", column: 1 };
|
|
183
188
|
const planItems = getItems(planWaiting, false);
|
|
184
|
-
expect(planItems.map((i: any) => i ? i[1] : null)).toEqual(["Review plan", "Archive", null, "Abandon"]);
|
|
189
|
+
expect(planItems.map((i: any) => i ? i[1] : null)).toEqual(["Review plan", "Archive", "Back to idea", null, "Abandon"]);
|
|
185
190
|
expect(planItems[0][2]).toBe(ico("open"));
|
|
186
191
|
expect(planItems[1][2]).toBe(ico("archive"));
|
|
187
|
-
expect(planItems[
|
|
192
|
+
expect(planItems[2][2]).toBe(ico("restore"));
|
|
193
|
+
expect(planItems[4][2]).toBe(ico("x"));
|
|
188
194
|
|
|
189
195
|
// Phase 2: Building (running run)
|
|
190
196
|
const buildingRun = { kind: "run", status: "running", column: 2 };
|
package/cli/browser.test.ts
CHANGED
|
@@ -18,7 +18,7 @@ const run = async (code: string, env: Record<string, string> = {}) => {
|
|
|
18
18
|
const script = `import * as br from ${JSON.stringify(join(repo, "cli/browser.ts"))};
|
|
19
19
|
Object.assign(process.env, ${JSON.stringify({ HOME: process.env.HOME ?? "", USERPROFILE: process.env.USERPROFILE ?? "" })});
|
|
20
20
|
console.log(JSON.stringify(await (async () => { ${code} })()));`;
|
|
21
|
-
const p = Bun.spawn(["bun", "-e", script], { cwd: home, env: { ...process.env, HOME: home, USERPROFILE: home, ...env }, stdout: "pipe", stderr: "pipe" });
|
|
21
|
+
const p = Bun.spawn(["bun", "-e", script], { cwd: home, env: { ...process.env, HOME: home, USERPROFILE: home, KAIZEN_BROWSER_PACE: "0.1", ...env }, stdout: "pipe", stderr: "pipe" });
|
|
22
22
|
const [out, err, code_] = [await new Response(p.stdout).text(), await new Response(p.stderr).text(), await p.exited];
|
|
23
23
|
if (code_ !== 0) throw new Error(err);
|
|
24
24
|
return JSON.parse(out.trim().split("\n").at(-1)!);
|
package/cli/browser.ts
CHANGED
|
@@ -30,6 +30,8 @@ export const POST: Record<string, RegExp> = {
|
|
|
30
30
|
};
|
|
31
31
|
// Grids show thumbnails, not text: each post page is opened for its caption.
|
|
32
32
|
const GRID = new Set(["instagram"]);
|
|
33
|
+
// The fixed waits below are sized for real platforms; KAIZEN_BROWSER_PACE scales them (the tests run at 0.1).
|
|
34
|
+
const nap = (ms: number) => Bun.sleep(ms * (Number(process.env.KAIZEN_BROWSER_PACE) || 1));
|
|
33
35
|
|
|
34
36
|
// KAIZEN_BROWSER, else the usual names on PATH, else the usual install paths.
|
|
35
37
|
export function findBrowser(): string | null {
|
|
@@ -97,9 +99,9 @@ export class Page {
|
|
|
97
99
|
async go(url: string) {
|
|
98
100
|
await this.call("Page.navigate", { url });
|
|
99
101
|
const end = Date.now() + 20_000;
|
|
100
|
-
await
|
|
101
|
-
while (Date.now() < end && (await this.js("document.readyState")) !== "complete") await
|
|
102
|
-
await
|
|
102
|
+
await nap(1500);
|
|
103
|
+
while (Date.now() < end && (await this.js("document.readyState")) !== "complete") await nap(250);
|
|
104
|
+
await nap(2000); // the posts arrive after the page itself
|
|
103
105
|
}
|
|
104
106
|
close() { try { this.ws.close(); } catch { /* already gone */ } }
|
|
105
107
|
}
|
|
@@ -200,7 +202,10 @@ export async function browse<T>(use: (page: Page) => Promise<T>): Promise<T | {
|
|
|
200
202
|
} finally {
|
|
201
203
|
page?.close();
|
|
202
204
|
proc.kill();
|
|
203
|
-
|
|
205
|
+
// A cleared timer, not Bun.sleep: a pending sleep keeps the process alive 5 s after Chrome is gone.
|
|
206
|
+
let t: Timer | undefined;
|
|
207
|
+
await Promise.race([proc.exited, new Promise((r) => { t = setTimeout(r, 5000); })]);
|
|
208
|
+
clearTimeout(t);
|
|
204
209
|
if (proc.exitCode === null) proc.kill(9);
|
|
205
210
|
}
|
|
206
211
|
}
|
|
@@ -230,7 +235,7 @@ export async function readPage(page: Page, url: string, platform: string, max =
|
|
|
230
235
|
// A page that renders its posts late is not empty: stop early only once some arrived.
|
|
231
236
|
still = seen.size && seen.size === before ? still + 1 : 0;
|
|
232
237
|
await page.js("window.scrollBy(0, window.innerHeight * 1.5)");
|
|
233
|
-
await
|
|
238
|
+
await nap(1500);
|
|
234
239
|
}
|
|
235
240
|
let found = [...seen.values()].slice(0, max);
|
|
236
241
|
if (GRID.has(platform)) {
|
|
@@ -310,12 +315,12 @@ export async function replyOn(page: Page, link: string, platform: string, text:
|
|
|
310
315
|
if (!(await page.js<boolean>(FIND(how.box, "focus"))))
|
|
311
316
|
return { ok: false, note: (await page.js<boolean>(OFFERS)) ? signInNote(platform) : `Could not find the reply box on ${name}. Nothing was posted.` };
|
|
312
317
|
await page.call("Input.insertText", { text });
|
|
313
|
-
await
|
|
318
|
+
await nap(800);
|
|
314
319
|
if (!(await here())) return { ok: false, note: `The page left ${name}. Nothing was posted.` };
|
|
315
320
|
if (!(await page.js<boolean>(FIND(how.send, "click")))) return { ok: false, note: `Could not find ${name}'s send button. Nothing was posted.` };
|
|
316
321
|
// Sent once the box is gone or emptied; still holding the text means it did not go.
|
|
317
322
|
for (let i = 0; i < 20; i++) {
|
|
318
|
-
await
|
|
323
|
+
await nap(500);
|
|
319
324
|
const left = await page.js<string | null>(BOX);
|
|
320
325
|
if (left === null || !left.trim()) return { ok: true };
|
|
321
326
|
}
|
package/cli/html.test.ts
ADDED
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
import { expect, test } from "bun:test";
|
|
2
|
+
import { html, ico, raw } from "../web/board/dom.ts";
|
|
3
|
+
|
|
4
|
+
test("html escapes interpolations once and keeps trusted markup", () => {
|
|
5
|
+
const bad = '<img src=x onerror=1> & "q"';
|
|
6
|
+
const out = String(html`<b title="${bad}">${bad}</b>`);
|
|
7
|
+
expect(out).not.toContain("<img");
|
|
8
|
+
expect(out).toContain("<img src=x onerror=1> & "q"");
|
|
9
|
+
expect(String(html`${"a&b"}`)).toBe("a&b");
|
|
10
|
+
expect(String(html`${raw("<i>")}${ico("x")}`)).toBe('<i><svg><use href="#i-x"/></svg>');
|
|
11
|
+
expect(String(html`<ul>${[1, 2].map((n) => html`<li>${n}</li>`)}</ul>`)).toBe("<ul><li>1</li><li>2</li></ul>");
|
|
12
|
+
expect(String(html`${["<a>"]}`)).toBe("<a>");
|
|
13
|
+
expect(String(html`[${null}${undefined}${false}]`)).toBe("[]");
|
|
14
|
+
expect(String(html`${html`<u>${"&"}</u>`}`)).toBe("<u>&</u>");
|
|
15
|
+
});
|
|
16
|
+
|
|
17
|
+
test("aria states render false explicitly when stringified", () => {
|
|
18
|
+
expect(String(html`<b aria-pressed="${String(false)}" aria-checked="${String(true)}">`)).toBe('<b aria-pressed="false" aria-checked="true">');
|
|
19
|
+
});
|
package/cli/notes.ts
CHANGED
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
// the page goes through safeName before it becomes a path.
|
|
4
4
|
import { existsSync, mkdirSync, readdirSync, realpathSync, renameSync, rmdirSync, statSync, writeFileSync } from "node:fs";
|
|
5
5
|
import { join, dirname, resolve, sep } from "node:path";
|
|
6
|
-
import { boardCards, reviewFindings, readText } from "./state.ts";
|
|
6
|
+
import { backlogLines, boardCards, reviewFindings, readText } from "./state.ts";
|
|
7
7
|
import { wikilinks, boardLink, resolveRun } from "../web/notes-lib.ts";
|
|
8
8
|
|
|
9
9
|
export type Note = { name: string; mtime: number; text: string };
|
|
@@ -114,11 +114,7 @@ function prune(state: string, dir: string) {
|
|
|
114
114
|
// project keeps hundreds of runs.
|
|
115
115
|
export function boardIndex(state: string) {
|
|
116
116
|
const cards = boardCards([state], Date.now());
|
|
117
|
-
const backlog = (id: string) => {
|
|
118
|
-
let text = "";
|
|
119
|
-
try { text = readText(join(state, "runs", id, "06-backlog.md")); } catch { /* none */ }
|
|
120
|
-
return [...text.matchAll(/^\s*-\s*(open|done|rejected):\s*(.*)$/gm)].map((m, i) => ({ n: i + 1, status: m[1]!, text: m[2]!.trim() }));
|
|
121
|
-
};
|
|
117
|
+
const backlog = (id: string) => backlogLines(join(state, "runs", id, "06-backlog.md")).map(({ n, status, text }) => ({ n, status, text }));
|
|
122
118
|
return {
|
|
123
119
|
runs: cards.filter((c) => c.kind === "run").map((c) => ({
|
|
124
120
|
id: c.id!, short: c.id!.replace(/^\d{4}-\d{2}-\d{2}-/, ""), title: c.title ?? c.text, status: c.status,
|
package/cli/pty.ts
CHANGED
|
@@ -81,7 +81,8 @@ export function startPty(key: string, cmd: string[], cwd: string, pidFile?: stri
|
|
|
81
81
|
for (const f of p.listeners) f(null);
|
|
82
82
|
p.listeners.clear();
|
|
83
83
|
try { p.proc.terminal?.close(); } catch { /* already closed */ }
|
|
84
|
-
|
|
84
|
+
// Dropped: pages showing it (the Terminal page) refresh, or its tile lingers until some other change.
|
|
85
|
+
setTimeout(() => { if (ptys.get(key) === p) { ptys.delete(key); onChange?.(); } }, KEEP_MS).unref();
|
|
85
86
|
onChange?.();
|
|
86
87
|
});
|
|
87
88
|
return p.proc.pid;
|
package/cli/settings.test.ts
CHANGED
|
@@ -6,6 +6,7 @@ import { test, expect, beforeAll, afterAll } from "bun:test";
|
|
|
6
6
|
import { mkdtempSync, mkdirSync, writeFileSync, readFileSync, copyFileSync, rmSync } from "node:fs";
|
|
7
7
|
import { join, dirname } from "node:path";
|
|
8
8
|
import { tmpdir } from "node:os";
|
|
9
|
+
import { applyPreset, detectPreset } from "./settings.ts";
|
|
9
10
|
|
|
10
11
|
const repo = join(dirname(import.meta.path), "..");
|
|
11
12
|
const defaults = join(repo, "skills/kaizen/config.default.yml");
|
|
@@ -48,3 +49,40 @@ test("board settings save to the global config, project settings to the project"
|
|
|
48
49
|
expect(inProject(`return setSetting(repo, "git.auto_commit", "true")`)).toEqual({ ok: true });
|
|
49
50
|
expect(readFileSync(join(project, ".kaizen", "config.yml"), "utf8")).toMatch(/auto_commit: true/);
|
|
50
51
|
});
|
|
52
|
+
|
|
53
|
+
test("every row is a key in config.default.yml, and every `when` names a row and one of its values", () => {
|
|
54
|
+
const rows = inProject(`return listSettings(repo).rows`);
|
|
55
|
+
const text = readFileSync(defaults, "utf8");
|
|
56
|
+
for (const r of rows) {
|
|
57
|
+
const [top, leaf] = r.key.split(".");
|
|
58
|
+
const block = leaf ? new RegExp(`^${top}:\\n(?:(?: .*|\\s*)\\n)*? ${leaf}:`, "m") : new RegExp(`^${top}:`, "m");
|
|
59
|
+
expect(block.test(text) ? r.key : `${r.key} missing`).toBe(r.key);
|
|
60
|
+
if (!r.when) continue;
|
|
61
|
+
const dep = rows.find((x) => x.key === r.when[0]);
|
|
62
|
+
expect(dep?.values).toContain(r.when[1]);
|
|
63
|
+
}
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
test("a row dims while the setting it depends on has it switched off, and a leftover dead key changes nothing", () => {
|
|
67
|
+
const conf = join(project, ".kaizen", "config.yml");
|
|
68
|
+
// The low preset as an older config holds it, with keys kaizen no longer reads.
|
|
69
|
+
writeFileSync(conf, readFileSync(defaults, "utf8")
|
|
70
|
+
.replace("enabled: true\n # critical", "enabled: false\n # critical")
|
|
71
|
+
.replace("git:\n", "git:\n branch_before_implement: false\n")
|
|
72
|
+
+ "\ntrack:\n default: software\n");
|
|
73
|
+
const idle = (k: string) => inProject(`return listSettings(repo).rows.find((r) => r.key === ${JSON.stringify(k)}).idle`);
|
|
74
|
+
expect(idle("auto_fix.min_severity")).toBe("No effect unless Fix findings automatically is on");
|
|
75
|
+
expect(idle("auto_fix.enabled")).toBe("");
|
|
76
|
+
expect(inProject(`return setSetting(repo, "auto_fix.max_iterations", "3")`)).toEqual({ ok: true });
|
|
77
|
+
expect(inProject(`return setSetting(repo, "auto_fix.enabled", "true")`)).toEqual({ ok: true });
|
|
78
|
+
expect(idle("auto_fix.min_severity")).toBe("");
|
|
79
|
+
expect(inProject(`return value("auto_fix.max_iterations")`)).toBe("3");
|
|
80
|
+
copyFileSync(defaults, conf);
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
test("a config still holding git.branch_before_implement keeps its preset", () => {
|
|
84
|
+
const text = readFileSync(defaults, "utf8");
|
|
85
|
+
const low = applyPreset(text, "low", text).replace("git:\n", "git:\n branch_before_implement: true\n");
|
|
86
|
+
expect(detectPreset(low, text)).toBe("low");
|
|
87
|
+
expect(detectPreset(text, text)).toBe("medium");
|
|
88
|
+
});
|
package/cli/settings.ts
CHANGED
|
@@ -6,7 +6,10 @@ import { homedir } from "node:os";
|
|
|
6
6
|
import { readText } from "./state.ts";
|
|
7
7
|
|
|
8
8
|
// `text` is a value typed rather than chosen: a path. It has no values to cycle.
|
|
9
|
-
|
|
9
|
+
// `when`: the row only has an effect while that other key holds that value; both
|
|
10
|
+
// settings screens dim it otherwise, and it stays editable (`/kaizen full` overrides
|
|
11
|
+
// the runner for one run, so a subagent model still matters under `runner: lite`).
|
|
12
|
+
type Setting = { key: string; label: string; group: string; values: string[]; help: string; text?: boolean; folder?: boolean; when?: [string, string] };
|
|
10
13
|
|
|
11
14
|
export type PresetName = "low" | "medium" | "ultra";
|
|
12
15
|
|
|
@@ -29,7 +32,6 @@ export const PRESETS: Record<PresetName, {
|
|
|
29
32
|
"auto_fix.min_severity": "high",
|
|
30
33
|
"auto_fix.max_iterations": "1",
|
|
31
34
|
"git.auto_commit": "false",
|
|
32
|
-
"git.branch_before_implement": "false",
|
|
33
35
|
"git.worktree_per_run": "false",
|
|
34
36
|
},
|
|
35
37
|
},
|
|
@@ -47,7 +49,6 @@ export const PRESETS: Record<PresetName, {
|
|
|
47
49
|
"auto_fix.min_severity": "high",
|
|
48
50
|
"auto_fix.max_iterations": "2",
|
|
49
51
|
"git.auto_commit": "false",
|
|
50
|
-
"git.branch_before_implement": "true",
|
|
51
52
|
"git.worktree_per_run": "true",
|
|
52
53
|
},
|
|
53
54
|
},
|
|
@@ -65,7 +66,6 @@ export const PRESETS: Record<PresetName, {
|
|
|
65
66
|
"auto_fix.min_severity": "low",
|
|
66
67
|
"auto_fix.max_iterations": "4",
|
|
67
68
|
"git.auto_commit": "true",
|
|
68
|
-
"git.branch_before_implement": "true",
|
|
69
69
|
"git.worktree_per_run": "true",
|
|
70
70
|
},
|
|
71
71
|
},
|
|
@@ -99,31 +99,29 @@ export function applyPreset(text: string, preset: PresetName, defaults: string):
|
|
|
99
99
|
|
|
100
100
|
const SETTINGS: Setting[] = [
|
|
101
101
|
{ key: "preset", label: "Preset", group: "Preset", values: ["low", "medium", "ultra", "custom"], help: "" },
|
|
102
|
-
{ key: "mode", label: "
|
|
103
|
-
{ key: "approvals.plan", label: "Approve the plan first", group: "Approvals", values: ["true", "false"], help: "" },
|
|
104
|
-
{ key: "approvals.review", label: "Approve after review", group: "Approvals", values: ["true", "false"], help: "" },
|
|
105
|
-
{ key: "approvals.each_file", label: "Confirm every file edit", group: "Approvals", values: ["true", "false"], help: "" },
|
|
102
|
+
{ key: "mode", label: "Run mode", group: "Approvals", values: ["approve", "auto", "plan-only", "review-only"], help: "" },
|
|
103
|
+
{ key: "approvals.plan", label: "Approve the plan first", group: "Approvals", values: ["true", "false"], help: "", when: ["mode", "approve"] },
|
|
104
|
+
{ key: "approvals.review", label: "Approve after review", group: "Approvals", values: ["true", "false"], help: "", when: ["mode", "approve"] },
|
|
105
|
+
{ key: "approvals.each_file", label: "Confirm every file edit", group: "Approvals", values: ["true", "false"], help: "", when: ["mode", "approve"] },
|
|
106
106
|
{ key: "runner", label: "Stage isolation", group: "Agents", values: ["full", "lite"], help: "full: separate agent per stage. lite: one session" },
|
|
107
|
-
{ key: "build.executor", label: "Who builds", group: "Agents", values: ["subagent", "inline", "ask"], help: "" },
|
|
107
|
+
{ key: "build.executor", label: "Who builds", group: "Agents", values: ["subagent", "inline", "ask"], help: "subagent: its own agent · inline: this session · ask: pick at plan approval", when: ["runner", "full"] },
|
|
108
108
|
{ key: "agent.default", label: "Tool the board launches", group: "Agents", values: ["auto", "claude", "codex", "agy", "opencode", "gemini"], help: "" },
|
|
109
109
|
{ key: "agent.launch", label: "Where the board runs agents", group: "Agents", values: ["card", "terminal"], help: "" },
|
|
110
|
-
{ key: "subagents.model", label: "Model for
|
|
111
|
-
{ key: "subagents.effort", label: "Effort for
|
|
110
|
+
{ key: "subagents.model", label: "Model for subagents", group: "Agents", values: ["inherit", "opus", "sonnet", "haiku"], help: "Planner, builder, reviewer", when: ["runner", "full"] },
|
|
111
|
+
{ key: "subagents.effort", label: "Effort for subagents", group: "Agents", values: ["inherit", "low", "medium", "high"], help: "Planner, builder, reviewer", when: ["runner", "full"] },
|
|
112
112
|
{ key: "auto_fix.enabled", label: "Fix findings automatically", group: "Auto-fix", values: ["true", "false"], help: "" },
|
|
113
|
-
{ key: "auto_fix.min_severity", label: "
|
|
114
|
-
{ key: "auto_fix.max_iterations", label: "Max fix rounds", group: "Auto-fix", values: ["1", "2", "3", "4", "5"], help: "" },
|
|
113
|
+
{ key: "auto_fix.min_severity", label: "Lowest severity to fix", group: "Auto-fix", values: ["critical", "high", "medium", "low"], help: "", when: ["auto_fix.enabled", "true"] },
|
|
114
|
+
{ key: "auto_fix.max_iterations", label: "Max fix rounds", group: "Auto-fix", values: ["1", "2", "3", "4", "5"], help: "", when: ["auto_fix.enabled", "true"] },
|
|
115
115
|
{ key: "git.auto_commit", label: "Commit when done", group: "Git", values: ["false", "true"], help: "Never pushes" },
|
|
116
|
-
{ key: "git.branch_before_implement", label: "Branch before building", group: "Git", values: ["true", "false"], help: "" },
|
|
117
116
|
{ key: "git.worktree_per_run", label: "Branch and folder per run", group: "Git", values: ["true", "false"], help: "" },
|
|
118
|
-
{ key: "track.default", label: "Kind of work", group: "Project", values: ["auto", "software", "writing", "communication", "operations", "research"], help: "" },
|
|
119
117
|
{ key: "review.write_memory", label: "Remember lessons", group: "Project", values: ["true", "false"], help: "" },
|
|
120
118
|
{ key: "review.memory_max_lines", label: "Memory size cap", group: "Project", values: ["50", "100", "200", "400"], help: "" },
|
|
121
119
|
{ key: "state.keep_runs", label: "Runs to keep", group: "Project", values: ["5", "10", "20", "50"], help: "" },
|
|
122
120
|
{ key: "dictation.model", label: "Dictation model", group: "Board", values: ["tiny", "base", "small", "large-v3-turbo"], help: "" },
|
|
123
121
|
{ key: "sources.every", label: "Gather requests", group: "Board", values: ["off", "1h", "6h", "24h"], help: "" },
|
|
124
|
-
{ key: "sources.agent", label: "Who
|
|
125
|
-
{ key: "sources.model", label: "ChatGPT model for gathering", group: "Board", values: [], text: true, help: "Empty: plan default" },
|
|
126
|
-
{ key: "sources.effort", label: "ChatGPT effort for gathering", group: "Board", values: ["low", "medium", "high"], help: "" },
|
|
122
|
+
{ key: "sources.agent", label: "Who picks social posts", group: "Board", values: ["ask", "chatgpt", "agent"], help: "X, TikTok, Instagram, Threads" },
|
|
123
|
+
{ key: "sources.model", label: "ChatGPT model for gathering", group: "Board", values: [], text: true, help: "Empty: plan default", when: ["sources.agent", "chatgpt"] },
|
|
124
|
+
{ key: "sources.effort", label: "ChatGPT effort for gathering", group: "Board", values: ["low", "medium", "high"], help: "", when: ["sources.agent", "chatgpt"] },
|
|
127
125
|
{ key: "board.projects_dir", label: "Projects folder", group: "Board", values: [], text: true, folder: true, help: "Empty: known projects only" },
|
|
128
126
|
{ key: "board.open", label: "Open the board in", group: "Board", values: ["browser", "app"], help: "" },
|
|
129
127
|
{ key: "ui.mouse", label: "Enable mouse", group: "Terminal", values: ["false", "true"], help: "" },
|
|
@@ -137,13 +135,18 @@ export function listSettings(repoRoot: string) {
|
|
|
137
135
|
const load = (f: string) => existsSync(f) ? readText(f) : "";
|
|
138
136
|
const text = load(file), board = load(globalFile());
|
|
139
137
|
const preset = detectPreset(text, defaults);
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
138
|
+
const rows = SETTINGS.map((s) => ({
|
|
139
|
+
...s,
|
|
140
|
+
value: s.key === "preset" ? preset : (read(s.group === "Board" ? board : text, s.key) || read(defaults, s.key)) ?? s.values[0] ?? "",
|
|
141
|
+
}));
|
|
142
|
+
const valueOf = (k: string) => rows.find((r) => r.key === k)?.value ?? null;
|
|
143
|
+
return { file: tilde(file), rows: rows.map((r) => ({ ...r, idle: idle(r, valueOf) })) };
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
// Why a row has no effect right now, or "" while it has one: its `when` is not met.
|
|
147
|
+
function idle(s: Setting, valueOf: (key: string) => string | null) {
|
|
148
|
+
if (!s.when || valueOf(s.when[0]) === s.when[1]) return "";
|
|
149
|
+
return `No effect unless ${SETTINGS.find((x) => x.key === s.when![0])!.label} is ${s.when[1] === "true" ? "on" : s.when[1]}`;
|
|
147
150
|
}
|
|
148
151
|
|
|
149
152
|
export function setSetting(repoRoot: string, key: string, value: string) {
|
|
@@ -217,13 +220,17 @@ export async function settings(repoRoot: string, standalone = true) {
|
|
|
217
220
|
}
|
|
218
221
|
return v;
|
|
219
222
|
};
|
|
223
|
+
const valueOf = (k: string) => {
|
|
224
|
+
const d = SETTINGS.find((x) => x.key === k)!;
|
|
225
|
+
return read(fileFor(d) === file ? text : readText(fileFor(d)), k) || read(defaults, k);
|
|
226
|
+
};
|
|
220
227
|
const rows = SETTINGS.map((s) => ({
|
|
221
228
|
s,
|
|
222
|
-
|
|
229
|
+
idle: idle(s, valueOf),
|
|
223
230
|
// The preset as a game's quick preset: arrows either side, the tier lit after it. No colour codes: the selected row wraps this in inverse, which a reset would end.
|
|
224
231
|
value: s.key === "preset"
|
|
225
232
|
? `◁ ${currentPreset.padEnd(6)} ▷ ${(["low", "medium", "ultra"] as const).map((p) => p === currentPreset ? "━" : "─").join("")}`
|
|
226
|
-
: show(s,
|
|
233
|
+
: show(s, read(fileFor(s) === file ? text : readText(fileFor(s)), s.key) ?? c.dim("(default)")),
|
|
227
234
|
}));
|
|
228
235
|
const width = Math.max(...SETTINGS.map((s) => s.label.length)) + 4;
|
|
229
236
|
|
|
@@ -234,7 +241,7 @@ export async function settings(repoRoot: string, standalone = true) {
|
|
|
234
241
|
rowAt = [];
|
|
235
242
|
let line = 2, group = "";
|
|
236
243
|
const list = shown();
|
|
237
|
-
for (const [i, { s, value }] of rows.entries()) {
|
|
244
|
+
for (const [i, { s, value, idle: off }] of rows.entries()) {
|
|
238
245
|
if (!list.includes(i)) continue;
|
|
239
246
|
if (s.group !== group) {
|
|
240
247
|
group = s.group;
|
|
@@ -245,14 +252,14 @@ export async function settings(repoRoot: string, standalone = true) {
|
|
|
245
252
|
const name = s.label.padEnd(width);
|
|
246
253
|
stdout.write(on
|
|
247
254
|
? ` ${c.cyan("›")} ${c.bold(name)}${c.inv(` ${value} `)}\n`
|
|
248
|
-
: ` ${c.dim(name)}${value}\n`);
|
|
255
|
+
: ` ${c.dim(name)}${off ? c.dim(value) : value}\n`);
|
|
249
256
|
rowAt[line++] = i;
|
|
250
257
|
}
|
|
251
258
|
const cur = SETTINGS[active]!;
|
|
252
259
|
if (!list.length) stdout.write(`\n ${c.dim(`no setting matches "${query}"`)}\n\n`);
|
|
253
260
|
else {
|
|
254
261
|
stdout.write(`\n ${c.dim(cur.key)} ${c.dim("options:")} ${cur.values.map((v) => v || '""').join(c.dim(" / "))}\n`);
|
|
255
|
-
stdout.write(` ${cur.help}\n`);
|
|
262
|
+
stdout.write(` ${[cur.help, rows[active]!.idle].filter(Boolean).join(" · ")}\n`);
|
|
256
263
|
}
|
|
257
264
|
stdout.write(`\n ${c.dim(typing
|
|
258
265
|
? "type to filter · enter or ↑↓ keep it · esc clear"
|