kaizen-agent 1.39.0 → 1.40.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/pty.test.ts +9 -1
- package/cli/pty.ts +27 -5
- package/cli/settings.ts +29 -29
- package/cli/sources.test.ts +1 -1
- package/cli/state.test.ts +52 -9
- package/cli/state.ts +40 -11
- package/cli/web.ts +3 -1
- package/package.json +1 -1
- package/skills/kaizen/spec-build.md +6 -1
- package/skills/kaizen/spec-main.md +2 -1
- package/web/board/cards.ts +3 -1
- package/web/board/icebox.ts +14 -15
- package/web/board/settings.ts +11 -31
- package/web/board/update.ts +1 -1
- package/web/board.html +7 -4
- package/web/board.js +23 -23
- package/web/notebook.js +1 -1
- package/web/term.js +1 -1
package/cli/pty.test.ts
CHANGED
|
@@ -49,7 +49,7 @@ test.skipIf(process.platform === "win32")("the board runs the agent in the card:
|
|
|
49
49
|
expect(r.ok).toBe(true);
|
|
50
50
|
expect(r.pty).toMatch(/^[\w-]+$/);
|
|
51
51
|
const card = ((await (await fetch(at + "/state")).json()) as any).cards.find((c: any) => c.text === "say hello");
|
|
52
|
-
expect(card.pty).toEqual({ key: r.pty, alive: true });
|
|
52
|
+
expect(card.pty).toEqual({ key: r.pty, alive: true, asking: false });
|
|
53
53
|
|
|
54
54
|
// G-01: no Origin, a foreign one, and another local port are all refused.
|
|
55
55
|
const url = `${at}/pty?key=${r.pty}`, up = { upgrade: "websocket", connection: "Upgrade", "sec-websocket-version": "13", "sec-websocket-key": "dGhlIHNhbXBsZSBub25jZQ==" };
|
|
@@ -186,3 +186,11 @@ test.skipIf(process.platform === "win32")("the done sweep keeps a session the us
|
|
|
186
186
|
expect(alive(used)).toBe(true);
|
|
187
187
|
process.kill(used, "SIGTERM");
|
|
188
188
|
});
|
|
189
|
+
|
|
190
|
+
test("asking: Claude Code and Codex prompts count as a question, ordinary output does not", async () => {
|
|
191
|
+
const { asking } = await import("./pty.ts");
|
|
192
|
+
expect(asking("\x1b[1mWould you like to run the following command?\x1b[0m")).toBe(true);
|
|
193
|
+
expect(asking("What behavior do you want?\r\n\x1b[36m❯ 1. Retry with backoff\x1b[0m\r\n 2. Fail\r\n 4. Type something.")).toBe(true);
|
|
194
|
+
expect(asking("Do you want to make this edit to cli/pty.ts?")).toBe(true);
|
|
195
|
+
expect(asking("Reading cli/pty.ts\r\n\x1b[2K✻ Thinking… 1. step one done")).toBe(false);
|
|
196
|
+
});
|
package/cli/pty.ts
CHANGED
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
// window ends the agent in it. Bun has no pseudo-terminal on Windows.
|
|
5
5
|
import { writeFileSync } from "node:fs";
|
|
6
6
|
|
|
7
|
-
type Pty = { proc: Bun.Subprocess; out: string[]; size: number; listeners: Set<(d: string | null) => void>; exit: number | null };
|
|
7
|
+
type Pty = { proc: Bun.Subprocess; out: string[]; size: number; listeners: Set<(d: string | null) => void>; exit: number | null; asking: boolean; quiet?: Timer; total: number; since: number; onChange?: () => void };
|
|
8
8
|
|
|
9
9
|
// What a page attaching late gets replayed. A full-screen tool redraws often, so the
|
|
10
10
|
// tail of its output is enough to rebuild the screen.
|
|
@@ -13,21 +13,38 @@ const CAP = 512 * 1024;
|
|
|
13
13
|
const KEEP_MS = 10 * 60_000;
|
|
14
14
|
|
|
15
15
|
const ptys = new Map<string, Pty>();
|
|
16
|
+
|
|
17
|
+
// A question the agent stopped on: Claude Code's and Codex's approval and choice prompts.
|
|
18
|
+
// ponytail: screen-text heuristic, misses a prompt worded otherwise; read agent hooks if that bites.
|
|
19
|
+
const ASK = /Would you like to|Do you want to|[❯›>] ?1\. |Type something|\(y\/n\)|\[y\/N\]|Press enter to/i;
|
|
20
|
+
// Escape sequences out; a cursor move becomes a space so words drawn apart stay apart.
|
|
21
|
+
export const asking = (screen: string) =>
|
|
22
|
+
ASK.test(screen.replace(/\x1b\[[0-9;?]*[A-Za-z]|\x1b\][^\x07\x1b]*(?:\x07|\x1b\\)|\x1b[()][0-9A-Za-z]/g, " "));
|
|
23
|
+
// How long output stays still before the screen is read for a question.
|
|
24
|
+
const QUIET_MS = 700;
|
|
16
25
|
// Bun gained pseudo-terminals in 1.3.5; an older one spawns without a tty, so it
|
|
17
26
|
// keeps opening terminal windows.
|
|
18
27
|
export const ptySupported = process.platform !== "win32" && typeof (Bun as any).Terminal === "function";
|
|
19
28
|
|
|
20
29
|
// Starts cmd in a new pseudo-terminal under `key` and returns its PID. pidFile gets
|
|
21
30
|
// that PID, the agent's own, so closeSessions can end it like any launched session.
|
|
22
|
-
// onChange: called when the agent ends, so the board can redraw.
|
|
31
|
+
// onChange: called when the agent ends or starts or stops asking, so the board can redraw.
|
|
23
32
|
export function startPty(key: string, cmd: string[], cwd: string, pidFile?: string, onChange?: () => void): number {
|
|
24
33
|
const dec = new TextDecoder();
|
|
25
|
-
const p: Pty = { proc: null as unknown as Bun.Subprocess, out: [], size: 0, listeners: new Set(), exit: null };
|
|
34
|
+
const p: Pty = { proc: null as unknown as Bun.Subprocess, out: [], size: 0, listeners: new Set(), exit: null, asking: false, total: 0, since: 0, onChange };
|
|
35
|
+
const flag = (v: boolean) => { if (p.asking !== v) { p.asking = v; onChange?.(); } };
|
|
26
36
|
const push = (d: string) => {
|
|
27
37
|
p.out.push(d);
|
|
28
38
|
p.size += d.length;
|
|
39
|
+
p.total += d.length;
|
|
29
40
|
while (p.size > CAP && p.out.length > 1) p.size -= p.out.shift()!.length;
|
|
30
41
|
for (const f of p.listeners) f(d);
|
|
42
|
+
clearTimeout(p.quiet);
|
|
43
|
+
p.quiet = setTimeout(() => {
|
|
44
|
+
// Only what came after the user's last keystroke: a question already answered may still be in the tail.
|
|
45
|
+
const n = Math.min(4096, p.total - p.since);
|
|
46
|
+
flag(p.exit === null && n > 0 && asking(p.out.join("").slice(-n)));
|
|
47
|
+
}, QUIET_MS);
|
|
31
48
|
};
|
|
32
49
|
p.proc = Bun.spawn(cmd, {
|
|
33
50
|
cwd,
|
|
@@ -38,6 +55,8 @@ export function startPty(key: string, cmd: string[], cwd: string, pidFile?: stri
|
|
|
38
55
|
if (pidFile) writeFileSync(pidFile, String(p.proc.pid));
|
|
39
56
|
p.proc.exited.then((code) => {
|
|
40
57
|
p.exit = code;
|
|
58
|
+
clearTimeout(p.quiet);
|
|
59
|
+
p.asking = false;
|
|
41
60
|
for (const f of p.listeners) f(null);
|
|
42
61
|
p.listeners.clear();
|
|
43
62
|
try { p.proc.terminal?.close(); } catch { /* already closed */ }
|
|
@@ -60,7 +79,10 @@ export function attachPty(key: string, fn: (d: string | null) => void): (() => v
|
|
|
60
79
|
|
|
61
80
|
export function writePty(key: string, d: string) {
|
|
62
81
|
const p = ptys.get(key);
|
|
63
|
-
if (p
|
|
82
|
+
if (!p || p.exit !== null) return;
|
|
83
|
+
p.since = p.total;
|
|
84
|
+
if (p.asking) { p.asking = false; p.onChange?.(); }
|
|
85
|
+
p.proc.terminal?.write(d);
|
|
64
86
|
}
|
|
65
87
|
|
|
66
88
|
export function resizePty(key: string, cols: number, rows: number) {
|
|
@@ -73,7 +95,7 @@ export const ptyKeys = () => [...ptys.keys()];
|
|
|
73
95
|
|
|
74
96
|
export const ptyState = (key: string) => {
|
|
75
97
|
const p = ptys.get(key);
|
|
76
|
-
return p ? { alive: p.exit === null, exit: p.exit } : null;
|
|
98
|
+
return p ? { alive: p.exit === null, exit: p.exit, asking: p.asking } : null;
|
|
77
99
|
};
|
|
78
100
|
|
|
79
101
|
// The environment the user's login shell builds: PATH and anything exported in their
|
package/cli/settings.ts
CHANGED
|
@@ -98,35 +98,35 @@ export function applyPreset(text: string, preset: PresetName, defaults: string):
|
|
|
98
98
|
}
|
|
99
99
|
|
|
100
100
|
const SETTINGS: Setting[] = [
|
|
101
|
-
{ key: "preset", label: "Preset", group: "Preset", values: ["low", "medium", "ultra", "custom"], help: "
|
|
102
|
-
{ key: "mode", label: "Stop for approval", 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: "
|
|
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: "
|
|
106
|
-
{ key: "runner", label: "Stage isolation", group: "Agents", values: ["full", "lite"], help: "full:
|
|
107
|
-
{ key: "build.executor", label: "Who builds", group: "Agents", values: ["subagent", "inline", "ask"], help: "
|
|
108
|
-
{ key: "agent.default", label: "Tool the board launches", group: "Agents", values: ["auto", "claude", "codex", "agy", "opencode", "gemini"], help: "
|
|
109
|
-
{ key: "agent.launch", label: "Where the board runs agents", group: "Agents", values: ["card", "terminal"], help: "
|
|
110
|
-
{ key: "subagents.model", label: "Model for stage agents", group: "Agents", values: ["inherit", "opus", "sonnet", "haiku"], help: "
|
|
111
|
-
{ key: "subagents.effort", label: "Effort for stage agents", group: "Agents", values: ["inherit", "low", "medium", "high"], help: "
|
|
112
|
-
{ key: "auto_fix.enabled", label: "Fix findings automatically", group: "Auto-fix", values: ["true", "false"], help: "
|
|
113
|
-
{ key: "auto_fix.min_severity", label: "Auto-fix from severity", group: "Auto-fix", values: ["critical", "high", "medium", "low"], help: "
|
|
114
|
-
{ key: "auto_fix.max_iterations", label: "Max fix rounds", group: "Auto-fix", values: ["1", "2", "3", "4", "5"], help: "
|
|
115
|
-
{ key: "git.auto_commit", label: "Commit when done", group: "Git", values: ["false", "true"], help: "
|
|
116
|
-
{ key: "git.branch_before_implement", label: "Branch before building", group: "Git", values: ["true", "false"], help: "
|
|
117
|
-
{ 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
|
-
{ key: "review.write_memory", label: "Remember lessons", group: "Project", values: ["true", "false"], help: "
|
|
120
|
-
{ key: "review.memory_max_lines", label: "Memory size cap", group: "Project", values: ["50", "100", "200", "400"], help: "
|
|
121
|
-
{ key: "state.keep_runs", label: "Runs to keep", group: "Project", values: ["5", "10", "20", "50"], help: "
|
|
122
|
-
{ key: "dictation.model", label: "Dictation model", group: "Board", values: ["tiny", "base", "small", "large-v3-turbo"], help: "
|
|
123
|
-
{ key: "sources.every", label: "Gather requests", group: "Board", values: ["off", "1h", "6h", "24h"], help: "
|
|
124
|
-
{ key: "sources.agent", label: "Who reads social sources", group: "Board", values: ["ask", "chatgpt", "agent"], help: "
|
|
125
|
-
{ key: "sources.model", label: "ChatGPT model for gathering", group: "Board", values: [], text: true, help: "
|
|
126
|
-
{ key: "sources.effort", label: "ChatGPT effort for gathering", group: "Board", values: ["low", "medium", "high"], help: "
|
|
127
|
-
{ key: "board.projects_dir", label: "Projects folder", group: "Board", values: [], text: true, folder: true, help:
|
|
128
|
-
{ key: "board.open", label: "Open the board in", group: "Board", values: ["browser", "app"], help: "
|
|
129
|
-
{ key: "ui.mouse", label: "Enable mouse", group: "Terminal", values: ["false", "true"], help: "
|
|
101
|
+
{ key: "preset", label: "Preset", group: "Preset", values: ["low", "medium", "ultra", "custom"], help: "" },
|
|
102
|
+
{ key: "mode", label: "Stop for approval", 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: "" },
|
|
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: "" },
|
|
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: "" },
|
|
108
|
+
{ key: "agent.default", label: "Tool the board launches", group: "Agents", values: ["auto", "claude", "codex", "agy", "opencode", "gemini"], help: "" },
|
|
109
|
+
{ key: "agent.launch", label: "Where the board runs agents", group: "Agents", values: ["card", "terminal"], help: "" },
|
|
110
|
+
{ key: "subagents.model", label: "Model for stage agents", group: "Agents", values: ["inherit", "opus", "sonnet", "haiku"], help: "" },
|
|
111
|
+
{ key: "subagents.effort", label: "Effort for stage agents", group: "Agents", values: ["inherit", "low", "medium", "high"], help: "" },
|
|
112
|
+
{ key: "auto_fix.enabled", label: "Fix findings automatically", group: "Auto-fix", values: ["true", "false"], help: "" },
|
|
113
|
+
{ key: "auto_fix.min_severity", label: "Auto-fix from severity", group: "Auto-fix", values: ["critical", "high", "medium", "low"], help: "" },
|
|
114
|
+
{ key: "auto_fix.max_iterations", label: "Max fix rounds", group: "Auto-fix", values: ["1", "2", "3", "4", "5"], help: "" },
|
|
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
|
+
{ 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
|
+
{ key: "review.write_memory", label: "Remember lessons", group: "Project", values: ["true", "false"], help: "" },
|
|
120
|
+
{ key: "review.memory_max_lines", label: "Memory size cap", group: "Project", values: ["50", "100", "200", "400"], help: "" },
|
|
121
|
+
{ key: "state.keep_runs", label: "Runs to keep", group: "Project", values: ["5", "10", "20", "50"], help: "" },
|
|
122
|
+
{ key: "dictation.model", label: "Dictation model", group: "Board", values: ["tiny", "base", "small", "large-v3-turbo"], help: "" },
|
|
123
|
+
{ key: "sources.every", label: "Gather requests", group: "Board", values: ["off", "1h", "6h", "24h"], help: "" },
|
|
124
|
+
{ key: "sources.agent", label: "Who reads social sources", group: "Board", values: ["ask", "chatgpt", "agent"], help: "" },
|
|
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: "" },
|
|
127
|
+
{ key: "board.projects_dir", label: "Projects folder", group: "Board", values: [], text: true, folder: true, help: "Empty: known projects only" },
|
|
128
|
+
{ key: "board.open", label: "Open the board in", group: "Board", values: ["browser", "app"], help: "" },
|
|
129
|
+
{ key: "ui.mouse", label: "Enable mouse", group: "Terminal", values: ["false", "true"], help: "" },
|
|
130
130
|
];
|
|
131
131
|
|
|
132
132
|
// The same rows and the same write the TUI uses, for `kaizen web`'s settings panel:
|
package/cli/sources.test.ts
CHANGED
|
@@ -281,7 +281,7 @@ test("hostile content becomes plain icebox items, and promoted, plain open ideas
|
|
|
281
281
|
for (const l of open()) promote(state, [l.id]);
|
|
282
282
|
const lines = readInbox(state);
|
|
283
283
|
expect(lines.length).toBe(7);
|
|
284
|
-
expect(lines[0]).
|
|
284
|
+
expect(lines[0]).toMatchObject({ status: "started", text: "already here" });
|
|
285
285
|
const fresh = lines.slice(1);
|
|
286
286
|
expect(fresh.every((l) => l.status === "open")).toBe(true);
|
|
287
287
|
expect(fresh.map((l) => l.text)).toEqual([
|
package/cli/state.test.ts
CHANGED
|
@@ -25,6 +25,12 @@ const run = (id: string, s: object, request = "") => {
|
|
|
25
25
|
beforeAll(() => {
|
|
26
26
|
state = join(mkdtempSync(join(tmpdir(), "kaizen-")), ".kaizen");
|
|
27
27
|
mkdirSync(state, { recursive: true });
|
|
28
|
+
writeInbox(state, [
|
|
29
|
+
{ status: "open", text: "add oauth login" },
|
|
30
|
+
{ status: "open", text: "retry failed webhooks" },
|
|
31
|
+
{ status: "started", text: "nightly digest" },
|
|
32
|
+
{ status: "rejected", text: "gantt view | not this product" },
|
|
33
|
+
]);
|
|
28
34
|
run("2026-09-01-waiting", { stage: "plan", awaiting: "approvals.plan" }, "# Request\n\nAdd OAuth login\n");
|
|
29
35
|
run("2026-09-02-done", { stage: "done", awaiting: null }, "# Request\n\nRename cli flags\n");
|
|
30
36
|
const old = run("2026-09-03-stale", { stage: "build", awaiting: null });
|
|
@@ -33,12 +39,6 @@ beforeAll(() => {
|
|
|
33
39
|
writeFileSync(join(state, "runs", "2026-09-02-done", "06-backlog.md"),
|
|
34
40
|
"# Backlog\n\n- open: `cli/x.ts:12`: low: first thing. Fix: do it.\n- done: closed one | run y\n- open: plain item\n");
|
|
35
41
|
writeFileSync(join(state, "backlog.md"), "- open: orphaned item\n");
|
|
36
|
-
writeInbox(state, [
|
|
37
|
-
{ status: "open", text: "add oauth login" },
|
|
38
|
-
{ status: "open", text: "retry failed webhooks" },
|
|
39
|
-
{ status: "started", text: "nightly digest" },
|
|
40
|
-
{ status: "rejected", text: "gantt view | not this product" },
|
|
41
|
-
]);
|
|
42
42
|
});
|
|
43
43
|
afterAll(() => rmSync(join(state, ".."), { recursive: true, force: true }));
|
|
44
44
|
|
|
@@ -80,6 +80,48 @@ test("boardCards: idea retired once a run's request contains it; started idea si
|
|
|
80
80
|
expect(runs).toEqual([["2026-09-03-stale", 2, "stalled"], ["2026-09-02-done", 4, "done"], ["2026-09-01-waiting", 1, "waiting"]]);
|
|
81
81
|
});
|
|
82
82
|
|
|
83
|
+
test("boardCards: a started idea stays until its run has a state.json", () => {
|
|
84
|
+
const dir = join(mkdtempSync(join(tmpdir(), "kaizen-")), ".kaizen");
|
|
85
|
+
const r = join(dir, "runs", "2026-01-01-new");
|
|
86
|
+
mkdirSync(r, { recursive: true });
|
|
87
|
+
writeInbox(dir, [{ status: "started", text: "full release" }]);
|
|
88
|
+
writeFileSync(join(r, "00-request.md"), "full release\n");
|
|
89
|
+
const cards = () => boardCards([dir], Date.now()).map((k) => k.kind);
|
|
90
|
+
expect(cards()).toEqual(["idea"]);
|
|
91
|
+
writeFileSync(join(r, "state.json"), JSON.stringify({ id: "2026-01-01-new", stage: "plan", awaiting: null }));
|
|
92
|
+
expect(cards()).toEqual(["run"]);
|
|
93
|
+
rmSync(join(dir, ".."), { recursive: true, force: true });
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
test("boardCards: a run older than the idea does not retire it", () => {
|
|
97
|
+
const dir = join(mkdtempSync(join(tmpdir(), "kaizen-")), ".kaizen");
|
|
98
|
+
const r = join(dir, "runs", "2026-01-01-old");
|
|
99
|
+
mkdirSync(r, { recursive: true });
|
|
100
|
+
writeFileSync(join(r, "state.json"), JSON.stringify({ id: "2026-01-01-old", stage: "done", awaiting: null }));
|
|
101
|
+
writeFileSync(join(r, "00-request.md"), "fix this bugs\n");
|
|
102
|
+
const then = new Date(Date.now() - 3600_000);
|
|
103
|
+
utimesSync(join(r, "00-request.md"), then, then);
|
|
104
|
+
writeInbox(dir, [{ status: "open", text: "fix this bugs" }]);
|
|
105
|
+
expect(readFileSync(join(dir, "inbox.md"), "utf8")).toMatch(/- open: fix this bugs <!-- added:\d+ -->/);
|
|
106
|
+
expect(readInbox(dir)[0]!.text).toBe("fix this bugs");
|
|
107
|
+
const ideas = () => boardCards([dir], Date.now()).filter((k) => k.kind === "idea").map((k) => k.text);
|
|
108
|
+
expect(ideas()).toEqual(["fix this bugs"]);
|
|
109
|
+
// Rewriting keeps the stamp, so an edit to notes does not make it vanish.
|
|
110
|
+
writeInbox(dir, readInbox(dir).map((l) => ({ status: l.status, text: l.text, notes: "n" })));
|
|
111
|
+
expect(ideas()).toEqual(["fix this bugs"]);
|
|
112
|
+
// A run started after it retires it as before.
|
|
113
|
+
utimesSync(join(r, "00-request.md"), new Date(), new Date(Date.now() + 5000));
|
|
114
|
+
expect(ideas()).toEqual([]);
|
|
115
|
+
// Re-adding the same words while an old line holds them is a new idea with its
|
|
116
|
+
// own stamp, not the old line's (or no) stamp.
|
|
117
|
+
writeFileSync(join(dir, "inbox.md"), "- done: fix this bugs\n");
|
|
118
|
+
writeInbox(dir, [...readInbox(dir), { status: "open", text: "fix this bugs" }]);
|
|
119
|
+
utimesSync(join(r, "00-request.md"), then, then);
|
|
120
|
+
expect(ideas()).toEqual(["fix this bugs"]);
|
|
121
|
+
|
|
122
|
+
rmSync(join(dir, ".."), { recursive: true, force: true });
|
|
123
|
+
});
|
|
124
|
+
|
|
83
125
|
test("replaceIdea edits, rejects, deletes by text", () => {
|
|
84
126
|
replaceIdea(state, "retry failed webhooks", { status: "open", text: "retry failed webhooks twice" });
|
|
85
127
|
expect(readInbox(state).some((l) => l.text === "retry failed webhooks twice")).toBe(true);
|
|
@@ -188,8 +230,8 @@ test("notes: indented lines under a bullet round-trip; a legacy file is untouche
|
|
|
188
230
|
expect(readFileSync(join(state, "inbox.md"), "utf8")).toBe(legacy);
|
|
189
231
|
writeInbox(state, [{ status: "open", text: "with notes", notes: "see https://x.y/z\ncli/state.ts:12 | not a reason" }, { status: "open", text: "plain zq8" }]);
|
|
190
232
|
const [a, b] = readInbox(state);
|
|
191
|
-
expect(a).
|
|
192
|
-
expect(b).
|
|
233
|
+
expect(a).toMatchObject({ status: "open", text: "with notes", notes: "see https://x.y/z\ncli/state.ts:12 | not a reason" });
|
|
234
|
+
expect(b).toMatchObject({ status: "open", text: "plain zq8" });
|
|
193
235
|
});
|
|
194
236
|
|
|
195
237
|
test("notes: survive reject and started; edit can replace or clear them", () => {
|
|
@@ -334,6 +376,7 @@ console.log(JSON.stringify(out));`;
|
|
|
334
376
|
test("boardCards: a short idea is not retired by a request that merely contains the word", () => {
|
|
335
377
|
const dir = join(mkdtempSync(join(tmpdir(), "kz-")), ".kaizen");
|
|
336
378
|
mkdirSync(join(dir, "runs", "2026-09-01-a"), { recursive: true });
|
|
379
|
+
writeFileSync(join(dir, "runs", "2026-09-01-a", "state.json"), JSON.stringify({ id: "2026-09-01-a", stage: "plan", awaiting: null }));
|
|
337
380
|
writeFileSync(join(dir, "runs", "2026-09-01-a", "00-request.md"), "# Request\n\n> /kaizen lite add retry\n\nDone means the tests pass.\n");
|
|
338
381
|
writeInbox(dir, [{ status: "open", text: "test" }, { status: "open", text: "add retry" }]);
|
|
339
382
|
expect(boardCards([dir], Date.now()).filter((k) => k.kind === "idea").map((k) => k.text)).toEqual(["test"]);
|
|
@@ -986,7 +1029,7 @@ test("moveIdea carries notes and linked attachments, leaves the old inbox withou
|
|
|
986
1029
|
expect(moveIdea(a, b, "nope")).toBe("no such idea");
|
|
987
1030
|
expect(moveIdea(a, b, "go")).toBeNull();
|
|
988
1031
|
expect(readInbox(a).map((l) => l.text)).toEqual(["keep"]);
|
|
989
|
-
expect(readInbox(b)).
|
|
1032
|
+
expect(readInbox(b)).toMatchObject([{ status: "open", text: "go", notes: "see [shot](attachments/x-shot.png)" }]);
|
|
990
1033
|
expect(readFileSync(join(b, "attachments", "x-shot.png"), "utf8")).toBe("png");
|
|
991
1034
|
writeInbox(a, [{ status: "open", text: "go" }]);
|
|
992
1035
|
expect(moveIdea(a, b, "go")).toBe("that project already has this idea");
|
package/cli/state.ts
CHANGED
|
@@ -676,7 +676,8 @@ export function columnOf(stage: string, awaiting: string | null = null) {
|
|
|
676
676
|
|
|
677
677
|
// notes: free text under the bullet -- links, path:line refs, background. Kept as
|
|
678
678
|
// indented continuation lines so a one-line inbox from before notes reads unchanged.
|
|
679
|
-
|
|
679
|
+
// `added` is when the line first appeared (ms), kept as a hidden comment on its bullet.
|
|
680
|
+
export type InboxLine = { status: string; text: string; notes?: string; added?: number };
|
|
680
681
|
|
|
681
682
|
// Ideas with no run yet. Deliberately not backlog.md, which the spec defines as
|
|
682
683
|
// an orphanage for items rescued from pruned runs, and not a run stub each.
|
|
@@ -686,7 +687,11 @@ export function readInbox(state: string): InboxLine[] {
|
|
|
686
687
|
const out: InboxLine[] = [];
|
|
687
688
|
for (const raw of readText(f).split("\n")) {
|
|
688
689
|
const m = /^-\s*(open|started|done|rejected|archived):\s*(.+)$/.exec(raw.trim());
|
|
689
|
-
if (m) {
|
|
690
|
+
if (m) {
|
|
691
|
+
const a = /\s*<!-- added:(\d+) -->$/.exec(m[2]!);
|
|
692
|
+
out.push(a ? { status: m[1]!, text: m[2]!.slice(0, a.index).trim(), added: +a[1]! } : { status: m[1]!, text: m[2]!.trim() });
|
|
693
|
+
continue;
|
|
694
|
+
}
|
|
690
695
|
// An indented line belongs to the bullet above it; anything else is noise.
|
|
691
696
|
const last = out[out.length - 1];
|
|
692
697
|
if (last && /^\s{2,}\S/.test(raw)) last.notes = (last.notes ? last.notes + "\n" : "") + raw.trim();
|
|
@@ -699,12 +704,24 @@ export function readInbox(state: string): InboxLine[] {
|
|
|
699
704
|
// needs a lock or an append-only journal.
|
|
700
705
|
export function writeInbox(state: string, lines: InboxLine[]) {
|
|
701
706
|
mkdirSync(state, { recursive: true });
|
|
707
|
+
// A line already in the file keeps its stamp; a text the file never had is new now.
|
|
708
|
+
// Lines from before stamps existed stay unstamped rather than looking new.
|
|
709
|
+
// Stamps queue per text and each is used once: re-adding words the file already
|
|
710
|
+
// holds (a done or rejected line, or an open duplicate) is a new idea, not the old
|
|
711
|
+
// one's stamp, which an older run with those words would retire.
|
|
712
|
+
const had = new Map<string, (number | undefined)[]>();
|
|
713
|
+
for (const l of readInbox(state)) had.set(l.text, [...(had.get(l.text) ?? []), l.added]);
|
|
714
|
+
const stamp = (l: InboxLine) => {
|
|
715
|
+
if (l.added) return l.added;
|
|
716
|
+
const q = had.get(l.text);
|
|
717
|
+
return q?.length ? q.shift() : Date.now();
|
|
718
|
+
};
|
|
702
719
|
const body = [
|
|
703
720
|
"# Inbox",
|
|
704
721
|
"",
|
|
705
722
|
"Ideas with no run yet. One line each, same format as a run's backlog.",
|
|
706
723
|
"",
|
|
707
|
-
...lines.flatMap((l) => [`- ${l.status}: ${l.text}`, ...noteLines(l.notes)]),
|
|
724
|
+
...lines.flatMap((l) => { const a = stamp(l); return [`- ${l.status}: ${l.text}${a ? ` <!-- added:${a} -->` : ""}`, ...noteLines(l.notes)]; }),
|
|
708
725
|
];
|
|
709
726
|
writeFileSync(join(state, "inbox.md"), body.join("\n") + "\n");
|
|
710
727
|
}
|
|
@@ -961,15 +978,20 @@ export function boardCards(states: string[], now: number): Card[] {
|
|
|
961
978
|
if (!bodies.has(f)) { try { bodies.set(f, readText(f)); } catch { bodies.set(f, ""); } }
|
|
962
979
|
return bodies.get(f)!;
|
|
963
980
|
};
|
|
964
|
-
|
|
981
|
+
// Each request with when it was written, so a run older than an idea cannot retire it.
|
|
982
|
+
const requests: { at: number; text: string }[] = [];
|
|
965
983
|
// Each request line on its own, quote and list markers stripped: what an idea
|
|
966
984
|
// nobody launched from the board is matched against.
|
|
967
|
-
const lines: string[] = [];
|
|
985
|
+
const lines: { at: number; text: string }[] = [];
|
|
968
986
|
for (const st of [...states, ...knownProjects().map((d) => join(d, ".kaizen")), join(home, ".kaizen")]
|
|
969
987
|
.filter((d, i, all) => all.indexOf(d) === i && existsSync(join(d, "runs")))) {
|
|
970
|
-
|
|
971
|
-
|
|
972
|
-
|
|
988
|
+
// A run with no state.json yet has no card either: retiring the idea against it
|
|
989
|
+
// leaves the board showing neither until the agent writes one.
|
|
990
|
+
for (const id of readdirSync(join(st, "runs"))) { const b = body(st, id); if (!b || !existsSync(join(st, "runs", id, "state.json"))) continue;
|
|
991
|
+
let at = 0;
|
|
992
|
+
try { at = statSync(join(st, "runs", id, "00-request.md")).mtimeMs; } catch {}
|
|
993
|
+
requests.push({ at, text: normalise(b) });
|
|
994
|
+
for (const l of b.split("\n")) lines.push({ at, text: normalise(l.replace(/^[\s>*"'`-]+|[\s"'`.]+$/g, "")) });
|
|
973
995
|
}
|
|
974
996
|
}
|
|
975
997
|
for (const st of states) {
|
|
@@ -986,7 +1008,11 @@ export function boardCards(states: string[], now: number): Card[] {
|
|
|
986
1008
|
// prefix): a short idea like "test" is a substring of half the requests
|
|
987
1009
|
// ever written, and would vanish the moment it was added.
|
|
988
1010
|
const t = normalise(it.text);
|
|
989
|
-
|
|
1011
|
+
// A run written before the idea existed is not this idea's run: an old request
|
|
1012
|
+
// titled "fix this bugs" must not swallow a new idea with the same words.
|
|
1013
|
+
// ponytail: 2s slack for coarse filesystem clocks.
|
|
1014
|
+
const after = (r: { at: number }) => !it.added || r.at >= it.added - 2000;
|
|
1015
|
+
if (it.status === "started" ? requests.some((r) => after(r) && r.text.includes(t)) : lines.some((l) => after(l) && (l.text === t || l.text.endsWith(" " + t)))) continue;
|
|
990
1016
|
next.push(it.status === "started"
|
|
991
1017
|
? { kind: "idea", status: "starting", text: it.text, state: st, where, column: 1, awaiting: null, dim: true, archived: false, notes: it.notes, parent: parentOf(it.notes) }
|
|
992
1018
|
: { kind: "idea", status: "idea", text: it.text, state: st, where, column: 0, awaiting: null, dim: false, archived: it.status === "archived", notes: it.notes, parent: parentOf(it.notes) });
|
|
@@ -1392,7 +1418,7 @@ export function trustFolder(dir: string, cfg = join(process.env.CLAUDE_CONFIG_DI
|
|
|
1392
1418
|
|
|
1393
1419
|
// run: the run a decision is for. Without it, an idea starting a new run.
|
|
1394
1420
|
// pick: the tool, model and yolo chosen for a new run; a decision takes the run's own
|
|
1395
|
-
// tool and model, and
|
|
1421
|
+
// tool and model, and stays in Yolo when the run was started in it. card: run it inside the board (agent.launch: card),
|
|
1396
1422
|
// where a pseudo-terminal exists; onChange is told when that agent ends.
|
|
1397
1423
|
const CHILD_ENV = /^(CLAUDECODE|CLAUDE_PID|CLAUDE_CODE_(SESSION_ID|CHILD_SESSION|SESSION_ATTENDED|MESSAGING_SOCKET|MESSAGING_TOKEN))$/;
|
|
1398
1424
|
|
|
@@ -1413,7 +1439,10 @@ export function launchRun(it: Item, from: string, run?: string, pick: { agent?:
|
|
|
1413
1439
|
const wanted = pick.model ?? (own?.cmd === agent.cmd ? own.model : null) ?? undefined;
|
|
1414
1440
|
const model = wanted && plainModel(wanted) ? wanted : undefined;
|
|
1415
1441
|
// Yolo was picked for one tool; a fallback to another opens in its normal mode.
|
|
1416
|
-
|
|
1442
|
+
// A decision on a run started in Yolo keeps it, or its next stage stops on every command.
|
|
1443
|
+
const st = join(projectDir, ".kaizen");
|
|
1444
|
+
const wasYolo = !!run && !pick.agent && readSessions(st).some((s) => s.yolo && s.cmd === agent.cmd && ownerOf(st, s) === run);
|
|
1445
|
+
const yolo = (wasYolo || (!!pick.yolo && agent.cmd === pick.agent)) && !!YOLO_FLAGS[agent.cmd];
|
|
1417
1446
|
const flags = [...agentFlags(projectDir, agent.cmd, undefined, model), ...(yolo ? YOLO_FLAGS[agent.cmd] : [])];
|
|
1418
1447
|
// The idea as its run's request will hold it: without the runner word in front.
|
|
1419
1448
|
const s = sessionLaunch(projectDir, agent.cmd, [agentBin, ...flags], prompt, it.text.replace(/^(lite|full|auto|plan)\s+/i, ""), run, model, yolo);
|
package/cli/web.ts
CHANGED
|
@@ -191,7 +191,9 @@ export async function web(repoDir: string, opts: Opts = {}) {
|
|
|
191
191
|
const key = c.kind === "run"
|
|
192
192
|
? readSessions(c.state).filter((s) => live.has(s.key) && ownerOf(c.state, s) === c.id).pop()?.key
|
|
193
193
|
: ideaPty.get(`${c.state}\0${c.text}`);
|
|
194
|
-
|
|
194
|
+
if (!key || !live.has(key)) return undefined;
|
|
195
|
+
const st = ptyState(key)!;
|
|
196
|
+
return { key, alive: st.alive, asking: st.asking };
|
|
195
197
|
};
|
|
196
198
|
// Dev: the page reloads itself when board.html or its bundle is saved. Server code needs
|
|
197
199
|
// `bun --watch cli/install.ts web`; the page reconnects and reloads on hello.
|
package/package.json
CHANGED
|
@@ -119,7 +119,12 @@ library, an API — may give command output alone. A report with no screenshot i
|
|
|
119
119
|
A headless browser does it: `playwright screenshot <url> runs/<id>/attachments/<name>.png`,
|
|
120
120
|
or `chromium --headless --screenshot=<abs path>.png --window-size=1280,900 <url>` where
|
|
121
121
|
playwright has no browser installed. A file (document, diff, output) opens as `file://<path>`.
|
|
122
|
-
Where no proof can be produced, say why in one line under `## Preview
|
|
122
|
+
Where no proof can be produced, say why in one line under `## Preview` — but a missing
|
|
123
|
+
browser is never that reason. Before giving up, try each of: `playwright screenshot`,
|
|
124
|
+
`bunx playwright screenshot`, `chromium --headless`, `google-chrome --headless`,
|
|
125
|
+
and `bunx playwright install chromium` then retry. Start the app if it is not running.
|
|
126
|
+
Only a result with no screen, or a capture that failed after every one of these (quote
|
|
127
|
+
the error), may skip the screenshot.
|
|
123
128
|
|
|
124
129
|
**A revise retakes the screenshots.** When the run being revised, or the run a follow-up
|
|
125
130
|
names in `Follow-up of:`, already shows screenshots in its `## Preview`, the build that
|
|
@@ -89,7 +89,8 @@ Before setting `stage: "review"` after a build, in either runner, open `03-impl.
|
|
|
89
89
|
check its `## Preview`: it has `### How to test` steps, and — when the result shows on a
|
|
90
90
|
screen — at least one `` embed whose file exists under
|
|
91
91
|
`runs/<id>/attachments/`. A result with no screen needs one line under `## Preview`
|
|
92
|
-
saying why there is no screenshot
|
|
92
|
+
saying why there is no screenshot; "no headless browser" for a result that shows on a
|
|
93
|
+
screen does not count — take it as missing. Anything missing means the build is not finished:
|
|
93
94
|
under `lite`, take the screenshot and write the section now; under `full`, send the
|
|
94
95
|
builder back to add it. Then advance. This is the step that keeps the board's Preview
|
|
95
96
|
tab from staying empty; the reviewer flagging it later is a second net, not the first.
|
package/web/board/cards.ts
CHANGED
|
@@ -84,7 +84,9 @@ function card(c) {
|
|
|
84
84
|
marks.push(`<span class="mk warn" title="Started in Yolo: the agent runs commands without asking">yolo</span>`);
|
|
85
85
|
else chips.push(`<span class="chip">${esc(NAMES[t] || t)}</span>`);
|
|
86
86
|
}
|
|
87
|
-
if (c.pty && c.pty.alive)
|
|
87
|
+
if (c.pty && c.pty.alive && c.pty.asking)
|
|
88
|
+
marks.push(mk("i-ask", "The agent is asking you something: open it, terminal button at the top", " warn ask"));
|
|
89
|
+
else if (c.pty && c.pty.alive)
|
|
88
90
|
marks.push(mk("i-term", "The agent is running in this card: open it, terminal button at the top", " run"));
|
|
89
91
|
// A gathered idea's notes open with the stamp promote() writes (ingest() wrote it before
|
|
90
92
|
// the icebox); its chip names the source.
|