kaizen-agent 1.7.1 → 1.7.2

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.
Files changed (39) hide show
  1. package/LICENSE +21 -21
  2. package/README.md +657 -657
  3. package/agents/kaizen-builder.md +77 -77
  4. package/agents/kaizen-planner.md +250 -250
  5. package/agents/kaizen-reviewer.md +127 -127
  6. package/cli/dashboard.ts +1187 -1187
  7. package/cli/install.ts +947 -947
  8. package/cli/publish-gh.sh +27 -27
  9. package/cli/release.sh +45 -45
  10. package/cli/settings.ts +415 -415
  11. package/cli/state.test.ts +197 -197
  12. package/cli/state.ts +704 -704
  13. package/cli/web.ts +423 -423
  14. package/commands/kaizen-abort.md +5 -5
  15. package/commands/kaizen-approve.md +5 -5
  16. package/commands/kaizen-auto.md +6 -6
  17. package/commands/kaizen-backlog.md +5 -5
  18. package/commands/kaizen-config.md +5 -5
  19. package/commands/kaizen-full.md +5 -5
  20. package/commands/kaizen-init.md +5 -5
  21. package/commands/kaizen-install.md +5 -5
  22. package/commands/kaizen-lite.md +5 -5
  23. package/commands/kaizen-plan.md +6 -6
  24. package/commands/kaizen-reject.md +6 -6
  25. package/commands/kaizen-review.md +6 -6
  26. package/commands/kaizen-run.md +5 -5
  27. package/commands/kaizen-status.md +5 -5
  28. package/package.json +1 -1
  29. package/skills/kaizen/README.md +281 -281
  30. package/skills/kaizen/SKILL.md +440 -440
  31. package/skills/kaizen/adapters.md +77 -77
  32. package/skills/kaizen/config.default.yml +167 -167
  33. package/skills/kaizen/spec-build.md +119 -119
  34. package/skills/kaizen/spec-main.md +104 -104
  35. package/skills/kaizen/spec-plan.md +201 -201
  36. package/skills/kaizen/spec-review.md +121 -121
  37. package/skills/kaizen/spec.md +153 -153
  38. package/skills/kaizen-help/SKILL.md +129 -129
  39. package/web/board.html +520 -513
package/cli/state.test.ts CHANGED
@@ -1,197 +1,197 @@
1
- // State module against a throwaway .kaizen/. Nothing here spawns a terminal: the
2
- // launch path is covered only up to the command it would run.
3
- import { test, expect, beforeAll, afterAll } from "bun:test";
4
- import { mkdtempSync, mkdirSync, writeFileSync, readFileSync, rmSync, utimesSync } from "node:fs";
5
- import { join } from "node:path";
6
- import { tmpdir } from "node:os";
7
- import {
8
- readRuns, readInbox, writeInbox, replaceIdea, abandonRun, allBacklog, backlog,
9
- statusOf, columnOf, parseItem, startedRuns, boardCards, readArchive, setArchived,
10
- requestOf, agentFlags, readNotes, writeNotes, manualCommand, parseNotes, formatNote, appendNote, saveAttachment,
11
- } from "./state.ts";
12
-
13
- let state: string;
14
- const run = (id: string, s: object, request = "") => {
15
- const dir = join(state, "runs", id);
16
- mkdirSync(dir, { recursive: true });
17
- writeFileSync(join(dir, "state.json"), JSON.stringify(s));
18
- if (request) writeFileSync(join(dir, "00-request.md"), request);
19
- return dir;
20
- };
21
-
22
- beforeAll(() => {
23
- state = join(mkdtempSync(join(tmpdir(), "kaizen-")), ".kaizen");
24
- mkdirSync(state, { recursive: true });
25
- run("2026-09-01-waiting", { stage: "plan", awaiting: "approvals.plan" }, "# Request\n\nAdd OAuth login\n");
26
- run("2026-09-02-done", { stage: "done", awaiting: null }, "# Request\n\nRename cli flags\n");
27
- const old = run("2026-09-03-stale", { stage: "build", awaiting: null });
28
- const then = new Date(Date.now() - 2 * 3600_000);
29
- utimesSync(join(old, "state.json"), then, then);
30
- writeFileSync(join(state, "runs", "2026-09-02-done", "06-backlog.md"),
31
- "# 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");
32
- writeFileSync(join(state, "backlog.md"), "- open: orphaned item\n");
33
- writeInbox(state, [
34
- { status: "open", text: "add oauth login" },
35
- { status: "open", text: "retry failed webhooks" },
36
- { status: "started", text: "nightly digest" },
37
- { status: "rejected", text: "gantt view | not this product" },
38
- ]);
39
- });
40
- afterAll(() => rmSync(join(state, ".."), { recursive: true, force: true }));
41
-
42
- test("readRuns: newest first, mtime as moved", () => {
43
- const runs = readRuns(state);
44
- expect(runs.map((r) => r.id)).toEqual(["2026-09-03-stale", "2026-09-02-done", "2026-09-01-waiting"]);
45
- expect(runs[2]!.awaiting).toBe("approvals.plan");
46
- expect(runs[0]!.moved).toBeLessThan(Date.now() - 3600_000);
47
- });
48
-
49
- test("statusOf: waiting beats stage, mtime decides running vs stalled", () => {
50
- const now = Date.now();
51
- const [stale, done, waiting] = readRuns(state);
52
- expect(statusOf(waiting!, now)).toBe("waiting");
53
- expect(statusOf(done!, now)).toBe("done");
54
- expect(statusOf(stale!, now)).toBe("stalled");
55
- expect(statusOf({ ...stale!, moved: now }, now)).toBe("running");
56
- expect(statusOf({ ...stale!, stage: "abandoned", awaiting: "x" }, now)).toBe("abandoned");
57
- });
58
-
59
- test("columnOf: every stage word lands somewhere", () => {
60
- expect(["plan", "build", "review", "done", "abandoned", "weird"].map((s) => columnOf(s))).toEqual([1, 2, 3, 4, 4, 1]);
61
- expect(columnOf("implement", "approvals.review")).toBe(3);
62
- expect(columnOf("implement", "findings")).toBe(3);
63
- expect(columnOf("implement", "approvals.each_file")).toBe(2);
64
- expect(columnOf("plan", "approvals.plan")).toBe(1);
65
- });
66
-
67
- test("inbox round-trips and ignores malformed lines", () => {
68
- writeFileSync(join(state, "inbox.md"), readFileSync(join(state, "inbox.md"), "utf8") + "not an item\n");
69
- expect(readInbox(state).map((l) => l.status)).toEqual(["open", "open", "started", "rejected"]);
70
- });
71
-
72
- test("boardCards: idea retired once a run's request contains it; started idea sits in planning", () => {
73
- const cards = boardCards([state], Date.now());
74
- const ideas = cards.filter((k) => k.kind === "idea").map((k) => [k.text, k.column, k.status]);
75
- expect(ideas).toEqual([["retry failed webhooks", 0, "idea"], ["nightly digest", 1, "starting"]]);
76
- const runs = cards.filter((k) => k.kind === "run").map((k) => [k.id, k.column, k.status]);
77
- expect(runs).toEqual([["2026-09-03-stale", 2, "stalled"], ["2026-09-02-done", 4, "done"], ["2026-09-01-waiting", 1, "waiting"]]);
78
- });
79
-
80
- test("replaceIdea edits, rejects, deletes by text", () => {
81
- replaceIdea(state, "retry failed webhooks", { status: "open", text: "retry failed webhooks twice" });
82
- expect(readInbox(state).some((l) => l.text === "retry failed webhooks twice")).toBe(true);
83
- replaceIdea(state, "retry failed webhooks twice", { status: "rejected", text: "retry failed webhooks twice | no" });
84
- replaceIdea(state, "nightly digest", null);
85
- expect(readInbox(state).map((l) => l.status)).toEqual(["open", "rejected", "rejected"]);
86
- });
87
-
88
- test("abandonRun: flips stage, clears awaiting, records the reason, refuses twice", () => {
89
- expect(abandonRun(state, "2026-09-01-waiting", "changed my mind")).toBeNull();
90
- const s = JSON.parse(readFileSync(join(state, "runs", "2026-09-01-waiting", "state.json"), "utf8"));
91
- expect([s.stage, s.awaiting]).toEqual(["abandoned", null]);
92
- expect(readFileSync(join(state, "runs", "2026-09-01-waiting", "02-approval.md"), "utf8")).toContain("changed my mind");
93
- expect(abandonRun(state, "2026-09-01-waiting", "again")).toMatch(/already/);
94
- });
95
-
96
- test("backlog: open items only, orphanage last, count matches", () => {
97
- const groups = allBacklog(state);
98
- expect(groups.map((g) => g.run)).toEqual(["2026-09-02-done", "Orphaned"]);
99
- expect(groups[0]!.items.length).toBe(2);
100
- expect(backlog(state)).toBe(3);
101
- });
102
-
103
- test("parseItem: finding line splits into where, severity, text", () => {
104
- const it = parseItem("`cli/x.ts:12`: low: first thing. Fix: do it.");
105
- expect([it.where, it.severity, it.text]).toEqual(["x.ts:12", "low", "first thing."]);
106
- expect(parseItem("plain item").severity).toBeNull();
107
- });
108
-
109
- test("startedRuns: normalised request bodies", () => {
110
- expect(startedRuns(state).sort()).toEqual(["# request add oauth login", "# request rename cli flags"]);
111
- });
112
-
113
- test("archive: a run is listed, hidden by flag, never moved", () => {
114
- setArchived(state, "2026-09-02-done", true);
115
- expect([...readArchive(state)]).toEqual(["2026-09-02-done"]);
116
- const card = boardCards([state], Date.now()).find((k) => k.id === "2026-09-02-done")!;
117
- expect([card.archived, card.column]).toEqual([true, 4]);
118
- setArchived(state, "2026-09-02-done", false);
119
- expect(readArchive(state).size).toBe(0);
120
- expect(boardCards([state], Date.now()).find((k) => k.id === "2026-09-02-done")!.archived).toBe(false);
121
- });
122
-
123
- test("archive: an idea keeps its line with status archived", () => {
124
- writeInbox(state, [{ status: "open", text: "park this", notes: "kept" }, { status: "archived", text: "parked zq7" }]);
125
- const ideas = boardCards([state], Date.now()).filter((k) => k.kind === "idea");
126
- expect(ideas.map((k) => [k.text, k.archived])).toEqual([["park this", false], ["parked zq7", true]]);
127
- });
128
-
129
- test("notes: indented lines under a bullet round-trip; a legacy file is untouched", () => {
130
- const legacy = "# Inbox\n\nIdeas with no run yet. One line each, same format as a run's backlog.\n\n- open: a\n- rejected: b | why\n";
131
- writeFileSync(join(state, "inbox.md"), legacy);
132
- writeInbox(state, readInbox(state));
133
- expect(readFileSync(join(state, "inbox.md"), "utf8")).toBe(legacy);
134
- 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" }]);
135
- const [a, b] = readInbox(state);
136
- expect(a).toEqual({ status: "open", text: "with notes", notes: "see https://x.y/z\ncli/state.ts:12 | not a reason" });
137
- expect(b).toEqual({ status: "open", text: "plain zq8" });
138
- });
139
-
140
- test("notes: survive reject and started; edit can replace or clear them", () => {
141
- replaceIdea(state, "with notes", { status: "started", text: "with notes" });
142
- expect(readInbox(state)[0]!.notes).toContain("https://x.y/z");
143
- replaceIdea(state, "with notes", { status: "rejected", text: "with notes | no" });
144
- expect(readInbox(state)[0]!.notes).toContain("https://x.y/z");
145
- replaceIdea(state, "plain zq8", { status: "open", text: "plain zq8", notes: "" });
146
- expect(readInbox(state)[1]!.notes).toBeUndefined();
147
- expect(boardCards([state], Date.now()).find((k) => k.text === "plain zq8")!.notes).toBeUndefined();
148
- });
149
-
150
- test("notes: the prompt is text, blank line, notes; still one quoted argument", () => {
151
- expect(requestOf("/kaizen do x")).toBe("/kaizen do x");
152
- expect(requestOf("/kaizen do x", "l1\nl2\n")).toBe("/kaizen do x\n\nl1\nl2");
153
- const cmd = manualCommand("/p", "claude", requestOf("/kaizen do x", "l1"));
154
- if (process.platform !== "win32") expect(cmd).toBe('cd "/p" && claude "/kaizen do x\n\nl1"');
155
- });
156
-
157
- test("notes: a run's notes.md is read beside it, written only for a real run", () => {
158
- expect(writeNotes(state, "2026-09-09-nope", "x")).toBe("no such run");
159
- expect(writeNotes(state, "2026-09-01-waiting", " ref: docs/a.md ")).toBeNull();
160
- expect(readNotes(state, "2026-09-01-waiting")).toBe("ref: docs/a.md\n");
161
- expect(boardCards([state], Date.now()).find((k) => k.id === "2026-09-01-waiting")!.notes).toBe("ref: docs/a.md\n");
162
- writeNotes(state, "2026-09-01-waiting", "");
163
- expect(readNotes(state, "2026-09-01-waiting")).toBe("");
164
- });
165
-
166
- test("notes log: stamped entries, continuation lines, unstamped preamble", () => {
167
- const src = "hand written\n[2026-09-14 10:00] first\n more\n[2026-09-14 10:05] ![shot](attachments/a.png)\n";
168
- expect(parseNotes(src)).toEqual([
169
- { at: "", text: "hand written" },
170
- { at: "2026-09-14 10:00", text: "first\nmore" },
171
- { at: "2026-09-14 10:05", text: "![shot](attachments/a.png)" },
172
- ]);
173
- expect(formatNote("a\nb", "2026-09-14 10:00")).toBe("[2026-09-14 10:00] a\n b");
174
- });
175
-
176
- test("notes log: append to a run and to an idea; attachments saved and made absolute in the prompt", () => {
177
- expect(appendNote(state, { id: "2026-09-01-waiting" }, "run note")).toBeNull();
178
- expect(parseNotes(readNotes(state, "2026-09-01-waiting")).map((n) => n.text)).toEqual(["run note"]);
179
- writeInbox(state, [{ status: "open", text: "chatty zq6" }]);
180
- expect(appendNote(state, { text: "chatty zq6" }, "one")).toBeNull();
181
- expect(appendNote(state, { text: "chatty zq6" }, "two\nlines")).toBeNull();
182
- expect(appendNote(state, { text: "nope zq6" }, "x")).toBe("no such idea");
183
- expect(parseNotes(readInbox(state)[0]!.notes!).map((n) => n.text)).toEqual(["one", "two\nlines"]);
184
- const rel = saveAttachment(state, null, "../evil name.png", new Uint8Array([1]));
185
- expect(rel).toMatch(/^attachments\/\w+-_evil_name\.png$/);
186
- expect(requestOf("x", `![s](${rel})`, state)).toBe(`x\n\n![s](${join(state, rel)})`);
187
- expect(saveAttachment(state, "2026-09-01-waiting", "b.txt", new Uint8Array([2]))).toMatch(/^runs\/2026-09-01-waiting\/attachments\//);
188
- });
189
-
190
- test("agentFlags: per-tool model and effort from the agent block, inline or nested", () => {
191
- const cfg = `agent:\n default: auto\n codex: { model: gpt-5, effort: low }\n claude:\n model: sonnet\n effort: low\n gemini:\n model: "gemini-2.5-pro"\n`;
192
- expect(agentFlags("/p", "codex", cfg)).toEqual(["-m", "gpt-5", "-c", "model_reasoning_effort=low"]);
193
- expect(agentFlags("/p", "claude", cfg)).toEqual(["--model", "sonnet", "--effort", "low"]);
194
- expect(agentFlags("/p", "gemini", cfg)).toEqual(["-m", "gemini-2.5-pro"]);
195
- expect(agentFlags("/p", "agy", cfg)).toEqual([]);
196
- expect(agentFlags("/p", "codex", "agent:\n default: codex\n")).toEqual([]);
197
- });
1
+ // State module against a throwaway .kaizen/. Nothing here spawns a terminal: the
2
+ // launch path is covered only up to the command it would run.
3
+ import { test, expect, beforeAll, afterAll } from "bun:test";
4
+ import { mkdtempSync, mkdirSync, writeFileSync, readFileSync, rmSync, utimesSync } from "node:fs";
5
+ import { join } from "node:path";
6
+ import { tmpdir } from "node:os";
7
+ import {
8
+ readRuns, readInbox, writeInbox, replaceIdea, abandonRun, allBacklog, backlog,
9
+ statusOf, columnOf, parseItem, startedRuns, boardCards, readArchive, setArchived,
10
+ requestOf, agentFlags, readNotes, writeNotes, manualCommand, parseNotes, formatNote, appendNote, saveAttachment,
11
+ } from "./state.ts";
12
+
13
+ let state: string;
14
+ const run = (id: string, s: object, request = "") => {
15
+ const dir = join(state, "runs", id);
16
+ mkdirSync(dir, { recursive: true });
17
+ writeFileSync(join(dir, "state.json"), JSON.stringify(s));
18
+ if (request) writeFileSync(join(dir, "00-request.md"), request);
19
+ return dir;
20
+ };
21
+
22
+ beforeAll(() => {
23
+ state = join(mkdtempSync(join(tmpdir(), "kaizen-")), ".kaizen");
24
+ mkdirSync(state, { recursive: true });
25
+ run("2026-09-01-waiting", { stage: "plan", awaiting: "approvals.plan" }, "# Request\n\nAdd OAuth login\n");
26
+ run("2026-09-02-done", { stage: "done", awaiting: null }, "# Request\n\nRename cli flags\n");
27
+ const old = run("2026-09-03-stale", { stage: "build", awaiting: null });
28
+ const then = new Date(Date.now() - 2 * 3600_000);
29
+ utimesSync(join(old, "state.json"), then, then);
30
+ writeFileSync(join(state, "runs", "2026-09-02-done", "06-backlog.md"),
31
+ "# 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");
32
+ writeFileSync(join(state, "backlog.md"), "- open: orphaned item\n");
33
+ writeInbox(state, [
34
+ { status: "open", text: "add oauth login" },
35
+ { status: "open", text: "retry failed webhooks" },
36
+ { status: "started", text: "nightly digest" },
37
+ { status: "rejected", text: "gantt view | not this product" },
38
+ ]);
39
+ });
40
+ afterAll(() => rmSync(join(state, ".."), { recursive: true, force: true }));
41
+
42
+ test("readRuns: newest first, mtime as moved", () => {
43
+ const runs = readRuns(state);
44
+ expect(runs.map((r) => r.id)).toEqual(["2026-09-03-stale", "2026-09-02-done", "2026-09-01-waiting"]);
45
+ expect(runs[2]!.awaiting).toBe("approvals.plan");
46
+ expect(runs[0]!.moved).toBeLessThan(Date.now() - 3600_000);
47
+ });
48
+
49
+ test("statusOf: waiting beats stage, mtime decides running vs stalled", () => {
50
+ const now = Date.now();
51
+ const [stale, done, waiting] = readRuns(state);
52
+ expect(statusOf(waiting!, now)).toBe("waiting");
53
+ expect(statusOf(done!, now)).toBe("done");
54
+ expect(statusOf(stale!, now)).toBe("stalled");
55
+ expect(statusOf({ ...stale!, moved: now }, now)).toBe("running");
56
+ expect(statusOf({ ...stale!, stage: "abandoned", awaiting: "x" }, now)).toBe("abandoned");
57
+ });
58
+
59
+ test("columnOf: every stage word lands somewhere", () => {
60
+ expect(["plan", "build", "review", "done", "abandoned", "weird"].map((s) => columnOf(s))).toEqual([1, 2, 3, 4, 4, 1]);
61
+ expect(columnOf("implement", "approvals.review")).toBe(3);
62
+ expect(columnOf("implement", "findings")).toBe(3);
63
+ expect(columnOf("implement", "approvals.each_file")).toBe(2);
64
+ expect(columnOf("plan", "approvals.plan")).toBe(1);
65
+ });
66
+
67
+ test("inbox round-trips and ignores malformed lines", () => {
68
+ writeFileSync(join(state, "inbox.md"), readFileSync(join(state, "inbox.md"), "utf8") + "not an item\n");
69
+ expect(readInbox(state).map((l) => l.status)).toEqual(["open", "open", "started", "rejected"]);
70
+ });
71
+
72
+ test("boardCards: idea retired once a run's request contains it; started idea sits in planning", () => {
73
+ const cards = boardCards([state], Date.now());
74
+ const ideas = cards.filter((k) => k.kind === "idea").map((k) => [k.text, k.column, k.status]);
75
+ expect(ideas).toEqual([["retry failed webhooks", 0, "idea"], ["nightly digest", 1, "starting"]]);
76
+ const runs = cards.filter((k) => k.kind === "run").map((k) => [k.id, k.column, k.status]);
77
+ expect(runs).toEqual([["2026-09-03-stale", 2, "stalled"], ["2026-09-02-done", 4, "done"], ["2026-09-01-waiting", 1, "waiting"]]);
78
+ });
79
+
80
+ test("replaceIdea edits, rejects, deletes by text", () => {
81
+ replaceIdea(state, "retry failed webhooks", { status: "open", text: "retry failed webhooks twice" });
82
+ expect(readInbox(state).some((l) => l.text === "retry failed webhooks twice")).toBe(true);
83
+ replaceIdea(state, "retry failed webhooks twice", { status: "rejected", text: "retry failed webhooks twice | no" });
84
+ replaceIdea(state, "nightly digest", null);
85
+ expect(readInbox(state).map((l) => l.status)).toEqual(["open", "rejected", "rejected"]);
86
+ });
87
+
88
+ test("abandonRun: flips stage, clears awaiting, records the reason, refuses twice", () => {
89
+ expect(abandonRun(state, "2026-09-01-waiting", "changed my mind")).toBeNull();
90
+ const s = JSON.parse(readFileSync(join(state, "runs", "2026-09-01-waiting", "state.json"), "utf8"));
91
+ expect([s.stage, s.awaiting]).toEqual(["abandoned", null]);
92
+ expect(readFileSync(join(state, "runs", "2026-09-01-waiting", "02-approval.md"), "utf8")).toContain("changed my mind");
93
+ expect(abandonRun(state, "2026-09-01-waiting", "again")).toMatch(/already/);
94
+ });
95
+
96
+ test("backlog: open items only, orphanage last, count matches", () => {
97
+ const groups = allBacklog(state);
98
+ expect(groups.map((g) => g.run)).toEqual(["2026-09-02-done", "Orphaned"]);
99
+ expect(groups[0]!.items.length).toBe(2);
100
+ expect(backlog(state)).toBe(3);
101
+ });
102
+
103
+ test("parseItem: finding line splits into where, severity, text", () => {
104
+ const it = parseItem("`cli/x.ts:12`: low: first thing. Fix: do it.");
105
+ expect([it.where, it.severity, it.text]).toEqual(["x.ts:12", "low", "first thing."]);
106
+ expect(parseItem("plain item").severity).toBeNull();
107
+ });
108
+
109
+ test("startedRuns: normalised request bodies", () => {
110
+ expect(startedRuns(state).sort()).toEqual(["# request add oauth login", "# request rename cli flags"]);
111
+ });
112
+
113
+ test("archive: a run is listed, hidden by flag, never moved", () => {
114
+ setArchived(state, "2026-09-02-done", true);
115
+ expect([...readArchive(state)]).toEqual(["2026-09-02-done"]);
116
+ const card = boardCards([state], Date.now()).find((k) => k.id === "2026-09-02-done")!;
117
+ expect([card.archived, card.column]).toEqual([true, 4]);
118
+ setArchived(state, "2026-09-02-done", false);
119
+ expect(readArchive(state).size).toBe(0);
120
+ expect(boardCards([state], Date.now()).find((k) => k.id === "2026-09-02-done")!.archived).toBe(false);
121
+ });
122
+
123
+ test("archive: an idea keeps its line with status archived", () => {
124
+ writeInbox(state, [{ status: "open", text: "park this", notes: "kept" }, { status: "archived", text: "parked zq7" }]);
125
+ const ideas = boardCards([state], Date.now()).filter((k) => k.kind === "idea");
126
+ expect(ideas.map((k) => [k.text, k.archived])).toEqual([["park this", false], ["parked zq7", true]]);
127
+ });
128
+
129
+ test("notes: indented lines under a bullet round-trip; a legacy file is untouched", () => {
130
+ const legacy = "# Inbox\n\nIdeas with no run yet. One line each, same format as a run's backlog.\n\n- open: a\n- rejected: b | why\n";
131
+ writeFileSync(join(state, "inbox.md"), legacy);
132
+ writeInbox(state, readInbox(state));
133
+ expect(readFileSync(join(state, "inbox.md"), "utf8")).toBe(legacy);
134
+ 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" }]);
135
+ const [a, b] = readInbox(state);
136
+ expect(a).toEqual({ status: "open", text: "with notes", notes: "see https://x.y/z\ncli/state.ts:12 | not a reason" });
137
+ expect(b).toEqual({ status: "open", text: "plain zq8" });
138
+ });
139
+
140
+ test("notes: survive reject and started; edit can replace or clear them", () => {
141
+ replaceIdea(state, "with notes", { status: "started", text: "with notes" });
142
+ expect(readInbox(state)[0]!.notes).toContain("https://x.y/z");
143
+ replaceIdea(state, "with notes", { status: "rejected", text: "with notes | no" });
144
+ expect(readInbox(state)[0]!.notes).toContain("https://x.y/z");
145
+ replaceIdea(state, "plain zq8", { status: "open", text: "plain zq8", notes: "" });
146
+ expect(readInbox(state)[1]!.notes).toBeUndefined();
147
+ expect(boardCards([state], Date.now()).find((k) => k.text === "plain zq8")!.notes).toBeUndefined();
148
+ });
149
+
150
+ test("notes: the prompt is text, blank line, notes; still one quoted argument", () => {
151
+ expect(requestOf("/kaizen do x")).toBe("/kaizen do x");
152
+ expect(requestOf("/kaizen do x", "l1\nl2\n")).toBe("/kaizen do x\n\nl1\nl2");
153
+ const cmd = manualCommand("/p", "claude", requestOf("/kaizen do x", "l1"));
154
+ if (process.platform !== "win32") expect(cmd).toBe('cd "/p" && claude "/kaizen do x\n\nl1"');
155
+ });
156
+
157
+ test("notes: a run's notes.md is read beside it, written only for a real run", () => {
158
+ expect(writeNotes(state, "2026-09-09-nope", "x")).toBe("no such run");
159
+ expect(writeNotes(state, "2026-09-01-waiting", " ref: docs/a.md ")).toBeNull();
160
+ expect(readNotes(state, "2026-09-01-waiting")).toBe("ref: docs/a.md\n");
161
+ expect(boardCards([state], Date.now()).find((k) => k.id === "2026-09-01-waiting")!.notes).toBe("ref: docs/a.md\n");
162
+ writeNotes(state, "2026-09-01-waiting", "");
163
+ expect(readNotes(state, "2026-09-01-waiting")).toBe("");
164
+ });
165
+
166
+ test("notes log: stamped entries, continuation lines, unstamped preamble", () => {
167
+ const src = "hand written\n[2026-09-14 10:00] first\n more\n[2026-09-14 10:05] ![shot](attachments/a.png)\n";
168
+ expect(parseNotes(src)).toEqual([
169
+ { at: "", text: "hand written" },
170
+ { at: "2026-09-14 10:00", text: "first\nmore" },
171
+ { at: "2026-09-14 10:05", text: "![shot](attachments/a.png)" },
172
+ ]);
173
+ expect(formatNote("a\nb", "2026-09-14 10:00")).toBe("[2026-09-14 10:00] a\n b");
174
+ });
175
+
176
+ test("notes log: append to a run and to an idea; attachments saved and made absolute in the prompt", () => {
177
+ expect(appendNote(state, { id: "2026-09-01-waiting" }, "run note")).toBeNull();
178
+ expect(parseNotes(readNotes(state, "2026-09-01-waiting")).map((n) => n.text)).toEqual(["run note"]);
179
+ writeInbox(state, [{ status: "open", text: "chatty zq6" }]);
180
+ expect(appendNote(state, { text: "chatty zq6" }, "one")).toBeNull();
181
+ expect(appendNote(state, { text: "chatty zq6" }, "two\nlines")).toBeNull();
182
+ expect(appendNote(state, { text: "nope zq6" }, "x")).toBe("no such idea");
183
+ expect(parseNotes(readInbox(state)[0]!.notes!).map((n) => n.text)).toEqual(["one", "two\nlines"]);
184
+ const rel = saveAttachment(state, null, "../evil name.png", new Uint8Array([1]));
185
+ expect(rel).toMatch(/^attachments\/\w+-_evil_name\.png$/);
186
+ expect(requestOf("x", `![s](${rel})`, state)).toBe(`x\n\n![s](${join(state, rel)})`);
187
+ expect(saveAttachment(state, "2026-09-01-waiting", "b.txt", new Uint8Array([2]))).toMatch(/^runs\/2026-09-01-waiting\/attachments\//);
188
+ });
189
+
190
+ test("agentFlags: per-tool model and effort from the agent block, inline or nested", () => {
191
+ const cfg = `agent:\n default: auto\n codex: { model: gpt-5, effort: low }\n claude:\n model: sonnet\n effort: low\n gemini:\n model: "gemini-2.5-pro"\n`;
192
+ expect(agentFlags("/p", "codex", cfg)).toEqual(["-m", "gpt-5", "-c", "model_reasoning_effort=low"]);
193
+ expect(agentFlags("/p", "claude", cfg)).toEqual(["--model", "sonnet", "--effort", "low"]);
194
+ expect(agentFlags("/p", "gemini", cfg)).toEqual(["-m", "gemini-2.5-pro"]);
195
+ expect(agentFlags("/p", "agy", cfg)).toEqual([]);
196
+ expect(agentFlags("/p", "codex", "agent:\n default: codex\n")).toEqual([]);
197
+ });