kaizen-agent 1.7.9 → 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.
Files changed (47) hide show
  1. package/LICENSE +21 -21
  2. package/README.md +694 -658
  3. package/agents/kaizen-builder.md +82 -77
  4. package/agents/kaizen-planner.md +250 -250
  5. package/agents/kaizen-reviewer.md +127 -127
  6. package/cli/board.test.ts +55 -0
  7. package/cli/dashboard.ts +1188 -1187
  8. package/cli/install.ts +76 -3
  9. package/cli/publish-gh.sh +27 -27
  10. package/cli/release.sh +45 -45
  11. package/cli/settings.ts +7 -3
  12. package/cli/sources.test.ts +494 -0
  13. package/cli/sources.ts +323 -0
  14. package/cli/state.test.ts +422 -197
  15. package/cli/state.ts +989 -700
  16. package/cli/web.ts +182 -11
  17. package/commands/kaizen-abort.md +5 -5
  18. package/commands/kaizen-approve.md +5 -5
  19. package/commands/kaizen-auto.md +6 -6
  20. package/commands/kaizen-backlog.md +5 -5
  21. package/commands/kaizen-config.md +5 -5
  22. package/commands/kaizen-full.md +5 -5
  23. package/commands/kaizen-gather.md +5 -0
  24. package/commands/kaizen-init.md +5 -5
  25. package/commands/kaizen-install.md +5 -5
  26. package/commands/kaizen-lite.md +5 -5
  27. package/commands/kaizen-plan.md +6 -6
  28. package/commands/kaizen-reject.md +6 -6
  29. package/commands/kaizen-review.md +6 -6
  30. package/commands/kaizen-run.md +5 -5
  31. package/commands/kaizen-status.md +5 -5
  32. package/package.json +3 -1
  33. package/skills/kaizen/README.md +283 -281
  34. package/skills/kaizen/SKILL.md +334 -440
  35. package/skills/kaizen/adapters.md +84 -77
  36. package/skills/kaizen/config.default.yml +181 -167
  37. package/skills/kaizen/gather.md +50 -0
  38. package/skills/kaizen/install.md +42 -0
  39. package/skills/kaizen/settings.md +52 -0
  40. package/skills/kaizen/spec-build.md +131 -119
  41. package/skills/kaizen/spec-main.md +109 -104
  42. package/skills/kaizen/spec-plan.md +201 -201
  43. package/skills/kaizen/spec-review.md +121 -121
  44. package/skills/kaizen/spec.md +154 -153
  45. package/skills/kaizen/status.md +43 -0
  46. package/skills/kaizen-help/SKILL.md +131 -129
  47. package/web/board.html +292 -125
package/cli/install.ts CHANGED
@@ -2,13 +2,16 @@
2
2
  import {
3
3
  symlinkSync, mkdirSync, readdirSync, lstatSync, readlinkSync, unlinkSync,
4
4
  existsSync, copyFileSync, readFileSync, appendFileSync, rmSync, writeFileSync, renameSync,
5
- statSync, cpSync,
5
+ statSync, cpSync, realpathSync,
6
6
  } from "node:fs";
7
7
  import { join, dirname, basename } from "node:path";
8
8
  import { homedir, tmpdir } from "node:os";
9
9
  import { applyPreset, detectPreset, PRESETS, type PresetName } from "./settings.ts";
10
10
 
11
- const home = homedir();
11
+ // The real path: process.cwd() is always physical, so a raw $HOME that crosses a
12
+ // symlink (/home -> var/home) would never compare equal to it, and the home
13
+ // directory would be offered set-up as if it were a project.
14
+ const home = (() => { try { return realpathSync(homedir()); } catch { return homedir(); } })();
12
15
  const REPO_URL = "https://github.com/hfadhlullah/kaizen.git";
13
16
  const args = new Set(Bun.argv.slice(2));
14
17
  const upgrade = args.has("upgrade") || args.has("--upgrade");
@@ -70,6 +73,48 @@ const onPath = new Set(
70
73
  const found = (r: string) =>
71
74
  AGENTS.filter((a) => existsSync(join(r, a.dir)) || onPath.has(a.dir));
72
75
 
76
+ // Request sources. Dispatched on the first word alone and ahead of every flag
77
+ // check below, so a label or a link that happens to read `web` or `uninstall` is
78
+ // only ever a label.
79
+ if (Bun.argv[2] === "sources" || Bun.argv[2] === "gather") {
80
+ const { locate, label } = await import("./state.ts");
81
+ const src = await import("./sources.ts");
82
+ const state = locate() ?? join(home, ".kaizen");
83
+ // A source belongs to the project the command is run in; every answer names it.
84
+ const where = c.dim(`project ${label(state)}`);
85
+ const [, , cmd, op, url = "", ...rest] = Bun.argv;
86
+ const line = (r: { url: string; ok: boolean; note: string }) => ` ${r.ok ? c.green("ok ") : "fail"} ${r.url} ${c.dim(r.note)}`;
87
+ try {
88
+ if (cmd === "gather" && op === "--stdin") {
89
+ // What an agent read from a link kaizen could not: one request per line, an
90
+ // optional tab, then notes. Only for a link already listed.
91
+ if (!src.readSources(state).some((s) => s.url === url)) { console.log(`
92
+ not a listed source: ${url || "(no link given)"}
93
+ `); process.exit(1); }
94
+ const items = (await Bun.stdin.text()).split("\n").filter((l) => l.trim()).map((l) => {
95
+ const [text = "", ...notes] = l.split("\t");
96
+ return { text, notes: notes.join(" ") };
97
+ });
98
+ console.log(line(src.record(state, url, src.ingest(state, url, items))));
99
+ } else if (cmd === "gather") {
100
+ const results = await src.gatherAll(state);
101
+ console.log(results.length ? results.map(line).join("\n") : `\n no sources yet ${c.dim("— kaizen sources add <link>")}\n`);
102
+ if (results.some((r) => !r.ok)) process.exit(1);
103
+ } else if (op === "add" || op === "rm") {
104
+ const why = op === "add" ? src.addSource(state, url, rest.join(" ")) : src.removeSource(state, url);
105
+ if (why) { console.log(`\n ${why}\n`); process.exit(1); }
106
+ console.log(`\n ${op === "add" ? "added" : "removed"} ${url}\n ${where}\n`);
107
+ } else {
108
+ const list = src.readSources(state);
109
+ console.log(`\n ${where}`);
110
+ console.log(list.length
111
+ ? "\n" + list.map((s) => ` ${s.url}${s.label ? ` ${s.label}` : ""}\n ${c.dim(s.last ? `${s.last.at.slice(0, 16).replace("T", " ")} ${s.last.note}` : "not gathered yet")}`).join("\n") + "\n"
112
+ : `\n no sources yet ${c.dim("— kaizen sources add <link>")}\n`);
113
+ }
114
+ } catch (e) { console.log(`\n ${(e as Error).message}\n`); process.exit(1); }
115
+ process.exit(0);
116
+ }
117
+
73
118
  if (args.has("--version") || args.has("-v") || args.has("version")) {
74
119
  console.log(versionOf(dirname(import.meta.dir)));
75
120
  process.exit(0);
@@ -212,6 +257,22 @@ if (wantSettings) {
212
257
  process.exit(0);
213
258
  }
214
259
 
260
+ // Deleting runs is a job for code with a test, not for an agent with `rm`. Lists by
261
+ // default; --yes is what deletes.
262
+ if (args.has("prune")) {
263
+ const { locate, pruneRuns } = await import("./state.ts");
264
+ const { listSettings } = await import("./settings.ts");
265
+ const state = locate();
266
+ if (!state) { console.log("\n no .kaizen here\n"); process.exit(1); }
267
+ const keep = Number(listSettings(await resolveRepoQuietly()).rows.find((r) => r.key === "state.keep_runs")?.value) || 20;
268
+ const apply = args.has("--yes");
269
+ const gone = pruneRuns(state, keep, apply);
270
+ console.log(gone.length
271
+ ? `\n ${apply ? "pruned" : "would prune"} ${gone.length} finished run(s), keeping the newest ${keep}:\n${gone.map((g) => ` ${g.id}${g.rescued ? c.dim(` ${g.rescued} open item(s) ${apply ? "moved" : "to move"} to backlog.md`) : ""}`).join("\n")}\n${apply ? "" : `\n ${c.dim("nothing deleted — run:")} ${c.cyan("kaizen prune --yes")}\n`}`
272
+ : `\n nothing to prune ${c.dim(`(${keep} finished runs are kept)`)}\n`);
273
+ process.exit(0);
274
+ }
275
+
215
276
  if (wantWeb) {
216
277
  if (args.has("--shortcut")) { const { shortcut } = await import("./web.ts"); console.log(`\n kaizen web shortcut ${await shortcut()}\n`); process.exit(0); }
217
278
  const at = Bun.argv.indexOf("--port");
@@ -629,7 +690,17 @@ for (const [src, sub] of links) {
629
690
  if (verbose) console.log(c.dim(` link ${name}`));
630
691
  }
631
692
 
693
+ // A project's spec copies are what a run there reads, and they are copied once.
694
+ function specDrift() {
695
+ const dir = join(process.cwd(), ".kaizen");
696
+ return readdirSync(join(repo, "skills/kaizen"))
697
+ .filter((f) => f.startsWith("spec") && existsSync(join(dir, f)) && !sameFile(join(repo, "skills/kaizen", f), join(dir, f)));
698
+ }
699
+
632
700
  if (check) {
701
+ const drift = specDrift();
702
+ for (const f of drift) console.log(`DRIFT .kaizen/${f} (behind the skill's copy)`);
703
+ if (drift.length) { console.log(`\n${drift.length} spec file(s) behind — run: kaizen --yes`); if (!blocked) process.exit(1); }
633
704
  console.log(blocked ? `\n${blocked} link(s) missing — run: bunx kaizen-agent` : "\nall linked");
634
705
  process.exit(blocked ? 1 : 0);
635
706
  }
@@ -734,7 +805,9 @@ if (inProject && !kaizenAbove()) {
734
805
  : false;
735
806
  if (now) initProject();
736
807
  } else if (existsSync(kaizenDir)) {
737
- step(`${basename(process.cwd())}/.kaizen already set up`);
808
+ const drift = specDrift();
809
+ for (const f of drift) copyFileSync(join(repo, "skills/kaizen", f), join(kaizenDir, f));
810
+ step(`${basename(process.cwd())}/.kaizen already set up${drift.length ? ` — refreshed ${drift.join(", ")}` : ""}`);
738
811
  }
739
812
 
740
813
  function initProject() {
package/cli/publish-gh.sh CHANGED
@@ -1,27 +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}"
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,45 +1,45 @@
1
- #!/bin/sh
2
- # Runs after `bun publish`: tag the published version and open a GitHub release
3
- # for it, so a version can never exist on npm with nothing to read on GitHub.
4
- #
5
- # Never fails the publish. The package is already public by the time this runs;
6
- # exiting non-zero here would report a failure that did not happen.
7
- set -u
8
- v="${npm_package_version:-$(bun -p "require('./package.json').version" 2>/dev/null)}"
9
- [ -n "$v" ] || { echo "release: no version, skipping"; exit 0; }
10
- tag="v$v"
11
-
12
- git rev-parse --git-dir >/dev/null 2>&1 || { echo "release: not a git checkout, skipping"; exit 0; }
13
-
14
- if git rev-parse "$tag" >/dev/null 2>&1; then
15
- echo "release: $tag already tagged"
16
- else
17
- git tag -a "$tag" -m "kaizen-agent $tag" || { echo "release: could not tag"; exit 0; }
18
- git push origin "$tag" >/dev/null 2>&1 || echo "release: tag made but not pushed"
19
- fi
20
-
21
- command -v gh >/dev/null 2>&1 || { echo "release: gh not installed — tag pushed, write the release by hand"; exit 0; }
22
- gh auth status >/dev/null 2>&1 || { echo "release: gh not logged in — tag pushed, write the release by hand"; exit 0; }
23
-
24
- if gh release view "$tag" >/dev/null 2>&1; then
25
- echo "release: $tag already released"
26
- exit 0
27
- fi
28
-
29
- # --generate-notes only lists pull requests, and this repo merges none, so the
30
- # notes are built here: the title from the release commit's subject, the body from
31
- # every commit since the previous tag with its message, oldest first.
32
- prev="$(git describe --tags --abbrev=0 "$tag^" 2>/dev/null || true)"
33
- subject="$(git log -1 --format=%s "$tag" | sed -n 's/^Release [0-9.]*: //p')"
34
- title="$tag${subject:+: $subject}"
35
- notes="$(mktemp)"
36
- git log --reverse --format='### %s%n%n%b' ${prev:+"$prev.."}"$tag" \
37
- | grep -vE '^(Co-Authored-By|Claude-Session):' \
38
- | grep -vE '^### Release [0-9.]+:' \
39
- | cat -s > "$notes"
40
- [ -n "$prev" ] && printf '\n**Full Changelog**: https://github.com/hfadhlullah/kaizen/compare/%s...%s\n' "$prev" "$tag" >> "$notes"
41
- gh release create "$tag" --title "$title" --notes-file "$notes" >/dev/null 2>&1 \
42
- && echo "release: published $tag https://github.com/hfadhlullah/kaizen/releases/tag/$tag" \
43
- || echo "release: tag pushed, release not created"
44
- rm -f "$notes"
45
- exit 0
1
+ #!/bin/sh
2
+ # Runs after `bun publish`: tag the published version and open a GitHub release
3
+ # for it, so a version can never exist on npm with nothing to read on GitHub.
4
+ #
5
+ # Never fails the publish. The package is already public by the time this runs;
6
+ # exiting non-zero here would report a failure that did not happen.
7
+ set -u
8
+ v="${npm_package_version:-$(bun -p "require('./package.json').version" 2>/dev/null)}"
9
+ [ -n "$v" ] || { echo "release: no version, skipping"; exit 0; }
10
+ tag="v$v"
11
+
12
+ git rev-parse --git-dir >/dev/null 2>&1 || { echo "release: not a git checkout, skipping"; exit 0; }
13
+
14
+ if git rev-parse "$tag" >/dev/null 2>&1; then
15
+ echo "release: $tag already tagged"
16
+ else
17
+ git tag -a "$tag" -m "kaizen-agent $tag" || { echo "release: could not tag"; exit 0; }
18
+ git push origin "$tag" >/dev/null 2>&1 || echo "release: tag made but not pushed"
19
+ fi
20
+
21
+ command -v gh >/dev/null 2>&1 || { echo "release: gh not installed — tag pushed, write the release by hand"; exit 0; }
22
+ gh auth status >/dev/null 2>&1 || { echo "release: gh not logged in — tag pushed, write the release by hand"; exit 0; }
23
+
24
+ if gh release view "$tag" >/dev/null 2>&1; then
25
+ echo "release: $tag already released"
26
+ exit 0
27
+ fi
28
+
29
+ # --generate-notes only lists pull requests, and this repo merges none, so the
30
+ # notes are built here: the title from the release commit's subject, the body from
31
+ # every commit since the previous tag with its message, oldest first.
32
+ prev="$(git describe --tags --abbrev=0 "$tag^" 2>/dev/null || true)"
33
+ subject="$(git log -1 --format=%s "$tag" | sed -n 's/^Release [0-9.]*: //p')"
34
+ title="$tag${subject:+: $subject}"
35
+ notes="$(mktemp)"
36
+ git log --reverse --format='### %s%n%n%b' ${prev:+"$prev.."}"$tag" \
37
+ | grep -vE '^(Co-Authored-By|Claude-Session):' \
38
+ | grep -vE '^### Release [0-9.]+:' \
39
+ | cat -s > "$notes"
40
+ [ -n "$prev" ] && printf '\n**Full Changelog**: https://github.com/hfadhlullah/kaizen/compare/%s...%s\n' "$prev" "$tag" >> "$notes"
41
+ gh release create "$tag" --title "$title" --notes-file "$notes" >/dev/null 2>&1 \
42
+ && echo "release: published $tag https://github.com/hfadhlullah/kaizen/releases/tag/$tag" \
43
+ || echo "release: tag pushed, release not created"
44
+ rm -f "$notes"
45
+ exit 0
package/cli/settings.ts CHANGED
@@ -4,7 +4,8 @@ import { existsSync, readFileSync, writeFileSync, copyFileSync, mkdirSync } from
4
4
  import { join, dirname } from "node:path";
5
5
  import { homedir } from "node:os";
6
6
 
7
- type Setting = { key: string; label: string; group: string; values: string[]; help: string };
7
+ // `text` is a value typed rather than chosen: a path. It has no values to cycle.
8
+ type Setting = { key: string; label: string; group: string; values: string[]; help: string; text?: boolean };
8
9
 
9
10
  export type PresetName = "low" | "medium" | "ultra";
10
11
 
@@ -114,6 +115,8 @@ const SETTINGS: Setting[] = [
114
115
  { key: "subagents.effort", label: "Effort for stage agents", group: "Agents", values: ["inherit", "low", "medium", "high"], help: "How hard the stage agents think. low is faster and cheaper; high finds more. Claude Code only" },
115
116
  { key: "agent.default", label: "Tool the board launches", group: "Agents", values: ["auto", "claude", "codex", "agy", "opencode", "gemini"], help: "Coding tool opened when you start a run from the board. auto: Claude Code if installed, else the first one found" },
116
117
  { key: "dictation.model", label: "Dictation model", group: "Board", values: ["tiny", "base", "small", "large-v3-turbo"], help: "Whisper model the web board dictates with, in the browser. tiny ~60 MB rough; base ~165 MB English-leaning; small ~410 MB accurate across languages; large-v3-turbo ~1.5 GB best, needs a capable GPU. Downloaded once on the next mic press" },
118
+ { key: "sources.every", label: "Gather requests", group: "Board", values: ["off", "1h", "6h", "24h"], help: "How often the board checks your request sources for new ideas while it is running. off: only when you press Gather now" },
119
+ { key: "board.projects_dir", label: "Projects folder", group: "Board", values: [], text: true, help: "A folder your projects live in, e.g. ~/Projects. Every folder inside it appears in the board's project list, ready to choose; choosing one sets it up. Empty: only projects kaizen already knows" },
117
120
  ];
118
121
 
119
122
  // The same rows and the same write the TUI uses, for `kaizen web`'s settings panel:
@@ -127,14 +130,14 @@ export function listSettings(repoRoot: string) {
127
130
  file: tilde(file),
128
131
  rows: SETTINGS.map((s) => ({
129
132
  ...s,
130
- value: s.key === "preset" ? preset : (read(text, s.key) || read(defaults, s.key)) ?? s.values[0]!,
133
+ value: s.key === "preset" ? preset : (read(text, s.key) || read(defaults, s.key)) ?? s.values[0] ?? "",
131
134
  })),
132
135
  };
133
136
  }
134
137
 
135
138
  export function setSetting(repoRoot: string, key: string, value: string) {
136
139
  const s = SETTINGS.find((x) => x.key === key);
137
- if (!s || !s.values.includes(value)) return { error: "unknown setting or value" };
140
+ if (!s || !(s.text || s.values.includes(value))) return { error: "unknown setting or value" };
138
141
  const file = locate();
139
142
  const defaultsPath = join(repoRoot, "skills/kaizen/config.default.yml");
140
143
  if (!existsSync(file)) { mkdirSync(dirname(file), { recursive: true }); copyFileSync(defaultsPath, file); }
@@ -234,6 +237,7 @@ export async function settings(repoRoot: string, standalone = true) {
234
237
  function cycle(step: number) {
235
238
  const s = SETTINGS[active]!;
236
239
  const wasMouse = s.key === "ui.mouse";
240
+ if (s.text) { saved = c.dim(`type ${s.key} in the board's settings, or in ${tilde(file)}`); return; }
237
241
  const text = readFileSync(file, "utf8");
238
242
  if (s.key === "preset") {
239
243
  const current = detectPreset(text, defaults);