kaizen-agent 1.34.2 → 1.34.4

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.
@@ -0,0 +1,113 @@
1
+ // A real bare remote, two clones and real git: the landing is only as good as git says.
2
+ import { test, expect, beforeEach } from "bun:test";
3
+ import { mkdtempSync, mkdirSync, writeFileSync, readFileSync, existsSync } from "node:fs";
4
+ import { join } from "node:path";
5
+ import { tmpdir } from "node:os";
6
+ import { startWorktree, landWorktree, codeDir, projectFromWorktree, run, type Run } from "./worktree.ts";
7
+
8
+ const ID = "2026-10-17-demo";
9
+ let remote: string, project: string, other: string;
10
+ const g = (cwd: string, ...a: string[]) => { const r = run(["git", "-c", "user.email=a@b.c", "-c", "user.name=t", ...a], cwd); if (!r.ok) throw new Error(r.why); return r.out; };
11
+ const commit = (cwd: string, f: string, text: string) => { writeFileSync(join(cwd, f), text); g(cwd, "add", f); g(cwd, "commit", "-qm", `feat: ${f}`); };
12
+ const state = () => JSON.parse(readFileSync(join(project, ".kaizen/runs", ID, "state.json"), "utf8"));
13
+
14
+ beforeEach(() => {
15
+ const root = mkdtempSync(join(tmpdir(), "wt-"));
16
+ remote = join(root, "remote.git"); project = join(root, "project"); other = join(root, "other");
17
+ g(root, "init", "-q", "--bare", "-b", "main", remote);
18
+ g(root, "clone", "-q", remote, project);
19
+ commit(project, "a.txt", "a"); g(project, "push", "-q", "origin", "HEAD:main");
20
+ g(root, "clone", "-q", remote, other);
21
+ mkdirSync(join(project, ".kaizen/runs", ID), { recursive: true });
22
+ writeFileSync(join(project, ".kaizen/runs", ID, "state.json"), JSON.stringify({ id: ID, stage: "build" }));
23
+ writeFileSync(join(project, ".gitignore"), ".kaizen/*\n");
24
+ });
25
+
26
+ test("G-01 start leaves the project folder alone and records the worktree", () => {
27
+ writeFileSync(join(project, "dirty.txt"), "someone else's edit");
28
+ const head = g(project, "rev-parse", "HEAD"), br = g(project, "branch", "--show-current");
29
+ const r = startWorktree(project, ID);
30
+ expect(r.ok).toBe(true);
31
+ expect(g(project, "rev-parse", "HEAD")).toBe(head);
32
+ expect(g(project, "branch", "--show-current")).toBe(br);
33
+ expect(readFileSync(join(project, "dirty.txt"), "utf8")).toBe("someone else's edit");
34
+ expect(state().branch).toBe(`kaizen/${ID}`);
35
+ expect(codeDir(project, ID)).toBe(state().worktree);
36
+ expect(startWorktree(project, ID).ok).toBe(true); // a resume
37
+ });
38
+
39
+ test("G-06 a run id is a slug before it reaches a path or a branch", () => {
40
+ expect(startWorktree(project, "../evil").ok).toBe(false);
41
+ expect(startWorktree(project, "x; rm -rf").ok).toBe(false);
42
+ expect(landWorktree(project, "../evil").ok).toBe(false);
43
+ });
44
+
45
+ test("G-02 lands by rebase and push after main moved on", () => {
46
+ const w = (startWorktree(project, ID) as { path: string }).path;
47
+ commit(w, "run.txt", "mine");
48
+ commit(other, "theirs.txt", "x"); g(other, "push", "-q", "origin", "HEAD:main"); // main moves ahead
49
+ const r = landWorktree(project, ID, run, false);
50
+ expect(r).toEqual({ ok: true, how: "push", url: undefined });
51
+ g(project, "fetch", "-q", "origin");
52
+ expect(g(project, "ls-tree", "--name-only", "origin/main").split("\n")).toEqual(["a.txt", "run.txt", "theirs.txt"]);
53
+ expect(existsSync(w)).toBe(false); // G-04: gone only after landing
54
+ expect(state().worktree).toBeUndefined();
55
+ });
56
+
57
+ test("G-02 lands by pull request when gh is there", () => {
58
+ const w = (startWorktree(project, ID) as { path: string }).path;
59
+ commit(w, "run.txt", "mine");
60
+ const calls: string[][] = [];
61
+ const sh: Run = (cmd, cwd) => {
62
+ if (cmd[0] !== "gh") return run(cmd, cwd);
63
+ calls.push(cmd);
64
+ if (cmd[2] === "create") return { ok: true, out: "https://github.com/x/y/pull/1", why: "" };
65
+ // gh's rebase-merge, done by hand: the work reaches main under new hashes
66
+ g(cwd, "fetch", "-q", "origin"); g(cwd, "rebase", "-q", "origin/main");
67
+ g(cwd, "push", "-q", "origin", "HEAD:main");
68
+ return { ok: true, out: "", why: "" };
69
+ };
70
+ commit(other, "theirs.txt", "x"); g(other, "push", "-q", "origin", "HEAD:main");
71
+ const r = landWorktree(project, ID, sh, true);
72
+ expect(r).toEqual({ ok: true, how: "pr", url: "https://github.com/x/y/pull/1" });
73
+ expect(calls.map((c) => c.slice(0, 4).join(" "))).toEqual(["gh pr create --base", "gh pr merge kaizen/" + ID]);
74
+ expect(existsSync(w)).toBe(false);
75
+ });
76
+
77
+ test("G-03 a conflict keeps the worktree and branch and says so", () => {
78
+ const w = (startWorktree(project, ID) as { path: string }).path;
79
+ commit(w, "a.txt", "mine");
80
+ commit(other, "a.txt", "theirs"); g(other, "push", "-q", "origin", "HEAD:main");
81
+ const r = landWorktree(project, ID, run, false);
82
+ expect(r.ok).toBe(false);
83
+ expect(existsSync(w)).toBe(true);
84
+ expect(g(project, "branch", "--list", `kaizen/${ID}`)).toContain(ID);
85
+ expect(g(w, "status", "--porcelain")).toBe(""); // rebase aborted, tree clean
86
+ });
87
+
88
+ test("G-03 a pull request that will not merge is left open", () => {
89
+ const w = (startWorktree(project, ID) as { path: string }).path;
90
+ commit(w, "run.txt", "mine");
91
+ const sh: Run = (cmd, cwd) => cmd[0] !== "gh" ? run(cmd, cwd)
92
+ : cmd[2] === "create" ? { ok: true, out: "url", why: "" } : { ok: false, out: "", why: "not mergeable" };
93
+ const r = landWorktree(project, ID, sh, true);
94
+ expect(r.ok).toBe(false);
95
+ expect(existsSync(w)).toBe(true);
96
+ });
97
+
98
+ test("G-03 uncommitted work in the worktree is never landed or removed", () => {
99
+ const w = (startWorktree(project, ID) as { path: string }).path;
100
+ writeFileSync(join(w, "wip.txt"), "unsaved");
101
+ expect(landWorktree(project, ID, run, false).ok).toBe(false);
102
+ expect(existsSync(join(w, "wip.txt"))).toBe(true);
103
+ });
104
+
105
+ test("G-05 a run with no worktree has none to land and codeDir is the project", () => {
106
+ expect(codeDir(project, ID)).toBe(project);
107
+ expect(landWorktree(project, ID, run, false)).toEqual({ ok: false, why: "this run has no worktree" });
108
+ });
109
+
110
+ test("projectFromWorktree finds the project from inside a run's folder", () => {
111
+ expect(projectFromWorktree("/p/.kaizen/wt/2026-10-17-x/cli")).toBe("/p");
112
+ expect(projectFromWorktree("/p/cli")).toBe("/p/cli");
113
+ });
@@ -0,0 +1,128 @@
1
+ // One branch and one git worktree per run, so agents building at the same time never
2
+ // share a working folder. Run notes stay in the project's .kaizen/runs/<id>/; only the
3
+ // code lives in the worktree. Every git call is an argv array, never a shell string.
4
+ import { existsSync, readFileSync, writeFileSync, rmSync } from "node:fs";
5
+ import { join, dirname } from "node:path";
6
+
7
+ export type Out = { ok: boolean; out: string; why: string };
8
+ export type Run = (cmd: string[], cwd: string) => Out;
9
+
10
+ const SLUG = /^\d{4}-\d{2}-\d{2}-[a-z0-9][a-z0-9-]*$/;
11
+ const env = { ...process.env, GIT_TERMINAL_PROMPT: "0" };
12
+
13
+ export const run: Run = (cmd, cwd) => {
14
+ try {
15
+ const r = Bun.spawnSync(cmd, { cwd, env, stdin: "ignore", timeout: 120_000, windowsHide: true });
16
+ const out = r.stdout.toString().trimEnd(), err = r.stderr.toString().trim();
17
+ return { ok: r.exitCode === 0, out, why: (err || out).split("\n").pop() || `${cmd[0]} did not finish` };
18
+ } catch { return { ok: false, out: "", why: `${cmd[0]} is not installed` }; }
19
+ };
20
+
21
+ const git = (cwd: string, ...a: string[]) => run(["git", ...a], cwd);
22
+
23
+ export const branchOf = (id: string) => `kaizen/${id}`;
24
+ export const worktreeOf = (project: string, id: string) => join(project, ".kaizen", "wt", id);
25
+
26
+ const file = (project: string, id: string) => join(project, ".kaizen", "runs", id, "state.json");
27
+
28
+ function patchState(project: string, id: string, set: Record<string, unknown>) {
29
+ const f = file(project, id), s = JSON.parse(readFileSync(f, "utf8"));
30
+ for (const [k, v] of Object.entries(set)) { if (v === null) delete s[k]; else s[k] = v; }
31
+ writeFileSync(f, JSON.stringify(s));
32
+ }
33
+
34
+ /** The folder a run's code is in: its worktree while it has one, else the project. */
35
+ export function codeDir(project: string, id: string): string {
36
+ try {
37
+ const w = JSON.parse(readFileSync(file(project, id), "utf8")).worktree;
38
+ if (typeof w === "string" && existsSync(w)) return w;
39
+ } catch { /* no state, no worktree */ }
40
+ return project;
41
+ }
42
+
43
+ /** The project folder when `dir` is inside one of its run worktrees, else `dir`. */
44
+ export const projectFromWorktree = (dir: string) => {
45
+ const m = /^(.*?)[\\/]\.kaizen[\\/]wt[\\/]/.exec(dir);
46
+ return m ? m[1]! : dir;
47
+ };
48
+
49
+ const baseOf = (project: string) => {
50
+ const head = git(project, "symbolic-ref", "--short", "refs/remotes/origin/HEAD");
51
+ return head.ok ? head.out.replace(/^origin\//, "") : "main";
52
+ };
53
+
54
+ /** Makes the run's worktree and branch off the remote default branch and records both. */
55
+ export function startWorktree(project: string, id: string): { ok: true; path: string; branch: string } | { ok: false; why: string } {
56
+ if (!SLUG.test(id)) return { ok: false, why: `"${id}" is not a run id` };
57
+ if (!existsSync(file(project, id))) return { ok: false, why: "no such run" };
58
+ if (!git(project, "rev-parse", "--is-inside-work-tree").ok) return { ok: false, why: "not a git repository" };
59
+ const path = worktreeOf(project, id), branch = branchOf(id);
60
+ if (existsSync(path)) return { ok: true, path, branch }; // already made: a resume
61
+ if (!git(project, "remote").out) return { ok: false, why: "no git remote to branch from" };
62
+ const f = git(project, "fetch", "origin");
63
+ if (!f.ok) return { ok: false, why: `fetch failed: ${f.why}` };
64
+ const base = `origin/${baseOf(project)}`;
65
+ const exists = git(project, "rev-parse", "--verify", "--quiet", `refs/heads/${branch}`).ok;
66
+ const add = git(project, "worktree", "add", ...(exists ? [path, branch] : [path, "--no-track", "-b", branch, base]));
67
+ if (!add.ok) return { ok: false, why: add.why };
68
+ patchState(project, id, { worktree: path, branch });
69
+ return { ok: true, path, branch };
70
+ }
71
+
72
+ export type Landed = { ok: true; how: "pr" | "push"; url?: string } | { ok: false; why: string };
73
+
74
+ // Whether every commit of the branch is on the base, by patch: a rebase-merge rewrites
75
+ // the hashes, so "is an ancestor" would say not landed for work that is.
76
+ const landed = (dir: string, base: string, branch: string) =>
77
+ git(dir, "cherry", `origin/${base}`, branch).out.split("\n").every((l) => !l.startsWith("+"));
78
+
79
+ /**
80
+ * Pushes the run's branch and lands it on the base: a pull request rebase-merged when
81
+ * `gh` is there and the pull request is clean, else a rebase and push, retried once.
82
+ * The worktree and branch go only after the base holds the work; any failure leaves
83
+ * them where they are.
84
+ */
85
+ export function landWorktree(project: string, id: string, sh: Run = run,
86
+ // gh only knows GitHub: any other remote lands by rebase and push.
87
+ hasGh = !!Bun.which("gh") && /github\.com/.test(git(project, "remote", "get-url", "origin").out)): Landed {
88
+ if (!SLUG.test(id)) return { ok: false, why: `"${id}" is not a run id` };
89
+ const dir = codeDir(project, id), branch = branchOf(id);
90
+ if (dir === project) return { ok: false, why: "this run has no worktree" };
91
+ const clean = (d: string) => !sh(["git", "status", "--porcelain"], d).out;
92
+ if (!clean(dir)) return { ok: false, why: "the worktree has uncommitted changes; commit them first" };
93
+ const base = baseOf(project);
94
+ const finish = (how: "pr" | "push", url?: string): Landed => {
95
+ git(project, "fetch", "origin");
96
+ if (!landed(dir, base, branch)) return { ok: false, why: `${how === "pr" ? "the pull request" : "the push"} went through but ${branch} is not on ${base} yet; worktree kept` };
97
+ // The worktree is clean (checked above); --force is only for the untracked node_modules link.
98
+ const rm = git(project, "worktree", "remove", "--force", dir);
99
+ if (!rm.ok) return { ok: false, why: `landed, but the worktree stays: ${rm.why}` };
100
+ git(project, "branch", "-D", branch);
101
+ git(project, "push", "origin", "--delete", branch);
102
+ patchState(project, id, { worktree: null, branch: null });
103
+ return { ok: true, how, url };
104
+ };
105
+
106
+ const push = sh(["git", "push", "-u", "origin", branch], dir);
107
+ if (!push.ok) return { ok: false, why: `push of ${branch} failed: ${push.why}` };
108
+
109
+ if (hasGh) {
110
+ const subject = git(dir, "log", "-1", "--format=%s").out;
111
+ const pr = sh(["gh", "pr", "create", "--base", base, "--head", branch, "--title", subject, "--body", `Run ${id}.`], dir);
112
+ // An existing pull request for the branch is fine: merge that one.
113
+ const url = pr.ok ? pr.out.split("\n").pop() : undefined;
114
+ if (!pr.ok && !/already exists/i.test(pr.why)) return { ok: false, why: `pull request failed: ${pr.why}; ${branch} is pushed` };
115
+ const merge = sh(["gh", "pr", "merge", branch, "--rebase"], dir);
116
+ if (!merge.ok) return { ok: false, why: `pull request not merged (${merge.why}); left open, worktree kept` };
117
+ return finish("pr", url);
118
+ }
119
+
120
+ for (let i = 0; i < 2; i++) {
121
+ const f = sh(["git", "fetch", "origin"], dir);
122
+ if (!f.ok) return { ok: false, why: `fetch failed: ${f.why}` };
123
+ const rb = sh(["git", "rebase", `origin/${base}`], dir);
124
+ if (!rb.ok) { sh(["git", "rebase", "--abort"], dir); return { ok: false, why: `rebase onto ${base} conflicts: ${rb.why}; worktree kept` }; }
125
+ if (sh(["git", "push", "origin", `HEAD:${base}`], dir).ok) return finish("push");
126
+ }
127
+ return { ok: false, why: `${base} kept moving; push rejected twice. Worktree kept` };
128
+ }
@@ -0,0 +1,102 @@
1
+ // Write-back against a throwaway .kaizen/: gh and Google are injected, nothing leaves the machine.
2
+ import { test, expect, beforeEach, afterAll } from "bun:test";
3
+ import { mkdtempSync, mkdirSync, writeFileSync, readFileSync, rmSync, realpathSync } from "node:fs";
4
+ import { join } from "node:path";
5
+ import { tmpdir } from "node:os";
6
+ import { readIcebox, writeIcebox, writeSources, sift } from "./sources.ts";
7
+ import { writeBacks } from "./writeback.ts";
8
+
9
+ const root = realpathSync(mkdtempSync(join(tmpdir(), "kaizen-wb-")));
10
+ let state: string;
11
+ let n = 0;
12
+ beforeEach(() => { state = join(root, String(n++), ".kaizen"); mkdirSync(state, { recursive: true }); });
13
+ afterAll(() => rmSync(root, { recursive: true, force: true }));
14
+
15
+ const GH = "https://github.com/acme/app/issues";
16
+ const SHEET = "https://docs.google.com/spreadsheets/d/abc123/edit#gid=7";
17
+ const item = (source: string, extra = {}) => ({ id: "a1", text: "Dark mode", source, at: "2026-10-17", status: "done" as const, run: "r1", ...extra });
18
+ const source = (url: string, writeback = true) => writeSources(state, [{ url, added: "x", seen: [], ...(writeback ? { writeback } : {}) }]);
19
+
20
+ test("github: a done item closes its issue once, with fixed text", async () => {
21
+ source(GH);
22
+ writeIcebox(state, [item(GH, { notes: "#12 · https://github.com/acme/app/issues/12" })]);
23
+ const calls: string[][] = [];
24
+ const gh = async (a: string[]) => { calls.push(a); return { code: 0, err: "" }; };
25
+ await writeBacks(state, { gh });
26
+ await writeBacks(state, { gh });
27
+ expect(calls).toEqual([["issue", "close", "12", "--repo", "acme/app", "--comment", "Done in kaizen run r1."]]);
28
+ expect(readIcebox(state)[0]!.written).toBeTruthy();
29
+ });
30
+
31
+ test("off by default: nothing is sent", async () => {
32
+ source(GH, false);
33
+ writeIcebox(state, [item(GH, { notes: "#12 · x" })]);
34
+ let sent = 0;
35
+ await writeBacks(state, { gh: async () => { sent++; return { code: 0, err: "" }; } });
36
+ expect(sent).toBe(0);
37
+ });
38
+
39
+ test("a failed write stays done, keeps the error and is tried again", async () => {
40
+ source(GH);
41
+ writeIcebox(state, [item(GH, { notes: "#12 · x" })]);
42
+ await writeBacks(state, { gh: async () => ({ code: 1, err: "boom\nHTTP 401" }) });
43
+ expect(readIcebox(state)[0]).toMatchObject({ status: "done", writeError: "HTTP 401" });
44
+ expect(readIcebox(state)[0]!.written).toBeUndefined();
45
+ await writeBacks(state, { gh: async () => ({ code: 0, err: "" }) });
46
+ expect(readIcebox(state)[0]!.written).toBeTruthy();
47
+ expect(readIcebox(state)[0]!.writeError).toBeUndefined();
48
+ });
49
+
50
+ function google(rows: string[][]) {
51
+ const puts: { url: string; body: any }[] = [];
52
+ const doFetch = (async (url: string, init: any = {}) => {
53
+ if (init.method === "PUT") { puts.push({ url: decodeURIComponent(url), body: JSON.parse(init.body) }); return Response.json({}); }
54
+ if (url.includes("fields=sheets")) return Response.json({ sheets: [{ properties: { sheetId: 7, title: "Req" } }] });
55
+ const range = /values\/([^?]+)/.exec(decodeURIComponent(url))![1]!;
56
+ const m = /!(\d+):(\d+)$/.exec(range)!;
57
+ return Response.json({ values: rows.slice(+m[1]! - 1, +m[2]!) });
58
+ }) as unknown as typeof fetch;
59
+ return { puts, doFetch };
60
+ }
61
+
62
+ test("sift records the sheet row an item came from", () => {
63
+ const { items } = sift("sheet", "Request\tStatus\nDark mode\topen\nFaster search\topen\n");
64
+ expect(items.map((i) => [i.text, i.row])).toEqual([["Dark mode", 2], ["Faster search", 3]]);
65
+ });
66
+
67
+ const token = async () => "t";
68
+
69
+ test("sheet: adds a Status column when missing, then writes Done in the row", async () => {
70
+ source(SHEET);
71
+ writeIcebox(state, [item(SHEET, { row: 2 })]);
72
+ const g = google([["Request"], ["Dark mode"]]);
73
+ await writeBacks(state, { fetch: g.doFetch, token });
74
+ expect(g.puts.map((p) => [p.url.split("values/")[1]!.split("?")[0], p.body.values[0][0]])).toEqual([["'Req'!B1", "Status"], ["'Req'!B2", "Done in kaizen run r1."]]);
75
+ expect(readIcebox(state)[0]!.written).toBeTruthy();
76
+ });
77
+
78
+ test("sheet: a row that no longer holds the request is left alone", async () => {
79
+ source(SHEET);
80
+ writeIcebox(state, [item(SHEET, { row: 2 })]);
81
+ const g = google([["Request", "Status"], ["Something else", ""]]);
82
+ await writeBacks(state, { fetch: g.doFetch, token });
83
+ expect(g.puts).toEqual([]);
84
+ expect(readIcebox(state)[0]).toMatchObject({ writeError: "the row has changed since it was gathered" });
85
+ });
86
+
87
+ test("sheet: uses the existing Status column", async () => {
88
+ source(SHEET);
89
+ writeIcebox(state, [item(SHEET, { row: 2 })]);
90
+ const g = google([["Request", "Owner", "Status"], ["Dark mode", "x", ""]]);
91
+ await writeBacks(state, { fetch: g.doFetch, token });
92
+ expect(g.puts.map((p) => p.url.split("values/")[1]!.split("?")[0])).toEqual(["'Req'!C2"]);
93
+ });
94
+
95
+ test("sheet: a header below row 10 is still found", async () => {
96
+ source(SHEET);
97
+ const rows = [...Array.from({ length: 14 }, () => [""]), ["Request", "Status"], ["Dark mode", ""]];
98
+ writeIcebox(state, [item(SHEET, { row: 16 })]);
99
+ const g = google(rows);
100
+ await writeBacks(state, { fetch: g.doFetch, token });
101
+ expect(g.puts.map((p) => p.url.split("values/")[1]!.split("?")[0])).toEqual(["'Req'!B16"]);
102
+ });
@@ -0,0 +1,79 @@
1
+ // Closes the loop: when a promoted request's run is done, tell the place it came from.
2
+ // A GitHub issue gets a comment and is closed (through the user's own `gh` login); a
3
+ // sheet row gets "Done in <run>" in its Status column. Off unless the source says so.
4
+ // The text sent is fixed: nothing the source said is ever sent back or put in a command.
5
+ import { normalise } from "./state.ts";
6
+ import { readIcebox, writeIcebox, readSources, type Signal, type Source } from "./sources.ts";
7
+ import * as google from "./google.ts";
8
+
9
+ type Gh = (args: string[]) => Promise<{ code: number; err: string }>;
10
+ const runGh: Gh = async (args) => {
11
+ try {
12
+ const p = Bun.spawn(["gh", ...args], { stdout: "ignore", stderr: "pipe" });
13
+ const err = await new Response(p.stderr).text();
14
+ return { code: await p.exited, err };
15
+ } catch { return { code: 127, err: "The gh command was not found. Install GitHub's CLI and run `gh auth login`." }; }
16
+ };
17
+
18
+ const say = (run: string) => `Done in kaizen run ${run}.`;
19
+ const col = (i: number) => { let s = ""; for (i++; i; i = Math.floor((i - 1) / 26)) s = String.fromCharCode(65 + ((i - 1) % 26)) + s; return s; };
20
+
21
+ async function github(it: Signal, src: Source, gh: Gh): Promise<string | null> {
22
+ const repo = /^https:\/\/(?:www\.)?github\.com\/([\w.-]+\/[\w.-]+)\/issues\/?$/.exec(src.url.split(/[?#]/)[0]!)?.[1];
23
+ const n = /^#(\d+) /.exec(it.notes ?? "")?.[1];
24
+ if (!repo || !n) return "no issue number was kept for this item";
25
+ const r = await gh(["issue", "close", n, "--repo", repo, "--comment", say(it.run!)]);
26
+ return r.code ? (r.err.trim().split("\n").pop() || `gh exited ${r.code}`).slice(0, 200) : null;
27
+ }
28
+
29
+ // The row's own request cell is re-read first: if rows moved since the gather, the
30
+ // status goes nowhere rather than onto the wrong request.
31
+ async function sheet(it: Signal, src: Source, doFetch: typeof fetch, token?: () => Promise<string>): Promise<string | null> {
32
+ const id = /^https:\/\/docs\.google\.com\/spreadsheets\/d\/([\w-]+)/.exec(src.url)?.[1];
33
+ if (!id || !it.row) return "no row number was kept for this item";
34
+ const gid = /[#&?]gid=(\d+)/.exec(src.url)?.[1] ?? "0";
35
+ const meta = await google.sheets(`${id}?fields=sheets.properties(sheetId,title)`, {}, doFetch, token);
36
+ const title = meta.sheets?.find((s: any) => String(s.properties.sheetId) === gid)?.properties.title as string | undefined;
37
+ if (!title) return "the sheet's tab was not found";
38
+ const q = `'${title.replace(/'/g, "''")}'`;
39
+ const head = (await google.sheets(`${id}/values/${encodeURIComponent(`${q}!1:50`)}`, {}, doFetch, token)).values as string[][] | undefined ?? [];
40
+ const at = it.row <= 50 ? head[it.row - 1] : (await google.sheets(`${id}/values/${encodeURIComponent(`${q}!${it.row}:${it.row}`)}`, {}, doFetch, token)).values?.[0];
41
+ const want = normalise(it.text);
42
+ if (!at?.some((c) => { const t = normalise(c ?? ""); return t && (t.startsWith(want) || want.startsWith(t)); })) return "the row has changed since it was gathered";
43
+ // The Status column is the one headed so; with none, it is added after the last header cell.
44
+ let hr = head.findIndex((r) => r.some((c) => /^status$/i.test((c ?? "").trim())));
45
+ let c = hr >= 0 ? head[hr]!.findIndex((x) => /^status$/i.test((x ?? "").trim())) : -1;
46
+ if (c < 0) {
47
+ hr = head.findIndex((r) => r.some((x) => x?.trim()));
48
+ if (hr < 0) return "the sheet has no header row to add a Status column to";
49
+ c = head[hr]!.length;
50
+ await google.sheets(`${id}/values/${encodeURIComponent(`${q}!${col(c)}${hr + 1}`)}?valueInputOption=RAW`, { method: "PUT", body: { values: [["Status"]] } }, doFetch, token);
51
+ }
52
+ await google.sheets(`${id}/values/${encodeURIComponent(`${q}!${col(c)}${it.row}`)}?valueInputOption=RAW`, { method: "PUT", body: { values: [[say(it.run!)]] } }, doFetch, token);
53
+ return null;
54
+ }
55
+
56
+ const busy = new Set<string>();
57
+
58
+ // Every done item whose source asks for it and that has not been written yet. A failure
59
+ // is kept on the item and tried again on the next call; success is stamped and never repeated.
60
+ export async function writeBacks(state: string, deps: { gh?: Gh; fetch?: typeof fetch; token?: () => Promise<string> } = {}): Promise<void> {
61
+ const sources = readSources(state);
62
+ for (const it of readIcebox(state)) {
63
+ const src = sources.find((s) => s.url === it.source);
64
+ const key = state + " " + it.id;
65
+ if (it.status !== "done" || !it.run || it.written || !src?.writeback || busy.has(key)) continue;
66
+ busy.add(key);
67
+ let err: string | null;
68
+ try {
69
+ err = /github\.com/.test(src.url) ? await github(it, src, deps.gh ?? runGh)
70
+ : /docs\.google\.com\/spreadsheets/.test(src.url) ? await sheet(it, src, deps.fetch ?? fetch, deps.token)
71
+ : "this kind of source cannot be written to";
72
+ } catch (e) { err = ((e as Error).message || String(e)).slice(0, 200); }
73
+ finally { busy.delete(key); }
74
+ const ice = readIcebox(state), now = ice.find((s) => s.id === it.id);
75
+ if (!now) continue;
76
+ if (err) { now.writeError = err; delete now.written; } else { now.written = new Date().toISOString(); delete now.writeError; }
77
+ writeIcebox(state, ice);
78
+ }
79
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "kaizen-agent",
3
- "version": "1.34.2",
3
+ "version": "1.34.4",
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": {
@@ -111,21 +111,14 @@ worth two more stages to confirm a one-line message now reads correctly. A findi
111
111
  `high` or above pulls the run back onto the full path whatever its size, since that
112
112
  severity is the loop's whole reason for existing.
113
113
 
114
- **Trivial changes take the direct path.** Where the request is one obvious change with
115
- nothing to decide — a typo, a label, a one-line copy or config tweak — a plan is longer
116
- than the work and a review re-reads what the user is about to look at anyway. Write
117
- `00-request.md`, build straight away, write `03-impl.md`, and stop at
118
- `stage: "review"`, `awaiting: "approvals.review"`: the user checks the result and
119
- approves it or says what to change. No `01-plan.md`, no plan approval, no
120
- `04-review.md` — the user's check is the review. Never the direct path for money, data,
121
- a migration, security, anything hard to undo, or a request with an open question; in
122
- `plan-only`, `auto`, or `/kaizen full`; or when in doubt. Work that turns out bigger
123
- mid-build stops and goes back to a plan.
114
+ **Short work still gets a run.** A one-line change is not skipped past the stages: the card on the board moves through plan, build and review like any other. Run it with `runner: lite` and the short path above — a brief plan, the build, a short review — so the cost stays small and the run stays visible.
124
115
 
125
116
  This is a judgement, not a threshold to enforce: fifty lines that change how money is
126
- rounded are not a small change. Say which path you took and why, in one line, when the
117
+ rounded are not a small change. Say which path you took (short or full) and why, in one line, when the
127
118
  run starts.
128
119
 
120
+ **Follow-ups are runs too.** Both runners record the same artifacts: `01-plan.md`, `03-impl.md`, `04-review.md`, and a final `state.json` — `lite` and `full` differ only in who executes the stages. A message the user sends later in the same session is judged by what it asks: a question, explanation or chat is answered, no run; a request to change or produce something is building, so it goes through the flow. While the previous run has not reached `done` (including awaiting its final approval), the change is a revise of that run, recorded in `02-approval.md`. After `done` or `abandoned`, open a new run whose `00-request.md` is the user's words verbatim, then a line `Follow-up of: <previous run id>`. Never reopen a finished run. When unsure whether a message asks for a change, ask once.
121
+
129
122
  ## Modes
130
123
 
131
124
  Set by `mode` in config, overridable per invocation.
@@ -131,6 +131,11 @@ git:
131
131
  # Create a branch before the implement stage when on the default branch.
132
132
  # Ignored when the project is not a git repository.
133
133
  branch_before_implement: true
134
+ # Build each run on its own branch (kaizen/<run-id>) in its own git worktree under
135
+ # .kaizen/wt/, so runs and people working at the same time never share a folder. Done
136
+ # lands the branch by pull request (gh) or a rebase and push. Ignored when the
137
+ # project is not a git repository with a remote.
138
+ worktree_per_run: true
134
139
 
135
140
  ui:
136
141
  # Mouse in the TUI: click a row or card to select it, click it again to open or
@@ -24,6 +24,7 @@ nearest first, so a change lands globally unless this project keeps its own conf
24
24
  | `review.write_memory` | `true`, `false` | Let the reviewer append lessons to `memory.md` |
25
25
  | `git.auto_commit` | `true`, `false` | Commit the work when a run finishes |
26
26
  | `git.branch_before_implement` | `true`, `false` | Branch before building when on the default branch |
27
+ | `git.worktree_per_run` | `true`, `false` | Build each run on its own branch in its own worktree and land it by pull request |
27
28
  | `ui.mouse` | `false`, `true` | Click to select, click again to act; while on, the terminal cannot select text |
28
29
  | `subagents.model` | `inherit`, `opus`, `sonnet`, `haiku` | Model the stage subagents run on (Claude Code only) |
29
30
  | `subagents.effort` | `inherit`, `low`, `medium`, `high` | Reasoning effort for the stage subagents (Claude Code only) |
@@ -9,11 +9,6 @@ builder's contract. The planner and reviewer do not read it.
9
9
  **Agent:** builder. **Input:** `01-plan.md`, `02-approval.md`, the track.
10
10
  **Output:** the deliverable itself, plus `03-impl.md`.
11
11
 
12
- On the direct path (see `spec.md`, Stage 0) there is no plan: the input is
13
- `00-request.md`, and the request is the scope. If the work turns out to need a
14
- decision, more than the one obvious change, or anything the direct path excludes,
15
- stop and send the run back to `stage: "plan"`.
16
-
17
12
  The builder produces whatever the track's deliverable is — code, a chapter, the
18
13
  campaign copy, the runbook — and nothing else.
19
14
 
@@ -44,6 +39,21 @@ Rules:
44
39
  folder there is no branch to make and no commit to skip, and that absence is not a
45
40
  finding.
46
41
 
42
+ **Build on the run's own branch, where `git.worktree_per_run` is on** (the default, in a
43
+ git repository with a remote). Right after `stage: "build"` is written and before the
44
+ first edit, run `kaizen worktree start` with the run id from the project folder. It
45
+ makes `.kaizen/wt/<id>` on the branch `kaizen/<id>`, cut from the remote default branch,
46
+ and records `worktree` and `branch` in `state.json`. From then on every edit, test run
47
+ and commit of the code happens inside that folder, and never in the project folder,
48
+ which other sessions may be using. Everything the run *records* stays in the project's
49
+ `.kaizen/runs/<id>/`: `03-impl.md`, `progress.md`, attachments and `state.json` are
50
+ written there by absolute path. The docs vault is committed, so its notes are updated in
51
+ the worktree with the code. The worktree has its own checkout: run `bun install` or the
52
+ project's equivalent there before the baseline. Fix iterations and the reviewer read the
53
+ code in the same folder. If `kaizen worktree start` fails, say why and stop; do not fall
54
+ back to the project folder unasked. A run whose `state.json` has no `worktree` builds in
55
+ place, exactly as before.
56
+
47
57
  **Keep `progress.md` while you work.** Before the first change, write
48
58
  `runs/<id>/progress.md` with one line per bullet of the plan's Work list, in order:
49
59
  `- todo: <bullet text>`. Change a line's prefix to `- doing:` when you start that item
@@ -87,8 +97,7 @@ cover, and note in `03-impl.md` what was missing from it.
87
97
  deviations from the plan and why, verification carried out with its actual result,
88
98
  anything deliberately left undone, a `## Preview` section with its `### How to test`
89
99
  steps, and — in a git repository — a `## Commit` section, both described below. A
90
- report without `## Preview` leaves the board's Preview tab empty, in every runner and
91
- on the direct path alike. Write each changed path in backticks, relative
100
+ report without `## Preview` leaves the board's Preview tab empty, in every runner. Write each changed path in backticks, relative
92
101
  to the repository, and no other path that way: the board commits a run by the changed
93
102
  files its report names, so a changed path written any other way is left out of the
94
103
  commit, and a backticked path the run did not change is taken into it. Report failures honestly; a false claim of
@@ -99,9 +108,14 @@ commands to start it locally and the URL to open, copied from the material's own
99
108
  where one exists. For anything else — a document, copy, a runbook, a memo — the path
100
109
  to open, or the result itself when it fits in a screen. The board shows this section on
101
110
  the run's card, so a person can try the work without reading the rest of the report.
102
- Where the result can be seen, put screenshots of it here: save each under
111
+ Where the result can be seen, put screenshots of it here. On a `code` track this is required, not optional: run the result and attach proof.
112
+ A UI (a page, a board, a terminal screen, an app window) gets a screenshot of the changed
113
+ screen; a CLI, library or API gets the real output of the command that exercises it, pasted
114
+ in a code block. A code report with no proof is incomplete. Save each under
103
115
  `runs/<id>/attachments/` (letters, digits, `.`, `-`, `_` only) and embed it as
104
116
  `![what it shows](attachments/<name>.png)`; the board shows them in the Preview tab.
117
+ A headless browser does it: `playwright screenshot <url> runs/<id>/attachments/<name>.png`.
118
+ Where no proof can be produced, say why in one line under `## Preview`.
105
119
 
106
120
  It also says how to test the result, under a `### How to test` subhead: numbered steps a
107
121
  person follows to check the run did what was asked, each an action and what they should
@@ -85,17 +85,23 @@ subagents at all.
85
85
 
86
86
  ## Final approval
87
87
 
88
- Controlled by `approvals.review`, skipped in `auto`. A direct-path run always stops here,
89
- whatever `approvals.review` says: the user's check is its only review.
88
+ Controlled by `approvals.review`, skipped in `auto`.
90
89
 
91
- Report: what was built, verification results, findings fixed, findings still open,
90
+ Report: what was built (for a code run, show its Preview proof), verification results, findings fixed, findings still open,
92
91
  every criterion the reviewer answered `CANNOT VERIFY` with the check it says a human
93
92
  must make, and anything the builder noted as out of scope. Where the run used `runner: lite`, say
94
93
  so in that report — the reviewer saw the work, and nobody should read a same-session
95
- review as an independent one because the output looked the same. Where the run took
96
- the direct path, say there was no plan and no review: the user's check is the review.
94
+ review as an independent one because the output looked the same.
95
+ Later build requests in the same session follow *Follow-ups are runs too* in [`spec.md`](spec.md): a revise of this run until it is `done`, then a new linked run.
97
96
  Then stop. Committing, pushing, or
98
- opening a pull request happens only if the user asks.
97
+ opening a pull request happens only if the user asks; the board's Approve button is that ask for commit and push, and its prompt says so.
98
+ For a run with a `worktree` in `state.json`, that ask means: commit in the worktree, then
99
+ `kaizen worktree land` with the run id, from the project folder,. It pushes `kaizen/<id>`, opens a pull
100
+ request and rebase-merges it when GitHub says it is clean (without `gh`: a rebase onto
101
+ the default branch and a push, tried twice), and removes the worktree and branch only
102
+ once the default branch holds the work. A conflict or an unmerged pull request leaves
103
+ everything in place: report its message as printed and let the user decide. The run is
104
+ `done` once approved; whether the work landed is reported, not hidden.
99
105
 
100
106
  **Open findings are offered by number.** List them with the reviewer's numbering
101
107
  intact and let the user name which to fix — "fix 2 and 5". A number not named is not
@@ -43,7 +43,9 @@ Five checks are universal, in every track:
43
43
  now is a `high` finding at minimum, whatever else the work achieved. If the builder
44
44
  recorded no baseline, that absence is itself the finding. So is a `03-impl.md`
45
45
  without a `## Preview` section, or one without `### How to test` steps: `medium`,
46
- since the user is left with no way to try the work.
46
+ since the user is left with no way to try the work. A `code` run whose
47
+ `## Preview` has no proof (screenshot for a UI, real command output otherwise) and gives
48
+ no reason is `high`.
47
49
 
48
50
  5. **Acceptance** — every criterion, `G-nn` and `R-nn`, answered against the work
49
51
  rather than against `03-impl.md`, by trying to falsify it: name the concrete check
@@ -5,8 +5,7 @@ follow this document; only the way stages are dispatched differs per tool. If an
5
5
  adapter and this file disagree, this file wins.
6
6
 
7
7
  A stage may only start when the previous stage's artifact exists on disk and the approval
8
- between them, if enabled, has been resolved. The one exception is the direct path
9
- (see *Stage 0*), which skips the plan and the review on purpose.
8
+ between them, if enabled, has been resolved.
10
9
 
11
10
  ---
12
11
 
@@ -144,16 +143,9 @@ the config said the day someone came back to it.
144
143
  If the request remains ambiguous enough that a plan would be guesswork, ask now. One
145
144
  round of questions here is cheaper than a rejected plan.
146
145
 
147
- **The direct path.** Where the request is one obvious change with nothing to decide —
148
- a typo, a label, a one-line copy or config tweak — and being wrong is cheap and
149
- visible, intake may skip the plan and the review: say so in one line, set
150
- `stage: "build"`, build against `00-request.md`, write `03-impl.md` — its `## Preview`
151
- included, since the user's check starts there — then set
152
- `stage: "review"`, `awaiting: "approvals.review"` and stop for the user to check the
153
- result. No `01-plan.md` or `04-review.md` is written, and no `02-approval.md` unless
154
- the user revises at that stop. Never for money, data, a migration, security, anything
155
- hard to undo, or a request with an open question; never in `plan-only` or `auto`
156
- mode or under `runner: full`; and when in doubt, not.
146
+ **Short work is not skipped.** Even a one-line change gets `01-plan.md` and `04-review.md`, so the run moves through every stage. Keep them brief and run under `runner: lite`.
147
+
148
+ **Follow-ups are runs too.** Both runners record the same artifacts: `01-plan.md`, `03-impl.md`, `04-review.md`, and a final `state.json` — `lite` and `full` differ only in who executes the stages. A message the user sends later in the same session is judged by what it asks: a question, explanation or chat is answered, no run; a request to change or produce something is building, so it goes through the flow. While the previous run has not reached `done` (including awaiting its final approval), the change is a revise of that run, recorded in `02-approval.md`. After `done` or `abandoned`, open a new run whose `00-request.md` is the user's words verbatim, then a line `Follow-up of: <previous run id>`. Never reopen a finished run. When unsure whether a message asks for a change, ask once.
157
149
 
158
150
  ---
159
151