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 +13 -0
- package/cli/board.test.ts +67 -0
- package/cli/dashboard.ts +1 -0
- package/cli/install.ts +14 -0
- package/cli/settings.ts +2 -2
- package/cli/state.test.ts +112 -1
- package/cli/state.ts +158 -41
- package/cli/web.ts +65 -3
- package/package.json +1 -1
- package/skills/kaizen/SKILL.md +4 -1
- package/web/board.html +155 -42
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
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
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)) &&
|
|
46
|
+
if (existsSync(join(projectDir, a.dir)) && onPath(a.cmd)) return a;
|
|
38
47
|
}
|
|
39
|
-
if (
|
|
48
|
+
if (onPath("claude")) return { name: "Claude Code", cmd: "claude" };
|
|
40
49
|
for (const a of KNOWN_AGENTS) {
|
|
41
|
-
if (
|
|
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
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
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
|
|
66
|
-
|
|
67
|
-
const
|
|
68
|
-
const
|
|
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
|
-
...(
|
|
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:
|
|
878
|
-
| { ok: false; agent:
|
|
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
|
-
|
|
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
|
|
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
|
|
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
package/skills/kaizen/SKILL.md
CHANGED
|
@@ -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.
|
|
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;
|
|
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:
|
|
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 .
|
|
149
|
-
|
|
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
|
-
|
|
228
|
-
|
|
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){
|
|
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){
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
else
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
|
|
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
|
|
478
|
-
|
|
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
|
|
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
|
|
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
|
|
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"
|
|
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
|
|
832
|
-
|
|
833
|
-
|
|
834
|
-
|
|
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
|
-
|
|
840
|
-
|
|
841
|
-
|
|
842
|
-
|
|
843
|
-
$("
|
|
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").
|
|
851
|
-
|
|
852
|
-
|
|
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.
|