slidemind 0.3.4 → 0.3.12

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -18,17 +18,12 @@
18
18
  // - comments -> the user's slide-anchored comments (the agent revises
19
19
  // plan.md to address them and re-runs).
20
20
  //
21
- // ASYNC REVIEW (the drain loop). A comments round no longer freezes the tab:
22
- // once the decision is sent the page enters WORKING mode — still readable and
23
- // navigable, composer still live — and further comments queue in the tab's
24
- // sessionStorage. When the agent re-runs this command the waiting tab reloads
25
- // (heartbeat claim) and its boot script POSTs that backlog to
26
- // /plan-review/backlog. A non-empty backlog resolves the gate AT ONCE with
27
- // those comments (a "drain round": the command prints DECISION: comments and
28
- // exits without ever parking), so several consecutive comment rounds are the
29
- // normal shape of a review. An empty backlog never POSTs and the run parks and
30
- // blocks, exactly as before. Approve is refused once a backlog arrived in this
31
- // run — that round is already superseded.
21
+ // ROUNDS ARE SYNCHRONOUS (add-in parity). Send resolves the gate with the
22
+ // comment and the command exits with DECISION: comments; the tab then sits in
23
+ // WORKING mode — readable and navigable, composer and Approve off — until the
24
+ // agent's next `slidemind plan` run reloads it with the revised plan. Nothing is
25
+ // queued behind the agent's back; several consecutive comment rounds are the
26
+ // normal shape of a review.
32
27
  //
33
28
  // Host-free: the plan gate runs BEFORE any slide exists, so it needs no bundle
34
29
  // and no open host. A per-brand orb PNG is used when a bundle happens to be
@@ -57,6 +52,7 @@ import path from "node:path";
57
52
  import { fileURLToPath, pathToFileURL } from "node:url";
58
53
 
59
54
  import { openInBrowser } from "./build.mjs";
55
+ import { loadReviewState } from "./review.mjs";
60
56
  import { renderPlanMarkdown } from "./plan-md.mjs";
61
57
  import { splitPlanFrontmatter } from "./resolve-skill.mjs";
62
58
  import {
@@ -67,7 +63,6 @@ import {
67
63
  createHeartbeat,
68
64
  bundleGateSession,
69
65
  normalizeGateComments,
70
- serveBacklogPost,
71
66
  serveDecisionPost,
72
67
  stripModuleSyntax,
73
68
  listenWithFallback,
@@ -79,7 +74,7 @@ import {
79
74
  POLL_MS,
80
75
  POLL_TTL_MS,
81
76
  } from "./gate-server.mjs";
82
- import { resolveBrandAsset } from "../bridge/bundle.mjs";
77
+ import { resolveBrandAsset, resolveBundleDirForDeck } from "../bridge/bundle.mjs";
83
78
 
84
79
  const __filename = fileURLToPath(import.meta.url);
85
80
  const __dirname = path.dirname(__filename);
@@ -94,7 +89,12 @@ const STORYBOARD_MARKDOWN_PATH = path.join(__dirname, "storyboard-markdown.mjs")
94
89
  const RED_THREAD_BACKBONE_PATH = path.join(__dirname, "red-thread-backbone.mjs");
95
90
  const PLAN_MARKDOWN_EDIT_PATH = path.join(__dirname, "plan-markdown-edit.mjs");
96
91
  const PLAN_STATE_FILE = "plan-review.json";
97
- const DEFAULT_TIMEOUT_MS = 30 * 60 * 1000;
92
+ // No decision timeout: the gate returns only on the user's decision (or when
93
+ // no browser could be opened at all — see openFailedGraceMs). An agent cannot
94
+ // proceed past a plan a human has not approved, so the process waits as long as
95
+ // the human does; a harness that backgrounds the call gets the decision when it
96
+ // lands. Pass timeoutMs explicitly to cap the wait (tests).
97
+ const DEFAULT_TIMEOUT_MS = null;
98
98
 
99
99
  // Escape a </script> sequence so an inlined JSON/JS blob can't break out of the
100
100
  // surrounding <script> tag (copied from review.mjs — that module is off-limits
@@ -158,6 +158,39 @@ export function savePlanState(deckDir, state) {
158
158
  );
159
159
  }
160
160
 
161
+ // The human checkmark every post-plan command (concepts, build, view-draft,
162
+ // review) requires — enforced by the CLI, not by the skill's prose. A deck
163
+ // without plan.md is the in-chat single-slide path and passes. Otherwise
164
+ // null = approved and unchanged since (planHash is the one persisted with the
165
+ // decision); else the agent-facing reason plus the command that clears it.
166
+ //
167
+ // The gate binds only until the deck has passed a review: from then on the plan
168
+ // has been executed and plan.md is a record, not a contract — the add-in's
169
+ // manager likewise hands an edit delegation `approvedPlan: null` and never
170
+ // re-gates the old plan. Edits to existing slides are the review gate's job
171
+ // (it re-arms on every slide change); the plan rule for slides added to a
172
+ // reviewed deck stays the skill's, as it is in-app.
173
+ export function requirePlanApproved(deckDir) {
174
+ const planPath = path.join(deckDir, "plan.md");
175
+ if (!fs.existsSync(planPath)) return null;
176
+ if (loadReviewState(deckDir).decision === "approve") return null;
177
+ const base = path.basename(deckDir);
178
+ const state = loadPlanState(deckDir);
179
+ if (state.decision !== "approve") {
180
+ return (
181
+ `${base}'s plan is not signed off — a human must approve it first: ` +
182
+ `run \`slidemind plan ${base}\` and wait for DECISION: approved.`
183
+ );
184
+ }
185
+ if (state.planHash !== hashPlan(planPath)) {
186
+ return (
187
+ `${base}/plan.md changed since the user approved it — ` +
188
+ `re-run \`slidemind plan ${base}\` for a fresh sign-off.`
189
+ );
190
+ }
191
+ return null;
192
+ }
193
+
161
194
  // ---------------------------------------------------------------------------
162
195
  // harness output
163
196
  // ---------------------------------------------------------------------------
@@ -177,26 +210,15 @@ function commentBullet(c) {
177
210
  // Render the comments round for the harness — the plan analog of review.mjs's
178
211
  // formatCommentsForHarness, on slide-anchored comments instead of spatial pins.
179
212
  //
180
- // The tail is deliberately imperative: the reviewer's tab is LIVE while the
181
- // agent revises (they can keep reading and keep queueing comments), and those
182
- // queued comments are only delivered by the next `slidemind plan` run. An agent
183
- // that ends its turn here strands them, so the re-run instruction says "same
184
- // turn" and says why more comments may follow.
185
- export function formatPlanCommentsForHarness(
186
- deckDir,
187
- comments,
188
- { edited = false, drained = false } = {},
189
- ) {
213
+ // The tail is deliberately imperative: the reviewer's tab is waiting on the
214
+ // next `slidemind plan` run — nothing reaches them until it runs. An agent that
215
+ // ends its turn here strands them, so the re-run instruction says "same turn"
216
+ // and says why more comment rounds may follow.
217
+ export function formatPlanCommentsForHarness(deckDir, comments, { edited = false } = {}) {
190
218
  const rows = normalizePlanComments(comments);
191
219
  const out = [];
192
220
  out.push("DECISION: comments");
193
221
  out.push("The user requested changes to the plan. Nothing was authored.");
194
- if (drained) {
195
- out.push(
196
- "(These comments were queued while you were revising and arrived with this " +
197
- "run — that is why the gate returned immediately instead of waiting.)",
198
- );
199
- }
200
222
  if (edited) {
201
223
  out.push(
202
224
  "The user also edited plan.md directly — re-read it before revising; the " +
@@ -213,9 +235,8 @@ export function formatPlanCommentsForHarness(
213
235
  out.push("Revise plan.md to address ALL comments — change only what the feedback");
214
236
  out.push("requires; keep unaffected slides verbatim.");
215
237
  out.push(`Then run \`slidemind plan ${path.basename(deckDir)}\` again in the same turn.`);
216
- out.push("The reviewer may queue further comments while you revise; they are");
217
- out.push("delivered on that next run (it can return immediately with them — that is");
218
- out.push("normal, not an error). Do NOT end your turn and do NOT send the user back");
238
+ out.push("The reviewer then reviews the revised plan — several comment rounds are");
239
+ out.push("normal, not an error. Do NOT end your turn and do NOT send the user back");
219
240
  out.push("to the browser before you re-run the gate.");
220
241
  out.push("");
221
242
  // Recovery pointer in the tail (truncated captures usually lose the TOP):
@@ -232,11 +253,13 @@ export function formatPlanCommentsForHarness(
232
253
  // state (the user stepped away), not a failure — the agent must neither treat
233
254
  // it as approval nor abort the task; it wraps up with clear resume instructions.
234
255
  export function formatPlanPendingForHarness(deckDir, { timeoutMs, pageLoaded, url } = {}) {
235
- const mins = Math.max(1, Math.round((timeoutMs ?? DEFAULT_TIMEOUT_MS) / 60000));
236
256
  const base = path.basename(deckDir);
257
+ const waited = timeoutMs
258
+ ? `the gate waited ${Math.max(1, Math.round(timeoutMs / 60000))} minutes and the user hasn't answered.`
259
+ : "the gate closed without an answer from the user.";
237
260
  return [
238
261
  "DECISION: pending",
239
- `No decision yet — the gate waited ${mins} minutes and the user hasn't answered.`,
262
+ `No decision yet — ${waited}`,
240
263
  ...pendingBrowserLines({ pageLoaded, url }),
241
264
  "This is NOT an approval and NOT a rejection. Do not author anything.",
242
265
  "Close out your turn gracefully:",
@@ -262,6 +285,7 @@ const PLAN_NAMESPACE_EXPORTS = [
262
285
  "replaceRenderedMarkdownBlock",
263
286
  "splitPlanMarkdownIntoSegments",
264
287
  "replaceStoryboardSegment",
288
+ "setStoryboardLayoutForAll",
265
289
  "extractRedThreadMessages",
266
290
  "findRedThreadSection",
267
291
  "parseStoryboardCanvas",
@@ -278,6 +302,7 @@ const PLAN_NAMESPACE_EXPORTS = [
278
302
  "MAX_BLOCKS",
279
303
  "MAX_DESIGN_ELEMENT_LENGTH",
280
304
  "MAX_CONTENT_LENGTH",
305
+ "MAX_IMAGE_PROMPT_LENGTH",
281
306
  ];
282
307
 
283
308
  // stripModuleSyntax now lives in gate-server.mjs (both gates bundle browser
@@ -358,6 +383,215 @@ display:block;overflow-x:auto;}
358
383
  #plan-root a{color:#a9c2ff;}
359
384
  `;
360
385
 
386
+ // ---------------------------------------------------------------------------
387
+ // layout picker data
388
+ // ---------------------------------------------------------------------------
389
+
390
+ const CHROME_ASSET_RE = /^chrome-[\w-]+\.(png|webp|jpe?g)$/i;
391
+
392
+ // Readable chip per OOXML placeholder type; null = meta chrome drawn as an
393
+ // unlabeled faint outline (footer/date/page number chips would only add noise).
394
+ const PH_OUTLINE_LABELS = {
395
+ title: "Title",
396
+ ctrTitle: "Title",
397
+ subTitle: "Subtitle",
398
+ body: "Body",
399
+ obj: "Body",
400
+ pic: "Image",
401
+ media: "Image",
402
+ chart: "Chart",
403
+ tbl: "Table",
404
+ dt: null,
405
+ ftr: null,
406
+ sldNum: null,
407
+ };
408
+
409
+ // Chrome placeholder outlines for one layout, host-free: prop names + types
410
+ // from the JSX's `// PLACEHOLDERS:` metadata line, geometry from the generated
411
+ // layouts.css positional rules (`.layout-<slug> .slide__<prop> { left: Npx; … }`
412
+ // on the canonical 1280×720 stage). Best-effort: any parse miss just drops that
413
+ // outline.
414
+ export function parsePlaceholderOutlines(source, layoutsCss) {
415
+ if (!layoutsCss) return [];
416
+ const slugMatch = /className="slide(?:\s+slide--dark)?\s+(layout-[a-z0-9-]+)"/.exec(source);
417
+ const metaMatch = /^\s*\/\/ PLACEHOLDERS: (\{.*\})\s*$/m.exec(source);
418
+ if (!slugMatch || !metaMatch) return [];
419
+ let chrome;
420
+ try {
421
+ chrome = JSON.parse(metaMatch[1]).chrome;
422
+ } catch {
423
+ return [];
424
+ }
425
+ if (!Array.isArray(chrome)) return [];
426
+ const outlines = [];
427
+ for (const entry of chrome) {
428
+ if (!entry || typeof entry.prop !== "string") continue;
429
+ const kebab = entry.prop.replace(/([A-Z])/g, "-$1").toLowerCase();
430
+ const rule = new RegExp(
431
+ "\\." + slugMatch[1] + "\\s+\\.slide__" + kebab + "\\s*\\{([^}]*)\\}",
432
+ ).exec(layoutsCss);
433
+ if (!rule) continue;
434
+ const px = (name) => {
435
+ const m = new RegExp(name + ":\\s*(-?\\d+(?:\\.\\d+)?)px").exec(rule[1]);
436
+ return m ? Number(m[1]) : null;
437
+ };
438
+ const left = px("left");
439
+ const top = px("top");
440
+ const width = px("width");
441
+ const height = px("height");
442
+ if (left == null || top == null || !width || !height) continue;
443
+ const b = { x: left / 1280, y: top / 720, w: width / 1280, h: height / 720 };
444
+ const sane = (v) => Number.isFinite(v) && v >= -0.05 && v <= 1.05;
445
+ if (!(sane(b.x) && sane(b.y) && sane(b.w) && sane(b.h))) continue;
446
+ outlines.push({
447
+ label:
448
+ typeof entry.type === "string" && entry.type in PH_OUTLINE_LABELS
449
+ ? PH_OUTLINE_LABELS[entry.type]
450
+ : null,
451
+ // Image slots carry their codegen prop name (`image`, `image2`, …) so the
452
+ // storyboard can author a per-slot `imagePrompts` entry under that key.
453
+ ...(entry.type === "pic" || entry.type === "media" ? { key: entry.prop } : {}),
454
+ x: b.x,
455
+ y: b.y,
456
+ w: b.w,
457
+ h: b.h,
458
+ });
459
+ }
460
+ return outlines;
461
+ }
462
+
463
+ // The per-slide layout picker's option list, resolved host-free from the
464
+ // deck's materialized bundle: componentName from `layouts/<Name>.jsx`, the
465
+ // canvas line from the codegen's `// [CONTENT: …]` annotation (present only on
466
+ // workhorse layouts), the purpose from design.md's Layout Catalog, and the
467
+ // chrome-render thumbnail from `assets/chrome-<Name>.*` served at
468
+ // `/brand/chrome/<file>`. No bundle → `{ layouts: [], chromeDir: null }` and
469
+ // the picker degrades to read-only (the host-free contract holds).
470
+ export function resolvePlanLayouts(deckDir) {
471
+ const dir = resolveBundleDirForDeck(deckDir);
472
+ if (!dir) return { layouts: [], chromeDir: null };
473
+ const layoutsDir = path.join(dir, "layouts");
474
+ const assetsDir = path.join(dir, "assets");
475
+ let files;
476
+ try {
477
+ files = fs.readdirSync(layoutsDir).filter((f) => f.endsWith(".jsx"));
478
+ } catch {
479
+ return { layouts: [], chromeDir: null };
480
+ }
481
+
482
+ // design.md Layout Catalog → `### <Name>` + `- Purpose: …` + `- Content
483
+ // areas: 1. …; 2. …` (or `none (chrome-only)`). Line scan; catalog entries
484
+ // are the only place these bullets follow a ### heading in the generated
485
+ // design.md.
486
+ const purposes = new Map();
487
+ const canvases = new Map();
488
+ try {
489
+ const md = fs.readFileSync(path.join(dir, "design.md"), "utf8");
490
+ let current = null;
491
+ for (const line of md.split(/\r?\n/)) {
492
+ const heading = /^###\s+(\S+)\s*$/.exec(line);
493
+ if (heading) {
494
+ current = heading[1];
495
+ continue;
496
+ }
497
+ if (!current) continue;
498
+ const purpose = /^- Purpose:\s*(.+)$/.exec(line);
499
+ if (purpose) purposes.set(current, purpose[1].trim());
500
+ // Newer add-ins write `- Content areas:`; the deployed one still writes
501
+ // `- Canvas:` — a bundle from either must feed the picker.
502
+ const canvas = /^- (?:Content areas|Canvas):\s*(.+)$/.exec(line);
503
+ if (canvas) canvases.set(current, canvas[1].trim());
504
+ }
505
+ } catch {
506
+ /* no design.md — names + annotations still make a usable picker */
507
+ }
508
+
509
+ let chromeFiles = [];
510
+ try {
511
+ chromeFiles = fs.readdirSync(assetsDir).filter((f) => CHROME_ASSET_RE.test(f));
512
+ } catch {
513
+ /* no assets dir */
514
+ }
515
+ let layoutsCss = "";
516
+ try {
517
+ layoutsCss = fs.readFileSync(path.join(layoutsDir, "layouts.css"), "utf8");
518
+ } catch {
519
+ /* no positional CSS — placeholder outlines just stay empty */
520
+ }
521
+ const chromeByComponent = new Map();
522
+ for (const file of chromeFiles) {
523
+ chromeByComponent.set(file.replace(/^chrome-/, "").replace(/\.[^.]+$/, ""), file);
524
+ }
525
+
526
+ const layouts = [];
527
+ for (const file of files) {
528
+ const componentName = file.replace(/\.jsx$/, "");
529
+ let source = "";
530
+ try {
531
+ source = fs.readFileSync(path.join(layoutsDir, file), "utf8");
532
+ } catch {
533
+ continue;
534
+ }
535
+ // The `[CONTENT: …]` annotation is emitted only above a `children` prop —
536
+ // its presence IS the workhorse test, its text the canvas line, and its
537
+ // numbers (px on the canonical 1280×720 stage) normalize into the
538
+ // content-zone rectangles the storyboard frame draws. The head is the
539
+ // union bbox; a multi-area layout appends `Allowed regions
540
+ // (content-relative): 1: W×H px @ (x, y); …` — the gaps between those
541
+ // regions are not writable, so each becomes its own rectangle.
542
+ const content = /\[CONTENT:\s*([^\]]+)\]/.exec(source);
543
+ const annotated = content ? content[1].trim() : null;
544
+ const canvas = canvases.get(componentName) ?? annotated;
545
+ let canvasBounds = null;
546
+ let contentAreas = [];
547
+ const nums = content
548
+ ? /^(\d+(?:\.\d+)?)\s*[×x]\s*(\d+(?:\.\d+)?)\s*px\s*@\s*\((\d+(?:\.\d+)?),\s*(\d+(?:\.\d+)?)\)/.exec(
549
+ annotated,
550
+ )
551
+ : null;
552
+ if (nums) {
553
+ const b = {
554
+ x: Number(nums[3]) / 1280,
555
+ y: Number(nums[4]) / 720,
556
+ w: Number(nums[1]) / 1280,
557
+ h: Number(nums[2]) / 720,
558
+ };
559
+ const sane = (v) => Number.isFinite(v) && v >= 0 && v <= 1.05;
560
+ const clamp = (r) =>
561
+ sane(r.x) && sane(r.y) && sane(r.w) && sane(r.h)
562
+ ? { x: Math.min(r.x, 1), y: Math.min(r.y, 1), w: Math.min(r.w, 1), h: Math.min(r.h, 1) }
563
+ : null;
564
+ canvasBounds = clamp(b);
565
+ const regions = /Allowed regions \(content-relative\):\s*([^.]+)\./.exec(annotated);
566
+ if (canvasBounds && regions) {
567
+ const re = /(\d+(?:\.\d+)?)\s*[×x]\s*(\d+(?:\.\d+)?)\s*px\s*@\s*\((-?\d+(?:\.\d+)?),\s*(-?\d+(?:\.\d+)?)\)/g;
568
+ for (const m of regions[1].matchAll(re)) {
569
+ const area = clamp({
570
+ x: (Number(nums[3]) + Number(m[3])) / 1280,
571
+ y: (Number(nums[4]) + Number(m[4])) / 720,
572
+ w: Number(m[1]) / 1280,
573
+ h: Number(m[2]) / 720,
574
+ });
575
+ if (area) contentAreas.push(area);
576
+ }
577
+ }
578
+ if (canvasBounds && contentAreas.length === 0) contentAreas = [canvasBounds];
579
+ }
580
+ const chrome = chromeByComponent.get(componentName);
581
+ layouts.push({
582
+ componentName,
583
+ purpose: purposes.get(componentName) ?? null,
584
+ mode: content ? "workhorse" : "purpose-built",
585
+ canvas,
586
+ canvasBounds,
587
+ contentAreas,
588
+ placeholders: parsePlaceholderOutlines(source, layoutsCss),
589
+ previewUrl: chrome ? `/brand/chrome/${chrome}` : null,
590
+ });
591
+ }
592
+ return { layouts, chromeDir: chromeFiles.length ? assetsDir : null };
593
+ }
594
+
361
595
  // Build the standalone plan-review page: the rendered markdown in #plan-root
362
596
  // (the no-JS fallback and the preview's initial state), the injected overlay
363
597
  // config, the bundled plan-review modules, then the overlay script itself.
@@ -375,15 +609,17 @@ export function buildPlanPage({
375
609
  round,
376
610
  deck,
377
611
  ledgerSeed,
612
+ layouts,
378
613
  }) {
379
614
  const cfg = safeInlineJson({
380
615
  endpoint: "/plan-review",
381
- backlogEndpoint: "/plan-review/backlog",
382
616
  heartbeat: "/__plan/heartbeat",
383
617
  round: round ?? null,
384
618
  pollMs: POLL_MS,
385
619
  pollTtlMs: POLL_TTL_MS,
386
620
  logoMark: logoMark ?? null,
621
+ // Brand layouts for the per-slide layout picker (empty → picker read-only).
622
+ layouts: Array.isArray(layouts) ? layouts : [],
387
623
  planBody: planBody ?? "",
388
624
  bodyStartLine: bodyStartLine ?? 1,
389
625
  // deck: the sessionStorage namespace for this deck's comment ledger.
@@ -439,6 +675,8 @@ export async function createPlanReviewServer({
439
675
  logoMark = null,
440
676
  brandAssetPath = null,
441
677
  ledgerSeed = [],
678
+ layouts = [],
679
+ chromeDir = null,
442
680
  port = 0,
443
681
  }) {
444
682
  const gate = createDecisionGate({
@@ -460,6 +698,7 @@ export async function createPlanReviewServer({
460
698
  round: hb.round,
461
699
  deck: path.basename(path.resolve(deckDir)),
462
700
  ledgerSeed: normalizePlanComments(ledgerSeed),
701
+ layouts,
463
702
  });
464
703
  const deckRoot = path.resolve(deckDir);
465
704
 
@@ -467,23 +706,11 @@ export async function createPlanReviewServer({
467
706
  const u = new URL(req.url, "http://127.0.0.1");
468
707
  const pathname = decodeURIComponent(u.pathname);
469
708
 
470
- // Both POST routes are gate-server's shared handlers: the backlog drain and
471
- // the decision + 409 guard behave identically in both gates. `gate.settled()`
472
- // guards BOTH: once one tab's drain or decision resolved this run, a late
473
- // POST from another waiting tab is refused instead of being acked and
474
- // silently dropped (every waiter reloads into a new round, so two tabs
475
- // posting into one run is the normal multi-window case, not an anomaly).
476
- if (req.method === "POST" && pathname === "/plan-review/backlog") {
477
- serveBacklogPost(req, res, {
478
- normalize: normalizePlanComments,
479
- isSuperseded: () => gate.settled(),
480
- onDrained: (comments) => {
481
- gate.resolve({ decision: "comments", comments, editedPlan: null, drained: true });
482
- },
483
- });
484
- return;
485
- }
486
-
709
+ // The decision POST is gate-server's shared handler: the 409 guard behaves
710
+ // identically in both gates. `gate.settled()` refuses a late POST from
711
+ // another waiting tab instead of acking and silently dropping it (every
712
+ // waiter reloads into a new round, so two tabs posting into one run is the
713
+ // normal multi-window case, not an anomaly).
487
714
  if (req.method === "POST" && pathname === "/plan-review") {
488
715
  serveDecisionPost(req, res, {
489
716
  normalize: normalizePlanComments,
@@ -493,7 +720,6 @@ export async function createPlanReviewServer({
493
720
  decision,
494
721
  comments,
495
722
  editedPlan: typeof payload.editedPlan === "string" ? payload.editedPlan : null,
496
- drained: false,
497
723
  });
498
724
  },
499
725
  });
@@ -521,7 +747,10 @@ export async function createPlanReviewServer({
521
747
  // actually opened in front of the user (see gate-server's page-load
522
748
  // tracker; serveAndOpen and the pending contract report from it).
523
749
  hb.notePageLoad();
524
- res.writeHead(200, { "Content-Type": MIME[".html"] });
750
+ res.writeHead(200, {
751
+ "Content-Type": MIME[".html"],
752
+ "Cache-Control": "no-store, max-age=0",
753
+ });
525
754
  res.end(indexHtml);
526
755
  return;
527
756
  }
@@ -539,7 +768,35 @@ export async function createPlanReviewServer({
539
768
  return;
540
769
  }
541
770
  const ext = path.extname(brandAssetPath).toLowerCase();
542
- res.writeHead(200, { "Content-Type": MIME[ext] || "application/octet-stream" });
771
+ res.writeHead(200, {
772
+ "Content-Type": MIME[ext] || "application/octet-stream",
773
+ "Cache-Control": "no-store, max-age=0",
774
+ });
775
+ res.end(data);
776
+ });
777
+ return;
778
+ }
779
+
780
+ // Layout chrome thumbnails for the picker — served from the materialized
781
+ // bundle's assets dir, basename-guarded to the chrome-<Name>.<ext> shape.
782
+ if (pathname.startsWith("/brand/chrome/")) {
783
+ const name = path.basename(pathname);
784
+ if (!chromeDir || !CHROME_ASSET_RE.test(name)) {
785
+ res.writeHead(404);
786
+ res.end("not found");
787
+ return;
788
+ }
789
+ fs.readFile(path.join(chromeDir, name), (err, data) => {
790
+ if (err) {
791
+ res.writeHead(404);
792
+ res.end("not found");
793
+ return;
794
+ }
795
+ const ext = path.extname(name).toLowerCase();
796
+ res.writeHead(200, {
797
+ "Content-Type": MIME[ext] || "application/octet-stream",
798
+ "Cache-Control": "no-store, max-age=0",
799
+ });
543
800
  res.end(data);
544
801
  });
545
802
  return;
@@ -614,6 +871,24 @@ export async function runPlanReview({
614
871
  return;
615
872
  }
616
873
 
874
+ // Headless simulation runs (scripts/codex-sim.mjs): approve without a browser.
875
+ if (process.env.SLIDEMIND_GATE_AUTOAPPROVE === "1") {
876
+ savePlanState(deckDir, {
877
+ version: 3,
878
+ decision: "approve",
879
+ comments: [],
880
+ edited: false,
881
+ planHash: hashPlan(planPath),
882
+ at: new Date().toISOString(),
883
+ });
884
+ process.stdout.write(
885
+ "DECISION: approved\n" +
886
+ "Auto-approved via SLIDEMIND_GATE_AUTOAPPROVE — no human reviewed this plan. " +
887
+ "Proceed to author the slides.\n",
888
+ );
889
+ return { decision: "approve", comments: [] };
890
+ }
891
+
617
892
  const { frontmatterRaw, body, bodyStartLine } = splitPlanFrontmatter(
618
893
  fs.readFileSync(planPath, "utf8"),
619
894
  );
@@ -624,6 +899,7 @@ export async function runPlanReview({
624
899
  const mark = resolveBrandAsset(deckDir, ["logoMarkDark", "logoMark", "logoMarkLight"]);
625
900
  const logoMark = mark ? "/brand/logo" : null;
626
901
  const brandAssetPath = mark?.path ?? null;
902
+ const { layouts, chromeDir } = resolvePlanLayouts(deckDir);
627
903
 
628
904
  const prev = loadPlanState(deckDir);
629
905
 
@@ -637,6 +913,8 @@ export async function runPlanReview({
637
913
  // A fresh tab renders the previous round's comments from the persisted file
638
914
  // (the plan gate's analogue of review.json's ghost pins).
639
915
  ledgerSeed: prev.comments || [],
916
+ layouts,
917
+ chromeDir,
640
918
  port: wantPort,
641
919
  });
642
920
  // A waiting tab can only exist when the previous round ended in comments — so
@@ -648,9 +926,8 @@ export async function runPlanReview({
648
926
  reuseNoun: "plan tab",
649
927
  gateNoun: "plan review",
650
928
  waitLine:
651
- `Waiting for the user's decision — this command BLOCKS (up to ${Math.round(
652
- timeoutMs / 60000,
653
- )} min) while they review. A long quiet wait is normal; do not cancel it.`,
929
+ "Waiting for the user's decision — this command BLOCKS until they decide; there is no " +
930
+ "timeout. A long quiet wait is normal; do not cancel it, and do nothing else meanwhile.",
654
931
  });
655
932
 
656
933
  let decision;
@@ -721,7 +998,7 @@ export async function runPlanReview({
721
998
  }
722
999
  } else {
723
1000
  process.stdout.write(
724
- formatPlanCommentsForHarness(deckDir, comments, { edited, drained: !!decision.drained }),
1001
+ formatPlanCommentsForHarness(deckDir, comments, { edited }),
725
1002
  );
726
1003
  }
727
1004
  return decision;