kaizen-agent 1.39.0 → 1.40.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/cli/board.test.ts +1 -1
- 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 +66 -9
- package/cli/state.ts +48 -12
- package/cli/web.ts +3 -1
- package/package.json +1 -1
- package/skills/kaizen/SKILL.md +3 -2
- package/skills/kaizen/spec-build.md +6 -1
- package/skills/kaizen/spec-main.md +2 -1
- package/skills/kaizen/spec.md +1 -1
- package/web/board/cards.ts +9 -4
- 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/board.test.ts
CHANGED
|
@@ -157,7 +157,7 @@ test("card menu items: appropriate actions and distinct icons across phases", ()
|
|
|
157
157
|
const menuSnippet = (cards.slice(from, cards.indexOf('el.innerHTML += `<div class="menu-pop"', from)) + "}").replace("const items = [];", "items = [];");
|
|
158
158
|
const a0 = cards.indexOf("const actionOf =");
|
|
159
159
|
const actionOf = cards.slice(a0, cards.indexOf("\n\n", a0));
|
|
160
|
-
const getItems = new Function("c", "archive", "S", `const ico = ${ico.toString()};\n${actionOf}\nconst store = { S, menuKey: 1 };\nlet k = 1, items = [];\n${menuSnippet}\nreturn items;`);
|
|
160
|
+
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;`);
|
|
161
161
|
|
|
162
162
|
// Phase 0: Ideas (idea card)
|
|
163
163
|
const idea = { kind: "idea", status: "idea", column: 0 };
|
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);
|
|
@@ -103,6 +145,20 @@ test("backlog: open items only, orphanage last, count matches", () => {
|
|
|
103
145
|
expect(backlog(state)).toBe(3);
|
|
104
146
|
});
|
|
105
147
|
|
|
148
|
+
test("backlog: an abandoned run's items are neither listed nor counted", () => {
|
|
149
|
+
const dir = join(state, "runs", "2026-09-03-dropped");
|
|
150
|
+
mkdirSync(dir, { recursive: true });
|
|
151
|
+
writeFileSync(join(dir, "state.json"), JSON.stringify({ id: "2026-09-03-dropped", stage: "abandoned" }));
|
|
152
|
+
writeFileSync(join(dir, "06-backlog.md"), "- open: never happening\n");
|
|
153
|
+
expect(allBacklog(state).some((g) => g.run === "2026-09-03-dropped")).toBe(false);
|
|
154
|
+
expect(backlog(state)).toBe(3);
|
|
155
|
+
rmSync(dir, { recursive: true });
|
|
156
|
+
});
|
|
157
|
+
|
|
158
|
+
test("abandonRun refuses a done run", () => {
|
|
159
|
+
expect(abandonRun(state, "2026-09-02-done", "late")).toMatch(/already done/);
|
|
160
|
+
});
|
|
161
|
+
|
|
106
162
|
test("parseItem: finding line splits into where, severity, text", () => {
|
|
107
163
|
const it = parseItem("`cli/x.ts:12`: low: first thing. Fix: do it.");
|
|
108
164
|
expect([it.where, it.severity, it.text]).toEqual(["x.ts:12", "low", "first thing."]);
|
|
@@ -188,8 +244,8 @@ test("notes: indented lines under a bullet round-trip; a legacy file is untouche
|
|
|
188
244
|
expect(readFileSync(join(state, "inbox.md"), "utf8")).toBe(legacy);
|
|
189
245
|
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
246
|
const [a, b] = readInbox(state);
|
|
191
|
-
expect(a).
|
|
192
|
-
expect(b).
|
|
247
|
+
expect(a).toMatchObject({ status: "open", text: "with notes", notes: "see https://x.y/z\ncli/state.ts:12 | not a reason" });
|
|
248
|
+
expect(b).toMatchObject({ status: "open", text: "plain zq8" });
|
|
193
249
|
});
|
|
194
250
|
|
|
195
251
|
test("notes: survive reject and started; edit can replace or clear them", () => {
|
|
@@ -334,6 +390,7 @@ console.log(JSON.stringify(out));`;
|
|
|
334
390
|
test("boardCards: a short idea is not retired by a request that merely contains the word", () => {
|
|
335
391
|
const dir = join(mkdtempSync(join(tmpdir(), "kz-")), ".kaizen");
|
|
336
392
|
mkdirSync(join(dir, "runs", "2026-09-01-a"), { recursive: true });
|
|
393
|
+
writeFileSync(join(dir, "runs", "2026-09-01-a", "state.json"), JSON.stringify({ id: "2026-09-01-a", stage: "plan", awaiting: null }));
|
|
337
394
|
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
395
|
writeInbox(dir, [{ status: "open", text: "test" }, { status: "open", text: "add retry" }]);
|
|
339
396
|
expect(boardCards([dir], Date.now()).filter((k) => k.kind === "idea").map((k) => k.text)).toEqual(["test"]);
|
|
@@ -986,7 +1043,7 @@ test("moveIdea carries notes and linked attachments, leaves the old inbox withou
|
|
|
986
1043
|
expect(moveIdea(a, b, "nope")).toBe("no such idea");
|
|
987
1044
|
expect(moveIdea(a, b, "go")).toBeNull();
|
|
988
1045
|
expect(readInbox(a).map((l) => l.text)).toEqual(["keep"]);
|
|
989
|
-
expect(readInbox(b)).
|
|
1046
|
+
expect(readInbox(b)).toMatchObject([{ status: "open", text: "go", notes: "see [shot](attachments/x-shot.png)" }]);
|
|
990
1047
|
expect(readFileSync(join(b, "attachments", "x-shot.png"), "utf8")).toBe("png");
|
|
991
1048
|
writeInbox(a, [{ status: "open", text: "go" }]);
|
|
992
1049
|
expect(moveIdea(a, b, "go")).toBe("that project already has this idea");
|
package/cli/state.ts
CHANGED
|
@@ -435,7 +435,7 @@ export function unrechecked(state: string, id: string) {
|
|
|
435
435
|
export function backlog(state: string) {
|
|
436
436
|
const files = [join(state, "backlog.md")];
|
|
437
437
|
const runs = join(state, "runs");
|
|
438
|
-
if (existsSync(runs)) for (const id of readdirSync(runs)) files.push(join(runs, id, "06-backlog.md"));
|
|
438
|
+
if (existsSync(runs)) for (const id of readdirSync(runs)) if (!abandoned(join(runs, id))) files.push(join(runs, id, "06-backlog.md"));
|
|
439
439
|
let n = 0;
|
|
440
440
|
for (const f of files) {
|
|
441
441
|
if (!existsSync(f)) continue;
|
|
@@ -581,11 +581,17 @@ export function readBacklog(file: string) {
|
|
|
581
581
|
.map((l) => l.replace(/^-\s*open:\s*/, ""));
|
|
582
582
|
}
|
|
583
583
|
|
|
584
|
+
// An abandoned run's backlog is not open work (spec-main.md).
|
|
585
|
+
function abandoned(run: string) {
|
|
586
|
+
try { return JSON.parse(readText(join(run, "state.json"))).stage === "abandoned"; } catch { return false; }
|
|
587
|
+
}
|
|
588
|
+
|
|
584
589
|
export function allBacklog(state: string) {
|
|
585
590
|
const out: { run: string; items: string[] }[] = [];
|
|
586
591
|
const runs = join(state, "runs");
|
|
587
592
|
if (existsSync(runs)) {
|
|
588
593
|
for (const id of readdirSync(runs).sort().reverse()) {
|
|
594
|
+
if (abandoned(join(runs, id))) continue;
|
|
589
595
|
const items = readBacklog(join(runs, id, "06-backlog.md"));
|
|
590
596
|
if (items.length) out.push({ run: id, items });
|
|
591
597
|
}
|
|
@@ -676,7 +682,8 @@ export function columnOf(stage: string, awaiting: string | null = null) {
|
|
|
676
682
|
|
|
677
683
|
// notes: free text under the bullet -- links, path:line refs, background. Kept as
|
|
678
684
|
// indented continuation lines so a one-line inbox from before notes reads unchanged.
|
|
679
|
-
|
|
685
|
+
// `added` is when the line first appeared (ms), kept as a hidden comment on its bullet.
|
|
686
|
+
export type InboxLine = { status: string; text: string; notes?: string; added?: number };
|
|
680
687
|
|
|
681
688
|
// Ideas with no run yet. Deliberately not backlog.md, which the spec defines as
|
|
682
689
|
// an orphanage for items rescued from pruned runs, and not a run stub each.
|
|
@@ -686,7 +693,11 @@ export function readInbox(state: string): InboxLine[] {
|
|
|
686
693
|
const out: InboxLine[] = [];
|
|
687
694
|
for (const raw of readText(f).split("\n")) {
|
|
688
695
|
const m = /^-\s*(open|started|done|rejected|archived):\s*(.+)$/.exec(raw.trim());
|
|
689
|
-
if (m) {
|
|
696
|
+
if (m) {
|
|
697
|
+
const a = /\s*<!-- added:(\d+) -->$/.exec(m[2]!);
|
|
698
|
+
out.push(a ? { status: m[1]!, text: m[2]!.slice(0, a.index).trim(), added: +a[1]! } : { status: m[1]!, text: m[2]!.trim() });
|
|
699
|
+
continue;
|
|
700
|
+
}
|
|
690
701
|
// An indented line belongs to the bullet above it; anything else is noise.
|
|
691
702
|
const last = out[out.length - 1];
|
|
692
703
|
if (last && /^\s{2,}\S/.test(raw)) last.notes = (last.notes ? last.notes + "\n" : "") + raw.trim();
|
|
@@ -699,12 +710,24 @@ export function readInbox(state: string): InboxLine[] {
|
|
|
699
710
|
// needs a lock or an append-only journal.
|
|
700
711
|
export function writeInbox(state: string, lines: InboxLine[]) {
|
|
701
712
|
mkdirSync(state, { recursive: true });
|
|
713
|
+
// A line already in the file keeps its stamp; a text the file never had is new now.
|
|
714
|
+
// Lines from before stamps existed stay unstamped rather than looking new.
|
|
715
|
+
// Stamps queue per text and each is used once: re-adding words the file already
|
|
716
|
+
// holds (a done or rejected line, or an open duplicate) is a new idea, not the old
|
|
717
|
+
// one's stamp, which an older run with those words would retire.
|
|
718
|
+
const had = new Map<string, (number | undefined)[]>();
|
|
719
|
+
for (const l of readInbox(state)) had.set(l.text, [...(had.get(l.text) ?? []), l.added]);
|
|
720
|
+
const stamp = (l: InboxLine) => {
|
|
721
|
+
if (l.added) return l.added;
|
|
722
|
+
const q = had.get(l.text);
|
|
723
|
+
return q?.length ? q.shift() : Date.now();
|
|
724
|
+
};
|
|
702
725
|
const body = [
|
|
703
726
|
"# Inbox",
|
|
704
727
|
"",
|
|
705
728
|
"Ideas with no run yet. One line each, same format as a run's backlog.",
|
|
706
729
|
"",
|
|
707
|
-
...lines.flatMap((l) => [`- ${l.status}: ${l.text}`, ...noteLines(l.notes)]),
|
|
730
|
+
...lines.flatMap((l) => { const a = stamp(l); return [`- ${l.status}: ${l.text}${a ? ` <!-- added:${a} -->` : ""}`, ...noteLines(l.notes)]; }),
|
|
708
731
|
];
|
|
709
732
|
writeFileSync(join(state, "inbox.md"), body.join("\n") + "\n");
|
|
710
733
|
}
|
|
@@ -863,6 +886,7 @@ export function abandonRun(st: string, id: string, why: string): string | null {
|
|
|
863
886
|
try {
|
|
864
887
|
const run = JSON.parse(readText(file));
|
|
865
888
|
if (run.stage === "abandoned") return "That run is already abandoned.";
|
|
889
|
+
if (run.stage === "done") return "That run is already done.";
|
|
866
890
|
run.stage = "abandoned";
|
|
867
891
|
run.awaiting = null;
|
|
868
892
|
run.updated = new Date().toISOString();
|
|
@@ -961,15 +985,20 @@ export function boardCards(states: string[], now: number): Card[] {
|
|
|
961
985
|
if (!bodies.has(f)) { try { bodies.set(f, readText(f)); } catch { bodies.set(f, ""); } }
|
|
962
986
|
return bodies.get(f)!;
|
|
963
987
|
};
|
|
964
|
-
|
|
988
|
+
// Each request with when it was written, so a run older than an idea cannot retire it.
|
|
989
|
+
const requests: { at: number; text: string }[] = [];
|
|
965
990
|
// Each request line on its own, quote and list markers stripped: what an idea
|
|
966
991
|
// nobody launched from the board is matched against.
|
|
967
|
-
const lines: string[] = [];
|
|
992
|
+
const lines: { at: number; text: string }[] = [];
|
|
968
993
|
for (const st of [...states, ...knownProjects().map((d) => join(d, ".kaizen")), join(home, ".kaizen")]
|
|
969
994
|
.filter((d, i, all) => all.indexOf(d) === i && existsSync(join(d, "runs")))) {
|
|
970
|
-
|
|
971
|
-
|
|
972
|
-
|
|
995
|
+
// A run with no state.json yet has no card either: retiring the idea against it
|
|
996
|
+
// leaves the board showing neither until the agent writes one.
|
|
997
|
+
for (const id of readdirSync(join(st, "runs"))) { const b = body(st, id); if (!b || !existsSync(join(st, "runs", id, "state.json"))) continue;
|
|
998
|
+
let at = 0;
|
|
999
|
+
try { at = statSync(join(st, "runs", id, "00-request.md")).mtimeMs; } catch {}
|
|
1000
|
+
requests.push({ at, text: normalise(b) });
|
|
1001
|
+
for (const l of b.split("\n")) lines.push({ at, text: normalise(l.replace(/^[\s>*"'`-]+|[\s"'`.]+$/g, "")) });
|
|
973
1002
|
}
|
|
974
1003
|
}
|
|
975
1004
|
for (const st of states) {
|
|
@@ -986,7 +1015,11 @@ export function boardCards(states: string[], now: number): Card[] {
|
|
|
986
1015
|
// prefix): a short idea like "test" is a substring of half the requests
|
|
987
1016
|
// ever written, and would vanish the moment it was added.
|
|
988
1017
|
const t = normalise(it.text);
|
|
989
|
-
|
|
1018
|
+
// A run written before the idea existed is not this idea's run: an old request
|
|
1019
|
+
// titled "fix this bugs" must not swallow a new idea with the same words.
|
|
1020
|
+
// ponytail: 2s slack for coarse filesystem clocks.
|
|
1021
|
+
const after = (r: { at: number }) => !it.added || r.at >= it.added - 2000;
|
|
1022
|
+
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
1023
|
next.push(it.status === "started"
|
|
991
1024
|
? { 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
1025
|
: { 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 +1425,7 @@ export function trustFolder(dir: string, cfg = join(process.env.CLAUDE_CONFIG_DI
|
|
|
1392
1425
|
|
|
1393
1426
|
// run: the run a decision is for. Without it, an idea starting a new run.
|
|
1394
1427
|
// pick: the tool, model and yolo chosen for a new run; a decision takes the run's own
|
|
1395
|
-
// tool and model, and
|
|
1428
|
+
// tool and model, and stays in Yolo when the run was started in it. card: run it inside the board (agent.launch: card),
|
|
1396
1429
|
// where a pseudo-terminal exists; onChange is told when that agent ends.
|
|
1397
1430
|
const CHILD_ENV = /^(CLAUDECODE|CLAUDE_PID|CLAUDE_CODE_(SESSION_ID|CHILD_SESSION|SESSION_ATTENDED|MESSAGING_SOCKET|MESSAGING_TOKEN))$/;
|
|
1398
1431
|
|
|
@@ -1413,7 +1446,10 @@ export function launchRun(it: Item, from: string, run?: string, pick: { agent?:
|
|
|
1413
1446
|
const wanted = pick.model ?? (own?.cmd === agent.cmd ? own.model : null) ?? undefined;
|
|
1414
1447
|
const model = wanted && plainModel(wanted) ? wanted : undefined;
|
|
1415
1448
|
// Yolo was picked for one tool; a fallback to another opens in its normal mode.
|
|
1416
|
-
|
|
1449
|
+
// A decision on a run started in Yolo keeps it, or its next stage stops on every command.
|
|
1450
|
+
const st = join(projectDir, ".kaizen");
|
|
1451
|
+
const wasYolo = !!run && !pick.agent && readSessions(st).some((s) => s.yolo && s.cmd === agent.cmd && ownerOf(st, s) === run);
|
|
1452
|
+
const yolo = (wasYolo || (!!pick.yolo && agent.cmd === pick.agent)) && !!YOLO_FLAGS[agent.cmd];
|
|
1417
1453
|
const flags = [...agentFlags(projectDir, agent.cmd, undefined, model), ...(yolo ? YOLO_FLAGS[agent.cmd] : [])];
|
|
1418
1454
|
// The idea as its run's request will hold it: without the runner word in front.
|
|
1419
1455
|
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
package/skills/kaizen/SKILL.md
CHANGED
|
@@ -108,8 +108,9 @@ rather than dispatching them, then stop after the review: report the findings an
|
|
|
108
108
|
the user choose, rather than entering the fix loop. The loop
|
|
109
109
|
exists because a fix to something intricate can be worse than the defect — it is not
|
|
110
110
|
worth two more stages to confirm a one-line message now reads correctly. A finding at
|
|
111
|
-
`high` or above
|
|
112
|
-
severity is the loop's whole reason for existing
|
|
111
|
+
`high` or above still enters the fix loop whatever the run's size, since that
|
|
112
|
+
severity is the loop's whole reason for existing — on the same runner, which is fixed
|
|
113
|
+
at intake.
|
|
113
114
|
|
|
114
115
|
**Short work still gets a run.** A one-line change is not skipped past the stages: the card on the board moves through plan, build and review like any other. Run it with `runner: lite` and the short path above — a brief plan, the build, a short review — so the cost stays small and the run stays visible.
|
|
115
116
|
|
|
@@ -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/skills/kaizen/spec.md
CHANGED
|
@@ -143,7 +143,7 @@ the config said the day someone came back to it.
|
|
|
143
143
|
If the request remains ambiguous enough that a plan would be guesswork, ask now. One
|
|
144
144
|
round of questions here is cheaper than a rejected plan.
|
|
145
145
|
|
|
146
|
-
**Short work is not skipped.** Even a one-line change gets `01-plan.md` and `04-review.md`, so the run moves through every stage. Keep them brief and run under `runner: lite
|
|
146
|
+
**Short work is not skipped.** Even a one-line change gets `01-plan.md` and `04-review.md`, so the run moves through every stage. Keep them brief and run under `runner: lite`; a `high`+ finding still enters the fix loop, on that same runner.
|
|
147
147
|
|
|
148
148
|
**Follow-ups are runs too.** Both runners record the same artifacts: `01-plan.md`, `03-impl.md`, `04-review.md`, and a final `state.json` — `lite` and `full` differ only in who executes the stages. A message the user sends later in the same session is judged by what it asks: a question, explanation or chat is answered, no run; a request to change or produce something is building, so it goes through the flow. While the previous run has not reached `done` (including awaiting its final approval), the change is a revise of that run, recorded in `02-approval.md`. After `done` or `abandoned`, open a new run whose `00-request.md` is the user's words verbatim, then a line `Follow-up of: <previous run id>`. Never reopen a finished run. When unsure whether a message asks for a change, ask once.
|
|
149
149
|
|
package/web/board/cards.ts
CHANGED
|
@@ -9,7 +9,7 @@ import { brief } from "./markdown.ts";
|
|
|
9
9
|
import { open, toggleArchive } from "./panel.ts";
|
|
10
10
|
import { ideaCombo, list, newDir, plogo, short, showList, showable } from "./projects.ts";
|
|
11
11
|
import { askNotify, toast } from "./toast.ts";
|
|
12
|
-
import { resumable, resume } from "./approvals.ts";
|
|
12
|
+
import { answer, decided, resumable, resume } from "./approvals.ts";
|
|
13
13
|
|
|
14
14
|
let filter = null;
|
|
15
15
|
let query = ""; // the header filter box, matched against a card's title and request
|
|
@@ -42,8 +42,11 @@ const nested = (c) =>
|
|
|
42
42
|
const kidsOpen = new Set();
|
|
43
43
|
|
|
44
44
|
// What opening a waiting run does, in words: the card's button and its menu say the same.
|
|
45
|
+
// One already answered from this page says so until the agent moves the run on.
|
|
45
46
|
const actionOf = (c) =>
|
|
46
|
-
c
|
|
47
|
+
decided.has(answer(c))
|
|
48
|
+
? "Sent to agent"
|
|
49
|
+
: c.status !== "waiting"
|
|
47
50
|
? "Open"
|
|
48
51
|
: { "approvals.plan": "Review plan", findings: "See findings", "approvals.review": "Review work" }[c.awaiting] || "Open";
|
|
49
52
|
|
|
@@ -84,7 +87,9 @@ function card(c) {
|
|
|
84
87
|
marks.push(`<span class="mk warn" title="Started in Yolo: the agent runs commands without asking">yolo</span>`);
|
|
85
88
|
else chips.push(`<span class="chip">${esc(NAMES[t] || t)}</span>`);
|
|
86
89
|
}
|
|
87
|
-
if (c.pty && c.pty.alive)
|
|
90
|
+
if (c.pty && c.pty.alive && c.pty.asking)
|
|
91
|
+
marks.push(mk("i-ask", "The agent is asking you something: open it, terminal button at the top", " warn ask"));
|
|
92
|
+
else if (c.pty && c.pty.alive)
|
|
88
93
|
marks.push(mk("i-term", "The agent is running in this card: open it, terminal button at the top", " run"));
|
|
89
94
|
// A gathered idea's notes open with the stamp promote() writes (ingest() wrote it before
|
|
90
95
|
// the icebox); its chip names the source.
|
|
@@ -112,7 +117,7 @@ function card(c) {
|
|
|
112
117
|
const act =
|
|
113
118
|
c.kind === "run" && !archive && !pick
|
|
114
119
|
? c.status === "waiting"
|
|
115
|
-
? `<button class="btn sm pri act" data-a="open">${actionOf(c)}</button>`
|
|
120
|
+
? `<button class="btn sm${decided.has(answer(c)) ? "" : " pri"} act" data-a="open">${actionOf(c)}</button>`
|
|
116
121
|
: c.status === "stalled" && resumable(c)
|
|
117
122
|
? `<button class="btn sm act" data-a="resume">Resume</button>`
|
|
118
123
|
: ""
|