kaizen-agent 1.3.10 → 1.5.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/README.md +51 -24
- package/cli/dashboard.ts +55 -482
- package/cli/install.ts +91 -0
- package/cli/publish-gh.sh +27 -0
- package/cli/release.sh +2 -2
- package/cli/state.test.ts +106 -0
- package/cli/state.ts +532 -0
- package/cli/web.ts +235 -0
- package/package.json +4 -2
- package/web/board.html +361 -0
package/cli/install.ts
CHANGED
|
@@ -13,7 +13,9 @@ const REPO_URL = "https://github.com/hfadhlullah/kaizen.git";
|
|
|
13
13
|
const args = new Set(Bun.argv.slice(2));
|
|
14
14
|
const upgrade = args.has("upgrade") || args.has("--upgrade");
|
|
15
15
|
const wantSettings = args.has("settings") || args.has("config");
|
|
16
|
+
const wantWeb = args.has("web");
|
|
16
17
|
const check = args.has("--check");
|
|
18
|
+
const uninstall = args.has("uninstall") || args.has("--uninstall");
|
|
17
19
|
const force = args.has("--force");
|
|
18
20
|
const verbose = args.has("--verbose");
|
|
19
21
|
const interactive = process.stdin.isTTY && !check && !upgrade && !args.has("--yes");
|
|
@@ -68,6 +70,87 @@ const onPath = new Set(
|
|
|
68
70
|
const found = (r: string) =>
|
|
69
71
|
AGENTS.filter((a) => existsSync(join(r, a.dir)) || onPath.has(a.dir));
|
|
70
72
|
|
|
73
|
+
if (uninstall) { await removeEverything(); process.exit(0); }
|
|
74
|
+
|
|
75
|
+
// Everything kaizen put on this machine, in one place, so uninstalling needs no
|
|
76
|
+
// clone to read: the links are known by name, the rest by path. State (~/.kaizen
|
|
77
|
+
// and any project's .kaizen) is left alone unless --purge says otherwise, because
|
|
78
|
+
// it holds runs and decisions this program did not write.
|
|
79
|
+
async function removeEverything() {
|
|
80
|
+
let purge = args.has("--purge") || args.has("--all");
|
|
81
|
+
// Deleting someone's runs is not a thing to do on a typo, so ask unless the
|
|
82
|
+
// answer already arrived as a flag.
|
|
83
|
+
if (interactive && !purge) {
|
|
84
|
+
const choice = await select("Uninstall kaizen", [
|
|
85
|
+
{ label: "Keep my runs and settings", hint: "removes the app, keeps ~/.kaizen and each project's .kaizen/", value: "keep" },
|
|
86
|
+
{ label: "Remove everything", hint: "also deletes runs, backlog, memory and settings", value: "all" },
|
|
87
|
+
{ label: "Cancel", hint: "change nothing", value: "cancel" },
|
|
88
|
+
]);
|
|
89
|
+
if (choice === "cancel") { console.log(`\n ${c.dim("Nothing removed.")}\n`); return; }
|
|
90
|
+
purge = choice === "all";
|
|
91
|
+
}
|
|
92
|
+
const gone: string[] = [];
|
|
93
|
+
const stuck: string[] = [];
|
|
94
|
+
const drop = (p: string) => {
|
|
95
|
+
if (!existsSync(p) && !isLink(p)) return;
|
|
96
|
+
// Windows refuses to delete a file another process holds open, and a silent
|
|
97
|
+
// failure here would read as a clean uninstall that removed nothing.
|
|
98
|
+
try { rmSync(p, { recursive: true, force: true }); gone.push(tilde(p)); }
|
|
99
|
+
catch { stuck.push(tilde(p)); }
|
|
100
|
+
};
|
|
101
|
+
|
|
102
|
+
// A --project install puts links in the project folder, and on Windows those
|
|
103
|
+
// projects live on other drives entirely. Every project kaizen knows about is
|
|
104
|
+
// checked, plus whatever a drive-wide scan turns up.
|
|
105
|
+
const { knownProjects, searchRoots, findProjects } = await import("./state.ts");
|
|
106
|
+
step("looking for project installs");
|
|
107
|
+
const roots = [home, process.cwd(), ...knownProjects(),
|
|
108
|
+
...searchRoots([process.cwd()]).flatMap((r) => findProjects(r))]
|
|
109
|
+
.filter((d, i, all) => all.indexOf(d) === i);
|
|
110
|
+
|
|
111
|
+
for (const root of roots) {
|
|
112
|
+
for (const a of AGENTS) {
|
|
113
|
+
for (const n of ["kaizen", "kaizen-help"]) drop(join(root, a.dir, "skills", n));
|
|
114
|
+
for (const sub of ["agents", "commands"]) {
|
|
115
|
+
const dir = join(root, a.dir, sub);
|
|
116
|
+
try {
|
|
117
|
+
for (const f of readdirSync(dir)) {
|
|
118
|
+
if (f.startsWith("kaizen-") && f.endsWith(".md")) drop(join(dir, f));
|
|
119
|
+
}
|
|
120
|
+
} catch { /* no such directory */ }
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
drop(process.env.KAIZEN_HOME ?? join(home, "kaizen"));
|
|
126
|
+
drop(join(process.env.BUN_INSTALL ?? join(home, ".bun"), "bin", "kaizen.cmd"));
|
|
127
|
+
drop(join(home, ".local", "bin", "kaizen"));
|
|
128
|
+
await clearBunxCache();
|
|
129
|
+
|
|
130
|
+
if (purge) {
|
|
131
|
+
drop(join(home, ".kaizen"));
|
|
132
|
+
drop(join(process.cwd(), ".kaizen"));
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
console.log(`\n ${c.bold("Uninstalled")}`);
|
|
136
|
+
for (const p of gone) console.log(` ${c.dim("removed " + p)}`);
|
|
137
|
+
if (!gone.length && !stuck.length) console.log(` ${c.dim("nothing left to remove")}`);
|
|
138
|
+
if (stuck.length) {
|
|
139
|
+
console.log(`\n ${c.bold("could not remove")} ${c.dim("— close any dashboard or agent still using these, then run it again")}`);
|
|
140
|
+
for (const p of stuck) console.log(` ${c.dim(p)}`);
|
|
141
|
+
}
|
|
142
|
+
console.log(purge
|
|
143
|
+
? `\n ${c.dim("Runs and settings deleted too.")}\n`
|
|
144
|
+
: `\n ${c.dim("Runs and settings kept in")} ${c.cyan(tilde(join(home, ".kaizen")))}${c.dim(" — add --purge to delete them.")}\n`);
|
|
145
|
+
console.log(` ${c.dim("The CLAUDE.md / AGENTS.md line in each project is left for you to remove.")}\n`);
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
// A dangling symlink fails existsSync, and a stale link is exactly what wants
|
|
149
|
+
// removing here.
|
|
150
|
+
function isLink(p: string) {
|
|
151
|
+
try { return lstatSync(p).isSymbolicLink(); } catch { return false; }
|
|
152
|
+
}
|
|
153
|
+
|
|
71
154
|
// Nothing to install into. kaizen is a workflow an agent runs; on its own it does
|
|
72
155
|
// nothing, so send them to the agent first rather than laying out files no reader
|
|
73
156
|
// will ever load.
|
|
@@ -121,6 +204,14 @@ if (wantSettings) {
|
|
|
121
204
|
process.exit(0);
|
|
122
205
|
}
|
|
123
206
|
|
|
207
|
+
if (wantWeb) {
|
|
208
|
+
const { web } = await import("./web.ts");
|
|
209
|
+
const at = Bun.argv.indexOf("--port");
|
|
210
|
+
const port = at !== -1 ? Number(Bun.argv[at + 1]) || undefined : undefined;
|
|
211
|
+
await web(await resolveRepoQuietly(), { port, open: !args.has("--no-open") });
|
|
212
|
+
process.exit(0);
|
|
213
|
+
}
|
|
214
|
+
|
|
124
215
|
// Bare `kaizen` on a machine where it is already installed is not an install --
|
|
125
216
|
// show what the project is doing instead, and offer the things you would have
|
|
126
217
|
// opened a terminal for.
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
#!/bin/sh
|
|
2
|
+
# Publish the same tarball to GitHub Packages, so the repo's "Packages" box lists
|
|
3
|
+
# it. GitHub only accepts names scoped to the repo owner, so the package goes there
|
|
4
|
+
# as @hfadhlullah/kaizen-agent while npm keeps kaizen-agent. Run after `bun publish`.
|
|
5
|
+
set -eu
|
|
6
|
+
cd "$(dirname "$0")/.."
|
|
7
|
+
token="${GITHUB_TOKEN:-$(gh auth token 2>/dev/null || true)}"
|
|
8
|
+
[ -n "$token" ] || { echo "publish-gh: no GitHub token (gh auth login, scope write:packages)"; exit 1; }
|
|
9
|
+
|
|
10
|
+
tmp="$(mktemp -d)"
|
|
11
|
+
trap 'rm -rf "$tmp"' EXIT
|
|
12
|
+
cp package.json "$tmp/package.json.orig"
|
|
13
|
+
bun -e '
|
|
14
|
+
const p = JSON.parse(await Bun.file("package.json").text());
|
|
15
|
+
p.name = "@hfadhlullah/kaizen-agent";
|
|
16
|
+
p.publishConfig = { registry: "https://npm.pkg.github.com" };
|
|
17
|
+
await Bun.write("package.json", JSON.stringify(p, null, 2) + "\n");
|
|
18
|
+
'
|
|
19
|
+
# Bun reads the project's own .npmrc; a temporary one carries the token and never
|
|
20
|
+
# lands in git (it is removed on exit, and .gitignore refuses it besides).
|
|
21
|
+
[ -e .npmrc ] && cp .npmrc "$tmp/npmrc.orig"
|
|
22
|
+
printf '//npm.pkg.github.com/:_authToken=%s\n@hfadhlullah:registry=https://npm.pkg.github.com\n' "$token" > .npmrc
|
|
23
|
+
# The scoped copy must not run postpublish: the tag and release already exist.
|
|
24
|
+
bun publish --ignore-scripts "$@" || status=$?
|
|
25
|
+
cp "$tmp/package.json.orig" package.json
|
|
26
|
+
if [ -e "$tmp/npmrc.orig" ]; then cp "$tmp/npmrc.orig" .npmrc; else rm -f .npmrc; fi
|
|
27
|
+
exit "${status:-0}"
|
package/cli/release.sh
CHANGED
|
@@ -1,11 +1,11 @@
|
|
|
1
1
|
#!/bin/sh
|
|
2
|
-
# Runs after `
|
|
2
|
+
# Runs after `bun publish`: tag the published version and open a GitHub release
|
|
3
3
|
# for it, so a version can never exist on npm with nothing to read on GitHub.
|
|
4
4
|
#
|
|
5
5
|
# Never fails the publish. The package is already public by the time this runs;
|
|
6
6
|
# exiting non-zero here would report a failure that did not happen.
|
|
7
7
|
set -u
|
|
8
|
-
v="${npm_package_version:-$(
|
|
8
|
+
v="${npm_package_version:-$(bun -p "require('./package.json').version" 2>/dev/null)}"
|
|
9
9
|
[ -n "$v" ] || { echo "release: no version, skipping"; exit 0; }
|
|
10
10
|
tag="v$v"
|
|
11
11
|
|
|
@@ -0,0 +1,106 @@
|
|
|
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,
|
|
10
|
+
} from "./state.ts";
|
|
11
|
+
|
|
12
|
+
let state: string;
|
|
13
|
+
const run = (id: string, s: object, request = "") => {
|
|
14
|
+
const dir = join(state, "runs", id);
|
|
15
|
+
mkdirSync(dir, { recursive: true });
|
|
16
|
+
writeFileSync(join(dir, "state.json"), JSON.stringify(s));
|
|
17
|
+
if (request) writeFileSync(join(dir, "00-request.md"), request);
|
|
18
|
+
return dir;
|
|
19
|
+
};
|
|
20
|
+
|
|
21
|
+
beforeAll(() => {
|
|
22
|
+
state = join(mkdtempSync(join(tmpdir(), "kaizen-")), ".kaizen");
|
|
23
|
+
mkdirSync(state, { recursive: true });
|
|
24
|
+
run("2026-09-01-waiting", { stage: "plan", awaiting: "approvals.plan" }, "# Request\n\nAdd OAuth login\n");
|
|
25
|
+
run("2026-09-02-done", { stage: "done", awaiting: null }, "# Request\n\nRename cli flags\n");
|
|
26
|
+
const old = run("2026-09-03-stale", { stage: "build", awaiting: null });
|
|
27
|
+
const then = new Date(Date.now() - 2 * 3600_000);
|
|
28
|
+
utimesSync(join(old, "state.json"), then, then);
|
|
29
|
+
writeFileSync(join(state, "runs", "2026-09-02-done", "06-backlog.md"),
|
|
30
|
+
"# 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");
|
|
31
|
+
writeFileSync(join(state, "backlog.md"), "- open: orphaned item\n");
|
|
32
|
+
writeInbox(state, [
|
|
33
|
+
{ status: "open", text: "add oauth login" },
|
|
34
|
+
{ status: "open", text: "retry failed webhooks" },
|
|
35
|
+
{ status: "started", text: "nightly digest" },
|
|
36
|
+
{ status: "rejected", text: "gantt view | not this product" },
|
|
37
|
+
]);
|
|
38
|
+
});
|
|
39
|
+
afterAll(() => rmSync(join(state, ".."), { recursive: true, force: true }));
|
|
40
|
+
|
|
41
|
+
test("readRuns: newest first, mtime as moved", () => {
|
|
42
|
+
const runs = readRuns(state);
|
|
43
|
+
expect(runs.map((r) => r.id)).toEqual(["2026-09-03-stale", "2026-09-02-done", "2026-09-01-waiting"]);
|
|
44
|
+
expect(runs[2]!.awaiting).toBe("approvals.plan");
|
|
45
|
+
expect(runs[0]!.moved).toBeLessThan(Date.now() - 3600_000);
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
test("statusOf: waiting beats stage, mtime decides running vs stalled", () => {
|
|
49
|
+
const now = Date.now();
|
|
50
|
+
const [stale, done, waiting] = readRuns(state);
|
|
51
|
+
expect(statusOf(waiting!, now)).toBe("waiting");
|
|
52
|
+
expect(statusOf(done!, now)).toBe("done");
|
|
53
|
+
expect(statusOf(stale!, now)).toBe("stalled");
|
|
54
|
+
expect(statusOf({ ...stale!, moved: now }, now)).toBe("running");
|
|
55
|
+
expect(statusOf({ ...stale!, stage: "abandoned", awaiting: "x" }, now)).toBe("abandoned");
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
test("columnOf: every stage word lands somewhere", () => {
|
|
59
|
+
expect(["plan", "build", "review", "done", "abandoned", "weird"].map(columnOf)).toEqual([1, 2, 3, 4, 4, 1]);
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
test("inbox round-trips and ignores malformed lines", () => {
|
|
63
|
+
writeFileSync(join(state, "inbox.md"), readFileSync(join(state, "inbox.md"), "utf8") + "not an item\n");
|
|
64
|
+
expect(readInbox(state).map((l) => l.status)).toEqual(["open", "open", "started", "rejected"]);
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
test("boardCards: idea retired once a run's request contains it; started idea sits in planning", () => {
|
|
68
|
+
const cards = boardCards([state], Date.now());
|
|
69
|
+
const ideas = cards.filter((k) => k.kind === "idea").map((k) => [k.text, k.column, k.status]);
|
|
70
|
+
expect(ideas).toEqual([["retry failed webhooks", 0, "idea"], ["nightly digest", 1, "starting"]]);
|
|
71
|
+
const runs = cards.filter((k) => k.kind === "run").map((k) => [k.id, k.column, k.status]);
|
|
72
|
+
expect(runs).toEqual([["2026-09-03-stale", 2, "stalled"], ["2026-09-02-done", 4, "done"], ["2026-09-01-waiting", 1, "waiting"]]);
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
test("replaceIdea edits, rejects, deletes by text", () => {
|
|
76
|
+
replaceIdea(state, "retry failed webhooks", { status: "open", text: "retry failed webhooks twice" });
|
|
77
|
+
expect(readInbox(state).some((l) => l.text === "retry failed webhooks twice")).toBe(true);
|
|
78
|
+
replaceIdea(state, "retry failed webhooks twice", { status: "rejected", text: "retry failed webhooks twice | no" });
|
|
79
|
+
replaceIdea(state, "nightly digest", null);
|
|
80
|
+
expect(readInbox(state).map((l) => l.status)).toEqual(["open", "rejected", "rejected"]);
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
test("abandonRun: flips stage, clears awaiting, records the reason, refuses twice", () => {
|
|
84
|
+
expect(abandonRun(state, "2026-09-01-waiting", "changed my mind")).toBeNull();
|
|
85
|
+
const s = JSON.parse(readFileSync(join(state, "runs", "2026-09-01-waiting", "state.json"), "utf8"));
|
|
86
|
+
expect([s.stage, s.awaiting]).toEqual(["abandoned", null]);
|
|
87
|
+
expect(readFileSync(join(state, "runs", "2026-09-01-waiting", "02-approval.md"), "utf8")).toContain("changed my mind");
|
|
88
|
+
expect(abandonRun(state, "2026-09-01-waiting", "again")).toMatch(/already/);
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
test("backlog: open items only, orphanage last, count matches", () => {
|
|
92
|
+
const groups = allBacklog(state);
|
|
93
|
+
expect(groups.map((g) => g.run)).toEqual(["2026-09-02-done", "Orphaned"]);
|
|
94
|
+
expect(groups[0]!.items.length).toBe(2);
|
|
95
|
+
expect(backlog(state)).toBe(3);
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
test("parseItem: finding line splits into where, severity, text", () => {
|
|
99
|
+
const it = parseItem("`cli/x.ts:12`: low: first thing. Fix: do it.");
|
|
100
|
+
expect([it.where, it.severity, it.text]).toEqual(["x.ts:12", "low", "first thing."]);
|
|
101
|
+
expect(parseItem("plain item").severity).toBeNull();
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
test("startedRuns: normalised request bodies", () => {
|
|
105
|
+
expect(startedRuns(state).sort()).toEqual(["# request add oauth login", "# request rename cli flags"]);
|
|
106
|
+
});
|