kaizen-agent 1.42.2 → 1.43.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/backlog.test.ts +133 -0
- package/cli/board.test.ts +7 -2
- package/cli/html.test.ts +19 -0
- package/cli/notes.ts +2 -6
- package/cli/state.test.ts +61 -2
- package/cli/state.ts +219 -45
- package/cli/web.ts +58 -15
- package/package.json +1 -1
- package/web/board/approvals.ts +3 -3
- package/web/board/cards.ts +240 -107
- package/web/board/chat.ts +4 -4
- package/web/board/dom.ts +17 -5
- package/web/board/fixes.ts +6 -8
- package/web/board/git.ts +3 -3
- package/web/board/icebox.ts +51 -69
- package/web/board/keys.ts +7 -6
- package/web/board/main.ts +1 -0
- package/web/board/panel.ts +22 -16
- package/web/board/projects.ts +9 -14
- package/web/board/settings.ts +18 -22
- package/web/board/store.ts +2 -0
- package/web/board/update.ts +7 -7
- package/web/board.html +12 -0
- package/web/board.js +23 -23
- package/web/notebook.js +1 -1
- package/web/term.js +1 -1
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
// Backlog items as cards in the board's Ideas column: numbering, hiding while a run
|
|
2
|
+
// started from one lives, closing in place, and the board routes that drive them.
|
|
3
|
+
import { test, expect } from "bun:test";
|
|
4
|
+
import { mkdtempSync, mkdirSync, writeFileSync, readFileSync, statSync } from "node:fs";
|
|
5
|
+
import { join, dirname } from "node:path";
|
|
6
|
+
import { tmpdir } from "node:os";
|
|
7
|
+
import { backlogLines, backlogCards, startedBacklog, closeBacklog, markBacklogDone, backlogItem } from "./state.ts";
|
|
8
|
+
import { boardIndex } from "./notes.ts";
|
|
9
|
+
|
|
10
|
+
const scratch = () => {
|
|
11
|
+
const state = join(mkdtempSync(join(tmpdir(), "kz-bl-")), ".kaizen");
|
|
12
|
+
const run = (id: string, stage: string, files: Record<string, string> = {}) => {
|
|
13
|
+
const dir = join(state, "runs", id);
|
|
14
|
+
mkdirSync(dir, { recursive: true });
|
|
15
|
+
writeFileSync(join(dir, "state.json"), JSON.stringify({ id, stage, awaiting: null }));
|
|
16
|
+
for (const [f, t] of Object.entries(files)) writeFileSync(join(dir, f), t);
|
|
17
|
+
return dir;
|
|
18
|
+
};
|
|
19
|
+
return { state, run };
|
|
20
|
+
};
|
|
21
|
+
|
|
22
|
+
const BACKLOG = "# Backlog\n\n- open: `cli/x.ts:12`: low: first thing. Fix: do it.\n- done: closed one | run y\n- open: high: urgent thing\n a note under it\n- rejected: no | why\n- open: plain item\n";
|
|
23
|
+
|
|
24
|
+
test("backlogLines numbers every open, done and rejected line as boardIndex does", () => {
|
|
25
|
+
const { state, run } = scratch();
|
|
26
|
+
run("2026-10-01-src", "done", { "06-backlog.md": BACKLOG });
|
|
27
|
+
const lines = backlogLines(join(state, "runs", "2026-10-01-src", "06-backlog.md"));
|
|
28
|
+
expect(lines.map((l) => [l.n, l.status])).toEqual([[1, "open"], [2, "done"], [3, "open"], [4, "rejected"], [5, "open"]]);
|
|
29
|
+
const idx = boardIndex(state).runs.find((r) => r.id === "2026-10-01-src")!.backlog;
|
|
30
|
+
expect(idx).toEqual(lines.map(({ n, status, text }) => ({ n, status, text })));
|
|
31
|
+
expect(backlogLines(join(state, "nope.md"))).toEqual([]);
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
test("backlogCards: open items of live runs and the orphanage, most severe first; abandoned runs skipped", () => {
|
|
35
|
+
const { state, run } = scratch();
|
|
36
|
+
run("2026-10-01-src", "done", { "06-backlog.md": BACKLOG });
|
|
37
|
+
run("2026-10-02-gone", "abandoned", { "06-backlog.md": "- open: critical: from an abandoned run\n" });
|
|
38
|
+
writeFileSync(join(state, "backlog.md"), "- open: rescued item | from 2026-09-01-old\n");
|
|
39
|
+
const cards = backlogCards([state]);
|
|
40
|
+
expect(cards.map((c) => [c.source, c.n, c.severity])).toEqual([
|
|
41
|
+
["2026-10-01-src", 3, "high"], ["2026-10-01-src", 1, "low"], ["2026-10-01-src", 5, null], ["orphan", 1, null],
|
|
42
|
+
]);
|
|
43
|
+
expect(cards[1]).toMatchObject({ text: "first thing.", at: "x.ts:12", raw: "`cli/x.ts:12`: low: first thing. Fix: do it." });
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
test("a run started from an item hides its card; an abandoned one gives it back", () => {
|
|
47
|
+
const { state, run } = scratch();
|
|
48
|
+
run("2026-10-01-src", "done", { "06-backlog.md": BACKLOG });
|
|
49
|
+
const kid = run("2026-10-03-kid", "plan", { "00-request.md": "/kaizen urgent thing\n\nParent: 2026-10-01-src\nBacklog: 2026-10-01-src#3\n" });
|
|
50
|
+
expect(startedBacklog(state).get("2026-10-01-src#3")).toEqual([{ id: "2026-10-03-kid", stage: "plan" }]);
|
|
51
|
+
expect(backlogCards([state]).some((c) => c.n === 3)).toBe(false);
|
|
52
|
+
writeFileSync(join(kid, "state.json"), JSON.stringify({ stage: "abandoned" }));
|
|
53
|
+
expect(backlogCards([state]).some((c) => c.n === 3)).toBe(true);
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
test("markBacklogDone closes only the line its done run names, once, and touches no state.json", () => {
|
|
57
|
+
const { state, run } = scratch();
|
|
58
|
+
const src = run("2026-10-01-src", "done", { "06-backlog.md": BACKLOG });
|
|
59
|
+
run("2026-10-03-kid", "build", { "00-request.md": "x\n\nBacklog: 2026-10-01-src#3\n" });
|
|
60
|
+
run("2026-10-04-orph", "done", { "00-request.md": "y\n\nBacklog: orphan#1\n" });
|
|
61
|
+
writeFileSync(join(state, "backlog.md"), "- open: rescued item | from 2026-09-01-old\n");
|
|
62
|
+
markBacklogDone(state);
|
|
63
|
+
expect(readFileSync(join(src, "06-backlog.md"), "utf8")).toBe(BACKLOG); // still building
|
|
64
|
+
expect(readFileSync(join(state, "backlog.md"), "utf8")).toBe("- open: rescued item | from 2026-09-01-old | 2026-10-04-orph\n".replace("open", "done"));
|
|
65
|
+
const kidState = join(state, "runs", "2026-10-03-kid", "state.json");
|
|
66
|
+
writeFileSync(kidState, JSON.stringify({ stage: "done" }));
|
|
67
|
+
const before = statSync(kidState).mtimeMs;
|
|
68
|
+
markBacklogDone(state);
|
|
69
|
+
markBacklogDone(state);
|
|
70
|
+
expect(readFileSync(join(src, "06-backlog.md"), "utf8")).toBe(BACKLOG.replace("- open: high: urgent thing", "- done: high: urgent thing | 2026-10-03-kid"));
|
|
71
|
+
expect(statSync(kidState).mtimeMs).toBe(before);
|
|
72
|
+
expect(backlogCards([state]).map((c) => c.n)).toEqual([1, 5]);
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
test("closeBacklog rewrites one open line in place and keeps CRLF; refuses closed or missing lines", () => {
|
|
76
|
+
const { state, run } = scratch();
|
|
77
|
+
const src = run("2026-10-01-src", "done", { "06-backlog.md": BACKLOG.replace(/\n/g, "\r\n") });
|
|
78
|
+
expect(closeBacklog(state, "2026-10-01-src", 5, "rejected", "not worth it")).toBeNull();
|
|
79
|
+
expect(readFileSync(join(src, "06-backlog.md"), "utf8")).toBe(BACKLOG.replace("- open: plain item", "- rejected: plain item | not worth it").replace(/\n/g, "\r\n"));
|
|
80
|
+
expect(closeBacklog(state, "2026-10-01-src", 2, "rejected", "x")).toBe("that item is already closed");
|
|
81
|
+
expect(closeBacklog(state, "2026-10-01-src", 9, "rejected", "x")).toBe("no such backlog item");
|
|
82
|
+
expect(closeBacklog(state, "../../etc", 1, "rejected", "x")).toBe("no such backlog");
|
|
83
|
+
expect(backlogItem(state, "2026-10-01-src", 5)?.status).toBe("rejected");
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
// The real server in a scratch home. Linux only: launching is made to fail before any
|
|
87
|
+
// terminal or agent opens (no display, no terminal on PATH, terminal launch mode), and
|
|
88
|
+
// the manual command it hands back is the request a run would have been given.
|
|
89
|
+
test.skipIf(process.platform !== "linux")("board routes: /state lists backlog cards, /backlog rejects, /run carries the item's source", async () => {
|
|
90
|
+
const homeDir = mkdtempSync(join(tmpdir(), "kz-bl-home-")), proj = join(homeDir, "p"), state = join(proj, ".kaizen");
|
|
91
|
+
for (const d of [state, join(homeDir, ".kaizen"), join(homeDir, ".claude")]) mkdirSync(d, { recursive: true });
|
|
92
|
+
writeFileSync(join(homeDir, ".kaizen", "projects"), proj + "\n");
|
|
93
|
+
writeFileSync(join(homeDir, ".kaizen", "config.yml"), "agent:\n launch: terminal\n");
|
|
94
|
+
const src = join(state, "runs", "2026-10-01-src");
|
|
95
|
+
mkdirSync(src, { recursive: true });
|
|
96
|
+
writeFileSync(join(src, "state.json"), JSON.stringify({ stage: "done", awaiting: null }));
|
|
97
|
+
writeFileSync(join(src, "06-backlog.md"), BACKLOG);
|
|
98
|
+
const port = 20000 + Math.floor(Math.random() * 20000);
|
|
99
|
+
const repo = join(import.meta.dir, "..");
|
|
100
|
+
const env: Record<string, string> = { HOME: homeDir, USERPROFILE: homeDir, KAIZEN_HOME: repo, PATH: dirname(process.execPath), DISPLAY: "", WAYLAND_DISPLAY: "", TERMINAL: "", TMUX: "" };
|
|
101
|
+
const proc = Bun.spawn([process.execPath, join(repo, "cli", "install.ts"), "web", "--port", String(port), "--no-open", "--foreground"], { cwd: proj, env, stdout: "ignore", stderr: "ignore" });
|
|
102
|
+
try {
|
|
103
|
+
const at = `http://127.0.0.1:${port}`;
|
|
104
|
+
for (let i = 0; ; i++) { try { await fetch(at + "/state"); break; } catch { if (i > 100) throw new Error("board did not start"); await Bun.sleep(50); } }
|
|
105
|
+
const post = async (path: string, body: object) => { const r = await fetch(at + path, { method: "POST", body: JSON.stringify({ dir: state, ...body }) }); return { status: r.status, ...(await r.json() as any) }; };
|
|
106
|
+
const listed = async () => ((await (await fetch(at + "/state?all=1")).json()) as any).backlog.map((b: any) => [b.source, b.n]);
|
|
107
|
+
expect(await listed()).toEqual([["2026-10-01-src", 3], ["2026-10-01-src", 1], ["2026-10-01-src", 5]]);
|
|
108
|
+
|
|
109
|
+
expect(await post("/backlog", { op: "reject", source: "2026-10-01-src", n: 5, reason: "" })).toMatchObject({ status: 400 });
|
|
110
|
+
expect(await post("/backlog", { op: "reject", source: "2026-10-01-src", n: 5, reason: "not now" })).toMatchObject({ status: 200 });
|
|
111
|
+
expect(await post("/backlog", { op: "reject", source: "2026-10-01-src", n: 5, reason: "again" })).toMatchObject({ status: 409 });
|
|
112
|
+
expect(await post("/backlog", { op: "reject", source: "2026-10-01-src", n: 42, reason: "x" })).toMatchObject({ status: 404 });
|
|
113
|
+
expect(readFileSync(join(src, "06-backlog.md"), "utf8")).toContain("- rejected: plain item | not now\n");
|
|
114
|
+
expect(await listed()).toEqual([["2026-10-01-src", 3], ["2026-10-01-src", 1]]);
|
|
115
|
+
|
|
116
|
+
expect(await post("/run", { backlog: { source: "2026-10-01-src", n: 2 } })).toMatchObject({ status: 409 });
|
|
117
|
+
expect(await post("/run", { backlog: { source: "2026-10-01-src", n: 42 } })).toMatchObject({ status: 404 });
|
|
118
|
+
const r = await post("/run", { kind: "lite", backlog: { source: "2026-10-01-src", n: 1 } });
|
|
119
|
+
expect(r).toMatchObject({ status: 500, ok: false });
|
|
120
|
+
expect(r.manual).toContain("/kaizen lite first thing. (x.ts:12)");
|
|
121
|
+
expect(r.manual).toContain("Parent: 2026-10-01-src");
|
|
122
|
+
expect(r.manual).toContain("Backlog: 2026-10-01-src#1");
|
|
123
|
+
expect(r.manual).toContain("Backlog item: `cli/x.ts:12`: low: first thing. Fix: do it.");
|
|
124
|
+
|
|
125
|
+
// Once a run carries the line, a second start is refused and the card is gone.
|
|
126
|
+
const kid = join(state, "runs", "2026-10-05-kid");
|
|
127
|
+
mkdirSync(kid, { recursive: true });
|
|
128
|
+
writeFileSync(join(kid, "state.json"), JSON.stringify({ stage: "plan", awaiting: null }));
|
|
129
|
+
writeFileSync(join(kid, "00-request.md"), "first thing\n\nParent: 2026-10-01-src\nBacklog: 2026-10-01-src#1\n");
|
|
130
|
+
expect(await post("/run", { backlog: { source: "2026-10-01-src", n: 1 } })).toMatchObject({ status: 409 });
|
|
131
|
+
expect(await listed()).toEqual([["2026-10-01-src", 3]]);
|
|
132
|
+
} finally { proc.kill(); await proc.exited; }
|
|
133
|
+
}, 30_000);
|
package/cli/board.test.ts
CHANGED
|
@@ -22,7 +22,8 @@ test("Settings rechecks an existing update alert and clears or refreshes it", as
|
|
|
22
22
|
getElementById: (id: string) => id === "pbody" ? body : elements.get(id),
|
|
23
23
|
createElement: () => ({
|
|
24
24
|
isConnected: false,
|
|
25
|
-
set innerHTML(
|
|
25
|
+
set innerHTML(v: unknown) {
|
|
26
|
+
const html = String(v);
|
|
26
27
|
this.html = html;
|
|
27
28
|
elements.clear();
|
|
28
29
|
for (const [, id] of html.matchAll(/id="([^"]+)"/g)) elements.set(id, { textContent: "", disabled: false, focus() {} });
|
|
@@ -154,7 +155,7 @@ test("card menu items: appropriate actions and distinct icons across phases", ()
|
|
|
154
155
|
|
|
155
156
|
const ico = (n: string) => `<svg><use href="#i-${n}"/></svg>`;
|
|
156
157
|
const from = cards.indexOf("if (k === store.menuKey) {");
|
|
157
|
-
const menuSnippet = (cards.slice(from, cards.indexOf('el.innerHTML += `<div class="menu-pop"', from)) + "}").replace("const items = [];", "items = [];");
|
|
158
|
+
const menuSnippet = (cards.slice(from, cards.indexOf('el.innerHTML += html`<div class="menu-pop"', from)) + "}").replace("const items = [];", "items = [];");
|
|
158
159
|
const a0 = cards.indexOf("const actionOf =");
|
|
159
160
|
const actionOf = cards.slice(a0, cards.indexOf("\n\n", a0));
|
|
160
161
|
const getItems = new Function("c", "archive", "S", `const ico = ${ico.toString()};\nconst decided = new Set(), answer = () => "";\n${actionOf}\nconst store = { S, menuKey: 1 };\nlet k = 1, items = [];\n${menuSnippet}\nreturn items;`);
|
|
@@ -178,6 +179,10 @@ test("card menu items: appropriate actions and distinct icons across phases", ()
|
|
|
178
179
|
const startItems = getItems(startingIdea, false);
|
|
179
180
|
expect(startItems.map((i: any) => i ? i[1] : null)).toEqual(["Open", "Run again", "Archive", null, "Remove"]);
|
|
180
181
|
|
|
182
|
+
// Ideas column, Backlog tab: a run's leftover, run like an idea, rejected with a reason.
|
|
183
|
+
expect(getItems({ kind: "backlog", status: "idea", column: 0 }, false).map((i: any) => i ? i[1] : null)).toEqual(["Run", "Open its run", null, "Reject"]);
|
|
184
|
+
expect(getItems({ kind: "backlog", status: "starting", column: 0 }, false)[0][1]).toBe("Run again");
|
|
185
|
+
|
|
181
186
|
// Phase 1: Planning (run waiting for plan approval)
|
|
182
187
|
const planWaiting = { kind: "run", status: "waiting", awaiting: "approvals.plan", column: 1 };
|
|
183
188
|
const planItems = getItems(planWaiting, false);
|
package/cli/html.test.ts
ADDED
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
import { expect, test } from "bun:test";
|
|
2
|
+
import { html, ico, raw } from "../web/board/dom.ts";
|
|
3
|
+
|
|
4
|
+
test("html escapes interpolations once and keeps trusted markup", () => {
|
|
5
|
+
const bad = '<img src=x onerror=1> & "q"';
|
|
6
|
+
const out = String(html`<b title="${bad}">${bad}</b>`);
|
|
7
|
+
expect(out).not.toContain("<img");
|
|
8
|
+
expect(out).toContain("<img src=x onerror=1> & "q"");
|
|
9
|
+
expect(String(html`${"a&b"}`)).toBe("a&b");
|
|
10
|
+
expect(String(html`${raw("<i>")}${ico("x")}`)).toBe('<i><svg><use href="#i-x"/></svg>');
|
|
11
|
+
expect(String(html`<ul>${[1, 2].map((n) => html`<li>${n}</li>`)}</ul>`)).toBe("<ul><li>1</li><li>2</li></ul>");
|
|
12
|
+
expect(String(html`${["<a>"]}`)).toBe("<a>");
|
|
13
|
+
expect(String(html`[${null}${undefined}${false}]`)).toBe("[]");
|
|
14
|
+
expect(String(html`${html`<u>${"&"}</u>`}`)).toBe("<u>&</u>");
|
|
15
|
+
});
|
|
16
|
+
|
|
17
|
+
test("aria states render false explicitly when stringified", () => {
|
|
18
|
+
expect(String(html`<b aria-pressed="${String(false)}" aria-checked="${String(true)}">`)).toBe('<b aria-pressed="false" aria-checked="true">');
|
|
19
|
+
});
|
package/cli/notes.ts
CHANGED
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
// the page goes through safeName before it becomes a path.
|
|
4
4
|
import { existsSync, mkdirSync, readdirSync, realpathSync, renameSync, rmdirSync, statSync, writeFileSync } from "node:fs";
|
|
5
5
|
import { join, dirname, resolve, sep } from "node:path";
|
|
6
|
-
import { boardCards, reviewFindings, readText } from "./state.ts";
|
|
6
|
+
import { backlogLines, boardCards, reviewFindings, readText } from "./state.ts";
|
|
7
7
|
import { wikilinks, boardLink, resolveRun } from "../web/notes-lib.ts";
|
|
8
8
|
|
|
9
9
|
export type Note = { name: string; mtime: number; text: string };
|
|
@@ -114,11 +114,7 @@ function prune(state: string, dir: string) {
|
|
|
114
114
|
// project keeps hundreds of runs.
|
|
115
115
|
export function boardIndex(state: string) {
|
|
116
116
|
const cards = boardCards([state], Date.now());
|
|
117
|
-
const backlog = (id: string) => {
|
|
118
|
-
let text = "";
|
|
119
|
-
try { text = readText(join(state, "runs", id, "06-backlog.md")); } catch { /* none */ }
|
|
120
|
-
return [...text.matchAll(/^\s*-\s*(open|done|rejected):\s*(.*)$/gm)].map((m, i) => ({ n: i + 1, status: m[1]!, text: m[2]!.trim() }));
|
|
121
|
-
};
|
|
117
|
+
const backlog = (id: string) => backlogLines(join(state, "runs", id, "06-backlog.md")).map(({ n, status, text }) => ({ n, status, text }));
|
|
122
118
|
return {
|
|
123
119
|
runs: cards.filter((c) => c.kind === "run").map((c) => ({
|
|
124
120
|
id: c.id!, short: c.id!.replace(/^\d{4}-\d{2}-\d{2}-/, ""), title: c.title ?? c.text, status: c.status,
|
package/cli/state.test.ts
CHANGED
|
@@ -57,6 +57,11 @@ test("statusOf: waiting beats stage, mtime decides running vs stalled", () => {
|
|
|
57
57
|
expect(statusOf(stale!, now)).toBe("stalled");
|
|
58
58
|
expect(statusOf({ ...stale!, moved: now }, now)).toBe("running");
|
|
59
59
|
expect(statusOf({ ...stale!, stage: "abandoned", awaiting: "x" }, now)).toBe("abandoned");
|
|
60
|
+
// A builder ticking progress.md is moving, though state.json is old.
|
|
61
|
+
const progress = join(state, "runs", "2026-09-03-stale", "progress.md");
|
|
62
|
+
writeFileSync(progress, "- doing: step 2\n");
|
|
63
|
+
try { expect(statusOf(readRuns(state)[0]!, now)).toBe("running"); } finally { rmSync(progress); }
|
|
64
|
+
expect(statusOf(readRuns(state)[0]!, now)).toBe("stalled");
|
|
60
65
|
});
|
|
61
66
|
|
|
62
67
|
test("columnOf: every stage word lands somewhere", () => {
|
|
@@ -501,6 +506,52 @@ test("commitPush: commits the run's files only, pushes, and records it; the mess
|
|
|
501
506
|
expect(cardsGit(join(root, ".kaizen"), ["r1"])).toEqual({}); // not a repo
|
|
502
507
|
}, 60_000); // dozens of git calls, ~250ms each on Windows
|
|
503
508
|
|
|
509
|
+
test("runGit, cardsGit: a run committed elsewhere is not offered the edits made since, nor another run's unpushed commit", () => {
|
|
510
|
+
const root = mkdtempSync(join(tmpdir(), "kz-git-"));
|
|
511
|
+
const bare = join(root, "remote.git"), proj = join(root, "proj"), st = join(proj, ".kaizen");
|
|
512
|
+
// Commit times pinned (seconds since the epoch), so each sits where the test says.
|
|
513
|
+
const sh = (cwd: string, at: number | null, ...a: string[]) => Bun.spawnSync(["git", "-C", cwd, ...a], { env: { ...process.env, ...(at ? { GIT_COMMITTER_DATE: `@${at} +0000`, GIT_AUTHOR_DATE: `@${at} +0000` } : {}) } }).stdout.toString().trim();
|
|
514
|
+
const report = (id: string, text: string, at: number) => {
|
|
515
|
+
mkdirSync(join(st, "runs", id), { recursive: true });
|
|
516
|
+
writeFileSync(join(st, "runs", id, "03-impl.md"), text);
|
|
517
|
+
utimesSync(join(st, "runs", id, "03-impl.md"), new Date(at * 1000), new Date(at * 1000));
|
|
518
|
+
};
|
|
519
|
+
Bun.spawnSync(["git", "init", "-q", "--bare", bare]);
|
|
520
|
+
Bun.spawnSync(["git", "init", "-q", proj]);
|
|
521
|
+
for (const kv of [["user.name", "t"], ["user.email", "t@t"], ["commit.gpgsign", "false"], ["core.hooksPath", "/dev/null"]]) sh(proj, null, "config", ...kv);
|
|
522
|
+
sh(proj, null, "remote", "add", "origin", bare);
|
|
523
|
+
writeFileSync(join(proj, ".gitignore"), ".kaizen/\n");
|
|
524
|
+
writeFileSync(join(proj, "x.txt"), "1");
|
|
525
|
+
report("old", "- `x.txt`: x\n", 1000);
|
|
526
|
+
sh(proj, null, "add", "-A");
|
|
527
|
+
sh(proj, 2000, "commit", "-qm", "feat: x");
|
|
528
|
+
sh(proj, null, "push", "-qu", "origin", "HEAD");
|
|
529
|
+
// Edited again after the run's commit, by someone with no run of their own.
|
|
530
|
+
writeFileSync(join(proj, "x.txt"), "2");
|
|
531
|
+
expect(runGit(st, "old")).toMatchObject({ files: [], ahead: 0, others: 1 });
|
|
532
|
+
expect(cardsGit(st, ["old"])).toEqual({ old: "pushed" });
|
|
533
|
+
// Another run's commit, not pushed: the branch is ahead, the old run is not.
|
|
534
|
+
writeFileSync(join(proj, "y.txt"), "y");
|
|
535
|
+
report("new", "- `y.txt`: y\n", 3000);
|
|
536
|
+
sh(proj, null, "add", "y.txt");
|
|
537
|
+
sh(proj, 4000, "commit", "-qm", "feat: y");
|
|
538
|
+
expect(runGit(st, "old")).toMatchObject({ files: [], ahead: 0 });
|
|
539
|
+
expect(runGit(st, "new")).toMatchObject({ files: [], ahead: 1 });
|
|
540
|
+
expect(cardsGit(st, ["old", "new"])).toEqual({ old: "pushed", new: "pending" });
|
|
541
|
+
// The old run reporting again after its commit: the edit is its work after all.
|
|
542
|
+
report("old", "- `x.txt`: x, again\n", 5000);
|
|
543
|
+
expect((runGit(st, "old") as any).files).toEqual([" M x.txt"]);
|
|
544
|
+
// A commit no run's report names still gets a push: from the run that reported last by then.
|
|
545
|
+
const commit = (f: string, at: number, msg: string) => { writeFileSync(join(proj, f), f); sh(proj, null, "add", f); sh(proj, at, "commit", "-qm", msg); };
|
|
546
|
+
commit("z.txt", 6000, "chore: z");
|
|
547
|
+
expect(runGit(st, "old")).toMatchObject({ ahead: 1 });
|
|
548
|
+
expect(runGit(st, "new")).toMatchObject({ ahead: 1 });
|
|
549
|
+
// A run whose report holds the commit's subject as a line owns it, over the file count.
|
|
550
|
+
report("new", "- `y.txt`: y\n\n## Commit\nchore: z\n", 3000);
|
|
551
|
+
expect(runGit(st, "old")).toMatchObject({ ahead: 0 });
|
|
552
|
+
expect(runGit(st, "new")).toMatchObject({ ahead: 2 });
|
|
553
|
+
}, 60_000);
|
|
554
|
+
|
|
504
555
|
test.skipIf(process.platform === "win32")("findTerminal: a running herdr wins, then tmux, else neither; the prompt stays one literal argument", () => {
|
|
505
556
|
const bin = mkdtempSync(join(tmpdir(), "kz-bin-"));
|
|
506
557
|
const stub = (name: string, code: number) => writeFileSync(join(bin, name), `#!/bin/sh\nexit ${code}\n`, { mode: 0o755 });
|
|
@@ -921,7 +972,7 @@ test.skipIf(process.platform === "win32")("closeSessions ends only the run's own
|
|
|
921
972
|
const alive = (pr: ReturnType<typeof Bun.spawn>) => pr.exitCode === null && pr.signalCode === null;
|
|
922
973
|
const procs: Record<string, ReturnType<typeof Bun.spawn>> = {
|
|
923
974
|
mine: start("mine", {}),
|
|
924
|
-
later: start("later", { started: Date.now()
|
|
975
|
+
later: start("later", { started: Date.now() - 1000 }), // the decision's own successor: after `before` below
|
|
925
976
|
stranger: start("stranger", { cmd: "notkzagent" }), // a PID that is not this agent
|
|
926
977
|
other: start("other", { run: "2026-10-01-b" }), // another run's
|
|
927
978
|
};
|
|
@@ -929,13 +980,14 @@ test.skipIf(process.platform === "win32")("closeSessions ends only the run's own
|
|
|
929
980
|
writeFileSync(join(dir, "dead.json"), JSON.stringify({ run: "2026-10-01-a", text: "a", cmd: "kzagent", sessionId: null, started: 0 }));
|
|
930
981
|
writeFileSync(join(dir, "dead.pid"), "999999\n");
|
|
931
982
|
try {
|
|
932
|
-
expect(closeSessions(st, "2026-10-01-a", { before: Date.now() })).toBe(1);
|
|
983
|
+
expect(closeSessions(st, "2026-10-01-a", { before: Date.now() - 3000 })).toBe(1);
|
|
933
984
|
expect(await procs.mine.exited).not.toBe(0);
|
|
934
985
|
for (const k of ["later", "stranger", "other"] as const) expect(alive(procs[k])).toBe(true);
|
|
935
986
|
expect(existsSync(join(dir, "mine.pid"))).toBe(false);
|
|
936
987
|
expect(existsSync(join(dir, "mine.json"))).toBe(true); // its conversation id is still needed
|
|
937
988
|
expect(existsSync(join(dir, "dead.pid"))).toBe(false);
|
|
938
989
|
expect(existsSync(join(dir, "later.pid"))).toBe(true);
|
|
990
|
+
expect(existsSync(join(dir, "stranger.pid"))).toBe(false); // its number is held by something else: dropped, not killed
|
|
939
991
|
|
|
940
992
|
// Off: nothing is ended.
|
|
941
993
|
writeFileSync(join(st, "config.yml"), "agent:\n default: claude\n close: off\n");
|
|
@@ -951,6 +1003,13 @@ test.skipIf(process.platform === "win32")("closeSessions ends only the run's own
|
|
|
951
1003
|
sweepSessions(st, Date.now() + CLOSE_GRACE_MS);
|
|
952
1004
|
expect(await procs.other.exited).not.toBe(0);
|
|
953
1005
|
expect(alive(procs.later)).toBe(true); // run a is still waiting
|
|
1006
|
+
// A PID file whose process is gone is dropped by the sweep, whatever its run is doing.
|
|
1007
|
+
writeFileSync(join(dir, "dead2.json"), JSON.stringify({ run: "2026-10-01-a", text: "a", cmd: "kzagent", sessionId: null, started: 0 }));
|
|
1008
|
+
writeFileSync(join(dir, "dead2.pid"), "999999\n");
|
|
1009
|
+
sweepSessions(st, Date.now());
|
|
1010
|
+
expect(existsSync(join(dir, "dead2.pid"))).toBe(false);
|
|
1011
|
+
expect(existsSync(join(dir, "dead2.json"))).toBe(true);
|
|
1012
|
+
expect(existsSync(join(dir, "later.pid"))).toBe(true);
|
|
954
1013
|
|
|
955
1014
|
// A fix launched on a done run, after it last moved, is doing the work: kept.
|
|
956
1015
|
const fix = start("fix", { run: "2026-10-01-b", started: Date.now() + 1000 });
|