kaizen-agent 1.48.0 → 1.49.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 (183) hide show
  1. package/README.md +2 -2
  2. package/cli/build-web.ts +4 -2
  3. package/cli/hermes.ts +65 -0
  4. package/cli/install.ts +18 -5
  5. package/cli/localsend.test.ts +84 -0
  6. package/cli/localsend.ts +131 -0
  7. package/cli/notes.test.ts +6 -4
  8. package/cli/settings.ts +1 -0
  9. package/cli/state.test.ts +37 -0
  10. package/cli/state.ts +35 -20
  11. package/cli/video.test.ts +479 -0
  12. package/cli/video.ts +507 -0
  13. package/cli/web.ts +127 -1
  14. package/package.json +1 -1
  15. package/skills/kaizen/config.default.yml +4 -0
  16. package/skills/media/kaizen-book/SKILL.md +107 -0
  17. package/skills/media/kaizen-book/assets/reader.html +440 -0
  18. package/skills/media/kaizen-book/assets/styles.json +45 -0
  19. package/skills/media/kaizen-book/examples/book.json +14 -0
  20. package/skills/media/kaizen-book/examples/images/reach.svg +1 -0
  21. package/skills/media/kaizen-book/examples/manuscript.md +127 -0
  22. package/skills/media/kaizen-book/references/interview.md +85 -0
  23. package/skills/media/kaizen-book/references/proofread.md +47 -0
  24. package/skills/media/kaizen-book/references/writing.md +53 -0
  25. package/skills/media/kaizen-book/scripts/build_book.py +279 -0
  26. package/skills/media/kaizen-book/scripts/check.sh +10 -0
  27. package/skills/media/kaizen-book/scripts/check_book.py +128 -0
  28. package/skills/media/kaizen-book/scripts/deploy.py +58 -0
  29. package/skills/media/kaizen-book/scripts/pdf.mjs +99 -0
  30. package/skills/media/kaizen-book/scripts/setup.sh +9 -0
  31. package/skills/media/kaizen-book/scripts/verify.mjs +95 -0
  32. package/skills/{story-deck → media/kaizen-deck}/SKILL.md +1 -1
  33. package/skills/{story-deck → media/kaizen-deck}/scripts/check.sh +1 -1
  34. package/skills/{story-deck → media/kaizen-deck}/scripts/install_fonts.py +2 -2
  35. package/skills/media/kaizen-video/SKILL.md +112 -0
  36. package/skills/{kaizen-video → media/kaizen-video}/assets/CREDITS.txt +5 -0
  37. package/skills/media/kaizen-video/assets/film-fonts/Fraunces.ttf +0 -0
  38. package/skills/media/kaizen-video/assets/film-fonts/Inter.ttf +0 -0
  39. package/skills/media/kaizen-video/assets/film-fonts/PlayfairDisplay-Italic.ttf +0 -0
  40. package/skills/media/kaizen-video/assets/film-fonts/PlayfairDisplay.ttf +0 -0
  41. package/skills/media/kaizen-video/examples/sample-film/README.md +10 -0
  42. package/skills/media/kaizen-video/examples/sample-film/film.json +63 -0
  43. package/skills/media/kaizen-video/film.html +246 -0
  44. package/skills/media/kaizen-video/presets/film-cinematic-drama/CREDITS.txt +1 -0
  45. package/skills/media/kaizen-video/presets/film-cinematic-drama/README.md +2 -0
  46. package/skills/media/kaizen-video/presets/film-cinematic-drama/style.json +39 -0
  47. package/skills/media/kaizen-video/presets/film-fun-energetic/CREDITS.txt +1 -0
  48. package/skills/media/kaizen-video/presets/film-fun-energetic/README.md +2 -0
  49. package/skills/media/kaizen-video/presets/film-fun-energetic/style.json +39 -0
  50. package/skills/media/kaizen-video/presets/film-promo-booth/CREDITS.txt +1 -0
  51. package/skills/media/kaizen-video/presets/film-promo-booth/README.md +2 -0
  52. package/skills/media/kaizen-video/presets/film-promo-booth/style.json +39 -0
  53. package/skills/media/kaizen-video/presets/film-viral-social/CREDITS.txt +1 -0
  54. package/skills/media/kaizen-video/presets/film-viral-social/README.md +2 -0
  55. package/skills/media/kaizen-video/presets/film-viral-social/style.json +39 -0
  56. package/skills/media/kaizen-video/presets/tiktok-explainer/CREDITS.txt +3 -0
  57. package/skills/media/kaizen-video/presets/tiktok-explainer/README.md +52 -0
  58. package/skills/media/kaizen-video/presets/tiktok-explainer/assets/fonts/InstrumentSerif.ttf +0 -0
  59. package/skills/media/kaizen-video/presets/tiktok-explainer/assets/fonts/SpaceMono.ttf +0 -0
  60. package/skills/media/kaizen-video/presets/tiktok-explainer/assets/sfx/boing.wav +0 -0
  61. package/skills/media/kaizen-video/presets/tiktok-explainer/assets/sfx/ding.wav +0 -0
  62. package/skills/media/kaizen-video/presets/tiktok-explainer/assets/sfx/ending.wav +0 -0
  63. package/skills/media/kaizen-video/presets/tiktok-explainer/assets/sfx/pop.wav +0 -0
  64. package/skills/media/kaizen-video/presets/tiktok-explainer/assets/sfx/reveal.wav +0 -0
  65. package/skills/media/kaizen-video/presets/tiktok-explainer/assets/sfx/shutter.wav +0 -0
  66. package/skills/media/kaizen-video/presets/tiktok-explainer/assets/sfx/stamp.wav +0 -0
  67. package/skills/media/kaizen-video/presets/tiktok-explainer/assets/sfx/tick.wav +0 -0
  68. package/skills/media/kaizen-video/presets/tiktok-explainer/assets/sfx/whoosh.wav +0 -0
  69. package/skills/media/kaizen-video/presets/tiktok-explainer/index.html +272 -0
  70. package/skills/media/kaizen-video/presets/tiktok-explainer/package.json +10 -0
  71. package/skills/{kaizen-video → media/kaizen-video}/references/presets.md +22 -0
  72. package/skills/{story-video → media/kaizen-video}/references/production.md +2 -2
  73. package/skills/{story-video → media/kaizen-video}/references/revisions.md +9 -9
  74. package/skills/media/kaizen-video/scripts/__pycache__/intake.cpython-314.pyc +0 -0
  75. package/skills/media/kaizen-video/scripts/__pycache__/make_film.cpython-314.pyc +0 -0
  76. package/skills/media/kaizen-video/scripts/__pycache__/make_tiktok.cpython-314.pyc +0 -0
  77. package/skills/media/kaizen-video/scripts/__pycache__/render_cut.cpython-314.pyc +0 -0
  78. package/skills/media/kaizen-video/scripts/__pycache__/sfx.cpython-314.pyc +0 -0
  79. package/skills/media/kaizen-video/scripts/__pycache__/srt.cpython-314.pyc +0 -0
  80. package/skills/media/kaizen-video/scripts/__pycache__/transcribe.cpython-314.pyc +0 -0
  81. package/skills/media/kaizen-video/scripts/__pycache__/vo.cpython-314.pyc +0 -0
  82. package/skills/{kaizen-video → media/kaizen-video}/scripts/check.sh +8 -0
  83. package/skills/{story-video → media/kaizen-video}/scripts/intake.py +2 -2
  84. package/skills/media/kaizen-video/scripts/make_film.py +263 -0
  85. package/skills/{kaizen-video → media/kaizen-video}/scripts/make_tiktok.py +8 -3
  86. package/skills/{story-video → media/kaizen-video}/scripts/master.sh +12 -11
  87. package/skills/{story-video → media/kaizen-video}/scripts/sfx.py +3 -3
  88. package/skills/media/kaizen-video/scripts/srt.py +9 -0
  89. package/skills/media/kaizen-video/scripts/still.sh +7 -0
  90. package/web/board/views.ts +1 -1
  91. package/web/board.html +2 -0
  92. package/web/board.js +2 -2
  93. package/web/notebook.js +1 -1
  94. package/web/notes.html +2 -0
  95. package/web/term.js +1 -1
  96. package/web/video.html +298 -0
  97. package/web/video.js +81 -0
  98. package/web/video.ts +610 -0
  99. package/skills/kaizen-video/SKILL.md +0 -73
  100. package/skills/story-video/SKILL.md +0 -108
  101. package/skills/story-video/engine/package.json +0 -12
  102. package/skills/story-video/engine/src/Root.tsx +0 -24
  103. package/skills/story-video/engine/src/engine/Film.tsx +0 -405
  104. package/skills/story-video/engine/src/engine/presets.json +0 -58
  105. package/skills/story-video/engine/src/index.ts +0 -3
  106. package/skills/story-video/engine/tsconfig.json +0 -1
  107. package/skills/story-video/examples/sample-film/README.md +0 -12
  108. package/skills/story-video/examples/sample-film/film.json +0 -16
  109. package/skills/story-video/references/presets.md +0 -21
  110. package/skills/story-video/scripts/check.sh +0 -19
  111. package/skills/story-video/scripts/setup.sh +0 -25
  112. package/skills/story-video/scripts/srt.py +0 -30
  113. package/skills/story-video/scripts/still.sh +0 -10
  114. /package/skills/{story-deck → media/kaizen-deck}/examples/build-example.js +0 -0
  115. /package/skills/{story-deck → media/kaizen-deck}/references/rules.md +0 -0
  116. /package/skills/{story-deck → media/kaizen-deck}/references/story-arc.md +0 -0
  117. /package/skills/{story-deck → media/kaizen-deck}/scripts/kit.js +0 -0
  118. /package/skills/{story-deck → media/kaizen-deck}/scripts/prep_images.py +0 -0
  119. /package/skills/{story-deck → media/kaizen-deck}/scripts/qa.sh +0 -0
  120. /package/skills/{kaizen-video → media/kaizen-video}/assets/fonts/Fredoka.ttf +0 -0
  121. /package/skills/{kaizen-video → media/kaizen-video}/assets/fonts/Montserrat.ttf +0 -0
  122. /package/skills/{kaizen-video → media/kaizen-video}/assets/fonts/SpecialElite.ttf +0 -0
  123. /package/skills/{kaizen-video → media/kaizen-video}/assets/music/investigations.mp3 +0 -0
  124. /package/skills/{kaizen-video → media/kaizen-video}/assets/music/lights.mp3 +0 -0
  125. /package/skills/{kaizen-video → media/kaizen-video}/assets/music/sunny.mp3 +0 -0
  126. /package/skills/{kaizen-video → media/kaizen-video}/examples/config.json +0 -0
  127. /package/skills/{story-video → media/kaizen-video}/examples/sample-film/lines.json +0 -0
  128. /package/skills/{kaizen-video → media/kaizen-video}/hyperframes.json +0 -0
  129. /package/skills/{kaizen-video → media/kaizen-video}/presets/educational-warning/CREDITS.txt +0 -0
  130. /package/skills/{kaizen-video → media/kaizen-video}/presets/educational-warning/README.md +0 -0
  131. /package/skills/{kaizen-video → media/kaizen-video}/presets/educational-warning/assets/sfx/ending.wav +0 -0
  132. /package/skills/{kaizen-video → media/kaizen-video}/presets/educational-warning/assets/sfx/reveal.wav +0 -0
  133. /package/skills/{kaizen-video → media/kaizen-video}/presets/educational-warning/assets/sfx/shutter.wav +0 -0
  134. /package/skills/{kaizen-video → media/kaizen-video}/presets/educational-warning/assets/sfx/stamp.wav +0 -0
  135. /package/skills/{kaizen-video → media/kaizen-video}/presets/educational-warning/index.html +0 -0
  136. /package/skills/{kaizen-video → media/kaizen-video}/presets/educational-warning/package.json +0 -0
  137. /package/skills/{kaizen-video → media/kaizen-video}/presets/tiktok-cheerful/CREDITS.txt +0 -0
  138. /package/skills/{kaizen-video → media/kaizen-video}/presets/tiktok-cheerful/README.md +0 -0
  139. /package/skills/{kaizen-video → media/kaizen-video}/presets/tiktok-cheerful/assets/sfx/boing.wav +0 -0
  140. /package/skills/{kaizen-video → media/kaizen-video}/presets/tiktok-cheerful/assets/sfx/chime.wav +0 -0
  141. /package/skills/{kaizen-video → media/kaizen-video}/presets/tiktok-cheerful/assets/sfx/ding.wav +0 -0
  142. /package/skills/{kaizen-video → media/kaizen-video}/presets/tiktok-cheerful/assets/sfx/ending.wav +0 -0
  143. /package/skills/{kaizen-video → media/kaizen-video}/presets/tiktok-cheerful/assets/sfx/pop.wav +0 -0
  144. /package/skills/{kaizen-video → media/kaizen-video}/presets/tiktok-cheerful/assets/sfx/reveal.wav +0 -0
  145. /package/skills/{kaizen-video → media/kaizen-video}/presets/tiktok-cheerful/assets/sfx/shutter.wav +0 -0
  146. /package/skills/{kaizen-video → media/kaizen-video}/presets/tiktok-cheerful/assets/sfx/stamp.wav +0 -0
  147. /package/skills/{kaizen-video → media/kaizen-video}/presets/tiktok-cheerful/assets/sfx/whoosh.wav +0 -0
  148. /package/skills/{kaizen-video → media/kaizen-video}/presets/tiktok-cheerful/index.html +0 -0
  149. /package/skills/{kaizen-video → media/kaizen-video}/presets/tiktok-cheerful/package.json +0 -0
  150. /package/skills/{kaizen-video → media/kaizen-video}/presets/tiktok-mystery/CREDITS.txt +0 -0
  151. /package/skills/{kaizen-video → media/kaizen-video}/presets/tiktok-mystery/README.md +0 -0
  152. /package/skills/{kaizen-video → media/kaizen-video}/presets/tiktok-mystery/assets/sfx/clock.wav +0 -0
  153. /package/skills/{kaizen-video → media/kaizen-video}/presets/tiktok-mystery/assets/sfx/ding.wav +0 -0
  154. /package/skills/{kaizen-video → media/kaizen-video}/presets/tiktok-mystery/assets/sfx/ending.wav +0 -0
  155. /package/skills/{kaizen-video → media/kaizen-video}/presets/tiktok-mystery/assets/sfx/heart.wav +0 -0
  156. /package/skills/{kaizen-video → media/kaizen-video}/presets/tiktok-mystery/assets/sfx/projector.wav +0 -0
  157. /package/skills/{kaizen-video → media/kaizen-video}/presets/tiktok-mystery/assets/sfx/reveal.wav +0 -0
  158. /package/skills/{kaizen-video → media/kaizen-video}/presets/tiktok-mystery/assets/sfx/shutter.wav +0 -0
  159. /package/skills/{kaizen-video → media/kaizen-video}/presets/tiktok-mystery/assets/sfx/stamp.wav +0 -0
  160. /package/skills/{kaizen-video → media/kaizen-video}/presets/tiktok-mystery/assets/sfx/whoosh.wav +0 -0
  161. /package/skills/{kaizen-video → media/kaizen-video}/presets/tiktok-mystery/index.html +0 -0
  162. /package/skills/{kaizen-video → media/kaizen-video}/presets/tiktok-mystery/package.json +0 -0
  163. /package/skills/{kaizen-video → media/kaizen-video}/presets/tiktok-scam-alert/CREDITS.txt +0 -0
  164. /package/skills/{kaizen-video → media/kaizen-video}/presets/tiktok-scam-alert/README.md +0 -0
  165. /package/skills/{kaizen-video → media/kaizen-video}/presets/tiktok-scam-alert/assets/sfx/clock.wav +0 -0
  166. /package/skills/{kaizen-video → media/kaizen-video}/presets/tiktok-scam-alert/assets/sfx/ding.wav +0 -0
  167. /package/skills/{kaizen-video → media/kaizen-video}/presets/tiktok-scam-alert/assets/sfx/ending.wav +0 -0
  168. /package/skills/{kaizen-video → media/kaizen-video}/presets/tiktok-scam-alert/assets/sfx/heart.wav +0 -0
  169. /package/skills/{kaizen-video → media/kaizen-video}/presets/tiktok-scam-alert/assets/sfx/reveal.wav +0 -0
  170. /package/skills/{kaizen-video → media/kaizen-video}/presets/tiktok-scam-alert/assets/sfx/shutter.wav +0 -0
  171. /package/skills/{kaizen-video → media/kaizen-video}/presets/tiktok-scam-alert/assets/sfx/stamp.wav +0 -0
  172. /package/skills/{kaizen-video → media/kaizen-video}/presets/tiktok-scam-alert/assets/sfx/whoosh.wav +0 -0
  173. /package/skills/{kaizen-video → media/kaizen-video}/presets/tiktok-scam-alert/index.html +0 -0
  174. /package/skills/{kaizen-video → media/kaizen-video}/presets/tiktok-scam-alert/package.json +0 -0
  175. /package/skills/{story-video → media/kaizen-video}/references/licensing.md +0 -0
  176. /package/skills/{story-video → media/kaizen-video}/references/script.md +0 -0
  177. /package/skills/{story-video → media/kaizen-video}/scripts/music.sh +0 -0
  178. /package/skills/{kaizen-video → media/kaizen-video}/scripts/render_cut.py +0 -0
  179. /package/skills/{kaizen-video → media/kaizen-video}/scripts/setup.sh +0 -0
  180. /package/skills/{kaizen-video → media/kaizen-video}/scripts/transcribe.py +0 -0
  181. /package/skills/{story-video → media/kaizen-video}/scripts/vo.py +0 -0
  182. /package/skills/{kaizen-video → media/kaizen-video}/tools/carve.mjs +0 -0
  183. /package/skills/{kaizen-video → media/kaizen-video}/tools/lib/main-module.mjs +0 -0
package/README.md CHANGED
@@ -28,7 +28,7 @@
28
28
 
29
29
  | | |
30
30
  |---|---|
31
- | **Any kind of work** | Each run works out what it is making (code, writing, communication, operations, research) and plans, builds and reviews it in those terms. Videos and decks go through the bundled `story-video`, `kaizen-video` (editing your own footage) and `story-deck` skills. |
31
+ | **Any kind of work** | Each run works out what it is making (code, writing, communication, operations, research) and plans, builds and reviews it in those terms. Videos and decks go through the bundled `kaizen-video` (films from scratch, or editing your own footage) and `kaizen-deck` skills; books go through `kaizen-book` (interview, writing, proofreading, then an e-book reader plus colour and black-and-white print PDFs). |
32
32
  | **Plan first** | The agent writes what it will do, and what *wrong* would look like, then waits for your approve or change. |
33
33
  | **A reviewer that starts cold** | A second agent reviews from the request, the plan and the diff only. It never saw the work, so it has nothing to defend. |
34
34
  | **A fix loop that ends** | Serious findings are fixed and re-checked, at most twice. The rest come to you; what is left goes to a backlog. |
@@ -141,7 +141,7 @@ bun run cli/install.ts # install from this checkout
141
141
  | Path | What it is |
142
142
  |---|---|
143
143
  | `cli/` | The installer and the runtime behind every `kaizen` command: `install.ts` (the CLI), `state.ts` (runs), `web.ts` (board server), `dashboard.ts` (terminal board), `sources.ts`, `settings.ts`, `notes.ts`. Tests beside them. |
144
- | `skills/` | The workflow as Agent Skills folders: `kaizen/` (`SKILL.md`, the `spec-*.md` stage contracts, `adapters.md`, `config.default.yml`), `kaizen-help/`, `story-deck/`, `story-video/`, `kaizen-video/`. |
144
+ | `skills/` | The workflow as Agent Skills folders: `kaizen/` (`SKILL.md`, the `spec-*.md` stage contracts, `adapters.md`, `config.default.yml`), `kaizen-help/`, and `media/` with `kaizen-deck/`, `kaizen-video/`, `kaizen-book/`. |
145
145
  | `agents/` | The Claude Code subagents: `kaizen-planner`, `kaizen-builder`, `kaizen-reviewer`. |
146
146
  | `commands/` | The `/kaizen-*` slash commands, one file each. |
147
147
  | `web/` | The board and notebook front end: `board.html`, `notes.html`, `notebook.ts`. |
package/cli/build-web.ts CHANGED
@@ -1,7 +1,7 @@
1
1
  // `bun run build:web`: bundles the notebook's script and CodeMirror into
2
2
  // web/notebook.js, the in-card terminal and xterm.js into web/term.js (+ its
3
3
  // stylesheet, web/term.css), the board's modules (web/board/) into web/board.js and
4
- // Kaizen Bot's page modules (bot/web/app/) into bot/web/app.js, all committed, so an install or a checkout serves it with no
4
+ // Kaizen Bot's page modules (bot/web/app/) into bot/web/app.js, the Video page's script into web/video.js, all committed, so an install or a checkout serves it with no
5
5
  // install step and no network. The first line is a hash of what it was built from;
6
6
  // cli/notes.test.ts fails when a committed file no longer matches.
7
7
  import { createHash } from "node:crypto";
@@ -10,11 +10,12 @@ import { join, dirname } from "node:path";
10
10
 
11
11
  const root = dirname(import.meta.dir);
12
12
  const modules = (dir: string) => readdirSync(join(root, dir)).filter((f) => f.endsWith(".ts")).sort().map((f) => `${dir}/${f}`);
13
- const SOURCES = ["web/notebook.ts", "web/notes-lib.ts", "web/term.ts", ...modules("web/board"), ...modules("bot/web/app")];
13
+ const SOURCES = ["web/notebook.ts", "web/notes-lib.ts", "web/term.ts", "web/video.ts", ...modules("web/board"), ...modules("bot/web/app")];
14
14
  export const OUT = join(root, "web", "notebook.js");
15
15
  export const TERM_OUT = join(root, "web", "term.js");
16
16
  export const BOARD_OUT = join(root, "web", "board.js");
17
17
  export const BOT_OUT = join(root, "bot", "web", "app.js");
18
+ export const VIDEO_OUT = join(root, "web", "video.js");
18
19
 
19
20
  // The sources and the exact dependency versions: a CodeMirror bump is a rebuild too.
20
21
  export function sourceHash() {
@@ -31,6 +32,7 @@ if (import.meta.main) {
31
32
  ["web/term.ts", TERM_OUT, "terminal"],
32
33
  ["web/board/main.ts", BOARD_OUT, "board"],
33
34
  ["bot/web/app/main.ts", BOT_OUT, "bot page"],
35
+ ["web/video.ts", VIDEO_OUT, "video page"],
34
36
  ] as const;
35
37
  for (const [src, out, what] of bundles) {
36
38
  // The terminal, the board and the bot page are classic scripts: wrapped, or their
package/cli/hermes.ts ADDED
@@ -0,0 +1,65 @@
1
+ // WhatsApp through Hermes (`hermes send`): the chats it knows, and one file with a
2
+ // message to one of them. Always argv, never a shell line: the message is one argument.
3
+ import { spawn } from "node:child_process";
4
+ import { existsSync } from "node:fs";
5
+ import { join } from "node:path";
6
+
7
+ export type Chat = { id: string; name: string; type: string };
8
+ type Out = { code: number | string; out: string; err: string };
9
+
10
+ // On PATH first (tests put a fake there), else where Hermes' installer puts it: a board
11
+ // started from a desktop launcher often has no ~/.local/bin on its PATH.
12
+ function bin() {
13
+ const p = Bun.which("hermes", { PATH: process.env.PATH ?? "" });
14
+ if (p) return p;
15
+ const home = join(process.env.HOME || process.env.USERPROFILE || "", ".local", "bin", "hermes");
16
+ return existsSync(home) ? home : null;
17
+ }
18
+
19
+ function run(args: string[], ms: number): Promise<Out> {
20
+ const exe = bin();
21
+ if (!exe) return Promise.resolve({ code: "Hermes is not installed (no hermes on PATH or in ~/.local/bin)", out: "", err: "" });
22
+ return new Promise((done) => {
23
+ const p = spawn(exe, args, { env: process.env, stdio: ["ignore", "pipe", "pipe"], windowsHide: true });
24
+ let out = "", err = "";
25
+ p.stdout!.on("data", (c) => { out += c; });
26
+ p.stderr!.on("data", (c) => { err += c; });
27
+ const t = setTimeout(() => { p.kill("SIGKILL"); done({ code: `Hermes did not finish in ${Math.round(ms / 1000)} s`, out, err }); }, ms);
28
+ p.on("error", (e) => { clearTimeout(t); done({ code: `Hermes could not start: ${e.message}`, out, err }); });
29
+ p.on("close", (c) => { clearTimeout(t); done({ code: c ?? 1, out, err }); });
30
+ });
31
+ }
32
+ const lastLine = (s: string) => s.trim().split("\n").filter(Boolean).pop()?.replace(/^hermes send:\s*/, "") ?? "";
33
+
34
+ export async function targets(): Promise<{ chats: Chat[] } | { error: string }> {
35
+ const r = await run(["send", "--list", "whatsapp", "--json"], 10_000);
36
+ if (typeof r.code === "string") return { error: r.code };
37
+ let j: any;
38
+ try { j = JSON.parse(r.out); } catch { return { error: lastLine(r.err) || "Hermes gave no chat list" }; }
39
+ const wa = j?.platforms?.whatsapp;
40
+ if (!Array.isArray(wa) || !wa.length) return { error: "WhatsApp is not set up in Hermes (hermes gateway setup)" };
41
+ return { chats: wa.filter((c: any) => typeof c?.id === "string" && c.id).map((c: any) => ({ id: `whatsapp:${c.id}`, name: String(c.name ?? c.id), type: String(c.type ?? "") })) };
42
+ }
43
+
44
+ // Hermes attaches every MEDIA:<path> it finds in the text, so the user's words may not
45
+ // carry one: that would send a file the page never offered.
46
+ export function message(text: string, file: string): string | { error: string } {
47
+ if (text.length > 1000) return { error: "the message is over 1,000 characters" };
48
+ if (/MEDIA:/i.test(text)) return { error: "the message may not contain MEDIA:" };
49
+ if (/[`\n\r]/.test(file)) return { error: "the file's path cannot be passed to Hermes" };
50
+ // The path in backticks: Hermes reads a quoted path whole, spaces and all.
51
+ return `${text.trim()}${text.trim() ? "\n" : ""}MEDIA:\`${file}\``;
52
+ }
53
+
54
+ // Sent only when Hermes exits 0 with {success: true}; anything else is its own error.
55
+ export async function sendMedia(target: string, text: string, file: string): Promise<{ ok: true } | { ok: false; error: string }> {
56
+ const m = message(text, file);
57
+ if (typeof m !== "string") return { ok: false, error: m.error };
58
+ // "--" so a message starting with "-" is text, not an option.
59
+ const r = await run(["send", "-t", target, "--json", "--", m], 5 * 60_000);
60
+ if (typeof r.code === "string") return { ok: false, error: r.code };
61
+ let j: any = null;
62
+ try { j = JSON.parse(r.out); } catch { /* not JSON: fall through to stderr */ }
63
+ if (r.code === 0 && j?.success && !j.error) return { ok: true };
64
+ return { ok: false, error: String(j?.error ?? (lastLine(r.err) || `Hermes exited ${r.code}`)).slice(0, 400) };
65
+ }
package/cli/install.ts CHANGED
@@ -2,7 +2,7 @@
2
2
  import {
3
3
  symlinkSync, mkdirSync, readdirSync, lstatSync, unlinkSync,
4
4
  existsSync, copyFileSync, readFileSync, appendFileSync, rmSync, writeFileSync, renameSync,
5
- statSync, cpSync, realpathSync, rmdirSync,
5
+ statSync, cpSync, realpathSync, rmdirSync, readlinkSync,
6
6
  } from "node:fs";
7
7
  import { join, dirname, basename, resolve } from "node:path";
8
8
  import { homedir, tmpdir } from "node:os";
@@ -46,8 +46,12 @@ const c = {
46
46
  green: (s: string) => `\x1b[32m${s}\x1b[0m`,
47
47
  };
48
48
 
49
- // Skill folders shipped in skills/ and linked into every agent.
50
- const SKILLS = ["kaizen", "kaizen-help", "story-deck", "story-video", "kaizen-video"];
49
+ // Skill folders shipped in skills/ and linked into every agent, by their last
50
+ // path segment: media/kaizen-deck is linked as skills/kaizen-deck.
51
+ const SKILLS = ["kaizen", "kaizen-help", "media/kaizen-deck", "media/kaizen-video", "media/kaizen-book"];
52
+ // Skill names an older release linked; uninstall drops them and install
53
+ // clears them when they are still our links.
54
+ const RENAMED_SKILLS = ["story-deck", "story-video", "kaizen-motion"];
51
55
 
52
56
  // Where each agent reads skills from. The skill folders follow the open Agent
53
57
  // Skills standard, so every one of these can run the workflow; agents/ and
@@ -244,7 +248,9 @@ async function removeEverything() {
244
248
 
245
249
  for (const root of roots) {
246
250
  for (const a of AGENTS) {
247
- for (const n of SKILLS) drop(join(root, a.dir, "skills", n));
251
+ for (const n of SKILLS) drop(join(root, a.dir, "skills", basename(n)));
252
+ // An old name is only ours while it is still a link; a real folder there is the user's.
253
+ for (const n of RENAMED_SKILLS) if (isLink(join(root, a.dir, "skills", n))) drop(join(root, a.dir, "skills", n));
248
254
  for (const sub of ["agents", "commands"]) {
249
255
  const dir = join(root, a.dir, sub);
250
256
  try {
@@ -524,7 +530,7 @@ if (interactive && !upgrade && Bun.argv.length === 2) {
524
530
  if (!stale) {
525
531
  const here = found(home);
526
532
  const linked = here.length > 0 && here.every((a) =>
527
- SKILLS.every((n) => existsSync(join(home, a.dir, "skills", n))));
533
+ SKILLS.every((n) => existsSync(join(home, a.dir, "skills", basename(n)))));
528
534
  if (linked) {
529
535
  const { dashboard } = await import("./dashboard.ts");
530
536
  await dashboard(repoDir, here.map((a) => a.name), async (action) => {
@@ -900,6 +906,13 @@ for (const [src, sub] of links) {
900
906
  wrote++;
901
907
  if (verbose) console.log(c.dim(` link ${name}`));
902
908
  }
909
+ if (!check) {
910
+ for (const a of targets) for (const n of RENAMED_SKILLS) {
911
+ const p = join(root, a.dir, "skills", n);
912
+ // Ours only: a dangling link to a folder of that same name, which kaizen has since renamed.
913
+ if (isLink(p) && !existsSync(p) && basename(readlinkSync(p).replace(/[\\/]+$/, "")) === n) removeLink(p);
914
+ }
915
+ }
903
916
 
904
917
  // A project's spec copies are what a run there reads, and they are copied once.
905
918
  function specDrift() {
@@ -0,0 +1,84 @@
1
+ // LocalSend's sending half against a fake device that speaks protocol v2: what it
2
+ // receives, and what each of its refusals reads as on the page.
3
+ import { test, expect } from "bun:test";
4
+ import { mkdtempSync, writeFileSync } from "node:fs";
5
+ import { join } from "node:path";
6
+ import { tmpdir } from "node:os";
7
+ import { randomBytes, X509Certificate, createPrivateKey } from "node:crypto";
8
+ import { identity, sendFile, type Device } from "./localsend.ts";
9
+
10
+ // prepare: what prepare-upload answers (a status, or "hang" to never answer).
11
+ function fakeDevice(prepare: number | "hang") {
12
+ const got: { prepare?: any; upload?: { query: Record<string, string>; bytes: Buffer } } = {};
13
+ const srv = Bun.serve({ port: 0, hostname: "127.0.0.1", async fetch(req) {
14
+ const u = new URL(req.url);
15
+ if (u.pathname === "/api/localsend/v2/prepare-upload") {
16
+ got.prepare = await req.json();
17
+ if (prepare === "hang") { await Bun.sleep(5000); return new Response(null, { status: 500 }); }
18
+ if (prepare !== 200) return new Response(null, { status: prepare });
19
+ return Response.json({ sessionId: "s-1", files: { f1: "tok-1" } });
20
+ }
21
+ if (u.pathname === "/api/localsend/v2/upload") {
22
+ const q = Object.fromEntries(u.searchParams);
23
+ if (q.sessionId !== "s-1" || q.token !== "tok-1" || q.fileId !== "f1") return new Response(null, { status: 403 });
24
+ got.upload = { query: q, bytes: Buffer.from(await req.arrayBuffer()) };
25
+ return new Response(null, { status: 200 });
26
+ }
27
+ return new Response(null, { status: 404 });
28
+ } });
29
+ const dev: Device = { alias: "Pixel 8", deviceModel: "Pixel", deviceType: "mobile", ip: "127.0.0.1", port: srv.port, protocol: "http", fingerprint: "fp" };
30
+ return { dev, got, stop: () => srv.stop(true) };
31
+ }
32
+
33
+ function file() {
34
+ const f = join(mkdtempSync(join(tmpdir(), "kaizen-ls-")), "demo-9x16.mp4");
35
+ writeFileSync(f, randomBytes(300_000));
36
+ return f;
37
+ }
38
+
39
+ test("a send carries the file's bytes with the session and token the device gave", async () => {
40
+ const d = fakeDevice(200), f = file(), steps: string[] = [];
41
+ try {
42
+ expect(await sendFile(d.dev, f, (p) => steps.push(p.phase))).toEqual({ ok: true });
43
+ expect(d.got.prepare.files.f1).toMatchObject({ id: "f1", fileName: "demo-9x16.mp4", size: 300_000, fileType: "video/mp4" });
44
+ expect(d.got.prepare.info.alias).toStartWith("kaizen");
45
+ expect(d.got.upload!.query).toEqual({ sessionId: "s-1", fileId: "f1", token: "tok-1" });
46
+ expect(d.got.upload!.bytes.equals(await Bun.file(f).bytes().then(Buffer.from))).toBe(true);
47
+ expect(steps[0]).toBe("waiting");
48
+ expect(steps.at(-1)).toBe("uploading");
49
+ } finally { d.stop(); }
50
+ });
51
+
52
+ test("a refusal, a busy device and no answer each say so, never sent", async () => {
53
+ const f = file();
54
+ for (const [status, words] of [[403, "Pixel 8 said no"], [409, "Pixel 8 is busy with another transfer"], [401, "PIN"], [500, "Pixel 8 answered 500"]] as const) {
55
+ const d = fakeDevice(status);
56
+ try {
57
+ const r = await sendFile(d.dev, f);
58
+ expect(r.ok).toBe(false);
59
+ expect((r as any).error).toContain(words);
60
+ expect(d.got.upload).toBeUndefined();
61
+ } finally { d.stop(); }
62
+ }
63
+ const d = fakeDevice("hang");
64
+ try {
65
+ const r = await sendFile(d.dev, f, () => {}, { acceptMs: 300 });
66
+ expect(r).toEqual({ ok: false, error: "No answer from Pixel 8 in 1 minute" });
67
+ } finally { d.stop(); }
68
+ // Nobody there at all.
69
+ const r = await sendFile({ ...d.dev, port: 1 }, f);
70
+ expect(r.ok).toBe(false);
71
+ expect((r as any).error).toStartWith("Could not reach Pixel 8");
72
+ });
73
+
74
+ // A real LocalSend in HTTPS mode refuses a sender without a client certificate
75
+ // ("tlsv13 alert certificate required"), and reads the sender's fingerprint as its SHA-256.
76
+ test("we carry a self-signed certificate whose SHA-256 is our fingerprint", () => {
77
+ const me = identity(), x = new X509Certificate(me.cert);
78
+ expect(x.subject).toBe("CN=kaizen");
79
+ expect(x.verify(x.publicKey)).toBe(true);
80
+ expect(x.checkPrivateKey(createPrivateKey(me.key))).toBe(true);
81
+ expect(x.fingerprint256.replace(/:/g, "")).toBe(me.fingerprint);
82
+ expect(new Date(x.validTo).getTime()).toBeGreaterThan(Date.now() + 365 * 86_400_000);
83
+ expect(identity()).toBe(me);
84
+ });
@@ -0,0 +1,131 @@
1
+ // LocalSend protocol v2, the sending half: find devices on this network the way the
2
+ // LocalSend app does, and send one file to one of them. The person accepts on the
3
+ // device. https://github.com/localsend/protocol — Bun's dgram, fetch and Bun.serve only.
4
+ import { createSocket } from "node:dgram";
5
+ import { createHash, generateKeyPairSync, randomBytes, sign } from "node:crypto";
6
+ import { basename } from "node:path";
7
+ import { hostname } from "node:os";
8
+
9
+ export const GROUP = "224.0.0.167", PORT = 53317;
10
+ export type Device = { alias: string; deviceModel: string | null; deviceType: string | null; ip: string; port: number; protocol: "http" | "https"; fingerprint: string };
11
+
12
+ // A device in HTTPS mode (the app's default) asks the sender for a TLS client
13
+ // certificate, and LocalSend's fingerprint is that certificate's SHA-256. So we carry a
14
+ // self-signed one, made once per board on first use: an RSA key and a minimal X.509 v3
15
+ // certificate in DER, signed with node:crypto, since Bun can sign but not issue one.
16
+ const der = (tag: number, ...parts: Buffer[]) => {
17
+ const body = Buffer.concat(parts), n = body.length;
18
+ const len = n < 128 ? Buffer.from([n]) : n < 256 ? Buffer.from([0x81, n]) : Buffer.from([0x82, n >> 8, n & 255]);
19
+ return Buffer.concat([Buffer.from([tag]), len, body]);
20
+ };
21
+ const seq = (...p: Buffer[]) => der(0x30, ...p);
22
+ const oid = (hex: string) => der(0x06, Buffer.from(hex, "hex"));
23
+ const time = (d: Date) => der(0x18, Buffer.from(d.toISOString().replace(/[-:T]|\.\d+/g, "")));
24
+ const pem = (label: string, b: Buffer) => `-----BEGIN ${label}-----\n${b.toString("base64").match(/.{1,64}/g)!.join("\n")}\n-----END ${label}-----\n`;
25
+ let id: { cert: string; key: string; fingerprint: string } | null = null;
26
+ export function identity() {
27
+ if (id) return id;
28
+ const { publicKey, privateKey } = generateKeyPairSync("rsa", { modulusLength: 2048 });
29
+ const alg = seq(oid("2a864886f70d01010b"), Buffer.from([5, 0])); // sha256WithRSAEncryption
30
+ const name = seq(der(0x31, seq(oid("550403"), der(0x0c, Buffer.from("kaizen")))));
31
+ const now = Date.now();
32
+ const tbs = seq(der(0xa0, der(0x02, Buffer.from([2]))), der(0x02, Buffer.concat([Buffer.from([1]), randomBytes(8)])), alg, name,
33
+ seq(time(new Date(now - 86_400_000)), time(new Date(now + 10 * 365 * 86_400_000))), name, publicKey.export({ type: "spki", format: "der" }));
34
+ const cert = seq(tbs, alg, der(0x03, Buffer.from([0]), sign("sha256", tbs, privateKey)));
35
+ return id = { cert: pem("CERTIFICATE", cert), key: privateKey.export({ type: "pkcs8", format: "pem" }) as string, fingerprint: createHash("sha256").update(cert).digest("hex").toUpperCase() };
36
+ }
37
+ // Who we say we are; the fingerprint also lets discovery drop our own announce.
38
+ const me = () => ({ alias: `kaizen (${hostname()})`, version: "2.0", deviceModel: "kaizen", deviceType: "desktop", fingerprint: identity().fingerprint, download: false });
39
+
40
+ const device = (m: any, ip: string): Device | null => {
41
+ if (!m || typeof m.fingerprint !== "string" || !m.fingerprint || m.fingerprint === me().fingerprint || typeof m.alias !== "string") return null;
42
+ const port = Number(m.port ?? PORT);
43
+ if (!Number.isInteger(port) || port < 1 || port > 65535) return null;
44
+ return { alias: m.alias.slice(0, 80), deviceModel: typeof m.deviceModel === "string" ? m.deviceModel : null, deviceType: typeof m.deviceType === "string" ? m.deviceType : null,
45
+ ip: ip.replace(/^::ffff:/, ""), port, protocol: m.protocol === "https" ? "https" : "http", fingerprint: m.fingerprint };
46
+ };
47
+
48
+ // Announce on the multicast group and collect who answers for `ms`: devices reply by
49
+ // POSTing /register to the port we announced, or by multicast. The LocalSend app on this
50
+ // computer may hold UDP 53317 already; reuseAddr shares it on Linux and macOS, and where
51
+ // binding still fails we announce from any port and rely on the HTTP replies.
52
+ export async function discover(ms = 3000): Promise<{ devices: Device[]; note: string | null }> {
53
+ const found = new Map<string, Device>();
54
+ const add = (d: Device | null) => { if (d) found.set(d.fingerprint, d); };
55
+ const http = Bun.serve({ port: 0, hostname: "0.0.0.0", async fetch(req, srv) {
56
+ if (req.method === "POST" && new URL(req.url).pathname === "/api/localsend/v2/register") {
57
+ add(device(await req.json().catch(() => null), srv.requestIP(req)?.address ?? ""));
58
+ return Response.json({ ...me(), port: http.port, protocol: "http" });
59
+ }
60
+ return new Response("not found", { status: 404 });
61
+ } });
62
+ const sock = createSocket({ type: "udp4", reuseAddr: true });
63
+ let note: string | null = null;
64
+ try {
65
+ sock.on("message", (buf, r) => { try { add(device(JSON.parse(String(buf)), r.address)); } catch { /* not LocalSend */ } });
66
+ await new Promise<void>((ok) => {
67
+ sock.once("error", () => { note = "could not listen on UDP 53317; only devices that answer over HTTP are listed"; sock.bind(0, ok); });
68
+ sock.bind(PORT, () => { try { sock.addMembership(GROUP); } catch { /* no multicast route; replies still come over HTTP */ } ok(); });
69
+ });
70
+ const hello = Buffer.from(JSON.stringify({ ...me(), port: http.port, protocol: "http", announce: true }));
71
+ // Twice, as the app does: one datagram on a busy Wi-Fi is easy to lose.
72
+ for (const wait of [0, 600]) { await Bun.sleep(wait); sock.send(hello, PORT, GROUP); }
73
+ await Bun.sleep(Math.max(0, ms - 600));
74
+ } catch (e) { note = `discovery failed: ${e instanceof Error ? e.message : e}`; }
75
+ finally { try { sock.close(); } catch { /* closed */ } http.stop(true); }
76
+ return { devices: [...found.values()].sort((a, b) => a.alias.localeCompare(b.alias)), note };
77
+ }
78
+
79
+ export type Progress = { phase: "waiting" | "uploading"; pct: number };
80
+ export type Result = { ok: true } | { ok: false; error: string };
81
+
82
+ // One file to one device. The device shows its accept prompt during prepare-upload;
83
+ // "sent" is only the upload's 200. Self-signed certificates are accepted: LocalSend
84
+ // devices all use one, the same trust LocalSend itself gives a device on the LAN; ours goes
85
+ // back as the client certificate.
86
+ export async function sendFile(dev: Device, file: string, onProgress: (p: Progress) => void = () => {},
87
+ { acceptMs = 120_000, uploadMs = 30 * 60_000 } = {}): Promise<Result> {
88
+ const base = `${dev.protocol}://${dev.ip.includes(":") ? `[${dev.ip}]` : dev.ip}:${dev.port}/api/localsend/v2`;
89
+ const f = Bun.file(file), size = f.size, name = basename(file);
90
+ const type = name.endsWith(".mp4") ? "video/mp4" : name.endsWith(".jpg") ? "image/jpeg" : name.endsWith(".srt") ? "text/plain" : "application/octet-stream";
91
+ const { cert, key } = identity();
92
+ const tls = { rejectUnauthorized: false, cert, key };
93
+ onProgress({ phase: "waiting", pct: 0 });
94
+ let r: Response;
95
+ try {
96
+ r = await fetch(`${base}/prepare-upload`, { method: "POST", tls, signal: AbortSignal.timeout(acceptMs), headers: { "content-type": "application/json" },
97
+ body: JSON.stringify({ info: { ...me(), port: PORT, protocol: "http" }, files: { f1: { id: "f1", fileName: name, size, fileType: type } } }) } as RequestInit);
98
+ } catch (e) {
99
+ return { ok: false, error: e instanceof Error && e.name === "TimeoutError" ? `No answer from ${dev.alias} in ${Math.round(acceptMs / 60_000) || 1} minute${acceptMs > 60_000 ? "s" : ""}` : `Could not reach ${dev.alias}: ${e instanceof Error ? e.message : e}` };
100
+ }
101
+ if (r.status === 204) return { ok: false, error: `${dev.alias} needed nothing (it already has the file)` };
102
+ if (r.status === 403) return { ok: false, error: `${dev.alias} said no` };
103
+ if (r.status === 401) return { ok: false, error: `${dev.alias} asks for a PIN; turn the PIN off in its LocalSend settings` };
104
+ if (r.status === 409) return { ok: false, error: `${dev.alias} is busy with another transfer` };
105
+ if (r.status === 429) return { ok: false, error: `${dev.alias} says too many requests; try again in a minute` };
106
+ if (r.status !== 200) return { ok: false, error: `${dev.alias} answered ${r.status}` };
107
+ const s = await r.json().catch(() => null) as { sessionId?: string; files?: Record<string, string> } | null;
108
+ const token = s?.files?.f1;
109
+ if (!s?.sessionId || !token) return { ok: false, error: `${dev.alias} did not accept the file` };
110
+
111
+ // The bytes, streamed, counting as they go.
112
+ let sent = 0, last = -1;
113
+ const reader = f.stream().getReader();
114
+ const body = new ReadableStream<Uint8Array>({ async pull(c) {
115
+ const { done, value } = await reader.read();
116
+ if (done) return c.close();
117
+ sent += value.byteLength;
118
+ const pct = size ? Math.floor((sent / size) * 100) : 100;
119
+ if (pct !== last) { last = pct; onProgress({ phase: "uploading", pct }); }
120
+ c.enqueue(value);
121
+ } });
122
+ const q = new URLSearchParams({ sessionId: s.sessionId, fileId: "f1", token });
123
+ try {
124
+ const u = await fetch(`${base}/upload?${q}`, { method: "POST", tls, body, signal: AbortSignal.timeout(uploadMs),
125
+ headers: { "content-type": type, "content-length": String(size) } } as RequestInit);
126
+ if (u.status === 200) return { ok: true };
127
+ return { ok: false, error: u.status === 403 ? `${dev.alias} cancelled the transfer` : `${dev.alias} stopped the upload (${u.status})` };
128
+ } catch (e) {
129
+ return { ok: false, error: e instanceof Error && e.name === "TimeoutError" ? `The upload to ${dev.alias} took over ${Math.round(uploadMs / 60_000)} minutes` : `The upload to ${dev.alias} broke off: ${e instanceof Error ? e.message : e}` };
130
+ }
131
+ }
package/cli/notes.test.ts CHANGED
@@ -6,7 +6,7 @@ import { join, dirname } from "node:path";
6
6
  import { tmpdir } from "node:os";
7
7
  import { safeName, listNotes, saveNote, renameNote, deleteNote, boardIndex, linksTo } from "./notes.ts";
8
8
  import { wikilinks, tags, resolve, safeUrl, tree, uncode, freshName, parseTable, boardLink, resolveRun, anchorWords } from "../web/notes-lib.ts";
9
- import { sourceHash, OUT, TERM_OUT, BOARD_OUT, BOT_OUT } from "./build-web.ts";
9
+ import { sourceHash, OUT, TERM_OUT, BOARD_OUT, BOT_OUT, VIDEO_OUT } from "./build-web.ts";
10
10
 
11
11
  let state = "";
12
12
  beforeEach(() => { state = join(mkdtempSync(join(tmpdir(), "kaizen-notes-")), ".kaizen"); mkdirSync(state); });
@@ -223,15 +223,17 @@ test("the side menu is the board's on the notebook, line for line", () => {
223
223
  return s.slice(at, s.indexOf(to, at) + to.length);
224
224
  };
225
225
  for (const [from, to] of [["/* side menu:", "/* end side menu */"], ['<nav class="rail"', "</script>"]] as const)
226
- expect(between("notes.html", from, to)).toBe(between("board.html", from, to));
227
- const syms = (f: string) => ["board", "note", "link", "gear", "side", "sun", "moon"].map((n) => readFileSync(join(root, "web", f), "utf8").split("\n").find((l) => l.startsWith(`<symbol id="i-${n}"`)));
226
+ for (const page of ["notes.html", "video.html"]) expect(between(page, from, to)).toBe(between("board.html", from, to));
227
+ const syms = (f: string) => ["board", "note", "video", "link", "gear", "side", "sun", "moon"].map((n) => readFileSync(join(root, "web", f), "utf8").split("\n").find((l) => l.startsWith(`<symbol id="i-${n}"`)));
228
228
  expect(syms("notes.html")).toEqual(syms("board.html"));
229
+ expect(syms("video.html")).toEqual(syms("board.html"));
229
230
  expect(syms("board.html").every(Boolean)).toBe(true);
230
231
  });
231
232
 
232
- test("web/notebook.js, web/term.js, web/board.js and bot/web/app.js were built from the sources as they are (bun run build:web)", () => {
233
+ test("web/notebook.js, web/term.js, web/board.js, web/video.js and bot/web/app.js were built from the sources as they are (bun run build:web)", () => {
233
234
  expect(readFileSync(OUT, "utf8").split("\n", 1)[0]!).toContain(`kaizen notebook ${sourceHash()} `);
234
235
  expect(readFileSync(TERM_OUT, "utf8").split("\n", 1)[0]!).toContain(`kaizen terminal ${sourceHash()} `);
235
236
  expect(readFileSync(BOARD_OUT, "utf8").split("\n", 1)[0]!).toContain(`kaizen board ${sourceHash()} `);
236
237
  expect(readFileSync(BOT_OUT, "utf8").split("\n", 1)[0]!).toContain(`kaizen bot page ${sourceHash()} `);
238
+ expect(readFileSync(VIDEO_OUT, "utf8").split("\n", 1)[0]!).toContain(`kaizen video page ${sourceHash()} `);
237
239
  });
package/cli/settings.ts CHANGED
@@ -125,6 +125,7 @@ const SETTINGS: Setting[] = [
125
125
  { key: "sources.model", label: "ChatGPT model for gathering", group: "Board", values: [], text: true, help: "Empty: plan default", when: ["sources.agent", "chatgpt"] },
126
126
  { key: "sources.effort", label: "ChatGPT effort for gathering", group: "Board", values: ["low", "medium", "high"], help: "", when: ["sources.agent", "chatgpt"] },
127
127
  { key: "board.projects_dir", label: "Projects folder", group: "Board", values: [], text: true, folder: true, help: "Empty: known projects only" },
128
+ { key: "board.video_dir", label: "Video library folder", group: "Board", values: [], text: true, folder: true, help: "Empty: ~/.kaizen" },
128
129
  { key: "board.open", label: "Open the board in", group: "Board", values: ["browser", "app"], help: "" },
129
130
  { key: "ui.mouse", label: "Enable mouse", group: "Terminal", values: ["false", "true"], help: "" },
130
131
  ];
package/cli/state.test.ts CHANGED
@@ -854,6 +854,43 @@ test("ownerOf: a run that recorded the launch's session id owns it, however its
854
854
  expect(ownerOf(st, { ...rec, sessionId: "other" })).toBe(null);
855
855
  });
856
856
 
857
+ test("ownerOf: a conversation that opens a follow-up run moves to it, even from a decision on its parent", () => {
858
+ const p = sessionProject(), st = join(p, ".kaizen");
859
+ const tag = (id: string, more: object = {}) => {
860
+ const f = join(st, "runs", id, "state.json");
861
+ writeFileSync(f, JSON.stringify({ ...JSON.parse(readFileSync(f, "utf8")), session: "abc", ...more }));
862
+ };
863
+ const rec = { key: "k", run: null, text: "tidy logs", cmd: "claude", sessionId: "abc", started: Date.now() - 2000 };
864
+ newRun(p, "2026-10-01-parent", "/kaizen tidy logs");
865
+ tag("2026-10-01-parent");
866
+ expect(ownerOf(st, rec)).toBe("2026-10-01-parent");
867
+ // A parent still in flight keeps its terminal.
868
+ newRun(p, "2026-10-02-child", "/kaizen rotate them too\nFollow-up of: 2026-10-01-parent");
869
+ tag("2026-10-02-child");
870
+ expect(ownerOf(st, rec)).toBe("2026-10-01-parent");
871
+ expect(ownerOf(st, { ...rec, run: "2026-10-01-parent" })).toBe("2026-10-01-parent");
872
+ tag("2026-10-01-parent", { stage: "done", awaiting: null });
873
+ expect(ownerOf(st, rec)).toBe("2026-10-02-child");
874
+ expect(ownerOf(st, { ...rec, run: "2026-10-01-parent" })).toBe("2026-10-02-child");
875
+ // A decision launched after both: the run it names, as before.
876
+ expect(ownerOf(st, { ...rec, run: "2026-10-01-parent", started: Date.now() + 5000 })).toBe("2026-10-01-parent");
877
+
878
+ // The parent's next decision still continues the conversation the child took over.
879
+ const saved = process.env.CLAUDE_CONFIG_DIR;
880
+ process.env.CLAUDE_CONFIG_DIR = mkdtempSync(join(tmpdir(), "kz-claude-"));
881
+ try {
882
+ mkdirSync(join(st, "sessions"), { recursive: true });
883
+ const { key, ...body } = rec;
884
+ writeFileSync(join(st, "sessions", key + ".json"), JSON.stringify(body) + "\n");
885
+ const t = join(process.env.CLAUDE_CONFIG_DIR, "projects", p.replace(/[^A-Za-z0-9]/g, "-"));
886
+ mkdirSync(t, { recursive: true });
887
+ writeFileSync(join(t, "abc.jsonl"), "{}\n");
888
+ expect(sessionLaunch(p, "claude", ["/bin/claude"], "/kaizen run x", "x", "2026-10-01-parent").fullCmd.slice(0, 3)).toEqual(["/bin/claude", "--resume", "abc"]);
889
+ } finally {
890
+ saved === undefined ? delete process.env.CLAUDE_CONFIG_DIR : (process.env.CLAUDE_CONFIG_DIR = saved);
891
+ }
892
+ });
893
+
857
894
  test("findTerminal records the agent's PID: a POSIX wrapper that execs it, a PowerShell prefix on Windows", () => {
858
895
  const keys = ["PATH", "HERDR_ENV", "TMUX", "TERMINAL", "SystemRoot", "windir", "ComSpec"];
859
896
  const saved = keys.map((k) => process.env[k]);
package/cli/state.ts CHANGED
@@ -1356,21 +1356,23 @@ export function readSessions(st: string): Session[] {
1356
1356
  }
1357
1357
 
1358
1358
  // What ownerOf matches a record against, read once: each conversation id a run recorded
1359
- // at intake, to the first run (by id) that recorded it, and every run's request with
1360
- // when it was written.
1361
- type RunIndex = { bySession: Map<string, string>; requests: { id: string; born: number; text: string }[] };
1359
+ // at intake, to every run (by id) that recorded it with when its request was written,
1360
+ // and every run's request with that time.
1361
+ type RunIndex = { bySession: Map<string, { id: string; born: number; open: boolean }[]>; requests: { id: string; born: number; text: string }[] };
1362
1362
  export function runIndex(st: string): RunIndex {
1363
- const bySession = new Map<string, string>(), requests: RunIndex["requests"] = [];
1363
+ const bySession: RunIndex["bySession"] = new Map(), requests: RunIndex["requests"] = [];
1364
1364
  try {
1365
1365
  for (const id of readdirSync(join(st, "runs")).sort()) {
1366
- try {
1367
- const sid = JSON.parse(readText(join(st, "runs", id, "state.json"))).session;
1368
- if (sid && !bySession.has(sid)) bySession.set(sid, id);
1369
- } catch { /* no state yet */ }
1366
+ let born = 0;
1370
1367
  try {
1371
1368
  const f = join(st, "runs", id, "00-request.md"), st2 = statSync(f);
1372
- requests.push({ id, born: st2.birthtimeMs || st2.mtimeMs, text: normalise(readText(f)) });
1369
+ born = st2.birthtimeMs || st2.mtimeMs;
1370
+ requests.push({ id, born, text: normalise(readText(f)) });
1373
1371
  } catch { /* no request yet */ }
1372
+ try {
1373
+ const { session: sid, stage } = JSON.parse(readText(join(st, "runs", id, "state.json")));
1374
+ if (sid) bySession.set(sid, [...(bySession.get(sid) ?? []), { id, born, open: stage !== "done" && stage !== "abandoned" }]);
1375
+ } catch { /* no state yet */ }
1374
1376
  }
1375
1377
  } catch { /* no runs dir */ }
1376
1378
  return { bySession, requests };
@@ -1382,14 +1384,19 @@ export function runIndex(st: string): RunIndex {
1382
1384
  // A caller asking about many sessions passes runIndex(st) once: built per call, it
1383
1385
  // reads every run's state.json and request each time.
1384
1386
  export function ownerOf(st: string, s: Session, idx?: RunIndex): string | null {
1385
- if (s.run) return s.run;
1386
1387
  const { bySession, requests } = idx ?? runIndex(st);
1387
1388
  // The launch's own conversation id, recorded by the run at intake: holds however the
1388
- // request was worded. Text matching below is for tools that expose no id.
1389
- if (s.sessionId) {
1390
- const mine = bySession.get(s.sessionId);
1391
- if (mine) return mine;
1392
- }
1389
+ // request was worded. A conversation that opened a run after this launch (a follow-up)
1390
+ // has moved on to the newest such run, even from a decision on its parent: otherwise
1391
+ // that run never gets the terminal or a Resume. A parent still in flight keeps it: its
1392
+ // stage is not over, so the terminal is still its. Text matching below is for tools
1393
+ // that expose no id.
1394
+ const rec = s.sessionId ? bySession.get(s.sessionId) ?? [] : [];
1395
+ const later = rec.filter((r) => r.born >= s.started - 1000).sort((a, b) => a.born - b.born);
1396
+ const base = s.run ?? later[0]?.id ?? rec[0]?.id ?? null;
1397
+ const newest = later.pop();
1398
+ if (newest && !(base && base !== newest.id && rec.find((r) => r.id === base)?.open)) return newest.id;
1399
+ if (base) return base;
1393
1400
  const t = normalise(s.text);
1394
1401
  let best: string | null = null, at = Infinity;
1395
1402
  // A second of slack: file times come from a coarser clock than Date.now().
@@ -1537,8 +1544,11 @@ export function firstSession(st: string, id: string, cmd: string, cwd: string):
1537
1544
  if (!f) return null;
1538
1545
  const all = readSessions(st);
1539
1546
  const taken = new Set(all.flatMap((s) => (s.sessionId ? [s.sessionId] : []))), idx = runIndex(st);
1547
+ // Conversations this run recorded at intake: still its to continue after a follow-up
1548
+ // run it opened took the session's ownership.
1549
+ const mine = new Set([...idx.bySession].filter(([, rs]) => rs.some((r) => r.id === id)).map(([sid]) => sid));
1540
1550
  for (const s of all) {
1541
- if (s.cmd !== cmd || ownerOf(st, s, idx) !== id) continue;
1551
+ if (s.cmd !== cmd || (ownerOf(st, s, idx) !== id && !(s.sessionId && mine.has(s.sessionId)))) continue;
1542
1552
  if (!s.sessionId && f.find) {
1543
1553
  // Before the tool's next launch here, any run's: from then on a conversation may be that launch's.
1544
1554
  const next = all.find((o) => o.cmd === cmd && o.started > s.started)?.started ?? Infinity;
@@ -1610,10 +1620,13 @@ export function trustFolder(dir: string, cfg = join(process.env.CLAUDE_CONFIG_DI
1610
1620
  // where a pseudo-terminal exists; onChange is told when that agent ends.
1611
1621
  const CHILD_ENV = /^(CLAUDECODE|CLAUDE_PID|CLAUDE_CODE_(SESSION_ID|CHILD_SESSION|SESSION_ATTENDED|MESSAGING_SOCKET|MESSAGING_TOKEN))$/;
1612
1622
 
1613
- export function launchRun(it: Item, from: string, run?: string, pick: { agent?: string; model?: string; yolo?: boolean; card?: boolean; onChange?: () => void } = {}): Launch {
1614
- const projectDir = projectOf(from);
1623
+ // skill: start that skill instead of /kaizen (the Video page starts kaizen-video), in cwd:
1624
+ // a work folder that may not be a project, so it is not remembered as one. A session record is
1625
+ // written only where that folder has a .kaizen (a project place does; the library usually not).
1626
+ export function launchRun(it: Item, from: string, run?: string, pick: { agent?: string; model?: string; yolo?: boolean; card?: boolean; onChange?: () => void; skill?: "kaizen-video"; cwd?: string } = {}): Launch {
1627
+ const projectDir = pick.cwd ?? projectOf(from);
1615
1628
  // The run will be born here; the board must know the dir or the idea never retires.
1616
- remember(projectDir);
1629
+ if (!pick.cwd) remember(projectDir);
1617
1630
  const own = run && !pick.agent ? runAgent(join(projectDir, ".kaizen"), run) : null;
1618
1631
  const agent = detectDefaultAgent(projectDir, pick.agent ?? own?.cmd);
1619
1632
  if (agent.cmd === "claude") trustFolder(projectDir);
@@ -1623,7 +1636,9 @@ export function launchRun(it: Item, from: string, run?: string, pick: { agent?:
1623
1636
  // onPath, not Bun.which: the board may have taken a login shell's PATH after it started.
1624
1637
  const agentBin = winShim(onPath(agent.cmd) ?? agent.cmd);
1625
1638
  // No text: a run's conversation reopened as it was, with nothing sent to it.
1626
- const prompt = !it.text ? "" : requestOf(it.where ? `/kaizen ${it.text} (${it.where})` : `/kaizen ${it.text}`, it.notes, from);
1639
+ // A skill other than kaizen: Claude Code takes it as a slash command; other agents are told to use it.
1640
+ const lead = !pick.skill ? "/kaizen" : agent.cmd === "claude" ? `/${pick.skill}` : `Use the ${pick.skill} skill.`;
1641
+ const prompt = !it.text ? "" : requestOf(it.where ? `${lead} ${it.text} (${it.where})` : `${lead} ${it.text}`, it.notes, from);
1627
1642
  const wanted = pick.model ?? (own?.cmd === agent.cmd ? own.model : null) ?? undefined;
1628
1643
  const model = wanted && plainModel(wanted) ? wanted : undefined;
1629
1644
  // Yolo was picked for one tool; a fallback to another opens in its normal mode.