kaizen-agent 1.20.0 → 1.21.0

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";
@@ -444,6 +444,10 @@ export async function web(repoDir: string, opts: Opts = {}) {
444
444
  replaceIdea(dir, text, { status: "rejected", text: `${text} | ${why}` });
445
445
  } else if (body.op === "delete") {
446
446
  replaceIdea(dir, text, null);
447
+ } else if (body.op === "move") {
448
+ if (typeof body.to !== "string" || !states(true).includes(body.to)) return bad("unknown state dir", 404);
449
+ const why = moveIdea(dir, body.to, text);
450
+ if (why) return bad(why, why === "no such idea" ? 404 : 409);
447
451
  } else return bad("unknown op");
448
452
  changed();
449
453
  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.0",
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.