slidemind 0.3.4 → 0.3.12

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/bin/slidemind.mjs CHANGED
@@ -83,6 +83,7 @@ function printHelp() {
83
83
  ` --connect-only check the connection only (no design/bundle)\n` +
84
84
  ` --json machine-readable readiness payload\n` +
85
85
  ` slidemind new <name> Scaffold ./decks/<name>/ (auto-binds to the open design)\n` +
86
+ ` --brand <presFP> bind to a chosen (cached) design instead\n` +
86
87
  ` --to <presId> bind to a specific open window\n` +
87
88
  ` --no-bind skip the auto-bind\n` +
88
89
  ` slidemind plan <deck> Plan sign-off gate on <deck>/plan.md: opens it in the browser\n` +
@@ -94,6 +95,10 @@ function printHelp() {
94
95
  ` pane-free — no PowerPoint window needed).\n` +
95
96
  ` Read each as a design target, then rebuild it as slide JSX.\n` +
96
97
  ` Optional aid — skips cleanly (exit 0) if unavailable.\n` +
98
+ ` --concept <image>[@N,M] the user's own sketch/mockup IS the\n` +
99
+ ` mockup for slides N,M (default: all)\n` +
100
+ ` --style <image>[@N,M] the user's look-and-feel reference,\n` +
101
+ ` leads the generation (repeatable)\n` +
97
102
  ` slidemind build <deck> Build self-contained HTML\n` +
98
103
  ` slidemind review <deck> Open a spatial preview review; blocks until the user\n` +
99
104
  ` approves or leaves comments (host-free)\n` +
@@ -171,7 +176,6 @@ function printHelp() {
171
176
  ` --out <name> file name under assets/ (default imagen-N.png)\n` +
172
177
  ` --size <s> auto|1024x1024|1536x1024|1024x1536 (default auto)\n` +
173
178
  ` --quality <q> low|medium|high|auto\n` +
174
- ` --brand-style condition on the CD's reference samples\n` +
175
179
  ` slidemind download-bundle [deck] Materialize the active corporate design's bundle locally\n` +
176
180
  ` --to <presId> target a specific window\n` +
177
181
  ` --force re-download even if the cached bundle is current\n` +
@@ -221,6 +225,23 @@ function fail(msg, code = 1) {
221
225
  process.exit(code);
222
226
  }
223
227
 
228
+ // The two human checkmarks the CLI enforces itself, so no harness timeout,
229
+ // context loss, or agent rationalisation can walk past a gate: nothing after
230
+ // the plan gate (concepts, build, view-draft, review) runs on an unapproved or
231
+ // since-edited plan, and nothing ships (push, build-pptx) from a preview the
232
+ // user has not approved exactly as it stands. Both fast-fail before any
233
+ // bridge call with the command that clears them.
234
+ async function requirePlanApproved(deckDir) {
235
+ const plan = await import(pathToFileURL(path.join(BUILDERS_DIR, "plan.mjs")).href);
236
+ const why = plan.requirePlanApproved(deckDir);
237
+ if (why) fail(why);
238
+ }
239
+ async function requireReviewApproved(deckDir) {
240
+ const review = await import(pathToFileURL(path.join(BUILDERS_DIR, "review.mjs")).href);
241
+ const why = review.requireReviewApproved(deckDir);
242
+ if (why) fail(why);
243
+ }
244
+
224
245
  // The local PowerPoint files `read` and `add-slide --from` accept host-free.
225
246
  // All are OPC (zip) OOXML packages with the same ppt/presentation.xml +
226
247
  // ppt/slides/ structure — presentation (.pptx/.pptm), template (.potx/.potm),
@@ -287,6 +308,78 @@ function readDesignName(bundleDir) {
287
308
  }
288
309
  }
289
310
 
311
+ // The slide master's theme colours, from the one line design.md carries them
312
+ // on (`Theme colors: dk1=#000000, lt1=#FFFFFF, …, accent1=#0000FF, …`). This
313
+ // is what lets the agent SHOW a design to the user instead of naming it —
314
+ // {dk1, lt1, dk2, lt2, accent1..6} → "#RRGGBB", or null when the bundle has
315
+ // no such line.
316
+ function readThemeColors(bundleDir) {
317
+ if (!bundleDir) return null;
318
+ try {
319
+ const md = fs.readFileSync(path.join(bundleDir, "design.md"), "utf8");
320
+ const line = md.split("\n").find((l) => /^\s*Theme colors:/i.test(l));
321
+ if (!line) return null;
322
+ const colors = {};
323
+ for (const [, key, hex] of line.matchAll(/(\w+)=(#[0-9a-fA-F]{6})/g)) {
324
+ colors[key] = hex.toUpperCase();
325
+ }
326
+ return Object.keys(colors).length ? colors : null;
327
+ } catch {
328
+ return null;
329
+ }
330
+ }
331
+
332
+ // Every design materialized on this machine, the way `ready` reports it: name
333
+ // + theme colours per design so the agent can put the choice to the user when
334
+ // more than one is at hand. `current` marks the one this run resolved.
335
+ function cachedDesigns(bundle, currentPresFP) {
336
+ return bundle.listCachedBundles().map((b) => ({
337
+ presFP: b.presFP,
338
+ name: readDesignName(b.dir),
339
+ colors: readThemeColors(b.dir),
340
+ current: b.presFP === currentPresFP,
341
+ }));
342
+ }
343
+
344
+ // One menu row per design: id, name, theme swatch, and the verified windows
345
+ // open on it. `(not cached)` marks a design only a window or the backend knows.
346
+ function designRows(designs) {
347
+ return designs.map((d) => {
348
+ const c = d.colors ?? {};
349
+ const swatch = ["accent1", "accent2", "dk1", "lt1"]
350
+ .filter((k) => c[k])
351
+ .map((k) => `${k} ${c[k]}`)
352
+ .join(", ");
353
+ const open = (d.windows ?? [])
354
+ .map((w) => `${w.title || w.fileName || "(untitled)"}${envTag(w.env)}`)
355
+ .join(", ");
356
+ return (
357
+ ` ${d.current ? "*" : " "} ${d.presFP} ${d.name ?? "(unnamed)"}` +
358
+ `${swatch ? ` [${swatch}]` : ""}` +
359
+ `${d.cached === false ? " (not cached)" : ""}` +
360
+ `${open ? ` open: ${open}` : ""}`
361
+ );
362
+ });
363
+ }
364
+
365
+ function designsHumanLines(designs) {
366
+ if (designs.length < 2) return "";
367
+ return (
368
+ ` ${designs.length} designs at hand — ask the user which to build against (* = current):\n` +
369
+ `${designRows(designs).join("\n")}\n` +
370
+ " Switch with: slidemind ready [deck] --brand <presFP>\n"
371
+ );
372
+ }
373
+
374
+ function windowsHumanLine(windows) {
375
+ if (windows.length === 0) return "";
376
+ const rows = windows.map(
377
+ (w) =>
378
+ `${w.title || w.fileName || "(untitled)"}${envTag(w.env)}${w.paneReady ? "" : " (suspended)"}`,
379
+ );
380
+ return ` windows: ${rows.join(", ")}\n`;
381
+ }
382
+
290
383
  function runNode(script, args) {
291
384
  return new Promise((resolve, reject) => {
292
385
  const child = spawn(process.execPath, [script, ...args], {
@@ -320,11 +413,9 @@ function splitDeckArg(rest, cmd) {
320
413
 
321
414
  async function cmdBuild(rest) {
322
415
  const { deck, passthrough } = splitDeckArg(rest, "build");
323
- await runNode(path.join(BUILDERS_DIR, "build.mjs"), [
324
- "--deck",
325
- resolveDeckArg(deck),
326
- ...passthrough,
327
- ]);
416
+ const deckDir = resolveDeckArg(deck);
417
+ await requirePlanApproved(deckDir);
418
+ await runNode(path.join(BUILDERS_DIR, "build.mjs"), ["--deck", deckDir, ...passthrough]);
328
419
  }
329
420
 
330
421
  async function cmdPlan(rest) {
@@ -338,11 +429,9 @@ async function cmdPlan(rest) {
338
429
 
339
430
  async function cmdReview(rest) {
340
431
  const { deck, passthrough } = splitDeckArg(rest, "review");
341
- await runNode(path.join(BUILDERS_DIR, "review.mjs"), [
342
- "--deck",
343
- resolveDeckArg(deck),
344
- ...passthrough,
345
- ]);
432
+ const deckDir = resolveDeckArg(deck);
433
+ await requirePlanApproved(deckDir);
434
+ await runNode(path.join(BUILDERS_DIR, "review.mjs"), ["--deck", deckDir, ...passthrough]);
346
435
  }
347
436
 
348
437
  // Live render progress for build-pptx/push (the job protocol streams it; the
@@ -381,7 +470,6 @@ async function cmdImagen(rest) {
381
470
  let outName = null;
382
471
  let size = null;
383
472
  let quality = null;
384
- let brandStyle = false;
385
473
  for (let i = 0; i < rest.length; i++) {
386
474
  const a = rest[i];
387
475
  if (a === "--deck") {
@@ -400,8 +488,6 @@ async function cmdImagen(rest) {
400
488
  if (!["low", "medium", "high", "auto"].includes(quality ?? "")) {
401
489
  fail("--quality expects low|medium|high|auto");
402
490
  }
403
- } else if (a === "--brand-style") {
404
- brandStyle = true;
405
491
  } else if (a.startsWith("-")) {
406
492
  fail(`unknown flag: ${a}`);
407
493
  } else if (prompt === null) {
@@ -413,7 +499,7 @@ async function cmdImagen(rest) {
413
499
  if (!prompt || !prompt.trim() || !deck) {
414
500
  fail(
415
501
  'usage: slidemind imagen "<prompt>" --deck <deck> [--out <name.png>] ' +
416
- "[--size auto|1024x1024|1536x1024|1024x1536] [--quality low|medium|high|auto] [--brand-style]",
502
+ "[--size auto|1024x1024|1536x1024|1024x1536] [--quality low|medium|high|auto]",
417
503
  );
418
504
  }
419
505
  const deckDir = resolveDeckArg(deck);
@@ -437,7 +523,6 @@ async function cmdImagen(rest) {
437
523
  prompt,
438
524
  size,
439
525
  quality,
440
- brandStyle,
441
526
  });
442
527
  } catch (err) {
443
528
  if (err?.code === "imagen_route_unavailable") {
@@ -462,9 +547,8 @@ async function cmdImagen(rest) {
462
547
  }
463
548
  const outPath = path.join(assetsDir, file);
464
549
  fs.writeFileSync(outPath, Buffer.from(m[2], "base64"));
465
- const styled = result.styleReferences > 0 ? ` (styled on ${result.styleReferences} brand sample(s))` : "";
466
550
  process.stdout.write(
467
- `Wrote ${path.relative(process.cwd(), outPath)}${styled}\n` +
551
+ `Wrote ${path.relative(process.cwd(), outPath)}\n` +
468
552
  ` Reference it from slide props as "assets/${file}" (e.g. summaryImage, illustration).\n`,
469
553
  );
470
554
  }
@@ -579,6 +663,9 @@ async function cmdBuildPptx(rest) {
579
663
  if (packDir && deckMode.mode !== "fill-template") {
580
664
  fail(`explicit --pack ${path.resolve(packDir)} is mode ${deckMode.mode}, not fill-template`);
581
665
  }
666
+ // Template packs have no HTML preview to review and carry their own plan
667
+ // sign-off; compose decks ship only from an approved, unchanged preview.
668
+ if (deckMode.mode !== "fill-template") await requireReviewApproved(deckDir);
582
669
  if (deckMode.mode === "fill-template") {
583
670
  const skill = resolveMod.resolveSkillForDeck(deckDir, resolveOpts);
584
671
  if (to) {
@@ -683,6 +770,7 @@ async function cmdViewDraft(rest) {
683
770
  if (!fs.existsSync(path.join(deckDir, "slides"))) {
684
771
  fail(`expected 'slides/' folder in ${deckDir}`);
685
772
  }
773
+ await requirePlanApproved(deckDir);
686
774
  // Draft previews are produced locally by the add-in's shipped capture engine
687
775
  // (no push, no .pptx) — the same module that hosts the render+push path.
688
776
  const mod = await import(
@@ -1106,13 +1194,16 @@ async function cmdSearch(rest) {
1106
1194
 
1107
1195
  // An explicit content/visual intent means "search slides", not imagery.
1108
1196
  const slideIntent = intent === "content" || intent === "visual";
1109
- // Thread the engine subtype on the wire whenever the CLI can determine it:
1110
- // - the pickImageEngine default branch is, by construction, an image engine;
1111
- // - an explicit slide intent (--intent content|visual) targets slides.
1112
- // This is belt-and-suspenders for an add-in regression where a missing
1113
- // subtype no longer defaulted to "image"; when the subtype is unknown we omit
1114
- // it and let the add-in resolve the caller's groups itself.
1115
- let subtype = slideIntent ? "slide" : null;
1197
+ // Thread the engine subtype on the wire only when the CLI actually KNOWS it:
1198
+ // `--intent content` is slide-only by contract (image engines are
1199
+ // visual-only), the pickImageEngine default branch is an image engine by
1200
+ // construction, and a scope search names its index below. `--intent visual`
1201
+ // asserts NOTHING — it is a valid intent for image AND slide engines, and
1202
+ // asserting "slide" from it made the backend record an icon-engine search
1203
+ // as slide results (the footer then taught add-slide instead of
1204
+ // download-result). Omitted subtype → the backend derives it from what the
1205
+ // results actually are.
1206
+ let subtype = intent === "content" ? "slide" : null;
1116
1207
  // `--engine Icons` — a display name straight off `ls engines` — would reach
1117
1208
  // the backend verbatim and 404 as "Engine not found". Humans type names,
1118
1209
  // agents paste ids; when the value cannot be an id (spaces, non-hex), look it
@@ -1327,11 +1418,16 @@ async function cmdSearch(rest) {
1327
1418
  }
1328
1419
  }
1329
1420
  // Tailor the next-step hint to the result kind: slide verbs (insert-slide /
1330
- // add-slide) for slide results, the image-download hint otherwise. The result
1331
- // subtype is authoritative; fall back to a per-result `kind` for older
1332
- // add-ins that don't echo a top-level subtype.
1333
- const isSlideResults =
1334
- res.subtype === "slide" || results.some((r) => r.kind === "slide");
1421
+ // add-slide) for slide results, the image-download hint otherwise. The
1422
+ // per-result `kind`s are authoritative — the top-level subtype echo has been
1423
+ // observed WRONG on live backends (icon-engine searches echoing
1424
+ // subtype:"slide" over kind:"image" results, which pointed agents at
1425
+ // add-slide instead of download-result) — with the subtype as the fallback
1426
+ // for add-ins that don't stamp per-result kinds.
1427
+ const resultKinds = results.map((r) => r.kind).filter(Boolean);
1428
+ const isSlideResults = resultKinds.length
1429
+ ? resultKinds.some((k) => k === "slide")
1430
+ : res.subtype === "slide";
1335
1431
  const envLabel = envTag(ranEntry?.env);
1336
1432
  if (direct) {
1337
1433
  // The searchId belongs to the BACKEND here, not to a window: picks work with
@@ -1346,7 +1442,7 @@ async function cmdSearch(rest) {
1346
1442
  } else {
1347
1443
  process.stdout.write(
1348
1444
  `searchId ${res.searchId}${envLabel} is held by the add-in — download image result(s) into a deck (no window needed):\n` +
1349
- ` slidemind download-result --search ${res.searchId} --pick <n[,n,...]> --deck <deck> [--icon-color <hex>]\n`,
1445
+ ` slidemind download-result --search ${res.searchId} --pick <n[,n,...]> --deck <deck> [--icon-color "#0A7E8C"]\n`,
1350
1446
  );
1351
1447
  }
1352
1448
  } else if (isSlideResults) {
@@ -1362,7 +1458,7 @@ async function cmdSearch(rest) {
1362
1458
  // target that same window, so spell out its presId in the next-step command.
1363
1459
  process.stdout.write(
1364
1460
  `searchId ${res.searchId} ran on window ${ranPresId}${envLabel} — download image result(s) into a deck:\n` +
1365
- ` slidemind download-result --search ${res.searchId} --pick <n[,n,...]> --deck <deck> --to ${ranPresId} [--icon-color <hex>]\n`,
1461
+ ` slidemind download-result --search ${res.searchId} --pick <n[,n,...]> --deck <deck> --to ${ranPresId} [--icon-color "#0A7E8C"]\n`,
1366
1462
  );
1367
1463
  }
1368
1464
  }
@@ -2282,13 +2378,62 @@ function base64FromDataUri(dataUri) {
2282
2378
  return m ? m[1] : null;
2283
2379
  }
2284
2380
 
2381
+ const REFERENCE_MIME = {
2382
+ ".png": "image/png",
2383
+ ".jpg": "image/jpeg",
2384
+ ".jpeg": "image/jpeg",
2385
+ ".webp": "image/webp",
2386
+ ".gif": "image/gif",
2387
+ };
2388
+
2389
+ // `--concept <image>[@N,M]` / `--style <image>[@N,M]` → one inline reference
2390
+ // `{ role, image, slides?, name }` for the concept-images route.
2391
+ function readReferenceArg(role, spec) {
2392
+ if (!spec) fail(`--${role} expects <image>[@slides]`);
2393
+ const at = spec.lastIndexOf("@");
2394
+ const file = at > 0 ? spec.slice(0, at) : spec;
2395
+ const slides =
2396
+ at > 0
2397
+ ? spec
2398
+ .slice(at + 1)
2399
+ .split(",")
2400
+ .map((n) => Number(n.trim()))
2401
+ .filter((n) => Number.isInteger(n) && n > 0)
2402
+ : [];
2403
+ if (at > 0 && slides.length === 0) {
2404
+ fail(`--${role} ${spec}: expected slide numbers after '@', e.g. sketch.png@2,3`);
2405
+ }
2406
+ const mime = REFERENCE_MIME[path.extname(file).toLowerCase()];
2407
+ if (!mime) fail(`--${role} ${file}: expected a png/jpg/webp/gif image`);
2408
+ let bytes;
2409
+ try {
2410
+ bytes = fs.readFileSync(file);
2411
+ } catch {
2412
+ fail(`--${role} ${file}: file not found`);
2413
+ }
2414
+ return {
2415
+ role,
2416
+ image: `data:${mime};base64,${bytes.toString("base64")}`,
2417
+ ...(slides.length > 0 ? { slides } : {}),
2418
+ name: path.basename(file),
2419
+ };
2420
+ }
2421
+
2285
2422
  async function cmdConceptImages(rest) {
2286
2423
  let deck = null;
2424
+ let force = false;
2425
+ const references = [];
2287
2426
  for (let i = 0; i < rest.length; i++) {
2288
2427
  const a = rest[i];
2289
2428
  if (a === "--deck") {
2290
2429
  deck = rest[++i];
2291
2430
  if (!deck) fail("--deck expects a deck name or path");
2431
+ } else if (a === "--force") {
2432
+ force = true;
2433
+ } else if (a === "--concept" || a === "--style") {
2434
+ references.push(readReferenceArg(a.slice(2), rest[++i]));
2435
+ // A new user reference changes what the mockups should be.
2436
+ force = true;
2292
2437
  } else if (a.startsWith("-")) {
2293
2438
  fail(`unknown flag: ${a}`);
2294
2439
  } else if (deck === null) {
@@ -2308,6 +2453,58 @@ async function cmdConceptImages(rest) {
2308
2453
  );
2309
2454
  }
2310
2455
  const planMarkdown = fs.readFileSync(planPath, "utf8");
2456
+ // Mockups are the first post-plan step, so this is where an unapproved or
2457
+ // since-edited plan stops the agent — before any authoring, and before the
2458
+ // fast path below re-prints mockups that would belong to a stale plan.
2459
+ await requirePlanApproved(deckDir);
2460
+
2461
+ // The slides the plan's storyboards name — the set every step below works
2462
+ // against (fast-path completeness, request waves, pruning, the missing
2463
+ // note). The server's slideCount is a progress figure only: it counts the
2464
+ // beats IT parsed, and a beat it drops must not shrink the request range.
2465
+ const families = await import(
2466
+ pathToFileURL(path.join(BUILDERS_DIR, "visual-families.mjs")).href
2467
+ );
2468
+ const planned = new Set(families.plannedSlideNumbers(planMarkdown));
2469
+
2470
+ // Idempotent fast path: mockups already on disk are re-printed IMMEDIATELY.
2471
+ // Generation takes 1-2 minutes and agent harnesses time commands out and
2472
+ // show the model only the output-so-far — while the detached process still
2473
+ // finishes writing the PNGs. The model's mandated retry then lands here and
2474
+ // gets the full path list within any timeout, instead of racing generation
2475
+ // again (measured: sessions that never saw the paths authored without the
2476
+ // concepts — the single biggest fidelity loss). `--force` regenerates.
2477
+ if (!force) {
2478
+ const conceptsDirEarly = path.join(deckDir, "concepts");
2479
+ let existing = [];
2480
+ try {
2481
+ existing = fs
2482
+ .readdirSync(conceptsDirEarly)
2483
+ .filter((f) => /^concept-slide-\d+\.png$/.test(f))
2484
+ .sort();
2485
+ } catch {}
2486
+ // Only when the on-disk set EXACTLY matches the plan's slide numbering —
2487
+ // a partial or stale set (leftover concept-slide-03.png on a 2-slide
2488
+ // plan) falls through to regeneration, which prunes and refills.
2489
+ const existingNums = new Set(
2490
+ existing.map((f) => Number(f.match(/(\d+)/)[1])),
2491
+ );
2492
+ const matchesPlan =
2493
+ planned.size > 0 &&
2494
+ planned.size === existingNums.size &&
2495
+ [...planned].every((n) => existingNums.has(n));
2496
+ if (existing.length > 0 && matchesPlan) {
2497
+ for (const f of existing) {
2498
+ process.stdout.write(path.join(conceptsDirEarly, f) + "\n");
2499
+ }
2500
+ process.stdout.write(
2501
+ `${existing.length} concept mockup(s) already in ${conceptsDirEarly} — ` +
2502
+ "Read each, then rebuild as slide JSX (workflow/create.md → Preconditions). " +
2503
+ "Pass --force to regenerate from the current plan.\n",
2504
+ );
2505
+ return;
2506
+ }
2507
+ }
2311
2508
 
2312
2509
  const bundle = await import(
2313
2510
  pathToFileURL(path.join(SRC_DIR, "bridge", "bundle.mjs")).href
@@ -2320,21 +2517,6 @@ async function cmdConceptImages(rest) {
2320
2517
  );
2321
2518
  }
2322
2519
 
2323
- // Plan sign-off is advisory here, not a hard gate: warn but proceed if the
2324
- // plan wasn't approved, so an agent can still fetch mockups while iterating.
2325
- const plan = await import(
2326
- pathToFileURL(path.join(BUILDERS_DIR, "plan.mjs")).href
2327
- );
2328
- const planState = plan.loadPlanState(deckDir);
2329
- // "approve" is the gate's own persisted vocabulary (stdout says "approved");
2330
- // comparing against "approved" here made the note fire on every approved deck.
2331
- if (planState?.decision !== "approve") {
2332
- process.stderr.write(
2333
- `slidemind: note — ${deck}'s plan is not signed off yet ` +
2334
- `(run \`slidemind plan ${deck}\`); generating concept mockups from the current plan.md anyway.\n`,
2335
- );
2336
- }
2337
-
2338
2520
  const client = await import(
2339
2521
  pathToFileURL(path.join(SRC_DIR, "bridge", "client.mjs")).href
2340
2522
  );
@@ -2347,12 +2529,22 @@ async function cmdConceptImages(rest) {
2347
2529
  process.exit(0);
2348
2530
  };
2349
2531
 
2532
+ // Generation is silent for 1–2 min otherwise, and a harness that yields a
2533
+ // long-running command early then hands the agent an empty output it reads
2534
+ // as "no mockups". Say up front what is happening and what to wait for.
2535
+ process.stderr.write(
2536
+ `slidemind: generating concept mockups for ${deck} — this takes 1–2 minutes. ` +
2537
+ `Wait for the concept-slide-NN.png paths below; an empty output so far means still running, ` +
2538
+ `not "no mockups". Do not re-run this command while it is still running.\n`,
2539
+ );
2540
+
2350
2541
  // First request learns slideCount (and pins the serving backend for the rest).
2351
2542
  let first;
2352
2543
  try {
2353
2544
  first = await client.generateConceptImagesForBrand(brandId, {
2354
2545
  planMarkdown,
2355
2546
  slideNumbers: [1],
2547
+ references,
2356
2548
  });
2357
2549
  } catch (err) {
2358
2550
  if (err?.code === "concept_route_unavailable") skipNote();
@@ -2363,16 +2555,19 @@ async function cmdConceptImages(rest) {
2363
2555
  const slideCount = Number.isFinite(first.slideCount) ? first.slideCount : 0;
2364
2556
  const images = [];
2365
2557
  const collect = (res) => {
2366
- if (Array.isArray(res?.images)) images.push(...res.images);
2558
+ if (!Array.isArray(res?.images)) return;
2559
+ for (const img of res.images) {
2560
+ images.push(img);
2561
+ process.stderr.write(
2562
+ `slidemind: concept mockup ${images.length}/${slideCount} ready (slide ${img?.slideNumber})\n`,
2563
+ );
2564
+ }
2367
2565
  };
2368
2566
  collect(first);
2369
2567
 
2370
2568
  // Family variants depend on their lowest-numbered anchor. Run independent
2371
2569
  // slides + anchors first, then variants; the server cache remains a safety
2372
2570
  // net for older clients and retries.
2373
- const families = await import(
2374
- pathToFileURL(path.join(BUILDERS_DIR, "visual-families.mjs")).href
2375
- );
2376
2571
  const waves = families.conceptGenerationWaves(planMarkdown, slideCount);
2377
2572
  const CONCURRENCY = 3;
2378
2573
  async function runWave(slides) {
@@ -2386,6 +2581,7 @@ async function cmdConceptImages(rest) {
2386
2581
  const res = await client.generateConceptImagesForBrand(brandId, {
2387
2582
  planMarkdown,
2388
2583
  slideNumbers: [n],
2584
+ references,
2389
2585
  backendURL,
2390
2586
  });
2391
2587
  collect(res);
@@ -2413,6 +2609,7 @@ async function cmdConceptImages(rest) {
2413
2609
  const conceptsDir = path.join(deckDir, "concepts");
2414
2610
  fs.mkdirSync(conceptsDir, { recursive: true });
2415
2611
  const written = [];
2612
+ const writtenNums = new Set();
2416
2613
  for (const img of images) {
2417
2614
  const n = Number(img?.slideNumber);
2418
2615
  if (!Number.isFinite(n) || n < 1) continue;
@@ -2422,16 +2619,28 @@ async function cmdConceptImages(rest) {
2422
2619
  const abs = path.join(conceptsDir, name);
2423
2620
  fs.writeFileSync(abs, Buffer.from(b64, "base64"));
2424
2621
  written.push(abs);
2622
+ writtenNums.add(n);
2425
2623
  }
2426
2624
 
2427
2625
  // Drop stale mockups from a previous, longer plan — any concept-slide-NN.png
2428
- // whose slide number now exceeds the plan's slideCount.
2626
+ // the plan no longer names (fallback: beyond the server's slideCount).
2627
+ const isStale = (n) =>
2628
+ planned.size > 0 ? !planned.has(n) : n > slideCount;
2429
2629
  for (const entry of fs.readdirSync(conceptsDir)) {
2430
2630
  const m = /^concept-slide-(\d+)\.png$/.exec(entry);
2431
- if (m && Number(m[1]) > slideCount) {
2631
+ if (m && isStale(Number(m[1]))) {
2432
2632
  fs.rmSync(path.join(conceptsDir, entry), { force: true });
2433
2633
  }
2434
2634
  }
2635
+ // A planned slide the server returned nothing for is invisible otherwise —
2636
+ // the agent would author it blind without knowing a mockup was expected.
2637
+ const missing = [...planned].filter((n) => !writtenNums.has(n));
2638
+ if (missing.length > 0) {
2639
+ process.stderr.write(
2640
+ `slidemind: no concept mockup returned for slide${missing.length > 1 ? "s" : ""} ` +
2641
+ `${missing.join(", ")} — author ${missing.length > 1 ? "those" : "it"} from the storyboard.\n`,
2642
+ );
2643
+ }
2435
2644
 
2436
2645
  if (written.length === 0) {
2437
2646
  process.stdout.write(
@@ -2580,6 +2789,8 @@ function publicConnectionStatus(status, client) {
2580
2789
  minCliVersion: b.minCliVersion ?? null,
2581
2790
  cliUpdateRequired: b.cliUpdateRequired === true,
2582
2791
  error: b.error,
2792
+ code: b.code ?? null,
2793
+ status: b.status ?? null,
2583
2794
  summary: b.summary ?? null,
2584
2795
  presentations: (b.presentations ?? []).map(client.toPublicPresentation),
2585
2796
  })),
@@ -2612,6 +2823,22 @@ function backendBlocker(pub) {
2612
2823
  };
2613
2824
  }
2614
2825
  if ((pub.backends ?? []).every((b) => !b.ok)) {
2826
+ // Something answered 503 (already retried by the probe): the add-in whose
2827
+ // session store did not answer (code `backend_unavailable`), or a proxy in
2828
+ // front of it with no machine code at all. The session is intact, so the
2829
+ // remedy is to wait — never to re-pair, which is what the generic wording
2830
+ // below invites. Same predicate as the probe's retry.
2831
+ const unavailable = (b) =>
2832
+ b.code === "backend_unavailable" || (b.status === 503 && b.code !== "pane_suspended");
2833
+ if ((pub.backends ?? []).every(unavailable)) {
2834
+ return {
2835
+ code: "backend_unavailable",
2836
+ actor: "agent",
2837
+ message:
2838
+ "The add-in backend is temporarily unavailable (its session store did not answer). " +
2839
+ "Wait a minute and run `slidemind ready` again; the CLI session itself is fine, do not reconnect.",
2840
+ };
2841
+ }
2615
2842
  return {
2616
2843
  code: "all_unreachable",
2617
2844
  actor: "user",
@@ -2773,12 +3000,12 @@ async function cmdSetup(rest) {
2773
3000
  // `upsertSession` below changes the count.
2774
3001
  const showEnv = cfg.readSessions().length > 0;
2775
3002
  await ui.section("connect", showEnv ? envLabel : "");
2776
- const sessionToken = await claims.redeemSetupClaim({
3003
+ const { sessionToken, oid } = await claims.redeemSetupClaim({
2777
3004
  backendURL,
2778
3005
  secret,
2779
3006
  cliVersion: pkg.version,
2780
3007
  });
2781
- cfg.upsertSession({ backendURL, sessionToken, label: envLabel });
3008
+ cfg.upsertSession({ backendURL, sessionToken, label: envLabel, oid });
2782
3009
  ui.step({
2783
3010
  label: "authorize",
2784
3011
  value: showEnv ? envLabel : "connected",
@@ -3168,6 +3395,91 @@ async function cmdReady(rest) {
3168
3395
  return m365Info;
3169
3396
  };
3170
3397
 
3398
+ const isRR = client.isRenderReadyPresentation;
3399
+
3400
+ // The render-ready windows as the payloads report them — after the zombie
3401
+ // prune below, so a listed window is one PowerPoint actually has. One row
3402
+ // per window: a presId mirrored across backends is still one window.
3403
+ const publicWindows = () => {
3404
+ const seen = new Set();
3405
+ return registry.filter(isRR).filter((p) => !seen.has(p.presId) && seen.add(p.presId)).map((p) => {
3406
+ const q = client.toPublicPresentation(p);
3407
+ return {
3408
+ presId: q.presId,
3409
+ env: q.env ?? null,
3410
+ title: q.title ?? null,
3411
+ fileName: q.fileName ?? null,
3412
+ presFP: p.brandId ?? null,
3413
+ paneReady: client.isPaneReadyPresentation(p),
3414
+ };
3415
+ });
3416
+ };
3417
+
3418
+ // The design menu: every design at hand — cached here, open in a verified
3419
+ // window, or (when asked) listed by the backend — one row per presFP with
3420
+ // name, theme colours and the windows on it. Success payloads and the
3421
+ // ambiguous blocker report the same shape, so the agent always has the
3422
+ // menu to put to the user.
3423
+ const designMenu = async ({ current = null, withBackend = false } = {}) => {
3424
+ const blank = (presFP) => ({
3425
+ presFP,
3426
+ name: null,
3427
+ colors: null,
3428
+ current: presFP === current,
3429
+ cached: false,
3430
+ windows: [],
3431
+ });
3432
+ const rows = new Map(
3433
+ cachedDesigns(bundle, current).map((d) => [
3434
+ d.presFP,
3435
+ { ...d, cached: true, windows: [] },
3436
+ ]),
3437
+ );
3438
+ for (const w of publicWindows()) {
3439
+ if (!w.presFP) continue;
3440
+ if (!rows.has(w.presFP)) rows.set(w.presFP, blank(w.presFP));
3441
+ rows.get(w.presFP).windows.push(w);
3442
+ }
3443
+ if (withBackend) {
3444
+ try {
3445
+ for (const b of await client.listBrands()) {
3446
+ if (!rows.has(b.presFP)) rows.set(b.presFP, blank(b.presFP));
3447
+ rows.get(b.presFP).name ??= b.name ?? null;
3448
+ }
3449
+ } catch {
3450
+ /* no brand list — the menu is what this machine knows */
3451
+ }
3452
+ }
3453
+ return [...rows.values()];
3454
+ };
3455
+
3456
+ // The one question ready cannot answer: several designs at hand and neither
3457
+ // a deck binding, a --brand, nor a single verified window says which.
3458
+ const finishAmbiguous = async () => {
3459
+ const designs = await designMenu({ withBackend: true });
3460
+ const suggested =
3461
+ deckPresFP && designs.some((d) => d.presFP === deckPresFP) ? deckPresFP : null;
3462
+ const rerun = `slidemind ready${deckSlug ? ` ${deckSlug}` : ""} --brand <presFP>`;
3463
+ const blocker = {
3464
+ code: "ambiguous_design",
3465
+ actor: "user",
3466
+ message: `Several corporate designs are at hand and nothing says which — ask the user which to build against (by name and colours), then re-run: ${rerun}.`,
3467
+ };
3468
+ let human =
3469
+ "slidemind: several corporate designs are at hand — ask the user which to build against:\n" +
3470
+ `${designRows(designs).join("\n")}\n`;
3471
+ if (suggested) {
3472
+ const s = designs.find((d) => d.presFP === suggested);
3473
+ human += ` (this deck was last built against ${s?.name ?? suggested} (${suggested}))\n`;
3474
+ }
3475
+ human += `Then re-run: ${rerun}`;
3476
+ finishBlocker({
3477
+ blocker,
3478
+ extras: { designs, suggested, windows: publicWindows() },
3479
+ humanLines: human,
3480
+ });
3481
+ };
3482
+
3171
3483
  // --- A design resolved WITHOUT a window — the deck's own binding, an
3172
3484
  // explicit --brand, or the backend's brand list. Same payload either way:
3173
3485
  // ready, `windowless: true`, and a pane status that is INFORMATIONAL. What
@@ -3214,6 +3526,8 @@ async function cmdReady(rest) {
3214
3526
  presId: null,
3215
3527
  presFP: result.presFP,
3216
3528
  designName,
3529
+ designs: await designMenu({ current: result.presFP }),
3530
+ windows: publicWindows(),
3217
3531
  bundleDir: result.dir,
3218
3532
  bundleEtag: result.etag ?? null,
3219
3533
  paneSuspended: !paneReady,
@@ -3236,6 +3550,8 @@ async function cmdReady(rest) {
3236
3550
  );
3237
3551
  process.stdout.write(` bundle: ${result.dir} (etag ${result.etag ?? "?"})\n`);
3238
3552
  if (deckDir) process.stdout.write(` deck ${deckSlug} bound\n`);
3553
+ process.stdout.write(windowsHumanLine(publicWindows()));
3554
+ process.stdout.write(designsHumanLines(await designMenu({ current: result.presFP })));
3239
3555
  process.stdout.write(
3240
3556
  noWindow
3241
3557
  ? eng.available
@@ -3283,8 +3599,6 @@ async function cmdReady(rest) {
3283
3599
  return;
3284
3600
  }
3285
3601
 
3286
- const isRR = client.isRenderReadyPresentation;
3287
-
3288
3602
  // --- Bound-deck ladder: session → bundle freshness, no pane precondition.
3289
3603
  // The design identity (presFP == brandId) lives in the deck's
3290
3604
  // .bundle-ref.json and the bundle syncs over the pane-free brand route, so
@@ -3321,7 +3635,12 @@ async function cmdReady(rest) {
3321
3635
  registry = (await client.listOpenPresentationsDetailed()).presentations;
3322
3636
  }
3323
3637
  }
3324
- if (!registry.some(isRR)) {
3638
+ // Everything below terminates the command: it is the whole "no render-ready
3639
+ // window" resolution (connect-only engine answer → bound-deck rung → brand
3640
+ // list → late blocker). A named closure because TWO paths land here: the
3641
+ // registry genuinely holding no render-ready window, and the ping check
3642
+ // below pruning the last one as a zombie.
3643
+ const finishNoRenderReady = async () => {
3325
3644
  // --connect-only asks one question — "can I work?" — and since the engine
3326
3645
  // routes answer off the session, a backend that serves them IS the answer:
3327
3646
  // discovery, search and every per-result fetch run with PowerPoint closed.
@@ -3366,48 +3685,35 @@ async function cmdReady(rest) {
3366
3685
  return;
3367
3686
  }
3368
3687
  }
3369
- // --- Unbound + no window: ask the backend which designs this user may
3370
- // build against. One is the answer; several genuinely need a human choice,
3371
- // which --brand records. A backend without the route (or a deck already
3372
- // bound to a design the brand route just refused) falls through unchanged. ---
3373
- if (!connectOnly && !to && !deckPresFP && !boundRungTried) {
3688
+ // --- Unbound + no window: the ONE design cached here is the answer, else
3689
+ // the ONE design the backend lists; several of either genuinely need a
3690
+ // human choice, which --brand records. A backend without the brand route
3691
+ // (or a deck already bound to a design it just refused) falls through
3692
+ // unchanged — as does a connection with every backend down, where a
3693
+ // design menu would only hide the real blocker. ---
3694
+ if (!connectOnly && !to && !deckPresFP && !boundRungTried && !backendBlocker(pub)) {
3695
+ const cached = bundle.listCachedBundles();
3696
+ if (cached.length === 1) {
3697
+ const result = await materializeBrand(cached[0].presFP);
3698
+ if (result) {
3699
+ await finishWindowless(result);
3700
+ return;
3701
+ }
3702
+ }
3374
3703
  let brands = [];
3375
3704
  try {
3376
3705
  brands = await client.listBrands();
3377
3706
  } catch {
3378
3707
  /* no brand list — the window ladder below is the answer */
3379
3708
  }
3380
- if (brands.length === 1) {
3709
+ if (cached.length === 0 && brands.length === 1) {
3381
3710
  const result = await materializeBrand(brands[0].presFP);
3382
3711
  if (result) {
3383
3712
  await finishWindowless(result);
3384
3713
  return;
3385
3714
  }
3386
- } else if (brands.length > 1) {
3387
- const rows = brands.map(
3388
- (b) => ` ${b.presFP}${envTag(b.env)} ${b.name ?? ""}`.trimEnd(),
3389
- );
3390
- const blocker = {
3391
- code: "ambiguous_brand",
3392
- actor: "user",
3393
- message:
3394
- "Several corporate designs are available and no PowerPoint window says which — ask the user which to build against, then re-run: slidemind ready" +
3395
- `${deckSlug ? ` ${deckSlug}` : ""} --brand <presFP>.`,
3396
- };
3397
- finishBlocker({
3398
- blocker,
3399
- extras: {
3400
- brands: brands.map((b) => ({
3401
- presFP: b.presFP,
3402
- name: b.name ?? null,
3403
- env: b.env ?? null,
3404
- })),
3405
- },
3406
- humanLines:
3407
- "slidemind: several corporate designs are available — ask the user which to build against:\n" +
3408
- `${rows.join("\n")}\n` +
3409
- `Then re-run: slidemind ready${deckSlug ? ` ${deckSlug}` : ""} --brand <presFP>`,
3410
- });
3715
+ } else if (cached.length > 1 || brands.length > 1) {
3716
+ await finishAmbiguous();
3411
3717
  return;
3412
3718
  }
3413
3719
  }
@@ -3429,6 +3735,53 @@ async function cmdReady(rest) {
3429
3735
  };
3430
3736
  finishBlocker({ blocker: b });
3431
3737
  return;
3738
+ };
3739
+ // --- Zombie prune, BEFORE any resolution: a webview whose document was
3740
+ // closed keeps its socket — and thus a "connected" registry lease — alive
3741
+ // indefinitely, and only a host round-trip inside the pane can tell. Every
3742
+ // window claiming a LIVE pane gets one truth ping (in parallel); a dead one
3743
+ // leaves the registry, so it can neither win silently nor fake a second
3744
+ // design. Narrow on purpose: an explicit --to is the user's call, a
3745
+ // suspended window keeps its silent-adopt semantics (backgrounded
3746
+ // PowerPoint must not start asking questions), and a backend without
3747
+ // paneLivenessV1 fails open. ---
3748
+ const backendAdvertisesPing = (entry) =>
3749
+ (statusRaw.backends ?? []).some(
3750
+ (b) =>
3751
+ b.ok &&
3752
+ b.backendURL === entry.backendURL &&
3753
+ b.capabilities?.paneLivenessV1 === true,
3754
+ );
3755
+ const entryIsAlive = async (entry) => {
3756
+ try {
3757
+ const ack = await client.pingPresentation({
3758
+ presId: entry.presId,
3759
+ backendURL: entry.backendURL ?? null,
3760
+ });
3761
+ return ack?.ok !== false;
3762
+ } catch (err) {
3763
+ // Dead only on the typed pane verdicts; anything else (404 route on an
3764
+ // older backend, a transport blip) fails open — adopt as before.
3765
+ return ![
3766
+ "pane_suspended",
3767
+ "pane_timeout",
3768
+ "pane_disconnected",
3769
+ "bridge_timeout",
3770
+ ].includes(err?.code);
3771
+ }
3772
+ };
3773
+ if (!to) {
3774
+ const suspects = registry.filter(
3775
+ (p) => isRR(p) && client.isPaneReadyPresentation(p) && backendAdvertisesPing(p),
3776
+ );
3777
+ const alive = await Promise.all(suspects.map(entryIsAlive));
3778
+ const dead = new Set(suspects.filter((_, i) => !alive[i]));
3779
+ if (dead.size > 0) registry = registry.filter((p) => !dead.has(p));
3780
+ }
3781
+
3782
+ if (!registry.some(isRR)) {
3783
+ await finishNoRenderReady();
3784
+ return;
3432
3785
  }
3433
3786
 
3434
3787
  // --- --connect-only: the connection is up with ≥1 render-ready window; the
@@ -3460,11 +3813,34 @@ async function cmdReady(rest) {
3460
3813
  return;
3461
3814
  }
3462
3815
 
3463
- // --- Local: resolve WHICH design to bind against. ---
3464
- const resolution = client.resolveDesignSource(registry, {
3465
- explicitPresId: to,
3466
- deckPresFP,
3467
- });
3816
+ // --- Local: pick WHICH design. The order is the user's, not the window's:
3817
+ // an explicit --to, else the deck's own binding (a window on that design is
3818
+ // targeted, none at all readies windowless over the brand route), else the
3819
+ // ONE design the verified windows agree on. Anything else is a choice only
3820
+ // the user can make — the ambiguous blocker puts the menu in front of them. ---
3821
+ let resolution;
3822
+ if (to) {
3823
+ resolution = client.resolveDesignSource(registry, {
3824
+ explicitPresId: to,
3825
+ deckPresFP,
3826
+ });
3827
+ } else {
3828
+ const onDeckDesign = deckPresFP
3829
+ ? registry.filter((p) => isRR(p) && p.brandId === deckPresFP)
3830
+ : [];
3831
+ if (boundDeckLadder && onDeckDesign.length === 0) {
3832
+ boundRungTried = true;
3833
+ const result = await materializeBrand(deckPresFP);
3834
+ if (result) {
3835
+ await finishWindowless(result);
3836
+ return;
3837
+ }
3838
+ }
3839
+ resolution = client.resolveDesignSource(
3840
+ onDeckDesign.length > 0 ? onDeckDesign : registry,
3841
+ { deckPresFP },
3842
+ );
3843
+ }
3468
3844
 
3469
3845
  if (resolution.status === "none") {
3470
3846
  // Defensive: a render-ready window exists (poll passed) so this is rare.
@@ -3507,41 +3883,7 @@ async function cmdReady(rest) {
3507
3883
  }
3508
3884
 
3509
3885
  if (resolution.status === "ambiguous") {
3510
- const cand = resolution.candidates;
3511
- const blocker = {
3512
- code: "ambiguous_design",
3513
- actor: "user",
3514
- message:
3515
- "Multiple corporate designs are open — ask the user which to build against, then re-run: slidemind ready --to <presId>.",
3516
- };
3517
- const rows = cand.map((c) => {
3518
- const env = envTag(c.env) ? ` ${envTag(c.env)}` : "";
3519
- const label = c.title || c.fileName || "(untitled)";
3520
- return ` ${c.presId}${env} ${label} (design ${c.brandId ?? "?"})`;
3521
- });
3522
- let human =
3523
- "slidemind: multiple corporate designs are open — ask the user which to build against:\n" +
3524
- `${rows.join("\n")}\n`;
3525
- if (resolution.suggested) {
3526
- const s = cand.find((c) => c.presId === resolution.suggested);
3527
- const sLabel = s?.title || s?.fileName || "(untitled)";
3528
- human += `(this deck was last built against ${s?.brandId ?? deckPresFP} — ${sLabel})\n`;
3529
- }
3530
- human += "Then re-run: slidemind ready --to <presId>";
3531
- finishBlocker({
3532
- blocker,
3533
- extras: {
3534
- candidates: cand.map((c) => ({
3535
- presId: c.presId,
3536
- env: c.env ?? null,
3537
- title: c.title ?? null,
3538
- fileName: c.fileName ?? null,
3539
- presFP: c.brandId ?? null,
3540
- })),
3541
- suggested: resolution.suggested,
3542
- },
3543
- humanLines: human,
3544
- });
3886
+ await finishAmbiguous();
3545
3887
  return;
3546
3888
  }
3547
3889
 
@@ -3581,6 +3923,8 @@ async function cmdReady(rest) {
3581
3923
  presId: entry.presId,
3582
3924
  presFP: result.presFP,
3583
3925
  designName,
3926
+ designs: await designMenu({ current: result.presFP }),
3927
+ windows: publicWindows(),
3584
3928
  bundleDir: result.dir,
3585
3929
  bundleEtag: result.etag ?? null,
3586
3930
  presentation: {
@@ -3619,6 +3963,8 @@ async function cmdReady(rest) {
3619
3963
  ` bundle: ${result.dir} (etag ${result.etag ?? "?"})\n`,
3620
3964
  );
3621
3965
  if (bound) process.stdout.write(` deck ${deckSlug} bound\n`);
3966
+ process.stdout.write(windowsHumanLine(publicWindows()));
3967
+ process.stdout.write(designsHumanLines(await designMenu({ current: result.presFP })));
3622
3968
  if (paneSuspended) {
3623
3969
  process.stdout.write(
3624
3970
  engineInfoForWindow.available
@@ -4210,6 +4556,7 @@ async function cmdPush(rest) {
4210
4556
  if (!fs.existsSync(path.join(deckDir, "slides"))) {
4211
4557
  fail(`expected 'slides/' folder in ${deckDir}`);
4212
4558
  }
4559
+ await requireReviewApproved(deckDir);
4213
4560
 
4214
4561
  const mod = await import(
4215
4562
  pathToFileURL(path.join(SRC_DIR, "bridge", "build-and-push.mjs")).href
@@ -4246,6 +4593,7 @@ async function cmdPush(rest) {
4246
4593
 
4247
4594
  async function cmdNew(rest) {
4248
4595
  let to = null;
4596
+ let brand = null;
4249
4597
  let noBind = false;
4250
4598
  const positional = [];
4251
4599
  for (let i = 0; i < rest.length; i++) {
@@ -4253,6 +4601,9 @@ async function cmdNew(rest) {
4253
4601
  if (a === "--to") {
4254
4602
  to = rest[++i];
4255
4603
  if (!to) fail("--to expects a presId");
4604
+ } else if (a === "--brand") {
4605
+ brand = rest[++i];
4606
+ if (!brand) fail("--brand expects a design id (presFP)");
4256
4607
  } else if (a === "--no-bind") {
4257
4608
  noBind = true;
4258
4609
  } else if (a.startsWith("-")) {
@@ -4261,8 +4612,11 @@ async function cmdNew(rest) {
4261
4612
  positional.push(a);
4262
4613
  }
4263
4614
  }
4615
+ if (brand && to) {
4616
+ fail("--brand and --to are mutually exclusive (a design id or a window, not both)");
4617
+ }
4264
4618
  const name = positional[0];
4265
- if (!name) fail("usage: slidemind new <name>");
4619
+ if (!name) fail("usage: slidemind new <name> [--brand <presFP> | --to <presId>] [--no-bind]");
4266
4620
  if (!/^[a-z0-9][a-z0-9-]*$/i.test(name)) {
4267
4621
  fail(`deck name must be kebab-case alphanumerics, got '${name}'`);
4268
4622
  }
@@ -4293,6 +4647,23 @@ async function cmdNew(rest) {
4293
4647
  const cfg = await loadBridgeModule("config");
4294
4648
  if (cfg.readSessions().length === 0) return;
4295
4649
  let boundMsg = null;
4650
+ // --brand: the user picked a design (the Clarify question) — bind to THAT,
4651
+ // never to whatever window happens to be open. Cache-only, so it needs the
4652
+ // bundle `ready` already materialized; a miss names the command that does.
4653
+ if (brand) {
4654
+ const bundle = await loadBridgeModule("bundle");
4655
+ const meta = bundle.readMaterializedBundleMeta(brand);
4656
+ if (!meta?.presFP) {
4657
+ process.stdout.write(
4658
+ `not bound — design '${brand}' is not materialized here; run \`slidemind ready ${name} --brand ${brand}\`\n`,
4659
+ );
4660
+ return;
4661
+ }
4662
+ bundle.writeDeckBundleRef(deckDir, { presFP: meta.presFP, etag: meta.etag ?? null });
4663
+ const label = readDesignName(bundle.bundleDirForPresFP(meta.presFP)) ?? meta.presFP;
4664
+ process.stdout.write(`bound to design ${label}\n`);
4665
+ return;
4666
+ }
4296
4667
  try {
4297
4668
  const client = await import(
4298
4669
  pathToFileURL(path.join(SRC_DIR, "bridge", "client.mjs")).href
@@ -4304,9 +4675,18 @@ async function cmdNew(rest) {
4304
4675
  const resolution = client.resolveDesignSource(presentations, {
4305
4676
  explicitPresId: to,
4306
4677
  });
4307
- if (resolution.status === "resolved") {
4308
- const entry = resolution.entry;
4309
- const meta = bundle.readMaterializedBundleMeta(entry.brandId);
4678
+ // The same order as ready: the one design open, else the one design
4679
+ // cached here. (No liveness ping — a zombie's false ambiguity just leaves
4680
+ // the deck unbound, and `ready <name>` prunes it.)
4681
+ const cached = bundle.listCachedBundles();
4682
+ const presFP =
4683
+ resolution.status === "resolved"
4684
+ ? resolution.entry.brandId
4685
+ : resolution.status === "none" && !to && cached.length === 1
4686
+ ? cached[0].presFP
4687
+ : null;
4688
+ if (presFP) {
4689
+ const meta = bundle.readMaterializedBundleMeta(presFP);
4310
4690
  if (meta?.presFP) {
4311
4691
  bundle.writeDeckBundleRef(deckDir, {
4312
4692
  presFP: meta.presFP,
@@ -4323,7 +4703,7 @@ async function cmdNew(rest) {
4323
4703
  process.stdout.write(
4324
4704
  boundMsg
4325
4705
  ? `${boundMsg}\n`
4326
- : `not bound to a design yet — run \`slidemind ready ${name}\` when a window is open\n`,
4706
+ : `not bound to a design yet — run \`slidemind ready ${name}\`\n`,
4327
4707
  );
4328
4708
  }
4329
4709
 
@@ -4630,6 +5010,27 @@ function scrubOutputStreams() {
4630
5010
 
4631
5011
  async function main(rawArgv) {
4632
5012
  scrubOutputStreams();
5013
+ // Simulation-only env bootstrap (scripts/codex-sim.mjs): codex's env
5014
+ // passthrough to spawned shells proved NONDETERMINISTIC per session, and a
5015
+ // session that misses SLIDEMIND_GATE_AUTOAPPROVE runs the plan/review gates
5016
+ // INTERACTIVELY — browser windows on the operator's machine and blocking
5017
+ // waits with no timeout. So the sim runner also drops `.slidemind-sim.json` into
5018
+ // each session workdir (= the CWD of every command the session runs), and
5019
+ // the CLI fills the env from it itself. Real users never have this file;
5020
+ // explicit env always wins.
5021
+ try {
5022
+ const sim = JSON.parse(fs.readFileSync(".slidemind-sim.json", "utf8"));
5023
+ for (const [k, v] of Object.entries(sim.env ?? {})) {
5024
+ // !process.env[k], not === undefined: codex has been observed delivering
5025
+ // an injected var as an EMPTY string, which would win over the marker
5026
+ // and send the gate down the interactive path anyway.
5027
+ if (/^SLIDEMIND_[A-Z_]+$/.test(k) && !process.env[k]) {
5028
+ process.env[k] = String(v);
5029
+ }
5030
+ }
5031
+ } catch {
5032
+ // no sim marker — the normal case
5033
+ }
4633
5034
  // A claim pasted WITHOUT the `setup` verb never reaches `decodeSetupClaim`,
4634
5035
  // so nothing would register it — and `unknown command: <argv>` would print it
4635
5036
  // whole. Registering straight off argv closes that without the error message