kaizen-agent 1.43.0 → 1.43.2
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/agents/kaizen-reviewer.md +1 -1
- package/cli/board.test.ts +3 -2
- package/cli/browser.test.ts +1 -1
- package/cli/browser.ts +12 -7
- package/cli/pty.test.ts +10 -0
- package/cli/pty.ts +15 -6
- package/cli/settings.test.ts +38 -0
- package/cli/settings.ts +37 -30
- package/cli/state.test.ts +25 -1
- package/cli/state.ts +32 -2
- package/cli/web.ts +11 -1
- package/package.json +1 -1
- package/skills/kaizen/README.md +0 -4
- package/skills/kaizen/SKILL.md +4 -1
- package/skills/kaizen/config.default.yml +2 -51
- package/skills/kaizen/docs.md +19 -5
- package/skills/kaizen/settings.md +10 -3
- package/skills/kaizen/spec-build.md +3 -2
- package/skills/kaizen/spec-main.md +68 -5
- package/skills/kaizen/spec-plan.md +95 -1
- package/skills/kaizen/spec-review.md +2 -1
- package/skills/kaizen-help/SKILL.md +1 -5
- package/web/board/agent-term.ts +1 -1
- package/web/board/api.ts +2 -0
- package/web/board/cards.ts +50 -7
- package/web/board/git.ts +1 -1
- package/web/board/icebox.ts +1 -1
- package/web/board/keys.ts +2 -0
- package/web/board/live.ts +3 -2
- package/web/board/projects.ts +2 -2
- package/web/board/settings.ts +47 -12
- package/web/board/terms.ts +297 -0
- package/web/board/views.ts +19 -5
- package/web/board.html +29 -2
- package/web/board.js +25 -24
- package/web/notebook.js +1 -1
- package/web/notes.html +2 -0
- package/web/term.js +1 -1
|
@@ -142,7 +142,7 @@ open human check never reads as a clean pass.
|
|
|
142
142
|
|
|
143
143
|
## Memory
|
|
144
144
|
|
|
145
|
-
When
|
|
145
|
+
When `review.write_memory` is true (the default), append to `.kaizen/memory.md` any durable lesson
|
|
146
146
|
about this codebase: a recurring bug pattern, a non-obvious constraint, a convention
|
|
147
147
|
worth preserving. One line each. Nothing run-specific, nothing already obvious from
|
|
148
148
|
reading the code.
|
package/cli/board.test.ts
CHANGED
|
@@ -186,10 +186,11 @@ test("card menu items: appropriate actions and distinct icons across phases", ()
|
|
|
186
186
|
// Phase 1: Planning (run waiting for plan approval)
|
|
187
187
|
const planWaiting = { kind: "run", status: "waiting", awaiting: "approvals.plan", column: 1 };
|
|
188
188
|
const planItems = getItems(planWaiting, false);
|
|
189
|
-
expect(planItems.map((i: any) => i ? i[1] : null)).toEqual(["Review plan", "Archive", null, "Abandon"]);
|
|
189
|
+
expect(planItems.map((i: any) => i ? i[1] : null)).toEqual(["Review plan", "Archive", "Back to idea", null, "Abandon"]);
|
|
190
190
|
expect(planItems[0][2]).toBe(ico("open"));
|
|
191
191
|
expect(planItems[1][2]).toBe(ico("archive"));
|
|
192
|
-
expect(planItems[
|
|
192
|
+
expect(planItems[2][2]).toBe(ico("restore"));
|
|
193
|
+
expect(planItems[4][2]).toBe(ico("x"));
|
|
193
194
|
|
|
194
195
|
// Phase 2: Building (running run)
|
|
195
196
|
const buildingRun = { kind: "run", status: "running", column: 2 };
|
package/cli/browser.test.ts
CHANGED
|
@@ -18,7 +18,7 @@ const run = async (code: string, env: Record<string, string> = {}) => {
|
|
|
18
18
|
const script = `import * as br from ${JSON.stringify(join(repo, "cli/browser.ts"))};
|
|
19
19
|
Object.assign(process.env, ${JSON.stringify({ HOME: process.env.HOME ?? "", USERPROFILE: process.env.USERPROFILE ?? "" })});
|
|
20
20
|
console.log(JSON.stringify(await (async () => { ${code} })()));`;
|
|
21
|
-
const p = Bun.spawn(["bun", "-e", script], { cwd: home, env: { ...process.env, HOME: home, USERPROFILE: home, ...env }, stdout: "pipe", stderr: "pipe" });
|
|
21
|
+
const p = Bun.spawn(["bun", "-e", script], { cwd: home, env: { ...process.env, HOME: home, USERPROFILE: home, KAIZEN_BROWSER_PACE: "0.1", ...env }, stdout: "pipe", stderr: "pipe" });
|
|
22
22
|
const [out, err, code_] = [await new Response(p.stdout).text(), await new Response(p.stderr).text(), await p.exited];
|
|
23
23
|
if (code_ !== 0) throw new Error(err);
|
|
24
24
|
return JSON.parse(out.trim().split("\n").at(-1)!);
|
package/cli/browser.ts
CHANGED
|
@@ -30,6 +30,8 @@ export const POST: Record<string, RegExp> = {
|
|
|
30
30
|
};
|
|
31
31
|
// Grids show thumbnails, not text: each post page is opened for its caption.
|
|
32
32
|
const GRID = new Set(["instagram"]);
|
|
33
|
+
// The fixed waits below are sized for real platforms; KAIZEN_BROWSER_PACE scales them (the tests run at 0.1).
|
|
34
|
+
const nap = (ms: number) => Bun.sleep(ms * (Number(process.env.KAIZEN_BROWSER_PACE) || 1));
|
|
33
35
|
|
|
34
36
|
// KAIZEN_BROWSER, else the usual names on PATH, else the usual install paths.
|
|
35
37
|
export function findBrowser(): string | null {
|
|
@@ -97,9 +99,9 @@ export class Page {
|
|
|
97
99
|
async go(url: string) {
|
|
98
100
|
await this.call("Page.navigate", { url });
|
|
99
101
|
const end = Date.now() + 20_000;
|
|
100
|
-
await
|
|
101
|
-
while (Date.now() < end && (await this.js("document.readyState")) !== "complete") await
|
|
102
|
-
await
|
|
102
|
+
await nap(1500);
|
|
103
|
+
while (Date.now() < end && (await this.js("document.readyState")) !== "complete") await nap(250);
|
|
104
|
+
await nap(2000); // the posts arrive after the page itself
|
|
103
105
|
}
|
|
104
106
|
close() { try { this.ws.close(); } catch { /* already gone */ } }
|
|
105
107
|
}
|
|
@@ -200,7 +202,10 @@ export async function browse<T>(use: (page: Page) => Promise<T>): Promise<T | {
|
|
|
200
202
|
} finally {
|
|
201
203
|
page?.close();
|
|
202
204
|
proc.kill();
|
|
203
|
-
|
|
205
|
+
// A cleared timer, not Bun.sleep: a pending sleep keeps the process alive 5 s after Chrome is gone.
|
|
206
|
+
let t: Timer | undefined;
|
|
207
|
+
await Promise.race([proc.exited, new Promise((r) => { t = setTimeout(r, 5000); })]);
|
|
208
|
+
clearTimeout(t);
|
|
204
209
|
if (proc.exitCode === null) proc.kill(9);
|
|
205
210
|
}
|
|
206
211
|
}
|
|
@@ -230,7 +235,7 @@ export async function readPage(page: Page, url: string, platform: string, max =
|
|
|
230
235
|
// A page that renders its posts late is not empty: stop early only once some arrived.
|
|
231
236
|
still = seen.size && seen.size === before ? still + 1 : 0;
|
|
232
237
|
await page.js("window.scrollBy(0, window.innerHeight * 1.5)");
|
|
233
|
-
await
|
|
238
|
+
await nap(1500);
|
|
234
239
|
}
|
|
235
240
|
let found = [...seen.values()].slice(0, max);
|
|
236
241
|
if (GRID.has(platform)) {
|
|
@@ -310,12 +315,12 @@ export async function replyOn(page: Page, link: string, platform: string, text:
|
|
|
310
315
|
if (!(await page.js<boolean>(FIND(how.box, "focus"))))
|
|
311
316
|
return { ok: false, note: (await page.js<boolean>(OFFERS)) ? signInNote(platform) : `Could not find the reply box on ${name}. Nothing was posted.` };
|
|
312
317
|
await page.call("Input.insertText", { text });
|
|
313
|
-
await
|
|
318
|
+
await nap(800);
|
|
314
319
|
if (!(await here())) return { ok: false, note: `The page left ${name}. Nothing was posted.` };
|
|
315
320
|
if (!(await page.js<boolean>(FIND(how.send, "click")))) return { ok: false, note: `Could not find ${name}'s send button. Nothing was posted.` };
|
|
316
321
|
// Sent once the box is gone or emptied; still holding the text means it did not go.
|
|
317
322
|
for (let i = 0; i < 20; i++) {
|
|
318
|
-
await
|
|
323
|
+
await nap(500);
|
|
319
324
|
const left = await page.js<string | null>(BOX);
|
|
320
325
|
if (left === null || !left.trim()) return { ok: true };
|
|
321
326
|
}
|
package/cli/pty.test.ts
CHANGED
|
@@ -186,6 +186,16 @@ test.skipIf(process.platform === "win32")("the done sweep keeps a session the us
|
|
|
186
186
|
await Bun.sleep(200);
|
|
187
187
|
expect(alive(used)).toBe(true);
|
|
188
188
|
process.kill(used, "SIGTERM");
|
|
189
|
+
|
|
190
|
+
// Only looked at: the page's focus and query answers reach the tty but are not typing.
|
|
191
|
+
done(Date.now() - 60_000);
|
|
192
|
+
const looked = launch("looked");
|
|
193
|
+
await Bun.sleep(200);
|
|
194
|
+
writePty("looked", "\x1b[I\x1b[O\x1b[?0u\x1b[<64;10;5M\x1bP>|xterm.js(6.0)\x1b\\");
|
|
195
|
+
await Bun.sleep(200);
|
|
196
|
+
expect(ptyState("looked")!.typed).toBe(0);
|
|
197
|
+
sweepSessions(st, Date.now() + CLOSE_GRACE_MS);
|
|
198
|
+
expect(await gone(looked)).toBe(true);
|
|
189
199
|
});
|
|
190
200
|
|
|
191
201
|
test("asking: Claude Code and Codex prompts count as a question, ordinary output does not", async () => {
|
package/cli/pty.ts
CHANGED
|
@@ -11,7 +11,7 @@ const WIN = process.platform === "win32";
|
|
|
11
11
|
// Windows PowerShell, which every Windows has, both hosts the console and runs the agent in it.
|
|
12
12
|
const winPs = () => join(process.env.SystemRoot ?? "C:\\Windows", "System32", "WindowsPowerShell", "v1.0", "powershell.exe");
|
|
13
13
|
|
|
14
|
-
type Pty = { proc: Bun.Subprocess; out: string[]; size: number; listeners: Set<(d: string | null) => void>; exit: number | null; asking: boolean; quiet?: Timer; total: number; since: number; onChange?: () => void };
|
|
14
|
+
type Pty = { proc: Bun.Subprocess; out: string[]; size: number; listeners: Set<(d: string | null) => void>; exit: number | null; asking: boolean; quiet?: Timer; total: number; since: number; typed: number; onChange?: () => void };
|
|
15
15
|
|
|
16
16
|
// What a page attaching late gets replayed. A full-screen tool redraws often, so the
|
|
17
17
|
// tail of its output is enough to rebuild the screen.
|
|
@@ -38,7 +38,7 @@ export const ptySupported = WIN || typeof (Bun as any).Terminal === "function";
|
|
|
38
38
|
// onChange: called when the agent ends or starts or stops asking, so the board can redraw.
|
|
39
39
|
export function startPty(key: string, cmd: string[], cwd: string, pidFile?: string, onChange?: () => void): number {
|
|
40
40
|
const dec = new TextDecoder();
|
|
41
|
-
const p: Pty = { proc: null as unknown as Bun.Subprocess, out: [], size: 0, listeners: new Set(), exit: null, asking: false, total: 0, since: 0, onChange };
|
|
41
|
+
const p: Pty = { proc: null as unknown as Bun.Subprocess, out: [], size: 0, listeners: new Set(), exit: null, asking: false, total: 0, since: 0, typed: 0, onChange };
|
|
42
42
|
const flag = (v: boolean) => { if (p.asking !== v) { p.asking = v; onChange?.(); } };
|
|
43
43
|
const push = (d: string) => {
|
|
44
44
|
p.out.push(d);
|
|
@@ -81,7 +81,8 @@ export function startPty(key: string, cmd: string[], cwd: string, pidFile?: stri
|
|
|
81
81
|
for (const f of p.listeners) f(null);
|
|
82
82
|
p.listeners.clear();
|
|
83
83
|
try { p.proc.terminal?.close(); } catch { /* already closed */ }
|
|
84
|
-
|
|
84
|
+
// Dropped: pages showing it (the Terminal page) refresh, or its tile lingers until some other change.
|
|
85
|
+
setTimeout(() => { if (ptys.get(key) === p) { ptys.delete(key); onChange?.(); } }, KEEP_MS).unref();
|
|
85
86
|
onChange?.();
|
|
86
87
|
});
|
|
87
88
|
return p.proc.pid;
|
|
@@ -98,11 +99,19 @@ export function attachPty(key: string, fn: (d: string | null) => void): (() => v
|
|
|
98
99
|
return () => p.listeners.delete(fn);
|
|
99
100
|
}
|
|
100
101
|
|
|
102
|
+
// What the page's terminal sends by itself: focus in/out (Claude Code turns focus
|
|
103
|
+
// reporting on), mouse and wheel reports, and answers to the agent's queries (cursor,
|
|
104
|
+
// device, mode, colour, version). None of it is the user typing.
|
|
105
|
+
const REPORT = /\x1b\[[IO]|\x1b\[<[\d;]*[Mm]|\x1b\[\?[\d;]*(?:[cnu]|\$y)|\x1b\[>[\d;]*c|\x1b\[\d+;\d+R|\x1b\[\d*n|\x1b\][^\x07\x1b]*(?:\x07|\x1b\\)|\x1bP[^\x1b]*\x1b\\/g;
|
|
106
|
+
|
|
101
107
|
export function writePty(key: string, d: string) {
|
|
102
108
|
const p = ptys.get(key);
|
|
103
109
|
if (!p || p.exit !== null) return;
|
|
104
|
-
|
|
105
|
-
|
|
110
|
+
if (d.replace(REPORT, "")) {
|
|
111
|
+
p.typed = Date.now();
|
|
112
|
+
p.since = p.total;
|
|
113
|
+
if (p.asking) { p.asking = false; p.onChange?.(); }
|
|
114
|
+
}
|
|
106
115
|
send(p, d);
|
|
107
116
|
}
|
|
108
117
|
|
|
@@ -121,7 +130,7 @@ export const ptyKeys = () => [...ptys.keys()];
|
|
|
121
130
|
|
|
122
131
|
export const ptyState = (key: string) => {
|
|
123
132
|
const p = ptys.get(key);
|
|
124
|
-
return p ? { alive: p.exit === null, exit: p.exit, asking: p.asking } : null;
|
|
133
|
+
return p ? { alive: p.exit === null, exit: p.exit, asking: p.asking, typed: p.typed } : null;
|
|
125
134
|
};
|
|
126
135
|
|
|
127
136
|
// The environment the user's login shell builds: PATH and anything exported in their
|
package/cli/settings.test.ts
CHANGED
|
@@ -6,6 +6,7 @@ import { test, expect, beforeAll, afterAll } from "bun:test";
|
|
|
6
6
|
import { mkdtempSync, mkdirSync, writeFileSync, readFileSync, copyFileSync, rmSync } from "node:fs";
|
|
7
7
|
import { join, dirname } from "node:path";
|
|
8
8
|
import { tmpdir } from "node:os";
|
|
9
|
+
import { applyPreset, detectPreset } from "./settings.ts";
|
|
9
10
|
|
|
10
11
|
const repo = join(dirname(import.meta.path), "..");
|
|
11
12
|
const defaults = join(repo, "skills/kaizen/config.default.yml");
|
|
@@ -48,3 +49,40 @@ test("board settings save to the global config, project settings to the project"
|
|
|
48
49
|
expect(inProject(`return setSetting(repo, "git.auto_commit", "true")`)).toEqual({ ok: true });
|
|
49
50
|
expect(readFileSync(join(project, ".kaizen", "config.yml"), "utf8")).toMatch(/auto_commit: true/);
|
|
50
51
|
});
|
|
52
|
+
|
|
53
|
+
test("every row is a key in config.default.yml, and every `when` names a row and one of its values", () => {
|
|
54
|
+
const rows = inProject(`return listSettings(repo).rows`);
|
|
55
|
+
const text = readFileSync(defaults, "utf8");
|
|
56
|
+
for (const r of rows) {
|
|
57
|
+
const [top, leaf] = r.key.split(".");
|
|
58
|
+
const block = leaf ? new RegExp(`^${top}:\\n(?:(?: .*|\\s*)\\n)*? ${leaf}:`, "m") : new RegExp(`^${top}:`, "m");
|
|
59
|
+
expect(block.test(text) ? r.key : `${r.key} missing`).toBe(r.key);
|
|
60
|
+
if (!r.when) continue;
|
|
61
|
+
const dep = rows.find((x) => x.key === r.when[0]);
|
|
62
|
+
expect(dep?.values).toContain(r.when[1]);
|
|
63
|
+
}
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
test("a row dims while the setting it depends on has it switched off, and a leftover dead key changes nothing", () => {
|
|
67
|
+
const conf = join(project, ".kaizen", "config.yml");
|
|
68
|
+
// The low preset as an older config holds it, with keys kaizen no longer reads.
|
|
69
|
+
writeFileSync(conf, readFileSync(defaults, "utf8")
|
|
70
|
+
.replace("enabled: true\n # critical", "enabled: false\n # critical")
|
|
71
|
+
.replace("git:\n", "git:\n branch_before_implement: false\n")
|
|
72
|
+
+ "\ntrack:\n default: software\n");
|
|
73
|
+
const idle = (k: string) => inProject(`return listSettings(repo).rows.find((r) => r.key === ${JSON.stringify(k)}).idle`);
|
|
74
|
+
expect(idle("auto_fix.min_severity")).toBe("No effect unless Fix findings automatically is on");
|
|
75
|
+
expect(idle("auto_fix.enabled")).toBe("");
|
|
76
|
+
expect(inProject(`return setSetting(repo, "auto_fix.max_iterations", "3")`)).toEqual({ ok: true });
|
|
77
|
+
expect(inProject(`return setSetting(repo, "auto_fix.enabled", "true")`)).toEqual({ ok: true });
|
|
78
|
+
expect(idle("auto_fix.min_severity")).toBe("");
|
|
79
|
+
expect(inProject(`return value("auto_fix.max_iterations")`)).toBe("3");
|
|
80
|
+
copyFileSync(defaults, conf);
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
test("a config still holding git.branch_before_implement keeps its preset", () => {
|
|
84
|
+
const text = readFileSync(defaults, "utf8");
|
|
85
|
+
const low = applyPreset(text, "low", text).replace("git:\n", "git:\n branch_before_implement: true\n");
|
|
86
|
+
expect(detectPreset(low, text)).toBe("low");
|
|
87
|
+
expect(detectPreset(text, text)).toBe("medium");
|
|
88
|
+
});
|
package/cli/settings.ts
CHANGED
|
@@ -6,7 +6,10 @@ 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
|
-
|
|
9
|
+
// `when`: the row only has an effect while that other key holds that value; both
|
|
10
|
+
// settings screens dim it otherwise, and it stays editable (`/kaizen full` overrides
|
|
11
|
+
// the runner for one run, so a subagent model still matters under `runner: lite`).
|
|
12
|
+
type Setting = { key: string; label: string; group: string; values: string[]; help: string; text?: boolean; folder?: boolean; when?: [string, string] };
|
|
10
13
|
|
|
11
14
|
export type PresetName = "low" | "medium" | "ultra";
|
|
12
15
|
|
|
@@ -29,7 +32,6 @@ export const PRESETS: Record<PresetName, {
|
|
|
29
32
|
"auto_fix.min_severity": "high",
|
|
30
33
|
"auto_fix.max_iterations": "1",
|
|
31
34
|
"git.auto_commit": "false",
|
|
32
|
-
"git.branch_before_implement": "false",
|
|
33
35
|
"git.worktree_per_run": "false",
|
|
34
36
|
},
|
|
35
37
|
},
|
|
@@ -47,7 +49,6 @@ export const PRESETS: Record<PresetName, {
|
|
|
47
49
|
"auto_fix.min_severity": "high",
|
|
48
50
|
"auto_fix.max_iterations": "2",
|
|
49
51
|
"git.auto_commit": "false",
|
|
50
|
-
"git.branch_before_implement": "true",
|
|
51
52
|
"git.worktree_per_run": "true",
|
|
52
53
|
},
|
|
53
54
|
},
|
|
@@ -65,7 +66,6 @@ export const PRESETS: Record<PresetName, {
|
|
|
65
66
|
"auto_fix.min_severity": "low",
|
|
66
67
|
"auto_fix.max_iterations": "4",
|
|
67
68
|
"git.auto_commit": "true",
|
|
68
|
-
"git.branch_before_implement": "true",
|
|
69
69
|
"git.worktree_per_run": "true",
|
|
70
70
|
},
|
|
71
71
|
},
|
|
@@ -99,31 +99,29 @@ export function applyPreset(text: string, preset: PresetName, defaults: string):
|
|
|
99
99
|
|
|
100
100
|
const SETTINGS: Setting[] = [
|
|
101
101
|
{ key: "preset", label: "Preset", group: "Preset", values: ["low", "medium", "ultra", "custom"], help: "" },
|
|
102
|
-
{ key: "mode", label: "
|
|
103
|
-
{ key: "approvals.plan", label: "Approve the plan first", group: "Approvals", values: ["true", "false"], help: "" },
|
|
104
|
-
{ key: "approvals.review", label: "Approve after review", group: "Approvals", values: ["true", "false"], help: "" },
|
|
105
|
-
{ key: "approvals.each_file", label: "Confirm every file edit", group: "Approvals", values: ["true", "false"], help: "" },
|
|
102
|
+
{ key: "mode", label: "Run mode", group: "Approvals", values: ["approve", "auto", "plan-only", "review-only"], help: "" },
|
|
103
|
+
{ key: "approvals.plan", label: "Approve the plan first", group: "Approvals", values: ["true", "false"], help: "", when: ["mode", "approve"] },
|
|
104
|
+
{ key: "approvals.review", label: "Approve after review", group: "Approvals", values: ["true", "false"], help: "", when: ["mode", "approve"] },
|
|
105
|
+
{ key: "approvals.each_file", label: "Confirm every file edit", group: "Approvals", values: ["true", "false"], help: "", when: ["mode", "approve"] },
|
|
106
106
|
{ key: "runner", label: "Stage isolation", group: "Agents", values: ["full", "lite"], help: "full: separate agent per stage. lite: one session" },
|
|
107
|
-
{ key: "build.executor", label: "Who builds", group: "Agents", values: ["subagent", "inline", "ask"], help: "" },
|
|
107
|
+
{ key: "build.executor", label: "Who builds", group: "Agents", values: ["subagent", "inline", "ask"], help: "subagent: its own agent · inline: this session · ask: pick at plan approval", when: ["runner", "full"] },
|
|
108
108
|
{ key: "agent.default", label: "Tool the board launches", group: "Agents", values: ["auto", "claude", "codex", "agy", "opencode", "gemini"], help: "" },
|
|
109
109
|
{ key: "agent.launch", label: "Where the board runs agents", group: "Agents", values: ["card", "terminal"], help: "" },
|
|
110
|
-
{ key: "subagents.model", label: "Model for
|
|
111
|
-
{ key: "subagents.effort", label: "Effort for
|
|
110
|
+
{ key: "subagents.model", label: "Model for subagents", group: "Agents", values: ["inherit", "opus", "sonnet", "haiku"], help: "Planner, builder, reviewer", when: ["runner", "full"] },
|
|
111
|
+
{ key: "subagents.effort", label: "Effort for subagents", group: "Agents", values: ["inherit", "low", "medium", "high"], help: "Planner, builder, reviewer", when: ["runner", "full"] },
|
|
112
112
|
{ key: "auto_fix.enabled", label: "Fix findings automatically", group: "Auto-fix", values: ["true", "false"], help: "" },
|
|
113
|
-
{ key: "auto_fix.min_severity", label: "
|
|
114
|
-
{ key: "auto_fix.max_iterations", label: "Max fix rounds", group: "Auto-fix", values: ["1", "2", "3", "4", "5"], help: "" },
|
|
113
|
+
{ key: "auto_fix.min_severity", label: "Lowest severity to fix", group: "Auto-fix", values: ["critical", "high", "medium", "low"], help: "", when: ["auto_fix.enabled", "true"] },
|
|
114
|
+
{ key: "auto_fix.max_iterations", label: "Max fix rounds", group: "Auto-fix", values: ["1", "2", "3", "4", "5"], help: "", when: ["auto_fix.enabled", "true"] },
|
|
115
115
|
{ key: "git.auto_commit", label: "Commit when done", group: "Git", values: ["false", "true"], help: "Never pushes" },
|
|
116
|
-
{ key: "git.branch_before_implement", label: "Branch before building", group: "Git", values: ["true", "false"], help: "" },
|
|
117
116
|
{ key: "git.worktree_per_run", label: "Branch and folder per run", group: "Git", values: ["true", "false"], help: "" },
|
|
118
|
-
{ key: "track.default", label: "Kind of work", group: "Project", values: ["auto", "software", "writing", "communication", "operations", "research"], help: "" },
|
|
119
117
|
{ key: "review.write_memory", label: "Remember lessons", group: "Project", values: ["true", "false"], help: "" },
|
|
120
118
|
{ key: "review.memory_max_lines", label: "Memory size cap", group: "Project", values: ["50", "100", "200", "400"], help: "" },
|
|
121
119
|
{ key: "state.keep_runs", label: "Runs to keep", group: "Project", values: ["5", "10", "20", "50"], help: "" },
|
|
122
120
|
{ key: "dictation.model", label: "Dictation model", group: "Board", values: ["tiny", "base", "small", "large-v3-turbo"], help: "" },
|
|
123
121
|
{ key: "sources.every", label: "Gather requests", group: "Board", values: ["off", "1h", "6h", "24h"], help: "" },
|
|
124
|
-
{ key: "sources.agent", label: "Who
|
|
125
|
-
{ key: "sources.model", label: "ChatGPT model for gathering", group: "Board", values: [], text: true, help: "Empty: plan default" },
|
|
126
|
-
{ key: "sources.effort", label: "ChatGPT effort for gathering", group: "Board", values: ["low", "medium", "high"], help: "" },
|
|
122
|
+
{ key: "sources.agent", label: "Who picks social posts", group: "Board", values: ["ask", "chatgpt", "agent"], help: "X, TikTok, Instagram, Threads" },
|
|
123
|
+
{ key: "sources.model", label: "ChatGPT model for gathering", group: "Board", values: [], text: true, help: "Empty: plan default", when: ["sources.agent", "chatgpt"] },
|
|
124
|
+
{ key: "sources.effort", label: "ChatGPT effort for gathering", group: "Board", values: ["low", "medium", "high"], help: "", when: ["sources.agent", "chatgpt"] },
|
|
127
125
|
{ key: "board.projects_dir", label: "Projects folder", group: "Board", values: [], text: true, folder: true, help: "Empty: known projects only" },
|
|
128
126
|
{ key: "board.open", label: "Open the board in", group: "Board", values: ["browser", "app"], help: "" },
|
|
129
127
|
{ key: "ui.mouse", label: "Enable mouse", group: "Terminal", values: ["false", "true"], help: "" },
|
|
@@ -137,13 +135,18 @@ export function listSettings(repoRoot: string) {
|
|
|
137
135
|
const load = (f: string) => existsSync(f) ? readText(f) : "";
|
|
138
136
|
const text = load(file), board = load(globalFile());
|
|
139
137
|
const preset = detectPreset(text, defaults);
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
138
|
+
const rows = SETTINGS.map((s) => ({
|
|
139
|
+
...s,
|
|
140
|
+
value: s.key === "preset" ? preset : (read(s.group === "Board" ? board : text, s.key) || read(defaults, s.key)) ?? s.values[0] ?? "",
|
|
141
|
+
}));
|
|
142
|
+
const valueOf = (k: string) => rows.find((r) => r.key === k)?.value ?? null;
|
|
143
|
+
return { file: tilde(file), rows: rows.map((r) => ({ ...r, idle: idle(r, valueOf) })) };
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
// Why a row has no effect right now, or "" while it has one: its `when` is not met.
|
|
147
|
+
function idle(s: Setting, valueOf: (key: string) => string | null) {
|
|
148
|
+
if (!s.when || valueOf(s.when[0]) === s.when[1]) return "";
|
|
149
|
+
return `No effect unless ${SETTINGS.find((x) => x.key === s.when![0])!.label} is ${s.when[1] === "true" ? "on" : s.when[1]}`;
|
|
147
150
|
}
|
|
148
151
|
|
|
149
152
|
export function setSetting(repoRoot: string, key: string, value: string) {
|
|
@@ -217,13 +220,17 @@ export async function settings(repoRoot: string, standalone = true) {
|
|
|
217
220
|
}
|
|
218
221
|
return v;
|
|
219
222
|
};
|
|
223
|
+
const valueOf = (k: string) => {
|
|
224
|
+
const d = SETTINGS.find((x) => x.key === k)!;
|
|
225
|
+
return read(fileFor(d) === file ? text : readText(fileFor(d)), k) || read(defaults, k);
|
|
226
|
+
};
|
|
220
227
|
const rows = SETTINGS.map((s) => ({
|
|
221
228
|
s,
|
|
222
|
-
|
|
229
|
+
idle: idle(s, valueOf),
|
|
223
230
|
// The preset as a game's quick preset: arrows either side, the tier lit after it. No colour codes: the selected row wraps this in inverse, which a reset would end.
|
|
224
231
|
value: s.key === "preset"
|
|
225
232
|
? `◁ ${currentPreset.padEnd(6)} ▷ ${(["low", "medium", "ultra"] as const).map((p) => p === currentPreset ? "━" : "─").join("")}`
|
|
226
|
-
: show(s,
|
|
233
|
+
: show(s, read(fileFor(s) === file ? text : readText(fileFor(s)), s.key) ?? c.dim("(default)")),
|
|
227
234
|
}));
|
|
228
235
|
const width = Math.max(...SETTINGS.map((s) => s.label.length)) + 4;
|
|
229
236
|
|
|
@@ -234,7 +241,7 @@ export async function settings(repoRoot: string, standalone = true) {
|
|
|
234
241
|
rowAt = [];
|
|
235
242
|
let line = 2, group = "";
|
|
236
243
|
const list = shown();
|
|
237
|
-
for (const [i, { s, value }] of rows.entries()) {
|
|
244
|
+
for (const [i, { s, value, idle: off }] of rows.entries()) {
|
|
238
245
|
if (!list.includes(i)) continue;
|
|
239
246
|
if (s.group !== group) {
|
|
240
247
|
group = s.group;
|
|
@@ -245,14 +252,14 @@ export async function settings(repoRoot: string, standalone = true) {
|
|
|
245
252
|
const name = s.label.padEnd(width);
|
|
246
253
|
stdout.write(on
|
|
247
254
|
? ` ${c.cyan("›")} ${c.bold(name)}${c.inv(` ${value} `)}\n`
|
|
248
|
-
: ` ${c.dim(name)}${value}\n`);
|
|
255
|
+
: ` ${c.dim(name)}${off ? c.dim(value) : value}\n`);
|
|
249
256
|
rowAt[line++] = i;
|
|
250
257
|
}
|
|
251
258
|
const cur = SETTINGS[active]!;
|
|
252
259
|
if (!list.length) stdout.write(`\n ${c.dim(`no setting matches "${query}"`)}\n\n`);
|
|
253
260
|
else {
|
|
254
261
|
stdout.write(`\n ${c.dim(cur.key)} ${c.dim("options:")} ${cur.values.map((v) => v || '""').join(c.dim(" / "))}\n`);
|
|
255
|
-
stdout.write(` ${cur.help}\n`);
|
|
262
|
+
stdout.write(` ${[cur.help, rows[active]!.idle].filter(Boolean).join(" · ")}\n`);
|
|
256
263
|
}
|
|
257
264
|
stdout.write(`\n ${c.dim(typing
|
|
258
265
|
? "type to filter · enter or ↑↓ keep it · esc clear"
|
package/cli/state.test.ts
CHANGED
|
@@ -7,7 +7,7 @@ import { tmpdir } from "node:os";
|
|
|
7
7
|
import { issueUrl, systemInfo, scrubHome, REPO_URL, home as realHome, trustFolder } from "./state.ts";
|
|
8
8
|
import * as stateModule from "./state.ts";
|
|
9
9
|
import {
|
|
10
|
-
readRuns, readInbox, writeInbox, replaceIdea, moveIdea, abandonRun, allBacklog, backlog,
|
|
10
|
+
readRuns, readInbox, writeInbox, replaceIdea, moveIdea, abandonRun, runToIdea, allBacklog, backlog,
|
|
11
11
|
requestTitle, statusOf, columnOf, parseItem, startedRuns, boardCards, parentOf, addCard, readArchive, setArchived,
|
|
12
12
|
requestOf, agentFlags, plainModel, readNotes, writeNotes, manualCommand, parseNotes, formatNote, appendNote, saveAttachment,
|
|
13
13
|
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,
|
|
@@ -176,6 +176,30 @@ test("backlog: an abandoned run's items are neither listed nor counted", () => {
|
|
|
176
176
|
rmSync(dir, { recursive: true });
|
|
177
177
|
});
|
|
178
178
|
|
|
179
|
+
test("runToIdea: a planning run goes back to its idea, newer than the run", () => {
|
|
180
|
+
const id = "2026-09-04-planning", dir = join(state, "runs", id);
|
|
181
|
+
mkdirSync(dir, { recursive: true });
|
|
182
|
+
writeFileSync(join(dir, "state.json"), JSON.stringify({ id, stage: "plan", awaiting: "approvals.plan" }));
|
|
183
|
+
writeFileSync(join(dir, "00-request.md"), "/kaizen lite export invoices as csv\n");
|
|
184
|
+
const then = new Date(Date.now() - 30_000);
|
|
185
|
+
utimesSync(join(dir, "00-request.md"), then, then);
|
|
186
|
+
writeInbox(state, [...readInbox(state), { status: "started", text: "export invoices", added: Date.now() - 60_000 }, { status: "started", text: "export invoices as csv", notes: "see #12", added: Date.now() - 60_000 }]);
|
|
187
|
+
expect(runToIdea(state, id)).toBeNull();
|
|
188
|
+
expect(JSON.parse(readFileSync(join(dir, "state.json"), "utf8")).stage).toBe("abandoned");
|
|
189
|
+
const cards = boardCards([state], Date.now());
|
|
190
|
+
expect(cards.find((c) => c.kind === "idea" && c.text === "export invoices as csv")).toMatchObject({ status: "idea", notes: "see #12" });
|
|
191
|
+
expect(cards.find((c) => c.kind === "run" && c.id === id)?.archived).toBe(true);
|
|
192
|
+
expect(readInbox(state).filter((l) => l.text === "export invoices as csv").length).toBe(1);
|
|
193
|
+
expect(readInbox(state).find((l) => l.text === "export invoices")?.status).toBe("started");
|
|
194
|
+
expect(runToIdea(state, id)).toMatch(/planning/);
|
|
195
|
+
writeFileSync(join(dir, "state.json"), JSON.stringify({ id, stage: "plan" }));
|
|
196
|
+
rmSync(join(dir, "00-request.md"));
|
|
197
|
+
expect(runToIdea(state, id)).toMatch(/no request/);
|
|
198
|
+
replaceIdea(state, "export invoices", null);
|
|
199
|
+
setArchived(state, id, false);
|
|
200
|
+
rmSync(dir, { recursive: true });
|
|
201
|
+
});
|
|
202
|
+
|
|
179
203
|
test("abandonRun refuses a done run", () => {
|
|
180
204
|
expect(abandonRun(state, "2026-09-02-done", "late")).toMatch(/already done/);
|
|
181
205
|
});
|
package/cli/state.ts
CHANGED
|
@@ -5,7 +5,7 @@ import { existsSync, readFileSync, readdirSync, appendFileSync, writeFileSync, m
|
|
|
5
5
|
import { Database } from "bun:sqlite";
|
|
6
6
|
import { join, dirname, win32, posix } from "node:path";
|
|
7
7
|
import { homedir, arch, cpus, release, totalmem, version as osVersion } from "node:os";
|
|
8
|
-
import { startPty, ptySupported } from "./pty.ts";
|
|
8
|
+
import { startPty, ptySupported, ptyState } from "./pty.ts";
|
|
9
9
|
import { codeDir, projectFromWorktree } from "./worktree.ts";
|
|
10
10
|
|
|
11
11
|
// The real path: process.cwd() is always physical, so a raw $HOME that crosses a
|
|
@@ -1015,6 +1015,32 @@ export function abandonRun(st: string, id: string, why: string): string | null {
|
|
|
1015
1015
|
}
|
|
1016
1016
|
}
|
|
1017
1017
|
|
|
1018
|
+
// A run still planning, back to the idea it came from: abandoned and archived (the
|
|
1019
|
+
// record stays), and the idea open again. Its fresh stamp is newer than the run's
|
|
1020
|
+
// request, so the old run no longer retires it.
|
|
1021
|
+
export function runToIdea(st: string, id: string): string | null {
|
|
1022
|
+
let stage = "";
|
|
1023
|
+
try { stage = JSON.parse(readText(join(st, "runs", id, "state.json"))).stage; } catch {}
|
|
1024
|
+
if (stage !== "plan") return "Only a run still in planning goes back to an idea.";
|
|
1025
|
+
let req = "";
|
|
1026
|
+
try { req = readText(join(st, "runs", id, "00-request.md")).trim(); } catch {}
|
|
1027
|
+
const lines = readInbox(st);
|
|
1028
|
+
// The longest started idea the request holds: "test" must not stand in for "test login".
|
|
1029
|
+
let at = -1;
|
|
1030
|
+
lines.forEach((l, i) => { if (l.status === "started" && normalise(req).includes(normalise(l.text)) && (at < 0 || l.text.length > lines[at]!.text.length)) at = i; });
|
|
1031
|
+
const [first = "", ...rest] = req.split("\n");
|
|
1032
|
+
const idea: InboxLine = at >= 0
|
|
1033
|
+
? { ...lines[at]!, status: "open", added: Date.now() }
|
|
1034
|
+
: { status: "open", text: first.replace(/^\/kaizen(?:-\w+)?\s+(?:(?:lite|full|auto|plan)\s+)?/i, "").trim(), notes: rest.join("\n").trim() || undefined, added: Date.now() };
|
|
1035
|
+
if (!idea.text) return "That run has no request to make an idea of.";
|
|
1036
|
+
const failed = abandonRun(st, id, "Sent back to ideas from the board.");
|
|
1037
|
+
if (failed) return failed;
|
|
1038
|
+
setArchived(st, id, true);
|
|
1039
|
+
if (at >= 0) lines.splice(at, 1);
|
|
1040
|
+
writeInbox(st, [...lines, idea]);
|
|
1041
|
+
return null;
|
|
1042
|
+
}
|
|
1043
|
+
|
|
1018
1044
|
export function replaceIdea(state: string, text: string, next: InboxLine | null) {
|
|
1019
1045
|
const lines = readInbox(state);
|
|
1020
1046
|
const at = lines.findIndex((l) => (l.status === "open" || l.status === "started") && l.text === text);
|
|
@@ -1317,7 +1343,7 @@ function opencodeRows(sql: string, ...args: (string | number)[]): string[] {
|
|
|
1317
1343
|
} catch { return []; }
|
|
1318
1344
|
}
|
|
1319
1345
|
|
|
1320
|
-
export const CLOSE_GRACE_MS =
|
|
1346
|
+
export const CLOSE_GRACE_MS = 60_000;
|
|
1321
1347
|
const sessionsDir = (st: string) => join(st, "sessions");
|
|
1322
1348
|
|
|
1323
1349
|
export function readSessions(st: string): Session[] {
|
|
@@ -1483,7 +1509,11 @@ export function sweepSessions(st: string, now: number) {
|
|
|
1483
1509
|
// Whether the user typed into the session's terminal after `t`: its tty's access time
|
|
1484
1510
|
// moves when input is read. Such a session is the user's now, not the run's to close.
|
|
1485
1511
|
// No tty (Windows, a stranger's PID): false, so the sweep closes as before.
|
|
1512
|
+
// A board card's session is judged by its keystrokes: the page's terminal answers the
|
|
1513
|
+
// agent's focus and query reports on that tty too, which moved its time on every look.
|
|
1486
1514
|
function typedSince(st: string, s: Session, t: number): boolean {
|
|
1515
|
+
const card = ptyState(s.key);
|
|
1516
|
+
if (card) return card.typed > t;
|
|
1487
1517
|
try {
|
|
1488
1518
|
if (process.platform === "win32") return false;
|
|
1489
1519
|
const pid = parseInt(readText(join(sessionsDir(st), s.key + ".pid")));
|
package/cli/web.ts
CHANGED
|
@@ -4,7 +4,7 @@ import { existsSync, readdirSync, statSync, watch, appendFileSync, mkdirSync, cp
|
|
|
4
4
|
import { join, dirname, resolve, sep, basename } 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, moveIdea, abandonRun, launchRun, parseItem, agentChoices, projectOf, KNOWN_AGENTS, plainModel, short, setArchived, writeNotes,
|
|
7
|
+
readInbox, writeInbox, replaceIdea, moveIdea, abandonRun, runToIdea, 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
9
|
reviewFindings, pickFindings, backlogCards, backlogItem, startedBacklog, closeBacklog, markBacklogDone, fixPrompt, approvalPrompt, canApproveAwaiting, nativePath, closeSessions, sweepSessions, issueUrl,
|
|
10
10
|
launchMode, readSessions, ownerOf, runIndex, resumePrompt, runAgent, firstSession, webHosts,
|
|
@@ -209,6 +209,8 @@ export async function web(repoDir: string, opts: Opts = {}) {
|
|
|
209
209
|
const key = c.kind === "run" ? runs.get(`${c.state}\0${c.id}`) : ideaPty.get(`${c.state}\0${c.text}`);
|
|
210
210
|
if (!key || !live.has(key)) return undefined;
|
|
211
211
|
const st = ptyState(key)!;
|
|
212
|
+
// A finished run's agent that has ended has nothing left to show or resume.
|
|
213
|
+
if (!st.alive && (c.status === "done" || c.status === "abandoned")) return undefined;
|
|
212
214
|
return { key, alive: st.alive, asking: st.asking };
|
|
213
215
|
};
|
|
214
216
|
};
|
|
@@ -1140,6 +1142,14 @@ export async function web(repoDir: string, opts: Opts = {}) {
|
|
|
1140
1142
|
: { ok: false, why: r.why, manual: r.manual, agent: r.agent }, r.ok ? 200 : 500);
|
|
1141
1143
|
}
|
|
1142
1144
|
|
|
1145
|
+
if (path === "/to-idea") {
|
|
1146
|
+
const id = String(body.id ?? "");
|
|
1147
|
+
if (!/^[\w.-]+$/.test(id)) return bad("bad run id");
|
|
1148
|
+
const failed = runToIdea(dir, id);
|
|
1149
|
+
changed();
|
|
1150
|
+
return failed ? bad(failed, 409) : json({ ok: true });
|
|
1151
|
+
}
|
|
1152
|
+
|
|
1143
1153
|
if (path === "/abort") {
|
|
1144
1154
|
const id = String(body.id ?? "");
|
|
1145
1155
|
const why = String(body.why ?? "").trim() || "abandoned from the web board";
|
package/package.json
CHANGED
package/skills/kaizen/README.md
CHANGED
|
@@ -207,11 +207,7 @@ the defaults.
|
|
|
207
207
|
| `auto_fix.max_iterations` | `2` | Fix/recheck rounds before whatever is left is escalated to you |
|
|
208
208
|
| `review.memory_max_lines` | `100` | The cap on `memory.md`, which every stage of every run reads. Raise only if the lessons are genuinely all load-bearing |
|
|
209
209
|
| `git.auto_commit` | `false` | `true` commits the files a run changed when it is done, by path. Never pushes |
|
|
210
|
-
| `track.default` | `auto` (inferred) | A repo that is always one kind of work, e.g. a docs repo |
|
|
211
210
|
| `backlog.enabled` | `true` | Set `false` to skip both backlog capture points; the run still executes, just without a `06-backlog.md` |
|
|
212
|
-
| `backlog.file` | `06-backlog.md` | The per-run file name. Rarely. |
|
|
213
|
-
| `backlog.statuses` | `[open, done, rejected]` | Rarely. Only `open` items are printed |
|
|
214
|
-
| `backlog.orphan_file` | `backlog.md` | Where open items go when their run is pruned |
|
|
215
211
|
| `state.keep_runs` | `20` | You want a longer or shorter run history |
|
|
216
212
|
|
|
217
213
|
Mode never overrides a refusal: destructive or irreversible actions still get confirmed
|
package/skills/kaizen/SKILL.md
CHANGED
|
@@ -276,7 +276,10 @@ After a subagent returns, update `state.json` yourself in the main thread. Subag
|
|
|
276
276
|
report; the main thread owns the state machine.
|
|
277
277
|
Before moving any run from `build` to `review`, under either runner, apply the
|
|
278
278
|
*Preview gate* in [`spec-main.md`](spec-main.md): no `## Preview` with a screenshot
|
|
279
|
-
(or a one-line reason there is none) means the build is not done.
|
|
279
|
+
(or a one-line reason there is none) means the build is not done. Before the final
|
|
280
|
+
report and before `stage: "done"`, under either runner and in every mode, apply the
|
|
281
|
+
*Docs gate* there too: the project's docs vault is brought up to date with what the
|
|
282
|
+
run changed.
|
|
280
283
|
|
|
281
284
|
**Name files, do not narrate them.** The prompt says which paths to read, which
|
|
282
285
|
decisions bind the stage, and what to report back. It does not summarize those files:
|