kaizen-agent 1.8.0 → 1.9.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -302,6 +302,16 @@ serves, the board opens on every project, or on the one picked last time in that
302
302
  detached so it outlives the terminal (pid kept in `~/.kaizen/web.pid`); `--stop` ends it. `kaizen web --shortcut` adds a launcher
303
303
  the OS can find — Spotlight on macOS, the app menu on Linux, a Desktop shortcut on Windows. Each one starts the board in the background (`--daemon`) and opens it; `kaizen web --stop` ends it.
304
304
 
305
+ **A notebook beside the board.** The note icon in the board's header opens
306
+ `/notebook`: markdown notes for the project, kept as plain files in `.kaizen/notes/`
307
+ (folders included, so Obsidian can open the same folder). Notes are edited in place —
308
+ the line you are on shows its markdown, the rest reads typeset, and `/` at the start of a line offers checkboxes, dividers, headings and the other blocks. `[[Other note]]` links
309
+ notes and creates the one that does not exist yet, every note lists what links to it,
310
+ `#tags` filter the list, and a `/` in a note's name puts it in a folder. Notes save as
311
+ you type; a note changed elsewhere since you opened it is never overwritten without
312
+ asking, and a deleted one goes to `.kaizen/notes/.trash/`. Notes link to the board too: `[[run:<id>#finding-2]]`, a tab (`#review`, `#backlog`), a backlog item (`#backlog-3`) or `[[idea:…]]` show as chips that open the board on that item, and a run's Notes tab lists the notes that link to it. The editor is CodeMirror,
313
+ bundled into the package, so the notebook works offline.
314
+
305
315
  **Dictate instead of type.** Every text field on the board — a new idea, its notes, an
306
316
  edit, a reject reason, a note on a run — has a mic. Press it, talk, press it again; the
307
317
  words land at the caret next to whatever you had typed. Recognition is Whisper running
@@ -0,0 +1,27 @@
1
+ // `bun run build:web`: bundles the notebook's script and CodeMirror into
2
+ // web/notebook.js, which is committed, so an install or a checkout serves it with no
3
+ // install step and no network. The first line is a hash of what it was built from;
4
+ // cli/notes.test.ts fails when the committed file no longer matches.
5
+ import { createHash } from "node:crypto";
6
+ import { readFileSync, writeFileSync } from "node:fs";
7
+ import { join, dirname } from "node:path";
8
+
9
+ const root = dirname(import.meta.dir);
10
+ const SOURCES = ["web/notebook.ts", "web/notes-lib.ts"];
11
+ export const OUT = join(root, "web", "notebook.js");
12
+
13
+ // The sources and the exact dependency versions: a CodeMirror bump is a rebuild too.
14
+ export function sourceHash() {
15
+ const h = createHash("sha256");
16
+ for (const f of SOURCES) h.update(readFileSync(join(root, f), "utf8").replace(/\r\n/g, "\n"));
17
+ h.update(JSON.stringify(JSON.parse(readFileSync(join(root, "package.json"), "utf8")).devDependencies ?? {}));
18
+ return h.digest("hex").slice(0, 16);
19
+ }
20
+ export const banner = () => `/* kaizen notebook ${sourceHash()} -- generated by bun run build:web from web/notebook.ts; do not edit */`;
21
+
22
+ if (import.meta.main) {
23
+ const r = await Bun.build({ entrypoints: [join(root, "web", "notebook.ts")], target: "browser", minify: true });
24
+ if (!r.success) { for (const l of r.logs) console.error(l); process.exit(1); }
25
+ writeFileSync(OUT, banner() + "\n" + await r.outputs[0]!.text());
26
+ console.log(` web/notebook.js ${(Bun.file(OUT).size / 1024).toFixed(0)} KB`);
27
+ }
@@ -0,0 +1,221 @@
1
+ // The notebook: file safety in cli/notes.ts, the text rules in web/notes-lib.ts, and
2
+ // the two things that drift silently — the copied design tokens and the committed bundle.
3
+ import { test, expect, beforeEach } from "bun:test";
4
+ import { mkdtempSync, mkdirSync, writeFileSync, readFileSync, existsSync, readdirSync, utimesSync, symlinkSync } from "node:fs";
5
+ import { join, dirname } from "node:path";
6
+ import { tmpdir } from "node:os";
7
+ import { safeName, listNotes, saveNote, renameNote, deleteNote, boardIndex, linksTo } from "./notes.ts";
8
+ import { wikilinks, tags, resolve, safeUrl, tree, uncode, freshName, parseTable, boardLink, resolveRun, anchorWords } from "../web/notes-lib.ts";
9
+ import { sourceHash, OUT } from "./build-web.ts";
10
+
11
+ let state = "";
12
+ beforeEach(() => { state = join(mkdtempSync(join(tmpdir(), "kaizen-notes-")), ".kaizen"); mkdirSync(state); });
13
+ const root = dirname(import.meta.dir);
14
+
15
+ test("safeName: folders allowed, nothing that leaves notes/", () => {
16
+ expect(safeName("Work/Standup")).toBe("Work/Standup");
17
+ expect(safeName(" Idea.md ")).toBe("Idea");
18
+ expect(safeName(" a / b ")).toBe("a/b");
19
+ for (const bad of ["", "../x", "a/../../b", ".hidden", "a/.b", "/etc/passwd", "a\\b", "C:x", "a//b", "a/", "x\ny", "1/2/3/4/5/6/7", "x".repeat(121), 7, null])
20
+ expect(safeName(bad as string)).toBeNull();
21
+ });
22
+
23
+ test("every write route refuses a name outside notes/, and writes nothing", () => {
24
+ for (const bad of ["../escape", "a/../../escape", "/tmp/escape"]) {
25
+ expect(saveNote(state, bad, "x", 0)).toEqual({ ok: false, why: "bad name" });
26
+ expect(renameNote(state, bad, "ok")).toBe("bad name");
27
+ expect(deleteNote(state, bad)).toBe("bad name");
28
+ }
29
+ saveNote(state, "ok", "x", 0);
30
+ expect(renameNote(state, "ok", "../../escape")).toBe("bad name");
31
+ expect(readdirSync(dirname(state)).sort()).toEqual([".kaizen"]);
32
+ expect(readdirSync(state)).toEqual(["notes"]);
33
+ });
34
+
35
+ test("save: creates folders, then refuses a stale base and returns what is on disk", () => {
36
+ const a = saveNote(state, "Work/Standup", "one", 0);
37
+ expect(a.ok).toBe(true);
38
+ if (!a.ok) return;
39
+ expect(readFileSync(join(state, "notes", "Work", "Standup.md"), "utf8")).toBe("one");
40
+ // Created twice: the second is a conflict, not an overwrite.
41
+ expect(saveNote(state, "Work/Standup", "again", 0).ok).toBe(false);
42
+ // Someone else writes it.
43
+ const f = join(state, "notes", "Work", "Standup.md");
44
+ writeFileSync(f, "theirs"); utimesSync(f, new Date(), new Date(Date.now() + 5000));
45
+ const c = saveNote(state, "Work/Standup", "mine", a.mtime);
46
+ expect(c).toMatchObject({ ok: false, why: "conflict", text: "theirs" });
47
+ expect(readFileSync(f, "utf8")).toBe("theirs");
48
+ // Saving over what is there now works.
49
+ if (!c.ok && c.mtime) expect(saveNote(state, "Work/Standup", "mine", c.mtime).ok).toBe(true);
50
+ expect(readFileSync(f, "utf8")).toBe("mine");
51
+ // A note deleted elsewhere, saved with an old base: a conflict with nothing there.
52
+ expect(saveNote(state, "Gone", "x", 123)).toMatchObject({ ok: false, why: "conflict", text: "" });
53
+ });
54
+
55
+ test("save keeps the text byte for byte, CRLF included", () => {
56
+ const text = "# T\r\n\r\n- [ ] a\r\n\ttab \r\n";
57
+ saveNote(state, "crlf", text, 0);
58
+ expect(readFileSync(join(state, "notes", "crlf.md"), "utf8")).toBe(text);
59
+ });
60
+
61
+ test("list: recursive, newest first, dot folders skipped", () => {
62
+ saveNote(state, "a", "A", 0);
63
+ saveNote(state, "Work/b", "B", 0);
64
+ const later = join(state, "notes", "Work", "b.md"); utimesSync(later, new Date(), new Date(Date.now() + 5000));
65
+ mkdirSync(join(state, "notes", ".obsidian")); writeFileSync(join(state, "notes", ".obsidian", "x.md"), "no");
66
+ writeFileSync(join(state, "notes", "skip.txt"), "no");
67
+ expect(listNotes(state).map((n) => n.name)).toEqual(["Work/b", "a"]);
68
+ expect(listNotes(join(state, "nothing"))).toEqual([]);
69
+ });
70
+
71
+ test("list: only names the notebook can save back; a symlinked folder once, never a loop", () => {
72
+ const n = join(state, "notes");
73
+ saveNote(state, "ok", "x", 0);
74
+ writeFileSync(join(n, "What?.md"), "x");
75
+ writeFileSync(join(n, "a: b.md"), "x");
76
+ mkdirSync(join(n, "1/2/3/4/5/6"), { recursive: true }); writeFileSync(join(n, "1/2/3/4/5/6/deep.md"), "x");
77
+ mkdirSync(join(n, "trail ")); writeFileSync(join(n, "trail ", "x.md"), "x");
78
+ symlinkSync("..", join(n, "up"));
79
+ expect(listNotes(state).map((x) => x.name)).toEqual(["ok"]);
80
+ });
81
+
82
+ test("rename moves between folders, refuses to clobber, prunes the emptied folder", () => {
83
+ saveNote(state, "Old/x", "X", 0);
84
+ saveNote(state, "y", "Y", 0);
85
+ expect(renameNote(state, "Old/x", "y")).toBe("a note with that name exists");
86
+ expect(renameNote(state, "nope", "z")).toBe("no such note");
87
+ expect(renameNote(state, "Old/x", "New/Deep/x")).toBeNull();
88
+ expect(existsSync(join(state, "notes", "Old"))).toBe(false);
89
+ expect(readFileSync(join(state, "notes", "New", "Deep", "x.md"), "utf8")).toBe("X");
90
+ });
91
+
92
+ test("delete moves to .trash, never unlinks", () => {
93
+ saveNote(state, "F/gone", "keep me", 0);
94
+ expect(deleteNote(state, "F/gone")).toBeNull();
95
+ expect(deleteNote(state, "F/gone")).toBe("no such note");
96
+ const trash = readdirSync(join(state, "notes", ".trash"));
97
+ expect(trash.length).toBe(1);
98
+ expect(trash[0]).toEndWith("-F~gone.md");
99
+ expect(readFileSync(join(state, "notes", ".trash", trash[0]!), "utf8")).toBe("keep me");
100
+ expect(existsSync(join(state, "notes", "F"))).toBe(false);
101
+ expect(listNotes(state)).toEqual([]);
102
+ });
103
+
104
+ test("wikilinks: plain, labelled, anchored; none inside code", () => {
105
+ const t = "See [[Alpha]] and [[Work/Beta|the beta]] and [[Gamma#Part]].\n`[[not]]`\n```\n[[nor]]\n```\n";
106
+ const w = wikilinks(t);
107
+ expect(w.map((x) => [x.name, x.label])).toEqual([["Alpha", "Alpha"], ["Work/Beta", "the beta"], ["Gamma", "Gamma#Part"]]);
108
+ expect(t.slice(w[1]!.from, w[1]!.to)).toBe("[[Work/Beta|the beta]]");
109
+ // uncode keeps offsets: blanked text is the same length.
110
+ expect(uncode(t).length).toBe(t.length);
111
+ });
112
+
113
+ test("tags: words after a space or at line start; not headings, numbers, urls or code", () => {
114
+ const t = "#plan and #Work/Q3 here\n# Heading\nissue #12 x#y http://a.b/#frag\n`#code`\n(#paren)";
115
+ expect(tags(t).map((x) => x.tag)).toEqual(["plan", "work/q3", "paren"]);
116
+ });
117
+
118
+ test("indented code is code: no tags or wikilinks in it; list continuations still count", () => {
119
+ const t = "Para #one\n\n #include <x>\n [[inCode]]\n\n\tmore #nottag\n\nback #two\n- item\n #three [[Cont]]\n";
120
+ expect(tags(t).map((x) => x.tag)).toEqual(["one", "two", "three"]);
121
+ expect(wikilinks(t).map((x) => x.name)).toEqual(["Cont"]);
122
+ expect(uncode(t).length).toBe(t.length);
123
+ });
124
+
125
+ test("resolve: exact name first, then by last segment, ignoring case", () => {
126
+ const names = ["Work/Standup", "Home/Standup", "Alpha"];
127
+ expect(resolve("alpha", names)).toBe("Alpha");
128
+ expect(resolve("Work/Standup", names)).toBe("Work/Standup");
129
+ expect(resolve("standup", names)).toBe("Home/Standup");
130
+ expect(resolve("Nope", names)).toBeNull();
131
+ });
132
+
133
+ test("safeUrl: web, mail and relative only", () => {
134
+ for (const ok of ["https://x.dev", "http://x", "mailto:a@b.c", "Other note.md", "img/a.png", "#part"]) expect(safeUrl(ok)).toBe(ok);
135
+ for (const bad of ["javascript:alert(1)", " JavaScript:alert(1)", "data:text/html,x", "vbscript:x", "file:///etc/passwd", "//evil.com/x", "java\tscript:x", ""])
136
+ expect(safeUrl(bad)).toBeNull();
137
+ });
138
+
139
+ test("parseTable: header, alignment, rows padded, escaped pipes kept", () => {
140
+ expect(parseTable("| A | B | C |\n|:--|:-:|--:|\n| 1 | a \\| b |\n|2|3|4|5|\n")).toEqual({
141
+ head: ["A", "B", "C"], align: ["left", "center", "right"], rows: [["1", "a | b", ""], ["2", "3", "4"]],
142
+ });
143
+ expect(parseTable("A | B\r\n--- | ---\r\nx | y").rows).toEqual([["x", "y"]]);
144
+ });
145
+
146
+ test("board links: kinds, tabs, items; a note link is not one", () => {
147
+ const w = (t: string) => wikilinks(t)[0]!;
148
+ expect(boardLink(w("[[run:2026-10-01-notebook]]"))).toEqual({ kind: "run", id: "2026-10-01-notebook" });
149
+ expect(boardLink(w("[[run:notebook#Review|see]]"))).toEqual({ kind: "run", id: "notebook", tab: "review" });
150
+ expect(boardLink(w("[[run:notebook#finding-2]]"))).toEqual({ kind: "run", id: "notebook", tab: "review", item: { type: "finding", n: 2 } });
151
+ expect(boardLink(w("[[run:notebook#backlog-13]]"))).toEqual({ kind: "run", id: "notebook", tab: "backlog", item: { type: "backlog", n: 13 } });
152
+ expect(boardLink(w("[[run:notebook#nonsense]]"))).toEqual({ kind: "run", id: "notebook" });
153
+ expect(boardLink(w("[[idea:fix #12 in the FAQ]]"))).toEqual({ kind: "idea", text: "fix #12 in the FAQ" });
154
+ expect(boardLink(w("[[Standup]]"))).toBeNull();
155
+ expect(boardLink(w("[[run:../../x]]"))).toBeNull();
156
+ expect(w("[[Note#Part|p]]")).toMatchObject({ name: "Note", anchor: "Part", target: "Note#Part", label: "p" });
157
+ expect(anchorWords({ kind: "run", id: "x", tab: "review", item: { type: "finding", n: 2 } })).toBe("finding 2");
158
+ });
159
+
160
+ test("resolveRun: exact id, else the newest run of that name", () => {
161
+ const ids = ["2026-09-01-notebook", "2026-10-01-notebook", "2026-10-01-notebook-board-links"];
162
+ expect(resolveRun("2026-09-01-notebook", ids)).toBe("2026-09-01-notebook");
163
+ expect(resolveRun("notebook", ids)).toBe("2026-10-01-notebook");
164
+ expect(resolveRun("board-links", ids)).toBeNull();
165
+ expect(resolveRun("nope", ids)).toBeNull();
166
+ });
167
+
168
+ test("boardIndex and linksTo: runs with findings and every backlog line numbered; notes that link a run", () => {
169
+ const run = (id: string, files: Record<string, string>) => {
170
+ mkdirSync(join(state, "runs", id), { recursive: true });
171
+ writeFileSync(join(state, "runs", id, "state.json"), JSON.stringify({ id, stage: "done", awaiting: null }));
172
+ for (const [f, t] of Object.entries(files)) writeFileSync(join(state, "runs", id, f), t);
173
+ };
174
+ run("2026-10-01-notes", {
175
+ "00-request.md": "# Request\n\n> make notes\n",
176
+ "04-review.md": "# Review\n\n## Findings\n\n1. a.ts:1: high: first. fix.\n2. b.ts:2: low: second. fix.\n",
177
+ "06-backlog.md": "# Backlog\n\n- done: one | x\n- open: two | y\n- rejected: three | z\n",
178
+ });
179
+ run("2026-10-02-other", {});
180
+ writeFileSync(join(state, "inbox.md"), "- open: an idea here\n");
181
+ const ix = boardIndex(state);
182
+ const r = ix.runs.find((x) => x.id === "2026-10-01-notes")!;
183
+ expect(r.short).toBe("notes");
184
+ expect(r.findings.map((f) => f.n)).toEqual([1, 2]);
185
+ expect(r.backlog).toEqual([{ n: 1, status: "done", text: "one | x" }, { n: 2, status: "open", text: "two | y" }, { n: 3, status: "rejected", text: "three | z" }]);
186
+ expect(ix.ideas.map((i) => i.text)).toEqual(["an idea here"]);
187
+ saveNote(state, "A", "see [[run:notes#finding-1]]", 0);
188
+ saveNote(state, "B", "see [[run:2026-10-02-other]] and [[notes]]", 0);
189
+ saveNote(state, "C", "`[[run:notes]]` in code", 0);
190
+ expect(linksTo(state, "2026-10-01-notes")).toEqual(["A"]);
191
+ expect(linksTo(state, "2026-10-02-other")).toEqual(["B"]);
192
+ });
193
+
194
+ test("tree and freshName", () => {
195
+ const tr = tree(["b", "Z/c", "A/x", "A/B/y", "a2"]);
196
+ expect([...tr.folders.keys()]).toEqual(["A", "Z"]);
197
+ expect(tr.notes).toEqual(["a2", "b"]);
198
+ expect([...tr.folders.get("A")!.folders.keys()]).toEqual(["B"]);
199
+ expect(freshName(["Untitled", "untitled 2"])).toBe("Untitled 3");
200
+ expect(freshName(["Untitled"], "Work")).toBe("Work/Untitled");
201
+ });
202
+
203
+ test("the notebook's design tokens are the board's, line for line", () => {
204
+ const block = (f: string) => {
205
+ const s = readFileSync(join(root, "web", f), "utf8").split("\n");
206
+ const at = s.findIndex((l) => l.startsWith(":root{--bg:"));
207
+ return s.slice(at, at + 8).join("\n");
208
+ };
209
+ expect(block("notes.html")).toBe(block("board.html"));
210
+ expect(block("board.html")).toContain("data-theme=dark]{--shade");
211
+ const syms = (f: string) => readFileSync(join(root, "web", f), "utf8").split("\n").filter((l) => l.startsWith('<symbol id="s-')).join("\n");
212
+ expect(syms("notes.html")).toBe(syms("board.html"));
213
+ expect(syms("board.html").split("\n").length).toBe(6);
214
+ const mascot = (f: string) => readFileSync(join(root, "web", f), "utf8").split("\n").find((l) => l.startsWith(".mascot{"));
215
+ expect(mascot("notes.html")).toBe(mascot("board.html")!);
216
+ });
217
+
218
+ test("web/notebook.js was built from the sources as they are (bun run build:web)", () => {
219
+ const first = readFileSync(OUT, "utf8").split("\n", 1)[0]!;
220
+ expect(first).toContain(`kaizen notebook ${sourceHash()} `);
221
+ });
package/cli/notes.ts ADDED
@@ -0,0 +1,139 @@
1
+ // The notebook's files: plain markdown under <state>/notes/, folders and all, so any
2
+ // editor (Obsidian included) can open the same folder. Every name that arrives from
3
+ // the page goes through safeName before it becomes a path.
4
+ import { existsSync, mkdirSync, readdirSync, readFileSync, realpathSync, renameSync, rmdirSync, statSync, writeFileSync } from "node:fs";
5
+ import { join, dirname, resolve, sep } from "node:path";
6
+ import { boardCards, reviewFindings } from "./state.ts";
7
+ import { wikilinks, boardLink, resolveRun } from "../web/notes-lib.ts";
8
+
9
+ export type Note = { name: string; mtime: number; text: string };
10
+
11
+ export const notesDir = (state: string) => join(state, "notes");
12
+
13
+ // A note name is its path under notes/ without `.md`: `Work/Standup`. At most six
14
+ // folders deep; no segment that is empty, dotted, or holds a character some
15
+ // filesystem refuses. Returns the tidied name, or null.
16
+ export function safeName(name: unknown): string | null {
17
+ if (typeof name !== "string") return null;
18
+ const parts = name.trim().replace(/\.md$/i, "").split("/").map((p) => p.trim());
19
+ if (parts.length > 6) return null;
20
+ for (const p of parts) {
21
+ if (!p || p.length > 120 || p.startsWith(".") || /[\\:*?"<>|\x00-\x1f]/.test(p)) return null;
22
+ }
23
+ return parts.join("/");
24
+ }
25
+
26
+ // The file for a name, or null when the name is unsafe or would land outside notes/.
27
+ function fileOf(state: string, name: unknown) {
28
+ const n = safeName(name);
29
+ if (!n) return null;
30
+ const root = resolve(notesDir(state));
31
+ const f = resolve(root, n + ".md");
32
+ return f.startsWith(root + sep) ? { name: n, file: f } : null;
33
+ }
34
+
35
+ // ponytail: the whole vault is read on every list; fine for hundreds of notes, send
36
+ // names and mtimes only (and text on open) if a vault reaches thousands.
37
+ // Only names safeName keeps as they are: a file the notebook could not save back
38
+ // (`What?.md`, seven folders deep) is left to the editor that made it.
39
+ export function listNotes(state: string): Note[] {
40
+ const root = notesDir(state), out: Note[] = [], seen = new Set<string>();
41
+ const walk = (dir: string, prefix: string) => {
42
+ let names: string[];
43
+ // A symlinked folder is followed once: `ln -s .. notes/up` is not a loop.
44
+ try { const real = realpathSync(dir); if (seen.has(real)) return; seen.add(real); names = readdirSync(dir); } catch { return; }
45
+ for (const n of names) {
46
+ if (n.startsWith(".")) continue; // .trash, .obsidian
47
+ const p = join(dir, n);
48
+ let st;
49
+ try { st = statSync(p); } catch { continue; }
50
+ if (st.isDirectory()) walk(p, prefix + n + "/");
51
+ else if (n.toLowerCase().endsWith(".md") && safeName(prefix + n) === prefix + n.slice(0, -3)) {
52
+ try { out.push({ name: prefix + n.slice(0, -3), mtime: st.mtimeMs, text: readFileSync(p, "utf8") }); } catch { /* vanished */ }
53
+ }
54
+ }
55
+ };
56
+ walk(root, "");
57
+ return out.sort((a, b) => b.mtime - a.mtime);
58
+ }
59
+
60
+ // `base` is the mtime the page loaded the note at, 0 for a note it is creating. Any
61
+ // other mtime on disk means someone else wrote it since: refuse, and say what is there.
62
+ export function saveNote(state: string, name: unknown, text: string, base: number):
63
+ { ok: true; name: string; mtime: number } | { ok: false; why: string; text?: string; mtime?: number } {
64
+ const t = fileOf(state, name);
65
+ if (!t) return { ok: false, why: "bad name" };
66
+ if (existsSync(t.file)) {
67
+ const now = statSync(t.file).mtimeMs;
68
+ if (now !== base) return { ok: false, why: "conflict", text: readFileSync(t.file, "utf8"), mtime: now };
69
+ } else if (base) return { ok: false, why: "conflict", text: "", mtime: 0 };
70
+ mkdirSync(dirname(t.file), { recursive: true });
71
+ writeFileSync(t.file, text);
72
+ return { ok: true, name: t.name, mtime: statSync(t.file).mtimeMs };
73
+ }
74
+
75
+ // Also how a note moves between folders: `Standup` to `Work/Standup`.
76
+ export function renameNote(state: string, from: unknown, to: unknown): string | null {
77
+ const a = fileOf(state, from), b = fileOf(state, to);
78
+ if (!a || !b) return "bad name";
79
+ if (!existsSync(a.file)) return "no such note";
80
+ if (a.file === b.file) return null;
81
+ // A case-only rename on a case-insensitive disk finds `to` already there: it is `from`.
82
+ if (existsSync(b.file) && a.file.toLowerCase() !== b.file.toLowerCase()) return "a note with that name exists";
83
+ mkdirSync(dirname(b.file), { recursive: true });
84
+ renameSync(a.file, b.file);
85
+ prune(state, dirname(a.file));
86
+ return null;
87
+ }
88
+
89
+ // Never unlinked: moved to notes/.trash/, where Obsidian keeps its own too.
90
+ export function deleteNote(state: string, name: unknown): string | null {
91
+ const t = fileOf(state, name);
92
+ if (!t) return "bad name";
93
+ if (!existsSync(t.file)) return "no such note";
94
+ const trash = join(notesDir(state), ".trash");
95
+ mkdirSync(trash, { recursive: true });
96
+ renameSync(t.file, join(trash, `${Date.now().toString(36)}-${t.name.replace(/\//g, "~")}.md`));
97
+ prune(state, dirname(t.file));
98
+ return null;
99
+ }
100
+
101
+ // A folder exists while it holds something; remove the ones a move or delete emptied.
102
+ function prune(state: string, dir: string) {
103
+ const root = resolve(notesDir(state));
104
+ for (let d = resolve(dir); d.startsWith(root + sep); d = dirname(d)) {
105
+ try { if (readdirSync(d).length) return; rmdirSync(d); } catch { return; }
106
+ }
107
+ }
108
+
109
+ // What a note can link to on the board, for the notebook's chips and completion: the
110
+ // project's runs, each with its review findings and its backlog items numbered from 1
111
+ // in file order (items are never deleted, so a number keeps pointing at its item),
112
+ // and its ideas. Archived and abandoned ones too: a link outlives the card.
113
+ // ponytail: every run's review and backlog read per request; cache by mtime if a
114
+ // project keeps hundreds of runs.
115
+ export function boardIndex(state: string) {
116
+ const cards = boardCards([state], Date.now());
117
+ const backlog = (id: string) => {
118
+ let text = "";
119
+ try { text = readFileSync(join(state, "runs", id, "06-backlog.md"), "utf8"); } catch { /* none */ }
120
+ return [...text.matchAll(/^\s*-\s*(open|done|rejected):\s*(.*)$/gm)].map((m, i) => ({ n: i + 1, status: m[1]!, text: m[2]!.trim() }));
121
+ };
122
+ return {
123
+ runs: cards.filter((c) => c.kind === "run").map((c) => ({
124
+ id: c.id!, short: c.id!.replace(/^\d{4}-\d{2}-\d{2}-/, ""), title: c.title ?? c.text, status: c.status,
125
+ findings: reviewFindings(state, c.id!), backlog: backlog(c.id!),
126
+ })),
127
+ ideas: cards.filter((c) => c.kind === "idea").map((c) => ({ text: c.text, status: c.status })),
128
+ };
129
+ }
130
+
131
+ // The notes whose board links name this run, for the board's "Linked from notes".
132
+ export function linksTo(state: string, id: string) {
133
+ let ids: string[] = [];
134
+ try { ids = readdirSync(join(state, "runs")); } catch { return []; }
135
+ return listNotes(state).filter((n) => wikilinks(n.text).some((w) => {
136
+ const l = boardLink(w);
137
+ return l?.kind === "run" && resolveRun(l.id, ids) === id;
138
+ })).map((n) => n.name);
139
+ }
package/cli/release.sh CHANGED
@@ -38,8 +38,8 @@ git log --reverse --format='### %s%n%n%b' ${prev:+"$prev.."}"$tag" \
38
38
  | grep -vE '^### Release [0-9.]+:' \
39
39
  | cat -s > "$notes"
40
40
  [ -n "$prev" ] && printf '\n**Full Changelog**: https://github.com/hfadhlullah/kaizen/compare/%s...%s\n' "$prev" "$tag" >> "$notes"
41
- gh release create "$tag" --title "$title" --notes-file "$notes" >/dev/null 2>&1 \
41
+ err="$(gh release create "$tag" --title "$title" --notes-file "$notes" 2>&1 >/dev/null)" \
42
42
  && echo "release: published $tag https://github.com/hfadhlullah/kaizen/releases/tag/$tag" \
43
- || echo "release: tag pushed, release not created"
43
+ || echo "release: tag pushed, release not created: $err"
44
44
  rm -f "$notes"
45
45
  exit 0
package/cli/state.test.ts CHANGED
@@ -8,7 +8,7 @@ import {
8
8
  readRuns, readInbox, writeInbox, replaceIdea, abandonRun, allBacklog, backlog,
9
9
  statusOf, columnOf, parseItem, startedRuns, boardCards, readArchive, setArchived,
10
10
  requestOf, agentFlags, readNotes, writeNotes, manualCommand, parseNotes, formatNote, appendNote, saveAttachment,
11
- reviewFindings, pickFindings, fixPrompt, gitStatus, runGit, commitPush, readCommit, findTerminal, pruneRuns, unrechecked, notice, notifier, notifyArgs, LOGO, type Card,
11
+ reviewFindings, pickFindings, fixPrompt, gitStatus, runGit, cardsGit, commitPush, readCommit, findTerminal, pruneRuns, unrechecked, notice, notifier, notifyArgs, LOGO, type Card,
12
12
  } from "./state.ts";
13
13
 
14
14
  let state: string;
@@ -274,6 +274,8 @@ test("commitPush: commits the run's files only, pushes, and records it; the mess
274
274
  writeFileSync(join(st, "runs", "r1", "03-impl.md"), "## Changed\n\n- `.gitignore`: x\n- `a.txt:1`: y\n- c.txt and `c.txt.bak` are not named\n");
275
275
  const files = () => (runGit(st, "r1") as any).files as string[];
276
276
  expect(files()).toEqual(["?? .gitignore", "?? a.txt"]);
277
+ // The card's mark: the run with changed files is pending; one with none and no record says nothing.
278
+ expect(cardsGit(st, ["r1", "nope"])).toEqual({ r1: "pending" });
277
279
  // An earlier run that named the same file is not offered it: the later report has it.
278
280
  mkdirSync(join(st, "runs", "r0"));
279
281
  writeFileSync(join(st, "runs", "r0", "03-impl.md"), "- `a.txt`: z\n");
@@ -303,6 +305,7 @@ test("commitPush: commits the run's files only, pushes, and records it; the mess
303
305
  expect(sh(proj, "show", "--name-only", "--format=", "HEAD")).toBe(".gitignore\na.txt");
304
306
  expect(Bun.spawnSync(["git", "-C", proj, "status", "--porcelain"]).stdout.toString()).toBe("A c.txt\n");
305
307
  expect(readCommit(st, "r1")).toEqual({ sha: r.sha, pushed: true });
308
+ expect(cardsGit(st, ["r1", "r0", "nope"])).toEqual({ r1: "pushed", r0: "pushed" }); // r0: no record, but its file is in; c.txt still changed is not its business
306
309
 
307
310
  // A push that fails leaves a record that says so.
308
311
  sh(proj, "remote", "set-url", "origin", join(root, "gone.git"));
@@ -318,6 +321,8 @@ test("commitPush: commits the run's files only, pushes, and records it; the mess
318
321
  expect(readCommit(st, "r1")!.pushed).toBe(false);
319
322
  expect(sh(proj, "show", "--name-only", "--format=", "HEAD")).toBe("b*.txt");
320
323
  expect(runGit(st, "r1")).toMatchObject({ files: [], ahead: 1, others: 2 });
324
+ expect(cardsGit(st, ["r1"])).toEqual({ r1: "pending" }); // committed, not pushed
325
+ expect(cardsGit(join(root, ".kaizen"), ["r1"])).toEqual({}); // not a repo
321
326
  });
322
327
 
323
328
  test.skipIf(process.platform === "win32")("findTerminal: a running herdr wins, then tmux, else neither; the prompt stays one literal argument", () => {
package/cli/state.ts CHANGED
@@ -918,6 +918,17 @@ function runReport(state: string, id: string) {
918
918
  }
919
919
  const names = (report: string, path: string) => report.includes("`" + path + "`") || report.includes("`" + path + ":");
920
920
 
921
+ type Report = { id: string; text: string; at: number };
922
+ function runReports(state: string): Report[] {
923
+ let ids: string[] = [];
924
+ try { ids = readdirSync(join(state, "runs")); } catch {}
925
+ return ids.map((id) => ({ id, ...runReport(state, id) }));
926
+ }
927
+ const runFiles = (changed: string[], mine: Report, rest: Report[]) => changed.filter((l) => {
928
+ const p = statusPath(l);
929
+ return names(mine.text, p) && !rest.some((r) => r.at > mine.at && names(r.text, p));
930
+ });
931
+
921
932
  // The project's status narrowed to one run: a changed file is the run's when its
922
933
  // report names the path in backticks and no run that reported later names it too,
923
934
  // so a finished run is not offered the work of the ones after it. `others` counts
@@ -929,14 +940,9 @@ const names = (report: string, path: string) => report.includes("`" + path + "`"
929
940
  export function runGit(state: string, id: string): GitStatus {
930
941
  const dir = dirname(state), s = gitStatus(dir);
931
942
  if (!s.repo) return s;
932
- const mine = runReport(state, id);
933
- let ids: string[] = [];
934
- try { ids = readdirSync(join(state, "runs")); } catch {}
935
- const rest = ids.filter((o) => o !== id).map((o) => ({ id: o, ...runReport(state, o) }));
936
- const files = s.files.filter((l) => {
937
- const p = statusPath(l);
938
- return names(mine.text, p) && !rest.some((r) => r.at > mine.at && names(r.text, p));
939
- });
943
+ const all = runReports(state), mine = all.find((r) => r.id === id) ?? { id, text: "", at: 0 };
944
+ const rest = all.filter((o) => o.id !== id);
945
+ const files = runFiles(s.files, mine, rest);
940
946
  const shared = files.flatMap((l) => {
941
947
  const p = statusPath(l);
942
948
  const since = Number(git(dir, "--literal-pathspecs", "log", "-1", "--format=%ct", "--", p).out) * 1000;
@@ -955,6 +961,24 @@ export function readCommit(state: string, id: string): { sha: string; pushed: bo
955
961
  } catch { return null; }
956
962
  }
957
963
 
964
+ // The card's git mark for each run asked about, by the rules of the panel's button
965
+ // (`gitBtn` in web/board.html): one project status and one read of the reports for
966
+ // the lot. A run with nothing to say either way gets no entry.
967
+ export function cardsGit(state: string, ids: string[]): Record<string, "pending" | "pushed"> {
968
+ const s = gitStatus(dirname(state)), out: Record<string, "pending" | "pushed"> = {};
969
+ if (!s.repo) return out;
970
+ const all = runReports(state);
971
+ for (const id of ids) {
972
+ const mine = all.find((r) => r.id === id) ?? { id, text: "", at: 0 }, k = readCommit(state, id);
973
+ const files = runFiles(s.files, mine, all.filter((o) => o.id !== id)).length;
974
+ if (files) out[id] = "pending";
975
+ else if (k?.pushed) out[id] = "pushed";
976
+ else if (s.ahead > 0) out[id] = "pending";
977
+ else if (k || mine.text) out[id] = "pushed";
978
+ }
979
+ return out;
980
+ }
981
+
958
982
  // A Conventional Commits subject: every commit the board makes says what kind it is.
959
983
  const COMMIT_TYPES = ["feat", "fix", "refactor", "perf", "docs", "test", "build", "ci", "chore", "style", "revert"];
960
984
  const COMMIT_TYPE = new RegExp(`^(${COMMIT_TYPES.join("|")})(\\([^)\\n]+\\))?!?: \\S`);
package/cli/web.ts CHANGED
@@ -3,14 +3,18 @@
3
3
  import { existsSync, readFileSync, readdirSync, statSync, watch, appendFileSync, mkdirSync, type FSWatcher } from "node:fs";
4
4
  import { join, dirname } from "node:path";
5
5
  import {
6
- home, type Item, boardCards, knownProjects, remember, locate, label, tilde, searchRoots, findProjects,
6
+ home, type Item, type Card, boardCards, knownProjects, remember, locate, label, tilde, searchRoots, findProjects,
7
7
  readInbox, writeInbox, replaceIdea, abandonRun, launchRun, parseItem, short, setArchived, writeNotes,
8
- appendNote, saveAttachment, pidOnPort, runGit, readCommit, commitPush, notice, notifier, LOGO,
8
+ appendNote, saveAttachment, pidOnPort, runGit, cardsGit, readCommit, commitPush, notice, notifier, LOGO,
9
9
  reviewFindings, pickFindings, fixPrompt,
10
10
  } from "./state.ts";
11
11
  import { readSources, addSource, removeSource, moveSource, gatherSource, gatherAll, gatherDirs, due } from "./sources.ts";
12
+ import { listNotes, saveNote, renameNote, deleteNote, boardIndex, linksTo } from "./notes.ts";
12
13
 
13
14
  const PAGE = join(dirname(import.meta.dir), "web", "board.html");
15
+ // The notebook: its page, and the CodeMirror bundle `bun run build:web` commits beside it.
16
+ const NOTES_PAGE = join(dirname(import.meta.dir), "web", "notes.html");
17
+ const NOTES_JS = join(dirname(import.meta.dir), "web", "notebook.js");
14
18
  const DEFAULT_PORT = 7420;
15
19
 
16
20
  type Opts = { port?: number; open?: boolean };
@@ -186,6 +190,26 @@ export async function web(repoDir: string, opts: Opts = {}) {
186
190
  });
187
191
  }
188
192
 
193
+ if (req.method === "GET" && (path === "/notebook" || path === "/notebook.js")) {
194
+ const [f, type] = path === "/notebook" ? [NOTES_PAGE, "text/html; charset=utf-8"] : [NOTES_JS, "application/javascript; charset=utf-8"];
195
+ if (!existsSync(f)) return new Response(`${path} missing; run bun run build:web`, { status: 500 });
196
+ return new Response(Bun.file(f), { headers: { "content-type": type, "cache-control": "no-store" } });
197
+ }
198
+
199
+ // Every note of one project, text included: search, tags and backlinks are the page's.
200
+ if (req.method === "GET" && path === "/vault") {
201
+ const dir = url.searchParams.get("dir") ?? state ?? join(home, ".kaizen");
202
+ if (!states(true).includes(dir)) return bad("unknown state dir", 404);
203
+ return json({ dir, notes: listNotes(dir) });
204
+ }
205
+
206
+ // What a note can link to on this project's board: runs, findings, backlog, ideas.
207
+ if (req.method === "GET" && path === "/links") {
208
+ const dir = url.searchParams.get("dir") ?? state ?? join(home, ".kaizen");
209
+ if (!states(true).includes(dir)) return bad("unknown state dir", 404);
210
+ return json(boardIndex(dir));
211
+ }
212
+
189
213
  // The icon on a web notification; this one file and nothing beside it.
190
214
  if (req.method === "GET" && path === "/logo.png") {
191
215
  if (!existsSync(LOGO)) return bad("no logo", 404);
@@ -197,11 +221,17 @@ export async function web(repoDir: string, opts: Opts = {}) {
197
221
  // Before the project list: it registers folders that turn out to be set up.
198
222
  const unset = await fresh();
199
223
  const dirs = states(all);
224
+ const cards = boardCards(dirs, Date.now());
225
+ // The git mark, for the cards whose panel offers commit and push: one status per project.
226
+ const marks = new Map<string, Record<string, string>>();
227
+ const markable = (c: Card) => c.kind === "run" && !c.archived && (c.status === "done" || c.awaiting === "approvals.review") && c.state !== join(home, ".kaizen");
228
+ for (const d of new Set(cards.filter(markable).map((c) => c.state)))
229
+ marks.set(d, cardsGit(d, cards.filter((c) => markable(c) && c.state === d).map((c) => c.id!)));
200
230
  return json({
201
231
  project, all, dir: state,
202
232
  projects: states(true).map((d) => ({ dir: d, label: label(d) })),
203
233
  fresh: unset.map((d) => ({ dir: join(d, ".kaizen"), label: tilde(d) })),
204
- cards: boardCards(dirs, Date.now()).map((c) => ({ ...c, notice: notice(c) })),
234
+ cards: cards.map((c) => ({ ...c, notice: notice(c), git: markable(c) ? marks.get(c.state)?.[c.id!] : undefined })),
205
235
  now: Date.now(),
206
236
  });
207
237
  }
@@ -218,6 +248,8 @@ export async function web(repoDir: string, opts: Opts = {}) {
218
248
  request: read("00-request.md"), plan: read("01-plan.md"), approval: read("02-approval.md"),
219
249
  impl: read("03-impl.md"), review: read("04-review.md"), backlog: read("06-backlog.md"),
220
250
  notes: read("notes.md"),
251
+ // Notebook notes whose [[run:…]] links name this run.
252
+ linkedFrom: linksTo(dir, id),
221
253
  // Nothing to pick in the global state dir: /fix refuses it.
222
254
  findings: dir === join(home, ".kaizen") ? [] : reviewFindings(dir, id),
223
255
  // The global state dir sits in $HOME, which is not a project to commit.
@@ -436,6 +468,21 @@ export async function web(repoDir: string, opts: Opts = {}) {
436
468
  return failed ? bad(failed, 404) : json({ ok: true });
437
469
  }
438
470
 
471
+ // A save names the mtime it loaded; a different one on disk is a 409 carrying
472
+ // what is there, so the page can keep the typed text and let the user choose.
473
+ if (path === "/vault") {
474
+ if (body.op === "save") {
475
+ if (typeof body.text !== "string") return bad("text required");
476
+ const r = saveNote(dir, body.name, body.text, Number(body.base) || 0);
477
+ changed();
478
+ return r.ok ? json(r) : r.why === "conflict" ? json(r, 409) : bad(r.why);
479
+ }
480
+ const why = body.op === "rename" ? renameNote(dir, body.name, body.to)
481
+ : body.op === "delete" ? deleteNote(dir, body.name) : "unknown op";
482
+ changed();
483
+ return why ? bad(why, why === "no such note" ? 404 : why === "a note with that name exists" ? 409 : 400) : json({ ok: true });
484
+ }
485
+
439
486
  // Only a finished run, or one waiting at the final approval, and never the
440
487
  // global state dir: $HOME may be a repository, and it is not the run's project.
441
488
  if (path === "/commit") {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "kaizen-agent",
3
- "version": "1.8.0",
3
+ "version": "1.9.0",
4
4
  "description": "Staged workflow for AI agents — code, writing, research, operations: plan, human approval, build, independent review, bounded fix loop.",
5
5
  "type": "module",
6
6
  "bin": {
@@ -21,6 +21,7 @@
21
21
  "scripts": {
22
22
  "install-links": "bun run cli/install.ts",
23
23
  "check": "bun run cli/install.ts --check",
24
+ "build:web": "bun run cli/build-web.ts",
24
25
  "test": "bun test",
25
26
  "prepublishOnly": "bun test",
26
27
  "postpublish": "sh cli/release.sh",
@@ -42,5 +43,14 @@
42
43
  "code-review",
43
44
  "writing",
44
45
  "research"
45
- ]
46
+ ],
47
+ "devDependencies": {
48
+ "@codemirror/autocomplete": "6.20.3",
49
+ "@codemirror/commands": "6.11.1",
50
+ "@codemirror/lang-markdown": "6.5.2",
51
+ "@codemirror/language": "6.12.4",
52
+ "@codemirror/state": "6.7.6",
53
+ "@codemirror/view": "6.43.13",
54
+ "@lezer/highlight": "1.2.5"
55
+ }
46
56
  }