kaizen-agent 1.19.4 → 1.21.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 (41) hide show
  1. package/README.md +11 -0
  2. package/cli/board.test.ts +6 -2
  3. package/cli/install.ts +6 -3
  4. package/cli/state.test.ts +18 -1
  5. package/cli/state.ts +24 -1
  6. package/cli/web.ts +5 -1
  7. package/package.json +1 -1
  8. package/skills/story-deck/SKILL.md +63 -0
  9. package/skills/story-deck/examples/build-example.js +29 -0
  10. package/skills/story-deck/references/rules.md +62 -0
  11. package/skills/story-deck/references/story-arc.md +35 -0
  12. package/skills/story-deck/scripts/check.sh +12 -0
  13. package/skills/story-deck/scripts/install_fonts.py +42 -0
  14. package/skills/story-deck/scripts/kit.js +174 -0
  15. package/skills/story-deck/scripts/prep_images.py +70 -0
  16. package/skills/story-deck/scripts/qa.sh +48 -0
  17. package/skills/story-video/SKILL.md +108 -0
  18. package/skills/story-video/engine/package.json +12 -0
  19. package/skills/story-video/engine/src/Root.tsx +24 -0
  20. package/skills/story-video/engine/src/engine/Film.tsx +405 -0
  21. package/skills/story-video/engine/src/engine/presets.json +58 -0
  22. package/skills/story-video/engine/src/index.ts +3 -0
  23. package/skills/story-video/engine/tsconfig.json +1 -0
  24. package/skills/story-video/examples/sample-film/README.md +12 -0
  25. package/skills/story-video/examples/sample-film/film.json +16 -0
  26. package/skills/story-video/examples/sample-film/lines.json +7 -0
  27. package/skills/story-video/references/licensing.md +14 -0
  28. package/skills/story-video/references/presets.md +21 -0
  29. package/skills/story-video/references/production.md +29 -0
  30. package/skills/story-video/references/revisions.md +25 -0
  31. package/skills/story-video/references/script.md +30 -0
  32. package/skills/story-video/scripts/check.sh +19 -0
  33. package/skills/story-video/scripts/intake.py +73 -0
  34. package/skills/story-video/scripts/master.sh +26 -0
  35. package/skills/story-video/scripts/music.sh +10 -0
  36. package/skills/story-video/scripts/setup.sh +25 -0
  37. package/skills/story-video/scripts/sfx.py +64 -0
  38. package/skills/story-video/scripts/srt.py +30 -0
  39. package/skills/story-video/scripts/still.sh +10 -0
  40. package/skills/story-video/scripts/vo.py +99 -0
  41. package/web/board.html +2 -1
package/README.md CHANGED
@@ -232,6 +232,17 @@ In a terminal: `kaizen` (dashboard), `kaizen web` (board), `kaizen settings`, `k
232
232
 
233
233
  Stuck on 1.18.0 or older? If `kaizen upgrade` says *could not pull* and stays on the old version, run this once: `bunx kaizen-agent@latest`. Your old install is kept as `~/kaizen.old`; from then on `kaizen upgrade` works by itself.
234
234
 
235
+ ## Bundled skills
236
+
237
+ Installed alongside kaizen; your agent uses them when a request fits. Everything they make lives in `~/.kaizen/`.
238
+
239
+ | Skill | What it makes |
240
+ |---|---|
241
+ | `story-deck` | A cinematic storytelling pitch deck (A5 `.pptx`): story first, the product last. Output in `~/.kaizen/decks/`. |
242
+ | `story-video` | A publish-ready video from a prompt, photos, screenshots, a deck or footage: narration, captions, sound design, music, mastering, 16:9 and 9:16, revisable. Output in `~/.kaizen/videos/`. Works with no accounts (free voice, synthesized sounds); an ElevenLabs key unlocks natural voices. |
243
+
244
+ Each skill has a `scripts/check.sh` that lists what it needs (Node, Python, ffmpeg, LibreOffice).
245
+
235
246
  ## Learn more
236
247
 
237
248
  - **[The guide](https://github.com/hfadhlullah/kaizen/blob/main/docs/guide.md)**: the board, request sources, notebook, settings, installer flags, uninstall
package/cli/board.test.ts CHANGED
@@ -109,12 +109,16 @@ test("card menu items: appropriate actions and distinct icons across phases", ()
109
109
 
110
110
  const ico = (n: string) => `<svg><use href="#i-${n}"/></svg>`;
111
111
  const menuSnippet = (html.slice(html.indexOf("if(k===menuKey){"), html.indexOf("el.innerHTML+=`<div class=\"menu-pop\"", html.indexOf("if(k===menuKey){"))) + "}").replace("const items=[];", "items=[];");
112
- const getItems = new Function("c", "archive", `const ico = ${ico.toString()};\nlet k = 1, menuKey = 1, items = [];\n${menuSnippet}\nreturn items;`);
112
+ const getItems = new Function("c", "archive", "S", `const ico = ${ico.toString()};\nlet k = 1, menuKey = 1, items = [];\n${menuSnippet}\nreturn items;`);
113
113
 
114
114
  // Phase 0: Ideas (idea card)
115
115
  const idea = { kind: "idea", status: "idea", column: 0 };
116
- const ideaItems = getItems(idea, false);
116
+ const ideaItems = getItems(idea, false, { projects: [] });
117
117
  expect(ideaItems.map((i: any) => i ? i[1] : null)).toEqual(["Run", "Edit", "Archive", null, "Reject", "Delete"]);
118
+ // Move only when there is another project to move to.
119
+ const moveItems = getItems({ ...idea, state: "/a" }, false, { projects: [{ dir: "/a" }, { dir: "/b" }] });
120
+ expect(moveItems.map((i: any) => i ? i[1] : null)).toEqual(["Run", "Edit", "Move to project", "Archive", null, "Reject", "Delete"]);
121
+ expect(moveItems[2][2]).toBe(ico("ext"));
118
122
  expect(ideaItems[0][2]).toBe(ico("play"));
119
123
  expect(ideaItems[1][2]).toBe(ico("pen"));
120
124
  expect(ideaItems[2][2]).toBe(ico("archive"));
package/cli/install.ts CHANGED
@@ -46,6 +46,9 @@ 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"];
51
+
49
52
  // Where each agent reads skills from. The skill folders follow the open Agent
50
53
  // Skills standard, so every one of these can run the workflow; agents/ and
51
54
  // commands/ are Claude Code's own formats and go nowhere else.
@@ -177,7 +180,7 @@ async function removeEverything() {
177
180
 
178
181
  for (const root of roots) {
179
182
  for (const a of AGENTS) {
180
- for (const n of ["kaizen", "kaizen-help"]) drop(join(root, a.dir, "skills", n));
183
+ for (const n of SKILLS) drop(join(root, a.dir, "skills", n));
181
184
  for (const sub of ["agents", "commands"]) {
182
185
  const dir = join(root, a.dir, sub);
183
186
  try {
@@ -386,7 +389,7 @@ if (interactive && !upgrade && Bun.argv.length === 2) {
386
389
  if (!stale) {
387
390
  const here = found(home);
388
391
  const linked = here.length > 0 && here.every((a) =>
389
- ["kaizen", "kaizen-help"].every((n) => existsSync(join(home, a.dir, "skills", n))));
392
+ SKILLS.every((n) => existsSync(join(home, a.dir, "skills", n))));
390
393
  if (linked) {
391
394
  const { dashboard } = await import("./dashboard.ts");
392
395
  await dashboard(repoDir, here.map((a) => a.name), async (action) => {
@@ -679,7 +682,7 @@ function dirEntries(sub: string) {
679
682
  }
680
683
 
681
684
  const links = targets.flatMap((a) => [
682
- ...["kaizen", "kaizen-help"].map((n) => [join(repo, "skills", n), join(a.dir, "skills")] as const),
685
+ ...SKILLS.map((n) => [join(repo, "skills", n), join(a.dir, "skills")] as const),
683
686
  ...(a.full ? [
684
687
  ...dirEntries("agents").map((f) => [f, join(a.dir, "agents")] as const),
685
688
  ...dirEntries("commands").map((f) => [f, join(a.dir, "commands")] as const),
package/cli/state.test.ts CHANGED
@@ -7,7 +7,7 @@ import { tmpdir } from "node:os";
7
7
  import { issueUrl, systemInfo, scrubHome, REPO_URL, home as realHome, trustFolder } from "./state.ts";
8
8
  import * as stateModule from "./state.ts";
9
9
  import {
10
- readRuns, readInbox, writeInbox, replaceIdea, abandonRun, allBacklog, backlog,
10
+ readRuns, readInbox, writeInbox, replaceIdea, moveIdea, abandonRun, allBacklog, backlog,
11
11
  statusOf, columnOf, parseItem, startedRuns, boardCards, readArchive, setArchived,
12
12
  requestOf, agentFlags, plainModel, readNotes, writeNotes, manualCommand, parseNotes, formatNote, appendNote, saveAttachment,
13
13
  reviewFindings, pickFindings, fixPrompt, approvalPrompt, sessionLaunch, closeSessions, sweepSessions, readSessions, ownerOf, CLOSE_GRACE_MS, gitStatus, runGit, cardsGit, commitPush, readCommit, findTerminal, nativePath, pruneRuns, unrechecked, notice, notifier, notifyArgs, LOGO, type Card,
@@ -827,3 +827,20 @@ test("trustFolder: marks the repo root trusted in a temp config, keeps the rest,
827
827
  expect(readFileSync(cfg, "utf8")).toBe("{bad");
828
828
  rmSync(t, { recursive: true, force: true });
829
829
  });
830
+
831
+ test("moveIdea carries notes and linked attachments, leaves the old inbox without it", () => {
832
+ const root = mkdtempSync(join(tmpdir(), "kaizen-mv-"));
833
+ const a = join(root, "a", ".kaizen"), b = join(root, "b", ".kaizen");
834
+ mkdirSync(join(a, "attachments"), { recursive: true });
835
+ writeFileSync(join(a, "attachments", "x-shot.png"), "png");
836
+ writeInbox(a, [{ status: "open", text: "keep" }, { status: "open", text: "go", notes: "see [shot](attachments/x-shot.png)" }]);
837
+ expect(moveIdea(a, a, "go")).toBe("already in that project");
838
+ expect(moveIdea(a, b, "nope")).toBe("no such idea");
839
+ expect(moveIdea(a, b, "go")).toBeNull();
840
+ expect(readInbox(a).map((l) => l.text)).toEqual(["keep"]);
841
+ expect(readInbox(b)).toEqual([{ status: "open", text: "go", notes: "see [shot](attachments/x-shot.png)" }]);
842
+ expect(readFileSync(join(b, "attachments", "x-shot.png"), "utf8")).toBe("png");
843
+ writeInbox(a, [{ status: "open", text: "go" }]);
844
+ expect(moveIdea(a, b, "go")).toBe("that project already has this idea");
845
+ rmSync(root, { recursive: true, force: true });
846
+ });
package/cli/state.ts CHANGED
@@ -1,7 +1,7 @@
1
1
  // Everything kaizen knows about a project's state, read from and written to the
2
2
  // `.kaizen/` directory. No terminal, no HTTP: the TUI (`dashboard.ts`) and the web
3
3
  // board (`web.ts`) both import this and draw it their own way.
4
- import { existsSync, readFileSync, readdirSync, appendFileSync, writeFileSync, mkdirSync, statSync, realpathSync, rmSync, renameSync } from "node:fs";
4
+ import { existsSync, readFileSync, readdirSync, appendFileSync, writeFileSync, mkdirSync, statSync, realpathSync, rmSync, renameSync, copyFileSync } from "node:fs";
5
5
  import { join, dirname, win32 } from "node:path";
6
6
  import { homedir, arch, cpus, release, totalmem, version as osVersion } from "node:os";
7
7
 
@@ -817,6 +817,29 @@ export function replaceIdea(state: string, text: string, next: InboxLine | null)
817
817
  writeInbox(state, lines);
818
818
  }
819
819
 
820
+ // An open idea, notes and all, from one project's inbox to another's. Files its notes
821
+ // link (attachments/...) are copied along, so the links resolve from the new state dir.
822
+ // Written to the new project first and taken back out if the old one cannot be
823
+ // written, like moveSource: a crash between the two leaves it listed twice, never lost.
824
+ export function moveIdea(from: string, to: string, text: string): string | null {
825
+ if (from === to) return "already in that project";
826
+ const src = readInbox(from);
827
+ const at = src.findIndex((l) => l.status === "open" && l.text === text);
828
+ if (at < 0) return "no such idea";
829
+ const dest = readInbox(to);
830
+ if (dest.some((l) => l.status === "open" && l.text === text)) return "that project already has this idea";
831
+ const idea = src[at]!;
832
+ for (const m of (idea.notes ?? "").matchAll(/\]\((attachments\/[^)\/]+)\)/g)) {
833
+ if (!existsSync(join(from, m[1]!)) || !statSync(join(from, m[1]!)).isFile()) continue;
834
+ mkdirSync(join(to, "attachments"), { recursive: true });
835
+ copyFileSync(join(from, m[1]!), join(to, m[1]!));
836
+ }
837
+ writeInbox(to, [...dest, idea]);
838
+ try { writeInbox(from, src.filter((_, i) => i !== at)); }
839
+ catch (e) { writeInbox(to, dest); throw e; }
840
+ return null;
841
+ }
842
+
820
843
  // Archived runs stay exactly where they are on disk -- run directories are never
821
844
  // moved or renamed, that is the resume contract -- and are listed here instead.
822
845
  // One id per line; the board hides them unless asked for the archive.
package/cli/web.ts CHANGED
@@ -4,7 +4,7 @@ import { existsSync, readdirSync, statSync, watch, appendFileSync, mkdirSync, ty
4
4
  import { join, dirname, resolve, sep } from "node:path";
5
5
  import {
6
6
  home, type Item, type Card, boardCards, knownProjects, remember, locate, label, tilde, searchRoots, findProjects,
7
- readInbox, writeInbox, replaceIdea, abandonRun, launchRun, parseItem, agentChoices, projectOf, KNOWN_AGENTS, plainModel, short, setArchived, writeNotes,
7
+ readInbox, writeInbox, replaceIdea, moveIdea, abandonRun, launchRun, parseItem, agentChoices, projectOf, KNOWN_AGENTS, plainModel, short, setArchived, writeNotes,
8
8
  appendNote, saveAttachment, pidOnPort, readText, cmdExe, sysExe, powershellExe, runGit, cardsGit, readCommit, commitPush, notice, notifier, LOGO,
9
9
  reviewFindings, pickFindings, fixPrompt, approvalPrompt, canApproveAwaiting, nativePath, closeSessions, sweepSessions, issueUrl,
10
10
  } from "./state.ts";
@@ -444,6 +444,10 @@ export async function web(repoDir: string, opts: Opts = {}) {
444
444
  replaceIdea(dir, text, { status: "rejected", text: `${text} | ${why}` });
445
445
  } else if (body.op === "delete") {
446
446
  replaceIdea(dir, text, null);
447
+ } else if (body.op === "move") {
448
+ if (typeof body.to !== "string" || !states(true).includes(body.to)) return bad("unknown state dir", 404);
449
+ const why = moveIdea(dir, body.to, text);
450
+ if (why) return bad(why, why === "no such idea" ? 404 : 409);
447
451
  } else return bad("unknown op");
448
452
  changed();
449
453
  return json({ ok: true });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "kaizen-agent",
3
- "version": "1.19.4",
3
+ "version": "1.21.0",
4
4
  "description": "Staged workflow for AI agents — code, writing, research, operations: plan, human approval, build, independent review, bounded fix loop.",
5
5
  "type": "module",
6
6
  "bin": {
@@ -0,0 +1,63 @@
1
+ ---
2
+ name: story-deck
3
+ description: Build a cinematic, minimalist storytelling pitch deck (A5 landscape .pptx) in the style of a senior agency — story first, problem, a single "what if" question, then the product reveal and feature scenes, ending in a demo CTA. Dark ink + gold, Playfair Display + Inter, full-bleed graded photos. Use when the user asks for a pitch deck / sales deck / product showcase deck "that tells a story", "cinematic", or a new deck for a product or audience in that style. Do NOT use for editing an existing .pptx, plain bullet decks, or data reports — use the pptx skill for those.
4
+ ---
5
+
6
+ # Story deck
7
+
8
+ A deck that tells a story before it sells. Worked example: [`examples/build-example.js`](examples/build-example.js)
9
+ (every helper on a fictional product, no images needed).
10
+
11
+ Read before building:
12
+ - [`references/story-arc.md`](references/story-arc.md) — the acts, slide recipes, copy rules, layout facts.
13
+ - [`references/rules.md`](references/rules.md) — truth, images, fonts, files, QA. Every rule there cost a redo.
14
+
15
+ If the Anthropic `pptx` skill is available, load it once (pptxgenjs gotchas, validator); `qa.sh` works without it.
16
+ Run `scripts/check.sh` first on a new machine.
17
+
18
+ ## Workflow
19
+
20
+ 1. **Brainstorm first.** Propose 2–3 story concepts + an outline (one line per slide) and
21
+ ask: audience, language, data available, photos, the CTA. Build only after the outline is agreed.
22
+ 2. **Deck folder:** `~/.kaizen/decks/<slug>/` — everything a deck needs and produces lives there
23
+ (never in the user's project folders). Shared dependencies live once in `~/.kaizen/decks/`:
24
+ `node_modules` (pptxgenjs, resolved upward by Node) and `.venv` (Pillow, defusedxml, lxml,
25
+ fonttools, qrcode). First time on a machine:
26
+ `cd ~/.kaizen/decks && npm init -y && npm i pptxgenjs && python3 -m venv .venv && .venv/bin/pip install pillow defusedxml lxml fonttools "qrcode[pil]"`.
27
+ Copy in `scripts/kit.js`, `scripts/prep_images.py`, `scripts/qa.sh`.
28
+ 3. **Research the truth.** Features from the product repos (shipped only — one sub-agent per
29
+ repo when there are several, if your agent has sub-agents). Stats from official sources with citations.
30
+ 4. **Photos.** Find on Unsplash with a web-fetch tool (or ask the user for photos), download to `img/`, contact-sheet, choose.
31
+ Write `plan.json` (backgrounds + panels), run `python3 prep_images.py plan.json` → `bg/`.
32
+ 5. **Fonts.** Run `scripts/install_fonts.py` once per machine (static instances; see rules.md).
33
+ 6. **Write `build-<name>.js`** with `kit.js`:
34
+
35
+ ```js
36
+ const { makeDeck } = require("./kit");
37
+ const d = makeDeck("Deck title", { logo: "logo.png", logoRatio: 199 / 880 }); // branding optional
38
+ d.story("s01", { kick, title, sub, notes, credit, top }); // photo story beat
39
+ d.pause(line, sub, notes); // black silence slide
40
+ d.question(line, notes); // gold "what if" slide
41
+ d.reveal(name, tagline, line, notes); // product name reveal
42
+ d.scene("f03", { kick, title, features: [[name, desc]], notes, credit, shot, shotRatio }); // shot = UI screenshot, framed right
43
+ d.cards(kick, title, [[label, name, desc] x3], notes, footnote);
44
+ d.cta("f16", { title, sub, facts, notes, credit, contact, site, qr }); // qr: PNG path (make with python qrcode), else a [QR] box
45
+ d.pres.writeFile({ fileName: "Deck.pptx" }).then(console.log);
46
+ ```
47
+
48
+ Branding options on `makeDeck(title, brand)`: `INK, CREAM, MUTED, GOLD, GOLD_BG, cardBg`
49
+ (hex, no `#`), `SERIF, SANS` (font names), `logo` (light-on-dark PNG path; none = no logo),
50
+ `logoRatio` (height/width). Defaults are the neutral ink + gold look with no logo.
51
+ For anything the helpers do not cover (triptych, recap timeline, stat rows) use
52
+ `d.slide()`, `d.kicker()`, `d.headline()`, `d.body()` and see build.js / build-features.js
53
+ in the reference project.
54
+ 7. **QA:** `./qa.sh build-<name>.js`, look at every printed sheet (or send them to the user), fix, repeat. Clean `qa/` after.
55
+ 8. **Report:** file path, the story in one line per slide, every placeholder left, every
56
+ claim the user must verify, and what was deliberately left out.
57
+
58
+ ## Agent notes (Claude Code, Codex, Gemini CLI, OpenCode, Cursor…)
59
+ Steps name capabilities, not tools. Where your agent lacks one:
60
+ - **web fetch** (finding stock photos, sources): ask the user for photos/links instead.
61
+ - **viewing images** (checking contact sheets / frames): send the image paths to the user and ask them to look.
62
+ - **sub-agents** (reading several repos at once): read them one after another.
63
+ - **structured questions**: ask in plain text with numbered options.
@@ -0,0 +1,29 @@
1
+ // Worked example: a story deck for a fictional product ("Ledgerly", bookkeeping for small cafés).
2
+ // No photos needed — it uses the image-free helpers. Copy into ~/.kaizen/decks/<slug>/ next to kit.js
3
+ // and run: node build-example.js (then ./qa.sh build-example.js to check it).
4
+ // In a real deck most beats are photo stories: d.story("bg01", {...}) after prep_images.py made bg/bg01.jpg.
5
+ const { makeDeck } = require("./kit");
6
+
7
+ const d = makeDeck("Ledgerly — The Closing Hour", {
8
+ GOLD: "D9A441", GOLD_BG: "D9A441", // brand accent; colours are hex without '#'
9
+ });
10
+
11
+ d.pause("Every café closes at ten.", "The owner's real work starts after that.",
12
+ "Open on a feeling, not the product. Pause for two seconds.");
13
+ d.cards("Act II — The pieces", "Three notebooks, one business.", [
14
+ ["Morning", "Supplier invoices", "Paid from the owner's phone, noted somewhere."],
15
+ ["Afternoon", "Cash drawer", "Counted twice, written on the back of a receipt."],
16
+ ["Night", "Spreadsheet", "Retyped from both, after closing."],
17
+ ], "Speaker note: numbers only with a source on the slide.", "Each piece is true. None of them talk to each other.");
18
+ d.question("What if the books closed themselves?", "The turn. Say it slowly.");
19
+ d.reveal("Ledgerly", "Bookkeeping that closes with the café.", "Invoices · Cash · Reports",
20
+ "First time the product is named.");
21
+ d.cta(null, {
22
+ kick: "Next step", label: "Book a demo",
23
+ title: "See your own numbers in 20 minutes.",
24
+ sub: "A demo with a sample of your last month's receipts.",
25
+ facts: "Web and mobile · English and Bahasa Indonesia",
26
+ contact: "[Name] · [WhatsApp] · [Email]", site: "[website]",
27
+ notes: "Fill the contact placeholders before presenting.",
28
+ });
29
+ d.pres.writeFile({ fileName: "Ledgerly-example.pptx" }).then((f) => console.log(f));
@@ -0,0 +1,62 @@
1
+ # Rules learned the hard way
2
+
3
+ ## Truth
4
+ - Features: only what has shipped. Verify in the product repo: CHANGELOG released
5
+ versions + existing pages/routes. "Unreleased", proposals, SPH/quotation docs = not shipped.
6
+ Note what is missing (e.g. no iOS, no payment gateway) and keep it out of the copy;
7
+ warn in speaker notes.
8
+ - A requirements/spec doc is not proof of shipping: confirm each feature in the CHANGELOG or in
9
+ the code itself (content files, pages). Re-read the top of every CHANGELOG right before
10
+ delivering — products ship while decks are being made (a product changed its login model
11
+ mid-project once and two slides were already wrong).
12
+ - Stats: never invent. Use official/scientific sources (e.g. Dapodik, BPS, peer-reviewed
13
+ journals); put a full `Sumber: …` line on the slide and details in notes. If no source
14
+ exists for a planned stat, swap the stat — do not leave a fake number.
15
+ - Narrative numbers ("140 pesan belum dibaca") are fine but tell the user they are story, not data.
16
+
17
+ ## Images
18
+ - Unsplash blocks curl/napi (bot wall). Use a web-fetch tool on `https://unsplash.com/s/photos/<query>`
19
+ asking for non-premium image URLs, then download
20
+ `https://images.unsplash.com/photo-<id>?w=1600&q=80&fm=jpg` with curl (that CDN works).
21
+ - Prefer real local scenes (for Indonesia: query `indonesia-school`, `sekolah`, `pesantren`,
22
+ `indonesian-teacher`, `indonesia-mechanic`). Build a contact sheet and LOOK before choosing.
23
+ - Never use pptxgenjs `sizing: cover` — Google Slides ignores it and stretches. Crop each
24
+ image to the exact box ratio in `prep_images.py` (panels: `[w_px, h_px]` = box ratio).
25
+ - Every photo must show the slide's own sentence. Problem slides show the real-world pain
26
+ (the workshop, the office, the WhatsApp phone); product and feature slides show people
27
+ USING the kind of tech the product is (students at computers / circuit simulation for a
28
+ simulator, adults in a training room or on laptops for an LMS). A pretty but unrelated
29
+ photo (a camera crew, a sermon, kids on a teacher-training slide) gets swapped.
30
+ - Show modern equipment (flat screens, laptops) — never CRT monitors or dated labs when
31
+ selling software. Mix genders in group shots of students; avoid all-one-gender rooms.
32
+ - For product slides, the product's REAL interface beats any stock photo: look in the repo for
33
+ screenshots/renders (`public/landing/`, `.preview/`, docs) and use them — framed via
34
+ `scene({shot, shotRatio})`, or graded as a background if it is a dark render. Never stock
35
+ photos of program code on screen for a non-coding product.
36
+ - Candid only: people absorbed in what they are doing (teaching, reading, working). No posed
37
+ portraits — nobody looking into the camera and smiling; it reads as stock and breaks the film.
38
+ Hands, backs, profiles and wide scenes work best.
39
+ - Photos with readable foreign text (e.g. English survey paper) break the illusion — swap.
40
+
41
+ ## Fonts
42
+ - Default Playfair Display + Inter (both in the Google Slides font menu). Install them locally
43
+ with `~/.kaizen/decks/.venv/bin/python scripts/install_fonts.py` — it makes STATIC instances. Never install the variable
44
+ `[wght]` files: some viewers (LibreOffice desktop) draw big headlines greyed out, as if
45
+ transparent, while the XML colour is correct. A missing font (e.g. Cambria) renders with
46
+ ugly letter spacing.
47
+ - If a user reports "text not seen": first check the slide XML colour (unzip, look for
48
+ `srgbClr`/`alpha` on that run). Solid colour there = viewer/font problem, not the deck.
49
+ - Playfair numbers are old-style (low digits); mention it, offer Lora if unwanted.
50
+
51
+ ## Files and tools
52
+ - Output decks as local `.pptx` (user imports into Google Slides). No Artifacts.
53
+ - Everything the skill generates lives in `~/.kaizen/decks/<slug>/`; never write inside the user's
54
+ project folders. Reading assets (logos, screenshots) from repos is fine.
55
+ - zsh does not word-split `$var` in `for x in $ids` — wrap in `bash -c` or use arrays.
56
+ - `qa.sh` uses the shared `~/.kaizen/decks/.venv` and creates it when missing.
57
+
58
+ ## QA loop
59
+ Run `qa.sh build-x.js`, look at every sheet, fix: text overflowing into the next block
60
+ (headline wrapped to 3 lines), kicker wrapping, labels wrapping in cards, low contrast on
61
+ bright photos (raise gradient strength), page number collisions. Re-run until clean.
62
+ Delete `qa/` and PDFs at the end.
@@ -0,0 +1,35 @@
1
+ # Story arc and slide recipes
2
+
3
+ The deck sells a feeling first and the product last. Never open with features.
4
+
5
+ ## The five acts (12–16 slides)
6
+
7
+ | Act | Slides | Helper | What it does |
8
+ |---|---|---|---|
9
+ | I. World | 1–2 | `story` | Cover line about the audience's world, not the product. Scale or context (sourced stat, or none). |
10
+ | II. One scene | 1–2 | `story`, `pause` | Shrink to one person at one time ("06.45 — Gerbang sekolah"). A black `pause` slide for silence. |
11
+ | III. The pieces | 2–4 | `story`, triptych | Time-stamped scenes of the problem from each viewpoint (admin, teacher, parent…). |
12
+ | IV. The cost | 1–2 | `story` + stats | What is lost (trust, time). Stats only with a source line on the slide. |
13
+ | V. The turn | 1 | `question` | Gold slide, one italic question: "Bagaimana jika …?" |
14
+ | Reveal | 1 | `reveal` | Product name appears for the first time. |
15
+ | Features | 3–6 | `scene`, `cards` | Each scene = kicker (time/place · product), headline, 2–4 feature rows. |
16
+ | CTA | 1 | `cta` | One action (demo), contact placeholders, QR placeholder. |
17
+
18
+ Follow-up decks (shown after a story deck) skip acts I–V: open with a callback
19
+ cover ("Hari yang sama. Cerita yang berbeda."), revisit the same scenes with a
20
+ `Dulu: "<old headline>"` line (see build-features.js in the reference project), end
21
+ with a recap timeline and the CTA.
22
+
23
+ ## Copy rules
24
+ - One idea per slide. Headline ≤ 2 lines at its size; if it wraps to 3, shorten the words.
25
+ - Kicker = TIME — PLACE or ACT — NAME, uppercase via helper.
26
+ - Body line is the human detail, not a feature list.
27
+ - Name the product only from the reveal onwards.
28
+ - Bahasa Indonesia by default (audience: kepala sekolah/yayasan); English if asked.
29
+ - Speaker notes: delivery cue + caveats + `Foto: <name> / Unsplash.` credit.
30
+ - Placeholders in brackets `[__]`, `[Nama]`, `[QR]` — list them in the final report.
31
+
32
+ ## Layout facts (A5 landscape 8.268 × 5.827 in, margin 0.6)
33
+ - Photo backgrounds: gradient side must match where the text sits (`left` for `scene`, `bottom` for `story`).
34
+ - `scene` rows: 0.7 in pitch, 4 rows max, column width 4.3 in.
35
+ - Source lines: 8 pt at `H - 0.75`, width ≤ 6.2 in (clear of the page number).
@@ -0,0 +1,12 @@
1
+ #!/usr/bin/env bash
2
+ # Requirements for story-deck. Exit 1 if anything required is missing.
3
+ miss=0
4
+ need(){ command -v "$1" >/dev/null 2>&1 && echo "ok $1" || { echo "MISSING $1 — $2"; miss=1; }; }
5
+ need node "Node.js 18+ (https://nodejs.org or your package manager)"
6
+ need npm "comes with Node.js"
7
+ need python3 "Python 3.9+"
8
+ need soffice "LibreOffice (renders slides for the visual check)"
9
+ need pdftoppm "poppler-utils (brew install poppler / apt install poppler-utils / pacman -S poppler)"
10
+ [ -d "$HOME/.kaizen/decks/node_modules/pptxgenjs" ] && echo "ok pptxgenjs" || echo "todo pptxgenjs — cd ~/.kaizen/decks && npm init -y && npm i pptxgenjs"
11
+ fc-list 2>/dev/null | grep -qi "Playfair Display" && echo "ok fonts" || echo "todo fonts — ~/.kaizen/decks/.venv/bin/python scripts/install_fonts.py"
12
+ exit $miss
@@ -0,0 +1,42 @@
1
+ """Install STATIC Playfair Display + Inter into ~/.local/share/fonts/story-deck/.
2
+
3
+ Variable-font builds (the [wght] files on Google Fonts) render large headlines greyed /
4
+ semi-transparent in some desktop viewers (LibreOffice). Static instances do not.
5
+ Needs: pip install fonttools (qa.sh's .venv works: .venv/bin/pip install fonttools).
6
+ Usage: python3 install_fonts.py
7
+ """
8
+ import os
9
+ import subprocess
10
+ import tempfile
11
+ import urllib.request
12
+
13
+ from fontTools.ttLib import TTFont
14
+ from fontTools.varLib.instancer import instantiateVariableFont
15
+
16
+ RAW = "https://github.com/google/fonts/raw/main/ofl/"
17
+ SRC = {
18
+ "pf": "playfairdisplay/PlayfairDisplay%5Bwght%5D.ttf",
19
+ "pfi": "playfairdisplay/PlayfairDisplay-Italic%5Bwght%5D.ttf",
20
+ "in": "inter/Inter%5Bopsz,wght%5D.ttf",
21
+ "ini": "inter/Inter-Italic%5Bopsz,wght%5D.ttf",
22
+ }
23
+ JOBS = [
24
+ ("pf", "PlayfairDisplay-Regular", {"wght": 400}),
25
+ ("pf", "PlayfairDisplay-Bold", {"wght": 700}),
26
+ ("pfi", "PlayfairDisplay-Italic", {"wght": 400}),
27
+ ("in", "Inter-Regular", {"wght": 400, "opsz": 14}),
28
+ ("in", "Inter-Bold", {"wght": 700, "opsz": 14}),
29
+ ("ini", "Inter-Italic", {"wght": 400, "opsz": 14}),
30
+ ]
31
+
32
+ dest = os.path.expanduser("~/.local/share/fonts/story-deck/")
33
+ os.makedirs(dest, exist_ok=True)
34
+ with tempfile.TemporaryDirectory() as tmp:
35
+ for key, path in SRC.items():
36
+ urllib.request.urlretrieve(RAW + path, f"{tmp}/{key}.ttf")
37
+ for key, out, loc in JOBS:
38
+ font = instantiateVariableFont(TTFont(f"{tmp}/{key}.ttf"), loc, updateFontNames=True, static=True)
39
+ assert "fvar" not in font
40
+ font.save(dest + out + ".ttf")
41
+ print("installed", out)
42
+ subprocess.run(["fc-cache", "-f"], check=False)
@@ -0,0 +1,174 @@
1
+ // Cinematic story deck helpers: A5 landscape, dark ink + one accent, serif display + sans body.
2
+ // Branding is adjustable: makeDeck(title, { INK, CREAM, MUTED, GOLD, GOLD_BG, SERIF, SANS, logo, logoRatio, cardBg }).
3
+ const pptxgen = require("pptxgenjs");
4
+
5
+ const W = 8.268, H = 5.827, M = 0.6;
6
+ const DEFAULTS = {
7
+ INK: "14110F", CREAM: "F3EDE3", MUTED: "B8AFA3", GOLD: "C9A24E", GOLD_BG: "C9A96E", cardBg: "1E1A17",
8
+ SERIF: "Playfair Display", SANS: "Inter",
9
+ logo: null, // path to a light-on-dark logo PNG; null = no logo drawn
10
+ logoRatio: 0.226, // logo height / width
11
+ };
12
+
13
+ function makeDeck(title, brand = {}) {
14
+ const { INK, CREAM, MUTED, GOLD, GOLD_BG, SERIF, SANS, logo: logoPath, logoRatio, cardBg } = { ...DEFAULTS, ...brand };
15
+ const pres = new pptxgen();
16
+ pres.defineLayout({ name: "A5L", width: W, height: H });
17
+ pres.layout = "A5L";
18
+ pres.title = title;
19
+ let page = 0;
20
+
21
+ const notesWith = (notes, credit) => notes + (credit ? `\n\nFoto: ${credit} / Unsplash.` : "");
22
+
23
+ function slide(bgFile, notes, dark = true) {
24
+ const s = pres.addSlide();
25
+ page++;
26
+ s.background = { color: dark ? INK : GOLD_BG };
27
+ if (bgFile) s.addImage({ path: `bg/${bgFile}.jpg`, x: 0, y: 0, w: W, h: H });
28
+ s.addText(String(page).padStart(2, "0"), {
29
+ x: W - M - 0.6, y: H - 0.45, w: 0.6, h: 0.25, align: "right", margin: 0,
30
+ fontFace: SANS, fontSize: 8, color: dark ? MUTED : INK, charSpacing: 2, isTextBox: true,
31
+ });
32
+ s.addNotes(notes);
33
+ return s;
34
+ }
35
+
36
+ function kicker(s, text, y, x = M) {
37
+ s.addText(text.toUpperCase(), {
38
+ x, y, w: W - x - M, h: 0.3, margin: 0, fontFace: SANS, fontSize: 9, bold: true,
39
+ color: GOLD, charSpacing: 4, isTextBox: true,
40
+ });
41
+ }
42
+
43
+ function headline(s, text, y, h, opts = {}) {
44
+ s.addText(text, {
45
+ x: opts.x || M, y, w: opts.w || 5.6, h, margin: 0, fontFace: SERIF, fontSize: opts.size || 30,
46
+ color: opts.color || CREAM, valign: "top", lineSpacingMultiple: 0.95, isTextBox: true, ...opts.extra,
47
+ });
48
+ }
49
+
50
+ function body(s, text, y, h, opts = {}) {
51
+ s.addText(text, {
52
+ x: opts.x || M, y, w: opts.w || 4.6, h, margin: 0, fontFace: SANS, fontSize: opts.size || 12,
53
+ color: opts.color || MUTED, valign: "top", lineSpacingMultiple: 1.2, isTextBox: true,
54
+ });
55
+ }
56
+
57
+ function logo(s, x, y, w) {
58
+ if (logoPath) s.addImage({ path: logoPath, x, y, w, h: w * logoRatio });
59
+ }
60
+
61
+ // Photo story beat: text sits low on the slide (photo gradient is at the bottom or left).
62
+ function story(bgFile, { kick, title, sub, notes, credit, top = 3.2 }) {
63
+ const s = slide(bgFile, notesWith(notes, credit));
64
+ kicker(s, kick, top);
65
+ headline(s, title, top + 0.35, 1.1, { size: 28, w: 6.2 });
66
+ if (sub) body(s, sub, top + 1.6, 0.6, { w: 5.2 });
67
+ return s;
68
+ }
69
+
70
+ // Black pause slide.
71
+ function pause(line, sub, notes) {
72
+ const s = slide(null, notes);
73
+ s.addText(line, {
74
+ x: 0.8, y: 2.1, w: W - 1.6, h: 0.9, align: "center", valign: "middle", margin: 0,
75
+ fontFace: SERIF, fontSize: 28, italic: true, color: CREAM, isTextBox: true,
76
+ });
77
+ if (sub) s.addText(sub, {
78
+ x: 0.8, y: 3.1, w: W - 1.6, h: 0.5, align: "center", margin: 0, fontFace: SANS, fontSize: 12,
79
+ color: MUTED, isTextBox: true,
80
+ });
81
+ return s;
82
+ }
83
+
84
+ // Gold statement slide.
85
+ function question(line, notes) {
86
+ const s = slide(null, notes, false);
87
+ s.addText(line, {
88
+ x: 1.0, y: 1.9, w: W - 2.0, h: 1.8, align: "center", valign: "middle", margin: 0,
89
+ fontFace: SERIF, fontSize: 32, italic: true, color: INK, isTextBox: true,
90
+ });
91
+ return s;
92
+ }
93
+
94
+ // Product name reveal.
95
+ function reveal(name, tagline, line, notes) {
96
+ const s = slide(null, notes);
97
+ s.addText(name, {
98
+ x: 0, y: 1.75, w: W, h: 1.0, align: "center", margin: 0, fontFace: SERIF, fontSize: 54,
99
+ color: CREAM, isTextBox: true,
100
+ });
101
+ s.addText(tagline, {
102
+ x: 0, y: 2.85, w: W, h: 0.5, align: "center", margin: 0, fontFace: SERIF, fontSize: 17,
103
+ italic: true, color: CREAM, isTextBox: true,
104
+ });
105
+ s.addText(line.toUpperCase(), {
106
+ x: 0, y: 3.45, w: W, h: 0.3, align: "center", margin: 0, fontFace: SANS, fontSize: 9,
107
+ bold: true, color: GOLD, charSpacing: 4, isTextBox: true,
108
+ });
109
+ return s;
110
+ }
111
+
112
+ // Feature slide: kicker, headline, feature rows in a left text column.
113
+ // shot: product screenshot path shown framed on the right (bgFile may be null); shotRatio = w/h.
114
+ function scene(bgFile, { kick, title, features, notes, credit, shot, shotRatio = 1.6 }) {
115
+ const s = slide(bgFile, notesWith(notes, credit));
116
+ if (shot) {
117
+ const sw = 3.0, sh = sw / shotRatio, sx = W - M - sw, sy = (H - sh) / 2;
118
+ s.addShape(pres.shapes.RECTANGLE, { x: sx - 0.06, y: sy - 0.06, w: sw + 0.12, h: sh + 0.12, fill: { color: cardBg }, line: { color: GOLD, width: 0.75 } });
119
+ s.addImage({ path: shot, x: sx, y: sy, w: sw, h: sh });
120
+ }
121
+ kicker(s, kick, M);
122
+ headline(s, title, 0.95, 1.0, { size: 24, w: 4.3 });
123
+ let y = 2.1;
124
+ features.forEach(([name, desc]) => {
125
+ s.addText([
126
+ { text: name, options: { fontFace: SANS, fontSize: 11, bold: true, color: CREAM, breakLine: true } },
127
+ { text: desc, options: { fontFace: SANS, fontSize: 9.5, color: MUTED } },
128
+ ], { x: M, y, w: shot ? 3.85 : 4.3, h: 0.62, margin: 0, valign: "top", lineSpacingMultiple: 1.1, isTextBox: true });
129
+ y += 0.7;
130
+ });
131
+ return s;
132
+ }
133
+
134
+ // Three dark cards.
135
+ function cards(kick, title, items, notes, footnote) {
136
+ const s = slide(null, notes);
137
+ kicker(s, kick, M);
138
+ headline(s, title, 0.95, 0.9, { size: 26, w: 6.8 });
139
+ const cw = 2.2, gap = (W - 2 * M - 3 * cw) / 2, cy = 2.3;
140
+ items.forEach(([label, name, desc], i) => {
141
+ const x = M + i * (cw + gap);
142
+ s.addShape(pres.shapes.RECTANGLE, { x, y: cy, w: cw, h: 2.0, fill: { color: cardBg } });
143
+ s.addText([
144
+ { text: label.toUpperCase(), options: { fontFace: SANS, fontSize: 8, bold: true, color: GOLD, charSpacing: 1, breakLine: true } },
145
+ { text: name, options: { fontFace: SERIF, fontSize: 17, color: CREAM, breakLine: true } },
146
+ { text: desc, options: { fontFace: SANS, fontSize: 10, color: MUTED } },
147
+ ], { x: x + 0.2, y: cy + 0.2, w: cw - 0.4, h: 1.6, margin: 0, valign: "top", paraSpaceAfter: 6, isTextBox: true });
148
+ });
149
+ if (footnote) body(s, footnote, 4.6, 0.3, { size: 10, w: 7.0 });
150
+ return s;
151
+ }
152
+
153
+ function cta(bgFile, { title, sub, facts, notes, credit, kick = "Langkah berikutnya", label = "Jadwalkan demo",
154
+ contact = "[Nama] · [WhatsApp] · [Email]", site = "[website]", qr = null }) {
155
+ const s = slide(bgFile, notesWith(notes, credit));
156
+ kicker(s, kick, 1.5);
157
+ headline(s, title, 1.85, 1.0, { size: 30, w: 6.2 }); // room for two lines
158
+ body(s, sub, 2.95, 0.5, { w: 4.4 });
159
+ s.addText([
160
+ { text: label.toUpperCase(), options: { fontFace: SANS, fontSize: 9, bold: true, color: GOLD, charSpacing: 4, breakLine: true } },
161
+ { text: contact, options: { fontFace: SANS, fontSize: 12, color: CREAM, breakLine: true } },
162
+ { text: site, options: { fontFace: SANS, fontSize: 12, color: MUTED } },
163
+ ], { x: M, y: 3.55, w: 4.4, h: 0.9, margin: 0, valign: "top", paraSpaceAfter: 4, isTextBox: true });
164
+ if (qr) s.addImage({ path: qr, x: W - M - 1.3, y: 3.2, w: 1.3, h: 1.3 });
165
+ else { s.addShape(pres.shapes.RECTANGLE, { x: W - M - 1.3, y: 3.2, w: 1.3, h: 1.3, fill: { color: CREAM } }); s.addText("[QR]", { x: W - M - 1.3, y: 3.2, w: 1.3, h: 1.3, align: "center", valign: "middle", margin: 0, fontFace: SANS, fontSize: 12, color: INK, isTextBox: true }); }
166
+ if (facts) body(s, facts, 4.45, 0.3, { size: 9 });
167
+ logo(s, M, H - 0.55, 1.6);
168
+ return s;
169
+ }
170
+
171
+ return { pres, slide, kicker, headline, body, logo, story, pause, question, reveal, scene, cards, cta };
172
+ }
173
+
174
+ module.exports = { makeDeck, W, H, M };