kaizen-agent 1.34.2 → 1.34.3
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/google.ts +99 -0
- package/cli/install.ts +14 -0
- package/cli/settings.ts +4 -0
- package/cli/sources.test.ts +39 -10
- package/cli/sources.ts +37 -9
- package/cli/state.test.ts +11 -1
- package/cli/state.ts +20 -8
- package/cli/web.ts +43 -7
- package/cli/worktree.test.ts +113 -0
- package/cli/worktree.ts +128 -0
- package/cli/writeback.test.ts +102 -0
- package/cli/writeback.ts +79 -0
- package/package.json +1 -1
- package/skills/kaizen/SKILL.md +4 -11
- package/skills/kaizen/config.default.yml +5 -0
- package/skills/kaizen/settings.md +1 -0
- package/skills/kaizen/spec-build.md +22 -8
- package/skills/kaizen/spec-main.md +12 -6
- package/skills/kaizen/spec-review.md +3 -1
- package/skills/kaizen/spec.md +4 -12
- package/web/board/cards.ts +2 -1
- package/web/board/icebox.ts +51 -6
- package/web/board/panel.ts +1 -0
- package/web/board/settings.ts +26 -1
- package/web/board.html +1 -1
- package/web/board.js +24 -21
- package/web/notebook.js +1 -1
- package/web/term.js +18 -18
- package/web/term.ts +42 -1
|
@@ -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
|
+
});
|
package/cli/worktree.ts
ADDED
|
@@ -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
|
+
});
|
package/cli/writeback.ts
ADDED
|
@@ -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
package/skills/kaizen/SKILL.md
CHANGED
|
@@ -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
|
-
**
|
|
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
|
|
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:
|
|
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
|
``; 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`.
|
|
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.
|
|
96
|
-
the
|
|
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
|
package/skills/kaizen/spec.md
CHANGED
|
@@ -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.
|
|
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
|
-
**
|
|
148
|
-
|
|
149
|
-
|
|
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
|
|
package/web/board/cards.ts
CHANGED
|
@@ -43,7 +43,7 @@ function card(c) {
|
|
|
43
43
|
el.dataset.k = k;
|
|
44
44
|
el.setAttribute("role", "button");
|
|
45
45
|
const st = ICON[c.status] || "idea",
|
|
46
|
-
idt = c.kind === "idea" ? "idea" : c.text;
|
|
46
|
+
idt = c.kind === "idea" ? "idea" : c.text.replace(/-/g, " ");
|
|
47
47
|
// The status icon carries the state; what a waiting run needs is its tooltip.
|
|
48
48
|
const sttl = c.status === "waiting" ? need(c.awaiting) || "needs you" : c.status || "idea";
|
|
49
49
|
// Footer: words on the left (project, what it needs), icon-only marks on the right.
|
|
@@ -73,6 +73,7 @@ function card(c) {
|
|
|
73
73
|
// A gathered idea's notes open with the stamp promote() writes (ingest() wrote it before
|
|
74
74
|
// the icebox); its chip names the source.
|
|
75
75
|
const g = c.notes && /^\[[^\]]*\] (?:Gathered from|Promoted from the icebox:) (.+?)\. Outside text/.exec(c.notes);
|
|
76
|
+
if (c.branch) chips.push(`<span class="chip" title="Built on its own branch, ${esc(c.branch)}">branch</span>`);
|
|
76
77
|
if (g) chips.push(`<span class="chip" title="From ${esc(g[1])}">gathered</span>`);
|
|
77
78
|
else if (c.notes) chips.push(`<span class="chip">notes</span>`);
|
|
78
79
|
// Information only: the panel's button is where it is committed and pushed.
|