kaizen-agent 1.43.0 → 1.43.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/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.ts +2 -1
- package/cli/settings.test.ts +38 -0
- package/cli/settings.ts +37 -30
- package/cli/state.test.ts +25 -1
- package/cli/state.ts +26 -0
- package/cli/web.ts +9 -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 +1 -50
- package/skills/kaizen/docs.md +3 -2
- package/skills/kaizen/settings.md +10 -3
- package/skills/kaizen/spec-build.md +3 -2
- package/skills/kaizen/spec-main.md +40 -5
- package/skills/kaizen/spec-review.md +2 -1
- package/skills/kaizen-help/SKILL.md +0 -4
- 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 +279 -0
- package/web/board/views.ts +19 -5
- package/web/board.html +28 -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.ts
CHANGED
|
@@ -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;
|
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
|
@@ -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);
|
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,
|
|
@@ -1140,6 +1140,14 @@ export async function web(repoDir: string, opts: Opts = {}) {
|
|
|
1140
1140
|
: { ok: false, why: r.why, manual: r.manual, agent: r.agent }, r.ok ? 200 : 500);
|
|
1141
1141
|
}
|
|
1142
1142
|
|
|
1143
|
+
if (path === "/to-idea") {
|
|
1144
|
+
const id = String(body.id ?? "");
|
|
1145
|
+
if (!/^[\w.-]+$/.test(id)) return bad("bad run id");
|
|
1146
|
+
const failed = runToIdea(dir, id);
|
|
1147
|
+
changed();
|
|
1148
|
+
return failed ? bad(failed, 409) : json({ ok: true });
|
|
1149
|
+
}
|
|
1150
|
+
|
|
1143
1151
|
if (path === "/abort") {
|
|
1144
1152
|
const id = String(body.id ?? "");
|
|
1145
1153
|
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:
|
|
@@ -58,45 +58,7 @@ auto_fix:
|
|
|
58
58
|
# knob in the file. A third round rarely closes what two could not.
|
|
59
59
|
max_iterations: 2
|
|
60
60
|
|
|
61
|
-
track:
|
|
62
|
-
# The kind of work a run is doing, which decides what "deliverable",
|
|
63
|
-
# "verification", and "finding" mean for every stage. It is INFERRED from the
|
|
64
|
-
# request by the planner and stated back in the plan's Framing for the user to
|
|
65
|
-
# correct at the approval — the user is never asked to categorize their own request.
|
|
66
|
-
# Set this only to pin a default for a repo that is always one kind of work
|
|
67
|
-
# (e.g. a docs repo). auto (or empty) infers per run.
|
|
68
|
-
default: auto
|
|
69
|
-
|
|
70
61
|
review:
|
|
71
|
-
# Checks that run in every track.
|
|
72
|
-
checks:
|
|
73
|
-
- correctness
|
|
74
|
-
- plan_conformance
|
|
75
|
-
# Additional checks per track, chosen by what the run actually is. The planner may
|
|
76
|
-
# name a track not listed here; the reviewer then derives its checks from the
|
|
77
|
-
# track's "what would make it wrong".
|
|
78
|
-
# Each check below is one question the reviewer asks; the line beside it says which.
|
|
79
|
-
track_checks:
|
|
80
|
-
# security - no new way in, no secret or user data exposed
|
|
81
|
-
# reuse_simplification - nothing rebuilt that the codebase already had
|
|
82
|
-
# test_coverage - the changed behaviour has a check that fails when it breaks
|
|
83
|
-
software: [security, reuse_simplification, test_coverage]
|
|
84
|
-
# blast_radius and regression are universal — see spec-review.md — not listed here.
|
|
85
|
-
# completeness - every part the piece promises is actually present
|
|
86
|
-
# claim_accuracy - every checkable claim traced to a source
|
|
87
|
-
# reader_usability - its intended reader can act on it without asking
|
|
88
|
-
writing: [completeness, claim_accuracy, reader_usability]
|
|
89
|
-
# audience_fit - written for the people who will actually read it
|
|
90
|
-
# voice_consistency - reads as the same voice as the surrounding material
|
|
91
|
-
communication: [claim_accuracy, audience_fit, voice_consistency]
|
|
92
|
-
# prerequisites - everything needed before step one is stated
|
|
93
|
-
# rollback - a way back exists for every step that changes something
|
|
94
|
-
# executability - someone else can run it start to finish unaided
|
|
95
|
-
operations: [prerequisites, rollback, executability]
|
|
96
|
-
# evidence_support - the conclusion follows from evidence shown, not asserted
|
|
97
|
-
# selection_bias - the evidence was not picked to fit the answer
|
|
98
|
-
# alternatives - the readings that were rejected are named and why
|
|
99
|
-
research: [evidence_support, selection_bias, alternatives]
|
|
100
62
|
# Let the reviewer append durable lessons to .kaizen/memory.md.
|
|
101
63
|
write_memory: true
|
|
102
64
|
# Every stage of every future run reads memory.md, so cap it. Over this, the reviewer
|
|
@@ -108,20 +70,12 @@ backlog:
|
|
|
108
70
|
# not take, findings below the fix threshold, and debts deferred at an approval.
|
|
109
71
|
# Written by the main thread at the plan approval and again at the final approval.
|
|
110
72
|
enabled: true
|
|
111
|
-
# Per run, beside its plan and review — provenance without a central file to sync.
|
|
112
|
-
file: 06-backlog.md
|
|
113
|
-
# Items are open | done | rejected. Only open ones are printed by /kaizen backlog;
|
|
114
|
-
# closed ones stay in their run's file as record.
|
|
115
|
-
statuses: [open, done, rejected]
|
|
116
|
-
# Before state.keep_runs prunes a run, its still-open items move here, relative to
|
|
117
|
-
# state.dir. Done and rejected items are pruned with the run.
|
|
118
|
-
orphan_file: backlog.md
|
|
119
73
|
|
|
120
74
|
state:
|
|
121
75
|
dir: .kaizen
|
|
122
76
|
# Keep this many completed runs before pruning the oldest. `kaizen prune` lists
|
|
123
77
|
# what would go and `kaizen prune --yes` deletes it, after rescuing open backlog
|
|
124
|
-
# items into backlog.
|
|
78
|
+
# items into backlog.md beside memory.md, so it never destroys open work.
|
|
125
79
|
keep_runs: 20
|
|
126
80
|
|
|
127
81
|
git:
|
|
@@ -130,9 +84,6 @@ git:
|
|
|
130
84
|
# pushes. false: never commit, push, or open a PR unless the user explicitly asks.
|
|
131
85
|
# Ignored when the project is not a git repository.
|
|
132
86
|
auto_commit: false
|
|
133
|
-
# Create a branch before the implement stage when on the default branch.
|
|
134
|
-
# Ignored when the project is not a git repository.
|
|
135
|
-
branch_before_implement: true
|
|
136
87
|
# Build each run on its own branch (kaizen/<run-id>) in its own git worktree under
|
|
137
88
|
# .kaizen/wt/, so runs and people working at the same time never share a folder. Done
|
|
138
89
|
# lands the branch by pull request (gh) or a rebase and push. Ignored when the
|
package/skills/kaizen/docs.md
CHANGED
|
@@ -1,8 +1,9 @@
|
|
|
1
1
|
# Kaizen — docs
|
|
2
2
|
|
|
3
3
|
Read for `/kaizen docs` and `/kaizen docs lint`. Nothing else needs this file, except
|
|
4
|
-
the
|
|
5
|
-
during a run.
|
|
4
|
+
the paragraphs in `spec-build.md` and `spec-review.md` that keep the vault current
|
|
5
|
+
during a run, and the *Docs gate* in `spec-main.md`, which reads *Update* step 4 and
|
|
6
|
+
*Lint* before a run is done.
|
|
6
7
|
|
|
7
8
|
`/kaizen docs` writes a project's documentation into the notebook as a small vault an
|
|
8
9
|
agent reads before it plans or builds: an **Index** listing every note with one line on
|
|
@@ -23,17 +23,24 @@ nearest first, so a change lands globally unless this project keeps its own conf
|
|
|
23
23
|
| `auto_fix.max_iterations` | `1`–`5` | Fix and recheck rounds before what is left is escalated |
|
|
24
24
|
| `review.write_memory` | `true`, `false` | Let the reviewer append lessons to `memory.md` |
|
|
25
25
|
| `git.auto_commit` | `true`, `false` | Commit the work when a run finishes |
|
|
26
|
-
| `git.branch_before_implement` | `true`, `false` | Branch before building when on the default branch |
|
|
27
26
|
| `git.worktree_per_run` | `true`, `false` | Build each run on its own branch in its own worktree and land it by pull request |
|
|
28
27
|
| `ui.mouse` | `false`, `true` | Click to select, click again to act; while on, the terminal cannot select text |
|
|
29
28
|
| `subagents.model` | `inherit`, `opus`, `sonnet`, `haiku` | Model the stage subagents run on (Claude Code only) |
|
|
30
29
|
| `subagents.effort` | `inherit`, `low`, `medium`, `high` | Reasoning effort for the stage subagents (Claude Code only) |
|
|
31
30
|
| `sources.every` | `off`, `1h`, `6h`, `24h` | How often the web board checks request sources for new requests, into the icebox, while it runs |
|
|
32
|
-
| `sources.agent` | `ask`, `chatgpt`, `agent` | Who
|
|
31
|
+
| `sources.agent` | `ask`, `chatgpt`, `agent` | Who picks the posts that fit from social sources when Gather is pressed: the ChatGPT plan signed in from the Icebox, or your agent on `/kaizen gather` |
|
|
33
32
|
| `sources.model` | text | Model the ChatGPT plan gathers with; empty takes the first it lists |
|
|
34
33
|
| `sources.effort` | `low`, `medium`, `high` | Reasoning effort of that model while gathering |
|
|
35
34
|
| `agent.default` | `auto`, `claude`, `codex`, `agy`, `opencode`, `gemini` | Coding agent to launch from dashboard backlog |
|
|
36
35
|
|
|
36
|
+
Some settings only have an effect while another stands a certain way: the approvals
|
|
37
|
+
while `mode` is `approve`, `build.executor` and `subagents.*` while `runner` is `full`,
|
|
38
|
+
`auto_fix.min_severity` and `auto_fix.max_iterations` while `auto_fix.enabled` is
|
|
39
|
+
`true`, `sources.model` and `sources.effort` while `sources.agent` is `chatgpt`. When
|
|
40
|
+
printing one of these that has no effect now, say so beside its value. Still offer it:
|
|
41
|
+
a value set now applies once the other setting changes, and `/kaizen full` overrides
|
|
42
|
+
the runner for one run.
|
|
43
|
+
|
|
37
44
|
Rules for writing the file:
|
|
38
45
|
|
|
39
46
|
- **Change the value in place.** `config.yml` ships with a comment above every key
|
|
@@ -46,7 +53,7 @@ Rules for writing the file:
|
|
|
46
53
|
- After writing, print the setting and its new value, and nothing else. No summary of
|
|
47
54
|
the whole file.
|
|
48
55
|
|
|
49
|
-
Settings not in the table —
|
|
56
|
+
Settings not in the table — such as `state.dir` and the per-tool
|
|
50
57
|
`agent.<cmd>.model` / `agent.<cmd>.effort` the board launches a session with — are edited by
|
|
51
58
|
hand in `config.yml`, which is commented throughout. Say so rather than offering a
|
|
52
59
|
picker with thirty options.
|
|
@@ -86,8 +86,9 @@ the note, correct what the change made untrue or left out — never the `## Note
|
|
|
86
86
|
section, which is the user's — and set its `updated` to today. Refresh the note's line
|
|
87
87
|
in `Index.md` if its `description` changed, and append one line to `Log.md`:
|
|
88
88
|
`- YYYY-MM-DD run <id> — <notes touched>`. Name the notes touched in `03-impl.md`. A
|
|
89
|
-
change no note covers is not written up here:
|
|
90
|
-
|
|
89
|
+
change no note covers is not written up here: list it in `03-impl.md` as "no note
|
|
90
|
+
covers `<path>`" — the main thread's *Docs gate* in `spec-main.md` documents it before
|
|
91
|
+
the run is done. No Index, nothing to do.
|
|
91
92
|
|
|
92
93
|
**Read the plan's File manifest instead of searching.** The planner already located
|
|
93
94
|
every path this run touches. Search the material only for what the manifest does not
|