slidemind 0.3.5 → 0.3.13

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
@@ -3371,48 +3685,35 @@ async function cmdReady(rest) {
3371
3685
  return;
3372
3686
  }
3373
3687
  }
3374
- // --- Unbound + no window: ask the backend which designs this user may
3375
- // build against. One is the answer; several genuinely need a human choice,
3376
- // which --brand records. A backend without the route (or a deck already
3377
- // bound to a design the brand route just refused) falls through unchanged. ---
3378
- 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
+ }
3379
3703
  let brands = [];
3380
3704
  try {
3381
3705
  brands = await client.listBrands();
3382
3706
  } catch {
3383
3707
  /* no brand list — the window ladder below is the answer */
3384
3708
  }
3385
- if (brands.length === 1) {
3709
+ if (cached.length === 0 && brands.length === 1) {
3386
3710
  const result = await materializeBrand(brands[0].presFP);
3387
3711
  if (result) {
3388
3712
  await finishWindowless(result);
3389
3713
  return;
3390
3714
  }
3391
- } else if (brands.length > 1) {
3392
- const rows = brands.map(
3393
- (b) => ` ${b.presFP}${envTag(b.env)} ${b.name ?? ""}`.trimEnd(),
3394
- );
3395
- const blocker = {
3396
- code: "ambiguous_brand",
3397
- actor: "user",
3398
- message:
3399
- "Several corporate designs are available and no PowerPoint window says which — ask the user which to build against, then re-run: slidemind ready" +
3400
- `${deckSlug ? ` ${deckSlug}` : ""} --brand <presFP>.`,
3401
- };
3402
- finishBlocker({
3403
- blocker,
3404
- extras: {
3405
- brands: brands.map((b) => ({
3406
- presFP: b.presFP,
3407
- name: b.name ?? null,
3408
- env: b.env ?? null,
3409
- })),
3410
- },
3411
- humanLines:
3412
- "slidemind: several corporate designs are available — ask the user which to build against:\n" +
3413
- `${rows.join("\n")}\n` +
3414
- `Then re-run: slidemind ready${deckSlug ? ` ${deckSlug}` : ""} --brand <presFP>`,
3415
- });
3715
+ } else if (cached.length > 1 || brands.length > 1) {
3716
+ await finishAmbiguous();
3416
3717
  return;
3417
3718
  }
3418
3719
  }
@@ -3435,6 +3736,49 @@ async function cmdReady(rest) {
3435
3736
  finishBlocker({ blocker: b });
3436
3737
  return;
3437
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
+
3438
3782
  if (!registry.some(isRR)) {
3439
3783
  await finishNoRenderReady();
3440
3784
  return;
@@ -3469,64 +3813,33 @@ async function cmdReady(rest) {
3469
3813
  return;
3470
3814
  }
3471
3815
 
3472
- // --- Local: resolve WHICH design to bind against. ---
3473
- let resolution = client.resolveDesignSource(registry, {
3474
- explicitPresId: to,
3475
- deckPresFP,
3476
- });
3477
-
3478
- // --- Zombie check: an implicitly adopted window that claims a LIVE pane gets
3479
- // one truth ping before its design silently wins. A webview whose document
3480
- // was closed keeps its socket — and thus a "connected" registry lease —
3481
- // alive indefinitely, and only a host round-trip inside the pane can tell.
3482
- // Narrow on purpose: an explicit --to is the user's call, a suspended window
3483
- // keeps today's silent-adopt semantics (backgrounded PowerPoint must not
3484
- // start asking questions), and a backend without paneLivenessV1 fails open.
3485
- // A dead window is pruned and resolution re-runs on the remainder; pruning
3486
- // the last one lands on the same rungs as an empty registry (bound deck →
3487
- // brand list → blocker). ---
3488
- const backendAdvertisesPing = (entry) =>
3489
- (statusRaw.backends ?? []).some(
3490
- (b) =>
3491
- b.ok &&
3492
- b.backendURL === entry.backendURL &&
3493
- b.capabilities?.paneLivenessV1 === true,
3494
- );
3495
- const entryIsAlive = async (entry) => {
3496
- try {
3497
- const ack = await client.pingPresentation({
3498
- presId: entry.presId,
3499
- backendURL: entry.backendURL ?? null,
3500
- });
3501
- return ack?.ok !== false;
3502
- } catch (err) {
3503
- // Dead only on the typed pane verdicts; anything else (404 route on an
3504
- // older backend, a transport blip) fails open — adopt as before.
3505
- return ![
3506
- "pane_suspended",
3507
- "pane_timeout",
3508
- "pane_disconnected",
3509
- "bridge_timeout",
3510
- ].includes(err?.code);
3511
- }
3512
- };
3513
- while (
3514
- !to &&
3515
- resolution.status === "resolved" &&
3516
- client.isPaneReadyPresentation(resolution.entry) &&
3517
- backendAdvertisesPing(resolution.entry)
3518
- ) {
3519
- if (await entryIsAlive(resolution.entry)) break;
3520
- const deadId = resolution.entry.presId;
3521
- registry = registry.filter((p) => p.presId !== deadId);
3522
- if (!registry.some(isRR)) {
3523
- await finishNoRenderReady();
3524
- return;
3525
- }
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) {
3526
3823
  resolution = client.resolveDesignSource(registry, {
3527
3824
  explicitPresId: to,
3528
3825
  deckPresFP,
3529
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
+ );
3530
3843
  }
3531
3844
 
3532
3845
  if (resolution.status === "none") {
@@ -3570,41 +3883,7 @@ async function cmdReady(rest) {
3570
3883
  }
3571
3884
 
3572
3885
  if (resolution.status === "ambiguous") {
3573
- const cand = resolution.candidates;
3574
- const blocker = {
3575
- code: "ambiguous_design",
3576
- actor: "user",
3577
- message:
3578
- "Multiple corporate designs are open — ask the user which to build against, then re-run: slidemind ready --to <presId>.",
3579
- };
3580
- const rows = cand.map((c) => {
3581
- const env = envTag(c.env) ? ` ${envTag(c.env)}` : "";
3582
- const label = c.title || c.fileName || "(untitled)";
3583
- return ` ${c.presId}${env} ${label} (design ${c.brandId ?? "?"})`;
3584
- });
3585
- let human =
3586
- "slidemind: multiple corporate designs are open — ask the user which to build against:\n" +
3587
- `${rows.join("\n")}\n`;
3588
- if (resolution.suggested) {
3589
- const s = cand.find((c) => c.presId === resolution.suggested);
3590
- const sLabel = s?.title || s?.fileName || "(untitled)";
3591
- human += `(this deck was last built against ${s?.brandId ?? deckPresFP} — ${sLabel})\n`;
3592
- }
3593
- human += "Then re-run: slidemind ready --to <presId>";
3594
- finishBlocker({
3595
- blocker,
3596
- extras: {
3597
- candidates: cand.map((c) => ({
3598
- presId: c.presId,
3599
- env: c.env ?? null,
3600
- title: c.title ?? null,
3601
- fileName: c.fileName ?? null,
3602
- presFP: c.brandId ?? null,
3603
- })),
3604
- suggested: resolution.suggested,
3605
- },
3606
- humanLines: human,
3607
- });
3886
+ await finishAmbiguous();
3608
3887
  return;
3609
3888
  }
3610
3889
 
@@ -3644,6 +3923,8 @@ async function cmdReady(rest) {
3644
3923
  presId: entry.presId,
3645
3924
  presFP: result.presFP,
3646
3925
  designName,
3926
+ designs: await designMenu({ current: result.presFP }),
3927
+ windows: publicWindows(),
3647
3928
  bundleDir: result.dir,
3648
3929
  bundleEtag: result.etag ?? null,
3649
3930
  presentation: {
@@ -3682,6 +3963,8 @@ async function cmdReady(rest) {
3682
3963
  ` bundle: ${result.dir} (etag ${result.etag ?? "?"})\n`,
3683
3964
  );
3684
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 })));
3685
3968
  if (paneSuspended) {
3686
3969
  process.stdout.write(
3687
3970
  engineInfoForWindow.available
@@ -4273,6 +4556,7 @@ async function cmdPush(rest) {
4273
4556
  if (!fs.existsSync(path.join(deckDir, "slides"))) {
4274
4557
  fail(`expected 'slides/' folder in ${deckDir}`);
4275
4558
  }
4559
+ await requireReviewApproved(deckDir);
4276
4560
 
4277
4561
  const mod = await import(
4278
4562
  pathToFileURL(path.join(SRC_DIR, "bridge", "build-and-push.mjs")).href
@@ -4309,6 +4593,7 @@ async function cmdPush(rest) {
4309
4593
 
4310
4594
  async function cmdNew(rest) {
4311
4595
  let to = null;
4596
+ let brand = null;
4312
4597
  let noBind = false;
4313
4598
  const positional = [];
4314
4599
  for (let i = 0; i < rest.length; i++) {
@@ -4316,6 +4601,9 @@ async function cmdNew(rest) {
4316
4601
  if (a === "--to") {
4317
4602
  to = rest[++i];
4318
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)");
4319
4607
  } else if (a === "--no-bind") {
4320
4608
  noBind = true;
4321
4609
  } else if (a.startsWith("-")) {
@@ -4324,8 +4612,11 @@ async function cmdNew(rest) {
4324
4612
  positional.push(a);
4325
4613
  }
4326
4614
  }
4615
+ if (brand && to) {
4616
+ fail("--brand and --to are mutually exclusive (a design id or a window, not both)");
4617
+ }
4327
4618
  const name = positional[0];
4328
- if (!name) fail("usage: slidemind new <name>");
4619
+ if (!name) fail("usage: slidemind new <name> [--brand <presFP> | --to <presId>] [--no-bind]");
4329
4620
  if (!/^[a-z0-9][a-z0-9-]*$/i.test(name)) {
4330
4621
  fail(`deck name must be kebab-case alphanumerics, got '${name}'`);
4331
4622
  }
@@ -4356,6 +4647,23 @@ async function cmdNew(rest) {
4356
4647
  const cfg = await loadBridgeModule("config");
4357
4648
  if (cfg.readSessions().length === 0) return;
4358
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
+ }
4359
4667
  try {
4360
4668
  const client = await import(
4361
4669
  pathToFileURL(path.join(SRC_DIR, "bridge", "client.mjs")).href
@@ -4367,9 +4675,18 @@ async function cmdNew(rest) {
4367
4675
  const resolution = client.resolveDesignSource(presentations, {
4368
4676
  explicitPresId: to,
4369
4677
  });
4370
- if (resolution.status === "resolved") {
4371
- const entry = resolution.entry;
4372
- 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);
4373
4690
  if (meta?.presFP) {
4374
4691
  bundle.writeDeckBundleRef(deckDir, {
4375
4692
  presFP: meta.presFP,
@@ -4386,7 +4703,7 @@ async function cmdNew(rest) {
4386
4703
  process.stdout.write(
4387
4704
  boundMsg
4388
4705
  ? `${boundMsg}\n`
4389
- : `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`,
4390
4707
  );
4391
4708
  }
4392
4709
 
@@ -4693,6 +5010,27 @@ function scrubOutputStreams() {
4693
5010
 
4694
5011
  async function main(rawArgv) {
4695
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
+ }
4696
5034
  // A claim pasted WITHOUT the `setup` verb never reaches `decodeSetupClaim`,
4697
5035
  // so nothing would register it — and `unknown command: <argv>` would print it
4698
5036
  // whole. Registering straight off argv closes that without the error message