slidemind 0.3.5 → 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/.claude-plugin/plugin.json +1 -1
- package/.codex-plugin/plugin.json +1 -1
- package/AGENTS.md +5 -5
- package/bin/slidemind.mjs +520 -182
- package/package.json +1 -1
- package/skills/create-slides/SKILL.md +263 -104
- package/skills/search/SKILL.md +9 -5
- package/src/bridge/build-and-push.mjs +3 -3
- package/src/bridge/bundle.mjs +91 -36
- package/src/bridge/capture.mjs +34 -0
- package/src/bridge/client.mjs +59 -23
- package/src/bridge/config.mjs +21 -2
- package/src/bridge/engine-download.mjs +26 -7
- package/src/bridge/recolor.mjs +46 -0
- package/src/bridge/setup-claim.mjs +7 -1
- package/src/builders/build.mjs +179 -5
- package/src/builders/gate-server.mjs +25 -36
- package/src/builders/gate-session.js +28 -48
- package/src/builders/lint-fix.mjs +88 -0
- package/src/builders/plan-overlay.js +507 -136
- package/src/builders/plan.mjs +337 -60
- package/src/builders/review-overlay.js +126 -109
- package/src/builders/review.mjs +118 -64
- package/src/builders/storyboard-markdown.mjs +15 -0
- package/src/builders/storyboard-schema.mjs +23 -6
- package/src/builders/visual-families.mjs +20 -4
- package/src/lib/adopt.mjs +10 -6
- package/src/lib/conversions.mjs +26 -6
- package/src/lib/overwrite-guard.mjs +14 -1
- package/src/lib/reconcile.mjs +15 -4
- package/src/pptx/tags.mjs +49 -0
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
|
-
|
|
324
|
-
|
|
325
|
-
|
|
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
|
-
|
|
342
|
-
|
|
343
|
-
|
|
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]
|
|
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)}
|
|
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
|
|
1110
|
-
//
|
|
1111
|
-
//
|
|
1112
|
-
//
|
|
1113
|
-
//
|
|
1114
|
-
// it
|
|
1115
|
-
|
|
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
|
|
1331
|
-
//
|
|
1332
|
-
//
|
|
1333
|
-
|
|
1334
|
-
|
|
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
|
|
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
|
|
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))
|
|
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
|
-
//
|
|
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])
|
|
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:
|
|
3375
|
-
//
|
|
3376
|
-
// which --brand records. A backend without the route
|
|
3377
|
-
// bound to a design
|
|
3378
|
-
|
|
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
|
-
|
|
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:
|
|
3473
|
-
|
|
3474
|
-
|
|
3475
|
-
|
|
3476
|
-
|
|
3477
|
-
|
|
3478
|
-
|
|
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
|
-
|
|
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
|
-
|
|
4371
|
-
|
|
4372
|
-
|
|
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}
|
|
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
|