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 +6 -2
- package/cli/state.test.ts +18 -1
- package/cli/state.ts +24 -1
- package/cli/web.ts +5 -1
- package/package.json +1 -1
- package/web/board.html +2 -1
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
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.
|