kaizen-agent 1.8.1 → 1.9.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/README.md +10 -0
- package/cli/build-web.ts +27 -0
- package/cli/install.ts +6 -4
- package/cli/notes.test.ts +221 -0
- package/cli/notes.ts +139 -0
- package/cli/web.ts +59 -4
- package/package.json +12 -2
- package/web/board.html +27 -1
- package/web/notebook.js +36 -0
- package/web/notebook.ts +696 -0
- package/web/notes-lib.ts +135 -0
- package/web/notes.html +214 -0
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
|
package/cli/build-web.ts
ADDED
|
@@ -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
|
+
}
|
package/cli/install.ts
CHANGED
|
@@ -279,7 +279,6 @@ if (wantWeb) {
|
|
|
279
279
|
const port = at !== -1 ? Number(Bun.argv[at + 1]) || undefined : undefined;
|
|
280
280
|
const url = `http://127.0.0.1:${port ?? 7420}/`;
|
|
281
281
|
const pidFile = join(home, ".kaizen", "web.pid");
|
|
282
|
-
const alive = async () => { try { await fetch(url); return true; } catch { return false; } };
|
|
283
282
|
|
|
284
283
|
if (args.has("--stop")) {
|
|
285
284
|
// The pid file only knows a --daemon; a board started from the shortcut or a
|
|
@@ -300,7 +299,10 @@ if (wantWeb) {
|
|
|
300
299
|
if (args.has("--daemon")) {
|
|
301
300
|
// The daemon is this same command, detached, with the browser left to us:
|
|
302
301
|
// ignored stdio is what lets it outlive the terminal.
|
|
303
|
-
|
|
302
|
+
// A board running an older kaizen is not "already running": the child finds
|
|
303
|
+
// it on the port and replaces it (web() in web.ts).
|
|
304
|
+
const { current } = await import("./web.ts");
|
|
305
|
+
if (await current(url)) {
|
|
304
306
|
console.log(` kaizen web already running at ${url}`);
|
|
305
307
|
} else {
|
|
306
308
|
const argv = Bun.argv.slice(2).filter((a) => a !== "--daemon" && a !== "--no-open");
|
|
@@ -310,8 +312,8 @@ if (wantWeb) {
|
|
|
310
312
|
child.unref();
|
|
311
313
|
mkdirSync(dirname(pidFile), { recursive: true });
|
|
312
314
|
writeFileSync(pidFile, String(child.pid));
|
|
313
|
-
for (let i = 0; i < 30 && !(await
|
|
314
|
-
if (!(await
|
|
315
|
+
for (let i = 0; i < 30 && !(await current(url)); i++) await Bun.sleep(100);
|
|
316
|
+
if (!(await current(url))) { console.log(` kaizen web did not come up on ${url}`); process.exit(1); }
|
|
315
317
|
console.log(`\n kaizen web ${url} (pid ${child.pid}, detached)`);
|
|
316
318
|
console.log(` kaizen web --stop to stop it\n`);
|
|
317
319
|
}
|
|
@@ -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/web.ts
CHANGED
|
@@ -9,9 +9,26 @@ import {
|
|
|
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;
|
|
19
|
+
// Read once at start: a board keeps the code it started with, so this is what it
|
|
20
|
+
// runs, not what is installed now. `/` sends it; `current()` compares the two.
|
|
21
|
+
const VERSION = (() => {
|
|
22
|
+
try { return JSON.parse(readFileSync(join(dirname(import.meta.dir), "package.json"), "utf8")).version as string; }
|
|
23
|
+
catch { return "unknown"; }
|
|
24
|
+
})();
|
|
25
|
+
|
|
26
|
+
// A board answers at url and runs the installed version. A board from before the
|
|
27
|
+
// version header sends none, so it counts as stale too.
|
|
28
|
+
export async function current(url: string) {
|
|
29
|
+
const r = await fetch(url, { signal: AbortSignal.timeout(1500) }).catch(() => null);
|
|
30
|
+
return !!r?.ok && r.headers.get("x-kaizen-version") === VERSION;
|
|
31
|
+
}
|
|
15
32
|
|
|
16
33
|
type Opts = { port?: number; open?: boolean };
|
|
17
34
|
|
|
@@ -154,11 +171,12 @@ export async function web(repoDir: string, opts: Opts = {}) {
|
|
|
154
171
|
if (!["EADDRINUSE", "EACCES"].includes((e as { code?: string }).code ?? "")) throw e;
|
|
155
172
|
const url = `http://127.0.0.1:${port}/`;
|
|
156
173
|
const up = await fetch(url, { signal: AbortSignal.timeout(1500) }).then((r) => r.ok, () => false);
|
|
157
|
-
if (up) { console.log(`\n kaizen web already at ${url}\n`); if (opts.open !== false) await openApp(url); return; }
|
|
158
|
-
// A listener that does not answer is a board that hung
|
|
174
|
+
if (up && await current(url)) { console.log(`\n kaizen web already at ${url}\n`); if (opts.open !== false) await openApp(url); return; }
|
|
175
|
+
// A listener that does not answer is a board that hung, and one running an
|
|
176
|
+
// older kaizen serves 404 for whatever came since; both are ours to stop.
|
|
159
177
|
const pid = await pidOnPort(port);
|
|
160
178
|
if (pid && pid !== process.pid) {
|
|
161
|
-
try { process.kill(pid, "SIGTERM"); console.log(` stopped a hung board on ${port} (pid ${pid})`); } catch { /* not ours to kill */ }
|
|
179
|
+
try { process.kill(pid, "SIGTERM"); console.log(` stopped a ${up ? "stale" : "hung"} board on ${port} (pid ${pid})`); } catch { /* not ours to kill */ }
|
|
162
180
|
await Bun.sleep(300);
|
|
163
181
|
try { server = serve(port); continue; } catch { /* still held */ }
|
|
164
182
|
}
|
|
@@ -182,10 +200,30 @@ export async function web(repoDir: string, opts: Opts = {}) {
|
|
|
182
200
|
if (!existsSync(PAGE)) return new Response("web/board.html missing", { status: 500 });
|
|
183
201
|
// The page gets the notify rule from state.ts rather than keeping its own copy.
|
|
184
202
|
return new Response(readFileSync(PAGE, "utf8").replace("/*notifier*/", () => notifier.toString()), {
|
|
185
|
-
headers: { "content-type": "text/html; charset=utf-8", "cache-control": "no-store" },
|
|
203
|
+
headers: { "content-type": "text/html; charset=utf-8", "cache-control": "no-store", "x-kaizen-version": VERSION },
|
|
186
204
|
});
|
|
187
205
|
}
|
|
188
206
|
|
|
207
|
+
if (req.method === "GET" && (path === "/notebook" || path === "/notebook.js")) {
|
|
208
|
+
const [f, type] = path === "/notebook" ? [NOTES_PAGE, "text/html; charset=utf-8"] : [NOTES_JS, "application/javascript; charset=utf-8"];
|
|
209
|
+
if (!existsSync(f)) return new Response(`${path} missing; run bun run build:web`, { status: 500 });
|
|
210
|
+
return new Response(Bun.file(f), { headers: { "content-type": type, "cache-control": "no-store" } });
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
// Every note of one project, text included: search, tags and backlinks are the page's.
|
|
214
|
+
if (req.method === "GET" && path === "/vault") {
|
|
215
|
+
const dir = url.searchParams.get("dir") ?? state ?? join(home, ".kaizen");
|
|
216
|
+
if (!states(true).includes(dir)) return bad("unknown state dir", 404);
|
|
217
|
+
return json({ dir, notes: listNotes(dir) });
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
// What a note can link to on this project's board: runs, findings, backlog, ideas.
|
|
221
|
+
if (req.method === "GET" && path === "/links") {
|
|
222
|
+
const dir = url.searchParams.get("dir") ?? state ?? join(home, ".kaizen");
|
|
223
|
+
if (!states(true).includes(dir)) return bad("unknown state dir", 404);
|
|
224
|
+
return json(boardIndex(dir));
|
|
225
|
+
}
|
|
226
|
+
|
|
189
227
|
// The icon on a web notification; this one file and nothing beside it.
|
|
190
228
|
if (req.method === "GET" && path === "/logo.png") {
|
|
191
229
|
if (!existsSync(LOGO)) return bad("no logo", 404);
|
|
@@ -224,6 +262,8 @@ export async function web(repoDir: string, opts: Opts = {}) {
|
|
|
224
262
|
request: read("00-request.md"), plan: read("01-plan.md"), approval: read("02-approval.md"),
|
|
225
263
|
impl: read("03-impl.md"), review: read("04-review.md"), backlog: read("06-backlog.md"),
|
|
226
264
|
notes: read("notes.md"),
|
|
265
|
+
// Notebook notes whose [[run:…]] links name this run.
|
|
266
|
+
linkedFrom: linksTo(dir, id),
|
|
227
267
|
// Nothing to pick in the global state dir: /fix refuses it.
|
|
228
268
|
findings: dir === join(home, ".kaizen") ? [] : reviewFindings(dir, id),
|
|
229
269
|
// The global state dir sits in $HOME, which is not a project to commit.
|
|
@@ -442,6 +482,21 @@ export async function web(repoDir: string, opts: Opts = {}) {
|
|
|
442
482
|
return failed ? bad(failed, 404) : json({ ok: true });
|
|
443
483
|
}
|
|
444
484
|
|
|
485
|
+
// A save names the mtime it loaded; a different one on disk is a 409 carrying
|
|
486
|
+
// what is there, so the page can keep the typed text and let the user choose.
|
|
487
|
+
if (path === "/vault") {
|
|
488
|
+
if (body.op === "save") {
|
|
489
|
+
if (typeof body.text !== "string") return bad("text required");
|
|
490
|
+
const r = saveNote(dir, body.name, body.text, Number(body.base) || 0);
|
|
491
|
+
changed();
|
|
492
|
+
return r.ok ? json(r) : r.why === "conflict" ? json(r, 409) : bad(r.why);
|
|
493
|
+
}
|
|
494
|
+
const why = body.op === "rename" ? renameNote(dir, body.name, body.to)
|
|
495
|
+
: body.op === "delete" ? deleteNote(dir, body.name) : "unknown op";
|
|
496
|
+
changed();
|
|
497
|
+
return why ? bad(why, why === "no such note" ? 404 : why === "a note with that name exists" ? 409 : 400) : json({ ok: true });
|
|
498
|
+
}
|
|
499
|
+
|
|
445
500
|
// Only a finished run, or one waiting at the final approval, and never the
|
|
446
501
|
// global state dir: $HOME may be a repository, and it is not the run's project.
|
|
447
502
|
if (path === "/commit") {
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "kaizen-agent",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.9.1",
|
|
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
|
}
|