kaizen-agent 1.10.0 → 1.11.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +65 -572
- 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 +15 -1
- package/skills/kaizen/spec-build.md +5 -0
- package/skills/kaizen/spec-main.md +5 -2
- package/skills/kaizen/spec.md +16 -2
- package/web/board.html +162 -47
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
|
+
}
|