kaizen-agent 1.9.4 → 1.11.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +24 -6
- package/agents/kaizen-builder.md +4 -1
- package/cli/board.test.ts +115 -0
- package/cli/dashboard.ts +1 -0
- package/cli/install.test.ts +85 -0
- package/cli/install.ts +60 -10
- package/cli/notes.test.ts +1 -1
- package/cli/settings.test.ts +50 -0
- package/cli/settings.ts +91 -36
- package/cli/sources.test.ts +44 -5
- package/cli/sources.ts +11 -6
- package/cli/state.test.ts +326 -3
- package/cli/state.ts +361 -47
- package/cli/web.ts +134 -16
- package/package.json +1 -1
- package/skills/kaizen/SKILL.md +5 -1
- package/skills/kaizen/config.default.yml +10 -4
- package/skills/kaizen/spec-build.md +17 -0
- package/skills/kaizen/spec-plan.md +6 -1
- package/web/board.html +363 -85
- package/web/notebook.js +2 -2
- package/web/notes-lib.ts +1 -1
- package/web/notes.html +1 -0
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, `'\\''`)}'`;
|
|
@@ -80,7 +120,9 @@ const shq = (a: string) => `'${a.replace(/'/g, `'\\''`)}'`;
|
|
|
80
120
|
// Windows PowerShell 5.1 writes `Set-Content -Encoding utf8` with a byte order mark,
|
|
81
121
|
// and agents on Windows write run files that way. JSON.parse refuses a leading BOM
|
|
82
122
|
// (the run vanished from the board) and it hides a first line from every ^ regex.
|
|
83
|
-
|
|
123
|
+
// PowerShell and Git for Windows clones (autocrlf) also write CRLF, which breaks
|
|
124
|
+
// every `key:\n` regex the same silent way, so line endings are made LF here too.
|
|
125
|
+
export const readText = (file: string) => readFileSync(file, "utf8").replace(/^\uFEFF/, "").replace(/\r\n/g, "\n");
|
|
84
126
|
|
|
85
127
|
const onPath = (bin: string) => Bun.which(bin, { PATH: process.env.PATH });
|
|
86
128
|
|
|
@@ -104,7 +146,8 @@ function answers(cmd: string[]): boolean {
|
|
|
104
146
|
}
|
|
105
147
|
}
|
|
106
148
|
|
|
107
|
-
|
|
149
|
+
// pidFile: where the launched agent writes its own PID, so closeSessions can end it.
|
|
150
|
+
export function findTerminal(cwd: string, fullCmd: string[], pidFile?: string): { cmd: string[]; detached: boolean } | null {
|
|
108
151
|
const hasDisplay = Boolean(process.env.WAYLAND_DISPLAY || process.env.DISPLAY);
|
|
109
152
|
|
|
110
153
|
// Windows has no xdg anything, and none of the terminals below exist there.
|
|
@@ -116,15 +159,17 @@ export function findTerminal(cwd: string, fullCmd: string[]): { cmd: string[]; d
|
|
|
116
159
|
if (process.platform === "win32") {
|
|
117
160
|
const q = (a: string) => `'${a.replace(/'/g, "''")}'`;
|
|
118
161
|
const shell = powershellExe();
|
|
119
|
-
|
|
162
|
+
// This PowerShell's PID: ending its tree closes the -NoExit tab or window.
|
|
163
|
+
const ps = `${pidFile ? `$PID | Set-Content -LiteralPath ${q(pidFile)}; ` : ""}Set-Location ${q(cwd)}; & ${fullCmd.map(q).join(" ")}`;
|
|
120
164
|
const enc = Buffer.from(ps, "utf16le").toString("base64");
|
|
121
165
|
// herdr (beta on Windows) first, as elsewhere: a new tab, and the run typed into
|
|
122
166
|
// its PowerShell. Typed text ends at a newline, so what is typed is one line, the
|
|
123
167
|
// same encoded command; -EncodedCommand is also not a script file, so an
|
|
124
|
-
// execution policy that blocks .ps1 files does not stop it.
|
|
168
|
+
// execution policy that blocks .ps1 files does not stop it. The trailing exit
|
|
169
|
+
// closes the tab once the run's PowerShell ends.
|
|
125
170
|
const herdr = onPath("herdr");
|
|
126
171
|
if (herdr && answers([herdr, "workspace", "list"])) {
|
|
127
|
-
const typed = `& ${q(shell)} -NoProfile -EncodedCommand ${enc}`;
|
|
172
|
+
const typed = `& ${q(shell)} -NoProfile -EncodedCommand ${enc}; exit`;
|
|
128
173
|
const launch = `$o = & ${q(herdr)} tab create --cwd ${q(cwd)} --label kaizen --focus | Out-String
|
|
129
174
|
if ($o -match '"pane_id":"([^"]*)"') { & ${q(herdr)} pane run $Matches[1] ${q(typed)} } else { exit 1 }`;
|
|
130
175
|
return { cmd: [shell, "-NoProfile", "-EncodedCommand", Buffer.from(launch, "utf16le").toString("base64")], detached: false };
|
|
@@ -146,11 +191,18 @@ if ($o -match '"pane_id":"([^"]*)"') { & ${q(herdr)} pane run $Matches[1] ${q(ty
|
|
|
146
191
|
// in, so $TMUX or $HERDR_ENV only says what was running then. herdr has no "new tab
|
|
147
192
|
// running this", and what it sends a pane is typed into an interactive shell, which
|
|
148
193
|
// eats tabs and quotes its own way -- so the line goes into a one-shot script and
|
|
149
|
-
// only `sh <path>` is typed.
|
|
194
|
+
// only `exec sh <path>` is typed. The exec is what closes the tab when the agent
|
|
195
|
+
// ends: left behind, the pane's own shell would sit at a prompt.
|
|
150
196
|
// ponytail: the mktemp path is typed unquoted; quote it if a TMPDIR with spaces shows up.
|
|
197
|
+
// The wrapper writes its PID and then becomes the agent, so the PID is the agent's
|
|
198
|
+
// on every branch below, whatever terminal or launcher sits in front of it.
|
|
199
|
+
if (pidFile) fullCmd = ["sh", "-c", 'echo $$ > "$0"; exec "$@"', pidFile, ...fullCmd];
|
|
151
200
|
const line = fullCmd.map(shq).join(" ");
|
|
201
|
+
// The one-shot scripts below end the tab when the agent ends, but a launch that
|
|
202
|
+
// never started (126/127: not executable, not found) holds it open on its error.
|
|
203
|
+
const hold = 's=$?; [ $s = 126 ] || [ $s = 127 ] || exit $s; printf "kaizen: the agent did not start. Press Enter to close. "; read _';
|
|
152
204
|
if (answers(["herdr", "workspace", "list"])) {
|
|
153
|
-
const script = `f=$(mktemp) && printf 'rm -f "$0"\\
|
|
205
|
+
const script = `f=$(mktemp) && printf 'rm -f "$0"\\n%s\\n${hold}\\n' "$2" > "$f" && p=$(herdr tab create --cwd "$1" --label kaizen --focus | sed -n 's/.*"pane_id":"\\([^"]*\\)".*/\\1/p') && [ -n "$p" ] && herdr pane run "$p" "exec sh $f"`;
|
|
154
206
|
return { cmd: ["sh", "-c", script, "sh", cwd, line], detached: false };
|
|
155
207
|
}
|
|
156
208
|
if (answers(["tmux", "has-session"])) {
|
|
@@ -186,8 +238,17 @@ if ($o -match '"pane_id":"([^"]*)"') { & ${q(herdr)} pane run $Matches[1] ${q(ty
|
|
|
186
238
|
|
|
187
239
|
if (process.platform === "darwin") {
|
|
188
240
|
// Terminal types what it is given into the login shell, so, as with herdr, the
|
|
189
|
-
// line goes into a one-shot script and only `sh <path>` is typed.
|
|
190
|
-
|
|
241
|
+
// line goes into a one-shot script and only `exec sh <path>` is typed. Whether the
|
|
242
|
+
// window closes when that ends is a profile setting kaizen cannot reach, so the
|
|
243
|
+
// launcher waits for the tab to run something, then to run nothing, and closes it:
|
|
244
|
+
// a tab with nothing running closes without Terminal asking first.
|
|
245
|
+
// ponytail: untested on a real Mac; the try leaves the window as it was if any step fails.
|
|
246
|
+
const close = ["tell application \"Terminal\"", "set t to do script \"exec sh $f\"", "activate", "try",
|
|
247
|
+
"repeat 150 times", "if busy of t then exit repeat", "delay 0.2", "end repeat",
|
|
248
|
+
"repeat while busy of t", "delay 1", "end repeat",
|
|
249
|
+
"repeat with w in windows", "if tabs of w contains t then close w", "end repeat",
|
|
250
|
+
"end try", "end tell"].map((l) => `-e ${JSON.stringify(l)}`).join(" ");
|
|
251
|
+
const script = `f=$(mktemp) && printf 'rm -f "$0"\\n{ cd %s || (exit 127); } && %s\\n${hold}\\n' "$1" "$2" > "$f" && osascript ${close}`;
|
|
191
252
|
return { cmd: ["/bin/sh", "-c", script, "sh", shq(cwd), line], detached: true };
|
|
192
253
|
}
|
|
193
254
|
|
|
@@ -236,6 +297,7 @@ export function nativePath(p: string) {
|
|
|
236
297
|
// dashboard to stop opening.
|
|
237
298
|
export function remember(projectDir: string) {
|
|
238
299
|
try {
|
|
300
|
+
projectDir = nativePath(projectDir);
|
|
239
301
|
if (knownProjects().includes(projectDir)) return;
|
|
240
302
|
mkdirSync(dirname(REGISTRY), { recursive: true });
|
|
241
303
|
appendFileSync(REGISTRY, projectDir + "\n");
|
|
@@ -275,13 +337,15 @@ export function findProjects(root: string, depth = 4): string[] {
|
|
|
275
337
|
return found;
|
|
276
338
|
}
|
|
277
339
|
|
|
340
|
+
// The same spelling knownProjects() gives: a terminal handing out c:\... (lower-case
|
|
341
|
+
// drive) would otherwise make every POST from the board fail "unknown state dir".
|
|
278
342
|
export function locate() {
|
|
279
|
-
let dir = process.cwd();
|
|
343
|
+
let dir = nativePath(process.cwd());
|
|
280
344
|
for (;;) {
|
|
281
345
|
if (existsSync(join(dir, ".kaizen"))) return join(dir, ".kaizen");
|
|
282
346
|
if (existsSync(join(dir, ".git"))) return null;
|
|
283
347
|
const up = dirname(dir);
|
|
284
|
-
if (up === dir) return existsSync(join(home, ".kaizen")) ? join(home, ".kaizen") : null;
|
|
348
|
+
if (up === dir) return existsSync(join(home, ".kaizen")) ? nativePath(join(home, ".kaizen")) : null;
|
|
285
349
|
dir = up;
|
|
286
350
|
}
|
|
287
351
|
}
|
|
@@ -419,6 +483,23 @@ export function fixPrompt(id: string, nums: number[]) {
|
|
|
419
483
|
+ `Leave every other finding alone, and mark the fixed ones done in its 06-backlog.md.`;
|
|
420
484
|
}
|
|
421
485
|
|
|
486
|
+
// The board's answer to an approval the run is waiting on. The comment is collapsed to
|
|
487
|
+
// one line: where the prompt is typed into a shell (herdr), a newline sends it early.
|
|
488
|
+
// Answers are the plan's open questions answered at the approval, one per question.
|
|
489
|
+
export function approvalPrompt(id: string, awaiting: string, why?: string, answers?: string[]) {
|
|
490
|
+
const plan = awaiting === "approvals.plan";
|
|
491
|
+
const one = (t: string) => t.replace(/\s+/g, " ").trim();
|
|
492
|
+
const note = why === undefined ? undefined : one(why);
|
|
493
|
+
if (plan && !note && answers?.length) return `run ${id}: the plan is approved from the board, with answers to its open questions: `
|
|
494
|
+
+ answers.map((a, i) => `${i + 1}) ${one(a).replace(/[.;]$/, "")}`).join("; ") + `. Record them in 02-approval.md and build the plan with them.`;
|
|
495
|
+
if (!note) return plan
|
|
496
|
+
? `run ${id}: the plan is approved from the board. Record it in 02-approval.md and build it, first asking any of its open questions still unanswered.`
|
|
497
|
+
: `run ${id}: the work is approved from the board at the final approval. Record it, write the backlog and finish the run.`;
|
|
498
|
+
return plan
|
|
499
|
+
? `run ${id}: the plan is sent back from the board to revise: "${note}". Record it in 02-approval.md, revise 01-plan.md with it, and stop at the plan approval again.`
|
|
500
|
+
: `run ${id}: the work is sent back from the board at the final approval: "${note}". Record it in 02-approval.md, change the work to address it, recheck, and stop at the final approval again.`;
|
|
501
|
+
}
|
|
502
|
+
|
|
422
503
|
// Backlog items are often a reviewer's finding pasted verbatim -- a backticked
|
|
423
504
|
// path, a severity, then the sentence. Split those apart so a list can show the
|
|
424
505
|
// severity and the place as columns and leave the prose to speak for itself.
|
|
@@ -481,7 +562,7 @@ export type Card = {
|
|
|
481
562
|
moved?: number; // runs only: state.json mtime
|
|
482
563
|
title?: string; // runs only: first line of the request
|
|
483
564
|
archived: boolean; // hidden from the board unless asked for
|
|
484
|
-
tags?: string[]; // runs only: runner, agent, "unrechecked"
|
|
565
|
+
tags?: string[]; // runs only: runner, agent, "yolo", "unrechecked"
|
|
485
566
|
notes?: string; // inbox notes, or runs/<id>/notes.md
|
|
486
567
|
};
|
|
487
568
|
|
|
@@ -716,6 +797,7 @@ export function abandonRun(st: string, id: string, why: string): string | null {
|
|
|
716
797
|
// resume contract every kaizen tool reads.
|
|
717
798
|
appendFileSync(join(st, "runs", id, "02-approval.md"),
|
|
718
799
|
`\n---\n\n## Abandoned\n\n${new Date().toISOString()} — abandoned from the board.\n\n${why}\n`);
|
|
800
|
+
closeSessions(st, id);
|
|
719
801
|
return null;
|
|
720
802
|
} catch (err: any) {
|
|
721
803
|
return err?.message ?? String(err);
|
|
@@ -810,6 +892,9 @@ export function boardCards(states: string[], now: number): Card[] {
|
|
|
810
892
|
? { kind: "idea", status: "starting", text: it.text, state: st, where, column: 1, awaiting: null, dim: true, archived: false, notes: it.notes }
|
|
811
893
|
: { kind: "idea", status: "idea", text: it.text, state: st, where, column: 0, awaiting: null, dim: false, archived: it.status === "archived", notes: it.notes });
|
|
812
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)));
|
|
813
898
|
for (const r of readRuns(st)) {
|
|
814
899
|
next.push({
|
|
815
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,
|
|
@@ -817,7 +902,7 @@ export function boardCards(states: string[], now: number): Card[] {
|
|
|
817
902
|
awaiting: r.stage === "abandoned" ? null : r.awaiting,
|
|
818
903
|
dim: r.stage === "abandoned",
|
|
819
904
|
archived: archive.has(r.id),
|
|
820
|
-
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),
|
|
821
906
|
notes: readNotes(st, r.id) || undefined,
|
|
822
907
|
});
|
|
823
908
|
}
|
|
@@ -832,8 +917,8 @@ export function boardCards(states: string[], now: number): Card[] {
|
|
|
832
917
|
// What starting a run needs, minus the screens: the TUI and the web board both
|
|
833
918
|
// launch the same way and only differ in how they tell the user.
|
|
834
919
|
export type Launch =
|
|
835
|
-
| { ok: true; agent:
|
|
836
|
-
| { 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 };
|
|
837
922
|
|
|
838
923
|
export function projectOf(from: string) {
|
|
839
924
|
return from === join(home, ".kaizen")
|
|
@@ -842,7 +927,7 @@ export function projectOf(from: string) {
|
|
|
842
927
|
}
|
|
843
928
|
|
|
844
929
|
export function manualCommand(projectDir: string, agentCmd: string, prompt: string, flags: string[] = []) {
|
|
845
|
-
const bin = [agentCmd, ...flags].join(" ");
|
|
930
|
+
const bin = [agentCmd, ...flags, ...(PROMPT_FLAGS[agentCmd] ?? [])].join(" ");
|
|
846
931
|
// Pasted into whatever shell the user has. fish reads \' and \\ inside single
|
|
847
932
|
// quotes as escapes, so ' and \ each go in double quotes, which sh and fish read alike.
|
|
848
933
|
const q = (a: string) => `'${a.replace(/['\\]/g, (ch) => (ch === "'" ? `'"'"'` : `'"\\\\"'`))}'`;
|
|
@@ -851,19 +936,192 @@ export function manualCommand(projectDir: string, agentCmd: string, prompt: stri
|
|
|
851
936
|
: `cd ${q(projectDir)} && ${bin} ${q(prompt)}`;
|
|
852
937
|
}
|
|
853
938
|
|
|
854
|
-
|
|
939
|
+
// Agent sessions kaizen opened. Each launch leaves `sessions/<key>.json` in the
|
|
940
|
+
// project's state dir ({run, text, cmd, model, sessionId, started}) and `<key>.pid`, which
|
|
941
|
+
// the agent writes itself. Only these are ever closed or resumed: a session the user
|
|
942
|
+
// opened by hand has no record.
|
|
943
|
+
export type Session = { key: string; run: string | null; text: string; cmd: string; model?: string | null; yolo?: boolean; sessionId: string | null; started: number };
|
|
944
|
+
|
|
945
|
+
// Tools that take a conversation id at launch and resume it by that id. Every other
|
|
946
|
+
// tool gets a fresh session on each decision, as before.
|
|
947
|
+
const SESSION_FLAGS: Record<string, { start: (id: string) => string[]; resume: (id: string) => string[]; transcript: (cwd: string, id: string) => string }> = {
|
|
948
|
+
claude: {
|
|
949
|
+
start: (id) => ["--session-id", id],
|
|
950
|
+
resume: (id) => ["--resume", id],
|
|
951
|
+
transcript: (cwd, id) => join(process.env.CLAUDE_CONFIG_DIR ?? join(homedir(), ".claude"), "projects", cwd.replace(/[^A-Za-z0-9]/g, "-"), `${id}.jsonl`),
|
|
952
|
+
},
|
|
953
|
+
};
|
|
954
|
+
|
|
955
|
+
export const CLOSE_GRACE_MS = 2 * 60_000;
|
|
956
|
+
const sessionsDir = (st: string) => join(st, "sessions");
|
|
957
|
+
|
|
958
|
+
export function readSessions(st: string): Session[] {
|
|
959
|
+
const dir = sessionsDir(st);
|
|
960
|
+
if (!existsSync(dir)) return [];
|
|
961
|
+
return readdirSync(dir).filter((f) => f.endsWith(".json")).flatMap((f) => {
|
|
962
|
+
try { return [{ ...JSON.parse(readText(join(dir, f))), key: f.slice(0, -5) }]; } catch { return []; }
|
|
963
|
+
}).sort((a, b) => a.started - b.started);
|
|
964
|
+
}
|
|
965
|
+
|
|
966
|
+
// Whose session a record is. A decision's launch names its run. An idea's launch came
|
|
967
|
+
// before its run existed, so it belongs to the first run created after it whose
|
|
968
|
+
// request holds the idea's text: one run per record, never every run that mentions it.
|
|
969
|
+
export function ownerOf(st: string, s: Session): string | null {
|
|
970
|
+
if (s.run) return s.run;
|
|
971
|
+
const t = normalise(s.text);
|
|
972
|
+
let best: string | null = null, at = Infinity;
|
|
973
|
+
try {
|
|
974
|
+
for (const id of readdirSync(join(st, "runs"))) {
|
|
975
|
+
try {
|
|
976
|
+
const f = join(st, "runs", id, "00-request.md");
|
|
977
|
+
const st2 = statSync(f), born = st2.birthtimeMs || st2.mtimeMs;
|
|
978
|
+
// A second of slack: file times come from a coarser clock than Date.now().
|
|
979
|
+
if (born >= s.started - 1000 && born < at && normalise(readText(f)).includes(t)) { best = id; at = born; }
|
|
980
|
+
} catch { /* no request yet */ }
|
|
981
|
+
}
|
|
982
|
+
} catch { /* no runs dir */ }
|
|
983
|
+
return best;
|
|
984
|
+
}
|
|
985
|
+
|
|
986
|
+
// agent.close in the config chain: on unless it says off.
|
|
987
|
+
export function closeEnabled(projectDir: string) {
|
|
988
|
+
for (const f of [join(projectDir, ".kaizen", "config.yml"), join(home, ".kaizen", "config.yml")]) {
|
|
989
|
+
try {
|
|
990
|
+
const m = /^agent:\n(?:[ \t]+.*\n)*?[ \t]+close:[ \t]*(\S+)/m.exec(readText(f));
|
|
991
|
+
if (m) return !/^(off|false|no)$/i.test(m[1]);
|
|
992
|
+
} catch { /* absent */ }
|
|
993
|
+
}
|
|
994
|
+
return true;
|
|
995
|
+
}
|
|
996
|
+
|
|
997
|
+
// Whether a live PID is still the agent a record launched, not a stranger that reused
|
|
998
|
+
// the number: the tool's name (and conversation id) in its arguments, started no
|
|
999
|
+
// earlier than the record. On Windows the PID is the PowerShell kaizen opened.
|
|
1000
|
+
function isSession(pid: number, s: Session): boolean {
|
|
1001
|
+
try {
|
|
1002
|
+
if (process.platform === "win32") {
|
|
1003
|
+
const out = Bun.spawnSync([sysExe("tasklist.exe"), "/FI", `PID eq ${pid}`, "/FO", "CSV", "/NH"], { stdin: "ignore", timeout: 5000, windowsHide: true }).stdout.toString();
|
|
1004
|
+
return /^"(powershell|pwsh)\.exe"/i.test(out.trim());
|
|
1005
|
+
}
|
|
1006
|
+
const ps = onPath("ps");
|
|
1007
|
+
if (!ps) return false;
|
|
1008
|
+
const m = /^\s*(\S+)\s+(.*)$/.exec(Bun.spawnSync([ps, "-o", "etime=,args=", "-p", String(pid)], { stdin: "ignore", timeout: 5000 }).stdout.toString().trim());
|
|
1009
|
+
if (!m) return false;
|
|
1010
|
+
// etime is [[dd-]hh:]mm:ss
|
|
1011
|
+
const [d, rest] = m[1].includes("-") ? m[1].split("-") : ["0", m[1]];
|
|
1012
|
+
const secs = rest.split(":").reduce((a, n) => a * 60 + Number(n), 0) + Number(d) * 86400;
|
|
1013
|
+
return Date.now() - secs * 1000 >= s.started - 2000 && m[2].includes(s.cmd) && (!s.sessionId || m[2].includes(s.sessionId));
|
|
1014
|
+
} catch {
|
|
1015
|
+
return false;
|
|
1016
|
+
}
|
|
1017
|
+
}
|
|
1018
|
+
|
|
1019
|
+
// End one recorded session if it is still running, and drop its PID file either way:
|
|
1020
|
+
// without one, the record is never looked at for closing again.
|
|
1021
|
+
function closeOne(st: string, s: Session): boolean {
|
|
1022
|
+
const pidFile = join(sessionsDir(st), s.key + ".pid");
|
|
1023
|
+
let closed = false;
|
|
1024
|
+
try {
|
|
1025
|
+
const pid = parseInt(readText(pidFile));
|
|
1026
|
+
if (pid > 0 && isSession(pid, s)) {
|
|
1027
|
+
if (process.platform === "win32") Bun.spawnSync([sysExe("taskkill.exe"), "/T", "/F", "/PID", String(pid)], { stdin: "ignore", stdout: "ignore", stderr: "ignore", timeout: 5000, windowsHide: true });
|
|
1028
|
+
else process.kill(pid, "SIGTERM");
|
|
1029
|
+
closed = true;
|
|
1030
|
+
}
|
|
1031
|
+
} catch { /* already gone */ }
|
|
1032
|
+
rmSync(pidFile, { force: true });
|
|
1033
|
+
return closed;
|
|
1034
|
+
}
|
|
1035
|
+
|
|
1036
|
+
const openSessions = (st: string) => readSessions(st).filter((s) => existsSync(join(sessionsDir(st), s.key + ".pid")));
|
|
1037
|
+
|
|
1038
|
+
// Close every session kaizen opened for a run, those launched before `before` only,
|
|
1039
|
+
// so a decision's own successor is never caught. The record stays: it holds the
|
|
1040
|
+
// conversation id the next decision resumes. Never throws.
|
|
1041
|
+
export function closeSessions(st: string, id: string, opts: { before?: number } = {}): number {
|
|
1042
|
+
try {
|
|
1043
|
+
if (!closeEnabled(dirname(st))) return 0;
|
|
1044
|
+
return openSessions(st).filter((s) => s.started < (opts.before ?? Infinity) && ownerOf(st, s) === id).filter((s) => closeOne(st, s)).length;
|
|
1045
|
+
} catch { return 0; }
|
|
1046
|
+
}
|
|
1047
|
+
|
|
1048
|
+
// The board's minute sweep: sessions of runs done for longer than the grace, so the
|
|
1049
|
+
// final summary can still be read first. Only sessions launched before the run last
|
|
1050
|
+
// moved: a fix started on a done run leaves it done until that agent writes, and is
|
|
1051
|
+
// the one doing the work. Abandoned runs are closed by abandonRun, not here.
|
|
1052
|
+
export function sweepSessions(st: string, now: number) {
|
|
1053
|
+
try {
|
|
1054
|
+
const live = openSessions(st);
|
|
1055
|
+
if (!live.length || !closeEnabled(dirname(st))) return;
|
|
1056
|
+
const runs = new Map(readRuns(st).map((r) => [r.id, r]));
|
|
1057
|
+
for (const s of live) {
|
|
1058
|
+
const r = runs.get(ownerOf(st, s) ?? "");
|
|
1059
|
+
if (r && r.stage === "done" && s.started <= r.moved && now - r.moved >= CLOSE_GRACE_MS) closeOne(st, s);
|
|
1060
|
+
}
|
|
1061
|
+
} catch { /* the next sweep tries again */ }
|
|
1062
|
+
}
|
|
1063
|
+
|
|
1064
|
+
// The run's conversation to continue: its first recorded one, on this tool, whose
|
|
1065
|
+
// transcript is still on disk. None means a fresh session, never a guess.
|
|
1066
|
+
export function firstSession(st: string, id: string, cmd: string, cwd: string): Session | null {
|
|
1067
|
+
const f = SESSION_FLAGS[cmd];
|
|
1068
|
+
if (!f) return null;
|
|
1069
|
+
return readSessions(st).find((s) => s.sessionId && s.cmd === cmd && ownerOf(st, s) === id && existsSync(f.transcript(cwd, s.sessionId))) ?? null;
|
|
1070
|
+
}
|
|
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
|
+
|
|
1084
|
+
// The agent's argv for a launch, and its record. With a run, a decision: it resumes
|
|
1085
|
+
// the run's conversation when there is one. A project without a state dir gets no
|
|
1086
|
+
// record; making one here would shadow the user's.
|
|
1087
|
+
export function sessionLaunch(projectDir: string, cmd: string, base: string[], prompt: string, text: string, run?: string, model?: string, yolo?: boolean) {
|
|
1088
|
+
const st = join(projectDir, ".kaizen");
|
|
1089
|
+
if (!existsSync(st)) return { fullCmd: [...base, ...promptArgs(cmd, prompt)], pidFile: undefined, record: undefined };
|
|
1090
|
+
const f = SESSION_FLAGS[cmd];
|
|
1091
|
+
const prev = run ? firstSession(st, run, cmd, projectDir) : null;
|
|
1092
|
+
const sessionId = f ? prev?.sessionId ?? crypto.randomUUID() : null;
|
|
1093
|
+
const fullCmd = [...base, ...(f && sessionId ? (prev ? f.resume(sessionId) : f.start(sessionId)) : []), ...promptArgs(cmd, prompt)];
|
|
1094
|
+
const key = `${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
|
|
1095
|
+
mkdirSync(sessionsDir(st), { recursive: true });
|
|
1096
|
+
const record = join(sessionsDir(st), key + ".json");
|
|
1097
|
+
writeFileSync(record, JSON.stringify({ run: run ?? null, text, cmd, model: model ?? null, ...(yolo ? { yolo: true } : {}), sessionId, started: Date.now() }) + "\n");
|
|
1098
|
+
return { fullCmd, pidFile: join(sessionsDir(st), key + ".pid"), record };
|
|
1099
|
+
}
|
|
1100
|
+
|
|
1101
|
+
// run: the run a decision is for. Without it, an idea starting a new run.
|
|
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 {
|
|
855
1105
|
const projectDir = projectOf(from);
|
|
856
1106
|
// The run will be born here; the board must know the dir or the idea never retires.
|
|
857
1107
|
remember(projectDir);
|
|
858
|
-
const
|
|
1108
|
+
const own = run && !pick.agent ? runAgent(join(projectDir, ".kaizen"), run) : null;
|
|
1109
|
+
const agent = detectDefaultAgent(projectDir, pick.agent ?? own?.cmd);
|
|
859
1110
|
const agentBin = Bun.which(agent.cmd) ?? agent.cmd;
|
|
860
1111
|
const prompt = requestOf(it.where ? `/kaizen ${it.text} (${it.where})` : `/kaizen ${it.text}`, it.notes, from);
|
|
861
|
-
const
|
|
862
|
-
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] : [])];
|
|
1117
|
+
// The idea as its run's request will hold it: without the runner word in front.
|
|
1118
|
+
const s = sessionLaunch(projectDir, agent.cmd, [agentBin, ...flags], prompt, it.text.replace(/^(lite|full|auto|plan)\s+/i, ""), run, model, yolo);
|
|
863
1119
|
const manual = manualCommand(projectDir, agent.cmd, prompt, flags);
|
|
1120
|
+
const drop = () => { if (s.record) rmSync(s.record, { force: true }); };
|
|
864
1121
|
|
|
865
|
-
const term = findTerminal(projectDir, fullCmd);
|
|
1122
|
+
const term = findTerminal(projectDir, s.fullCmd, s.pidFile);
|
|
866
1123
|
if (!term) {
|
|
1124
|
+
drop();
|
|
867
1125
|
return {
|
|
868
1126
|
ok: false, agent, prompt, projectDir, manual,
|
|
869
1127
|
why: process.platform === "darwin"
|
|
@@ -885,6 +1143,7 @@ export function launchRun(it: Item, from: string): Launch {
|
|
|
885
1143
|
if (term.detached) proc.unref();
|
|
886
1144
|
return { ok: true, agent, prompt, projectDir, cmd: term.cmd };
|
|
887
1145
|
} catch (err: any) {
|
|
1146
|
+
drop();
|
|
888
1147
|
return { ok: false, agent, prompt, projectDir, manual, why: err?.message ?? String(err) };
|
|
889
1148
|
}
|
|
890
1149
|
}
|
|
@@ -1052,3 +1311,58 @@ export async function commitPush(state: string, id: string, message: string, sho
|
|
|
1052
1311
|
record(true);
|
|
1053
1312
|
return { ok: true, sha };
|
|
1054
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
|
+
}
|