kaizen-agent 1.5.3 → 1.5.5
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 +8 -4
- package/agents/kaizen-builder.md +2 -0
- package/agents/kaizen-planner.md +9 -2
- package/cli/dashboard.ts +15 -4
- package/cli/install.ts +37 -1
- package/cli/settings.ts +37 -0
- package/cli/state.test.ts +79 -1
- package/cli/state.ts +136 -14
- package/cli/web.ts +163 -10
- package/package.json +1 -1
- package/skills/kaizen/SKILL.md +6 -2
- package/skills/kaizen/spec-build.md +6 -0
- package/skills/kaizen/spec-plan.md +17 -3
- package/skills/kaizen/spec.md +6 -1
- package/web/board.html +133 -50
package/README.md
CHANGED
|
@@ -288,14 +288,18 @@ serves the same board on `http://127.0.0.1:7420/` — loopback only, no account
|
|
|
288
288
|
opens it in a Chromium-family browser as an app window when one is installed, otherwise
|
|
289
289
|
in a tab.
|
|
290
290
|
|
|
291
|
-
<img src="https://raw.githubusercontent.com/hfadhlullah/kaizen/main/assets/web
|
|
291
|
+
<img src="https://raw.githubusercontent.com/hfadhlullah/kaizen/main/assets/board-web.png" alt="kaizen web board" width="760">
|
|
292
292
|
|
|
293
293
|
Same columns, same files: an idea added here lands in `inbox.md`, a run
|
|
294
294
|
started here opens your agent in a new terminal exactly as `r` does, and the board
|
|
295
295
|
redraws as your agents write to disk. Mouse first; the TUI's keys still work. Click a card for its plan, review and
|
|
296
|
-
backlog; `⋯` on a card runs, edits, rejects or
|
|
297
|
-
|
|
298
|
-
|
|
296
|
+
backlog; `⋯` on a card runs, edits, rejects, abandons or archives. `Clear` on Done sends
|
|
297
|
+
every finished run to the archive (`.kaizen/archive.md`, a list, nothing moves), and
|
|
298
|
+
`Archive` in the strip shows what is there, with `Restore` on each card. The gear at the top right holds the browser's theme
|
|
299
|
+
and every `kaizen settings` knob, same rows, same `config.yml`. `--port N` picks a port, `--no-open` just
|
|
300
|
+
serves, `?all=1` in the URL opens on every project. `--daemon` starts the server
|
|
301
|
+
detached so it outlives the terminal (pid kept in `~/.kaizen/web.pid`); `--stop` ends it. `kaizen web --shortcut` adds a launcher
|
|
302
|
+
the OS can find — Spotlight on macOS, the app menu on Linux, a Desktop shortcut on Windows.
|
|
299
303
|
|
|
300
304
|
### Mouse
|
|
301
305
|
|
package/agents/kaizen-builder.md
CHANGED
|
@@ -67,6 +67,8 @@ change — do not refactor around it. Write what you changed to
|
|
|
67
67
|
- **Baseline / after** — the checks run before and after, with both results verbatim.
|
|
68
68
|
- **Verification** — each check carried out, with its actual result. Quote failures.
|
|
69
69
|
- **Not done** — steps skipped, out-of-scope observations, known gaps.
|
|
70
|
+
- **Preview** — how to see the result: commands to run it locally and the URL for an
|
|
71
|
+
app; the path to open, or the result itself, for anything else. The board shows it.
|
|
70
72
|
|
|
71
73
|
## Report back
|
|
72
74
|
|
package/agents/kaizen-planner.md
CHANGED
|
@@ -14,8 +14,9 @@ execute without you present, so anything you leave implicit will be guessed at.
|
|
|
14
14
|
You will be given a run directory path. Your plan carries a **File manifest**: every
|
|
15
15
|
path this run will read or change, flat. The builder and the reviewer read that list
|
|
16
16
|
instead of searching the material a second and third time, so a path you leave out is
|
|
17
|
-
work done twice. Read `00-request.md` from it,
|
|
18
|
-
|
|
17
|
+
work done twice. Read `00-request.md` from it, `notes.md`
|
|
18
|
+
beside it if it exists (context the user attached from the board; treat it as part of
|
|
19
|
+
the request), and `.kaizen/memory.md` if it exists, before touching the codebase.
|
|
19
20
|
|
|
20
21
|
**You do not write code.** No source edits, no installs, no migrations, no
|
|
21
22
|
`git` state changes. The only file you write is `01-plan.md` in the run directory.
|
|
@@ -139,6 +140,12 @@ absorb.
|
|
|
139
140
|
Assumptions that do not change the shape of the plan go in *Open questions* and are
|
|
140
141
|
resolved at the approval, alongside everything else. Do not stop the run for them.
|
|
141
142
|
|
|
143
|
+
**Except when building from nothing.** A new app or product has no material to
|
|
144
|
+
answer platform, users and scale, stack, distribution, access, data, and hosting — and
|
|
145
|
+
a wrong answer there is a rebuild, not a finding. Decide each one in the plan: from
|
|
146
|
+
the request, by proposal with a reason, or as an *Open question* with options where
|
|
147
|
+
the builds differ. Ask them together, once, before any step is written.
|
|
148
|
+
|
|
142
149
|
## Method
|
|
143
150
|
|
|
144
151
|
1. Read the request verbatim. Note what is asked and what is merely implied.
|
package/cli/dashboard.ts
CHANGED
|
@@ -8,7 +8,7 @@ import {
|
|
|
8
8
|
searchRoots, knownProjects, remember, findProjects, locate,
|
|
9
9
|
readRuns, backlog, version, label, tilde, section, readFindings, parseItem, tidy, readBacklog, allBacklog,
|
|
10
10
|
startedRuns, normalise, statusOf, columnOf, readInbox, writeInbox, notify, short,
|
|
11
|
-
abandonRun, replaceIdea, boardCards, launchRun,
|
|
11
|
+
abandonRun, replaceIdea, boardCards, launchRun, appendNote, parseNotes,
|
|
12
12
|
} from "./state.ts";
|
|
13
13
|
const rgb = (r: number, g: number, b: number, s: string) =>
|
|
14
14
|
`\x1b[38;2;${r};${g};${b}m${s}\x1b[0m`;
|
|
@@ -502,6 +502,7 @@ export async function dashboard(
|
|
|
502
502
|
const now = Date.now();
|
|
503
503
|
const next: Drawn[] = [];
|
|
504
504
|
for (const k of boardCards(states(), now)) {
|
|
505
|
+
if (k.archived) continue; // cleared from the board; kaizen web shows the archive
|
|
505
506
|
if (k.kind === "run") {
|
|
506
507
|
const key = `${k.state}/${k.id}`;
|
|
507
508
|
const before = wasAwaiting.get(key);
|
|
@@ -539,6 +540,8 @@ export async function dashboard(
|
|
|
539
540
|
lines.push(`${marker} ${dot} ${painted}`);
|
|
540
541
|
}
|
|
541
542
|
if (allProjects && k.where !== "global") lines.push(" " + c.dim(cut(basename(k.where), inner - 4)));
|
|
543
|
+
// First note line only; the rest is for the web board's detail panel.
|
|
544
|
+
if (k.notes) { const n = parseNotes(k.notes); const l = n[n.length - 1]?.text.split("\n")[0]; if (l) lines.push(" " + c.dim(cut(l, inner - 4))); }
|
|
542
545
|
lines.push("");
|
|
543
546
|
}
|
|
544
547
|
return { meta, lines, starts };
|
|
@@ -586,8 +589,8 @@ export async function dashboard(
|
|
|
586
589
|
// space rather than by middots, which made one long undifferentiated line.
|
|
587
590
|
const footer = (k?: Card) => {
|
|
588
591
|
const acts = k?.kind === "idea"
|
|
589
|
-
? "n new e edit x reject d delete r run"
|
|
590
|
-
: k ? "n new idea x abandon this run" : "n new idea";
|
|
592
|
+
? "n new e edit t note x reject d delete r run"
|
|
593
|
+
: k ? "n new idea t note x abandon this run" : "n new idea";
|
|
591
594
|
const scope = allProjects ? "a this project" : "a all projects";
|
|
592
595
|
return ` ${c.dim("↑↓←→ move")} ${c.dim(acts)} ${c.dim(scope)} ${c.dim("q back")}\n`;
|
|
593
596
|
};
|
|
@@ -703,6 +706,11 @@ export async function dashboard(
|
|
|
703
706
|
// abandoning one is the user's decision to make, and /kaizen abort is the
|
|
704
707
|
// same single transition this writes.
|
|
705
708
|
if (target.kind === "run") {
|
|
709
|
+
if (key === "t" && target.id && target.column < 4) {
|
|
710
|
+
const line = await promptLine("Add a note", "");
|
|
711
|
+
if (line) appendNote(target.state, { id: target.id }, line);
|
|
712
|
+
refresh();
|
|
713
|
+
}
|
|
706
714
|
if (key === "x" && target.id) {
|
|
707
715
|
const why = await promptLine(`Abandon ${target.text}? Reason`, "");
|
|
708
716
|
if (why) {
|
|
@@ -716,6 +724,9 @@ export async function dashboard(
|
|
|
716
724
|
if (key === "e") {
|
|
717
725
|
const text = await promptLine("Edit idea", target.text);
|
|
718
726
|
if (text) replaceIdea(target.state, target.text, { status: "open", text });
|
|
727
|
+
} else if (key === "t" && target.status === "idea") {
|
|
728
|
+
const line = await promptLine("Add a note", "");
|
|
729
|
+
if (line) appendNote(target.state, { text: target.text }, line);
|
|
719
730
|
} else if (key === "x") {
|
|
720
731
|
const why = await promptLine(`Reject: ${target.text}`, "");
|
|
721
732
|
// The spec requires a reason on a rejected item; no reason, no rejection.
|
|
@@ -728,7 +739,7 @@ export async function dashboard(
|
|
|
728
739
|
// the prompt the agent is launched with.
|
|
729
740
|
if (mode !== null) {
|
|
730
741
|
const request = `${mode} ${target.text}`.trim();
|
|
731
|
-
await outside(() => startBacklogItem(parseItem(request), target.state));
|
|
742
|
+
await outside(() => startBacklogItem({ ...parseItem(request), notes: target.notes }, target.state));
|
|
732
743
|
// Without this the idea stays open forever and the board shows the
|
|
733
744
|
// same work twice: once as an idea, once as the run it became.
|
|
734
745
|
replaceIdea(target.state, target.text, { status: "started", text: target.text });
|
package/cli/install.ts
CHANGED
|
@@ -210,9 +210,45 @@ if (wantSettings) {
|
|
|
210
210
|
}
|
|
211
211
|
|
|
212
212
|
if (wantWeb) {
|
|
213
|
-
const {
|
|
213
|
+
if (args.has("--shortcut")) { const { shortcut } = await import("./web.ts"); console.log(`\n kaizen web shortcut ${await shortcut()}\n`); process.exit(0); }
|
|
214
214
|
const at = Bun.argv.indexOf("--port");
|
|
215
215
|
const port = at !== -1 ? Number(Bun.argv[at + 1]) || undefined : undefined;
|
|
216
|
+
const url = `http://127.0.0.1:${port ?? 7420}/`;
|
|
217
|
+
const pidFile = join(home, ".kaizen", "web.pid");
|
|
218
|
+
const alive = async () => { try { await fetch(url); return true; } catch { return false; } };
|
|
219
|
+
|
|
220
|
+
if (args.has("--stop")) {
|
|
221
|
+
const pid = existsSync(pidFile) ? Number(readFileSync(pidFile, "utf8")) : NaN;
|
|
222
|
+
if (!pid) { console.log(" no kaizen web daemon recorded"); process.exit(0); }
|
|
223
|
+
try { process.kill(pid, "SIGTERM"); console.log(` stopped kaizen web (pid ${pid})`); }
|
|
224
|
+
catch { console.log(` pid ${pid} already gone; cleared the stale record`); }
|
|
225
|
+
rmSync(pidFile, { force: true });
|
|
226
|
+
process.exit(0);
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
if (args.has("--daemon")) {
|
|
230
|
+
// The daemon is this same command, detached, with the browser left to us:
|
|
231
|
+
// ignored stdio is what lets it outlive the terminal.
|
|
232
|
+
if (await alive()) {
|
|
233
|
+
console.log(` kaizen web already running at ${url}`);
|
|
234
|
+
} else {
|
|
235
|
+
const argv = Bun.argv.slice(2).filter((a) => a !== "--daemon" && a !== "--no-open");
|
|
236
|
+
const child = Bun.spawn([process.execPath, Bun.main, ...argv, "--no-open"], {
|
|
237
|
+
detached: true, stdio: ["ignore", "ignore", "ignore"],
|
|
238
|
+
});
|
|
239
|
+
child.unref();
|
|
240
|
+
mkdirSync(dirname(pidFile), { recursive: true });
|
|
241
|
+
writeFileSync(pidFile, String(child.pid));
|
|
242
|
+
for (let i = 0; i < 30 && !(await alive()); i++) await Bun.sleep(100);
|
|
243
|
+
if (!(await alive())) { console.log(` kaizen web did not come up on ${url}`); process.exit(1); }
|
|
244
|
+
console.log(`\n kaizen web ${url} (pid ${child.pid}, detached)`);
|
|
245
|
+
console.log(` kaizen web --stop to stop it\n`);
|
|
246
|
+
}
|
|
247
|
+
if (!args.has("--no-open")) await openUrl(url);
|
|
248
|
+
process.exit(0);
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
const { web } = await import("./web.ts");
|
|
216
252
|
await web(await resolveRepoQuietly(), { port, open: !args.has("--no-open") });
|
|
217
253
|
process.exit(0);
|
|
218
254
|
}
|
package/cli/settings.ts
CHANGED
|
@@ -113,6 +113,43 @@ const SETTINGS: Setting[] = [
|
|
|
113
113
|
{ key: "agent.default", values: ["auto", "claude", "codex", "agy", "opencode", "gemini"], help: "Default coding agent to launch from dashboard backlog" },
|
|
114
114
|
];
|
|
115
115
|
|
|
116
|
+
// The same rows and the same write the TUI uses, for `kaizen web`'s settings panel:
|
|
117
|
+
// one place decides what a setting is called, what it may be, and how it is saved.
|
|
118
|
+
export function listSettings(repoRoot: string) {
|
|
119
|
+
const file = locate();
|
|
120
|
+
const defaults = readFileSync(join(repoRoot, "skills/kaizen/config.default.yml"), "utf8");
|
|
121
|
+
const text = existsSync(file) ? readFileSync(file, "utf8") : "";
|
|
122
|
+
const preset = detectPreset(text, defaults);
|
|
123
|
+
return {
|
|
124
|
+
file: tilde(file),
|
|
125
|
+
rows: SETTINGS.map((s) => ({
|
|
126
|
+
...s,
|
|
127
|
+
value: s.key === "preset" ? preset : read(text, s.key) ?? read(defaults, s.key) ?? s.values[0]!,
|
|
128
|
+
})),
|
|
129
|
+
};
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
export function setSetting(repoRoot: string, key: string, value: string) {
|
|
133
|
+
const s = SETTINGS.find((x) => x.key === key);
|
|
134
|
+
if (!s || !s.values.includes(value)) return { error: "unknown setting or value" };
|
|
135
|
+
const file = locate();
|
|
136
|
+
const defaultsPath = join(repoRoot, "skills/kaizen/config.default.yml");
|
|
137
|
+
if (!existsSync(file)) { mkdirSync(dirname(file), { recursive: true }); copyFileSync(defaultsPath, file); }
|
|
138
|
+
const defaults = readFileSync(defaultsPath, "utf8");
|
|
139
|
+
const text = readFileSync(file, "utf8");
|
|
140
|
+
let updated: string;
|
|
141
|
+
if (key === "preset") {
|
|
142
|
+
if (value === "custom") return { error: "custom is what any other change makes" };
|
|
143
|
+
updated = applyPreset(text, value as PresetName, defaults);
|
|
144
|
+
} else {
|
|
145
|
+
updated = write(text, key, value, defaults);
|
|
146
|
+
updated = write(updated, "preset", detectPreset(updated, defaults), defaults);
|
|
147
|
+
if (read(updated, key) !== value) return { error: `${key} did not save; edit ${tilde(file)} by hand` };
|
|
148
|
+
}
|
|
149
|
+
writeFileSync(file, updated);
|
|
150
|
+
return { ok: true };
|
|
151
|
+
}
|
|
152
|
+
|
|
116
153
|
const c = {
|
|
117
154
|
dim: (s: string) => `\x1b[2m${s}\x1b[0m`,
|
|
118
155
|
bold: (s: string) => `\x1b[1m${s}\x1b[0m`,
|
package/cli/state.test.ts
CHANGED
|
@@ -6,7 +6,8 @@ import { join } from "node:path";
|
|
|
6
6
|
import { tmpdir } from "node:os";
|
|
7
7
|
import {
|
|
8
8
|
readRuns, readInbox, writeInbox, replaceIdea, abandonRun, allBacklog, backlog,
|
|
9
|
-
statusOf, columnOf, parseItem, startedRuns, boardCards,
|
|
9
|
+
statusOf, columnOf, parseItem, startedRuns, boardCards, readArchive, setArchived,
|
|
10
|
+
requestOf, readNotes, writeNotes, manualCommand, parseNotes, formatNote, appendNote, saveAttachment,
|
|
10
11
|
} from "./state.ts";
|
|
11
12
|
|
|
12
13
|
let state: string;
|
|
@@ -104,3 +105,80 @@ test("parseItem: finding line splits into where, severity, text", () => {
|
|
|
104
105
|
test("startedRuns: normalised request bodies", () => {
|
|
105
106
|
expect(startedRuns(state).sort()).toEqual(["# request add oauth login", "# request rename cli flags"]);
|
|
106
107
|
});
|
|
108
|
+
|
|
109
|
+
test("archive: a run is listed, hidden by flag, never moved", () => {
|
|
110
|
+
setArchived(state, "2026-09-02-done", true);
|
|
111
|
+
expect([...readArchive(state)]).toEqual(["2026-09-02-done"]);
|
|
112
|
+
const card = boardCards([state], Date.now()).find((k) => k.id === "2026-09-02-done")!;
|
|
113
|
+
expect([card.archived, card.column]).toEqual([true, 4]);
|
|
114
|
+
setArchived(state, "2026-09-02-done", false);
|
|
115
|
+
expect(readArchive(state).size).toBe(0);
|
|
116
|
+
expect(boardCards([state], Date.now()).find((k) => k.id === "2026-09-02-done")!.archived).toBe(false);
|
|
117
|
+
});
|
|
118
|
+
|
|
119
|
+
test("archive: an idea keeps its line with status archived", () => {
|
|
120
|
+
writeInbox(state, [{ status: "open", text: "park this", notes: "kept" }, { status: "archived", text: "parked zq7" }]);
|
|
121
|
+
const ideas = boardCards([state], Date.now()).filter((k) => k.kind === "idea");
|
|
122
|
+
expect(ideas.map((k) => [k.text, k.archived])).toEqual([["park this", false], ["parked zq7", true]]);
|
|
123
|
+
});
|
|
124
|
+
|
|
125
|
+
test("notes: indented lines under a bullet round-trip; a legacy file is untouched", () => {
|
|
126
|
+
const legacy = "# Inbox\n\nIdeas with no run yet. One line each, same format as a run's backlog.\n\n- open: a\n- rejected: b | why\n";
|
|
127
|
+
writeFileSync(join(state, "inbox.md"), legacy);
|
|
128
|
+
writeInbox(state, readInbox(state));
|
|
129
|
+
expect(readFileSync(join(state, "inbox.md"), "utf8")).toBe(legacy);
|
|
130
|
+
writeInbox(state, [{ status: "open", text: "with notes", notes: "see https://x.y/z\ncli/state.ts:12 | not a reason" }, { status: "open", text: "plain zq8" }]);
|
|
131
|
+
const [a, b] = readInbox(state);
|
|
132
|
+
expect(a).toEqual({ status: "open", text: "with notes", notes: "see https://x.y/z\ncli/state.ts:12 | not a reason" });
|
|
133
|
+
expect(b).toEqual({ status: "open", text: "plain zq8" });
|
|
134
|
+
});
|
|
135
|
+
|
|
136
|
+
test("notes: survive reject and started; edit can replace or clear them", () => {
|
|
137
|
+
replaceIdea(state, "with notes", { status: "started", text: "with notes" });
|
|
138
|
+
expect(readInbox(state)[0]!.notes).toContain("https://x.y/z");
|
|
139
|
+
replaceIdea(state, "with notes", { status: "rejected", text: "with notes | no" });
|
|
140
|
+
expect(readInbox(state)[0]!.notes).toContain("https://x.y/z");
|
|
141
|
+
replaceIdea(state, "plain zq8", { status: "open", text: "plain zq8", notes: "" });
|
|
142
|
+
expect(readInbox(state)[1]!.notes).toBeUndefined();
|
|
143
|
+
expect(boardCards([state], Date.now()).find((k) => k.text === "plain zq8")!.notes).toBeUndefined();
|
|
144
|
+
});
|
|
145
|
+
|
|
146
|
+
test("notes: the prompt is text, blank line, notes; still one quoted argument", () => {
|
|
147
|
+
expect(requestOf("/kaizen do x")).toBe("/kaizen do x");
|
|
148
|
+
expect(requestOf("/kaizen do x", "l1\nl2\n")).toBe("/kaizen do x\n\nl1\nl2");
|
|
149
|
+
const cmd = manualCommand("/p", "claude", requestOf("/kaizen do x", "l1"));
|
|
150
|
+
if (process.platform !== "win32") expect(cmd).toBe('cd "/p" && claude "/kaizen do x\n\nl1"');
|
|
151
|
+
});
|
|
152
|
+
|
|
153
|
+
test("notes: a run's notes.md is read beside it, written only for a real run", () => {
|
|
154
|
+
expect(writeNotes(state, "2026-09-09-nope", "x")).toBe("no such run");
|
|
155
|
+
expect(writeNotes(state, "2026-09-01-waiting", " ref: docs/a.md ")).toBeNull();
|
|
156
|
+
expect(readNotes(state, "2026-09-01-waiting")).toBe("ref: docs/a.md\n");
|
|
157
|
+
expect(boardCards([state], Date.now()).find((k) => k.id === "2026-09-01-waiting")!.notes).toBe("ref: docs/a.md\n");
|
|
158
|
+
writeNotes(state, "2026-09-01-waiting", "");
|
|
159
|
+
expect(readNotes(state, "2026-09-01-waiting")).toBe("");
|
|
160
|
+
});
|
|
161
|
+
|
|
162
|
+
test("notes log: stamped entries, continuation lines, unstamped preamble", () => {
|
|
163
|
+
const src = "hand written\n[2026-09-14 10:00] first\n more\n[2026-09-14 10:05] \n";
|
|
164
|
+
expect(parseNotes(src)).toEqual([
|
|
165
|
+
{ at: "", text: "hand written" },
|
|
166
|
+
{ at: "2026-09-14 10:00", text: "first\nmore" },
|
|
167
|
+
{ at: "2026-09-14 10:05", text: "" },
|
|
168
|
+
]);
|
|
169
|
+
expect(formatNote("a\nb", "2026-09-14 10:00")).toBe("[2026-09-14 10:00] a\n b");
|
|
170
|
+
});
|
|
171
|
+
|
|
172
|
+
test("notes log: append to a run and to an idea; attachments saved and made absolute in the prompt", () => {
|
|
173
|
+
expect(appendNote(state, { id: "2026-09-01-waiting" }, "run note")).toBeNull();
|
|
174
|
+
expect(parseNotes(readNotes(state, "2026-09-01-waiting")).map((n) => n.text)).toEqual(["run note"]);
|
|
175
|
+
writeInbox(state, [{ status: "open", text: "chatty zq6" }]);
|
|
176
|
+
expect(appendNote(state, { text: "chatty zq6" }, "one")).toBeNull();
|
|
177
|
+
expect(appendNote(state, { text: "chatty zq6" }, "two\nlines")).toBeNull();
|
|
178
|
+
expect(appendNote(state, { text: "nope zq6" }, "x")).toBe("no such idea");
|
|
179
|
+
expect(parseNotes(readInbox(state)[0]!.notes!).map((n) => n.text)).toEqual(["one", "two\nlines"]);
|
|
180
|
+
const rel = saveAttachment(state, null, "../evil name.png", new Uint8Array([1]));
|
|
181
|
+
expect(rel).toMatch(/^attachments\/\w+-_evil_name\.png$/);
|
|
182
|
+
expect(requestOf("x", ``, state)).toBe(`x\n\n})`);
|
|
183
|
+
expect(saveAttachment(state, "2026-09-01-waiting", "b.txt", new Uint8Array([2]))).toMatch(/^runs\/2026-09-01-waiting\/attachments\//);
|
|
184
|
+
});
|
package/cli/state.ts
CHANGED
|
@@ -7,7 +7,7 @@ import { homedir } from "node:os";
|
|
|
7
7
|
|
|
8
8
|
export const home = homedir();
|
|
9
9
|
|
|
10
|
-
export type Run = { id: string; stage: string; awaiting: string | null; moved: number };
|
|
10
|
+
export type Run = { id: string; stage: string; awaiting: string | null; moved: number; runner?: string; agent?: string };
|
|
11
11
|
|
|
12
12
|
export const KNOWN_AGENTS = [
|
|
13
13
|
{ name: "Claude Code", dir: ".claude", cmd: "claude" },
|
|
@@ -202,7 +202,7 @@ export function readRuns(state: string): Run[] {
|
|
|
202
202
|
// own mtime is the only evidence that anything is still working on it.
|
|
203
203
|
let moved = 0;
|
|
204
204
|
try { moved = statSync(file).mtimeMs; } catch { /* vanished mid-read */ }
|
|
205
|
-
return [{ id, stage: s.stage ?? "?", awaiting: s.awaiting ?? null, moved }];
|
|
205
|
+
return [{ id, stage: s.stage ?? "?", awaiting: s.awaiting ?? null, moved, runner: s.runner, agent: s.agent }];
|
|
206
206
|
} catch { return []; }
|
|
207
207
|
}).reverse();
|
|
208
208
|
}
|
|
@@ -249,7 +249,7 @@ export function readFindings(file: string) {
|
|
|
249
249
|
// Backlog items are often a reviewer's finding pasted verbatim -- a backticked
|
|
250
250
|
// path, a severity, then the sentence. Split those apart so a list can show the
|
|
251
251
|
// severity and the place as columns and leave the prose to speak for itself.
|
|
252
|
-
export type Item = { severity: string | null; where: string | null; text: string; raw: string };
|
|
252
|
+
export type Item = { severity: string | null; where: string | null; text: string; raw: string; notes?: string };
|
|
253
253
|
|
|
254
254
|
export function parseItem(raw: string): Item {
|
|
255
255
|
const m = /^`?([^`:]+(?::\d+)?)`?\s*[:—-]\s*(critical|high|medium|low)\s*[:—-]\s*(.+)$/i.exec(raw);
|
|
@@ -307,6 +307,9 @@ export type Card = {
|
|
|
307
307
|
dim: boolean;
|
|
308
308
|
moved?: number; // runs only: state.json mtime
|
|
309
309
|
title?: string; // runs only: first line of the request
|
|
310
|
+
archived: boolean; // hidden from the board unless asked for
|
|
311
|
+
tags?: string[]; // runs only: runner and agent, when recorded
|
|
312
|
+
notes?: string; // inbox notes, or runs/<id>/notes.md
|
|
310
313
|
};
|
|
311
314
|
|
|
312
315
|
// A run is called running on the evidence that it moved recently; there is no PID
|
|
@@ -354,17 +357,24 @@ export function columnOf(stage: string) {
|
|
|
354
357
|
return 1;
|
|
355
358
|
}
|
|
356
359
|
|
|
357
|
-
|
|
360
|
+
// notes: free text under the bullet -- links, path:line refs, background. Kept as
|
|
361
|
+
// indented continuation lines so a one-line inbox from before notes reads unchanged.
|
|
362
|
+
export type InboxLine = { status: string; text: string; notes?: string };
|
|
358
363
|
|
|
359
364
|
// Ideas with no run yet. Deliberately not backlog.md, which the spec defines as
|
|
360
365
|
// an orphanage for items rescued from pruned runs, and not a run stub each.
|
|
361
366
|
export function readInbox(state: string): InboxLine[] {
|
|
362
367
|
const f = join(state, "inbox.md");
|
|
363
368
|
if (!existsSync(f)) return [];
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
369
|
+
const out: InboxLine[] = [];
|
|
370
|
+
for (const raw of readFileSync(f, "utf8").split("\n")) {
|
|
371
|
+
const m = /^-\s*(open|started|done|rejected|archived):\s*(.+)$/.exec(raw.trim());
|
|
372
|
+
if (m) { out.push({ status: m[1]!, text: m[2]!.trim() }); continue; }
|
|
373
|
+
// An indented line belongs to the bullet above it; anything else is noise.
|
|
374
|
+
const last = out[out.length - 1];
|
|
375
|
+
if (last && /^\s{2,}\S/.test(raw)) last.notes = (last.notes ? last.notes + "\n" : "") + raw.trim();
|
|
376
|
+
}
|
|
377
|
+
return out;
|
|
368
378
|
}
|
|
369
379
|
|
|
370
380
|
// Whole-file rewrite. The board is the only writer today.
|
|
@@ -377,11 +387,93 @@ export function writeInbox(state: string, lines: InboxLine[]) {
|
|
|
377
387
|
"",
|
|
378
388
|
"Ideas with no run yet. One line each, same format as a run's backlog.",
|
|
379
389
|
"",
|
|
380
|
-
...lines.
|
|
390
|
+
...lines.flatMap((l) => [`- ${l.status}: ${l.text}`, ...noteLines(l.notes)]),
|
|
381
391
|
];
|
|
382
392
|
writeFileSync(join(state, "inbox.md"), body.join("\n") + "\n");
|
|
383
393
|
}
|
|
384
394
|
|
|
395
|
+
function noteLines(notes?: string) {
|
|
396
|
+
return (notes ?? "").split("\n").map((n) => n.trim()).filter(Boolean).map((n) => " " + n);
|
|
397
|
+
}
|
|
398
|
+
|
|
399
|
+
// The request the agent is launched with: the text, then the notes as the rest of the
|
|
400
|
+
// request, so they land verbatim in 00-request.md and the planner reads them.
|
|
401
|
+
export function requestOf(text: string, notes?: string, state?: string) {
|
|
402
|
+
let n = (notes ?? "").trim();
|
|
403
|
+
if (state) n = n.replace(/\]\((attachments\/[^)]+)\)/g, (_, p) => `](${join(state, p)})`);
|
|
404
|
+
return n ? `${text}\n\n${n}` : text;
|
|
405
|
+
}
|
|
406
|
+
|
|
407
|
+
// Free text beside a run -- what the user learned after the idea became a run.
|
|
408
|
+
// Its own file, never state.json: that shape is the resume contract.
|
|
409
|
+
export function readNotes(state: string, id: string) {
|
|
410
|
+
try { return readFileSync(join(state, "runs", id, "notes.md"), "utf8"); } catch { return ""; }
|
|
411
|
+
}
|
|
412
|
+
|
|
413
|
+
export function writeNotes(state: string, id: string, text: string): string | null {
|
|
414
|
+
const dir = join(state, "runs", id);
|
|
415
|
+
if (!existsSync(join(dir, "state.json"))) return "no such run";
|
|
416
|
+
writeFileSync(join(dir, "notes.md"), text.trim() ? text.trim() + "\n" : "");
|
|
417
|
+
return null;
|
|
418
|
+
}
|
|
419
|
+
|
|
420
|
+
// Notes are a log, one entry per message: `[2026-09-14 15:34] text`, continuation
|
|
421
|
+
// lines indented. Text before the first stamp is one unstamped entry, so notes
|
|
422
|
+
// written by hand still read.
|
|
423
|
+
export type Note = { at: string; text: string };
|
|
424
|
+
|
|
425
|
+
export function parseNotes(src: string): Note[] {
|
|
426
|
+
const out: Note[] = [];
|
|
427
|
+
for (const raw of src.split("\n")) {
|
|
428
|
+
const m = /^\[(\d{4}-\d{2}-\d{2} \d{2}:\d{2})\]\s?(.*)$/.exec(raw);
|
|
429
|
+
if (m) { out.push({ at: m[1]!, text: m[2]! }); continue; }
|
|
430
|
+
const last = out[out.length - 1];
|
|
431
|
+
if (last) last.text += "\n" + raw.replace(/^\s{2}/, "");
|
|
432
|
+
else if (raw.trim()) out.push({ at: "", text: raw });
|
|
433
|
+
}
|
|
434
|
+
return out.map((n) => ({ ...n, text: n.text.trim() })).filter((n) => n.text);
|
|
435
|
+
}
|
|
436
|
+
|
|
437
|
+
export function stamp(now = new Date()) {
|
|
438
|
+
const p = (n: number) => String(n).padStart(2, "0");
|
|
439
|
+
return `${now.getFullYear()}-${p(now.getMonth() + 1)}-${p(now.getDate())} ${p(now.getHours())}:${p(now.getMinutes())}`;
|
|
440
|
+
}
|
|
441
|
+
|
|
442
|
+
export function formatNote(text: string, at = stamp()) {
|
|
443
|
+
return `[${at}] ${text.trim().replace(/\n/g, "\n ")}`;
|
|
444
|
+
}
|
|
445
|
+
|
|
446
|
+
// Append one entry: to runs/<id>/notes.md for a run, or under the idea's inbox
|
|
447
|
+
// bullet. Attachments arrive as markdown links already written into text.
|
|
448
|
+
export function appendNote(state: string, target: { id: string } | { text: string }, text: string): string | null {
|
|
449
|
+
if (!text.trim()) return "empty note";
|
|
450
|
+
if ("id" in target) {
|
|
451
|
+
if (!existsSync(join(state, "runs", target.id, "state.json"))) return "no such run";
|
|
452
|
+
appendFileSync(join(state, "runs", target.id, "notes.md"), formatNote(text) + "\n");
|
|
453
|
+
return null;
|
|
454
|
+
}
|
|
455
|
+
const lines = readInbox(state);
|
|
456
|
+
const at = lines.findIndex((l) => l.status === "open" && l.text === target.text);
|
|
457
|
+
if (at < 0) return "no such idea";
|
|
458
|
+
const prev = lines[at]!.notes;
|
|
459
|
+
lines[at] = { ...lines[at]!, notes: (prev ? prev + "\n" : "") + formatNote(text) };
|
|
460
|
+
writeInbox(state, lines);
|
|
461
|
+
return null;
|
|
462
|
+
}
|
|
463
|
+
|
|
464
|
+
// A file beside the notes it belongs to: runs/<id>/attachments/ for a run,
|
|
465
|
+
// <state>/attachments/ for an idea. Returns the path relative to the state dir,
|
|
466
|
+
// which is what the note links and what /file serves.
|
|
467
|
+
// ponytail: no dedupe, no size accounting beyond the request cap in web.ts.
|
|
468
|
+
export function saveAttachment(state: string, id: string | null, name: string, bytes: Uint8Array) {
|
|
469
|
+
const safe = name.replace(/[^\w.-]+/g, "_").replace(/^\.+/, "") || "file";
|
|
470
|
+
const rel = id ? join("runs", id, "attachments") : "attachments";
|
|
471
|
+
mkdirSync(join(state, rel), { recursive: true });
|
|
472
|
+
const file = `${Date.now().toString(36)}-${safe}`;
|
|
473
|
+
writeFileSync(join(state, rel, file), bytes);
|
|
474
|
+
return join(rel, file);
|
|
475
|
+
}
|
|
476
|
+
|
|
385
477
|
// Best effort: a machine without notify-send loses the notification, not the board.
|
|
386
478
|
export function notify(title: string, body: string) {
|
|
387
479
|
try {
|
|
@@ -421,10 +513,34 @@ export function replaceIdea(state: string, text: string, next: InboxLine | null)
|
|
|
421
513
|
const lines = readInbox(state);
|
|
422
514
|
const at = lines.findIndex((l) => (l.status === "open" || l.status === "started") && l.text === text);
|
|
423
515
|
if (at < 0) return; // changed underneath us; the redraw will show why
|
|
424
|
-
if (next) lines[at] = next; else lines.splice(at, 1);
|
|
516
|
+
if (next) lines[at] = { notes: lines[at]!.notes, ...next }; else lines.splice(at, 1);
|
|
425
517
|
writeInbox(state, lines);
|
|
426
518
|
}
|
|
427
519
|
|
|
520
|
+
// Archived runs stay exactly where they are on disk -- run directories are never
|
|
521
|
+
// moved or renamed, that is the resume contract -- and are listed here instead.
|
|
522
|
+
// One id per line; the board hides them unless asked for the archive.
|
|
523
|
+
export function readArchive(state: string): Set<string> {
|
|
524
|
+
const f = join(state, "archive.md");
|
|
525
|
+
if (!existsSync(f)) return new Set();
|
|
526
|
+
return new Set(readFileSync(f, "utf8").split("\n")
|
|
527
|
+
.map((l) => /^-\s*(\S+)/.exec(l.trim())?.[1])
|
|
528
|
+
.filter((x): x is string => !!x));
|
|
529
|
+
}
|
|
530
|
+
|
|
531
|
+
export function writeArchive(state: string, ids: Set<string>) {
|
|
532
|
+
mkdirSync(state, { recursive: true });
|
|
533
|
+
const body = ["# Archive", "", "Runs cleared from the board. One id per line; delete a line to bring it back.", "",
|
|
534
|
+
...[...ids].sort().map((id) => `- ${id}`)];
|
|
535
|
+
writeFileSync(join(state, "archive.md"), body.join("\n") + "\n");
|
|
536
|
+
}
|
|
537
|
+
|
|
538
|
+
export function setArchived(state: string, id: string, archived: boolean) {
|
|
539
|
+
const ids = readArchive(state);
|
|
540
|
+
if (archived) ids.add(id); else ids.delete(id);
|
|
541
|
+
writeArchive(state, ids);
|
|
542
|
+
}
|
|
543
|
+
|
|
428
544
|
// The request's first real line: not a heading, not a label ending in a colon,
|
|
429
545
|
// quote marker dropped. Long enough to recognise the run, short enough for a card.
|
|
430
546
|
export function requestTitle(text: string) {
|
|
@@ -458,16 +574,17 @@ export function boardCards(states: string[], now: number): Card[] {
|
|
|
458
574
|
}
|
|
459
575
|
for (const st of states) {
|
|
460
576
|
const where = label(st);
|
|
577
|
+
const archive = readArchive(st);
|
|
461
578
|
for (const it of readInbox(st)) {
|
|
462
|
-
if (it.status !== "open" && it.status !== "started") continue;
|
|
579
|
+
if (it.status !== "open" && it.status !== "started" && it.status !== "archived") continue;
|
|
463
580
|
// Retired once any run's request contains this text -- whoever started it.
|
|
464
581
|
// Checking `started` items only would leave every idea acted on outside the
|
|
465
582
|
// board sitting in IDEA forever, and typing `/kaizen ...` in a terminal is
|
|
466
583
|
// the common way a run begins.
|
|
467
584
|
if (requests.some((r) => r.includes(normalise(it.text)))) continue;
|
|
468
585
|
next.push(it.status === "started"
|
|
469
|
-
? { kind: "idea", status: "starting", text: it.text, state: st, where, column: 1, awaiting: null, dim: true }
|
|
470
|
-
: { kind: "idea", status: "idea", text: it.text, state: st, where, column: 0, awaiting: null, dim: false });
|
|
586
|
+
? { kind: "idea", status: "starting", text: it.text, state: st, where, column: 1, awaiting: null, dim: true, archived: false, notes: it.notes }
|
|
587
|
+
: { kind: "idea", status: "idea", text: it.text, state: st, where, column: 0, awaiting: null, dim: false, archived: it.status === "archived", notes: it.notes });
|
|
471
588
|
}
|
|
472
589
|
for (const r of readRuns(st)) {
|
|
473
590
|
next.push({
|
|
@@ -475,6 +592,9 @@ export function boardCards(states: string[], now: number): Card[] {
|
|
|
475
592
|
title: requestTitle(body(st, r.id)),
|
|
476
593
|
awaiting: r.stage === "abandoned" ? null : r.awaiting,
|
|
477
594
|
dim: r.stage === "abandoned",
|
|
595
|
+
archived: archive.has(r.id),
|
|
596
|
+
tags: [r.runner, r.agent].filter((t): t is string => !!t),
|
|
597
|
+
notes: readNotes(st, r.id) || undefined,
|
|
478
598
|
});
|
|
479
599
|
}
|
|
480
600
|
}
|
|
@@ -501,9 +621,11 @@ export function manualCommand(projectDir: string, agentCmd: string, prompt: stri
|
|
|
501
621
|
|
|
502
622
|
export function launchRun(it: Item, from: string): Launch {
|
|
503
623
|
const projectDir = projectOf(from);
|
|
624
|
+
// The run will be born here; the board must know the dir or the idea never retires.
|
|
625
|
+
remember(projectDir);
|
|
504
626
|
const agent = detectDefaultAgent(projectDir);
|
|
505
627
|
const agentBin = Bun.which(agent.cmd) ?? agent.cmd;
|
|
506
|
-
const prompt = it.where ? `/kaizen ${it.text} (${it.where})` : `/kaizen ${it.text}
|
|
628
|
+
const prompt = requestOf(it.where ? `/kaizen ${it.text} (${it.where})` : `/kaizen ${it.text}`, it.notes, from);
|
|
507
629
|
const fullCmd = [agentBin, prompt];
|
|
508
630
|
const manual = manualCommand(projectDir, agent.cmd, prompt);
|
|
509
631
|
|