kaizen-agent 1.9.4 → 1.11.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
@@ -301,6 +301,7 @@ and every `kaizen settings` knob, same rows, same `config.yml`. `--port N` picks
301
301
  serves, the board opens on every project, or on the one picked last time in that browser; `?all=0` in the URL narrows it to the one it was started in. `--daemon` starts the server
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
+ On Windows `kaizen web` starts in the background unless `--foreground` is given; `kaizen web --stop` ends it.
304
305
 
305
306
  **A notebook beside the board.** The note icon in the board's header opens
306
307
  `/notebook`: markdown notes for the project, kept as plain files in `.kaizen/notes/`
@@ -327,10 +328,14 @@ slower CPU path); a browser without a microphone API or WebAssembly shows no mic
327
328
 
328
329
  Requests that already live somewhere else — a team's Google Sheet, a Doc, a shared
329
330
  note — do not have to be retyped. The link icon beside the gear opens **Request
330
- sources**: paste a link, and kaizen reads it and adds every request it has not seen
331
- before to Ideas, each with a note saying where it came from. In a sheet, a column headed
332
- `Request`, `Idea`, `Title`, `Task`, `PBI`, `Summary` or `Name` is the request and the
333
- other cells become its notes; without such a header the request is one column for the whole sheet, the one holding the most text. A row whose `Status` column says `Done`,
331
+ sources**: paste a link, and kaizen reads it at once and adds every request it has not
332
+ seen before to Ideas, each with a note saying where it came from and a *gathered* chip;
333
+ the panel says what came back, or why the link could not be read. In a sheet, the header
334
+ is looked for in the first five rows that hold anything, and a column headed `Request`,
335
+ `Idea`, `Title`, `Task`, `PBI`, `Summary`, `Name`, `Detail`, `Details`, `Description`,
336
+ `Deskripsi`, `Feedback`, `Issue`, `Permintaan` or `Judul` is the request; the other
337
+ cells become its notes. Without such
338
+ a header the request is one column for the whole sheet, the one holding the most text. A row whose `Status` column says `Done`,
334
339
  `Finish` or `Finished` is left out, and gathered later if its status changes. A doc or
335
340
  a plain-text link gives one idea per line.
336
341
 
@@ -657,14 +662,14 @@ with a comment on each key, if you would rather edit the file.
657
662
  | `--yes` | Take every default, ask nothing |
658
663
  | `--verbose` | List every link instead of a one-line summary |
659
664
  | `upgrade` | Pull, relink, and clear the installer cache. No prompts. |
660
- | `uninstall` | Remove the links, the clone, and the launcher. Asks whether to keep your runs and settings. |
665
+ | `uninstall` | Remove the links, the clone, and the launcher. Asks whether to keep your runs and settings. The clone goes only when it is `~/kaizen`, cloned from kaizen's repository, with no local changes; any other `KAIZEN_HOME` is kept and named. |
661
666
  | `uninstall --purge` | The same, and delete `~/.kaizen` and this project's `.kaizen/` too. |
662
667
  | `settings` | Open the settings browser for the nearest `.kaizen/`. Also `config`. |
663
668
 
664
669
  Uninstalling works the same on Linux, macOS and Windows — `kaizen uninstall`, or
665
670
  `bunx kaizen-agent uninstall` when the command itself is already gone. It offers three
666
671
  answers — keep your runs and settings, remove everything, or cancel — and takes the
667
- first without asking when the output is not a terminal. It lists what it removed; the `## Kaizen workflow` line in each project's `CLAUDE.md` is left for
672
+ first without asking when the output is not a terminal. It lists what it removed and what it kept; the `## Kaizen workflow` line in each project's `CLAUDE.md` is left for
668
673
  you to delete, since you may have edited around it.
669
674
 
670
675
  A global install also writes `~/.local/bin/kaizen` (`kaizen.cmd` in bun's bin
@@ -698,6 +703,19 @@ for f in ~/kaizen/commands/kaizen-*.md; do ln -s "$f" ~/.claude/commands/"$(base
698
703
 
699
704
  </details>
700
705
 
706
+ ## Reporting a bug
707
+
708
+ ```
709
+ kaizen issue [what happened]
710
+ ```
711
+
712
+ Opens kaizen's GitHub bug form with your machine filled in: kaizen version, OS, arch,
713
+ CPU, RAM, bun version, terminal. The board does the same from *Report an issue* in its
714
+ footer, and every error it shows carries a *Report* link with the error already in the
715
+ form. Your home folder is written as `~`; no hostname or username is included. Nothing
716
+ is sent until you read it and press Submit on GitHub. `kaizen issue --env` only prints
717
+ the details.
718
+
701
719
  ## License
702
720
 
703
721
  MIT. *Kaizen (改善) is Japanese for continuous improvement — small changes, checked
@@ -68,7 +68,10 @@ change — do not refactor around it. Write what you changed to
68
68
  - **Verification** — each check carried out, with its actual result. Quote failures.
69
69
  - **Not done** — steps skipped, out-of-scope observations, known gaps.
70
70
  - **Preview** — how to see the result: commands to run it locally and the URL for an
71
- app; the path to open, or the result itself, for anything else. The board shows it.
71
+ app; the path to open, or the result itself, for anything else. Then `### How to test`:
72
+ numbered steps from the plan's gate, each an action and what the person should see,
73
+ plus any test command with its expected result. `###` subheads only — the board ends
74
+ the section at the next `##`. The board shows it.
72
75
  - **Commit** — in a git repository, the commit message for the work: a subject
73
76
  `<type>(<scope>): <what changed>` (type one of `feat`, `fix`, `refactor`, `perf`,
74
77
  `docs`, `test`, `build`, `ci`, `chore`, `style`, `revert`; at most 72 characters)
package/cli/board.test.ts CHANGED
@@ -53,3 +53,118 @@ test("undraft removes by prefix and leaves the rest", () => {
53
53
  undraft("f:");
54
54
  expect(Object.keys(ss).sort()).toEqual(["kz-d:n:x", "kz-form"]);
55
55
  });
56
+
57
+ // A building run's progress list, lifted the same way.
58
+ const pat = html.indexOf("function progress(");
59
+ const progress = new Function(`${esc}\n${html.slice(pat, html.indexOf("\n\n", pat))}\nreturn progress`)() as (src: string, prev?: string[]) => string;
60
+
61
+ test("progress: each item marked done, working on or to do, with the count", () => {
62
+ const out = progress("# Progress\n- done: one\n- doing: two <b>x</b>\n- todo: `three`\n- open: not an item\n");
63
+ expect(out).toContain("1 of 3 done");
64
+ expect(out).toContain("width:33%");
65
+ expect(out.match(/<li class="p-(\w+)/g)).toEqual(['<li class="p-done', '<li class="p-doing', '<li class="p-todo']);
66
+ expect(out).toContain("two &lt;b&gt;x&lt;/b&gt;");
67
+ expect(out).toContain("<code>three</code>");
68
+ expect(out).not.toContain("chg");
69
+ });
70
+
71
+ test("progress: only an item whose status moved animates", () => {
72
+ const out = progress("- done: one\n- doing: two\n- todo: three\n", ["done", "todo", "todo"]);
73
+ expect(out.match(/<li class="[^"]*"/g)).toEqual(['<li class="p-done"', '<li class="p-doing chg"', '<li class="p-todo"']);
74
+ });
75
+
76
+ test("progress: a file with no items draws nothing", () => {
77
+ expect(progress("# Progress\n\nnothing yet\n")).toBe("");
78
+ });
79
+
80
+ // A plan's open questions, lifted the same way: the function ends at the first line not indented.
81
+ const pq = html.indexOf("function planQuestions(");
82
+ type Q = { q: string; multi: boolean; options: { text: string; label: string; rec: boolean }[] };
83
+ const planQuestions = new Function(`${html.slice(pq).split(/\n(?! )/)[0]}\nreturn planQuestions`)() as (src: string) => Q[] | null;
84
+
85
+ test("planQuestions: Q lines with options, recommended and multi-select read", () => {
86
+ const qs = planQuestions(`# Plan\n## 9. Open questions\n\nQ: Which improvements to build? (multi-select)\n - Own project in row (recommended) — fixes Move; XS\n - Gather on add — instant result\nQ: Where the progress shows\n - Top of the Backlog tab (recommended): what you asked for.\n - Its own tab: adds an eighth tab\n\n- Note: not an option\n## 10. Out of scope suggestions\n- not a question\n`);
87
+ expect(qs!.map((q) => [q.q, q.multi])).toEqual([["Which improvements to build? (multi-select)", true], ["Where the progress shows", false]]);
88
+ expect(qs[0]!.options.map((o) => [o.label, o.rec])).toEqual([["Own project in row", true], ["Gather on add", false]]);
89
+ expect(qs[1]!.options.map((o) => o.label)).toEqual(["Top of the Backlog tab", "Its own tab"]);
90
+ });
91
+
92
+ test("planQuestions: fenced and numbered questions, sub-headings kept, none and missing read as nothing", () => {
93
+ const qs = planQuestions("## Open questions\n### Board\n```\nQ1: How should it run?\n - Detach (recommended): prompt comes back\n - Foreground: as is\n```\n");
94
+ expect(qs!.map((q) => [q.q, q.options.length])).toEqual([["How should it run?", 2]]);
95
+ expect(planQuestions("## 9. Open questions\n\nNone.\n")).toEqual([]);
96
+ expect(planQuestions("## 9. Open questions\n- Building → Backlog taken literally.\n")).toBeNull();
97
+ expect(planQuestions("# Plan\nno section\n")).toEqual([]);
98
+ });
99
+
100
+ test("planQuestions: a bullet after a blank line is not one more option", () => {
101
+ const qs = planQuestions("## Open questions\nQ: One?\n - A (recommended)\n - B\n\n- Note: aside\n")!;
102
+ expect(qs[0]!.options.map((o) => o.label)).toEqual(["A", "B"]);
103
+ });
104
+
105
+ test("card menu items: appropriate actions and distinct icons across phases", () => {
106
+ expect(html).toContain('<symbol id="i-open"');
107
+ expect(html).toContain('<symbol id="i-archive"');
108
+ expect(html).toContain('<symbol id="i-restore"');
109
+
110
+ const ico = (n: string) => `<svg><use href="#i-${n}"/></svg>`;
111
+ const menuSnippet = (html.slice(html.indexOf("if(k===menuKey){"), html.indexOf("el.innerHTML+=`<div class=\"menu-pop\"", html.indexOf("if(k===menuKey){"))) + "}").replace("const items=[];", "items=[];");
112
+ const getItems = new Function("c", "archive", `const ico = ${ico.toString()};\nlet k = 1, menuKey = 1, items = [];\n${menuSnippet}\nreturn items;`);
113
+
114
+ // Phase 0: Ideas (idea card)
115
+ const idea = { kind: "idea", status: "idea", column: 0 };
116
+ const ideaItems = getItems(idea, false);
117
+ expect(ideaItems.map((i: any) => i ? i[1] : null)).toEqual(["Run", "Edit", "Archive", null, "Reject", "Delete"]);
118
+ expect(ideaItems[0][2]).toBe(ico("play"));
119
+ expect(ideaItems[1][2]).toBe(ico("pen"));
120
+ expect(ideaItems[2][2]).toBe(ico("archive"));
121
+ expect(ideaItems[4][2]).toBe(ico("x"));
122
+ expect(ideaItems[5][2]).toBe(ico("trash"));
123
+
124
+ // Phase 1: Planning (starting idea)
125
+ const startingIdea = { kind: "idea", status: "starting", column: 1 };
126
+ const startItems = getItems(startingIdea, false);
127
+ expect(startItems.map((i: any) => i ? i[1] : null)).toEqual(["Open", "Run again", "Archive", null, "Remove"]);
128
+
129
+ // Phase 1: Planning (run waiting for plan approval)
130
+ const planWaiting = { kind: "run", status: "waiting", awaiting: "approvals.plan", column: 1 };
131
+ const planItems = getItems(planWaiting, false);
132
+ expect(planItems.map((i: any) => i ? i[1] : null)).toEqual(["Review plan", "Archive", null, "Abandon"]);
133
+ expect(planItems[0][2]).toBe(ico("open"));
134
+ expect(planItems[1][2]).toBe(ico("archive"));
135
+ expect(planItems[3][2]).toBe(ico("x"));
136
+
137
+ // Phase 2: Building (running run)
138
+ const buildingRun = { kind: "run", status: "running", column: 2 };
139
+ const buildItems = getItems(buildingRun, false);
140
+ expect(buildItems.map((i: any) => i ? i[1] : null)).toEqual(["Open", "Archive", null, "Abandon"]);
141
+
142
+ // Phase 3: Review (run waiting for review approval)
143
+ const reviewWaiting = { kind: "run", status: "waiting", awaiting: "approvals.review", column: 3 };
144
+ const reviewItems = getItems(reviewWaiting, false);
145
+ expect(reviewItems.map((i: any) => i ? i[1] : null)).toEqual(["Review work", "Archive", null, "Abandon"]);
146
+
147
+ // Phase 3: Review (run waiting on findings)
148
+ const findingsWaiting = { kind: "run", status: "waiting", awaiting: "findings", column: 3 };
149
+ const findingsItems = getItems(findingsWaiting, false);
150
+ expect(findingsItems.map((i: any) => i ? i[1] : null)).toEqual(["See findings", "Archive", null, "Abandon"]);
151
+
152
+ // Phase 4: Done (done run - no Abandon)
153
+ const doneRun = { kind: "run", status: "done", column: 4 };
154
+ const doneItems = getItems(doneRun, false);
155
+ expect(doneItems.map((i: any) => i ? i[1] : null)).toEqual(["Open", "Archive"]);
156
+
157
+ // Phase 4: Done (abandoned run - no Abandon)
158
+ const abandonedRun = { kind: "run", status: "abandoned", column: 4 };
159
+ const abandItems = getItems(abandonedRun, false);
160
+ expect(abandItems.map((i: any) => i ? i[1] : null)).toEqual(["Open", "Archive"]);
161
+
162
+ // Archive view
163
+ const archRun = getItems(doneRun, true);
164
+ expect(archRun.map((i: any) => i ? i[1] : null)).toEqual(["Restore", "Open"]);
165
+ expect(archRun[0][2]).toBe(ico("restore"));
166
+ expect(archRun[1][2]).toBe(ico("open"));
167
+
168
+ const archIdea = getItems(idea, true);
169
+ expect(archIdea.map((i: any) => i ? i[1] : null)).toEqual(["Restore", "Open", null, "Delete"]);
170
+ });
package/cli/dashboard.ts CHANGED
@@ -911,6 +911,7 @@ export async function dashboard(
911
911
  c.bold(r.cmd[0] === "sh" && r.cmd[2]?.includes("herdr tab create") ? "Started in a new herdr tab" : r.cmd[0] === "tmux" ? "Started in a new tmux window" : "Started in new terminal"),
912
912
  "",
913
913
  ` ${c.cyan("Agent")} ${r.agent.name} (${c.bold(r.agent.cmd)})`,
914
+ ...(r.agent.note ? [` ${c.amber(r.agent.note)}`] : []),
914
915
  ` ${c.cyan("Project")} ${tilde(r.projectDir)}`,
915
916
  ` ${c.cyan("Task")} ${it.text}`,
916
917
  ...(it.where ? [` ${c.cyan("File")} ${it.where}`] : []),
@@ -0,0 +1,85 @@
1
+ // `kaizen uninstall` against throwaway folders only. Every path it may delete comes
2
+ // from the env built here (HOME, KAIZEN_HOME, TMPDIR all scratch), never from the
3
+ // real environment: on 2026-10-01 an uninstall pointed at a checkout deleted it.
4
+ // Skipped on Windows: there uninstall also walks every drive (searchRoots) and
5
+ // strips kaizen links from real projects, whatever HOME says.
6
+ import { test, expect } from "bun:test";
7
+ import { mkdtempSync, mkdirSync, writeFileSync, existsSync, rmSync, symlinkSync } from "node:fs";
8
+ import { join, dirname } from "node:path";
9
+ import { tmpdir } from "node:os";
10
+
11
+ const script = join(dirname(import.meta.path), "install.ts");
12
+ const it = test.skipIf(process.platform === "win32");
13
+ const REPO = "https://github.com/hfadhlullah/kaizen.git";
14
+
15
+ // `custom` sets KAIZEN_HOME to a folder outside HOME; otherwise it is unset and the
16
+ // install is the scratch home's own ~/kaizen.
17
+ const uninstall = (make: (k: string) => void, custom = false) => {
18
+ const root = mkdtempSync(join(tmpdir(), "kz-uninstall-"));
19
+ const home = join(root, "home"), k = custom ? join(root, "kaizen") : join(home, "kaizen");
20
+ mkdirSync(home);
21
+ make(k);
22
+ const env: Record<string, string> = { PATH: process.env.PATH ?? "", HOME: home, USERPROFILE: home, TMPDIR: root };
23
+ if (custom) env.KAIZEN_HOME = k;
24
+ const p = Bun.spawnSync([process.execPath, script, "uninstall", "--yes"], { cwd: home, env });
25
+ const left = existsSync(k), out = p.stdout.toString();
26
+ rmSync(root, { recursive: true, force: true });
27
+ if (p.exitCode !== 0) throw new Error(p.stderr.toString());
28
+ return { left, out };
29
+ };
30
+ const kaizenFiles = (k: string) => { mkdirSync(join(k, "cli"), { recursive: true }); writeFileSync(join(k, "cli", "install.ts"), ""); };
31
+ // A clone without the network: an empty repository whose origin is `url`.
32
+ const clone = (url: string, dirty = false, more?: (k: string) => void) => (k: string) => {
33
+ mkdirSync(k, { recursive: true });
34
+ Bun.spawnSync(["git", "init", "-q", k]);
35
+ Bun.spawnSync(["git", "-C", k, "remote", "add", "origin", url]);
36
+ if (dirty) writeFileSync(join(k, "mine.txt"), "uncommitted");
37
+ more?.(k);
38
+ };
39
+ // The clone used as a project: its .kaizen/ is ignored, so `status --porcelain` alone reads clean.
40
+ const runs = (k: string) => {
41
+ writeFileSync(join(k, ".git", "info", "exclude"), ".kaizen/\n");
42
+ mkdirSync(join(k, ".kaizen", "runs", "r1"), { recursive: true });
43
+ writeFileSync(join(k, ".kaizen", "runs", "r1", "state.json"), "{}");
44
+ };
45
+ const commit = (k: string) => {
46
+ Bun.spawnSync(["git", "-C", k, "-c", "user.name=t", "-c", "user.email=t@t", "commit", "-q", "--allow-empty", "-m", "mine"]);
47
+ };
48
+
49
+ it("uninstall leaves a KAIZEN_HOME other than ~/kaizen, git checkout or not", () => {
50
+ const r = uninstall((k) => { kaizenFiles(k); mkdirSync(join(k, ".git")); }, true);
51
+ expect(r.left).toBe(true);
52
+ expect(r.out).toContain("KAIZEN_HOME is not ~/kaizen");
53
+ expect(uninstall(kaizenFiles, true).left).toBe(true);
54
+ // ~/kaizen is a link to the checkout KAIZEN_HOME names: same realpath, still refused.
55
+ const linked = uninstall((k) => { kaizenFiles(k); mkdirSync(join(k, ".git")); symlinkSync(k, join(dirname(k), "home", "kaizen")); }, true);
56
+ expect(linked.left).toBe(true);
57
+ expect(linked.out).toContain("KAIZEN_HOME is not ~/kaizen");
58
+ });
59
+
60
+ it("uninstall leaves a ~/kaizen that is not a kaizen install", () => {
61
+ const r = uninstall((k) => { mkdirSync(k); writeFileSync(join(k, "notes.txt"), "mine"); });
62
+ expect(r.left).toBe(true);
63
+ expect(r.out).toContain("not a kaizen install");
64
+ });
65
+
66
+ it("uninstall still removes the copy kaizen made without git", () => {
67
+ expect(uninstall(kaizenFiles).left).toBe(false);
68
+ });
69
+
70
+ it.skipIf(!Bun.which("git"))("uninstall removes a clean clone of kaizen and keeps any other", () => {
71
+ expect(uninstall(clone(REPO)).left).toBe(false);
72
+ expect(uninstall(clone(REPO.replace(/\.git$/, ""))).left).toBe(false);
73
+ const dirty = uninstall(clone(REPO, true));
74
+ expect(dirty.left).toBe(true);
75
+ expect(dirty.out).toContain("local changes");
76
+ const other = uninstall(clone("https://example.com/someone/else.git"));
77
+ expect(other.left).toBe(true);
78
+ expect(other.out).toContain("another repository");
79
+ const used = uninstall(clone(REPO, false, runs));
80
+ expect(used.left).toBe(true);
81
+ expect(used.out).toContain("ignored files");
82
+ const unpushed = uninstall(clone(REPO, false, commit));
83
+ expect(unpushed.left).toBe(true);
84
+ expect(unpushed.out).toContain("commits not pushed");
85
+ });
package/cli/install.ts CHANGED
@@ -1,10 +1,10 @@
1
1
  #!/usr/bin/env bun
2
2
  import {
3
- symlinkSync, mkdirSync, readdirSync, lstatSync, readlinkSync, unlinkSync,
3
+ symlinkSync, mkdirSync, readdirSync, lstatSync, unlinkSync,
4
4
  existsSync, copyFileSync, readFileSync, appendFileSync, rmSync, writeFileSync, renameSync,
5
- statSync, cpSync, realpathSync,
5
+ statSync, cpSync, realpathSync, rmdirSync,
6
6
  } from "node:fs";
7
- import { join, dirname, basename } from "node:path";
7
+ import { join, dirname, basename, resolve } from "node:path";
8
8
  import { homedir, tmpdir } from "node:os";
9
9
  import { applyPreset, detectPreset, PRESETS, type PresetName } from "./settings.ts";
10
10
 
@@ -115,6 +115,20 @@ if (Bun.argv[2] === "sources" || Bun.argv[2] === "gather") {
115
115
  process.exit(0);
116
116
  }
117
117
 
118
+ // `kaizen issue [what happened]` opens kaizen's GitHub bug form with this machine's
119
+ // details filled in; `--env` only prints them.
120
+ if (Bun.argv[2] === "issue") {
121
+ const { issueUrl, systemInfo } = await import("./state.ts");
122
+ if (args.has("--env")) { console.log(systemInfo()); process.exit(0); }
123
+ const what = Bun.argv.slice(3).join(" ");
124
+ const url = issueUrl({ title: what.split("\n")[0], what });
125
+ console.log(`\n ${c.cyan(url)}\n`);
126
+ console.log(await openUrl(url)
127
+ ? ` ${c.dim("Opened in your browser. Check it, then submit it on GitHub.")}\n`
128
+ : ` ${c.dim("Open that link to file the issue.")}\n`);
129
+ process.exit(0);
130
+ }
131
+
118
132
  if (args.has("--version") || args.has("-v") || args.has("version")) {
119
133
  console.log(versionOf(dirname(import.meta.dir)));
120
134
  process.exit(0);
@@ -141,11 +155,14 @@ async function removeEverything() {
141
155
  }
142
156
  const gone: string[] = [];
143
157
  const stuck: string[] = [];
158
+ const kept: string[] = [];
144
159
  const drop = (p: string) => {
145
160
  if (!existsSync(p) && !isLink(p)) return;
146
161
  // Windows refuses to delete a file another process holds open, and a silent
147
162
  // failure here would read as a clean uninstall that removed nothing.
148
- try { rmSync(p, { recursive: true, force: true }); gone.push(tilde(p)); }
163
+ // A link goes as a link: a recursive delete aimed at a junction can walk into
164
+ // the clone it points at.
165
+ try { if (isLink(p)) removeLink(p); else rmSync(p, { recursive: true, force: true }); gone.push(tilde(p)); }
149
166
  catch { stuck.push(tilde(p)); }
150
167
  };
151
168
 
@@ -172,7 +189,26 @@ async function removeEverything() {
172
189
  }
173
190
  }
174
191
 
175
- drop(process.env.KAIZEN_HOME ?? join(home, "kaizen"));
192
+ // KAIZEN_HOME can point anywhere, a development checkout included, and deleting
193
+ // that takes .git and every uncommitted edit with it. Only ~/kaizen is deleted,
194
+ // and only as kaizen left it: its own clone with a clean tree, or the copy made
195
+ // without git. Anything else is named and left for its owner. The path is compared
196
+ // as written, so a KAIZEN_HOME that reaches ~/kaizen through a link is refused too.
197
+ // A clone holds no ignored files unless someone used it (.kaizen/ is ignored), and
198
+ // no commits that are not on a remote.
199
+ const install = process.env.KAIZEN_HOME ?? join(home, "kaizen");
200
+ const git = async (...a: string[]) => {
201
+ try { return (await Bun.$`git -C ${install} ${a}`.quiet().text()).trim(); } catch { return null; }
202
+ };
203
+ const repo = (u: string | null) => u?.replace(/\/+$/, "").replace(/\.git$/, "");
204
+ const why = !existsSync(install) ? ""
205
+ : resolve(install) !== join(home, "kaizen") ? "KAIZEN_HOME is not ~/kaizen"
206
+ : !existsSync(join(install, ".git")) ? (existsSync(join(install, "cli", "install.ts")) ? "" : "not a kaizen install")
207
+ : repo(await git("remote", "get-url", "origin")) !== repo(REPO_URL) ? "a git checkout of another repository"
208
+ : (await git("status", "--porcelain", "--ignored")) !== "" ? "a git checkout with local changes or ignored files"
209
+ : (await git("rev-list", "--branches", "--not", "--remotes")) !== "" ? "a git checkout with commits not pushed" : "";
210
+ if (why) kept.push(`${tilde(install)} ${c.dim(`(${why}; delete it yourself if it is only kaizen)`)}`);
211
+ else drop(install);
176
212
  drop(join(process.env.BUN_INSTALL ?? join(home, ".bun"), "bin", "kaizen.cmd"));
177
213
  drop(join(home, ".local", "bin", "kaizen"));
178
214
  await clearBunxCache();
@@ -184,7 +220,8 @@ async function removeEverything() {
184
220
 
185
221
  console.log(`\n ${c.bold("Uninstalled")}`);
186
222
  for (const p of gone) console.log(` ${c.dim("removed " + p)}`);
187
- if (!gone.length && !stuck.length) console.log(` ${c.dim("nothing left to remove")}`);
223
+ for (const p of kept) console.log(` ${c.dim("kept ")}${p}`);
224
+ if (!gone.length && !stuck.length && !kept.length) console.log(` ${c.dim("nothing left to remove")}`);
188
225
  if (stuck.length) {
189
226
  console.log(`\n ${c.bold("could not remove")} ${c.dim("— close any dashboard or agent still using these, then run it again")}`);
190
227
  for (const p of stuck) console.log(` ${c.dim(p)}`);
@@ -201,6 +238,12 @@ function isLink(p: string) {
201
238
  try { return lstatSync(p).isSymbolicLink(); } catch { return false; }
202
239
  }
203
240
 
241
+ // Removes the link itself, never what it points at. A Windows junction is a
242
+ // directory to unlink(), so rmdir() is the fallback; neither one recurses.
243
+ function removeLink(p: string) {
244
+ try { unlinkSync(p); } catch { rmdirSync(p); }
245
+ }
246
+
204
247
  // Nothing to install into. kaizen is a workflow an agent runs; on its own it does
205
248
  // nothing, so send them to the agent first rather than laying out files no reader
206
249
  // will ever load.
@@ -296,7 +339,10 @@ if (wantWeb) {
296
339
  process.exit(0);
297
340
  }
298
341
 
299
- if (args.has("--daemon")) {
342
+ // On Windows a board in the foreground shares its console with every child it
343
+ // starts and Ctrl+C on kaizen.cmd asks "Terminate batch job (Y/N)?", so there a
344
+ // bare `kaizen web` detaches like the shortcut; --foreground keeps it here.
345
+ if (args.has("--daemon") || (process.platform === "win32" && !args.has("--foreground"))) {
300
346
  // The daemon is this same command, detached, with the browser left to us:
301
347
  // ignored stdio is what lets it outlive the terminal.
302
348
  // A board running an older kaizen is not "already running": the child finds
@@ -306,7 +352,7 @@ if (wantWeb) {
306
352
  console.log(` kaizen web already running at ${url}`);
307
353
  } else {
308
354
  const argv = Bun.argv.slice(2).filter((a) => a !== "--daemon" && a !== "--no-open");
309
- const child = Bun.spawn([process.execPath, Bun.main, ...argv, "--no-open"], {
355
+ const child = Bun.spawn([process.execPath, Bun.main, ...argv, "--no-open", "--foreground"], {
310
356
  detached: true, stdio: ["ignore", "ignore", "ignore"], windowsHide: true,
311
357
  });
312
358
  child.unref();
@@ -661,7 +707,10 @@ function linkState(src: string, dest: string) {
661
707
  if (isWindows && st.isFile()) return sameFile(src, dest) ? "linked" : "stale";
662
708
  return "occupied";
663
709
  }
664
- return readlinkSync(dest) === src ? "linked" : "stale";
710
+ // Compared by where they lead, not by spelling: a junction reads back with a
711
+ // trailing \ or a \\?\ prefix, which made every upgrade relink every skill.
712
+ try { return realpathSync(dest) === realpathSync(src) ? "linked" : "stale"; }
713
+ catch { return "stale"; } // dangling: still a link to replace, not a free slot
665
714
  } catch {
666
715
  return "missing";
667
716
  }
@@ -686,7 +735,8 @@ for (const [src, sub] of links) {
686
735
  continue;
687
736
  }
688
737
  mkdirSync(dir, { recursive: true });
689
- if (state !== "missing") rmSync(dest, { recursive: true, force: true });
738
+ if (state === "occupied") rmSync(dest, { recursive: true, force: true });
739
+ else if (state === "stale") removeLink(dest);
690
740
  makeLink(src, dest);
691
741
  wrote++;
692
742
  if (verbose) console.log(c.dim(` link ${name}`));
package/cli/notes.test.ts CHANGED
@@ -210,7 +210,7 @@ test("the notebook's design tokens are the board's, line for line", () => {
210
210
  expect(block("board.html")).toContain("data-theme=dark]{--shade");
211
211
  const syms = (f: string) => readFileSync(join(root, "web", f), "utf8").split("\n").filter((l) => l.startsWith('<symbol id="s-')).join("\n");
212
212
  expect(syms("notes.html")).toBe(syms("board.html"));
213
- expect(syms("board.html").split("\n").length).toBe(6);
213
+ expect(syms("board.html").split("\n").length).toBe(7);
214
214
  const mascot = (f: string) => readFileSync(join(root, "web", f), "utf8").split("\n").find((l) => l.startsWith(".mascot{"));
215
215
  expect(mascot("notes.html")).toBe(mascot("board.html")!);
216
216
  });
@@ -0,0 +1,50 @@
1
+ // Settings against a throwaway home and project: a project with its own config.yml
2
+ // must not hide the board's machine-wide settings. Bun reads HOME once at startup,
3
+ // so the settings code runs in a child process with a fake HOME; setting HOME here
4
+ // would write to the real ~/.kaizen/config.yml.
5
+ import { test, expect, beforeAll, afterAll } from "bun:test";
6
+ import { mkdtempSync, mkdirSync, writeFileSync, readFileSync, copyFileSync, rmSync } from "node:fs";
7
+ import { join, dirname } from "node:path";
8
+ import { tmpdir } from "node:os";
9
+
10
+ const repo = join(dirname(import.meta.path), "..");
11
+ const defaults = join(repo, "skills/kaizen/config.default.yml");
12
+ let root: string, home: string, project: string;
13
+
14
+ beforeAll(() => {
15
+ root = mkdtempSync(join(tmpdir(), "kaizen-settings-"));
16
+ home = join(root, "home");
17
+ mkdirSync(join(home, ".kaizen"), { recursive: true });
18
+ writeFileSync(join(home, ".kaizen", "config.yml"), readFileSync(defaults, "utf8").replace('projects_dir: ""', "projects_dir: ~/Projects"));
19
+ // A project that carries its own config, as a cloned repo with .kaizen/config.yml does.
20
+ project = join(home, "Projects", "app");
21
+ mkdirSync(join(project, ".git"), { recursive: true });
22
+ mkdirSync(join(project, ".kaizen"), { recursive: true });
23
+ copyFileSync(defaults, join(project, ".kaizen", "config.yml"));
24
+ });
25
+
26
+ afterAll(() => rmSync(root, { recursive: true, force: true }));
27
+
28
+ // Runs `code` with listSettings/setSetting in scope, from inside the project, and returns what it prints.
29
+ const inProject = (code: string) => {
30
+ const script = `import { listSettings, setSetting } from ${JSON.stringify(join(repo, "cli/settings.ts"))};
31
+ const repo = ${JSON.stringify(repo)};
32
+ const value = (k) => listSettings(repo).rows.find((r) => r.key === k)?.value;
33
+ console.log(JSON.stringify(await (async () => { ${code} })()));`;
34
+ const p = Bun.spawnSync(["bun", "-e", script], { cwd: project, env: { ...process.env, HOME: home, USERPROFILE: home } });
35
+ if (p.exitCode !== 0) throw new Error(p.stderr.toString());
36
+ return JSON.parse(p.stdout.toString());
37
+ };
38
+
39
+ test("the projects folder survives moving into a project with its own config", () => {
40
+ expect(inProject(`return value("board.projects_dir")`)).toBe("~/Projects");
41
+ });
42
+
43
+ test("board settings save to the global config, project settings to the project", () => {
44
+ expect(inProject(`return [setSetting(repo, "board.projects_dir", "~/Work"), value("board.projects_dir")]`)).toEqual([{ ok: true }, "~/Work"]);
45
+ expect(readFileSync(join(home, ".kaizen", "config.yml"), "utf8")).toContain("projects_dir: ~/Work");
46
+ expect(readFileSync(join(project, ".kaizen", "config.yml"), "utf8")).toContain('projects_dir: ""');
47
+
48
+ expect(inProject(`return setSetting(repo, "git.auto_commit", "true")`)).toEqual({ ok: true });
49
+ expect(readFileSync(join(project, ".kaizen", "config.yml"), "utf8")).toMatch(/auto_commit: true/);
50
+ });