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 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-board.png" alt="kaizen web board" width="760">
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 abandons. Theme lives in its settings;
297
- everything else is still `kaizen settings`. `--port N` picks a port, `--no-open` just
298
- serves, `?all=1` in the URL opens on every project.
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
 
@@ -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
 
@@ -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, and
18
- `.kaizen/memory.md` if it exists, before touching the codebase.
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 { web } = await import("./web.ts");
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] ![shot](attachments/a.png)\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: "![shot](attachments/a.png)" },
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", `![s](${rel})`, state)).toBe(`x\n\n![s](${join(state, rel)})`);
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
- export type InboxLine = { status: string; text: string };
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
- return readFileSync(f, "utf8").split("\n")
365
- .map((l) => /^-\s*(open|started|done|rejected):\s*(.+)$/.exec(l.trim()))
366
- .filter((m): m is RegExpExecArray => !!m)
367
- .map((m) => ({ status: m[1]!, text: m[2]!.trim() }));
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.map((l) => `- ${l.status}: ${l.text}`),
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