kaizen-agent 1.20.0 → 1.21.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/cli/board.test.ts CHANGED
@@ -109,12 +109,16 @@ test("card menu items: appropriate actions and distinct icons across phases", ()
109
109
 
110
110
  const ico = (n: string) => `<svg><use href="#i-${n}"/></svg>`;
111
111
  const menuSnippet = (html.slice(html.indexOf("if(k===menuKey){"), html.indexOf("el.innerHTML+=`<div class=\"menu-pop\"", html.indexOf("if(k===menuKey){"))) + "}").replace("const items=[];", "items=[];");
112
- const getItems = new Function("c", "archive", `const ico = ${ico.toString()};\nlet k = 1, menuKey = 1, items = [];\n${menuSnippet}\nreturn items;`);
112
+ const getItems = new Function("c", "archive", "S", `const ico = ${ico.toString()};\nlet k = 1, menuKey = 1, items = [];\n${menuSnippet}\nreturn items;`);
113
113
 
114
114
  // Phase 0: Ideas (idea card)
115
115
  const idea = { kind: "idea", status: "idea", column: 0 };
116
- const ideaItems = getItems(idea, false);
116
+ const ideaItems = getItems(idea, false, { projects: [] });
117
117
  expect(ideaItems.map((i: any) => i ? i[1] : null)).toEqual(["Run", "Edit", "Archive", null, "Reject", "Delete"]);
118
+ // Move only when there is another project to move to.
119
+ const moveItems = getItems({ ...idea, state: "/a" }, false, { projects: [{ dir: "/a" }, { dir: "/b" }] });
120
+ expect(moveItems.map((i: any) => i ? i[1] : null)).toEqual(["Run", "Edit", "Move to project", "Archive", null, "Reject", "Delete"]);
121
+ expect(moveItems[2][2]).toBe(ico("ext"));
118
122
  expect(ideaItems[0][2]).toBe(ico("play"));
119
123
  expect(ideaItems[1][2]).toBe(ico("pen"));
120
124
  expect(ideaItems[2][2]).toBe(ico("archive"));
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, abandonRun, allBacklog, backlog,
10
+ readRuns, readInbox, writeInbox, replaceIdea, moveIdea, abandonRun, allBacklog, backlog,
11
11
  statusOf, columnOf, parseItem, startedRuns, boardCards, 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,
@@ -827,3 +827,20 @@ test("trustFolder: marks the repo root trusted in a temp config, keeps the rest,
827
827
  expect(readFileSync(cfg, "utf8")).toBe("{bad");
828
828
  rmSync(t, { recursive: true, force: true });
829
829
  });
830
+
831
+ test("moveIdea carries notes and linked attachments, leaves the old inbox without it", () => {
832
+ const root = mkdtempSync(join(tmpdir(), "kaizen-mv-"));
833
+ const a = join(root, "a", ".kaizen"), b = join(root, "b", ".kaizen");
834
+ mkdirSync(join(a, "attachments"), { recursive: true });
835
+ writeFileSync(join(a, "attachments", "x-shot.png"), "png");
836
+ writeInbox(a, [{ status: "open", text: "keep" }, { status: "open", text: "go", notes: "see [shot](attachments/x-shot.png)" }]);
837
+ expect(moveIdea(a, a, "go")).toBe("already in that project");
838
+ expect(moveIdea(a, b, "nope")).toBe("no such idea");
839
+ expect(moveIdea(a, b, "go")).toBeNull();
840
+ expect(readInbox(a).map((l) => l.text)).toEqual(["keep"]);
841
+ expect(readInbox(b)).toEqual([{ status: "open", text: "go", notes: "see [shot](attachments/x-shot.png)" }]);
842
+ expect(readFileSync(join(b, "attachments", "x-shot.png"), "utf8")).toBe("png");
843
+ writeInbox(a, [{ status: "open", text: "go" }]);
844
+ expect(moveIdea(a, b, "go")).toBe("that project already has this idea");
845
+ rmSync(root, { recursive: true, force: true });
846
+ });
package/cli/state.ts CHANGED
@@ -1,7 +1,7 @@
1
1
  // Everything kaizen knows about a project's state, read from and written to the
2
2
  // `.kaizen/` directory. No terminal, no HTTP: the TUI (`dashboard.ts`) and the web
3
3
  // board (`web.ts`) both import this and draw it their own way.
4
- import { existsSync, readFileSync, readdirSync, appendFileSync, writeFileSync, mkdirSync, statSync, realpathSync, rmSync, renameSync } from "node:fs";
4
+ import { existsSync, readFileSync, readdirSync, appendFileSync, writeFileSync, mkdirSync, statSync, realpathSync, rmSync, renameSync, copyFileSync } from "node:fs";
5
5
  import { join, dirname, win32 } from "node:path";
6
6
  import { homedir, arch, cpus, release, totalmem, version as osVersion } from "node:os";
7
7
 
@@ -817,6 +817,29 @@ export function replaceIdea(state: string, text: string, next: InboxLine | null)
817
817
  writeInbox(state, lines);
818
818
  }
819
819
 
820
+ // An open idea, notes and all, from one project's inbox to another's. Files its notes
821
+ // link (attachments/...) are copied along, so the links resolve from the new state dir.
822
+ // Written to the new project first and taken back out if the old one cannot be
823
+ // written, like moveSource: a crash between the two leaves it listed twice, never lost.
824
+ export function moveIdea(from: string, to: string, text: string): string | null {
825
+ if (from === to) return "already in that project";
826
+ const src = readInbox(from);
827
+ const at = src.findIndex((l) => l.status === "open" && l.text === text);
828
+ if (at < 0) return "no such idea";
829
+ const dest = readInbox(to);
830
+ if (dest.some((l) => l.status === "open" && l.text === text)) return "that project already has this idea";
831
+ const idea = src[at]!;
832
+ for (const m of (idea.notes ?? "").matchAll(/\]\((attachments\/[^)\/]+)\)/g)) {
833
+ if (!existsSync(join(from, m[1]!)) || !statSync(join(from, m[1]!)).isFile()) continue;
834
+ mkdirSync(join(to, "attachments"), { recursive: true });
835
+ copyFileSync(join(from, m[1]!), join(to, m[1]!));
836
+ }
837
+ writeInbox(to, [...dest, idea]);
838
+ try { writeInbox(from, src.filter((_, i) => i !== at)); }
839
+ catch (e) { writeInbox(to, dest); throw e; }
840
+ return null;
841
+ }
842
+
820
843
  // Archived runs stay exactly where they are on disk -- run directories are never
821
844
  // moved or renamed, that is the resume contract -- and are listed here instead.
822
845
  // One id per line; the board hides them unless asked for the archive.
package/cli/web.ts CHANGED
@@ -4,7 +4,7 @@ import { existsSync, readdirSync, statSync, watch, appendFileSync, mkdirSync, ty
4
4
  import { join, dirname, resolve, sep } 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, abandonRun, launchRun, parseItem, agentChoices, projectOf, KNOWN_AGENTS, plainModel, short, setArchived, writeNotes,
7
+ readInbox, writeInbox, replaceIdea, moveIdea, abandonRun, 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, fixPrompt, approvalPrompt, canApproveAwaiting, nativePath, closeSessions, sweepSessions, issueUrl,
10
10
  } from "./state.ts";
@@ -18,10 +18,17 @@ const NOTES_JS = join(dirname(import.meta.dir), "web", "notebook.js");
18
18
  const DEFAULT_PORT = 7420;
19
19
  // Read once at start: a board keeps the code it started with, so this is what it
20
20
  // runs, not what is installed now. `/` sends it; `current()` compares the two.
21
- const VERSION = (() => {
21
+ const installed = () => {
22
22
  try { return JSON.parse(readText(join(dirname(import.meta.dir), "package.json"))).version as string; }
23
23
  catch { return "unknown"; }
24
- })();
24
+ };
25
+ const VERSION = installed();
26
+ // An upgrade replaces the page files under a running board but not its code, and a
27
+ // page newer than its server sends ops the server does not know ("unknown op").
28
+ // So the pages are kept as they were at start, and read live (dev edits) only while
29
+ // the installed version is still the one this board runs.
30
+ const AT_START = new Map([PAGE, NOTES_PAGE, NOTES_JS].map((f) => [f, existsSync(f) ? readText(f) : null]));
31
+ const served = (f: string) => installed() === VERSION && existsSync(f) ? readText(f) : AT_START.get(f) ?? null;
25
32
 
26
33
  // A board answers at url and runs the installed version. A board from before the
27
34
  // version header sends none, so it counts as stale too.
@@ -211,17 +218,19 @@ export async function web(repoDir: string, opts: Opts = {}) {
211
218
  const path = url.pathname;
212
219
 
213
220
  if (req.method === "GET" && path === "/") {
214
- if (!existsSync(PAGE)) return new Response("web/board.html missing", { status: 500 });
221
+ const page = served(PAGE);
222
+ if (page === null) return new Response("web/board.html missing", { status: 500 });
215
223
  // The page gets the notify rule from state.ts rather than keeping its own copy.
216
- return new Response(readText(PAGE).replace("/*notifier*/", () => notifier.toString()), {
224
+ return new Response(page.replace("/*notifier*/", () => notifier.toString()), {
217
225
  headers: { "content-type": "text/html; charset=utf-8", "cache-control": "no-store", "x-kaizen-version": VERSION },
218
226
  });
219
227
  }
220
228
 
221
229
  if (req.method === "GET" && (path === "/notebook" || path === "/notebook.js")) {
222
230
  const [f, type] = path === "/notebook" ? [NOTES_PAGE, "text/html; charset=utf-8"] : [NOTES_JS, "application/javascript; charset=utf-8"];
223
- if (!existsSync(f)) return new Response(`${path} missing; run bun run build:web`, { status: 500 });
224
- return new Response(Bun.file(f), { headers: { "content-type": type, "cache-control": "no-store" } });
231
+ const body = served(f);
232
+ if (body === null) return new Response(`${path} missing; run bun run build:web`, { status: 500 });
233
+ return new Response(body, { headers: { "content-type": type, "cache-control": "no-store" } });
225
234
  }
226
235
 
227
236
  // Every note of one project, text included: search, tags and backlinks are the page's.
@@ -444,6 +453,10 @@ export async function web(repoDir: string, opts: Opts = {}) {
444
453
  replaceIdea(dir, text, { status: "rejected", text: `${text} | ${why}` });
445
454
  } else if (body.op === "delete") {
446
455
  replaceIdea(dir, text, null);
456
+ } else if (body.op === "move") {
457
+ if (typeof body.to !== "string" || !states(true).includes(body.to)) return bad("unknown state dir", 404);
458
+ const why = moveIdea(dir, body.to, text);
459
+ if (why) return bad(why, why === "no such idea" ? 404 : 409);
447
460
  } else return bad("unknown op");
448
461
  changed();
449
462
  return json({ ok: true });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "kaizen-agent",
3
- "version": "1.20.0",
3
+ "version": "1.21.1",
4
4
  "description": "Staged workflow for AI agents — code, writing, research, operations: plan, human approval, build, independent review, bounded fix loop.",
5
5
  "type": "module",
6
6
  "bin": {
package/web/board.html CHANGED
@@ -518,7 +518,7 @@ function card(c){
518
518
  items.push(["open","Open",ico("open")]);
519
519
  if(c.kind==="idea")items.push(null,["delete","Delete",ico("trash"),"danger"]);
520
520
  }else if(c.kind==="idea"&&c.status==="idea"){
521
- items.push(["run","Run",ico("play")],["edit","Edit",ico("pen")],["archive","Archive",ico("archive")],null,["reject","Reject",ico("x")],["delete","Delete",ico("trash"),"danger"]);
521
+ items.push(["run","Run",ico("play")],["edit","Edit",ico("pen")],...(S.projects.some(p=>p.dir!==c.state)?[["move","Move to project",ico("ext")]]:[]),["archive","Archive",ico("archive")],null,["reject","Reject",ico("x")],["delete","Delete",ico("trash"),"danger"]);
522
522
  }else if(c.kind==="idea"){
523
523
  items.push(["open","Open",ico("open")],["run","Run again",ico("play")],["archive","Archive",ico("archive")],null,["delete","Remove",ico("trash"),"danger"]);
524
524
  }else{
@@ -542,6 +542,7 @@ function inlineForm(c){
542
542
  if(mode==="edit"){li.innerHTML=`<input value="${esc(c.text)}"><textarea placeholder="Notes: links, paths, background">${esc(c.notes||"")}</textarea><div class="q dim" id="clong" hidden>The first line is over 72 characters; logs will cut it short.</div><div class="row"><button class="btn ghost" data-a="c">Cancel</button><button class="btn pri" data-a="s">Save</button></div>`;li.querySelector('[data-a=s]').onclick=async()=>{const next=li.querySelector("input").value.trim(),notes=li.querySelector("textarea").value.trim();mode=null;if(next&&(next!==c.text||notes!==(c.notes||"")))await post("/inbox",{op:"edit",dir,text:c.text,next,notes});load()}}
543
543
  if(mode==="reject"){const idea=c.kind==="idea";li.innerHTML=`<div class="q">${idea?"Reject this idea?":"Abandon this run?"} The reason is recorded.</div><input placeholder="Reason"><div class="row"><button class="btn ghost" data-a="c">Keep it</button><button class="btn warn" data-a="s">${idea?"Reject":"Abandon"}</button></div>`;li.querySelector('[data-a=s]').onclick=async()=>{const why=li.querySelector("input").value.trim();if(!why){toast("A reason is required");return}mode=null;const r=idea?await post("/inbox",{op:"reject",dir,text:c.text,reason:why}):await post("/abort",{dir,id:c.id,why});if(r.ok)toast(idea?"Rejected":"Abandoned, reason recorded");load()}}
544
544
  if(mode==="delete"){li.innerHTML=`<div class="q">Delete "${esc(c.text)}"?</div><div class="q dim">Not recorded anywhere.</div><div class="row"><button class="btn ghost" data-a="c">Keep it</button><button class="btn danger" data-a="s">Delete</button></div>`;li.querySelector('[data-a=s]').onclick=async()=>{mode=null;const r=await post("/inbox",{op:"delete",dir,text:c.text});if(r.ok)toast("Deleted");load()}}
545
+ if(mode==="move"){const ps=S.projects.filter(p=>p.dir!==dir);li.innerHTML=`<div class="q">Move "${esc(c.text)}" to</div><select class="sel" aria-label="Project">${ps.map(p=>`<option value="${esc(p.dir)}">${esc(p.label)}</option>`).join("")}</select><div class="row"><button class="btn ghost" data-a="c">Cancel</button><button class="btn pri" data-a="s">Move</button></div>`;li.querySelector('[data-a=s]').onclick=async()=>{const sel=li.querySelector("select"),to=sel.value,name=sel.selectedOptions[0]?.textContent;mode=null;const r=await post("/inbox",{op:"move",dir,to,text:c.text});if(r.ok)toast(`Moved to ${name}`);load()}}
545
546
  if(mode==="run"){li.innerHTML=`<div class="q">Start a run for "${esc(c.text)}"</div><div class="q dim">Opens your agent in a new terminal, or a herdr tab or tmux window when one is running, with the request typed in.</div><div class="q dim">Full: planned, built, then checked by a separate reviewer. Lite: one session does it all, faster and cheaper.</div><div id="run-pick" style="display:grid;gap:6px"><div id="run-agent" class="agents" role="radiogroup" aria-label="Agent"></div></div><div class="row"><label class="yolo" id="run-yolo" title="Yolo: the agent runs every command and edit without asking" hidden><button type="button" class="chk" role="switch" aria-checked="false"></button>Yolo</label><button class="btn ghost" data-a="c">Cancel</button><button class="btn" data-a="l">Lite</button><button class="btn pri" data-a="s">Full run</button></div>`;li.querySelector('[data-a=s]').onclick=()=>startRun(c,"full");li.querySelector('[data-a=l]').onclick=()=>startRun(c,"lite");runForm(li,c)}
546
547
  li.querySelector('[data-a=c]').onclick=()=>{mode=null;render()};
547
548
  // Edit and reject take dictation; the run prompt has no field.