kaizen-agent 1.10.0 → 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
@@ -703,6 +703,19 @@ for f in ~/kaizen/commands/kaizen-*.md; do ln -s "$f" ~/.claude/commands/"$(base
703
703
 
704
704
  </details>
705
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
+
706
719
  ## License
707
720
 
708
721
  MIT. *Kaizen (改善) is Japanese for continuous improvement — small changes, checked
package/cli/board.test.ts CHANGED
@@ -101,3 +101,70 @@ test("planQuestions: a bullet after a blank line is not one more option", () =>
101
101
  const qs = planQuestions("## Open questions\nQ: One?\n - A (recommended)\n - B\n\n- Note: aside\n")!;
102
102
  expect(qs[0]!.options.map((o) => o.label)).toEqual(["A", "B"]);
103
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}`] : []),
package/cli/install.ts CHANGED
@@ -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);
package/cli/settings.ts CHANGED
@@ -6,7 +6,7 @@ import { homedir } from "node:os";
6
6
  import { readText } from "./state.ts";
7
7
 
8
8
  // `text` is a value typed rather than chosen: a path. It has no values to cycle.
9
- type Setting = { key: string; label: string; group: string; values: string[]; help: string; text?: boolean };
9
+ type Setting = { key: string; label: string; group: string; values: string[]; help: string; text?: boolean; folder?: boolean };
10
10
 
11
11
  export type PresetName = "low" | "medium" | "ultra";
12
12
 
@@ -116,7 +116,7 @@ const SETTINGS: Setting[] = [
116
116
  { key: "state.keep_runs", label: "Runs to keep", group: "Project", values: ["5", "10", "20", "50"], help: "Finished runs kept before the oldest is pruned. Open backlog items are rescued first" },
117
117
  { key: "dictation.model", label: "Dictation model", group: "Board", values: ["tiny", "base", "small", "large-v3-turbo"], help: "Whisper model the web board dictates with, in the browser. tiny ~60 MB rough; base ~165 MB English-leaning; small ~410 MB accurate across languages; large-v3-turbo ~1.5 GB best, needs a capable GPU. Downloaded once on the next mic press" },
118
118
  { key: "sources.every", label: "Gather requests", group: "Board", values: ["off", "1h", "6h", "24h"], help: "How often the board checks your request sources for new ideas while it is running. off: only when you press Gather now" },
119
- { key: "board.projects_dir", label: "Projects folder", group: "Board", values: [], text: true, help: `A folder your projects live in, e.g. ${process.platform === "win32" ? join(homedir(), "Projects") : "~/Projects"}. Every folder inside it appears in the board's project list, ready to choose; choosing one sets it up. Empty: only projects kaizen already knows` },
119
+ { key: "board.projects_dir", label: "Projects folder", group: "Board", values: [], text: true, folder: true, help: `A folder your projects live in, e.g. ${process.platform === "win32" ? join(homedir(), "Projects") : "~/Projects"}. Every folder inside it appears in the board's project list, ready to choose; choosing one sets it up. Empty: only projects kaizen already knows` },
120
120
  { key: "ui.mouse", label: "Enable mouse", group: "Terminal", values: ["false", "true"], help: "Click to select, click again to act. While on, the terminal cannot select text with the mouse" },
121
121
  ];
122
122
 
package/cli/state.test.ts CHANGED
@@ -4,10 +4,11 @@ import { test, expect, beforeAll, afterAll } from "bun:test";
4
4
  import { mkdtempSync, mkdirSync, writeFileSync, readFileSync, rmSync, utimesSync, existsSync, readdirSync } from "node:fs";
5
5
  import { join, dirname } from "node:path";
6
6
  import { tmpdir } from "node:os";
7
+ import { issueUrl, systemInfo, scrubHome, REPO_URL, home as realHome } from "./state.ts";
7
8
  import {
8
9
  readRuns, readInbox, writeInbox, replaceIdea, abandonRun, allBacklog, backlog,
9
10
  statusOf, columnOf, parseItem, startedRuns, boardCards, readArchive, setArchived,
10
- requestOf, agentFlags, readNotes, writeNotes, manualCommand, parseNotes, formatNote, appendNote, saveAttachment,
11
+ requestOf, agentFlags, plainModel, readNotes, writeNotes, manualCommand, parseNotes, formatNote, appendNote, saveAttachment,
11
12
  reviewFindings, pickFindings, fixPrompt, approvalPrompt, sessionLaunch, closeSessions, sweepSessions, readSessions, ownerOf, CLOSE_GRACE_MS, gitStatus, runGit, cardsGit, commitPush, readCommit, findTerminal, nativePath, pruneRuns, unrechecked, notice, notifier, notifyArgs, LOGO, type Card,
12
13
  } from "./state.ts";
13
14
 
@@ -246,6 +247,71 @@ test("agentFlags: per-tool model and effort from the agent block, inline or nest
246
247
  expect(agentFlags("/p", "codex", "agent:\n default: codex\n")).toEqual([]);
247
248
  });
248
249
 
250
+ test("plainModel: a typed model reaches launch lines only as a plain name", () => {
251
+ for (const m of ["opus", "gpt-5.1", "gemini-2.5-pro", "anthropic/claude-sonnet-4", "o3:high", "x@y"]) expect(plainModel(m)).toBe(true);
252
+ for (const m of ["", "x; rm -rf ~", "$(id)", "`id`", "a b", "'q'", "\"q\"", "opus[1m]", "-rf", "x".repeat(81)]) expect(plainModel(m)).toBe(false);
253
+ });
254
+
255
+ test("each tool gets the opening prompt the way it takes one: agy -i, opencode --prompt, the rest bare", () => {
256
+ const p = mkdtempSync(join(tmpdir(), "kz-")); // no .kaizen: no session record
257
+ const argv = (cmd: string) => sessionLaunch(p, cmd, [cmd], "/kaizen x", "x").fullCmd;
258
+ expect(argv("agy")).toEqual(["agy", "-i", "/kaizen x"]);
259
+ expect(argv("opencode")).toEqual(["opencode", "--prompt", "/kaizen x"]);
260
+ expect(argv("codex")).toEqual(["codex", "/kaizen x"]);
261
+ expect(argv("gemini")).toEqual(["gemini", "/kaizen x"]);
262
+ if (process.platform !== "win32") expect(manualCommand("/p", "agy", "/kaizen x", ["--model", "m"])).toBe("cd '/p' && agy --model m -i '/kaizen x'");
263
+ });
264
+
265
+ test.skipIf(process.platform === "win32")("agent choice: project then global config, a note for a missing tool, a pick, and a run keeps its own", () => {
266
+ // The global config sits at the home bound at import, so this runs in a child with a scratch HOME.
267
+ const home = mkdtempSync(join(tmpdir(), "kz-home-")), bin = mkdtempSync(join(tmpdir(), "kz-bin-"));
268
+ for (const t of ["claude", "codex"]) writeFileSync(join(bin, t), "#!/bin/sh\nexit 0\n", { mode: 0o755 });
269
+ const script = `const S = await import(${JSON.stringify(join(dirname(import.meta.path), "state.ts"))});
270
+ const fs = require("node:fs"), { join } = require("node:path");
271
+ const P = join(process.env.HOME, "proj"), st = join(P, ".kaizen"), G = join(process.env.HOME, ".kaizen");
272
+ fs.mkdirSync(join(st, "runs", "r1"), { recursive: true }); fs.mkdirSync(G, { recursive: true });
273
+ fs.writeFileSync(join(G, "config.yml"), "agent:\\n default: codex\\n codex: { model: gpt-5, effort: low }\\n");
274
+ fs.writeFileSync(join(st, "config.yml"), "mode: approve\\n");
275
+ const out = { chain: [S.detectDefaultAgent(P).cmd, S.agentFlags(P, "codex")] };
276
+ fs.writeFileSync(join(st, "config.yml"), "agent:\\n default: claude\\n codex:\\n model: o3\\n");
277
+ out.project = [S.detectDefaultAgent(P).cmd, S.agentFlags(P, "codex")];
278
+ out.missing = S.detectDefaultAgent(P, "gemini");
279
+ out.pick = [S.detectDefaultAgent(P, "codex").cmd, S.agentFlags(P, "codex", undefined, "gpt-5.1")];
280
+ out.choices = S.agentChoices(P);
281
+ // No terminal on PATH or display in env: the launch fails and hands back its manual line.
282
+ const idea = { severity: null, where: null, text: "x", raw: "x" };
283
+ // macOS always drives Terminal.app, which would open a real window: Linux only.
284
+ if (process.platform === "linux") out.yolo = [S.launchRun(idea, P, undefined, { agent: "codex", yolo: true }), S.launchRun(idea, P, undefined, { agent: "codex" }), S.launchRun(idea, P, undefined, { agent: "gemini", yolo: true })].map((r) => [r.ok, r.agent.cmd, /--dangerously-bypass|--dangerously-skip|--yolo/.test(r.manual)]);
285
+ fs.writeFileSync(join(st, "runs", "r1", "state.json"), JSON.stringify({ agent: "codex" }));
286
+ out.fromState = S.runAgent(st, "r1");
287
+ S.sessionLaunch(P, "codex", ["codex"], "/kaizen x", "x", "r1", "gpt-5.1");
288
+ out.fromRecord = S.runAgent(st, "r1");
289
+ out.tags = [S.boardCards([st], Date.now()).find((c) => c.id === "r1")?.tags];
290
+ S.sessionLaunch(P, "codex", ["codex"], "/kaizen x", "x", "r1", undefined, true);
291
+ out.tags.push(S.boardCards([st], Date.now()).find((c) => c.id === "r1")?.tags);
292
+ console.log(JSON.stringify(out));`;
293
+ try {
294
+ const p = Bun.spawnSync(["bun", "-e", script], { cwd: home, env: { ...process.env, HOME: home, PATH: bin + ":" + dirname(process.execPath), DISPLAY: "", WAYLAND_DISPLAY: "", TERMINAL: "", TMUX: "", HERDR_ENV: "" } });
295
+ if (p.exitCode !== 0) throw new Error(p.stderr.toString());
296
+ expect(JSON.parse(p.stdout.toString())).toEqual({
297
+ // A project config.yml with no agent block leaves the global one in force.
298
+ chain: ["codex", ["-m", "gpt-5", "-c", "model_reasoning_effort=low"]],
299
+ project: ["claude", ["-m", "o3"]],
300
+ missing: { name: "Claude Code", cmd: "claude", note: "Gemini CLI is not installed; opened Claude Code instead" },
301
+ pick: ["codex", ["-m", "gpt-5.1"]],
302
+ choices: { default: "claude", agents: [
303
+ { name: "Claude Code", cmd: "claude", model: "", models: true, yolo: true },
304
+ { name: "Codex", cmd: "codex", model: "o3", models: true, yolo: true },
305
+ ] },
306
+ // Yolo adds the tool's bypass flag only when asked for, and not to a fallback tool.
307
+ ...(process.platform === "linux" ? { yolo: [[false, "codex", true], [false, "codex", false], [false, "claude", false]] } : {}),
308
+ tags: [["codex"], ["codex", "yolo"]],
309
+ fromState: { cmd: "codex", model: null },
310
+ fromRecord: { cmd: "codex", model: "gpt-5.1" },
311
+ });
312
+ } finally { rmSync(home, { recursive: true, force: true }); rmSync(bin, { recursive: true, force: true }); }
313
+ });
314
+
249
315
  test("boardCards: a short idea is not retired by a request that merely contains the word", () => {
250
316
  const dir = join(mkdtempSync(join(tmpdir(), "kz-")), ".kaizen");
251
317
  mkdirSync(join(dir, "runs", "2026-09-01-a"), { recursive: true });
@@ -687,3 +753,48 @@ test("abandonRun closes the run's recorded sessions", () => {
687
753
  expect(abandonRun(st, "2026-10-01-z", "no")).toBe(null);
688
754
  expect(existsSync(join(st, "sessions", "k.pid"))).toBe(false);
689
755
  });
756
+
757
+ // The folder dialog cannot be opened in a test, but its script can be checked without
758
+ // showing it: compiled on a Mac, parsed on Windows. Elsewhere only the shape is pinned.
759
+ test("folder dialog: each platform's script is well formed", async () => {
760
+ const { folderDialog } = await import("./web.ts");
761
+ const mac = folderDialog("darwin")!, win = folderDialog("win32")!;
762
+ expect(mac.slice(0, 3)).toEqual(["osascript", "-e", "activate"]);
763
+ expect(win.at(-1)).toContain("FolderBrowserDialog");
764
+ if (process.platform === "darwin") {
765
+ const out = join(mkdtempSync(join(tmpdir(), "kaizen-osa-")), "x.scpt");
766
+ expect(Bun.spawnSync(["osacompile", ...mac.slice(1), "-o", out]).exitCode).toBe(0);
767
+ }
768
+ if (process.platform === "win32") {
769
+ const check = `$e=$null;[void][System.Management.Automation.Language.Parser]::ParseInput($env:KZ_PS,[ref]$null,[ref]$e);exit $e.Count`;
770
+ expect(Bun.spawnSync([win[0], "-NoProfile", "-Command", check], { env: { ...process.env, KZ_PS: win.at(-1)! } }).exitCode).toBe(0);
771
+ }
772
+ });
773
+
774
+ test("issueUrl prefills the bug form's fields, without the home path", () => {
775
+ const u = new URL(issueUrl({ title: "boom", what: `ENOENT ${realHome}/Projects/x/.kaizen/state.json` }));
776
+ expect(`${u.origin}${u.pathname}`).toBe(`${REPO_URL}/issues/new`);
777
+ expect(u.searchParams.get("what")).toBe("ENOENT ~/Projects/x/.kaizen/state.json");
778
+ expect(u.searchParams.get("env")).toBe(systemInfo());
779
+ // Every key but GitHub's own must be a field id in the form, or it is dropped silently.
780
+ const ids = [...readFileSync(join(import.meta.dir, "..", ".github", "ISSUE_TEMPLATE", "bug.yml"), "utf8").matchAll(/^\s+id: (\S+)/gm)].map((m) => m[1]);
781
+ for (const k of u.searchParams.keys()) if (!["template", "title", "body"].includes(k)) expect(ids).toContain(k);
782
+ // Without the form on GitHub only `body` is read, so it must hold the machine too.
783
+ expect(u.searchParams.get("body")).toContain(systemInfo());
784
+ expect(u.searchParams.get("body")).toContain("ENOENT ~/Projects/x/.kaizen/state.json");
785
+ expect(systemInfo()).toMatch(/^kaizen: .+\nOS: .+\nCPU: .+\nRAM: \d+ GB\nbun: /);
786
+ });
787
+
788
+ test("issueUrl stays under GitHub's URL limit for a huge error", () => {
789
+ const u = issueUrl({ title: "x".repeat(500), what: "é".repeat(20000) });
790
+ expect(u.length).toBeLessThan(8000);
791
+ expect(new URL(u).searchParams.get("what")).toContain("…(cut)");
792
+ });
793
+
794
+ test("scrubHome hides a Windows home however the error spells it", () => {
795
+ const h = ["C:\\Users\\Firman"];
796
+ expect(scrubHome(`open C:\\Users\\Firman\\x, "c:\\\\users\\\\firman\\\\y", C:/Users/Firman/z`, h, true))
797
+ .toBe(`open ~\\x, "~\\\\y", ~/z`);
798
+ // Case matters off Windows: /home/Ann is not /home/ann.
799
+ expect(scrubHome("/home/ann/a /home/Ann/b", ["/home/ann"], false)).toBe("~/a /home/Ann/b");
800
+ });
package/cli/state.ts CHANGED
@@ -3,7 +3,7 @@
3
3
  // board (`web.ts`) both import this and draw it their own way.
4
4
  import { existsSync, readFileSync, readdirSync, appendFileSync, writeFileSync, mkdirSync, statSync, realpathSync, rmSync } from "node:fs";
5
5
  import { join, dirname, win32 } from "node:path";
6
- import { homedir } from "node:os";
6
+ import { homedir, arch, cpus, release, totalmem, version as osVersion } from "node:os";
7
7
 
8
8
  // The real path: process.cwd() is always physical, so a raw $HOME that crosses a
9
9
  // symlink (/home -> var/home) would never compare equal to it.
@@ -20,25 +20,34 @@ export const KNOWN_AGENTS = [
20
20
  { name: "Cursor", dir: ".cursor", cmd: "cursor" },
21
21
  ];
22
22
 
23
- export function detectDefaultAgent(projectDir: string): { name: string; cmd: string } {
24
- const cfgFile = existsSync(join(projectDir, ".kaizen", "config.yml"))
25
- ? join(projectDir, ".kaizen", "config.yml")
26
- : join(home, ".kaizen", "config.yml");
27
- if (existsSync(cfgFile)) {
28
- const text = readText(cfgFile);
29
- const m = /^\s*agent:\s*\n\s*default:\s*(\S+)/m.exec(text) || /^\s*agent\.default:\s*(\S+)/m.exec(text);
30
- if (m && m[1] && m[1] !== "auto") {
31
- const found = KNOWN_AGENTS.find((a) => a.cmd === m[1] || a.name.toLowerCase() === m[1].toLowerCase());
32
- if (found && Bun.which(found.cmd)) return found;
33
- }
23
+ export type Agent = { name: string; cmd: string; note?: string };
24
+
25
+ // Each config.yml in the chain, project first: the first one that sets a key decides
26
+ // it, so a project file that is silent about `agent` leaves the global one in force.
27
+ const configTexts = (projectDir: string) =>
28
+ [join(projectDir, ".kaizen", "config.yml"), join(home, ".kaizen", "config.yml")].filter(existsSync).map(readText);
29
+
30
+ // `want` is a per-run pick; without one, agent.default from the config chain. A
31
+ // wanted tool that is not installed is swapped for the auto choice, and said so.
32
+ export function detectDefaultAgent(projectDir: string, want?: string): Agent {
33
+ if (!want) for (const t of configTexts(projectDir)) {
34
+ const m = /^agent:\n(?:[ \t]+.*\n)*?[ \t]+default:[ \t]*["']?([^"'\s]+)/m.exec(t) || /^agent\.default:[ \t]*["']?([^"'\s]+)/m.exec(t);
35
+ if (m) { want = m[1]; break; }
34
36
  }
37
+ if (!want || want === "auto") return autoAgent(projectDir);
38
+ const found = KNOWN_AGENTS.find((a) => a.cmd === want || a.name.toLowerCase() === want!.toLowerCase());
39
+ if (found && onPath(found.cmd)) return found;
40
+ const a = autoAgent(projectDir);
41
+ return { ...a, note: `${found?.name ?? want} is not installed; opened ${a.name} instead` };
42
+ }
35
43
 
44
+ function autoAgent(projectDir: string): Agent {
36
45
  for (const a of KNOWN_AGENTS) {
37
- if (existsSync(join(projectDir, a.dir)) && Bun.which(a.cmd)) return a;
46
+ if (existsSync(join(projectDir, a.dir)) && onPath(a.cmd)) return a;
38
47
  }
39
- if (Bun.which("claude")) return { name: "Claude Code", cmd: "claude" };
48
+ if (onPath("claude")) return { name: "Claude Code", cmd: "claude" };
40
49
  for (const a of KNOWN_AGENTS) {
41
- if (Bun.which(a.cmd)) return a;
50
+ if (onPath(a.cmd)) return a;
42
51
  }
43
52
  return { name: "Claude Code", cmd: "claude" };
44
53
  }
@@ -56,22 +65,53 @@ const EFFORT_FLAGS: Record<string, (e: string) => string[]> = {
56
65
  codex: (e) => ["-c", `model_reasoning_effort=${e}`],
57
66
  };
58
67
 
59
- export function agentFlags(projectDir: string, cmd: string, configText?: string): string[] {
60
- if (configText === undefined) {
61
- const f = [join(projectDir, ".kaizen", "config.yml"), join(home, ".kaizen", "config.yml")].find(existsSync);
62
- configText = f ? readText(f) : "";
63
- }
68
+ // Each tool's own skip-every-permission-prompt mode, taken only when the Run form's
69
+ // Yolo is picked for that launch. Flags as each tool's --help spells them.
70
+ export const YOLO_FLAGS: Record<string, string[]> = {
71
+ claude: ["--dangerously-skip-permissions"], agy: ["--dangerously-skip-permissions"],
72
+ codex: ["--dangerously-bypass-approvals-and-sandbox"], gemini: ["--yolo"], opencode: ["--auto"],
73
+ };
74
+
75
+ // How each tool takes the opening prompt and stays interactive. agy refuses a bare
76
+ // argument and exits; opencode reads one as the project folder to open.
77
+ const PROMPT_FLAGS: Record<string, string[]> = { agy: ["-i"], opencode: ["--prompt"] };
78
+ export const promptArgs = (cmd: string, prompt: string) => [...(PROMPT_FLAGS[cmd] ?? []), prompt];
79
+
80
+ // A model typed on the board reaches launch lines and the manual command as text, so
81
+ // only a plain name is taken: no spaces, quotes, `$`, `;`, backticks or globs.
82
+ export const plainModel = (m: string) => /^[A-Za-z0-9][\w.:\/@-]{0,79}$/.test(m);
83
+
84
+ // The tool's own block in the first config.yml of the chain that has one.
85
+ function agentConfig(projectDir: string, cmd: string, configText?: string) {
64
86
  // ponytail: regex over the yaml, like detectDefaultAgent; a parser when a third key needs it
65
- const block = new RegExp(`^agent:\\n(?:[ \\t]+.*\\n)*?[ \\t]+${cmd}:[ \\t]*(\\{[^}]*\\}|\\n(?:[ \\t]+[a-z]+:.*\\n?)+)`, "m").exec(configText);
66
- if (!block) return [];
67
- const get = (k: string) => new RegExp(`\\b${k}:[ \\t]*["']?([^"',}\\s]+)`).exec(block[1])?.[1];
68
- const model = get("model"), effort = get("effort");
87
+ const re = new RegExp(`^agent:\\n(?:[ \\t]+.*\\n)*?[ \\t]+${cmd}:[ \\t]*(\\{[^}]*\\}|\\n(?:[ \\t]+[a-z]+:.*\\n?)+)`, "m");
88
+ let block: RegExpExecArray | null = null;
89
+ for (const t of configText === undefined ? configTexts(projectDir) : [configText]) if ((block = re.exec(t))) break;
90
+ const get = (k: string) => block && new RegExp(`\\b${k}:[ \\t]*["']?([^"',}\\s]+)`).exec(block[1])?.[1];
91
+ return { model: get("model") || undefined, effort: get("effort") || undefined };
92
+ }
93
+
94
+ // `model`: a per-run pick, in place of the configured one. Effort stays configured.
95
+ export function agentFlags(projectDir: string, cmd: string, configText?: string, model?: string): string[] {
96
+ const cfg = agentConfig(projectDir, cmd, configText);
97
+ const m = model || cfg.model;
69
98
  return [
70
- ...(model && MODEL_FLAGS[cmd] ? MODEL_FLAGS[cmd](model) : []),
71
- ...(effort && EFFORT_FLAGS[cmd] ? EFFORT_FLAGS[cmd](effort) : []),
99
+ ...(m && MODEL_FLAGS[cmd] ? MODEL_FLAGS[cmd](m) : []),
100
+ ...(cfg.effort && EFFORT_FLAGS[cmd] ? EFFORT_FLAGS[cmd](cfg.effort) : []),
72
101
  ];
73
102
  }
74
103
 
104
+ // What the board's Run form offers: the installed tools, the one it would pick, and
105
+ // each one's configured model to start the field from.
106
+ export function agentChoices(projectDir: string) {
107
+ return {
108
+ default: detectDefaultAgent(projectDir).cmd,
109
+ agents: KNOWN_AGENTS.filter((a) => onPath(a.cmd)).map((a) => ({
110
+ name: a.name, cmd: a.cmd, model: agentConfig(projectDir, a.cmd).model ?? "", models: !!MODEL_FLAGS[a.cmd], yolo: !!YOLO_FLAGS[a.cmd],
111
+ })),
112
+ };
113
+ }
114
+
75
115
  // One argument as a POSIX shell reads it literally: a prompt carries quotes, `$`,
76
116
  // backticks and, with notes, newlines.
77
117
  const shq = (a: string) => `'${a.replace(/'/g, `'\\''`)}'`;
@@ -522,7 +562,7 @@ export type Card = {
522
562
  moved?: number; // runs only: state.json mtime
523
563
  title?: string; // runs only: first line of the request
524
564
  archived: boolean; // hidden from the board unless asked for
525
- tags?: string[]; // runs only: runner, agent, "unrechecked"
565
+ tags?: string[]; // runs only: runner, agent, "yolo", "unrechecked"
526
566
  notes?: string; // inbox notes, or runs/<id>/notes.md
527
567
  };
528
568
 
@@ -852,6 +892,9 @@ export function boardCards(states: string[], now: number): Card[] {
852
892
  ? { kind: "idea", status: "starting", text: it.text, state: st, where, column: 1, awaiting: null, dim: true, archived: false, notes: it.notes }
853
893
  : { kind: "idea", status: "idea", text: it.text, state: st, where, column: 0, awaiting: null, dim: false, archived: it.status === "archived", notes: it.notes });
854
894
  }
895
+ // Runs started in Yolo. Only yolo records are matched to runs, so a project that
896
+ // never used it pays one directory read.
897
+ const yolos = new Set(readSessions(st).filter((s) => s.yolo).map((s) => ownerOf(st, s)));
855
898
  for (const r of readRuns(st)) {
856
899
  next.push({
857
900
  kind: "run", id: r.id, status: statusOf(r, now), text: short(r.id), state: st, where, column: columnOf(r.stage, r.awaiting), moved: r.moved,
@@ -859,7 +902,7 @@ export function boardCards(states: string[], now: number): Card[] {
859
902
  awaiting: r.stage === "abandoned" ? null : r.awaiting,
860
903
  dim: r.stage === "abandoned",
861
904
  archived: archive.has(r.id),
862
- tags: [r.runner, r.agent, unrechecked(st, r.id) ? "unrechecked" : undefined].filter((t): t is string => !!t),
905
+ tags: [r.runner, r.agent, yolos.has(r.id) ? "yolo" : undefined, unrechecked(st, r.id) ? "unrechecked" : undefined].filter((t): t is string => !!t),
863
906
  notes: readNotes(st, r.id) || undefined,
864
907
  });
865
908
  }
@@ -874,8 +917,8 @@ export function boardCards(states: string[], now: number): Card[] {
874
917
  // What starting a run needs, minus the screens: the TUI and the web board both
875
918
  // launch the same way and only differ in how they tell the user.
876
919
  export type Launch =
877
- | { ok: true; agent: { name: string; cmd: string }; prompt: string; projectDir: string; cmd: string[] }
878
- | { ok: false; agent: { name: string; cmd: string }; prompt: string; projectDir: string; manual: string; why: string };
920
+ | { ok: true; agent: Agent; prompt: string; projectDir: string; cmd: string[] }
921
+ | { ok: false; agent: Agent; prompt: string; projectDir: string; manual: string; why: string };
879
922
 
880
923
  export function projectOf(from: string) {
881
924
  return from === join(home, ".kaizen")
@@ -884,7 +927,7 @@ export function projectOf(from: string) {
884
927
  }
885
928
 
886
929
  export function manualCommand(projectDir: string, agentCmd: string, prompt: string, flags: string[] = []) {
887
- const bin = [agentCmd, ...flags].join(" ");
930
+ const bin = [agentCmd, ...flags, ...(PROMPT_FLAGS[agentCmd] ?? [])].join(" ");
888
931
  // Pasted into whatever shell the user has. fish reads \' and \\ inside single
889
932
  // quotes as escapes, so ' and \ each go in double quotes, which sh and fish read alike.
890
933
  const q = (a: string) => `'${a.replace(/['\\]/g, (ch) => (ch === "'" ? `'"'"'` : `'"\\\\"'`))}'`;
@@ -894,10 +937,10 @@ export function manualCommand(projectDir: string, agentCmd: string, prompt: stri
894
937
  }
895
938
 
896
939
  // Agent sessions kaizen opened. Each launch leaves `sessions/<key>.json` in the
897
- // project's state dir ({run, text, cmd, sessionId, started}) and `<key>.pid`, which
940
+ // project's state dir ({run, text, cmd, model, sessionId, started}) and `<key>.pid`, which
898
941
  // the agent writes itself. Only these are ever closed or resumed: a session the user
899
942
  // opened by hand has no record.
900
- export type Session = { key: string; run: string | null; text: string; cmd: string; sessionId: string | null; started: number };
943
+ export type Session = { key: string; run: string | null; text: string; cmd: string; model?: string | null; yolo?: boolean; sessionId: string | null; started: number };
901
944
 
902
945
  // Tools that take a conversation id at launch and resume it by that id. Every other
903
946
  // tool gets a fresh session on each decision, as before.
@@ -1026,34 +1069,53 @@ export function firstSession(st: string, id: string, cmd: string, cwd: string):
1026
1069
  return readSessions(st).find((s) => s.sessionId && s.cmd === cmd && ownerOf(st, s) === id && existsSync(f.transcript(cwd, s.sessionId))) ?? null;
1027
1070
  }
1028
1071
 
1072
+ // The tool and model a run was started with: its latest recorded launch, else the
1073
+ // agent its state.json names. A decision continues on these whatever the config says now.
1074
+ export function runAgent(st: string, id: string): { cmd: string; model: string | null } | null {
1075
+ const s = readSessions(st).filter((x) => ownerOf(st, x) === id).pop();
1076
+ if (s) return { cmd: s.cmd, model: s.model ?? null };
1077
+ try {
1078
+ const a = JSON.parse(readText(join(st, "runs", id, "state.json"))).agent;
1079
+ if (typeof a === "string" && a) return { cmd: a, model: null };
1080
+ } catch { /* no run */ }
1081
+ return null;
1082
+ }
1083
+
1029
1084
  // The agent's argv for a launch, and its record. With a run, a decision: it resumes
1030
1085
  // the run's conversation when there is one. A project without a state dir gets no
1031
1086
  // record; making one here would shadow the user's.
1032
- export function sessionLaunch(projectDir: string, cmd: string, base: string[], prompt: string, text: string, run?: string) {
1087
+ export function sessionLaunch(projectDir: string, cmd: string, base: string[], prompt: string, text: string, run?: string, model?: string, yolo?: boolean) {
1033
1088
  const st = join(projectDir, ".kaizen");
1034
- if (!existsSync(st)) return { fullCmd: [...base, prompt], pidFile: undefined, record: undefined };
1089
+ if (!existsSync(st)) return { fullCmd: [...base, ...promptArgs(cmd, prompt)], pidFile: undefined, record: undefined };
1035
1090
  const f = SESSION_FLAGS[cmd];
1036
1091
  const prev = run ? firstSession(st, run, cmd, projectDir) : null;
1037
1092
  const sessionId = f ? prev?.sessionId ?? crypto.randomUUID() : null;
1038
- const fullCmd = [...base, ...(f && sessionId ? (prev ? f.resume(sessionId) : f.start(sessionId)) : []), prompt];
1093
+ const fullCmd = [...base, ...(f && sessionId ? (prev ? f.resume(sessionId) : f.start(sessionId)) : []), ...promptArgs(cmd, prompt)];
1039
1094
  const key = `${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
1040
1095
  mkdirSync(sessionsDir(st), { recursive: true });
1041
1096
  const record = join(sessionsDir(st), key + ".json");
1042
- writeFileSync(record, JSON.stringify({ run: run ?? null, text, cmd, sessionId, started: Date.now() }) + "\n");
1097
+ writeFileSync(record, JSON.stringify({ run: run ?? null, text, cmd, model: model ?? null, ...(yolo ? { yolo: true } : {}), sessionId, started: Date.now() }) + "\n");
1043
1098
  return { fullCmd, pidFile: join(sessionsDir(st), key + ".pid"), record };
1044
1099
  }
1045
1100
 
1046
1101
  // run: the run a decision is for. Without it, an idea starting a new run.
1047
- export function launchRun(it: Item, from: string, run?: string): Launch {
1102
+ // pick: the tool, model and yolo chosen for a new run; a decision takes the run's own
1103
+ // tool and model, and never yolo.
1104
+ export function launchRun(it: Item, from: string, run?: string, pick: { agent?: string; model?: string; yolo?: boolean } = {}): Launch {
1048
1105
  const projectDir = projectOf(from);
1049
1106
  // The run will be born here; the board must know the dir or the idea never retires.
1050
1107
  remember(projectDir);
1051
- const agent = detectDefaultAgent(projectDir);
1108
+ const own = run && !pick.agent ? runAgent(join(projectDir, ".kaizen"), run) : null;
1109
+ const agent = detectDefaultAgent(projectDir, pick.agent ?? own?.cmd);
1052
1110
  const agentBin = Bun.which(agent.cmd) ?? agent.cmd;
1053
1111
  const prompt = requestOf(it.where ? `/kaizen ${it.text} (${it.where})` : `/kaizen ${it.text}`, it.notes, from);
1054
- const flags = agentFlags(projectDir, agent.cmd);
1112
+ const wanted = pick.model ?? (own?.cmd === agent.cmd ? own.model : null) ?? undefined;
1113
+ const model = wanted && plainModel(wanted) ? wanted : undefined;
1114
+ // Yolo was picked for one tool; a fallback to another opens in its normal mode.
1115
+ const yolo = !!pick.yolo && agent.cmd === pick.agent && !!YOLO_FLAGS[agent.cmd];
1116
+ const flags = [...agentFlags(projectDir, agent.cmd, undefined, model), ...(yolo ? YOLO_FLAGS[agent.cmd] : [])];
1055
1117
  // The idea as its run's request will hold it: without the runner word in front.
1056
- const s = sessionLaunch(projectDir, agent.cmd, [agentBin, ...flags], prompt, it.text.replace(/^(lite|full|auto|plan)\s+/i, ""), run);
1118
+ const s = sessionLaunch(projectDir, agent.cmd, [agentBin, ...flags], prompt, it.text.replace(/^(lite|full|auto|plan)\s+/i, ""), run, model, yolo);
1057
1119
  const manual = manualCommand(projectDir, agent.cmd, prompt, flags);
1058
1120
  const drop = () => { if (s.record) rmSync(s.record, { force: true }); };
1059
1121
 
@@ -1249,3 +1311,58 @@ export async function commitPush(state: string, id: string, message: string, sho
1249
1311
  record(true);
1250
1312
  return { ok: true, sha };
1251
1313
  }
1314
+
1315
+ // ---- issue reports
1316
+
1317
+ // Where a bug report goes: package.json's repository, so a fork reports to itself.
1318
+ export const REPO_URL = (() => {
1319
+ try {
1320
+ const u = JSON.parse(readFileSync(join(import.meta.dir, "..", "package.json"), "utf8")).repository.url as string;
1321
+ return u.replace(/^git\+/, "").replace(/\.git$/, "");
1322
+ } catch { return "https://github.com/hfadhlullah/kaizen"; }
1323
+ })();
1324
+
1325
+ // The machine, as an issue's Environment field. Nothing that names the person: no
1326
+ // hostname, no username, no paths.
1327
+ export function systemInfo(): string {
1328
+ let osName = osVersion();
1329
+ try {
1330
+ if (process.platform === "linux") osName = /^PRETTY_NAME="?([^"\n]*)/m.exec(readFileSync("/etc/os-release", "utf8"))?.[1] || osName;
1331
+ else if (process.platform === "darwin") osName = `macOS ${Bun.spawnSync(["sw_vers", "-productVersion"]).stdout.toString().trim()}`;
1332
+ } catch { /* the kernel's own version string is still an answer */ }
1333
+ let kaizen = "?";
1334
+ try { kaizen = JSON.parse(readFileSync(join(import.meta.dir, "..", "package.json"), "utf8")).version; } catch {}
1335
+ const cpu = cpus();
1336
+ return [
1337
+ `kaizen: ${kaizen}`,
1338
+ `OS: ${osName} (${process.platform} ${release()}, ${arch()})`,
1339
+ `CPU: ${cpu[0]?.model.trim() ?? "?"} × ${cpu.length}`,
1340
+ `RAM: ${Math.round(totalmem() / 2 ** 30)} GB`,
1341
+ `bun: ${Bun.version}`,
1342
+ `terminal: ${process.env.TERM_PROGRAM ?? process.env.TERM ?? "?"}`,
1343
+ ].join("\n");
1344
+ }
1345
+
1346
+ // The home folder as `~`, so a pasted error does not carry the username. Windows paths
1347
+ // also arrive JSON-escaped (`C:\\Users\\x`) or with forward slashes, in any case.
1348
+ export function scrubHome(s: string, homes = [home, homedir()], win = process.platform === "win32"): string {
1349
+ const forms = [...new Set(homes.filter((h) => h.length > 1).flatMap((h) => [h, h.replaceAll("\\", "\\\\"), h.replaceAll("\\", "/")]))]
1350
+ .sort((a, b) => b.length - a.length);
1351
+ return forms.reduce((t, h) => t.replace(new RegExp(h.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"), win ? "gi" : "g"), "~"), s);
1352
+ }
1353
+
1354
+ // A new-issue link with the bug form filled in. The user reads and submits it on
1355
+ // GitHub; nothing is sent from here. GitHub refuses URLs much past 8 KB, so the error
1356
+ // text gives way first.
1357
+ export function issueUrl(o: { title?: string; what?: string } = {}): string {
1358
+ let what = scrubHome(o.what ?? "");
1359
+ const title = scrubHome(o.title ?? "").slice(0, 120), env = systemInfo();
1360
+ // `what` and `env` fill the form's fields; `body` carries the same text for when GitHub
1361
+ // has no bug.yml (not pushed yet, a fork without it) and ignores the field params.
1362
+ const build = () => `${REPO_URL}/issues/new?` + new URLSearchParams({
1363
+ template: "bug.yml", title, what, env, body: `### What happened\n\n${what}\n\n### Environment\n\n\`\`\`text\n${env}\n\`\`\``,
1364
+ }).toString();
1365
+ let url = build();
1366
+ while (url.length > 7500 && what) { what = what.slice(0, Math.floor(what.length * 0.8)) + "\n…(cut)"; url = build(); if (what.length < 20) what = ""; }
1367
+ return url;
1368
+ }
package/cli/web.ts CHANGED
@@ -4,9 +4,9 @@ import { existsSync, readdirSync, statSync, watch, appendFileSync, mkdirSync, ty
4
4
  import { join, dirname, resolve, sep } from "node:path";
5
5
  import {
6
6
  home, type Item, type Card, boardCards, knownProjects, remember, locate, label, tilde, searchRoots, findProjects,
7
- readInbox, writeInbox, replaceIdea, abandonRun, launchRun, parseItem, short, setArchived, writeNotes,
7
+ readInbox, writeInbox, replaceIdea, abandonRun, launchRun, parseItem, agentChoices, projectOf, KNOWN_AGENTS, plainModel, short, setArchived, writeNotes,
8
8
  appendNote, saveAttachment, pidOnPort, readText, cmdExe, sysExe, powershellExe, runGit, cardsGit, readCommit, commitPush, notice, notifier, LOGO,
9
- reviewFindings, pickFindings, fixPrompt, approvalPrompt, nativePath, closeSessions, sweepSessions,
9
+ reviewFindings, pickFindings, fixPrompt, approvalPrompt, nativePath, closeSessions, sweepSessions, issueUrl,
10
10
  } from "./state.ts";
11
11
  import { readSources, addSource, removeSource, moveSource, gatherSource, gatherAll, gatherDirs, due } from "./sources.ts";
12
12
  import { listNotes, saveNote, renameNote, deleteNote, boardIndex, linksTo } from "./notes.ts";
@@ -315,6 +315,19 @@ export async function web(repoDir: string, opts: Opts = {}) {
315
315
  return json({ sources, failed, every: await every() });
316
316
  }
317
317
 
318
+ // The Run form's choices for one project: installed tools and their models.
319
+ // A prefilled GitHub bug form. A redirect, so a plain link opens it with no script
320
+ // and no popup blocker in the way.
321
+ if (req.method === "GET" && path === "/issue") {
322
+ return Response.redirect(issueUrl({ title: url.searchParams.get("title") ?? "", what: url.searchParams.get("what") ?? "" }), 302);
323
+ }
324
+
325
+ if (req.method === "GET" && path === "/agents") {
326
+ const dir = url.searchParams.get("dir");
327
+ if (!dir || !states(true).includes(dir)) return bad("unknown state dir", 404);
328
+ return json(agentChoices(projectOf(dir)));
329
+ }
330
+
318
331
  if (req.method === "GET" && path === "/settings") {
319
332
  const { listSettings } = await import("./settings.ts");
320
333
  return json(listSettings(repoDir));
@@ -329,6 +342,28 @@ export async function web(repoDir: string, opts: Opts = {}) {
329
342
  return "error" in r ? bad(r.error) : json(r);
330
343
  }
331
344
 
345
+ // A page cannot learn a real path from a file input, so the board, which runs
346
+ // on the user's machine, opens the system's own folder dialog and answers it.
347
+ if (req.method === "POST" && path === "/pick-folder") {
348
+ if (!local(req)) return bad("cross-origin write refused", 403);
349
+ if (picking) return bad("a folder dialog is already open", 409);
350
+ const cmd = folderDialog();
351
+ if (!cmd) return bad("No folder dialog found: install zenity or kdialog, or type the path");
352
+ picking = true;
353
+ try {
354
+ const p = Bun.spawn(cmd, { stdin: "ignore", stdout: "pipe", stderr: "pipe" });
355
+ const [out, err, code] = await Promise.all([new Response(p.stdout).text(), new Response(p.stderr).text(), p.exited]);
356
+ // osascript ends a folder with "/"; a bare root (/, C:\) keeps its own.
357
+ const picked = /^([A-Za-z]:)?[\\/]$/.test(out.trim()) ? out.trim() : out.trim().replace(/[\\/]+$/, "");
358
+ if (code === 0 && picked) return json({ path: picked });
359
+ // Cancel: exit 1 with nothing said (zenity, kdialog), -128 (osascript), or exit 0 with no path (PowerShell).
360
+ if (code === 0 || !err.trim() || err.includes("-128")) return json({ cancelled: true });
361
+ return bad(err.trim().split("\n")[0]);
362
+ } catch (e) {
363
+ return bad(e instanceof Error ? e.message : String(e));
364
+ } finally { picking = false; }
365
+ }
366
+
332
367
  // Attachments only, never a run's own files: the path must sit under an
333
368
  // attachments/ directory of a known state dir, with no way back up.
334
369
  if (req.method === "GET" && path === "/file") {
@@ -452,11 +487,15 @@ export async function web(repoDir: string, opts: Opts = {}) {
452
487
  const text = String(body.text ?? "").trim();
453
488
  const kind = body.kind === "full" || body.kind === "lite" ? body.kind : "";
454
489
  if (!text) return bad("empty idea");
490
+ const agent = typeof body.agent === "string" && body.agent ? body.agent : undefined;
491
+ if (agent && !KNOWN_AGENTS.some((a) => a.cmd === agent)) return bad("unknown agent");
492
+ const model = typeof body.model === "string" ? body.model.trim() : "";
493
+ if (model && !plainModel(model)) return bad("A model name is letters, digits and . : / @ _ - only");
455
494
  const it: Item = parseItem(`${kind} ${text}`.trim());
456
495
  // A `started` idea may be run again: the terminal the first launch opened
457
496
  // can come up empty, and nothing else tells the board a run never began.
458
497
  it.notes = readInbox(dir).find((l) => (l.status === "open" || l.status === "started") && l.text === text)?.notes;
459
- const r = launchRun(it, dir);
498
+ const r = launchRun(it, dir, undefined, { agent, model: model || undefined, yolo: body.yolo === true });
460
499
  if (r.ok) replaceIdea(dir, text, { status: "started", text });
461
500
  changed();
462
501
  return json(r.ok
@@ -651,6 +690,29 @@ export async function openApp(url: string) {
651
690
  }
652
691
 
653
692
  // Windows keeps browsers out of PATH; the usual install roots are the next best guess.
693
+ // One dialog at a time: a second click while one is open would stack another.
694
+ let picking = false;
695
+
696
+ // The system folder picker for this platform, printing the chosen path on stdout.
697
+ // macOS and Windows always have one; on Linux it is whichever desktop tool exists.
698
+ export function folderDialog(platform: string = process.platform): string[] | null {
699
+ const prompt = "Choose your projects folder";
700
+ // osascript is a background process: without `activate` its dialog can open behind
701
+ // the browser. Plain `activate` targets osascript itself, so no Automation prompt.
702
+ if (platform === "darwin") return ["osascript", "-e", "activate", "-e", `POSIX path of (choose folder with prompt "${prompt}")`];
703
+ if (platform === "win32") {
704
+ // An owner form kept on top, or the dialog opens behind the browser.
705
+ const ps = `[Console]::OutputEncoding=[Text.Encoding]::UTF8;Add-Type -AssemblyName System.Windows.Forms;`
706
+ + `$o=New-Object System.Windows.Forms.Form -Property @{TopMost=$true};$d=New-Object System.Windows.Forms.FolderBrowserDialog;`
707
+ + `$d.Description='${prompt}';if($d.ShowDialog($o) -eq 'OK'){[Console]::Out.Write($d.SelectedPath)}`;
708
+ return [powershellExe(), "-NoProfile", "-STA", "-Command", ps];
709
+ }
710
+ const zenity = Bun.which("zenity", { PATH: process.env.PATH });
711
+ if (zenity) return [zenity, "--file-selection", "--directory", `--title=${prompt}`];
712
+ const kdialog = Bun.which("kdialog", { PATH: process.env.PATH });
713
+ return kdialog ? [kdialog, "--getexistingdirectory", home, "--title", prompt] : null;
714
+ }
715
+
654
716
  function winChrome(exe: string) {
655
717
  const roots = [process.env["ProgramFiles"], process.env["ProgramFiles(x86)"], process.env["LOCALAPPDATA"]].filter(Boolean) as string[];
656
718
  const sub: Record<string, string> = {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "kaizen-agent",
3
- "version": "1.10.0",
3
+ "version": "1.11.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": {
@@ -62,7 +62,10 @@ If the user typed `/kaizen` with no argument and a run is in progress, treat it
62
62
  ## Before doing anything
63
63
 
64
64
  1. Locate the state directory: `.kaizen/` in the project folder's root, else
65
- `~/.kaizen/` keyed by working directory. If neither exists and the user is starting
65
+ `~/.kaizen/` keyed by working directory. The project folder is the working
66
+ directory this session started in — never the folder this skill was read from, nor
67
+ the repository it lives in, even when that has a `.kaizen/` of its own. A run
68
+ written there is invisible to the board the user launched it from. If neither exists and the user is starting
66
69
  work, run `/kaizen init` first: it creates `.kaizen/` with a copy of the `spec*.md`
67
70
  files and — where the folder is a git repository — the gitignore entry
68
71
  (see [`adapters.md`](adapters.md)). Also append the project folder's absolute path
package/web/board.html CHANGED
@@ -26,6 +26,11 @@ pre.mark span:nth-child(1){color:var(--ink-hi)}pre.mark span:nth-child(2){color:
26
26
  .sel{border:1px solid var(--line);border-radius:var(--r-s);padding:6px 12px;background-color:var(--bg-2);color:var(--ink);font-size:13px}
27
27
  select.sel,button.sel{cursor:pointer;appearance:none;padding-right:30px;max-width:100%;background-image:linear-gradient(45deg,transparent 50%,var(--dim) 50%),linear-gradient(135deg,var(--dim) 50%,transparent 50%);background-position:calc(100% - 16px) 50%,calc(100% - 11px) 50%;background-size:5px 5px;background-repeat:no-repeat}
28
28
  .sel:hover{border-color:var(--dim)}
29
+ .agents{display:flex;flex-wrap:wrap;gap:6px}
30
+ .agent{display:inline-flex;align-items:center;gap:6px;border:1px solid var(--line);border-radius:var(--r-s);padding:6px 12px;background:var(--bg-2);color:var(--dim);font-size:13px;cursor:pointer}
31
+ .agent svg{width:14px;height:14px}.agent:hover{border-color:var(--dim)}
32
+ .yolo{display:inline-flex;align-items:center;gap:8px;margin-right:auto;font-size:13px;color:var(--ink);cursor:pointer;white-space:nowrap}
33
+ .agent[aria-checked=true]{border-color:var(--accent);color:var(--ink);background:color-mix(in srgb,var(--accent) 14%,var(--bg-2))}
29
34
  .hdr .sel{max-width:min(320px,100%);overflow:hidden;text-overflow:ellipsis;white-space:nowrap;text-align:left}
30
35
  /* project picker: a select-shaped button over a searchable list */
31
36
  .combo{position:relative;max-width:100%;display:flex}
@@ -55,14 +60,14 @@ select.sel,button.sel{cursor:pointer;appearance:none;padding-right:30px;max-widt
55
60
  .btn.ghost{background:none;border-color:transparent;color:var(--dim);box-shadow:none}.btn.ghost:hover{color:var(--ink);background:var(--hover)}
56
61
 
57
62
  /* summary strip: each count is a pill that filters the board */
58
- .strip{display:flex;gap:8px;padding:10px 28px;flex-wrap:wrap;align-items:center;font-size:13px;color:var(--dim)}
63
+ .strip{display:inline-flex;gap:8px;flex-wrap:wrap;align-items:center;font-size:13px;color:var(--dim)}
59
64
  .strip button{display:inline-flex;align-items:center;gap:6px;color:var(--dim);padding:4px 12px;border-radius:var(--r-pill);box-shadow:inset 0 0 0 1px var(--line);transition:background .12s,color .12s}
60
65
  .strip button:hover,.strip button.on{color:var(--ink);background:var(--hover)}
61
66
  .strip button.attn{color:var(--warn);box-shadow:inset 0 0 0 1px color-mix(in srgb,var(--warn) 45%,transparent)}
62
67
  .strip svg{width:14px;height:14px;stroke-width:1.8}
63
68
 
64
69
  /* board */
65
- main{flex:1;min-height:0;display:grid;grid-template-columns:repeat(5,minmax(200px,1fr));gap:24px;padding:0 28px 20px;overflow:auto;align-content:start}
70
+ main{flex:1;min-height:0;display:grid;grid-template-columns:repeat(5,minmax(200px,1fr));gap:24px;padding:16px 28px 20px;overflow:auto;align-content:start}
66
71
  section h2::before{content:"";display:block;height:16px;margin-top:-16px}
67
72
  section{padding-top:16px}
68
73
  section h2{margin:0;font-size:13px;color:var(--ink);font-weight:600;display:flex;align-items:center;gap:8px;height:30px;position:sticky;top:0;background:var(--bg);z-index:1}
@@ -118,7 +123,7 @@ section h2 .clear{margin-left:auto;min-height:22px;padding:0 8px}
118
123
  .menu-pop button{text-align:left;padding:7px 10px;font-size:13px;font-weight:400;color:var(--ink);border-radius:var(--r-s);display:flex;gap:8px;align-items:center}
119
124
  .menu-pop button:hover{background:var(--hover)}
120
125
  .menu-pop button.danger{color:var(--danger)}
121
- .menu-pop svg{width:15px;height:15px}
126
+ .menu-pop svg{width:15px;height:15px;flex-shrink:0}
122
127
  .menu-pop hr{border:0;border-top:1px solid var(--line);margin:4px 0}
123
128
  .more-btn{width:100%;padding:8px;color:var(--dim);font-size:12px;border:1px dashed var(--line);border-radius:var(--r)}
124
129
  .more-btn:hover{color:var(--ink);border-color:var(--dim)}
@@ -145,8 +150,10 @@ section h2 .clear{margin-left:auto;min-height:22px;padding:0 8px}
145
150
 
146
151
  /* footer */
147
152
  .ftr{display:flex;justify-content:space-between;gap:16px;padding:10px 28px;border-top:1px solid var(--line);color:var(--dim);font-size:12px;flex-wrap:wrap;align-items:center}
148
- .ftr span+span{margin-left:14px}.ftr .st svg{width:12px;height:12px}
149
- .ftr a{color:var(--dim);cursor:pointer;text-decoration:underline;text-underline-offset:2px}.ftr a:hover{color:var(--ink)}
153
+ .ftr span+span,.ftr a+a{margin-left:14px}.ftr .strip{margin-right:14px;vertical-align:middle;font-size:12px}
154
+ /* The footer is out of the eye's path, so the waiting alert is filled, not just outlined. */
155
+ .ftr .strip .attn{color:var(--ink);background:color-mix(in srgb,var(--warn) 18%,transparent);box-shadow:inset 0 0 0 1px var(--warn)}.ftr .strip .attn:hover,.ftr .strip .attn.on{background:color-mix(in srgb,var(--warn) 30%,transparent)}.ftr .strip .attn svg{color:var(--warn)}.ftr .st svg{width:12px;height:12px}
156
+ .ftr a{color:var(--dim);cursor:pointer;white-space:nowrap;text-decoration:underline;text-underline-offset:2px}.ftr a:hover{color:var(--ink)}
150
157
 
151
158
  /* side panel */
152
159
  .scrim{position:fixed;inset:0;z-index:10;background:color-mix(in srgb,var(--bg) 60%,transparent);backdrop-filter:blur(3px);display:none}
@@ -202,6 +209,7 @@ aside .foot .more .menu-pop{left:0;right:auto;top:auto;bottom:calc(100% + 4px)}.
202
209
  .srow>:nth-child(2){grid-column:2;grid-row:1/span 2;align-self:center;justify-self:end}
203
210
  .srow .hint{grid-column:1;margin:0;font-size:12px;color:var(--dim);line-height:1.45;max-width:46ch}
204
211
  .srow input.sel{width:170px}
212
+ .fpick{display:inline-flex;gap:6px;align-items:center}
205
213
  .srow[hidden],.sg[hidden],.sjump button[hidden]{display:none}
206
214
  /* search and section chips: pinned to the top of the panel while the rows scroll under it */
207
215
  .sq{position:sticky;top:-24px;z-index:1;background:var(--bg-2);padding:24px 0 12px;margin:-24px 0 4px;border-bottom:1px solid var(--line)}.sq input{width:100%;max-width:none}.sq+.sg{margin-top:16px}
@@ -222,10 +230,55 @@ aside .foot .more .menu-pop{left:0;right:auto;top:auto;bottom:calc(100% + 4px)}.
222
230
  .car i{display:flex;gap:3px}.car u{width:14px;height:3px;border-radius:2px;background:var(--line)}.car u.on{background:var(--accent)}
223
231
  .toast{position:fixed;z-index:12;left:28px;bottom:56px;max-width:calc(100% - 56px);background:var(--ink);color:var(--bg);border-radius:var(--r);padding:10px 16px;font-size:13px;box-shadow:var(--shadow-2);opacity:0;transition:opacity .2s;pointer-events:none}
224
232
  .toast.on{opacity:1}
233
+ .toast.on.rep{pointer-events:auto}.toast a{color:inherit;margin-left:8px;text-decoration:underline;text-underline-offset:2px}
225
234
  .help{position:fixed;inset:0;z-index:12;display:none;align-items:center;justify-content:center;background:color-mix(in srgb,var(--bg) 60%,transparent);backdrop-filter:blur(3px)}
226
235
  .help.on{display:flex}.help div{background:var(--bg-2);border:1px solid var(--line);border-radius:var(--r);padding:24px 28px;line-height:2.2;box-shadow:var(--shadow-2)}
227
- .help kbd{display:inline-block;min-width:40px;margin-right:12px;padding:0 6px;text-align:center;border:1px solid var(--line);border-radius:var(--r-s);background:var(--bg);font:12px/20px var(--mono);color:var(--ink)}
228
- @media(max-width:900px){main{grid-template-columns:1fr;padding-inline:16px;gap:8px}.hdr,.strip,.ftr{padding-inline:16px}.more{display:grid}.card:hover .age{display:inline}.toast{left:16px;max-width:calc(100% - 32px)}}
236
+ /* request sources: cards, status badges, top bar, and add-box */
237
+ .src-topbar{display:flex;justify-content:space-between;align-items:center;padding:10px 14px;background:var(--bg);border:1px solid var(--line);border-radius:var(--r);margin:14px 0 16px;gap:12px}
238
+ .src-topbar .meta{display:flex;flex-direction:column;gap:2px}
239
+ .src-topbar .count{font-size:13px;font-weight:600;color:var(--ink)}
240
+ .src-topbar .sched{font-size:12px;color:var(--dim)}
241
+ .src-topbar .sched a{color:var(--accent);text-decoration:none;cursor:pointer}
242
+ .src-topbar .sched a:hover{text-decoration:underline}
243
+ .src-topbar .btn{display:inline-flex;align-items:center;gap:6px}
244
+ .src-topbar .btn svg{width:14px;height:14px}
245
+ .src-list{display:flex;flex-direction:column;gap:10px}
246
+ .src-card{background:var(--bg-2);border:1px solid var(--line);border-radius:var(--r);padding:14px;box-shadow:var(--shadow-1);display:flex;flex-direction:column;gap:9px;transition:border-color .15s}
247
+ .src-card:hover{border-color:color-mix(in srgb,var(--line) 70%,var(--ink))}
248
+ .src-hd{display:flex;justify-content:space-between;align-items:center;gap:10px}
249
+ .src-info{display:flex;align-items:center;gap:8px;min-width:0}
250
+ .src-ico{width:20px;height:20px;flex-shrink:0;color:var(--accent);display:inline-flex;align-items:center;justify-content:center}
251
+ .src-ico svg{width:18px;height:18px}
252
+ .src-title{font-size:14px;font-weight:600;color:var(--ink);white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
253
+ .src-acts{display:flex;align-items:center;gap:4px;flex-shrink:0}
254
+ .src-acts .btn{display:inline-flex;align-items:center;gap:5px}
255
+ .src-acts .btn svg{width:14px;height:14px}
256
+ .src-url{font-size:12px;color:var(--dim);overflow-wrap:anywhere;line-height:1.4}
257
+ .src-url a{color:inherit;text-decoration:none;display:inline-flex;align-items:center;gap:4px}
258
+ .src-url a:hover{color:var(--ink);text-decoration:underline}
259
+ .src-url svg{width:12px;height:12px;stroke-width:1.8;flex-shrink:0}
260
+ .src-status{display:flex;flex-wrap:wrap;align-items:center;gap:6px;font-size:12px}
261
+ .src-badge{display:inline-flex;align-items:center;gap:5px;padding:2px 8px;border-radius:var(--r-pill);font-size:11px;font-weight:500}
262
+ .src-badge.ok{background:color-mix(in srgb,var(--ok) 14%,transparent);color:var(--ok)}
263
+ .src-badge.warn{background:color-mix(in srgb,var(--warn) 20%,transparent);color:var(--ink);font-weight:500}
264
+ .src-badge.warn svg{color:var(--warn)}
265
+ .src-badge.dim{background:color-mix(in srgb,var(--dim) 14%,transparent);color:var(--dim)}
266
+ .src-badge svg{width:12px;height:12px;stroke-width:2;flex-shrink:0}
267
+ .src-dest{display:flex;align-items:center;gap:8px;font-size:12px;color:var(--dim);padding-top:8px;border-top:1px solid var(--line)}
268
+ .src-dest .sel{flex:1;max-width:240px;height:28px;font-size:12px;padding:2px 8px}
269
+ .src-dest .btn{height:28px;padding:0 10px;font-size:12px}
270
+ .src-empty{padding:26px 16px;text-align:center;background:var(--bg);border:1px dashed var(--line);border-radius:var(--r);margin:12px 0 16px}
271
+ .src-empty-ico{width:36px;height:36px;margin:0 auto 10px;color:var(--dim);display:flex;align-items:center;justify-content:center}
272
+ .src-empty-ico svg{width:32px;height:32px}
273
+ .src-empty h4{margin:0 0 6px;font-size:14px;font-weight:600;color:var(--ink)}
274
+ .src-empty p{margin:0 0 12px;font-size:12px;color:var(--dim);line-height:1.5}
275
+ .src-empty-grid{display:grid;grid-template-columns:repeat(auto-fit,minmax(130px,1fr));gap:8px;margin-top:12px;text-align:left}
276
+ .src-empty-item{background:var(--bg-2);border:1px solid var(--line);border-radius:var(--r-s);padding:8px 10px;font-size:11px;color:var(--dim);line-height:1.4}
277
+ .src-empty-item strong{display:block;color:var(--ink);font-weight:500;margin-bottom:2px}
278
+ .src-add-box{background:var(--bg-2);border:1px solid var(--line);border-radius:var(--r);padding:16px;margin-top:16px;box-shadow:var(--shadow-1)}
279
+ .src-add-box h3{margin:0 0 12px;font-size:13px;font-weight:600;color:var(--ink);display:flex;align-items:center;gap:6px}
280
+ .src-add-box h3 svg{width:16px;height:16px}
281
+ @media(max-width:900px){main{grid-template-columns:1fr;padding-inline:16px;gap:8px}.hdr,.ftr{padding-inline:16px}.more{display:grid}.card:hover .age{display:inline}.toast{left:16px;max-width:calc(100% - 32px)}}
229
282
  @media(prefers-reduced-motion:reduce){*,*::before,*::after{transition:none!important;animation:none!important}}
230
283
  </style>
231
284
  </head>
@@ -243,6 +296,13 @@ aside .foot .more .menu-pop{left:0;right:auto;top:auto;bottom:calc(100% + 4px)}.
243
296
  <symbol id="i-trash" viewBox="0 0 24 24"><path d="M3 6h18M8 6V4h8v2M6 6l1 14h10l1-14M10 11v6M14 11v6"/></symbol>
244
297
  <symbol id="i-close" viewBox="0 0 24 24"><path d="M18 6L6 18M6 6l12 12"/></symbol>
245
298
  <symbol id="i-more" viewBox="0 0 24 24"><circle cx="5" cy="12" r="1.2" fill="currentColor"/><circle cx="12" cy="12" r="1.2" fill="currentColor"/><circle cx="19" cy="12" r="1.2" fill="currentColor"/></symbol>
299
+ <symbol id="i-open" viewBox="0 0 24 24"><rect x="3" y="3" width="18" height="18" rx="2"/><path d="M15 3v18"/></symbol>
300
+ <symbol id="i-archive" viewBox="0 0 24 24"><rect x="2" y="3" width="20" height="5" rx="1"/><path d="M4 8v11a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8M10 12h4"/></symbol>
301
+ <symbol id="i-restore" viewBox="0 0 24 24"><path d="M3 12a9 9 0 1 0 9-9 9.75 9.75 0 0 0-6.74 2.74L3 8"/><path d="M3 3v5h5"/></symbol>
302
+ <symbol id="i-sheet" viewBox="0 0 24 24"><rect x="3" y="3" width="18" height="18" rx="2"/><path d="M3 9h18M3 15h18M9 3v18"/></symbol>
303
+ <symbol id="i-doc" viewBox="0 0 24 24"><path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/><path d="M14 2v6h6M16 13H8M16 17H8M10 9H8"/></symbol>
304
+ <symbol id="i-refresh" viewBox="0 0 24 24"><path d="M21 3v5h-5M3 21v-5h5M20.5 13A9 9 0 0 1 5.7 18.3L3 16M3.5 11A9 9 0 0 1 18.3 5.7L21 8"/></symbol>
305
+ <symbol id="i-ext" viewBox="0 0 24 24"><path d="M18 13v6a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2h6M15 3h6v6M10 14L21 3"/></symbol>
246
306
  <symbol id="s-idea" viewBox="0 0 16 16"><circle cx="8" cy="8" r="6" stroke-dasharray="2 2.5"/></symbol>
247
307
  <symbol id="s-start" viewBox="0 0 16 16"><circle cx="8" cy="8" r="6" stroke-dasharray="2 2.5"/><path d="M6.5 5.5v5l4-2.5z" fill="currentColor" stroke="none"/></symbol>
248
308
  <symbol id="s-run" viewBox="0 0 16 16"><circle cx="8" cy="8" r="6"/><path d="M8 2a6 6 0 0 1 0 12z" fill="currentColor" stroke="none"/></symbol>
@@ -273,11 +333,9 @@ aside .foot .more .menu-pop{left:0;right:auto;top:auto;bottom:calc(100% + 4px)}.
273
333
  <button class="ib" id="sources" title="Request sources" aria-label="Request sources"><svg><use href="#i-link"/></svg></button>
274
334
  <button class="ib" id="settings" title="Settings" aria-label="Settings"><svg><use href="#i-gear"/></svg></button>
275
335
  </div>
276
- <div class="strip" id="strip"></div>
277
-
278
336
  <main id="board" aria-live="polite"></main>
279
337
 
280
- <div class="ftr"><span id="legend"></span><span><a id="kbd">Keyboard shortcuts</a></span></div>
338
+ <div class="ftr"><span id="legend"></span><span><span class="strip" id="strip"></span><a href="/issue" target="_blank" rel="noopener">Report an issue</a><a id="kbd">Keyboard shortcuts</a></span></div>
281
339
 
282
340
  <div class="scrim" id="scrim"></div>
283
341
  <aside id="panel" aria-label="detail">
@@ -374,7 +432,7 @@ function mic(fields){
374
432
  return b}
375
433
  const m=60e3,h=3600e3,d=86400e3;
376
434
  const age=(t,now)=>{const x=now-t;return x<h?Math.round(x/m)+"m":x<d?Math.round(x/h)+"h":Math.round(x/d)+"d"};
377
- const NAMES={claude:"Claude Code",codex:"Codex",agy:"Antigravity",opencode:"OpenCode",gemini:"Gemini",cursor:"Cursor",unrechecked:"fix not rechecked"};
435
+ const NAMES={claude:"Claude Code",codex:"Codex",agy:"Antigravity",opencode:"OpenCode",gemini:"Gemini",cursor:"Cursor",yolo:"yolo",unrechecked:"fix not rechecked"};
378
436
  const MARKS={claude:1,codex:1,agy:1,opencode:1,gemini:1,cursor:1,full:1,lite:1};
379
437
  const COLS=["Ideas","Planning","Building","Review","Done"];
380
438
  // state.ts statuses onto the icon set in the <defs> above.
@@ -425,7 +483,7 @@ async function follow(idea){
425
483
  if(openKey===k)close()}
426
484
  // A tab outliving its server would otherwise swallow every click: say so.
427
485
  const GONE="The board server is not running — start it with kaizen web (or the shortcut), then reload.";
428
- async function post(path,body){let r;try{r=await fetch(path,{method:"POST",headers:{"content-type":"application/json"},body:JSON.stringify(body)})}catch{toast(GONE);return{ok:false}}const j=await r.json().catch(()=>({}));if(!r.ok){toast(j.error||j.why||"That did not work");if(j.manual)console.log("run manually:",j.manual)}return j}
486
+ async function post(path,body){let r;try{r=await fetch(path,{method:"POST",headers:{"content-type":"application/json"},body:JSON.stringify(body)})}catch{toast(GONE);return{ok:false}}const j=await r.json().catch(()=>({}));if(!r.ok){const m=j.error||j.why||"That did not work";toast(m,{title:m.slice(0,80),what:`POST ${path} failed (HTTP ${r.status}): ${m}`});if(j.manual)console.log("run manually:",j.manual)}return j}
429
487
 
430
488
  function card(c){
431
489
  const k=key(c),el=document.createElement("div");el.className="card"+(k===openKey?" open":"")+(k===menuKey?" menu":"")+(c.archived?" archived":"");el.tabIndex=0;el.dataset.k=k;el.setAttribute("role","button");
@@ -434,25 +492,35 @@ function card(c){
434
492
  if(c.status==="waiting")chips.push(`<span class="chip warn"><i></i>${esc(need(c.awaiting)||"needs you")}</span>`);
435
493
  else if(c.status==="running")chips.push(`<span class="chip run"><i></i>running</span>`);
436
494
  if(all)chips.push(`<span class="chip">${esc(c.where)}</span>`);
437
- chips.push(...(c.tags||[]).map(t=>{const mark=MARKS[t]?`<svg><use href="#c-${t}"/></svg>`:"";return `<span class="chip${t==="full"||t==="lite"?" accent":""}">${mark}${esc(NAMES[t]||t)}</span>`}));
495
+ chips.push(...(c.tags||[]).map(t=>{const mark=MARKS[t]?`<svg><use href="#c-${t}"/></svg>`:"";return `<span class="chip${t==="full"||t==="lite"?" accent":t==="yolo"?" warn":""}"${t==="yolo"?' title="Started in Yolo: the agent runs commands without asking"':""}>${mark}${esc(NAMES[t]||t)}</span>`}));
438
496
  // A gathered idea's notes open with the stamp ingest() writes; its chip names the source.
439
497
  const g=c.notes&&/^\[[^\]]*\] Gathered from (.+?)\. Outside text/.exec(c.notes);if(g)chips.push(`<span class="chip" title="From ${esc(g[1])}">gathered</span>`);else if(c.notes)chips.push(`<span class="chip">notes</span>`);
440
498
  // Information only: the panel's button is where it is committed and pushed.
441
499
  if(c.git){const t=c.git==="pushed"?"Committed & pushed":"Not committed & pushed yet";chips.push(`<span class="chip ${c.git==="pushed"?"ok":"warn"}" role="img" title="${t}" aria-label="${t}"><svg><use href="#c-git"/></svg></span>`)}
442
500
  const pick=picking&&!archive&&c.column===4;
443
- el.innerHTML=(pick?`<input type="checkbox" class="pick" ${picked.has(k)?"checked":""} aria-label="Select">`:"")+`<div class="c-id"><span class="st d-${st}"><svg><use href="#s-${st}"/></svg></span><span class="idt">${esc(idt)}</span><span class="age">${c.kind==="run"?age(c.moved||S.now,S.now):""}</span><button class="ib sm more" data-a="menu" title="Actions" aria-label="Actions">${ico("more")}</button></div>
501
+ el.innerHTML=(pick?`<input type="checkbox" class="pick" ${picked.has(k)?"checked":""} aria-label="Select">`:"")+`<div class="c-id"><span class="st d-${st}"><svg><use href="#s-${st}"/></svg></span><span class="idt">${esc(idt)}</span><span class="age">${c.kind==="run"?age(c.moved||S.now,S.now):""}</span><button class="ib sm more" data-a="menu" title="Actions" aria-label="Actions" aria-haspopup="menu" aria-expanded="${k===menuKey}">${ico("more")}</button></div>
444
502
  <div class="c-title">${esc(c.kind==="idea"?c.text:(c.title||c.id))}</div>
445
503
  <div class="c-chips">${chips.join("")}</div>`;
446
504
  if(k===menuKey){
447
505
  const items=[];
448
- if(archive){items.push(["restore","Restore",ico("play")]);if(c.kind==="run")items.push(["open","Open",ico("play")]);}
449
- else if(c.kind==="idea"&&c.status==="idea")items.push(["run","Run",ico("play")],["edit","Edit",ico("pen")],["reject","Reject",ico("x")],null,["delete","Delete",ico("trash"),"danger"]);
450
- else if(c.kind==="idea")items.push(["run","Run again",ico("play")],null,["delete","Remove",ico("trash"),"danger"]);
451
- else if(c.status==="waiting"||c.status==="running"||c.status==="stalled")items.push(["open","Open",ico("play")],null,["reject","Abandon",ico("x"),"danger"]);
452
- else items.push(["open","Open",ico("play")]);
453
- if(!archive&&!(c.kind==="idea"&&c.status!=="idea"))items.push(null,["archive","Archive",ico("x")]);
454
- el.innerHTML+=`<div class="menu-pop">${items.map(i=>i?`<button data-a="${i[0]}" class="${i[3]||""}">${i[2]}${i[1]}</button>`:"<hr>").join("")}</div>`}
455
- el.querySelectorAll("[data-a]").forEach(bt=>bt.onclick=async e=>{e.stopPropagation();const a=bt.dataset.a;menuKey=null;if(a==="menu"){menuKey=k;render();return}if(a==="open"){open(c);return}
506
+ if(archive){
507
+ items.push(["restore","Restore",ico("restore")]);
508
+ items.push(["open","Open",ico("open")]);
509
+ if(c.kind==="idea")items.push(null,["delete","Delete",ico("trash"),"danger"]);
510
+ }else if(c.kind==="idea"&&c.status==="idea"){
511
+ items.push(["run","Run",ico("play")],["edit","Edit",ico("pen")],["archive","Archive",ico("archive")],null,["reject","Reject",ico("x")],["delete","Delete",ico("trash"),"danger"]);
512
+ }else if(c.kind==="idea"){
513
+ items.push(["open","Open",ico("open")],["run","Run again",ico("play")],["archive","Archive",ico("archive")],null,["delete","Remove",ico("trash"),"danger"]);
514
+ }else{
515
+ const openLabel=c.status==="waiting"?(c.awaiting==="approvals.plan"?"Review plan":c.awaiting==="findings"?"See findings":c.awaiting==="approvals.review"?"Review work":"Open"):"Open";
516
+ items.push(["open",openLabel,ico("open")]);
517
+ items.push(["archive","Archive",ico("archive")]);
518
+ if(c.status==="waiting"||c.status==="running"||c.status==="stalled"){
519
+ items.push(null,["reject","Abandon",ico("x"),"danger"]);
520
+ }
521
+ }
522
+ el.innerHTML+=`<div class="menu-pop" role="menu">${items.map(i=>i?`<button role="menuitem" data-a="${i[0]}" class="${i[3]||""}">${i[2]}${i[1]}</button>`:"<hr>").join("")}</div>`}
523
+ el.querySelectorAll("[data-a]").forEach(bt=>bt.onclick=async e=>{e.stopPropagation();const a=bt.dataset.a;const was=menuKey;menuKey=null;if(a==="menu"){menuKey=was===k?null:k;render();return}if(a==="open"){open(c);return}
456
524
  if(a==="archive"||a==="restore"){toggleArchive(c,a==="archive");return}
457
525
  mode=a;modeKey=k;render()});
458
526
  if(pick){el.classList.toggle("picked",picked.has(k));el.onclick=()=>{picked.has(k)?picked.delete(k):picked.add(k);render()};return el}
@@ -464,7 +532,7 @@ function inlineForm(c){
464
532
  if(mode==="edit"){li.innerHTML=`<input value="${esc(c.text)}"><textarea placeholder="Notes: links, paths, background">${esc(c.notes||"")}</textarea><div class="q dim" id="clong" hidden>The first line is over 72 characters; logs will cut it short.</div><div class="row"><button class="btn ghost" data-a="c">Cancel</button><button class="btn pri" data-a="s">Save</button></div>`;li.querySelector('[data-a=s]').onclick=async()=>{const next=li.querySelector("input").value.trim(),notes=li.querySelector("textarea").value.trim();mode=null;if(next&&(next!==c.text||notes!==(c.notes||"")))await post("/inbox",{op:"edit",dir,text:c.text,next,notes});load()}}
465
533
  if(mode==="reject"){const idea=c.kind==="idea";li.innerHTML=`<div class="q">${idea?"Reject this idea?":"Abandon this run?"} The reason is recorded.</div><input placeholder="Reason"><div class="row"><button class="btn ghost" data-a="c">Keep it</button><button class="btn warn" data-a="s">${idea?"Reject":"Abandon"}</button></div>`;li.querySelector('[data-a=s]').onclick=async()=>{const why=li.querySelector("input").value.trim();if(!why){toast("A reason is required");return}mode=null;const r=idea?await post("/inbox",{op:"reject",dir,text:c.text,reason:why}):await post("/abort",{dir,id:c.id,why});if(r.ok)toast(idea?"Rejected":"Abandoned, reason recorded");load()}}
466
534
  if(mode==="delete"){li.innerHTML=`<div class="q">Delete "${esc(c.text)}"?</div><div class="q dim">Not recorded anywhere.</div><div class="row"><button class="btn ghost" data-a="c">Keep it</button><button class="btn danger" data-a="s">Delete</button></div>`;li.querySelector('[data-a=s]').onclick=async()=>{mode=null;const r=await post("/inbox",{op:"delete",dir,text:c.text});if(r.ok)toast("Deleted");load()}}
467
- if(mode==="run"){li.innerHTML=`<div class="q">Start a run for "${esc(c.text)}"</div><div class="q dim">Opens your agent in a new terminal, or a herdr tab or tmux window when one is running, with the request typed in.</div><div class="q dim">Full: planned, built, then checked by a separate reviewer. Lite: one session does it all, faster and cheaper.</div><div class="row"><button class="btn ghost" data-a="c">Cancel</button><button class="btn" data-a="l">Lite</button><button class="btn pri" data-a="s">Full run</button></div>`;li.querySelector('[data-a=s]').onclick=()=>startRun(c,"full");li.querySelector('[data-a=l]').onclick=()=>startRun(c,"lite")}
535
+ if(mode==="run"){li.innerHTML=`<div class="q">Start a run for "${esc(c.text)}"</div><div class="q dim">Opens your agent in a new terminal, or a herdr tab or tmux window when one is running, with the request typed in.</div><div class="q dim">Full: planned, built, then checked by a separate reviewer. Lite: one session does it all, faster and cheaper.</div><div id="run-pick" style="display:grid;gap:6px"><div id="run-agent" class="agents" role="radiogroup" aria-label="Agent"></div></div><div class="row"><label class="yolo" id="run-yolo" title="Yolo: the agent runs every command and edit without asking" hidden><button type="button" class="chk" role="switch" aria-checked="false"></button>Yolo</label><button class="btn ghost" data-a="c">Cancel</button><button class="btn" data-a="l">Lite</button><button class="btn pri" data-a="s">Full run</button></div>`;li.querySelector('[data-a=s]').onclick=()=>startRun(c,"full");li.querySelector('[data-a=l]').onclick=()=>startRun(c,"lite");runForm(li,c)}
468
536
  li.querySelector('[data-a=c]').onclick=()=>{mode=null;render()};
469
537
  // Edit and reject take dictation; the run prompt has no field.
470
538
  li.querySelectorAll("input,textarea").forEach((f,i)=>draft(f,"f:"+mode+":"+modeKey+":"+i));
@@ -472,16 +540,42 @@ function inlineForm(c){
472
540
  const inp=li.querySelector("input");if(inp){inp.onkeydown=e=>{if(e.key==="Enter")li.querySelector('[data-a=s]').click();if(e.key==="Escape"){mode=null;render()}};setTimeout(()=>inp.focus())}
473
541
  return li;}
474
542
 
543
+ // The agent and Basic/Yolo picked in the open Run form. Kept here, not in the form:
544
+ // the board is rebuilt on every server change, and the choice must outlive that.
545
+ // Yolo never outlives the form: render() drops it once the form is closed.
546
+ let runPick=null;
547
+ // The tool a launch opened, and why when it is not the one asked for.
548
+ const via=a=>a.name+(a.note?". "+a.note:"");
549
+ async function runForm(li,c){
550
+ const k=key(c),box=li.querySelector("#run-agent");
551
+ const d=await fetch("/agents?dir="+encodeURIComponent(c.state)).then(r=>r.json()).catch(()=>null);
552
+ if(!d||!d.agents||!d.agents.length){li.querySelector("#run-pick").hidden=true;return}
553
+ // Antigravity and Gemini CLI are both Google's agent on Gemini; offer one of them.
554
+ if(d.agents.some(a=>a.cmd==="agy"))d.agents=d.agents.filter(a=>a.cmd!=="gemini");
555
+ if(!runPick||runPick.k!==k)runPick={k,agent:d.agents.some(a=>a.cmd===d.default)?d.default:d.agents[0].cmd};
556
+ box.innerHTML=d.agents.map(a=>`<button type="button" class="agent" role="radio" aria-checked="${a.cmd===runPick.agent}" data-cmd="${esc(a.cmd)}"><svg><use href="#c-${esc(a.cmd)}"/></svg>${esc(a.name)}</button>`).join("");
557
+ const yb=li.querySelector("#run-yolo");
558
+ // Yolo only where the picked tool has a skip-permissions mode; Basic otherwise.
559
+ const sw=yb.querySelector(".chk");
560
+ const showYolo=()=>{const can=!!d.agents.find(a=>a.cmd===runPick.agent)?.yolo,on=!!runPick.yolo;yb.hidden=!can;sw.classList.toggle("on",on);sw.setAttribute("aria-checked",on)};
561
+ box.querySelectorAll(".agent").forEach(b=>b.onclick=()=>{runPick.agent=b.dataset.cmd;box.querySelectorAll(".agent").forEach(x=>x.setAttribute("aria-checked",x===b));showYolo()});
562
+ sw.onclick=()=>{runPick.yolo=!runPick.yolo;showYolo()};
563
+ showYolo();
564
+ box.onkeydown=sw.onkeydown=e=>{if(e.key==="Escape"){mode=null;render()}}}
565
+
475
566
  async function startRun(c,kind){
476
567
  mode=null;askNotify();
477
- const r=await post("/run",{dir:c.state,text:c.text,kind});
478
- if(r.ok)toast((kind==="full"?"Full":"Lite")+" run opened in your terminal: "+r.agent.name);
568
+ const p=runPick&&runPick.k===key(c)?runPick:null;runPick=null;
569
+ const yolo=!!(p&&p.yolo&&!$("run-yolo")?.hidden);
570
+ const r=await post("/run",{dir:c.state,text:c.text,kind,agent:p?p.agent:undefined,yolo});
571
+ if(r.ok)toast((kind==="full"?"Full":"Lite")+(yolo?" yolo":"")+" run opened in your terminal: "+via(r.agent));
479
572
  load()}
480
573
 
481
574
  function render(){
482
575
  // A closed form leaves no draft behind; an open one is reopened after a reload.
483
576
  // A form whose card is gone (restored after a reload, or deleted elsewhere) has nowhere to show.
484
577
  if(mode&&mode!=="new"&&!S.cards.some(c=>key(c)===modeKey))mode=null;
578
+ if(mode!=="run"&&runPick)runPick.yolo=false;
485
579
  if(!mode)undraft("f:");try{sessionStorage.setItem("kz-form",JSON.stringify(["new","edit","reject"].includes(mode)?[mode,modeKey]:null))}catch(e){}
486
580
  // The board is rebuilt below; the card that had focus gets it back.
487
581
  const fk=focusedKey();
@@ -492,7 +586,7 @@ function render(){
492
586
  if(n("waiting"))strip.push(`<button class="attn ${filter==="waiting"?"on":""}" data-f="waiting"><svg><use href="#s-wait"/></svg>${n("waiting")} waiting on you</button>`);
493
587
  strip.push(`<button class="${filter==="running"?"on":""}" data-f="running"><span class="d-run"><span class="pulse"></span></span>${n("running")} running</button>`);
494
588
  if(n("stalled"))strip.push(`<button class="${filter==="stalled"?"on":""}" data-f="stalled"><svg><use href="#s-stall"/></svg>${n("stalled")} stalled</button>`);
495
- strip.push(`<span class="sp"></span><span>${cs.filter(c=>c.kind==="idea").length} ideas · ${cs.filter(c=>c.kind==="run").length} runs</span>`,`<button class="${archive?"on":""}" data-f="__archive">${archive?"Back to board":"Archive "+nArch}</button>`);
589
+ strip.push(`<span>${cs.filter(c=>c.kind==="idea").length} ideas · ${cs.filter(c=>c.kind==="run").length} runs</span>`,`<button class="${archive?"on":""}" data-f="__archive">${archive?"Back to board":"Archive "+nArch}</button>`);
496
590
  $("strip").innerHTML=strip.join("");
497
591
  $("strip").querySelectorAll("button").forEach(c=>c.onclick=()=>{if(c.dataset.f==="__archive"){archive=!archive;filter=null;render();return}filter=filter===c.dataset.f?null:c.dataset.f;render()});
498
592
  // [label, folder, from the projects folder]: the button names the current one, the open list is redrawn.
@@ -657,7 +751,7 @@ const APPROVAL={"approvals.plan":"The plan is ready. Read it in the Plan tab, th
657
751
  async function decide(c,b,why,answers){b.disabled=true;askNotify();const r=await post("/decide",{dir:c.state,id:c.id,awaiting:c.awaiting,...(why===undefined?{}:{why}),...(answers?{answers}:{})});
658
752
  // A failed send keeps the footer, and with it a typed comment.
659
753
  if(!r.ok){b.disabled=false;return}
660
- decided.add(answer(c));toast((why===undefined?"Approved":"Sent back")+" in your terminal: "+r.agent.name);
754
+ decided.add(answer(c));toast((why===undefined?"Approved":"Sent back")+" in your terminal: "+via(r.agent));
661
755
  if(openKey===key(c)){$("pfoot").innerHTML="";openRun(c,true)}}
662
756
  function confirmRevise(c){const foot=$("pfoot"),plan=c.awaiting==="approvals.plan";
663
757
  foot.innerHTML=`<div class="inl" style="flex:1"><div class="q">${plan?"What should the plan do differently?":"What should change in the work?"}</div><div class="q dim">Opens your agent on this run with your comment.${plan?" It revises the plan and comes back here for approval.":" It changes the work, rechecks it and comes back here."}</div><textarea aria-label="Revise comment"></textarea><div class="row"><button class="btn ghost" data-a="c">Cancel</button><button class="btn pri" data-a="s">Send back</button></div></div>`;
@@ -706,7 +800,7 @@ function fixForm(c){const k=n=>c.id+":"+n,sent=f=>fixSent.has(k(f.n)),fs=runDoc.
706
800
  allb.onclick=()=>{const none=picks().length===open.length;open.forEach(f=>none?fixOff.add(k(f.n)):fixOff.delete(k(f.n)));sync()};
707
801
  go.onclick=async()=>{const nums=picks();go.disabled=true;askNotify();const r=await post("/fix",{dir:c.state,id:c.id,nums});
708
802
  // Sent findings lock as sent, so a second press cannot open a second agent on them.
709
- if(r.ok){toast("Fixing "+nums.length+" finding"+(nums.length===1?"":"s")+" in your terminal: "+r.agent.name);nums.forEach(n=>fixSent.add(k(n)));try{sessionStorage.setItem(SK,JSON.stringify([...fixSent]))}catch(e){}openRun(c,true)}else sync()};
803
+ if(r.ok){toast("Fixing "+nums.length+" finding"+(nums.length===1?"":"s")+" in your terminal: "+via(r.agent));nums.forEach(n=>fixSent.add(k(n)));try{sessionStorage.setItem(SK,JSON.stringify([...fixSent]))}catch(e){}openRun(c,true)}else sync()};
710
804
  sync();$("pbody").appendChild(el)}
711
805
  // Commit and push, for a finished run in a project with a git remote. What the board
712
806
  // did is recorded per run, so the settled label outlives later changes to the tree.
@@ -787,7 +881,7 @@ async function openSettings(){
787
881
  const ntHelp="A desktop notification when a run starts waiting on you."+(np?np==="denied"?" Blocked in this browser's site settings.":"":" Not supported in this browser.");
788
882
  const names=[...(cfg?groups(cfg.rows).map(([g])=>g):[]),"Browser"];
789
883
  $("pbody").innerHTML=`<div class="sq"><input class="sel" id="sq" type="search" placeholder="Search settings" aria-label="Search settings" spellcheck="false" autocomplete="off" value="${esc(setQ)}"><div class="sjump" role="group" aria-label="Jump to section">${names.map(g=>`<button data-g="${esc(g)}">${esc(g)}</button>`).join("")}</div></div>
790
- ${cfg?groups(cfg.rows).map(([g,rows])=>`<h3 class="sg">${esc(g)}</h3>${rows.map(r=>{const id="s-"+r.key.replace(/\./g,"-");return row(r.label,r.text?`<input class="sel" id="${id}" value="${esc(r.value)}" placeholder="not set" spellcheck="false">`:ctl(id,r.values,r.value),esc(r.help),id,[r.label,r.help,r.key,g,...r.values].join(" "))}).join("")}`).join(""):`<p class="snone">Settings could not be read. Run <code>kaizen settings</code>.</p>`}
884
+ ${cfg?groups(cfg.rows).map(([g,rows])=>`<h3 class="sg">${esc(g)}</h3>${rows.map(r=>{const id="s-"+r.key.replace(/\./g,"-");return row(r.label,r.text?(r.folder?`<span class="fpick"><input class="sel" id="${id}" value="${esc(r.value)}" placeholder="not set" spellcheck="false"><button class="btn" id="${id}-pick" type="button">Browse…</button></span>`:`<input class="sel" id="${id}" value="${esc(r.value)}" placeholder="not set" spellcheck="false">`):ctl(id,r.values,r.value),esc(r.help),id,[r.label,r.help,r.key,g,...r.values].join(" "))}).join("")}`).join(""):`<p class="snone">Settings could not be read. Run <code>kaizen settings</code>.</p>`}
791
885
  <h3 class="sg">Browser</h3>${row("Theme",ctl("th",["system","light","dark"],cur),"Light or dark for this browser only; system follows your device.","th","theme light dark system browser")}
792
886
  ${row("Notifications",`<button class="chk${np==="granted"?" on":""}" id="nt" role="switch" aria-checked="${np==="granted"}"${np&&np!=="denied"?"":" disabled"}></button>`,ntHelp,"nt","notifications "+ntHelp+" browser")}
793
887
  <p class="snone" id="snone" hidden></p>`;
@@ -806,7 +900,9 @@ async function openSettings(){
806
900
  $("th").onchange=e=>setTheme(e.target.value);
807
901
  $("nt").onclick=()=>{askNotify();setTimeout(openSettings,300)};
808
902
  if(cfg)cfg.rows.forEach(r=>{const el=$("s-"+r.key.replace(/\./g,"-"));const save=async v=>{const f=document.activeElement?.id;const j=await post("/settings",{key:r.key,value:v});if(j.ok)toast("Saved");await openSettings();if(f?.startsWith("s-preset-"))$(f)?.focus()};
809
- if(el.tagName==="SELECT"||el.tagName==="INPUT")el.onchange=async()=>{await save(el.value.trim());if(el.tagName==="INPUT")load()};else el.querySelectorAll("[data-v]").forEach(b=>b.onclick=()=>save(b.dataset.v));if(el.matches(".chk"))el.onclick=()=>save(el.dataset.v)})}
903
+ if(el.tagName==="SELECT"||el.tagName==="INPUT")el.onchange=async()=>{await save(el.value.trim());if(el.tagName==="INPUT")load()};else el.querySelectorAll("[data-v]").forEach(b=>b.onclick=()=>save(b.dataset.v));if(el.matches(".chk"))el.onclick=()=>save(el.dataset.v);
904
+ // The system's own folder dialog, opened by the board: a page cannot read a real path.
905
+ if(r.folder)$(el.id+"-pick").onclick=async()=>{const j=await post("/pick-folder",{});if(j.path){await save(j.path);load()}}})}
810
906
  // ---- request sources: links kaizen reads for new ideas. Same panel as settings.
811
907
  const SRC="Request sources";
812
908
  const srcOpen=()=>$("panel").classList.contains("open")&&$("ptitle").textContent===SRC;
@@ -823,33 +919,50 @@ async function openSources(){
823
919
  const d=await fetch("/sources"+q).then(r=>r.json()).catch(()=>null);
824
920
  if(!srcOpen())return;
825
921
  const host=u=>{try{return new URL(u).hostname}catch{return u}};
922
+ const srcType=u=>{if(u.includes("docs.google.com/spreadsheets")||u.endsWith(".csv")||u.endsWith(".tsv"))return "sheet";if(u.includes("docs.google.com/document"))return "doc";return "link"};
826
923
  const list=d&&d.sources||[];
827
924
  // A source belongs to one project, named on its row. Picking another only arms Move: a select
828
925
  // changes on every arrow key, and each change would have moved the source. The names are the picker's;
829
926
  // a row whose project the picker does not list still gets its own, selected, so Move never points elsewhere.
830
927
  const opts=(on,name)=>(on&&!S.projects.some(p=>p.dir===on)?`<option value="${esc(on)}" selected>${esc(name||on)}</option>`:"")+S.projects.map(p=>`<option value="${esc(p.dir)}"${p.dir===on?" selected":""}>${esc(p.label)}</option>`).join("");
831
- const rows=list.map((s,i)=>`<div class="srow"><label>${esc(s.label||host(s.url))}</label><button class="btn ghost" id="src-rm-${i}" data-rm="${i}" aria-label="Remove ${esc(s.label||host(s.url))}">Remove</button><p class="hint" style="overflow-wrap:anywhere">${s.url.startsWith("https://")?`<a href="${esc(s.url)}" target="_blank" rel="noopener noreferrer" style="color:inherit">${esc(s.url)}</a>`:esc(s.url)}<br>${s.last?(s.last.ok?esc(s.last.note):`<span style="color:var(--ink);font-weight:500">Failed: ${esc(s.last.note)}</span>`)+" · "+ago(s.last.at):"Not gathered yet"}</p><p class="hint" style="margin-top:6px">Ideas go to <select class="sel" id="src-mv-${i}" data-mv="${i}" data-k="${esc(s.dir+" "+s.url)}" aria-label="Project of ${esc(s.label||host(s.url))}">${opts(s.dir,s.project)}</select> <button class="btn" id="src-mb-${i}" aria-label="Move ${esc(s.label||host(s.url))} to the chosen project" disabled>Move</button></p></div>`).join("")+(d&&d.failed||[]).map(f=>`<p style="color:var(--dim)">The sources of ${esc(f)} could not be read.</p>`).join("");
832
- $("pbody").innerHTML=(d&&d.error?`<p style="color:var(--dim)">${esc(d.error)}</p>`:!d?`<p style="color:var(--dim)">Sources could not be read.</p>`:rows||`<p style="color:var(--dim)">No sources yet. Paste a link to a Google Sheet, a Doc, or a shared note.</p>`)
833
- +`<h3 class="sg">Add a source</h3><div style="display:grid;gap:8px;margin-top:10px"><input class="sel" id="src-url" aria-label="Link" placeholder="https://docs.google.com/…" spellcheck="false" autocomplete="off" style="width:100%;max-width:none"><input class="sel" id="src-label" aria-label="Label (optional)" placeholder="Label (optional)" style="width:100%;max-width:none"><select class="sel" id="src-dir" aria-label="Project this source belongs to" style="width:100%;max-width:none">${dir?"":`<option value="">Choose the project it belongs to…</option>`}${opts(dir)}</select><div style="display:flex;gap:8px"><button class="btn pri" id="src-add">Add source</button><button class="btn" id="src-go"${list.length?"":" disabled"}>Gather now</button></div><p class="hint" style="font-size:12px;color:var(--dim);margin:0">${d&&d.every&&d.every!=="off"?`Checked every ${esc(d.every)} while the board is running.`:"Automatic checks are off."} Change it in Settings.</p></div>`;
834
- $("src-url").value=typed[0];$("src-label").value=typed[1];if(typed[3]!==undefined)$("src-dir").value=typed[3];
928
+ const topBar=`<div class="src-topbar"><div class="meta"><span class="count">${list.length} source${list.length===1?"":"s"}${dir?` in ${esc(S.projects.find(p=>p.dir===dir)?.label||dir)}`:""}</span><span class="sched">${d&&d.every&&d.every!=="off"?`Auto-check: every ${esc(d.every)}`:"Auto-check: off"} · <a id="src-to-settings" role="button" tabindex="0">Settings</a></span></div><button class="btn sm" id="src-go"${list.length?"":" disabled"}>${ico("refresh")} Gather all</button></div>`;
929
+ const cards=list.map((s,i)=>{
930
+ const st=srcType(s.url),title=s.label||host(s.url);
931
+ const statusHtml=!s.last?`<span class="src-badge dim">Not gathered yet</span>`:s.last.ok?`<span class="src-badge ok"><svg viewBox="0 0 16 16"><path d="M3.5 8.5l3 3 6-6" stroke="currentColor" fill="none" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/></svg>${esc(s.last.note)}</span><span class="hint" style="margin:0">${ago(s.last.at)}</span>`:`<span class="src-badge warn"><svg viewBox="0 0 16 16"><circle cx="8" cy="8" r="6" stroke="currentColor" fill="none" stroke-width="1.8"/><path d="M8 5v3.5M8 11v.1" stroke="currentColor" fill="none" stroke-width="2" stroke-linecap="round"/></svg>Failed: ${esc(s.last.note)}</span><span class="hint" style="margin:0">${ago(s.last.at)}</span>`;
932
+ return `<div class="src-card" id="src-card-${i}"><div class="src-hd"><div class="src-info"><span class="src-ico" title="${st}">${ico(st==="sheet"?"sheet":st==="doc"?"doc":"link")}</span><span class="src-title" title="${esc(title)}">${esc(title)}</span></div><div class="src-acts"><button class="btn ghost sm" id="src-chk-${i}" data-chk="${i}" aria-label="Check ${esc(title)} now" title="Check this source now">${ico("refresh")} Check</button><button class="btn ghost danger sm" id="src-rm-${i}" data-rm="${i}" aria-label="Remove ${esc(title)}" title="Remove source">${ico("trash")}</button></div></div><div class="src-url"><a href="${esc(s.url)}" target="_blank" rel="noopener noreferrer" title="${esc(s.url)}"><span>${esc(s.url)}</span>${ico("ext")}</a></div><div class="src-status">${statusHtml}</div><div class="src-dest"><span>Ideas go to</span><select class="sel" id="src-mv-${i}" data-mv="${i}" data-k="${esc(s.dir+" "+s.url)}" aria-label="Project of ${esc(title)}">${opts(s.dir,s.project)}</select><button class="btn sm" id="src-mb-${i}" aria-label="Move ${esc(title)} to the chosen project" disabled>Move</button></div></div>`;
933
+ }).join("")+(d&&d.failed||[]).map(f=>`<p style="color:var(--dim)">The sources of ${esc(f)} could not be read.</p>`).join("");
934
+ const emptyState=`<div class="src-empty"><div class="src-empty-ico">${ico("link")}</div><h4>No request sources yet</h4><p>Connect shared links to gather requests into your Ideas backlog. Nothing runs automatically until you start it.</p><div class="src-empty-grid"><div class="src-empty-item"><strong>📊 Google Sheets</strong>Public sheets with idea or task rows. Rows marked Done are skipped.</div><div class="src-empty-item"><strong>📄 Google Docs</strong>Shared public docs with items or notes written line by line.</div><div class="src-empty-item"><strong>🌐 Public links</strong>TSV, CSV, or raw text links containing requests.</div></div></div>`;
935
+ const addBox=`<div class="src-add-box"><h3>${ico("plus")} Add a source</h3><div style="display:grid;gap:8px"><div><label for="src-url" class="hint" style="display:block;margin-bottom:4px;font-weight:500;color:var(--ink)">Link</label><input class="sel" id="src-url" aria-label="Link" placeholder="https://docs.google.com/spreadsheets/d/… or public URL" spellcheck="false" autocomplete="off" style="width:100%;max-width:none"></div><div style="display:grid;grid-template-columns:1fr 1fr;gap:8px"><div><label for="src-label" class="hint" style="display:block;margin-bottom:4px">Label (optional)</label><input class="sel" id="src-label" aria-label="Label (optional)" placeholder="e.g. Feedback Q4" style="width:100%;max-width:none"></div><div><label for="src-dir" class="hint" style="display:block;margin-bottom:4px">Target project</label><select class="sel" id="src-dir" aria-label="Project this source belongs to" style="width:100%;max-width:none">${dir?"":`<option value="">Choose project…</option>`}${opts(dir)}</select></div></div><div style="display:flex;align-items:center;justify-content:space-between;margin-top:6px"><button class="btn pri" id="src-add">Add source</button><span class="hint" style="font-size:11px;color:var(--dim);margin:0">Public links only. For private docs, run /kaizen gather.</span></div></div></div>`;
936
+ $("pbody").innerHTML=(d&&d.error?`<p style="color:var(--dim)">${esc(d.error)}</p>`:!d?`<p style="color:var(--dim)">Sources could not be read.</p>`:topBar+(list.length?`<div class="src-list">${cards}</div>`:emptyState))+addBox;
937
+ $("src-url").value=typed[0];$("src-label").value=typed[1];if(typed[3]!==undefined&&$("src-dir"))$("src-dir").value=typed[3];
938
+ if($("src-to-settings"))$("src-to-settings").onclick=()=>{setQ="sources";openSettings()};
939
+ const act=async(body,done)=>{const j=await post("/sources",body);if(j.ok){done(j);load()}if(srcOpen())openSources()};
835
940
  $("pbody").querySelectorAll("[data-mv]").forEach(m=>{const s=list[+m.dataset.mv],b=$("src-mb-"+m.dataset.mv),to=picked.get(m.dataset.k);
836
941
  if(to&&[...m.options].some(o=>o.value===to))m.value=to;
837
942
  (m.onchange=()=>{b.disabled=m.value===s.dir})();
838
943
  b.onclick=()=>act({op:"move",dir:s.dir,to:m.value,url:s.url},()=>toast(`Source moved. Ideas already gathered stay in ${s.project||"the old project"}.`))});
839
- // A Move button that went back to disabled cannot take focus; its select can.
840
- const was=typed[2]&&$(typed[2]);if(was)(was.disabled?$(was.id.replace("src-mb-","src-mv-")):was).focus();
841
- // Every action names the project it acts on; only "gather everything" goes without one.
842
- const act=async(body,done)=>{const j=await post("/sources",body);if(j.ok){done(j);load()}if(srcOpen())openSources()};
843
- $("src-add").onclick=()=>{const url=$("src-url").value.trim();if(!url){$("src-url").focus();return}const to=$("src-dir").value;if(!to){$("src-dir").focus();return}// A new source is read at once, so the user learns now whether the link works.
944
+ $("pbody").querySelectorAll("[data-chk]").forEach(b=>{const s=list[+b.dataset.chk];
945
+ b.onclick=()=>{b.disabled=true;b.innerHTML=ico("refresh")+" Checking…";
946
+ act({op:"gather",dir:s.dir,url:s.url},j=>{const r=j.results&&j.results[0];
947
+ toast(r?(r.ok?`${s.label||host(s.url)}: ${r.note}`:`Failed: ${r.note}`):"Checked")})}});
948
+ $("pbody").querySelectorAll("[data-rm]").forEach(b=>{const s=list[+b.dataset.rm];
949
+ b.onclick=()=>{if(!confirm(`Remove ${s.label||host(s.url)}? Ideas it gathered stay; added again later, it may bring back ones you deleted.`))return;
950
+ act({op:"remove",dir:list[+b.dataset.rm].dir,url:list[+b.dataset.rm].url},()=>toast("Source removed"))}});
951
+ const was=typed[2]&&$(typed[2]);if(was)(was.disabled?$(was.id.replace("src-mb-","src-mv-")):was)?.focus();
952
+ $("src-add").onclick=()=>{const url=$("src-url").value.trim();if(!url){$("src-url").focus();return}const to=$("src-dir").value;if(!to){$("src-dir").focus();return}
844
953
  $("src-add").disabled=true;$("src-add").textContent="Adding…";
845
954
  (async()=>{const j=await post("/sources",{op:"add",dir:to,url,label:$("src-label").value.trim()});
846
955
  if(j.ok){$("src-url").value=$("src-label").value="";const g=await post("/sources",{op:"gather",dir:to,url}),r=g.results&&g.results[0];
847
956
  toast(r?(r.ok?`Source added: ${r.note}`:`Source added, but: ${r.note}`):"Source added");load()}
957
+ else toast(j.error||"Could not add source");
848
958
  if(srcOpen())openSources()})()};
849
959
  $("src-url").onkeydown=$("src-label").onkeydown=e=>{if(e.key==="Enter")$("src-add").click()};
850
- $("src-go").onclick=()=>{$("src-go").disabled=true;$("src-go").textContent="Gathering…";act({op:"gather",dir:dir||undefined},j=>{const n=(j.results||[]).reduce((a,r)=>a+r.added,0),bad=(j.results||[]).filter(r=>!r.ok).length;toast((n?`${n} new idea${n===1?"":"s"}`:"Nothing new")+(bad?`, ${bad} source${bad===1?"":"s"} could not be read`:""))})};
851
- $("pbody").querySelectorAll("[data-rm]").forEach(b=>b.onclick=()=>{const s=list[+b.dataset.rm];if(!confirm(`Remove ${s.label||host(s.url)}? Ideas it gathered stay; added again later, it may bring back ones you deleted.`))return;act({op:"remove",dir:list[+b.dataset.rm].dir,url:list[+b.dataset.rm].url},()=>toast("Source removed"))})}
852
- let tt;function toast(s){const t=$("toast");t.textContent=s;t.classList.add("on");clearTimeout(tt);tt=setTimeout(()=>t.classList.remove("on"),4000)}
960
+ if($("src-go"))$("src-go").onclick=()=>{$("src-go").disabled=true;$("src-go").innerHTML=ico("refresh")+" Gathering…";
961
+ act({op:"gather",dir:dir||undefined},j=>{const n=(j.results||[]).reduce((a,r)=>a+r.added,0),bad=(j.results||[]).filter(r=>!r.ok).length;
962
+ toast((n?`${n} new idea${n===1?"":"s"}`:"Nothing new")+(bad?`, ${bad} source${bad===1?"":"s"} could not be read`:""))})}}
963
+ // With `report`, the toast carries a link to a prefilled GitHub bug form (/issue) and
964
+ // stays long enough to click it.
965
+ let tt;function toast(s,report){const t=$("toast");t.textContent=s;t.classList.toggle("rep",!!report);if(report){const a=document.createElement("a");a.href="/issue?"+new URLSearchParams(report);a.target="_blank";a.rel="noopener";a.textContent="Report";t.append(" ",a)}t.classList.add("on");clearTimeout(tt);tt=setTimeout(()=>t.classList.remove("on"),report?8000:4000)}
853
966
  function askNotify(){try{if(typeof Notification!=="undefined"&&Notification.permission==="default")Notification.requestPermission()}catch(e){}}
854
967
  function notifyUser(body){try{if(typeof Notification!=="undefined"&&Notification.permission==="granted")new Notification("kaizen",{body,icon:"/logo.png"})}catch(e){}}
855
968
  // The project picker: a button shaped like a select that opens a search field over the list.