slidemind 0.3.13 → 0.3.14

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 (49) hide show
  1. package/.claude-plugin/plugin.json +1 -1
  2. package/.codex-plugin/plugin.json +1 -1
  3. package/AGENTS.md +86 -21
  4. package/README.md +14 -17
  5. package/bin/slidemind.mjs +65 -4969
  6. package/package.json +1 -1
  7. package/skills/create-slides/SKILL.md +113 -74
  8. package/skills/search/SKILL.md +25 -7
  9. package/src/bridge/build-and-push.mjs +108 -15
  10. package/src/bridge/bundle.mjs +69 -0
  11. package/src/bridge/capture.mjs +19 -0
  12. package/src/bridge/client/brands.mjs +622 -0
  13. package/src/bridge/client/engine-routing.mjs +237 -0
  14. package/src/bridge/client/engine-search.mjs +310 -0
  15. package/src/bridge/client/engine-slides.mjs +435 -0
  16. package/src/bridge/client/pane-ops.mjs +335 -0
  17. package/src/bridge/client/presentations.mjs +587 -0
  18. package/src/bridge/client/render.mjs +317 -0
  19. package/src/bridge/client/transport.mjs +695 -0
  20. package/src/bridge/client.mjs +80 -3452
  21. package/src/bridge/config.mjs +1 -1
  22. package/src/bridge/engine-download.mjs +15 -2
  23. package/src/builders/build.mjs +11 -804
  24. package/src/builders/draft-check.mjs +376 -0
  25. package/src/builders/lint.mjs +38 -2
  26. package/src/builders/preview.html +799 -0
  27. package/src/builders/review.mjs +23 -4
  28. package/src/builders/visual-families.mjs +6 -8
  29. package/src/cli/common.mjs +179 -0
  30. package/src/cli/concepts.mjs +310 -0
  31. package/src/cli/connection.mjs +147 -0
  32. package/src/cli/deck.mjs +608 -0
  33. package/src/cli/designs.mjs +113 -0
  34. package/src/cli/diagnose.mjs +141 -0
  35. package/src/cli/download-bundle.mjs +144 -0
  36. package/src/cli/engine-helpers.mjs +429 -0
  37. package/src/cli/engine-results.mjs +320 -0
  38. package/src/cli/help.mjs +201 -0
  39. package/src/cli/install.mjs +257 -0
  40. package/src/cli/read.mjs +120 -0
  41. package/src/cli/ready.mjs +805 -0
  42. package/src/cli/search.mjs +608 -0
  43. package/src/cli/session.mjs +177 -0
  44. package/src/cli/setup.mjs +282 -0
  45. package/src/cli/slide-reuse.mjs +334 -0
  46. package/src/cli/status.mjs +118 -0
  47. package/src/cli/workspace.mjs +302 -0
  48. package/src/installers/codex.mjs +1 -1
  49. package/src/update-check.mjs +231 -0
@@ -46,6 +46,7 @@ import path from "node:path";
46
46
  import { fileURLToPath, pathToFileURL } from "node:url";
47
47
 
48
48
  import { buildDeck, openInBrowser } from "./build.mjs";
49
+ import { draftCheckBlocker, verdictBlocker } from "./draft-check.mjs";
49
50
  import { resolveSkillForDeck } from "./resolve-skill.mjs";
50
51
  import { resolveBrandAsset } from "../bridge/bundle.mjs";
51
52
  import {
@@ -502,9 +503,25 @@ export async function runReview({
502
503
  // quiet: serveAndOpen's BROWSER: line owns all open/failed messaging.
503
504
  open = (url) => openInBrowser(url, { quiet: true }),
504
505
  openFailedGraceMs = OPEN_FAILED_GRACE_MS,
506
+ allowDraftDefects = false,
505
507
  } = {}) {
506
508
  await buildDeck(deckDir);
507
509
 
510
+ // The gate does not open on defects the last draft check MEASURED (fill
511
+ // bands, dropped connectors, unused assets) while the slide files are
512
+ // unchanged since: a warning printed after "task done" is a warning nobody
513
+ // acts on. Editing the slide re-arms the check; --allow-draft-defects is
514
+ // the explicit override.
515
+ if (!allowDraftDefects) {
516
+ const standing = draftCheckBlocker(deckDir) ?? verdictBlocker(deckDir);
517
+ if (standing) {
518
+ throw new Error(
519
+ `review not opened — ${standing}\n` +
520
+ "(pass --allow-draft-defects to open the gate anyway)",
521
+ );
522
+ }
523
+ }
524
+
508
525
  // Headless simulation runs (scripts/codex-sim.mjs): approve without a browser.
509
526
  // buildDeck still ran above, so lint/build errors surface before the approve.
510
527
  if (process.env.SLIDEMIND_GATE_AUTOAPPROVE === "1") {
@@ -613,17 +630,19 @@ export async function runReview({
613
630
  function parseArgs(argv) {
614
631
  // port null ⇒ derive the deterministic per-deck port; an explicit --port
615
632
  // (incl. --port 0 for OS-random) overrides it.
616
- const args = { deck: null, port: null };
633
+ const args = { deck: null, port: null, allowDraftDefects: false };
617
634
  for (let i = 0; i < argv.length; i++) {
618
635
  const a = argv[i];
619
636
  if (a === "--deck" && i + 1 < argv.length) {
620
637
  args.deck = argv[++i];
621
638
  } else if (a === "--port" && i + 1 < argv.length) {
622
639
  args.port = parseInt(argv[++i], 10) || 0;
640
+ } else if (a === "--allow-draft-defects") {
641
+ args.allowDraftDefects = true;
623
642
  } else if (a === "-h" || a === "--help") {
624
643
  process.stdout.write(
625
- "Usage: slidemind review <deck> [--port <n>]\n" +
626
- " node src/builders/review.mjs --deck <deck> [--port <n>]\n",
644
+ "Usage: slidemind review <deck> [--port <n>] [--allow-draft-defects]\n" +
645
+ " node src/builders/review.mjs --deck <deck> [--port <n>] [--allow-draft-defects]\n",
627
646
  );
628
647
  process.exit(0);
629
648
  } else {
@@ -641,7 +660,7 @@ function parseArgs(argv) {
641
660
  async function main() {
642
661
  const args = parseArgs(process.argv.slice(2));
643
662
  const deckDir = path.resolve(args.deck);
644
- await runReview({ deckDir, port: args.port });
663
+ await runReview({ deckDir, port: args.port, allowDraftDefects: args.allowDraftDefects });
645
664
  }
646
665
 
647
666
  if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) {
@@ -18,7 +18,7 @@ export function plannedSlideNumbers(planMarkdown) {
18
18
  return [...slides].sort((a, b) => a - b);
19
19
  }
20
20
 
21
- export function conceptGenerationWaves(planMarkdown, slideCount) {
21
+ export function conceptGenerationWaves(planMarkdown) {
22
22
  const groups = new Map();
23
23
  for (const segment of splitPlanMarkdownIntoSegments(planMarkdown)) {
24
24
  if (segment.type !== "storyboard" || !segment.data) continue;
@@ -35,14 +35,12 @@ export function conceptGenerationWaves(planMarkdown, slideCount) {
35
35
  for (const slide of members.slice(1)) variants.add(slide);
36
36
  }
37
37
 
38
- // Slide 1 is the caller's probe request; everything else the plan names
39
- // follows. A plan without parseable storyboards falls back to the count.
38
+ // Every slide the plan names, slide 1 included. The server derives its own
39
+ // beat list from the SAME storyboard-canvas blocks, so there is nothing a
40
+ // request to it could tell us about the deck's size that this does not.
40
41
  const planned = plannedSlideNumbers(planMarkdown);
41
- const remaining = planned.length
42
- ? planned.filter((slide) => slide !== 1)
43
- : Array.from({ length: Math.max(0, slideCount - 1) }, (_, i) => i + 2);
44
42
  return {
45
- firstWave: remaining.filter((slide) => !variants.has(slide)),
46
- variants: remaining.filter((slide) => variants.has(slide)),
43
+ firstWave: planned.filter((slide) => !variants.has(slide)),
44
+ variants: planned.filter((slide) => variants.has(slide)),
47
45
  };
48
46
  }
@@ -0,0 +1,179 @@
1
+ // Shared plumbing for the slidemind CLI command modules (src/cli/*.mjs).
2
+ // Split out of bin/slidemind.mjs verbatim; bin stays the dispatcher.
3
+
4
+ import { spawn } from "node:child_process";
5
+ import fs from "node:fs";
6
+ import path from "node:path";
7
+ import { fileURLToPath, pathToFileURL } from "node:url";
8
+
9
+ const __filename = fileURLToPath(import.meta.url);
10
+ const __dirname = path.dirname(__filename);
11
+ export const PACKAGE_ROOT = path.resolve(__dirname, "..", "..");
12
+ export const SRC_DIR = path.join(PACKAGE_ROOT, "src");
13
+ export const BUILDERS_DIR = path.join(SRC_DIR, "builders");
14
+ export const INSTALLERS_DIR = path.join(SRC_DIR, "installers");
15
+ // The CLI entry point, for commands that re-spawn the CLI (setup → ready).
16
+ export const BIN_PATH = path.join(PACKAGE_ROOT, "bin", "slidemind.mjs");
17
+
18
+ export function readPackageJson() {
19
+ return JSON.parse(
20
+ fs.readFileSync(path.join(PACKAGE_ROOT, "package.json"), "utf8"),
21
+ );
22
+ }
23
+
24
+ export function fail(msg, code = 1) {
25
+ process.stderr.write(`slidemind: ${msg}\n`);
26
+ process.exit(code);
27
+ }
28
+
29
+ // The two human checkmarks the CLI enforces itself, so no harness timeout,
30
+ // context loss, or agent rationalisation can walk past a gate: nothing after
31
+ // the plan gate (concepts, build, view-draft, review) runs on an unapproved or
32
+ // since-edited plan, and nothing ships (push, build-pptx) from a preview the
33
+ // user has not approved exactly as it stands. Both fast-fail before any
34
+ // bridge call with the command that clears them.
35
+ export async function requirePlanApproved(deckDir) {
36
+ const plan = await import(pathToFileURL(path.join(BUILDERS_DIR, "plan.mjs")).href);
37
+ const why = plan.requirePlanApproved(deckDir);
38
+ if (why) fail(why);
39
+ }
40
+
41
+ export async function requireReviewApproved(deckDir) {
42
+ const review = await import(pathToFileURL(path.join(BUILDERS_DIR, "review.mjs")).href);
43
+ const why = review.requireReviewApproved(deckDir);
44
+ if (why) fail(why);
45
+ }
46
+
47
+ // A render that stalls on "Multiple PowerPoint windows are open" is, for an
48
+ // UNBOUND deck, really a binding problem: bound, the deck renders pane-free
49
+ // and never asks. Name that fix first — the window pick is the workaround.
50
+ export function withUnboundHint(deckDir, err) {
51
+ const msg = err?.message ?? String(err);
52
+ if (!/Multiple PowerPoint windows are open/.test(msg)) return msg;
53
+ if (fs.existsSync(path.join(deckDir, ".bundle-ref.json"))) return msg;
54
+ const slug = path.basename(deckDir);
55
+ return (
56
+ `deck ${slug} is not bound to a corporate design, so the render has to go through an open window. ` +
57
+ `Bind it once and no window is needed: \`slidemind ready ${slug}\` (or \`--brand <presFP>\` to name the design). ` +
58
+ `Or pick a window for this run:\n${msg.replace(/^[^\n]*\n/, "")}`
59
+ );
60
+ }
61
+
62
+ // The local PowerPoint files `read` and `add-slide --from` accept host-free.
63
+ // All are OPC (zip) OOXML packages with the same ppt/presentation.xml +
64
+ // ppt/slides/ structure — presentation (.pptx/.pptm), template (.potx/.potm),
65
+ // slideshow (.ppsx/.ppsm) — so the src/pptx/ reader/swapper handle them
66
+ // identically. Presentation IDs are opaque and never carry these extensions,
67
+ // so this stays a safe discriminator for "positional is a local file, not a
68
+ // presId".
69
+ export const LOCAL_PPTX_RE = /\.(pptx|pptm|potx|potm|ppsx|ppsm)$/i;
70
+
71
+ export function resolveDecksDir() {
72
+ if (process.env.SLIDEMIND_DECKS) {
73
+ return path.resolve(process.env.SLIDEMIND_DECKS);
74
+ }
75
+ return path.resolve(process.cwd(), "decks");
76
+ }
77
+
78
+ export function resolveDeckArg(arg) {
79
+ // A user can pass either "decks/foo" (relative to CWD), "/abs/path/foo",
80
+ // or just "foo" — in which case we look it up under the resolved decks dir.
81
+ if (!arg) return null;
82
+ if (path.isAbsolute(arg)) return arg;
83
+ if (fs.existsSync(path.resolve(process.cwd(), arg))) {
84
+ return path.resolve(process.cwd(), arg);
85
+ }
86
+ const decksDir = resolveDecksDir();
87
+ return path.join(decksDir, arg);
88
+ }
89
+
90
+ export function runNode(script, args) {
91
+ return new Promise((resolve, reject) => {
92
+ const child = spawn(process.execPath, [script, ...args], {
93
+ stdio: "inherit",
94
+ env: process.env,
95
+ });
96
+ child.on("error", reject);
97
+ child.on("exit", (code) => {
98
+ if (code === 0) resolve();
99
+ else reject(new Error(`${path.basename(script)} exited with code ${code}`));
100
+ });
101
+ });
102
+ }
103
+
104
+ export function splitDeckArg(rest, cmd) {
105
+ // The first non-flag positional is the deck. Everything else passes
106
+ // through to the underlying script verbatim.
107
+ const passthrough = [...rest];
108
+ let deck = null;
109
+ for (let i = 0; i < passthrough.length; i++) {
110
+ if (!passthrough[i].startsWith("-")) {
111
+ deck = passthrough.splice(i, 1)[0];
112
+ break;
113
+ }
114
+ }
115
+ if (!deck) fail(`usage: slidemind ${cmd} <deck>`);
116
+ return { deck, passthrough };
117
+ }
118
+
119
+ // Live render progress for build-pptx/push (the job protocol streams it; the
120
+ // sync fallback emits nothing). One stderr line per state change — works in
121
+ // plain logs and TTYs alike.
122
+ export function renderProgressPrinter() {
123
+ let last = "";
124
+ let queuedAt = null;
125
+ return (p) => {
126
+ let line = null;
127
+ if (p.state === "queued") {
128
+ queuedAt ??= Date.now();
129
+ if (Date.now() - queuedAt > 5000) {
130
+ line = " render queued behind another render…";
131
+ }
132
+ } else if (p.state === "restarted") {
133
+ line = " render restarted (backend replica was replaced)…";
134
+ } else if (p.stage === "assembling") {
135
+ line = " assembling deck…";
136
+ } else if (p.stage === "rendering" && typeof p.total === "number") {
137
+ line = ` rendering slide ${Math.min((p.done ?? 0) + 1, p.total)}/${p.total}…`;
138
+ }
139
+ if (line && line !== last) {
140
+ last = line;
141
+ process.stderr.write(`${line}\n`);
142
+ }
143
+ };
144
+ }
145
+
146
+ export function dataUriToPng(dataUri) {
147
+ const m = /^data:image\/png;base64,(.+)$/.exec(dataUri || "");
148
+ return m ? Buffer.from(m[1], "base64") : null;
149
+ }
150
+
151
+ // Keep the deck working against the design as it stands RIGHT NOW: every deck
152
+ // command re-syncs its bound design before it works, so a corporate design that
153
+ // changed mid-session is picked up instead of silently building against the
154
+ // copy `ready` fetched hours ago. The mechanics (etag gate, short timeout,
155
+ // never-throw) live in bundle.mjs; this only reports a design that moved.
156
+ export async function freshenDeckBundle(deckDir) {
157
+ const bundle = await import(
158
+ pathToFileURL(path.join(SRC_DIR, "bridge", "bundle.mjs")).href
159
+ );
160
+ const sync = await bundle.refreshDeckBundle(deckDir);
161
+ if (sync?.changed) {
162
+ process.stdout.write(
163
+ ` design bundle updated (${sync.result.downloaded} file(s) changed) — building against the current corporate design\n`,
164
+ );
165
+ }
166
+ return sync;
167
+ }
168
+
169
+ // Strip the `data:<mime>;base64,` prefix off a data URI and return the raw
170
+ // base64 payload (or null when the URI isn't a base64 data URI we can decode).
171
+ export function base64FromDataUri(dataUri) {
172
+ if (typeof dataUri !== "string") return null;
173
+ const m = /^data:[^;,]*;base64,(.*)$/s.exec(dataUri.trim());
174
+ return m ? m[1] : null;
175
+ }
176
+
177
+ export async function loadBridgeModule(name) {
178
+ return import(pathToFileURL(path.join(SRC_DIR, "bridge", `${name}.mjs`)).href);
179
+ }
@@ -0,0 +1,310 @@
1
+ import fs from "node:fs";
2
+ import path from "node:path";
3
+ import { pathToFileURL } from "node:url";
4
+ import { fail, requirePlanApproved, resolveDeckArg, freshenDeckBundle, base64FromDataUri, SRC_DIR, BUILDERS_DIR } from "./common.mjs";
5
+
6
+ export const REFERENCE_MIME = {
7
+ ".png": "image/png",
8
+ ".jpg": "image/jpeg",
9
+ ".jpeg": "image/jpeg",
10
+ ".webp": "image/webp",
11
+ ".gif": "image/gif",
12
+ };
13
+
14
+ // `--concept <image>[@N,M]` / `--style <image>[@N,M]` → one inline reference
15
+ // `{ role, image, slides?, name }` for the concept-images route.
16
+ export function readReferenceArg(role, spec) {
17
+ if (!spec) fail(`--${role} expects <image>[@slides]`);
18
+ const at = spec.lastIndexOf("@");
19
+ const file = at > 0 ? spec.slice(0, at) : spec;
20
+ const slides =
21
+ at > 0
22
+ ? spec
23
+ .slice(at + 1)
24
+ .split(",")
25
+ .map((n) => Number(n.trim()))
26
+ .filter((n) => Number.isInteger(n) && n > 0)
27
+ : [];
28
+ if (at > 0 && slides.length === 0) {
29
+ fail(`--${role} ${spec}: expected slide numbers after '@', e.g. sketch.png@2,3`);
30
+ }
31
+ const mime = REFERENCE_MIME[path.extname(file).toLowerCase()];
32
+ if (!mime) fail(`--${role} ${file}: expected a png/jpg/webp/gif image`);
33
+ let bytes;
34
+ try {
35
+ bytes = fs.readFileSync(file);
36
+ } catch {
37
+ fail(`--${role} ${file}: file not found`);
38
+ }
39
+ return {
40
+ role,
41
+ image: `data:${mime};base64,${bytes.toString("base64")}`,
42
+ ...(slides.length > 0 ? { slides } : {}),
43
+ name: path.basename(file),
44
+ };
45
+ }
46
+
47
+ export async function cmdConceptImages(rest) {
48
+ let deck = null;
49
+ let force = false;
50
+ let asJson = false;
51
+ const references = [];
52
+ for (let i = 0; i < rest.length; i++) {
53
+ const a = rest[i];
54
+ if (a === "--deck") {
55
+ deck = rest[++i];
56
+ if (!deck) fail("--deck expects a deck name or path");
57
+ } else if (a === "--force") {
58
+ force = true;
59
+ } else if (a === "--json") {
60
+ asJson = true;
61
+ } else if (a === "--concept" || a === "--style") {
62
+ references.push(readReferenceArg(a.slice(2), rest[++i]));
63
+ // A new user reference changes what the mockups should be.
64
+ force = true;
65
+ } else if (a.startsWith("-")) {
66
+ fail(`unknown flag: ${a}`);
67
+ } else if (deck === null) {
68
+ deck = a;
69
+ } else {
70
+ fail(`unexpected argument: ${a}`);
71
+ }
72
+ }
73
+ if (!deck) fail("usage: slidemind concepts <deck>");
74
+
75
+ const deckDir = resolveDeckArg(deck);
76
+ const planPath = path.join(deckDir, "plan.md");
77
+ if (!fs.existsSync(planPath)) {
78
+ fail(
79
+ `no plan.md in ${path.relative(process.cwd(), deckDir) || deckDir}. ` +
80
+ `Run \`slidemind plan ${deck}\` to compose and sign off a plan first.`,
81
+ );
82
+ }
83
+ const planMarkdown = fs.readFileSync(planPath, "utf8");
84
+ // Every exit ends on ONE machine-readable line — `CONCEPTS: n/m …` — so a
85
+ // harness that cut the output short, and an agent reading a summary, can
86
+ // tell "generated", "none available" and "still running" apart without a
87
+ // double-evidence ritual. --json prints the same facts as a payload.
88
+ const finish = ({ paths = [], planned = 0, status, reason = null, missing = [], seeded = false, extra = "" }) => {
89
+ const conceptsDir = path.join(deckDir, "concepts");
90
+ if (asJson) {
91
+ process.stdout.write(
92
+ `${JSON.stringify({ deck: path.basename(deckDir), status, generated: paths.length, planned, paths, missing, reason, conceptsDir, assetPlan: path.join(deckDir, "asset-plan.md"), seededAssetPlan: seeded }, null, 2)}\n`,
93
+ );
94
+ } else {
95
+ if (paths.length) process.stdout.write(paths.join("\n") + "\n");
96
+ process.stdout.write(
97
+ `CONCEPTS: ${paths.length}/${planned} ${status}${reason ? ` — ${reason}` : ""}` +
98
+ (missing.length ? ` (no mockup for slide${missing.length > 1 ? "s" : ""} ${missing.join(", ")} — author from the storyboard)` : "") +
99
+ (paths.length ? ` — Read each PNG, then rebuild as slide JSX (workflow/create.md).` : "") +
100
+ `\n${extra}`,
101
+ );
102
+ }
103
+ process.exit(0);
104
+ };
105
+ // Mockups are the first post-plan step, so this is where an unapproved or
106
+ // since-edited plan stops the agent — before any authoring, and before the
107
+ // fast path below re-prints mockups that would belong to a stale plan.
108
+ await requirePlanApproved(deckDir);
109
+ await freshenDeckBundle(deckDir);
110
+
111
+ // The slides the plan's storyboards name — the set every step below works
112
+ // against (fast-path completeness, request waves, pruning, the missing
113
+ // note). The server's slideCount is a progress figure only: it counts the
114
+ // beats IT parsed, and a beat it drops must not shrink the request range.
115
+ const families = await import(
116
+ pathToFileURL(path.join(BUILDERS_DIR, "visual-families.mjs")).href
117
+ );
118
+ const planned = new Set(families.plannedSlideNumbers(planMarkdown));
119
+
120
+ // Idempotent fast path: mockups already on disk are re-printed IMMEDIATELY.
121
+ // Generation takes 1-2 minutes and agent harnesses time commands out and
122
+ // show the model only the output-so-far — while the detached process still
123
+ // finishes writing the PNGs. The model's mandated retry then lands here and
124
+ // gets the full path list within any timeout, instead of racing generation
125
+ // again (measured: sessions that never saw the paths authored without the
126
+ // concepts — the single biggest fidelity loss). `--force` regenerates.
127
+ if (!force) {
128
+ const conceptsDirEarly = path.join(deckDir, "concepts");
129
+ let existing = [];
130
+ try {
131
+ existing = fs
132
+ .readdirSync(conceptsDirEarly)
133
+ .filter((f) => /^concept-slide-\d+\.png$/.test(f))
134
+ .sort();
135
+ } catch {}
136
+ // Only when the on-disk set EXACTLY matches the plan's slide numbering —
137
+ // a partial or stale set (leftover concept-slide-03.png on a 2-slide
138
+ // plan) falls through to regeneration, which prunes and refills.
139
+ const existingNums = new Set(
140
+ existing.map((f) => Number(f.match(/(\d+)/)[1])),
141
+ );
142
+ const matchesPlan =
143
+ planned.size > 0 &&
144
+ planned.size === existingNums.size &&
145
+ [...planned].every((n) => existingNums.has(n));
146
+ if (existing.length > 0 && matchesPlan) {
147
+ finish({
148
+ paths: existing.map((f) => path.join(conceptsDirEarly, f)),
149
+ planned: planned.size,
150
+ status: "on disk",
151
+ reason: "already generated for this plan (--force regenerates)",
152
+ });
153
+ }
154
+ }
155
+
156
+ // No storyboards, nothing to generate: the server builds its beats from the
157
+ // same blocks, so a request would come back with zero images anyway.
158
+ if (planned.size === 0) {
159
+ finish({ status: "none", reason: "plan.md has no storyboard-canvas blocks (re-run `slidemind plan` to compose the storyboards)" });
160
+ }
161
+
162
+ const bundle = await import(
163
+ pathToFileURL(path.join(SRC_DIR, "bridge", "bundle.mjs")).href
164
+ );
165
+ const brandId = bundle.readDeckBundleRef(deckDir)?.presFP ?? null;
166
+ if (!brandId) {
167
+ fail(
168
+ `deck ${deck} is not bound to a corporate design (no .bundle-ref.json). ` +
169
+ `Run \`slidemind ready ${deck}\` to bind it first.`,
170
+ );
171
+ }
172
+
173
+ const client = await import(
174
+ pathToFileURL(path.join(SRC_DIR, "bridge", "client.mjs")).href
175
+ );
176
+
177
+ const skipNote = () =>
178
+ finish({ planned: planned.size, status: "unavailable", reason: "this add-in does not generate concept mockups; author from the storyboards" });
179
+
180
+ // Generation is silent for 1–2 min otherwise, and a harness that yields a
181
+ // long-running command early then hands the agent an empty output it reads
182
+ // as "no mockups". Say up front what is happening and what to wait for.
183
+ process.stderr.write(
184
+ `slidemind: generating concept mockups for ${deck} — this takes 1–2 minutes. ` +
185
+ `Wait for the concept-slide-NN.png paths below; an empty output so far means still running, ` +
186
+ `not "no mockups". Do not re-run this command while it is still running.\n`,
187
+ );
188
+
189
+ // Pin the serving backend up front. This is a cached capabilities probe, not
190
+ // a generation — the deck's slide set comes from the plan's own storyboard
191
+ // canvases, which is the SAME source the server parses its beats from, so
192
+ // there is nothing left for a throwaway single-slide request to learn.
193
+ let backendURL;
194
+ try {
195
+ backendURL = await client.resolveConceptBackend();
196
+ } catch (err) {
197
+ if (err?.code === "concept_route_unavailable") skipNote();
198
+ throw err;
199
+ }
200
+
201
+ const slideCount = planned.size;
202
+ const images = [];
203
+ const collect = (res) => {
204
+ if (!Array.isArray(res?.images)) return;
205
+ for (const img of res.images) {
206
+ images.push(img);
207
+ process.stderr.write(
208
+ `slidemind: concept mockup ${images.length}/${slideCount} ready (slide ${img?.slideNumber})\n`,
209
+ );
210
+ }
211
+ };
212
+
213
+ // Family variants depend on their lowest-numbered anchor. Run independent
214
+ // slides + anchors first, then variants; the server's per-family anchor cache
215
+ // (keyed by brand + CLI user) is what makes the anchor cross the two waves'
216
+ // separate requests.
217
+ const waves = families.conceptGenerationWaves(planMarkdown);
218
+ const CONCURRENCY = 3;
219
+ async function runWave(slides) {
220
+ let cursor = 0;
221
+ async function worker() {
222
+ for (;;) {
223
+ const idx = cursor++;
224
+ if (idx >= slides.length) return;
225
+ const n = slides[idx];
226
+ try {
227
+ const res = await client.generateConceptImagesForBrand(brandId, {
228
+ planMarkdown,
229
+ slideNumbers: [n],
230
+ references,
231
+ backendURL,
232
+ force,
233
+ });
234
+ collect(res);
235
+ } catch (err) {
236
+ process.stderr.write(
237
+ `slidemind: concept mockup for slide ${n} failed: ${err?.message ?? err}\n`,
238
+ );
239
+ }
240
+ }
241
+ }
242
+ await Promise.all(
243
+ Array.from({ length: Math.min(CONCURRENCY, slides.length) }, () => worker()),
244
+ );
245
+ }
246
+ await runWave(waves.firstWave);
247
+ await runWave(waves.variants);
248
+
249
+ if (images.length === 0) {
250
+ finish({ planned: planned.size, status: "generated", reason: "the add-in returned no image for any slide (see the per-slide failures above); author from the storyboards" });
251
+ }
252
+
253
+ const conceptsDir = path.join(deckDir, "concepts");
254
+ fs.mkdirSync(conceptsDir, { recursive: true });
255
+ const written = [];
256
+ const writtenNums = new Set();
257
+ for (const img of images) {
258
+ const n = Number(img?.slideNumber);
259
+ if (!Number.isFinite(n) || n < 1) continue;
260
+ const b64 = base64FromDataUri(img?.dataUri);
261
+ if (!b64) continue;
262
+ const name = `concept-slide-${String(n).padStart(2, "0")}.png`;
263
+ const abs = path.join(conceptsDir, name);
264
+ fs.writeFileSync(abs, Buffer.from(b64, "base64"));
265
+ written.push(abs);
266
+ writtenNums.add(n);
267
+ }
268
+
269
+ // Drop stale mockups from a previous, longer plan — any concept-slide-NN.png
270
+ // the plan no longer names (fallback: beyond the server's slideCount).
271
+ const isStale = (n) =>
272
+ planned.size > 0 ? !planned.has(n) : n > slideCount;
273
+ for (const entry of fs.readdirSync(conceptsDir)) {
274
+ const m = /^concept-slide-(\d+)\.png$/.exec(entry);
275
+ if (m && isStale(Number(m[1]))) {
276
+ fs.rmSync(path.join(conceptsDir, entry), { force: true });
277
+ }
278
+ }
279
+ // A planned slide the server returned nothing for is invisible otherwise —
280
+ // the agent would author it blind without knowing a mockup was expected.
281
+ const missing = [...planned].filter((n) => !writtenNums.has(n));
282
+
283
+ if (written.length === 0) {
284
+ finish({ planned: planned.size, status: "generated", reason: "the add-in returned images without usable bytes; author from the storyboards", missing });
285
+ }
286
+
287
+ written.sort();
288
+ // The inventory form is seeded next to the mockups (never over a filled
289
+ // one): the organism/connector list becomes a file the draft check counts,
290
+ // not a step the agent may skip.
291
+ const check = await import(
292
+ pathToFileURL(path.join(BUILDERS_DIR, "draft-check.mjs")).href
293
+ );
294
+ const seeded = check.seedAssetPlan(
295
+ deckDir,
296
+ images
297
+ .filter((img) => writtenNums.has(Number(img?.slideNumber)))
298
+ .map((img) => ({ slideNumber: Number(img.slideNumber), label: img.label })),
299
+ );
300
+ finish({
301
+ paths: written,
302
+ planned: planned.size,
303
+ status: "generated",
304
+ missing,
305
+ seeded,
306
+ extra: seeded
307
+ ? `Seeded ${path.join(deckDir, check.ASSET_PLAN_FILE)} — fill its regions/organisms/connectors/icons rows per slide from the mockups BEFORE writing any slide.\n`
308
+ : "",
309
+ });
310
+ }