kaizen-agent 1.7.8 → 1.8.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/LICENSE +21 -21
- package/README.md +694 -658
- package/agents/kaizen-builder.md +82 -77
- package/agents/kaizen-planner.md +250 -250
- package/agents/kaizen-reviewer.md +127 -127
- package/cli/board.test.ts +55 -0
- package/cli/dashboard.ts +1188 -1187
- package/cli/install.ts +76 -3
- package/cli/publish-gh.sh +27 -27
- package/cli/release.sh +45 -45
- package/cli/settings.ts +7 -3
- package/cli/sources.test.ts +494 -0
- package/cli/sources.ts +323 -0
- package/cli/state.test.ts +422 -197
- package/cli/state.ts +989 -700
- package/cli/web.ts +182 -11
- package/commands/kaizen-abort.md +5 -5
- package/commands/kaizen-approve.md +5 -5
- package/commands/kaizen-auto.md +6 -6
- package/commands/kaizen-backlog.md +5 -5
- package/commands/kaizen-config.md +5 -5
- package/commands/kaizen-full.md +5 -5
- package/commands/kaizen-gather.md +5 -0
- package/commands/kaizen-init.md +5 -5
- package/commands/kaizen-install.md +5 -5
- package/commands/kaizen-lite.md +5 -5
- package/commands/kaizen-plan.md +6 -6
- package/commands/kaizen-reject.md +6 -6
- package/commands/kaizen-review.md +6 -6
- package/commands/kaizen-run.md +5 -5
- package/commands/kaizen-status.md +5 -5
- package/package.json +3 -1
- package/skills/kaizen/README.md +283 -281
- package/skills/kaizen/SKILL.md +334 -440
- package/skills/kaizen/adapters.md +84 -77
- package/skills/kaizen/config.default.yml +181 -167
- package/skills/kaizen/gather.md +50 -0
- package/skills/kaizen/install.md +42 -0
- package/skills/kaizen/settings.md +52 -0
- package/skills/kaizen/spec-build.md +131 -119
- package/skills/kaizen/spec-main.md +109 -104
- package/skills/kaizen/spec-plan.md +201 -201
- package/skills/kaizen/spec-review.md +121 -121
- package/skills/kaizen/spec.md +154 -153
- package/skills/kaizen/status.md +43 -0
- package/skills/kaizen-help/SKILL.md +131 -129
- package/web/board.html +295 -126
package/cli/state.test.ts
CHANGED
|
@@ -1,197 +1,422 @@
|
|
|
1
|
-
// State module against a throwaway .kaizen/. Nothing here spawns a terminal: the
|
|
2
|
-
// launch path is covered only up to the command it would run.
|
|
3
|
-
import { test, expect, beforeAll, afterAll } from "bun:test";
|
|
4
|
-
import { mkdtempSync, mkdirSync, writeFileSync, readFileSync, rmSync, utimesSync } from "node:fs";
|
|
5
|
-
import { join } from "node:path";
|
|
6
|
-
import { tmpdir } from "node:os";
|
|
7
|
-
import {
|
|
8
|
-
readRuns, readInbox, writeInbox, replaceIdea, abandonRun, allBacklog, backlog,
|
|
9
|
-
statusOf, columnOf, parseItem, startedRuns, boardCards, readArchive, setArchived,
|
|
10
|
-
requestOf, agentFlags, readNotes, writeNotes, manualCommand, parseNotes, formatNote, appendNote, saveAttachment,
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
run("2026-09-
|
|
27
|
-
|
|
28
|
-
const
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
{ status: "open", text: "
|
|
36
|
-
{ status: "
|
|
37
|
-
{ status: "
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
expect(runs
|
|
46
|
-
expect(runs[
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
const
|
|
52
|
-
|
|
53
|
-
expect(statusOf(
|
|
54
|
-
expect(statusOf(
|
|
55
|
-
expect(statusOf(
|
|
56
|
-
expect(statusOf({ ...stale!,
|
|
57
|
-
});
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
expect(
|
|
62
|
-
expect(columnOf("implement", "
|
|
63
|
-
expect(columnOf("implement", "
|
|
64
|
-
expect(columnOf("
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
const
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
replaceIdea(state, "
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
expect(
|
|
93
|
-
expect(
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
expect(groups
|
|
100
|
-
expect(
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
expect(
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
expect(
|
|
118
|
-
|
|
119
|
-
expect(
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
expect(
|
|
127
|
-
});
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
expect(
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
expect(
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
const
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
expect(
|
|
160
|
-
expect(
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
});
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
}
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
expect(
|
|
193
|
-
expect(
|
|
194
|
-
expect(
|
|
195
|
-
expect(
|
|
196
|
-
|
|
197
|
-
|
|
1
|
+
// State module against a throwaway .kaizen/. Nothing here spawns a terminal: the
|
|
2
|
+
// launch path is covered only up to the command it would run.
|
|
3
|
+
import { test, expect, beforeAll, afterAll } from "bun:test";
|
|
4
|
+
import { mkdtempSync, mkdirSync, writeFileSync, readFileSync, rmSync, utimesSync, existsSync, readdirSync } from "node:fs";
|
|
5
|
+
import { join, dirname } from "node:path";
|
|
6
|
+
import { tmpdir } from "node:os";
|
|
7
|
+
import {
|
|
8
|
+
readRuns, readInbox, writeInbox, replaceIdea, abandonRun, allBacklog, backlog,
|
|
9
|
+
statusOf, columnOf, parseItem, startedRuns, boardCards, readArchive, setArchived,
|
|
10
|
+
requestOf, agentFlags, readNotes, writeNotes, manualCommand, parseNotes, formatNote, appendNote, saveAttachment,
|
|
11
|
+
reviewFindings, pickFindings, fixPrompt, gitStatus, runGit, commitPush, readCommit, findTerminal, pruneRuns, unrechecked, notice, notifier, notifyArgs, LOGO, type Card,
|
|
12
|
+
} from "./state.ts";
|
|
13
|
+
|
|
14
|
+
let state: string;
|
|
15
|
+
const run = (id: string, s: object, request = "") => {
|
|
16
|
+
const dir = join(state, "runs", id);
|
|
17
|
+
mkdirSync(dir, { recursive: true });
|
|
18
|
+
writeFileSync(join(dir, "state.json"), JSON.stringify(s));
|
|
19
|
+
if (request) writeFileSync(join(dir, "00-request.md"), request);
|
|
20
|
+
return dir;
|
|
21
|
+
};
|
|
22
|
+
|
|
23
|
+
beforeAll(() => {
|
|
24
|
+
state = join(mkdtempSync(join(tmpdir(), "kaizen-")), ".kaizen");
|
|
25
|
+
mkdirSync(state, { recursive: true });
|
|
26
|
+
run("2026-09-01-waiting", { stage: "plan", awaiting: "approvals.plan" }, "# Request\n\nAdd OAuth login\n");
|
|
27
|
+
run("2026-09-02-done", { stage: "done", awaiting: null }, "# Request\n\nRename cli flags\n");
|
|
28
|
+
const old = run("2026-09-03-stale", { stage: "build", awaiting: null });
|
|
29
|
+
const then = new Date(Date.now() - 2 * 3600_000);
|
|
30
|
+
utimesSync(join(old, "state.json"), then, then);
|
|
31
|
+
writeFileSync(join(state, "runs", "2026-09-02-done", "06-backlog.md"),
|
|
32
|
+
"# Backlog\n\n- open: `cli/x.ts:12`: low: first thing. Fix: do it.\n- done: closed one | run y\n- open: plain item\n");
|
|
33
|
+
writeFileSync(join(state, "backlog.md"), "- open: orphaned item\n");
|
|
34
|
+
writeInbox(state, [
|
|
35
|
+
{ status: "open", text: "add oauth login" },
|
|
36
|
+
{ status: "open", text: "retry failed webhooks" },
|
|
37
|
+
{ status: "started", text: "nightly digest" },
|
|
38
|
+
{ status: "rejected", text: "gantt view | not this product" },
|
|
39
|
+
]);
|
|
40
|
+
});
|
|
41
|
+
afterAll(() => rmSync(join(state, ".."), { recursive: true, force: true }));
|
|
42
|
+
|
|
43
|
+
test("readRuns: newest first, mtime as moved", () => {
|
|
44
|
+
const runs = readRuns(state);
|
|
45
|
+
expect(runs.map((r) => r.id)).toEqual(["2026-09-03-stale", "2026-09-02-done", "2026-09-01-waiting"]);
|
|
46
|
+
expect(runs[2]!.awaiting).toBe("approvals.plan");
|
|
47
|
+
expect(runs[0]!.moved).toBeLessThan(Date.now() - 3600_000);
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
test("statusOf: waiting beats stage, mtime decides running vs stalled", () => {
|
|
51
|
+
const now = Date.now();
|
|
52
|
+
const [stale, done, waiting] = readRuns(state);
|
|
53
|
+
expect(statusOf(waiting!, now)).toBe("waiting");
|
|
54
|
+
expect(statusOf(done!, now)).toBe("done");
|
|
55
|
+
expect(statusOf(stale!, now)).toBe("stalled");
|
|
56
|
+
expect(statusOf({ ...stale!, moved: now }, now)).toBe("running");
|
|
57
|
+
expect(statusOf({ ...stale!, stage: "abandoned", awaiting: "x" }, now)).toBe("abandoned");
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
test("columnOf: every stage word lands somewhere", () => {
|
|
61
|
+
expect(["plan", "build", "review", "done", "abandoned", "weird"].map((s) => columnOf(s))).toEqual([1, 2, 3, 4, 4, 1]);
|
|
62
|
+
expect(columnOf("implement", "approvals.review")).toBe(3);
|
|
63
|
+
expect(columnOf("implement", "findings")).toBe(3);
|
|
64
|
+
expect(columnOf("implement", "approvals.each_file")).toBe(2);
|
|
65
|
+
expect(columnOf("plan", "approvals.plan")).toBe(1);
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
test("inbox round-trips and ignores malformed lines", () => {
|
|
69
|
+
writeFileSync(join(state, "inbox.md"), readFileSync(join(state, "inbox.md"), "utf8") + "not an item\n");
|
|
70
|
+
expect(readInbox(state).map((l) => l.status)).toEqual(["open", "open", "started", "rejected"]);
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
test("boardCards: idea retired once a run's request contains it; started idea sits in planning", () => {
|
|
74
|
+
const cards = boardCards([state], Date.now());
|
|
75
|
+
const ideas = cards.filter((k) => k.kind === "idea").map((k) => [k.text, k.column, k.status]);
|
|
76
|
+
expect(ideas).toEqual([["retry failed webhooks", 0, "idea"], ["nightly digest", 1, "starting"]]);
|
|
77
|
+
const runs = cards.filter((k) => k.kind === "run").map((k) => [k.id, k.column, k.status]);
|
|
78
|
+
expect(runs).toEqual([["2026-09-03-stale", 2, "stalled"], ["2026-09-02-done", 4, "done"], ["2026-09-01-waiting", 1, "waiting"]]);
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
test("replaceIdea edits, rejects, deletes by text", () => {
|
|
82
|
+
replaceIdea(state, "retry failed webhooks", { status: "open", text: "retry failed webhooks twice" });
|
|
83
|
+
expect(readInbox(state).some((l) => l.text === "retry failed webhooks twice")).toBe(true);
|
|
84
|
+
replaceIdea(state, "retry failed webhooks twice", { status: "rejected", text: "retry failed webhooks twice | no" });
|
|
85
|
+
replaceIdea(state, "nightly digest", null);
|
|
86
|
+
expect(readInbox(state).map((l) => l.status)).toEqual(["open", "rejected", "rejected"]);
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
test("abandonRun: flips stage, clears awaiting, records the reason, refuses twice", () => {
|
|
90
|
+
expect(abandonRun(state, "2026-09-01-waiting", "changed my mind")).toBeNull();
|
|
91
|
+
const s = JSON.parse(readFileSync(join(state, "runs", "2026-09-01-waiting", "state.json"), "utf8"));
|
|
92
|
+
expect([s.stage, s.awaiting]).toEqual(["abandoned", null]);
|
|
93
|
+
expect(readFileSync(join(state, "runs", "2026-09-01-waiting", "02-approval.md"), "utf8")).toContain("changed my mind");
|
|
94
|
+
expect(abandonRun(state, "2026-09-01-waiting", "again")).toMatch(/already/);
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
test("backlog: open items only, orphanage last, count matches", () => {
|
|
98
|
+
const groups = allBacklog(state);
|
|
99
|
+
expect(groups.map((g) => g.run)).toEqual(["2026-09-02-done", "Orphaned"]);
|
|
100
|
+
expect(groups[0]!.items.length).toBe(2);
|
|
101
|
+
expect(backlog(state)).toBe(3);
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
test("parseItem: finding line splits into where, severity, text", () => {
|
|
105
|
+
const it = parseItem("`cli/x.ts:12`: low: first thing. Fix: do it.");
|
|
106
|
+
expect([it.where, it.severity, it.text]).toEqual(["x.ts:12", "low", "first thing."]);
|
|
107
|
+
expect(parseItem("plain item").severity).toBeNull();
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
test("reviewFindings: numbered lines under Findings, closed by the backlog; the prompt names only what was picked", () => {
|
|
111
|
+
const dir = join(state, "runs", "2026-09-02-done");
|
|
112
|
+
writeFileSync(join(dir, "04-review.md"),
|
|
113
|
+
"# Review\n\n1. not a finding, above the heading\n\n## Findings\n\n1. a.ts:1: high: one. Fix: x.\n2. b.ts:2: low: two.\n3. c.ts:3: low: three.\n\n## Notes\n\n4. not a finding either\n");
|
|
114
|
+
const before = readFileSync(join(dir, "06-backlog.md"), "utf8");
|
|
115
|
+
writeFileSync(join(dir, "06-backlog.md"), before + "- done: 1. a.ts:1: high: one, reworded | fixed in iteration 1\n- rejected: c.ts:3: low: three. | not worth it\n- open: 2. b.ts:2: low: two.\n");
|
|
116
|
+
expect(reviewFindings(state, "2026-09-02-done").map((f) => [f.n, f.done])).toEqual([[1, true], [2, false], [3, true]]);
|
|
117
|
+
expect(reviewFindings(state, "2026-09-02-done")[1]!.text).toBe("b.ts:2: low: two.");
|
|
118
|
+
expect(reviewFindings(state, "2026-09-03-stale")).toEqual([]);
|
|
119
|
+
expect(pickFindings(state, "2026-09-02-done", [2])).toEqual([2]);
|
|
120
|
+
for (const bad of [[2, 99], [1], ["2"], [], "2", undefined]) expect(pickFindings(state, "2026-09-02-done", bad)).toBeNull();
|
|
121
|
+
// Sub-headings stay inside the section; a recheck closes a finding before the backlog does.
|
|
122
|
+
writeFileSync(join(dir, "06-backlog.md"), before);
|
|
123
|
+
writeFileSync(join(dir, "04-review.md"), "## Findings\n\n### High\n\n1. a.ts:1: high: one.\n\n### Low\n\n2. b.ts:2: low: two.\n\n## Gate\n\n3. no\n");
|
|
124
|
+
mkdirSync(join(dir, "05-iterations"), { recursive: true });
|
|
125
|
+
writeFileSync(join(dir, "05-iterations", "01-recheck.md"), "**Finding 1 — closed.** Proven.\n\n**Finding 2 — still open**, by choice.\n");
|
|
126
|
+
expect(reviewFindings(state, "2026-09-02-done").map((f) => [f.n, f.done])).toEqual([[1, true], [2, false]]);
|
|
127
|
+
rmSync(join(dir, "05-iterations"), { recursive: true });
|
|
128
|
+
expect(fixPrompt("2026-09-02-done", [2])).toStartWith("run 2026-09-02-done: fix finding 2 from");
|
|
129
|
+
expect(fixPrompt("x", [2, 5])).toContain("fix findings 2, 5 from");
|
|
130
|
+
});
|
|
131
|
+
|
|
132
|
+
test("startedRuns: normalised request bodies", () => {
|
|
133
|
+
expect(startedRuns(state).sort()).toEqual(["# request add oauth login", "# request rename cli flags"]);
|
|
134
|
+
});
|
|
135
|
+
|
|
136
|
+
test("archive: a run is listed, hidden by flag, never moved", () => {
|
|
137
|
+
setArchived(state, "2026-09-02-done", true);
|
|
138
|
+
expect([...readArchive(state)]).toEqual(["2026-09-02-done"]);
|
|
139
|
+
const card = boardCards([state], Date.now()).find((k) => k.id === "2026-09-02-done")!;
|
|
140
|
+
expect([card.archived, card.column]).toEqual([true, 4]);
|
|
141
|
+
setArchived(state, "2026-09-02-done", false);
|
|
142
|
+
expect(readArchive(state).size).toBe(0);
|
|
143
|
+
expect(boardCards([state], Date.now()).find((k) => k.id === "2026-09-02-done")!.archived).toBe(false);
|
|
144
|
+
});
|
|
145
|
+
|
|
146
|
+
test("archive: an idea keeps its line with status archived", () => {
|
|
147
|
+
writeInbox(state, [{ status: "open", text: "park this", notes: "kept" }, { status: "archived", text: "parked zq7" }]);
|
|
148
|
+
const ideas = boardCards([state], Date.now()).filter((k) => k.kind === "idea");
|
|
149
|
+
expect(ideas.map((k) => [k.text, k.archived])).toEqual([["park this", false], ["parked zq7", true]]);
|
|
150
|
+
});
|
|
151
|
+
|
|
152
|
+
test("notes: indented lines under a bullet round-trip; a legacy file is untouched", () => {
|
|
153
|
+
const legacy = "# Inbox\n\nIdeas with no run yet. One line each, same format as a run's backlog.\n\n- open: a\n- rejected: b | why\n";
|
|
154
|
+
writeFileSync(join(state, "inbox.md"), legacy);
|
|
155
|
+
writeInbox(state, readInbox(state));
|
|
156
|
+
expect(readFileSync(join(state, "inbox.md"), "utf8")).toBe(legacy);
|
|
157
|
+
writeInbox(state, [{ status: "open", text: "with notes", notes: "see https://x.y/z\ncli/state.ts:12 | not a reason" }, { status: "open", text: "plain zq8" }]);
|
|
158
|
+
const [a, b] = readInbox(state);
|
|
159
|
+
expect(a).toEqual({ status: "open", text: "with notes", notes: "see https://x.y/z\ncli/state.ts:12 | not a reason" });
|
|
160
|
+
expect(b).toEqual({ status: "open", text: "plain zq8" });
|
|
161
|
+
});
|
|
162
|
+
|
|
163
|
+
test("notes: survive reject and started; edit can replace or clear them", () => {
|
|
164
|
+
replaceIdea(state, "with notes", { status: "started", text: "with notes" });
|
|
165
|
+
expect(readInbox(state)[0]!.notes).toContain("https://x.y/z");
|
|
166
|
+
replaceIdea(state, "with notes", { status: "rejected", text: "with notes | no" });
|
|
167
|
+
expect(readInbox(state)[0]!.notes).toContain("https://x.y/z");
|
|
168
|
+
replaceIdea(state, "plain zq8", { status: "open", text: "plain zq8", notes: "" });
|
|
169
|
+
expect(readInbox(state)[1]!.notes).toBeUndefined();
|
|
170
|
+
expect(boardCards([state], Date.now()).find((k) => k.text === "plain zq8")!.notes).toBeUndefined();
|
|
171
|
+
});
|
|
172
|
+
|
|
173
|
+
test("notes: the prompt is text, blank line, notes; still one quoted argument", () => {
|
|
174
|
+
expect(requestOf("/kaizen do x")).toBe("/kaizen do x");
|
|
175
|
+
expect(requestOf("/kaizen do x", "l1\nl2\n")).toBe("/kaizen do x\n\nl1\nl2");
|
|
176
|
+
const cmd = manualCommand("/p", "claude", requestOf("/kaizen do x", "l1"));
|
|
177
|
+
if (process.platform !== "win32") {
|
|
178
|
+
expect(cmd).toBe("cd '/p' && claude '/kaizen do x\n\nl1'");
|
|
179
|
+
// Pasted into a shell, outside text must stay text.
|
|
180
|
+
// ' and \ sit in double quotes: fish reads \' inside single quotes as an escape.
|
|
181
|
+
const hostile = "a $(x) `y` 'z' a\\' $(touch canary) \\'b \\";
|
|
182
|
+
const pasted = manualCommand("/p", "printf %s", hostile);
|
|
183
|
+
expect(pasted).toBe(`cd '/p' && printf %s 'a $(x) \`y\` '"'"'z'"'"' a'"\\\\"''"'"' $(touch canary) '"\\\\"''"'"'b '"\\\\"''`);
|
|
184
|
+
expect(pasted.replace(/'[^'\\]*'|"['\\]+"/g, "")).toBe("cd && printf %s "); // only '…' without \ or ', and "'" or "\\"
|
|
185
|
+
for (const sh of ["/bin/sh", "bash", "zsh"].filter((s) => Bun.which(s))) {
|
|
186
|
+
expect(Bun.spawnSync([sh, "-c", pasted.replace("cd '/p'", "cd /")]).stdout.toString()).toBe(hostile);
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
});
|
|
190
|
+
|
|
191
|
+
test("notes: a run's notes.md is read beside it, written only for a real run", () => {
|
|
192
|
+
expect(writeNotes(state, "2026-09-09-nope", "x")).toBe("no such run");
|
|
193
|
+
expect(writeNotes(state, "2026-09-01-waiting", " ref: docs/a.md ")).toBeNull();
|
|
194
|
+
expect(readNotes(state, "2026-09-01-waiting")).toBe("ref: docs/a.md\n");
|
|
195
|
+
expect(boardCards([state], Date.now()).find((k) => k.id === "2026-09-01-waiting")!.notes).toBe("ref: docs/a.md\n");
|
|
196
|
+
writeNotes(state, "2026-09-01-waiting", "");
|
|
197
|
+
expect(readNotes(state, "2026-09-01-waiting")).toBe("");
|
|
198
|
+
});
|
|
199
|
+
|
|
200
|
+
test("notes log: stamped entries, continuation lines, unstamped preamble", () => {
|
|
201
|
+
const src = "hand written\n[2026-09-14 10:00] first\n more\n[2026-09-14 10:05] \n";
|
|
202
|
+
expect(parseNotes(src)).toEqual([
|
|
203
|
+
{ at: "", text: "hand written" },
|
|
204
|
+
{ at: "2026-09-14 10:00", text: "first\nmore" },
|
|
205
|
+
{ at: "2026-09-14 10:05", text: "" },
|
|
206
|
+
]);
|
|
207
|
+
expect(formatNote("a\nb", "2026-09-14 10:00")).toBe("[2026-09-14 10:00] a\n b");
|
|
208
|
+
});
|
|
209
|
+
|
|
210
|
+
test("notes log: append to a run and to an idea; attachments saved and made absolute in the prompt", () => {
|
|
211
|
+
expect(appendNote(state, { id: "2026-09-01-waiting" }, "run note")).toBeNull();
|
|
212
|
+
expect(parseNotes(readNotes(state, "2026-09-01-waiting")).map((n) => n.text)).toEqual(["run note"]);
|
|
213
|
+
writeInbox(state, [{ status: "open", text: "chatty zq6" }]);
|
|
214
|
+
expect(appendNote(state, { text: "chatty zq6" }, "one")).toBeNull();
|
|
215
|
+
expect(appendNote(state, { text: "chatty zq6" }, "two\nlines")).toBeNull();
|
|
216
|
+
expect(appendNote(state, { text: "nope zq6" }, "x")).toBe("no such idea");
|
|
217
|
+
expect(parseNotes(readInbox(state)[0]!.notes!).map((n) => n.text)).toEqual(["one", "two\nlines"]);
|
|
218
|
+
const rel = saveAttachment(state, null, "../evil name.png", new Uint8Array([1]));
|
|
219
|
+
expect(rel).toMatch(/^attachments\/\w+-_evil_name\.png$/);
|
|
220
|
+
expect(requestOf("x", ``, state)).toBe(`x\n\n})`);
|
|
221
|
+
expect(saveAttachment(state, "2026-09-01-waiting", "b.txt", new Uint8Array([2]))).toMatch(/^runs\/2026-09-01-waiting\/attachments\//);
|
|
222
|
+
});
|
|
223
|
+
|
|
224
|
+
test("agentFlags: per-tool model and effort from the agent block, inline or nested", () => {
|
|
225
|
+
const cfg = `agent:\n default: auto\n codex: { model: gpt-5, effort: low }\n claude:\n model: sonnet\n effort: low\n gemini:\n model: "gemini-2.5-pro"\n`;
|
|
226
|
+
expect(agentFlags("/p", "codex", cfg)).toEqual(["-m", "gpt-5", "-c", "model_reasoning_effort=low"]);
|
|
227
|
+
expect(agentFlags("/p", "claude", cfg)).toEqual(["--model", "sonnet", "--effort", "low"]);
|
|
228
|
+
expect(agentFlags("/p", "gemini", cfg)).toEqual(["-m", "gemini-2.5-pro"]);
|
|
229
|
+
expect(agentFlags("/p", "agy", cfg)).toEqual([]);
|
|
230
|
+
expect(agentFlags("/p", "codex", "agent:\n default: codex\n")).toEqual([]);
|
|
231
|
+
});
|
|
232
|
+
|
|
233
|
+
test("boardCards: a short idea is not retired by a request that merely contains the word", () => {
|
|
234
|
+
const dir = join(mkdtempSync(join(tmpdir(), "kz-")), ".kaizen");
|
|
235
|
+
mkdirSync(join(dir, "runs", "2026-09-01-a"), { recursive: true });
|
|
236
|
+
writeFileSync(join(dir, "runs", "2026-09-01-a", "00-request.md"), "# Request\n\n> /kaizen lite add retry\n\nDone means the tests pass.\n");
|
|
237
|
+
writeInbox(dir, [{ status: "open", text: "test" }, { status: "open", text: "add retry" }]);
|
|
238
|
+
expect(boardCards([dir], Date.now()).filter((k) => k.kind === "idea").map((k) => k.text)).toEqual(["test"]);
|
|
239
|
+
});
|
|
240
|
+
|
|
241
|
+
test("boardCards: the done column is newest-moved first, the rest keep their place", () => {
|
|
242
|
+
const dir = join(mkdtempSync(join(tmpdir(), "kz-")), ".kaizen");
|
|
243
|
+
const at = (id: string, stage: string, hoursAgo: number) => {
|
|
244
|
+
mkdirSync(join(dir, "runs", id), { recursive: true });
|
|
245
|
+
writeFileSync(join(dir, "runs", id, "state.json"), JSON.stringify({ stage, awaiting: null }));
|
|
246
|
+
const t = new Date(Date.now() - hoursAgo * 3600_000);
|
|
247
|
+
utimesSync(join(dir, "runs", id, "state.json"), t, t);
|
|
248
|
+
};
|
|
249
|
+
at("2026-09-01-a", "done", 1); at("2026-09-02-b", "done", 3); at("2026-09-03-c", "abandoned", 2);
|
|
250
|
+
at("2026-09-04-d", "build", 9); at("2026-09-05-e", "done", 0);
|
|
251
|
+
const ids = boardCards([dir], Date.now()).map((k) => k.id);
|
|
252
|
+
// Unsorted this is e, d, c, b, a: d keeps slot two, the four finished runs reorder around it.
|
|
253
|
+
expect(ids).toEqual(["2026-09-05-e", "2026-09-04-d", "2026-09-01-a", "2026-09-03-c", "2026-09-02-b"]);
|
|
254
|
+
});
|
|
255
|
+
|
|
256
|
+
test("commitPush: commits the run's files only, pushes, and records it; the message never meets a shell", async () => {
|
|
257
|
+
const root = mkdtempSync(join(tmpdir(), "kz-git-"));
|
|
258
|
+
const sh = (cwd: string, ...a: string[]) => Bun.spawnSync(["git", "-C", cwd, ...a]).stdout.toString().trim();
|
|
259
|
+
expect(gitStatus(root)).toEqual({ repo: false });
|
|
260
|
+
const bare = join(root, "remote.git"), proj = join(root, "proj"), st = join(proj, ".kaizen");
|
|
261
|
+
mkdirSync(join(st, "runs", "r1"), { recursive: true });
|
|
262
|
+
Bun.spawnSync(["git", "init", "-q", "--bare", bare]);
|
|
263
|
+
Bun.spawnSync(["git", "init", "-q", proj]);
|
|
264
|
+
for (const kv of [["user.name", "t"], ["user.email", "t@t"], ["commit.gpgsign", "false"], ["core.hooksPath", "/dev/null"]]) sh(proj, "config", ...kv);
|
|
265
|
+
expect(gitStatus(proj)).toEqual({ repo: false }); // a repo with nowhere to push
|
|
266
|
+
sh(proj, "remote", "add", "origin", bare);
|
|
267
|
+
writeFileSync(join(proj, ".gitignore"), ".kaizen/\n");
|
|
268
|
+
writeFileSync(join(proj, "a.txt"), "a");
|
|
269
|
+
// Another run's file, staged by hand: the report does not name it, so it stays out.
|
|
270
|
+
writeFileSync(join(proj, "c.txt"), "c");
|
|
271
|
+
sh(proj, "add", "c.txt");
|
|
272
|
+
expect((gitStatus(proj) as any).files.length).toBe(3);
|
|
273
|
+
expect(runGit(st, "r1")).toMatchObject({ files: [], others: 3 }); // no report, no files
|
|
274
|
+
writeFileSync(join(st, "runs", "r1", "03-impl.md"), "## Changed\n\n- `.gitignore`: x\n- `a.txt:1`: y\n- c.txt and `c.txt.bak` are not named\n");
|
|
275
|
+
const files = () => (runGit(st, "r1") as any).files as string[];
|
|
276
|
+
expect(files()).toEqual(["?? .gitignore", "?? a.txt"]);
|
|
277
|
+
// An earlier run that named the same file is not offered it: the later report has it.
|
|
278
|
+
mkdirSync(join(st, "runs", "r0"));
|
|
279
|
+
writeFileSync(join(st, "runs", "r0", "03-impl.md"), "- `a.txt`: z\n");
|
|
280
|
+
utimesSync(join(st, "runs", "r0", "03-impl.md"), new Date(1000), new Date(1000));
|
|
281
|
+
expect(runGit(st, "r0")).toMatchObject({ files: [], others: 3 });
|
|
282
|
+
// The later run is told the file holds the earlier run's work too.
|
|
283
|
+
expect(runGit(st, "r1")).toMatchObject({ shared: ["a.txt: r0"] });
|
|
284
|
+
expect(await commitPush(st, "r1", "m", ["?? a.txt"])).toMatchObject({ ok: false }); // not the list that was shown
|
|
285
|
+
expect(sh(proj, "rev-list", "--all", "--count")).toBe("0"); // and nothing was committed
|
|
286
|
+
expect(await commitPush(st, "r1", " ", files())).toEqual({ ok: false, why: "A commit message is required." });
|
|
287
|
+
// A subject with no type is refused before anything is staged.
|
|
288
|
+
for (const untyped of ["add the thing", "feature: x", "fix:no space", "note\n\nfix: in the body"])
|
|
289
|
+
expect((await commitPush(st, "r1", untyped, files()) as { why: string }).why).toContain("Start the message with a type");
|
|
290
|
+
expect(sh(proj, "rev-list", "--all", "--count")).toBe("0");
|
|
291
|
+
expect(sh(proj, "diff", "--cached", "--name-only")).toBe("c.txt"); // only what was staged by hand
|
|
292
|
+
const msg = 'fix(web)!: $(touch pwned); "q" `id`', text = "what changed\nand why";
|
|
293
|
+
let ticked = false;
|
|
294
|
+
setTimeout(() => { ticked = true; }, 0);
|
|
295
|
+
const r = await commitPush(st, "r1", `${msg}\n\n${text}`, files()) as { ok: true; sha: string };
|
|
296
|
+
expect(r.ok).toBe(true);
|
|
297
|
+
expect(ticked).toBe(true); // the push did not hold the event loop
|
|
298
|
+
expect(sh(proj, "log", "-1", "--format=%s")).toBe(msg);
|
|
299
|
+
expect(sh(proj, "log", "-1", "--format=%b")).toBe(text);
|
|
300
|
+
expect(existsSync(join(proj, "pwned"))).toBe(false);
|
|
301
|
+
expect(sh(bare, "rev-parse", "--short", "HEAD")).toBe(r.sha);
|
|
302
|
+
expect(runGit(st, "r1")).toMatchObject({ files: [], ahead: 0, others: 1, shared: [] });
|
|
303
|
+
expect(sh(proj, "show", "--name-only", "--format=", "HEAD")).toBe(".gitignore\na.txt");
|
|
304
|
+
expect(Bun.spawnSync(["git", "-C", proj, "status", "--porcelain"]).stdout.toString()).toBe("A c.txt\n");
|
|
305
|
+
expect(readCommit(st, "r1")).toEqual({ sha: r.sha, pushed: true });
|
|
306
|
+
|
|
307
|
+
// A push that fails leaves a record that says so.
|
|
308
|
+
sh(proj, "remote", "set-url", "origin", join(root, "gone.git"));
|
|
309
|
+
// A fix round's report names files too, and a name with a star in it is not a pattern.
|
|
310
|
+
mkdirSync(join(st, "runs", "r1", "05-iterations"));
|
|
311
|
+
writeFileSync(join(st, "runs", "r1", "05-iterations", "01-fix.md"), "- `b*.txt`: fixed\n");
|
|
312
|
+
writeFileSync(join(proj, "b*.txt"), "b");
|
|
313
|
+
writeFileSync(join(proj, "bb.txt"), "b");
|
|
314
|
+
expect(files()).toEqual(["?? b*.txt"]);
|
|
315
|
+
const f = await commitPush(st, "r1", "chore: second", files()) as { ok: false; why: string };
|
|
316
|
+
expect(f.ok).toBe(false);
|
|
317
|
+
expect(f.why).toContain("not pushed");
|
|
318
|
+
expect(readCommit(st, "r1")!.pushed).toBe(false);
|
|
319
|
+
expect(sh(proj, "show", "--name-only", "--format=", "HEAD")).toBe("b*.txt");
|
|
320
|
+
expect(runGit(st, "r1")).toMatchObject({ files: [], ahead: 1, others: 2 });
|
|
321
|
+
});
|
|
322
|
+
|
|
323
|
+
test.skipIf(process.platform === "win32")("findTerminal: a running herdr wins, then tmux, else neither; the prompt stays one literal argument", () => {
|
|
324
|
+
const bin = mkdtempSync(join(tmpdir(), "kz-bin-"));
|
|
325
|
+
const stub = (name: string, code: number) => writeFileSync(join(bin, name), `#!/bin/sh\nexit ${code}\n`, { mode: 0o755 });
|
|
326
|
+
const keys = ["PATH", "HERDR_ENV", "TMUX", "TERMINAL"];
|
|
327
|
+
const saved = keys.map((k) => process.env[k]);
|
|
328
|
+
try {
|
|
329
|
+
process.env.PATH = bin;
|
|
330
|
+
for (const k of keys.slice(1)) delete process.env[k];
|
|
331
|
+
const prompt = `/kaizen it's "q" $HOME \`id\`\n\nnote`;
|
|
332
|
+
const full = ["/usr/bin/claude", "--model", "opus", prompt];
|
|
333
|
+
expect(findTerminal("/p", full)?.cmd[0]).not.toMatch(/^(sh|tmux)$/); // neither installed
|
|
334
|
+
stub("herdr", 1); stub("tmux", 1);
|
|
335
|
+
expect(findTerminal("/p", full)?.cmd[0]).not.toMatch(/^(sh|tmux)$/); // installed, no server
|
|
336
|
+
process.env.TMUX = "/tmp/x,1,0"; process.env.HERDR_ENV = "1";
|
|
337
|
+
expect(findTerminal("/p", full)?.cmd[0]).not.toMatch(/^(sh|tmux)$/); // stale env from a server since stopped
|
|
338
|
+
delete process.env.TMUX; delete process.env.HERDR_ENV; stub("tmux", 0);
|
|
339
|
+
const t = findTerminal("/p", full)!; // outside it, server up
|
|
340
|
+
expect(t.cmd.slice(0, 4)).toEqual(["tmux", "new-window", "-c", "/p"]);
|
|
341
|
+
stub("herdr", 0);
|
|
342
|
+
const h = findTerminal("/p", full)!;
|
|
343
|
+
expect([h.cmd[0], h.cmd[1], h.cmd[4], h.detached]).toEqual(["sh", "-c", "/p", false]);
|
|
344
|
+
expect(t.cmd.slice(4)).toEqual(full); // argv: tmux runs it without its default-shell
|
|
345
|
+
const back = Bun.spawnSync(["/bin/sh", "-c", `printf '%s\\n' ${h.cmd[5]}`]).stdout.toString();
|
|
346
|
+
expect(back).toBe(full.join("\n") + "\n");
|
|
347
|
+
} finally {
|
|
348
|
+
keys.forEach((k, i) => (saved[i] === undefined ? delete process.env[k] : (process.env[k] = saved[i])));
|
|
349
|
+
rmSync(bin, { recursive: true, force: true });
|
|
350
|
+
}
|
|
351
|
+
});
|
|
352
|
+
|
|
353
|
+
test("prune: only finished runs beyond keep go, open items are rescued, a dry run deletes nothing", () => {
|
|
354
|
+
const st = join(mkdtempSync(join(tmpdir(), "kaizen-prune-")), ".kaizen");
|
|
355
|
+
const mk = (id: string, stage: string, backlog = "") => {
|
|
356
|
+
const dir = join(st, "runs", id);
|
|
357
|
+
mkdirSync(dir, { recursive: true });
|
|
358
|
+
writeFileSync(join(dir, "state.json"), JSON.stringify({ stage, awaiting: null }));
|
|
359
|
+
if (backlog) writeFileSync(join(dir, "06-backlog.md"), backlog);
|
|
360
|
+
};
|
|
361
|
+
mk("2026-01-01-old-build", "build"); // oldest of all, not finished
|
|
362
|
+
mk("2026-01-02-old-review", "review");
|
|
363
|
+
mk("2026-01-03-old-plan", "plan");
|
|
364
|
+
mk("2026-01-04-done", "done", "- open: keep me\n- done: closed | x\n");
|
|
365
|
+
mk("2026-01-05-abandoned", "abandoned", "- open: not open work\n");
|
|
366
|
+
mk("2026-01-06-done", "done");
|
|
367
|
+
mk("2026-01-07-done", "done");
|
|
368
|
+
writeFileSync(join(st, "backlog.md"), "- open: already here"); // no trailing newline
|
|
369
|
+
|
|
370
|
+
expect(pruneRuns(st, 2).map((g) => g.id)).toEqual(["2026-01-04-done", "2026-01-05-abandoned"]);
|
|
371
|
+
expect(readdirSync(join(st, "runs")).length).toBe(7); // dry run
|
|
372
|
+
|
|
373
|
+
expect(pruneRuns(st, 2, true)).toEqual([{ id: "2026-01-04-done", rescued: 1 }, { id: "2026-01-05-abandoned", rescued: 0 }]);
|
|
374
|
+
expect(readdirSync(join(st, "runs")).sort()).toEqual(
|
|
375
|
+
["2026-01-01-old-build", "2026-01-02-old-review", "2026-01-03-old-plan", "2026-01-06-done", "2026-01-07-done"]);
|
|
376
|
+
expect(readFileSync(join(st, "backlog.md"), "utf8")).toBe("- open: already here\n- open: keep me | from 2026-01-04-done\n");
|
|
377
|
+
expect(pruneRuns(st, 2, true)).toEqual([]);
|
|
378
|
+
rmSync(dirname(st), { recursive: true, force: true });
|
|
379
|
+
});
|
|
380
|
+
|
|
381
|
+
test("unrechecked: the last fix with no recheck beside it", () => {
|
|
382
|
+
const dir = run("2026-09-04-fixes", { stage: "done", awaiting: null });
|
|
383
|
+
expect(unrechecked(state, "2026-09-04-fixes")).toBe(false);
|
|
384
|
+
mkdirSync(join(dir, "05-iterations"));
|
|
385
|
+
writeFileSync(join(dir, "05-iterations", "01-fix.md"), "");
|
|
386
|
+
expect(unrechecked(state, "2026-09-04-fixes")).toBe(true);
|
|
387
|
+
writeFileSync(join(dir, "05-iterations", "01-recheck.md"), "");
|
|
388
|
+
expect(unrechecked(state, "2026-09-04-fixes")).toBe(false);
|
|
389
|
+
writeFileSync(join(dir, "05-iterations", "02-fix.md"), "");
|
|
390
|
+
expect(boardCards([state], Date.now()).find((c) => c.id === "2026-09-04-fixes")!.tags).toContain("unrechecked");
|
|
391
|
+
rmSync(dir, { recursive: true, force: true });
|
|
392
|
+
});
|
|
393
|
+
|
|
394
|
+
test("notice: plain words for what a run waits on, done, and nothing for the rest", () => {
|
|
395
|
+
const c = (o: Partial<Card>): Card => ({ kind: "run", status: "waiting", text: "payroll", state: "", where: "~/acme", column: 1, awaiting: null, dim: false, archived: false, ...o });
|
|
396
|
+
expect(notice(c({ awaiting: "approvals.plan" }))).toBe("payroll — plan needs your approval\n~/acme");
|
|
397
|
+
for (const a of ["approvals.plan", "approvals.review", "approvals.each_file", "findings"]) expect(notice(c({ awaiting: a }))).not.toContain("waiting on");
|
|
398
|
+
expect(notice(c({ awaiting: "something.new" }))).toBe("payroll — waiting on something.new\n~/acme");
|
|
399
|
+
expect(notice(c({ status: "done" }))).toBe("payroll — done\n~/acme");
|
|
400
|
+
for (const o of [{ status: "running" }, { status: "abandoned" }, { kind: "idea" as const, status: "idea" }]) expect(notice(c(o))).toBeNull();
|
|
401
|
+
expect(notifyArgs("x")).toEqual(["notify-send", "-a", "kaizen", "-i", LOGO, "kaizen", "x"]);
|
|
402
|
+
});
|
|
403
|
+
|
|
404
|
+
test("notifier: only a run seen before, saying something new, and not a done the user just approved", () => {
|
|
405
|
+
const due = notifier(), M = 60_000;
|
|
406
|
+
expect(due("r", "a", false, 0)).toBe(false); // first load, or arrived already waiting
|
|
407
|
+
expect(due("r", "a", false, 1)).toBe(false); // still waiting
|
|
408
|
+
expect(due("r", "b", false, 2)).toBe(true); // waiting on something else now
|
|
409
|
+
expect(due("r", "d", true, 3 * M)).toBe(false); // approved: waiting straight to done
|
|
410
|
+
expect(due("s", null, false, 0)).toBe(false);
|
|
411
|
+
expect(due("s", "a", false, 1)).toBe(true); // started waiting
|
|
412
|
+
expect(due("s", null, false, 2)).toBe(false); // approved, back to work
|
|
413
|
+
expect(due("s", "d", true, 3)).toBe(false); // ...and done in a second write
|
|
414
|
+
expect(due("t", "a", false, 0)).toBe(false);
|
|
415
|
+
expect(due("t", null, false, 1)).toBe(false);
|
|
416
|
+
expect(due("t", "d", true, 2 * M)).toBe(true); // finished on its own, long after the last wait
|
|
417
|
+
expect(due("u", null, false, 0)).toBe(false);
|
|
418
|
+
expect(due("u", "d", true, 1)).toBe(true); // never waited
|
|
419
|
+
// web.ts writes the function into the page over this marker; without it the board throws on load.
|
|
420
|
+
expect(notifier.toString()).toStartWith("function notifier(");
|
|
421
|
+
expect(readFileSync(join(import.meta.dir, "..", "web", "board.html"), "utf8")).toContain("(/*notifier*/)()");
|
|
422
|
+
});
|