slidemind 0.3.20 → 0.4.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (187) hide show
  1. package/.claude-plugin/plugin.json +1 -1
  2. package/.codex-plugin/plugin.json +1 -1
  3. package/AGENTS.md +592 -16
  4. package/LICENSE +4 -0
  5. package/README.md +203 -8
  6. package/THIRD-PARTY-NOTICES.md +201 -0
  7. package/bin/slidemind.mjs +50 -2
  8. package/package.json +5 -3
  9. package/skills/create-slides/SKILL.md +484 -886
  10. package/skills/search/SKILL.md +46 -23
  11. package/src/bridge/build-and-push.mjs +548 -73
  12. package/src/bridge/bundle.mjs +100 -31
  13. package/src/bridge/capture.mjs +14 -17
  14. package/src/bridge/client/brands.mjs +157 -18
  15. package/src/bridge/client/engine-routing.mjs +48 -13
  16. package/src/bridge/client/engine-search.mjs +24 -1
  17. package/src/bridge/client/render.mjs +27 -1
  18. package/src/bridge/client/transport.mjs +499 -23
  19. package/src/bridge/client.mjs +10 -0
  20. package/src/bridge/config.mjs +67 -6
  21. package/src/bridge/engine-download.mjs +0 -10
  22. package/src/bridge/graph-auth.mjs +55 -3
  23. package/src/bridge/image-intent-envelope.mjs +239 -0
  24. package/src/bridge/package-preview.mjs +67 -0
  25. package/src/bridge/sharepoint.mjs +914 -0
  26. package/src/builders/asset-fetch.mjs +203 -0
  27. package/src/builders/block-dividers.mjs +83 -0
  28. package/src/builders/build.mjs +171 -87
  29. package/src/builders/card-height.mjs +294 -0
  30. package/src/builders/chart-hole.mjs +183 -0
  31. package/src/builders/chart-jsx.mjs +410 -0
  32. package/src/builders/chart-labels.mjs +326 -0
  33. package/src/builders/concept-geometry.mjs +370 -0
  34. package/src/builders/concept-intent.mjs +200 -0
  35. package/src/builders/concept-layout.mjs +79 -0
  36. package/src/builders/concept-lines.mjs +509 -0
  37. package/src/builders/concept-lists.mjs +270 -0
  38. package/src/builders/concept-marks.mjs +84 -0
  39. package/src/builders/concept-relationships.mjs +12 -0
  40. package/src/builders/concept-spec.mjs +645 -0
  41. package/src/builders/concept-text.mjs +140 -0
  42. package/src/builders/concept-waves.mjs +57 -0
  43. package/src/builders/copy-fit.mjs +139 -0
  44. package/src/builders/design-tokens.mjs +347 -0
  45. package/src/builders/draft-check.mjs +552 -340
  46. package/src/builders/draft-lines.mjs +369 -0
  47. package/src/builders/edit-route.mjs +29 -0
  48. package/src/builders/figure-sketch.mjs +375 -0
  49. package/src/builders/fold-sets.mjs +639 -0
  50. package/src/builders/gate-server.mjs +9 -4
  51. package/src/builders/gate-session.js +273 -12
  52. package/src/builders/gate-shell.js +1494 -0
  53. package/src/builders/image-intent-check.mjs +308 -0
  54. package/src/builders/image-use-reconcile.mjs +107 -0
  55. package/src/builders/kit-helpers.mjs +121 -0
  56. package/src/builders/layout-names.mjs +32 -0
  57. package/src/builders/legacy-reference-uses.mjs +179 -0
  58. package/src/builders/line-fit.mjs +224 -0
  59. package/src/builders/line-native.mjs +895 -0
  60. package/src/builders/line-relations.mjs +230 -0
  61. package/src/builders/lint-fix.mjs +117 -9
  62. package/src/builders/lint.mjs +25 -20
  63. package/src/builders/list-jsx.mjs +88 -0
  64. package/src/builders/placement.mjs +133 -0
  65. package/src/builders/plan-checks.mjs +135 -0
  66. package/src/builders/plan-comments.mjs +19 -0
  67. package/src/builders/plan-handlers.mjs +95 -0
  68. package/src/builders/plan-markdown-edit.mjs +3 -3
  69. package/src/builders/plan-overlay.js +1530 -1608
  70. package/src/builders/plan.mjs +689 -186
  71. package/src/builders/preset-definitions.json +1 -0
  72. package/src/builders/preset-eval.mjs +244 -0
  73. package/src/builders/preview.html +51 -120
  74. package/src/builders/red-thread-backbone.mjs +135 -36
  75. package/src/builders/repair.mjs +205 -0
  76. package/src/builders/repeat-groups.mjs +540 -0
  77. package/src/builders/resolve-skill.mjs +5 -4
  78. package/src/builders/review-overlay.js +296 -293
  79. package/src/builders/review.mjs +305 -84
  80. package/src/builders/rule-shift.mjs +96 -0
  81. package/src/builders/seat.mjs +354 -0
  82. package/src/builders/settle-driver.mjs +236 -0
  83. package/src/builders/settle.mjs +1448 -0
  84. package/src/builders/slide-prep.mjs +67 -0
  85. package/src/builders/spec-assets.mjs +236 -0
  86. package/src/builders/spec-match.mjs +56 -0
  87. package/src/builders/stack.mjs +413 -0
  88. package/src/builders/storyboard-markdown.mjs +70 -27
  89. package/src/builders/storyboard-schema.mjs +54 -114
  90. package/src/builders/text-blocks.mjs +212 -0
  91. package/src/builders/text-card-fit.mjs +552 -0
  92. package/src/builders/text-stack.mjs +276 -0
  93. package/src/builders/transcribe.mjs +1069 -0
  94. package/src/cli/assets.mjs +164 -0
  95. package/src/cli/common.mjs +173 -12
  96. package/src/cli/concept-spec-support.mjs +76 -0
  97. package/src/cli/concepts.mjs +1002 -103
  98. package/src/cli/deck.mjs +401 -65
  99. package/src/cli/diagnose.mjs +4 -2
  100. package/src/cli/engine-helpers.mjs +17 -10
  101. package/src/cli/engine-results.mjs +76 -3
  102. package/src/cli/help.mjs +232 -28
  103. package/src/cli/install.mjs +9 -0
  104. package/src/cli/maintenance.mjs +104 -0
  105. package/src/cli/read.mjs +2 -1
  106. package/src/cli/ready.mjs +6 -3
  107. package/src/cli/record.mjs +522 -0
  108. package/src/cli/remote.mjs +818 -0
  109. package/src/cli/revision.mjs +686 -0
  110. package/src/cli/search.mjs +30 -11
  111. package/src/cli/session.mjs +56 -13
  112. package/src/cli/setup.mjs +46 -2
  113. package/src/cli/status.mjs +142 -33
  114. package/src/cli/tasks.mjs +246 -0
  115. package/src/cli/transcribe.mjs +854 -0
  116. package/src/cli/workspace-ui.mjs +260 -0
  117. package/src/cli/workspace.mjs +212 -34
  118. package/src/install-channel.mjs +64 -7
  119. package/src/lib/adopt.mjs +346 -25
  120. package/src/lib/concept-spec-binding.mjs +96 -0
  121. package/src/lib/conversions.mjs +70 -6
  122. package/src/lib/convert-folded.mjs +107 -0
  123. package/src/lib/deck-paths.mjs +418 -0
  124. package/src/lib/durable-ids.mjs +47 -0
  125. package/src/lib/edit-operations.mjs +316 -0
  126. package/src/lib/edit-state.mjs +1244 -0
  127. package/src/lib/edit-transitions.mjs +120 -0
  128. package/src/lib/extensions.mjs +501 -0
  129. package/src/lib/image-assets.mjs +465 -0
  130. package/src/lib/image-derivatives.mjs +234 -0
  131. package/src/lib/image-intent-fingerprint.mjs +70 -0
  132. package/src/lib/image-intent-tags.mjs +176 -0
  133. package/src/lib/image-intent.mjs +802 -0
  134. package/src/lib/image-use-overrides.mjs +279 -0
  135. package/src/lib/inline-components.mjs +613 -0
  136. package/src/lib/lint-converted.mjs +68 -4
  137. package/src/lib/merge-json.mjs +163 -0
  138. package/src/lib/overwrite-guard.mjs +3 -3
  139. package/src/lib/presentation-context.mjs +531 -0
  140. package/src/lib/preservation-tags.mjs +157 -0
  141. package/src/lib/preservation-verdict.mjs +1342 -0
  142. package/src/lib/publish.mjs +1557 -0
  143. package/src/lib/rebase.mjs +945 -0
  144. package/src/lib/reconcile-ack.mjs +4 -6
  145. package/src/lib/reconcile.mjs +62 -24
  146. package/src/lib/record-client.mjs +175 -0
  147. package/src/lib/record-state.mjs +151 -0
  148. package/src/lib/remote.mjs +661 -0
  149. package/src/lib/revision.mjs +1210 -0
  150. package/src/lib/slide-guids.mjs +4 -6
  151. package/src/lib/slide-preservation.mjs +423 -0
  152. package/src/lib/slide-selection.mjs +9 -0
  153. package/src/lib/workspace-transaction.mjs +117 -0
  154. package/src/pptx/content-digest.mjs +334 -0
  155. package/src/pptx/deck-assemble.mjs +1278 -0
  156. package/src/pptx/fill/engine.mjs +2 -3
  157. package/src/pptx/fill/freeform.mjs +6 -3
  158. package/src/pptx/ooxml.mjs +20 -0
  159. package/src/pptx/package-evidence.mjs +383 -0
  160. package/src/pptx/tags.mjs +163 -11
  161. package/src/update-check.mjs +22 -4
  162. package/src/workspace/adapters.mjs +222 -0
  163. package/src/workspace/auth.mjs +256 -0
  164. package/src/workspace/autostart.mjs +204 -0
  165. package/src/workspace/daemon.mjs +360 -0
  166. package/src/workspace/discovery.mjs +180 -0
  167. package/src/workspace/edit.mjs +167 -0
  168. package/src/workspace/events.mjs +52 -0
  169. package/src/workspace/gate-client.mjs +290 -0
  170. package/src/workspace/handback.mjs +73 -0
  171. package/src/workspace/lifecycle.mjs +635 -0
  172. package/src/workspace/operations.mjs +200 -0
  173. package/src/workspace/pick-folder.mjs +152 -0
  174. package/src/workspace/registry.mjs +359 -0
  175. package/src/workspace/remote-links.mjs +397 -0
  176. package/src/workspace/remote-status.mjs +359 -0
  177. package/src/workspace/routes.mjs +1331 -0
  178. package/src/workspace/server.mjs +159 -0
  179. package/src/workspace/sync.mjs +1119 -0
  180. package/src/workspace/task-actions.mjs +215 -0
  181. package/src/workspace/tasks.mjs +507 -0
  182. package/src/workspace/ui/app.js +1886 -0
  183. package/src/workspace/ui/index.html +141 -0
  184. package/src/workspace/ui/plan-bridge.js +120 -0
  185. package/src/workspace/ui/sharepoint.svg +59 -0
  186. package/src/workspace/ui/styles.css +423 -0
  187. package/src/builders/visual-families.mjs +0 -46
@@ -52,11 +52,15 @@ import path from "node:path";
52
52
  import { fileURLToPath, pathToFileURL } from "node:url";
53
53
 
54
54
  import { openInBrowser } from "./build.mjs";
55
- import { loadReviewState } from "./review.mjs";
55
+ import { loadReviewState, writeStateAtomic } from "./review.mjs";
56
56
  import { renderPlanMarkdown } from "./plan-md.mjs";
57
57
  import { splitPlanFrontmatter } from "./resolve-skill.mjs";
58
58
  import { layoutJobs, isFrameJob, plannedJob } from "./layout-jobs.mjs";
59
59
  import { splitPlanMarkdownIntoSegments } from "./storyboard-markdown.mjs";
60
+ import { serializeStoryboardCanvas } from "./storyboard-schema.mjs";
61
+ import { isEditRouteDeck } from "./edit-route.mjs";
62
+ import { planCommentBullet } from "./plan-comments.mjs";
63
+ import { CONTENT_DRAFT, PLAN_NOT_PRESENTED, bodySlidesWithoutContent, parseDeliverable, planCheckErrors as checkPlan } from "./plan-checks.mjs";
60
64
  import {
61
65
  FAVICON_LINK,
62
66
  MIME,
@@ -76,7 +80,25 @@ import {
76
80
  POLL_MS,
77
81
  POLL_TTL_MS,
78
82
  } from "./gate-server.mjs";
79
- import { resolveBrandAsset, resolveBundleDirForDeck } from "../bridge/bundle.mjs";
83
+ import {
84
+ listCachedBundles,
85
+ readDeckBundleRef,
86
+ resolveBrandAsset,
87
+ resolveBundleDirForDeck,
88
+ writeDeckBundleRef,
89
+ } from "../bridge/bundle.mjs";
90
+ import { readDesignName, readThemeColors } from "../cli/designs.mjs";
91
+ import { readImageAssetManifest } from "../lib/image-assets.mjs";
92
+ import { reconcileImageUseOverrides } from "./image-use-reconcile.mjs";
93
+ import {
94
+ PLAN_PAGE_MARKER,
95
+ deckSlug,
96
+ planPageOwner,
97
+ planPagePath,
98
+ readPlanOwner,
99
+ statePath,
100
+ writePlanOwner,
101
+ } from "../lib/deck-paths.mjs";
80
102
 
81
103
  const __filename = fileURLToPath(import.meta.url);
82
104
  const __dirname = path.dirname(__filename);
@@ -85,12 +107,22 @@ const PLAN_MD_PATH = path.join(__dirname, "plan-md.mjs");
85
107
  // The storyboard/red-thread plan-review modules, bundled into the browser page
86
108
  // alongside plan-md.mjs (see bundlePlanModules). Order matters: each file's
87
109
  // inter-module imports are stripped, so a module must be concatenated AFTER the
88
- // module whose exports it references (schema ← storyboard-markdown ← backbone).
110
+ // module whose exports it references (image-intent ← schema ←
111
+ // storyboard-markdown ← backbone). image-intent.mjs lives under src/lib/ and
112
+ // was written browser-safe (no Node builtins, `ii`/`II_`-prefixed module
113
+ // scope) precisely so it can ride along here — the schema parses `assetUses`
114
+ // through it.
115
+ const IMAGE_INTENT_PATH = path.join(__dirname, "..", "lib", "image-intent.mjs");
89
116
  const STORYBOARD_SCHEMA_PATH = path.join(__dirname, "storyboard-schema.mjs");
90
117
  const STORYBOARD_MARKDOWN_PATH = path.join(__dirname, "storyboard-markdown.mjs");
91
118
  const RED_THREAD_BACKBONE_PATH = path.join(__dirname, "red-thread-backbone.mjs");
92
119
  const PLAN_MARKDOWN_EDIT_PATH = path.join(__dirname, "plan-markdown-edit.mjs");
93
120
  const PLAN_STATE_FILE = "plan-review.json";
121
+ // What a body slide's content draft holds — one wording (plan-checks.mjs) for
122
+ // the approval output, the deck stage, the concepts refusal and the add-in.
123
+ export { CONTENT_DRAFT, parseDeliverable };
124
+ const NEXT_AFTER_APPROVAL =
125
+ `write ${CONTENT_DRAFT} into each storyboard's \`content\` in plan.md (no new sign-off needed), then run \`slidemind concepts\`.`;
94
126
  // No decision timeout: the gate returns only on the user's decision (or when
95
127
  // no browser could be opened at all — see openFailedGraceMs). An agent cannot
96
128
  // proceed past a plan a human has not approved, so the process waits as long as
@@ -135,7 +167,7 @@ export function normalizePlanComments(raw) {
135
167
  // drives the tab-reuse grace window in runPlanReview (a waiting tab can only
136
168
  // exist after a prior comments round).
137
169
  export function loadPlanState(deckDir) {
138
- const f = path.join(deckDir, PLAN_STATE_FILE);
170
+ const f = statePath(deckDir, PLAN_STATE_FILE);
139
171
  if (!fs.existsSync(f)) return { ...EMPTY_STATE };
140
172
  try {
141
173
  const data = JSON.parse(fs.readFileSync(f, "utf8"));
@@ -146,7 +178,14 @@ export function loadPlanState(deckDir) {
146
178
  comments: normalizePlanComments(data.comments),
147
179
  edited: data.edited ?? false,
148
180
  planHash: data.planHash ?? null,
181
+ storyHash: data.storyHash ?? null,
149
182
  at: data.at ?? null,
183
+ // Workspace provenance (respondPlan): which operation wrote this round and
184
+ // who answered. Absent on rounds the standalone gate recorded.
185
+ operationId: typeof data.operationId === "string" ? data.operationId : null,
186
+ actor: data.actor ?? null,
187
+ // The design the user switched the deck to with this answer (switchPlanDesign).
188
+ design: data.design && typeof data.design.to === "string" ? data.design : null,
150
189
  };
151
190
  } catch {
152
191
  return { ...EMPTY_STATE };
@@ -154,10 +193,10 @@ export function loadPlanState(deckDir) {
154
193
  }
155
194
 
156
195
  export function savePlanState(deckDir, state) {
157
- fs.writeFileSync(
158
- path.join(deckDir, PLAN_STATE_FILE),
159
- JSON.stringify(state, null, 2) + "\n",
160
- );
196
+ writeStateAtomic(deckDir, PLAN_STATE_FILE, JSON.stringify(state, null, 2) + "\n");
197
+ // Every writer of the record re-renders the page in the same command —
198
+ // a plan.html nobody refreshed is worse than none at all.
199
+ writePlanPage(deckDir);
161
200
  }
162
201
 
163
202
  // The human checkmark every post-plan command (concepts, build, view-draft,
@@ -169,14 +208,32 @@ export function savePlanState(deckDir, state) {
169
208
  // The gate binds only until the deck has passed a review: from then on the plan
170
209
  // has been executed and plan.md is a record, not a contract — the add-in's
171
210
  // manager likewise hands an edit delegation `approvedPlan: null` and never
172
- // re-gates the old plan. Edits to existing slides are the review gate's job
173
- // (it re-arms on every slide change); the plan rule for slides added to a
174
- // reviewed deck stays the skill's, as it is in-app.
211
+ // re-gates the old plan. The preview review is one-time too, so neither gate
212
+ // re-arms on a later change; the plan rule for slides added to a reviewed deck
213
+ // stays the skill's, as it is in-app.
214
+
215
+ // Both gates ask it; it lives in edit-route.mjs so review.mjs can import it too.
216
+ export { isEditRouteDeck };
217
+
218
+ /**
219
+ * The plan gate's own question: was THIS plan signed off at the plan gate?
220
+ * Stricter than requirePlanApproved, which also lets later stages through once
221
+ * the preview was approved — a gate asked again about a changed plan must ask.
222
+ */
223
+ export function planApprovedExactly(deckDir) {
224
+ const planPath = path.join(deckDir, "plan.md");
225
+ if (!fs.existsSync(planPath)) return false;
226
+ const state = loadPlanState(deckDir);
227
+ if (state.decision !== "approve") return false;
228
+ return state.planHash === hashPlan(planPath) || state.storyHash === planStoryHash(deckDir);
229
+ }
230
+
175
231
  export function requirePlanApproved(deckDir) {
176
232
  const planPath = path.join(deckDir, "plan.md");
177
233
  if (!fs.existsSync(planPath)) return null;
178
- if (loadReviewState(deckDir).decision === "approve") return null;
179
- const base = path.basename(deckDir);
234
+ if (isEditRouteDeck(deckDir)) return null;
235
+ if (loadReviewState(deckDir).approval) return null;
236
+ const base = deckSlug(deckDir);
180
237
  const state = loadPlanState(deckDir);
181
238
  if (state.decision !== "approve") {
182
239
  return (
@@ -184,7 +241,9 @@ export function requirePlanApproved(deckDir) {
184
241
  `run \`slidemind plan ${base}\` and wait for DECISION: approved.`
185
242
  );
186
243
  }
187
- if (state.planHash !== hashPlan(planPath)) {
244
+ // A content expansion after sign-off is allowed (planStoryHash); anything that
245
+ // moves the story is not.
246
+ if (state.planHash !== hashPlan(planPath) && state.storyHash !== planStoryHash(deckDir)) {
188
247
  return (
189
248
  `${base}/plan.md changed since the user approved it — ` +
190
249
  `re-run \`slidemind plan ${base}\` for a fresh sign-off.`
@@ -197,16 +256,25 @@ export function requirePlanApproved(deckDir) {
197
256
  // harness output
198
257
  // ---------------------------------------------------------------------------
199
258
 
200
- // One anchored bullet per comment: `- [Slide 3: "Align the next outcome"] text`
201
- // or `- [Whole story] text`. Continuation lines are indented under the bullet so
202
- // a multi-line note can't be mistaken for a second comment.
203
- function commentBullet(c) {
204
- const anchor =
205
- c.scope === "slide"
206
- ? `Slide ${c.slideIndex ?? "?"}${c.slideHeading ? `: "${c.slideHeading}"` : ""}`
207
- : "Whole story";
208
- const [first, ...rest] = String(c.text).split("\n");
209
- return [`- [${anchor}] ${first}`, ...rest.map((l) => ` ${l}`)].join("\n");
259
+ // The design the user switched the deck to at the gate, said once in either
260
+ // decision: the agent planned against the old design.md and must not author
261
+ // from it. Concept mockups made before the switch show the old design, and
262
+ // `concepts` keeps a mockup it already has — so they are named for `--force`.
263
+ function designSwitchLine(deckDir, design) {
264
+ if (!design?.to) return null;
265
+ const dir = resolveBundleDirForDeck(deckDir);
266
+ const read = dir ? `Re-read ${path.join(dir, "design.md")}` : `Run \`slidemind ready ${deckSlug(deckDir)}\``;
267
+ let line =
268
+ `The user switched this deck's corporate design to ${design.name ?? design.to} (${design.to}) at the gate. ` +
269
+ `${read} before you go on — every later step builds against it.`;
270
+ try {
271
+ if (fs.readdirSync(path.join(deckDir, "concepts")).some((f) => /^concept-slide-\d+\.png$/.test(f))) {
272
+ line += ` The existing concept mockups show the previous design: regenerate them with \`slidemind concepts ${deckSlug(deckDir)} --force\`.`;
273
+ }
274
+ } catch {
275
+ /* no concepts yet */
276
+ }
277
+ return line;
210
278
  }
211
279
 
212
280
  // Render the comments round for the harness — the plan analog of review.mjs's
@@ -216,11 +284,13 @@ function commentBullet(c) {
216
284
  // next `slidemind plan` run — nothing reaches them until it runs. An agent that
217
285
  // ends its turn here strands them, so the re-run instruction says "same turn"
218
286
  // and says why more comment rounds may follow.
219
- export function formatPlanCommentsForHarness(deckDir, comments, { edited = false } = {}) {
287
+ export function formatPlanCommentsForHarness(deckDir, comments, { edited = false, design = null } = {}) {
220
288
  const rows = normalizePlanComments(comments);
221
289
  const out = [];
222
290
  out.push("DECISION: comments");
223
291
  out.push("The user requested changes to the plan. Nothing was authored.");
292
+ const switched = designSwitchLine(deckDir, design);
293
+ if (switched) out.push(switched);
224
294
  if (edited) {
225
295
  out.push(
226
296
  "The user also edited plan.md directly — re-read it before revising; the " +
@@ -229,14 +299,14 @@ export function formatPlanCommentsForHarness(deckDir, comments, { edited = false
229
299
  }
230
300
  out.push("");
231
301
  if (rows.length) {
232
- for (const c of rows) out.push(commentBullet(c));
302
+ for (const c of rows) out.push(planCommentBullet(c));
233
303
  } else if (edited) {
234
304
  out.push("(No written comments — their direct edits to plan.md are the change.)");
235
305
  }
236
306
  out.push("");
237
307
  out.push("Revise plan.md to address ALL comments — change only what the feedback");
238
308
  out.push("requires; keep unaffected slides verbatim.");
239
- out.push(`Then run \`slidemind plan ${path.basename(deckDir)}\` again in the same turn.`);
309
+ out.push(`Then run \`slidemind plan ${deckSlug(deckDir)}\` again in the same turn.`);
240
310
  out.push("The reviewer then reviews the revised plan — several comment rounds are");
241
311
  out.push("normal, not an error. Do NOT end your turn and do NOT send the user back");
242
312
  out.push("to the browser before you re-run the gate.");
@@ -245,17 +315,49 @@ export function formatPlanCommentsForHarness(deckDir, comments, { edited = false
245
315
  // the round is persisted before this prints, so a lost capture is recoverable.
246
316
  out.push(
247
317
  `This round (decision + comments) is also saved verbatim in ` +
248
- `${path.join(deckDir, PLAN_STATE_FILE)} — if this output looks truncated, read ` +
318
+ `${statePath(deckDir, PLAN_STATE_FILE)} — if this output looks truncated, read ` +
249
319
  `that file instead of guessing.`,
250
320
  );
251
321
  return out.join("\n") + "\n";
252
322
  }
253
323
 
324
+ // The approval as the harness reads it — one wording for the standalone gate
325
+ // and the workspace gate (an edit made at the gate is the approved plan).
326
+ export function planApprovedText(deckDir, { edited = false, note = null, design = null } = {}) {
327
+ const switched = designSwitchLine(deckDir, design);
328
+ return (
329
+ "DECISION: approved\n" +
330
+ (note ? `${note}\n` : "") +
331
+ (switched ? `${switched}\n` : "") +
332
+ (edited
333
+ ? "The user approved the plan — and edited plan.md directly before approving. " +
334
+ `Re-read ${deckDir}/plan.md: the edited text IS the approved plan. Author from ` +
335
+ `it, not from your earlier draft. Next: ${NEXT_AFTER_APPROVAL}\n`
336
+ : `The user approved the plan. Next: ${NEXT_AFTER_APPROVAL}\n`)
337
+ );
338
+ }
339
+
340
+ /**
341
+ * A workspace task's answer, printed exactly as the standalone gate prints its
342
+ * own: the comments come from the gate record the answer wrote (plan-review.json,
343
+ * the same operation), else from the task itself. "Edited" is the record's flag
344
+ * or the task having followed an edit the human made in the page.
345
+ */
346
+ export function formatPlanTaskDecision(deckDir, task, { note = null, editedInPage = false } = {}) {
347
+ const state = loadPlanState(deckDir);
348
+ const mine = Boolean(state.operationId) && task?.response?.operationId === state.operationId;
349
+ const edited = Boolean(editedInPage || (mine && state.edited) || (task?.subject?.requestedHash && task.subject.requestedHash !== task.subject.hash));
350
+ const design = mine ? state.design : null;
351
+ if (task?.response?.action === "approve") return planApprovedText(deckDir, { edited, note, design });
352
+ const comments = mine ? state.comments : task?.response?.comment ? [{ text: task.response.comment, scope: "story" }] : [];
353
+ return (note ? `NOTE: ${note}\n` : "") + formatPlanCommentsForHarness(deckDir, comments, { edited, design });
354
+ }
355
+
254
356
  // Render the no-answer outcome for the harness. A timed-out gate is an EXPECTED
255
357
  // state (the user stepped away), not a failure — the agent must neither treat
256
358
  // it as approval nor abort the task; it wraps up with clear resume instructions.
257
359
  export function formatPlanPendingForHarness(deckDir, { timeoutMs, pageLoaded, url } = {}) {
258
- const base = path.basename(deckDir);
360
+ const base = deckSlug(deckDir);
259
361
  const waited = timeoutMs
260
362
  ? `the gate waited ${Math.max(1, Math.round(timeoutMs / 60000))} minutes and the user hasn't answered.`
261
363
  : "the gate closed without an answer from the user.";
@@ -288,7 +390,7 @@ const PLAN_NAMESPACE_EXPORTS = [
288
390
  "splitPlanMarkdownIntoSegments",
289
391
  "replaceStoryboardSegment",
290
392
  "setStoryboardLayoutForAll",
291
- "setStoryboardStyle",
393
+ "remapStoryboardLayouts",
292
394
  "extractRedThreadMessages",
293
395
  "findRedThreadSection",
294
396
  "parseStoryboardCanvas",
@@ -297,14 +399,6 @@ const PLAN_NAMESPACE_EXPORTS = [
297
399
  "parsePlanBackbone",
298
400
  "applyPlanBackbone",
299
401
  "stageStoryIteration",
300
- "STORYBOARD_WIDTH",
301
- "STORYBOARD_HEIGHT",
302
- "BODY_SAFE_AREA",
303
- "MIN_BLOCK_WIDTH",
304
- "MIN_BLOCK_HEIGHT",
305
- "MAX_BLOCKS",
306
- "MAX_DESIGN_ELEMENT_LENGTH",
307
- "MAX_CONTENT_LENGTH",
308
402
  "MAX_IMAGE_PROMPT_LENGTH",
309
403
  ];
310
404
 
@@ -313,14 +407,15 @@ const PLAN_NAMESPACE_EXPORTS = [
313
407
  // caller and the module tests reach for it on this path.
314
408
  export { stripModuleSyntax };
315
409
 
316
- // Bundle the five plan-review modules into one IIFE for the browser page, in
317
- // dependency order (schema → storyboard-markdown → backbone → edit → plan-md).
410
+ // Bundle the plan-review modules into one IIFE for the browser page, in
411
+ // dependency order (image-intent → schema → storyboard-markdown → backbone →
412
+ // edit → plan-md).
318
413
  // The IIFE exposes window.__SLIDEMIND_RENDER_MD__ (back-compat with the old
319
414
  // preview) and window.__SLIDEMIND_PLAN__ (the full namespace the overlay reads).
320
415
  // Exported so a test can evaluate the output against a stub window and catch a
321
416
  // strip-regex miss or a top-level name collision for real.
322
- export function bundlePlanModules({ schema, storyboardMd, backbone, edit, planMd } = {}) {
323
- const body = [schema, storyboardMd, backbone, edit, planMd]
417
+ export function bundlePlanModules({ intent, schema, storyboardMd, backbone, edit, planMd } = {}) {
418
+ const body = [intent, schema, storyboardMd, backbone, edit, planMd]
324
419
  .map(stripModuleSyntax)
325
420
  .join("\n");
326
421
  return (
@@ -333,10 +428,11 @@ export function bundlePlanModules({ schema, storyboardMd, backbone, edit, planMd
333
428
  );
334
429
  }
335
430
 
336
- // Read the five module sources off disk and bundle them. Split from
431
+ // Read the module sources off disk and bundle them. Split from
337
432
  // bundlePlanModules so the pure string→string step stays unit-testable.
338
433
  export function bundlePlanModulesFromDisk() {
339
434
  return bundlePlanModules({
435
+ intent: fs.readFileSync(IMAGE_INTENT_PATH, "utf8"),
340
436
  schema: fs.readFileSync(STORYBOARD_SCHEMA_PATH, "utf8"),
341
437
  storyboardMd: fs.readFileSync(STORYBOARD_MARKDOWN_PATH, "utf8"),
342
438
  backbone: fs.readFileSync(RED_THREAD_BACKBONE_PATH, "utf8"),
@@ -471,96 +567,55 @@ export function parsePlaceholderOutlines(source, layoutsCss) {
471
567
  // `/brand/chrome/<file>`. No bundle → `{ layouts: [], chromeDir: null }` and
472
568
  // the picker degrades to read-only (the host-free contract holds).
473
569
  // ---------------------------------------------------------------------------
474
- // The plan's `## Deliverable` block and the deck frame it implies
475
- // (docs/slidemind-cli-connect/17-deck-frame.md). Parsed leniently: the block
476
- // is a contract the agent writes and the user edits by hand.
570
+ // The plan checks (plan-checks.mjs, shared with the add-in), read against this
571
+ // deck's bundle: what job each layout does.
477
572
  // ---------------------------------------------------------------------------
478
573
 
479
- export function parseDeliverable(markdown) {
480
- const m = /^##\s+Deliverable\s*\n([\s\S]*?)(?=^##\s|(?![\s\S]))/m.exec(markdown ?? "");
481
- if (!m) return null;
482
- const get = (key) => {
483
- const r = new RegExp(`^\\s*-?\\s*${key}\\s*:\\s*(.+)$`, "im").exec(m[1]);
484
- return r ? r[1].trim() : null;
485
- };
486
- const shape = (get("shape") ?? "").toLowerCase().replace(/[^a-z]/g, "");
487
- const body = Number.parseInt(get("body slides") ?? get("body") ?? "", 10);
488
- const frameRaw = (get("frame") ?? "").toLowerCase();
489
- const frame = /^(none|-|—|no)\b/.test(frameRaw)
490
- ? []
491
- : frameRaw.split(/[,;/]+/).map((x) => x.trim().replace(/s$/, "")).filter(Boolean);
492
- return {
493
- shape: shape === "deck" || shape === "slides" ? shape : null,
494
- bodySlides: Number.isFinite(body) ? body : null,
495
- frame,
496
- target: get("target"),
497
- };
574
+ function deckJobs(deckDir) {
575
+ const jobs = layoutJobs(resolveBundleDirForDeck(deckDir));
576
+ return { jobs, isFrameSlide: (data) => isFrameJob(plannedJob(data, jobs)) };
498
577
  }
499
578
 
500
579
  /**
501
- * `[plan:warn]` lines for a plan whose deliverable block and storyboards do
502
- * not agree with each other or with the bundle's layout jobs. Advisory: the
503
- * gate still opens — the user decides in it — but the agent reads these
504
- * before the user does.
580
+ * The body slides still owing their full content draft — the step between
581
+ * sign-off and `concepts`, read by the deck stage and the concepts refusal.
505
582
  */
506
- export function planDeliverableWarnings(deckDir, markdown) {
507
- const out = [];
508
- const dir = resolveBundleDirForDeck(deckDir);
509
- const jobs = layoutJobs(dir);
510
- const beats = [];
511
- for (const segment of splitPlanMarkdownIntoSegments(markdown ?? "")) {
512
- if (segment.type !== "storyboard" || !segment.data) continue;
513
- const layout = segment.data.layout ?? null;
514
- if (!segment.data.role) out.push(`[plan:warn] slide ${segment.data.slide} has no role; legacy layout inference is being used. Write role: body | frame before the next approval.`);
515
- if (segment.data.role === "frame" && !segment.data.frameKind) out.push(`[plan:warn] frame slide ${segment.data.slide} needs a frameKind.`);
516
- beats.push({ slide: segment.data.slide, layout, job: plannedJob(segment.data, jobs) });
517
- }
518
- if (beats.length === 0) return out;
519
- const frameBeats = beats.filter((b) => isFrameJob(b.job));
520
- const bodyBeats = beats.filter((b) => !isFrameJob(b.job));
521
- const d = parseDeliverable(markdown);
522
- if (!d) {
523
- out.push(
524
- `[plan:warn] plan.md has no \`## Deliverable\` block (shape: deck | slides, body slides: N, frame: <jobs>, target). ` +
525
- `Without it the frame (cover/agenda/dividers/closing) is unspecified — write the block before the gate.`,
526
- );
527
- return out;
528
- }
529
- if (d.shape === "deck" && frameBeats.length === 0) {
530
- const offered = [...jobs.values()].filter((j) => isFrameJob(j.job)).map((j) => j.job);
531
- out.push(
532
- `[plan:warn] shape is deck but no storyboard sits on a frame layout — this design offers ${offered.length ? [...new Set(offered)].join(", ") : "no frame layouts"}; a deck ships its cover${offered.includes("closing") ? " and closing" : ""}.`,
533
- );
534
- }
535
- if (d.shape === "slides" && frameBeats.length > 0 && d.frame.length === 0) {
536
- out.push(
537
- `[plan:warn] shape is slides but slide${frameBeats.length > 1 ? "s" : ""} ${frameBeats.map((b) => b.slide).join(", ")} sit on frame layouts (${[...new Set(frameBeats.map((b) => b.job))].join(", ")}) — slides extend a deck that has its own frame; drop them or set shape: deck.`,
538
- );
539
- }
540
- if (d.bodySlides != null && d.bodySlides !== bodyBeats.length) {
541
- out.push(
542
- `[plan:warn] Deliverable says body slides: ${d.bodySlides}, the storyboards carry ${bodyBeats.length} body slide${bodyBeats.length === 1 ? "" : "s"} (frame slides excluded).`,
543
- );
544
- }
545
- for (const want of d.frame) {
546
- if (!isFrameJob(want)) continue;
547
- if (!frameBeats.some((b) => b.job === want)) {
548
- out.push(`[plan:warn] frame lists ${want} but no storyboard declares that frameKind.`);
549
- }
550
- }
551
- const cover = frameBeats.find((b) => b.job === "cover");
552
- if (cover && beats.some((b) => b.slide < cover.slide)) {
553
- out.push(`[plan:warn] the cover is slide ${cover.slide} — a cover is slide 1.`);
554
- }
555
- const closing = frameBeats.find((b) => b.job === "closing");
556
- if (closing && beats.some((b) => b.slide > closing.slide)) {
557
- out.push(`[plan:warn] the closing is slide ${closing.slide} but slides follow it — a closing is the last slide.`);
558
- }
559
- return out;
583
+ export function slidesWithoutContent(deckDir, markdown) {
584
+ return bodySlidesWithoutContent(markdown, { isFrameSlide: deckJobs(deckDir).isFrameSlide });
560
585
  }
561
586
 
562
- export function resolvePlanLayouts(deckDir) {
563
- const dir = resolveBundleDirForDeck(deckDir);
587
+ /**
588
+ * `[plan:error]` lines for a plan whose deliverable block, red thread and
589
+ * storyboards do not agree with each other or with the bundle's layout jobs.
590
+ */
591
+ export function planCheckErrors(deckDir, markdown) {
592
+ const { jobs } = deckJobs(deckDir);
593
+ return checkPlan(markdown, {
594
+ slideJob: (data) => plannedJob(data, jobs),
595
+ isFrameJob,
596
+ offeredJobs: [...jobs.values()].map((j) => j.job),
597
+ });
598
+ }
599
+
600
+ /**
601
+ * The refusal for opening a NEW plan round on plan.md as it stands, or null —
602
+ * the add-in's `present_plan_for_review` refuses the same plans. Never asked of
603
+ * an answer the user already gave: their edits are their decision.
604
+ */
605
+ export function planRefusal(deckDir) {
606
+ const errors = planCheckErrors(deckDir, fs.readFileSync(path.join(deckDir, "plan.md"), "utf8"));
607
+ if (errors.length === 0) return null;
608
+ return (
609
+ `${PLAN_NOT_PRESENTED} Fix each line below in plan.md (workflow/plan.md), then run ` +
610
+ `\`slidemind plan ${deckSlug(deckDir)}\` again:\n` +
611
+ errors.map((line) => ` ${line}\n`).join("")
612
+ );
613
+ }
614
+
615
+ // `bundleDir` reads another cached design's palette instead of the deck's own
616
+ // (the design picker); the deck's layout context still applies by layoutKey.
617
+ export function resolvePlanLayouts(deckDir, { bundleDir = null } = {}) {
618
+ const dir = bundleDir ?? resolveBundleDirForDeck(deckDir);
564
619
  if (!dir) return { layouts: [], chromeDir: null };
565
620
  const layoutsDir = path.join(dir, "layouts");
566
621
  const assetsDir = path.join(dir, "assets");
@@ -696,36 +751,144 @@ export function resolvePlanLayouts(deckDir) {
696
751
  }
697
752
  // Target context is opt-in, written by `read --deck`; it never changes the brand bundle.
698
753
  try {
699
- const context = JSON.parse(fs.readFileSync(path.join(deckDir, ".layout-context.json"), "utf8"));
754
+ const context = JSON.parse(fs.readFileSync(statePath(deckDir, "layout-context.json"), "utf8"));
700
755
  for (const layout of layouts) {
701
756
  const selected = context.selections?.[layout.componentName];
702
757
  if (!layout.selection) continue;
703
758
  // A modern scoped identity mismatch must never fall back to a name join.
704
759
  if (selected?.layoutKey) {
705
760
  if (selected.layoutKey !== layout.layoutKey) continue;
706
- layout.selection = { ...layout.selection, currentCount: selected.currentCount,
707
- sectionCount: selected.sectionCount };
761
+ layout.selection = { ...layout.selection, currentCount: selected.currentCount };
708
762
  } else if (context.selections === undefined &&
709
763
  Object.hasOwn(context.layoutCounts ?? {}, layout.nativeLayoutName) &&
710
764
  layouts.filter((other) => other.nativeLayoutName === layout.nativeLayoutName).length === 1) {
711
765
  layout.selection = { ...layout.selection, currentCount: context.layoutCounts[layout.nativeLayoutName] };
712
766
  }
713
767
  const p = layout.selection;
714
- p.source = p.sectionCount > 0 ? "section" : p.currentCount > 0 ? "presentation" : p.usage?.count > 0 ? "brand" : "suggested";
768
+ p.source = p.currentCount > 0 ? "presentation" : p.usage?.count > 0 ? "brand" : "suggested";
715
769
  }
716
770
  } catch {}
717
- // Same precedence as the add-in profile: capability, section, target, brand.
718
- // Brand ranks are supplied by the add-in; legacy bundles retain their order.
771
+ // Same precedence as the add-in profile (layout-selection.ts): capability,
772
+ // target, brand. The brand rank is the add-in's own order, so its remaining
773
+ // tie-breaks (writable canvas area, then name) carry over unchanged.
719
774
  layouts.sort((a, b) => {
720
775
  if (!a.selection || !b.selection) return (a.selection?.rank ?? Infinity) - (b.selection?.rank ?? Infinity);
721
776
  return Number(b.selection.composition) - Number(a.selection.composition) ||
722
- (b.selection.sectionCount ?? 0) - (a.selection.sectionCount ?? 0) ||
723
777
  (b.selection.currentCount ?? 0) - (a.selection.currentCount ?? 0) ||
724
- b.selection.usage.share - a.selection.usage.share || a.componentName.localeCompare(b.componentName);
778
+ b.selection.usage.share - a.selection.usage.share ||
779
+ (a.selection.rank ?? Infinity) - (b.selection.rank ?? Infinity) ||
780
+ a.componentName.localeCompare(b.componentName);
725
781
  });
726
782
  return { layouts, chromeDir: chromeFiles.length ? assetsDir : null };
727
783
  }
728
784
 
785
+ // ---------------------------------------------------------------------------
786
+ // The design picker: every design cached on this machine, offered at the gate.
787
+ // Choosing one is part of the answer (`design` on the decision), not a write of
788
+ // its own — the deck is rebound when the user sends, together with the plan
789
+ // whose layouts the page moved onto the new design.
790
+ // ---------------------------------------------------------------------------
791
+
792
+ const ACCENT_KEYS = ["accent1", "accent2", "accent3", "accent4", "accent5", "accent6"];
793
+
794
+ function cachedDesign(presFP) {
795
+ return (presFP && listCachedBundles().find((b) => b.presFP === presFP)) || null;
796
+ }
797
+
798
+ /**
799
+ * The picker's menu: name + accent colours per cached design, sorted by name,
800
+ * `current` on the one the deck is bound to. Every other design carries its
801
+ * layout palette so the page can swap the layout picker without a round trip;
802
+ * its chrome thumbnails are addressed with `?design=<presFP>` (see
803
+ * `planChromeDir`). `mapUrl` prefixes those URLs for the workspace's routes.
804
+ */
805
+ export function planDesignOptions(deckDir, { mapUrl = (u) => u } = {}) {
806
+ const bound = readDeckBundleRef(deckDir)?.presFP ?? null;
807
+ return listCachedBundles()
808
+ .map((b) => {
809
+ const colors = readThemeColors(b.dir) ?? {};
810
+ const current = b.presFP === bound;
811
+ const design = {
812
+ presFP: b.presFP,
813
+ name: readDesignName(b.dir) ?? b.presFP,
814
+ accents: ACCENT_KEYS.map((k) => colors[k]).filter(Boolean),
815
+ current,
816
+ };
817
+ if (!current) {
818
+ design.layouts = resolvePlanLayouts(deckDir, { bundleDir: b.dir }).layouts.map((l) => ({
819
+ ...l,
820
+ previewUrl: l.previewUrl ? mapUrl(`${l.previewUrl}?design=${encodeURIComponent(b.presFP)}`) : null,
821
+ }));
822
+ }
823
+ return design;
824
+ })
825
+ .sort((a, b) => a.name.localeCompare(b.name));
826
+ }
827
+
828
+ /**
829
+ * The picker's whole menu: the cached designs above (`cached: true`) and, after
830
+ * them, every other design a connected backend serves (`cached: false` — a name,
831
+ * no palette yet), downloaded when picked (`downloadPlanDesign`). A backend that does not
832
+ * answer leaves the menu at what this machine already has. The page asks for it
833
+ * after it loads, so a slow backend never holds up the plan.
834
+ */
835
+ export async function listPlanDesigns(deckDir, { mapUrl = (u) => u, listBrands = null } = {}) {
836
+ const cached = planDesignOptions(deckDir, { mapUrl }).map((d) => ({ ...d, cached: true }));
837
+ let listed = [];
838
+ try {
839
+ listed = await (listBrands ?? (await import("../bridge/client.mjs")).listBrands)();
840
+ } catch {
841
+ listed = [];
842
+ }
843
+ const seen = new Set(cached.map((d) => d.presFP));
844
+ const more = [];
845
+ for (const b of listed) {
846
+ if (!b?.presFP || seen.has(b.presFP)) continue;
847
+ seen.add(b.presFP);
848
+ more.push({ presFP: b.presFP, name: b.name || b.presFP, accents: [], current: false, cached: false });
849
+ }
850
+ // what this machine has first (each sorted by name), what would still have to
851
+ // be downloaded last — the page draws those rows smaller, under a divider
852
+ const byName = (a, b) => a.name.localeCompare(b.name);
853
+ return [...cached.sort(byName), ...more.sort(byName)];
854
+ }
855
+
856
+ /**
857
+ * A design picked at the gate that this machine has not downloaded yet:
858
+ * materialize it over the pane-free brand route, then return its picker entry
859
+ * (palette, layouts) like any cached one. Throws `unknown_design` when no
860
+ * connected backend serves it.
861
+ */
862
+ export async function downloadPlanDesign(deckDir, presFP, { mapUrl = (u) => u, materialize = null } = {}) {
863
+ if (!cachedDesign(presFP)) {
864
+ const run = materialize ?? (async (brandId) => (await import("../bridge/bundle.mjs")).materializeBundle(null, { brandId }));
865
+ await run(presFP);
866
+ }
867
+ const design = planDesignOptions(deckDir, { mapUrl }).find((d) => d.presFP === presFP);
868
+ if (!design) throw Object.assign(new Error(`no connected backend serves design '${presFP}'`), { code: "unknown_design" });
869
+ return { ...design, cached: true };
870
+ }
871
+
872
+ /** Where `/brand/chrome/<file>` reads from: the named cached design, else the deck's own. */
873
+ export function planChromeDir(deckDir, design = null) {
874
+ const dir = design ? cachedDesign(design)?.dir : resolveBundleDirForDeck(deckDir);
875
+ return dir ? path.join(dir, "assets") : null;
876
+ }
877
+
878
+ /**
879
+ * Rebind the deck to the cached design the user chose at the gate. Returns
880
+ * `{ from, to, name }` when the binding moved, null when it already was that
881
+ * design, or `{ error }` for a design this machine does not hold.
882
+ */
883
+ export function switchPlanDesign(deckDir, presFP) {
884
+ const hit = cachedDesign(presFP);
885
+ if (!hit) return { error: `design '${presFP}' is not cached on this machine` };
886
+ const from = readDeckBundleRef(deckDir)?.presFP ?? null;
887
+ if (from === presFP) return null;
888
+ writeDeckBundleRef(deckDir, { presFP, etag: hit.meta?.etag ?? null });
889
+ return { from, to: presFP, name: readDesignName(hit.dir) ?? presFP };
890
+ }
891
+
729
892
  // Build the standalone plan-review page: the rendered markdown in #plan-root
730
893
  // (the no-JS fallback and the preview's initial state), the injected overlay
731
894
  // config, the bundled plan-review modules, then the overlay script itself.
@@ -744,16 +907,34 @@ export function buildPlanPage({
744
907
  deck,
745
908
  ledgerSeed,
746
909
  layouts,
910
+ designs,
911
+ imageAssets,
912
+ // The plan.md record this page was rendered from, verbatim. It rides in the
913
+ // page so the file at the deck root IS the plan — recoverable, diffable, and
914
+ // enough for `extract` to be unnecessary.
915
+ planRecord = null,
916
+ // The command that serves this page for editing. Set on the copy written to
917
+ // disk (a browser cannot write back to a file), null on the served copy —
918
+ // the overlay stays dormant while it is set, so the file reads as a document.
919
+ readOnly = null,
747
920
  }) {
748
921
  const cfg = safeInlineJson({
749
922
  endpoint: "/plan-review",
750
923
  heartbeat: "/__plan/heartbeat",
924
+ readOnly,
925
+ planRecord,
751
926
  round: round ?? null,
752
927
  pollMs: POLL_MS,
753
928
  pollTtlMs: POLL_TTL_MS,
754
929
  logoMark: logoMark ?? null,
755
930
  // Brand layouts for the per-slide layout picker (empty → picker read-only).
756
931
  layouts: Array.isArray(layouts) ? layouts : [],
932
+ // The cached designs for the design picker (planDesignOptions; empty → none).
933
+ designs: Array.isArray(designs) ? designs : [],
934
+ // The deck's image registry, for the per-slide image-use summary: name +
935
+ // preview for each `assetUses[].assetId`. Names and previews only — the
936
+ // obligation itself lives in the plan record the page already carries.
937
+ imageAssets: Array.isArray(imageAssets) ? imageAssets : [],
757
938
  planBody: planBody ?? "",
758
939
  bodyStartLine: bodyStartLine ?? 1,
759
940
  // deck: the sessionStorage namespace for this deck's comment ledger.
@@ -770,11 +951,19 @@ export function buildPlanPage({
770
951
  const rendererBlock = modulesSrc ? "<script>\n" + modulesSrc + "\n</script>\n" : "";
771
952
  // The shared gate session goes in before the overlay, which reads it at boot.
772
953
  const gateBlock = "<script>\n" + bundleGateSession().replace(/<\/script>/g, "<\\/script>") + "\n</script>\n";
954
+ const banner = readOnly
955
+ ? `<div id="plan-readonly-note" style="max-width:720px;margin:0 auto;` +
956
+ `padding:14px 28px 0;font:500 13.5px/1.5 ui-sans-serif,system-ui,sans-serif;` +
957
+ `color:#a8a8ad">This is the saved plan, read-only. To edit it, run ` +
958
+ `<code style="background:#1c1c20;padding:.12em .42em;border-radius:5px">` +
959
+ `${escapeHtml(readOnly)}</code>.</div>\n`
960
+ : "";
773
961
  return (
774
962
  "<!doctype html>\n" +
775
963
  '<html lang="en">\n' +
776
964
  "<head>\n" +
777
965
  '<meta charset="utf-8">\n' +
966
+ `${PLAN_PAGE_MARKER}\n` +
778
967
  '<meta name="viewport" content="width=device-width, initial-scale=1">\n' +
779
968
  "<title>Plan review — slidemind</title>\n" +
780
969
  FAVICON_LINK +
@@ -782,8 +971,11 @@ export function buildPlanPage({
782
971
  `<style>${PAGE_CSS}</style>\n` +
783
972
  "</head>\n" +
784
973
  "<body>\n" +
974
+ banner +
785
975
  `<div id="plan-root">${bodyHtml}</div>\n` +
786
976
  `<script>window.__SLIDEMIND_PLAN_REVIEW__ = ${cfg};</script>\n` +
977
+ LIVE_PATCH_SLOT +
978
+ "\n" +
787
979
  gateBlock +
788
980
  rendererBlock +
789
981
  `<script>\n${overlay}\n</script>\n` +
@@ -792,6 +984,125 @@ export function buildPlanPage({
792
984
  );
793
985
  }
794
986
 
987
+ // The served page IS the file at the deck root: the plan gate reads plan.html
988
+ // back and swaps this slot for the run's write channel (endpoint, round,
989
+ // layouts), which also clears `readOnly` and drops the banner. One rendering
990
+ // path, so the page the user edits and the page they reopen cannot diverge.
991
+ const LIVE_PATCH_SLOT = "<!--__PLAN_LIVE__-->";
992
+
993
+ export function withPlanWriteChannel(html, live) {
994
+ const patch =
995
+ "<script>\n" +
996
+ `Object.assign(window.__SLIDEMIND_PLAN_REVIEW__, ${safeInlineJson({ ...live, readOnly: null })});\n` +
997
+ 'document.addEventListener("DOMContentLoaded", function () {\n' +
998
+ ' var n = document.getElementById("plan-readonly-note");\n' +
999
+ " if (n) n.remove();\n" +
1000
+ "});\n" +
1001
+ "</script>";
1002
+ // Function replacers: `patch` embeds JSON.stringify'd reviewer comments and
1003
+ // user file names (imageAssets[].displayName), so a `$'`, `$&` or `` $` `` in
1004
+ // any of them would be expanded by String.replace and corrupt the script.
1005
+ return html.includes(LIVE_PATCH_SLOT)
1006
+ ? html.replace(LIVE_PATCH_SLOT, () => patch)
1007
+ : html.replace("</body>", () => `${patch}\n</body>`);
1008
+ }
1009
+
1010
+ function escapeHtml(text) {
1011
+ return String(text).replace(/[&<>"]/g, (c) =>
1012
+ ({ "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;" })[c],
1013
+ );
1014
+ }
1015
+
1016
+ // A deck root has one plan page and one writer of it (see planPageOwner). When
1017
+ // that is an extension — a pack deck, whose page renders a record this CLI
1018
+ // cannot read — the generic gates leave the page alone and name the command that
1019
+ // opens it. Reported once per workspace, on stderr so --json payloads stay clean.
1020
+ const ownerNoted = new Set();
1021
+ function foreignPlanPageOwner(deckDir) {
1022
+ const owner = planPageOwner(deckDir);
1023
+ if (owner.owner === "core") return null;
1024
+ if (!ownerNoted.has(deckDir)) {
1025
+ ownerNoted.add(deckDir);
1026
+ process.stderr.write(
1027
+ `slidemind: plan.html belongs to ${owner.owner} — left as it is. ` +
1028
+ `${owner.reviewCommand ? `Open it with \`${owner.reviewCommand}\`.` : `Its owner is not installed here.`}\n`,
1029
+ );
1030
+ }
1031
+ return owner;
1032
+ }
1033
+
1034
+ // Render `plan.html` at the DECK ROOT from the workspace's plan.md. Called
1035
+ // after every write to the record, so the file the user opens is never stale.
1036
+ // Returns the path written, or null when the deck has no plan record yet or the
1037
+ // page belongs to an extension.
1038
+ export function writePlanPage(deckDir) {
1039
+ const planPath = path.join(deckDir, "plan.md");
1040
+ let raw;
1041
+ try {
1042
+ raw = fs.readFileSync(planPath, "utf8");
1043
+ } catch {
1044
+ return null;
1045
+ }
1046
+ if (foreignPlanPageOwner(deckDir)) return null;
1047
+ const { body, bodyStartLine } = splitPlanFrontmatter(raw);
1048
+ const slug = deckSlug(deckDir);
1049
+ const html = buildPlanPage({
1050
+ bodyHtml: renderPlanMarkdown(body, { lineOffset: bodyStartLine - 1 }),
1051
+ overlayJs: fs.readFileSync(OVERLAY_PATH, "utf8"),
1052
+ planModulesJs: bundlePlanModulesFromDisk(),
1053
+ logoMark: null,
1054
+ planBody: body,
1055
+ bodyStartLine,
1056
+ round: null,
1057
+ deck: slug,
1058
+ ledgerSeed: loadPlanState(deckDir).comments || [],
1059
+ layouts: [],
1060
+ planRecord: raw,
1061
+ readOnly: `slidemind plan ${slug}`,
1062
+ });
1063
+ const out = planPagePath(deckDir);
1064
+ fs.mkdirSync(path.dirname(out), { recursive: true });
1065
+ fs.writeFileSync(out, html);
1066
+ // Claim the page the first time core renders it, so a later extension sees an
1067
+ // owner rather than having to guess from the page it finds.
1068
+ if (!readPlanOwner(deckDir)) {
1069
+ writePlanOwner(deckDir, {
1070
+ owner: "core",
1071
+ reviewCommand: `slidemind plan ${slug}`,
1072
+ recordPath: "plan.md",
1073
+ });
1074
+ }
1075
+ return out;
1076
+ }
1077
+
1078
+ /**
1079
+ * The deck's image registry as the plan page needs it: enough to NAME and SHOW
1080
+ * each asset a slide's `assetUses` points at, and nothing else.
1081
+ *
1082
+ * `previewUrl` is the manifest's own portable workspace path served off the
1083
+ * deck root — `serveStaticFile({roots:[deckRoot]})` already answers it, so
1084
+ * there is no second image route and no data URI inflating the page. An
1085
+ * unreadable manifest yields an empty list: the plan gate is how a user fixes
1086
+ * a deck, and it must open even when the registry is broken (the fence's own
1087
+ * typed issue still reports it).
1088
+ */
1089
+ export function planImageAssets(deckDir) {
1090
+ let manifest;
1091
+ try {
1092
+ manifest = readImageAssetManifest(deckDir);
1093
+ } catch {
1094
+ return [];
1095
+ }
1096
+ return manifest.assets.map((a) => ({
1097
+ assetId: a.assetId,
1098
+ displayName: a.displayName,
1099
+ mimeType: a.mimeType,
1100
+ width: a.width,
1101
+ height: a.height,
1102
+ previewUrl: `/${a.workspacePath}`,
1103
+ }));
1104
+ }
1105
+
795
1106
  // ---------------------------------------------------------------------------
796
1107
  // server
797
1108
  // ---------------------------------------------------------------------------
@@ -810,7 +1121,7 @@ export async function createPlanReviewServer({
810
1121
  brandAssetPath = null,
811
1122
  ledgerSeed = [],
812
1123
  layouts = [],
813
- chromeDir = null,
1124
+ designs = [],
814
1125
  port = 0,
815
1126
  }) {
816
1127
  const gate = createDecisionGate({
@@ -819,20 +1130,31 @@ export async function createPlanReviewServer({
819
1130
  // Own service marker so a tab left waiting on this port only reloads into the
820
1131
  // NEXT plan round — never into the preview gate or a foreign app on the port.
821
1132
  const hb = createHeartbeat({ service: "slidemind-plan" });
822
- const overlay = overlayJs ?? fs.readFileSync(OVERLAY_PATH, "utf8");
823
- const planModules = planModulesJs ?? bundlePlanModulesFromDisk();
824
- const bodyHtml = renderPlanMarkdown(body, { lineOffset: bodyStartLine - 1 });
825
- const indexHtml = buildPlanPage({
826
- bodyHtml,
827
- overlayJs: overlay,
828
- planModulesJs: planModules,
829
- logoMark,
830
- planBody: body,
831
- bodyStartLine,
1133
+ // What the gate serves IS the plan.html at the deck root: re-rendered from
1134
+ // the current record, then handed this run's write channel. So the page the
1135
+ // user edits here and the page they reopen from disk cannot drift apart.
1136
+ const pageFile = writePlanPage(deckDir);
1137
+ const diskHtml = pageFile
1138
+ ? fs.readFileSync(pageFile, "utf8")
1139
+ : buildPlanPage({
1140
+ bodyHtml: renderPlanMarkdown(body, { lineOffset: bodyStartLine - 1 }),
1141
+ overlayJs: overlayJs ?? fs.readFileSync(OVERLAY_PATH, "utf8"),
1142
+ planModulesJs: planModulesJs ?? bundlePlanModulesFromDisk(),
1143
+ logoMark: null,
1144
+ planBody: body,
1145
+ bodyStartLine,
1146
+ round: null,
1147
+ deck: deckSlug(deckDir),
1148
+ ledgerSeed: normalizePlanComments(ledgerSeed),
1149
+ layouts: [],
1150
+ });
1151
+ const indexHtml = withPlanWriteChannel(diskHtml, {
832
1152
  round: hb.round,
833
- deck: path.basename(path.resolve(deckDir)),
834
- ledgerSeed: normalizePlanComments(ledgerSeed),
1153
+ logoMark,
835
1154
  layouts,
1155
+ designs,
1156
+ imageAssets: planImageAssets(deckDir),
1157
+ ledgerSeed: normalizePlanComments(ledgerSeed),
836
1158
  });
837
1159
  const deckRoot = path.resolve(deckDir);
838
1160
 
@@ -854,6 +1176,7 @@ export async function createPlanReviewServer({
854
1176
  decision,
855
1177
  comments,
856
1178
  editedPlan: typeof payload.editedPlan === "string" ? payload.editedPlan : null,
1179
+ design: typeof payload.design === "string" ? payload.design : null,
857
1180
  });
858
1181
  },
859
1182
  });
@@ -912,9 +1235,11 @@ export async function createPlanReviewServer({
912
1235
  }
913
1236
 
914
1237
  // Layout chrome thumbnails for the picker — served from the materialized
915
- // bundle's assets dir, basename-guarded to the chrome-<Name>.<ext> shape.
1238
+ // bundle's assets dir (another cached design's with `?design=`, for the
1239
+ // design picker), basename-guarded to the chrome-<Name>.<ext> shape.
916
1240
  if (pathname.startsWith("/brand/chrome/")) {
917
1241
  const name = path.basename(pathname);
1242
+ const chromeDir = planChromeDir(deckDir, u.searchParams.get("design"));
918
1243
  if (!chromeDir || !CHROME_ASSET_RE.test(name)) {
919
1244
  res.writeHead(404);
920
1245
  res.end("not found");
@@ -981,21 +1306,178 @@ export function applyPlanEdit({ planPath, frontmatterRaw, body, editedPlan }) {
981
1306
  return { edited: true };
982
1307
  }
983
1308
 
1309
+ // Fold the targeted-edit image-use overrides into the plan being approved and
1310
+ // say so. Reported on stderr, never fatal: a sidecar this CLI cannot read must
1311
+ // not refuse a human's sign-off, and the obligation stays on file either way.
1312
+ function noteImageUseReconcile(deckDir) {
1313
+ const result = reconcileImageUseOverrides(deckDir);
1314
+ if (result.error) {
1315
+ process.stderr.write(`slidemind: image use overrides could not be read (${result.error}) — left as they are.\n`);
1316
+ return result;
1317
+ }
1318
+ if (result.applied.length) {
1319
+ process.stderr.write(
1320
+ `slidemind: folded image uses from ${result.applied.length} targeted edit${result.applied.length === 1 ? "" : "s"} ` +
1321
+ `into the approved plan (slide${result.applied.length === 1 ? "" : "s"} ${result.applied.map((a) => a.slide).join(", ")}).\n`,
1322
+ );
1323
+ }
1324
+ for (const entry of result.kept) {
1325
+ process.stderr.write(`slidemind: image use override for ${entry.slideId} kept — ${entry.why}.\n`);
1326
+ }
1327
+ return result;
1328
+ }
1329
+
984
1330
  // Persisted with the decision as this round's plan fingerprint (the round
985
1331
  // TRANSITION a reloaded tab detects is the server's heartbeat nonce, not this).
986
1332
  function hashPlan(planPath) {
987
1333
  return crypto.createHash("sha256").update(fs.readFileSync(planPath)).digest("hex");
988
1334
  }
989
1335
 
1336
+ /**
1337
+ * The plan's STORY fingerprint: the same markdown with every record's
1338
+ * `content` stripped before hashing.
1339
+ *
1340
+ * What a human signs off at the gate is the story, the layouts, the structure
1341
+ * and each slide's 3-5 key points (`contentNotes`). The agent then writes each
1342
+ * slide's full content draft into `content` — an elaboration of what was
1343
+ * already approved, not a new plan. Hashing the bytes would re-arm the gate on
1344
+ * that draft and stall every deck at the step after sign-off.
1345
+ *
1346
+ * Narrow on purpose, and safe by construction: a reworded beat or key point, a
1347
+ * swapped layout, a renamed heading or a reordered red thread all move this
1348
+ * hash and re-arm the gate. Only `content` is exempt.
1349
+ */
1350
+ export function planStoryHash(deckDir) {
1351
+ const markdown = fs.readFileSync(path.join(deckDir, "plan.md"), "utf8");
1352
+ const story = splitPlanMarkdownIntoSegments(markdown)
1353
+ .map((segment) => {
1354
+ if (segment.type !== "storyboard") return segment.text ?? "";
1355
+ if (!segment.data) return segment.raw ?? "";
1356
+ const { content: _content, ...rest } = segment.data;
1357
+ return serializeStoryboardCanvas(rest);
1358
+ })
1359
+ .join("\n");
1360
+ return crypto.createHash("sha256").update(story).digest("hex");
1361
+ }
1362
+
1363
+ /** The plan subject as the workspace names it: `sha256:<hex of plan.md bytes>`. */
1364
+ export function planSubjectHash(deckDir) {
1365
+ return "sha256:" + hashPlan(path.join(deckDir, "plan.md"));
1366
+ }
1367
+
1368
+ function bareHash(h) {
1369
+ return typeof h === "string" ? h.replace(/^sha256:/, "") : null;
1370
+ }
1371
+
1372
+ function refusal(status, code, message, details) {
1373
+ return { status, body: { ok: false, error: { code, message, retryable: false, ...(details ? { details } : {}) } } };
1374
+ }
1375
+
1376
+ // The ONE write path of a plan decision — the standalone gate's tail and the
1377
+ // workspace task response both land here, so plan.md, plan-review.json and
1378
+ // plan.html come out the same whichever page answered. `action` is approve,
1379
+ // comments (also `request-changes`, the task-contract name) or `done` (a
1380
+ // plan-edit task: the record is re-rendered, no decision is recorded).
1381
+ // `expectedHash` is the plan the reviewer saw (`planSubjectHash` or its bare
1382
+ // hex) — a plan.md rewritten since is refused, never approved by accident.
1383
+ // `operationId` makes a repeat idempotent; a different decision under the same
1384
+ // id is refused. `write` is the seam a crash test injects; the record is
1385
+ // written last and atomically, so a throw here leaves no approval behind.
1386
+ export function respondPlan(
1387
+ deckDir,
1388
+ {
1389
+ action,
1390
+ expectedHash = null,
1391
+ payload = {},
1392
+ actor = null,
1393
+ operationId = null,
1394
+ at = new Date().toISOString(),
1395
+ write = savePlanState,
1396
+ } = {},
1397
+ ) {
1398
+ const decision =
1399
+ action === "approve"
1400
+ ? "approve"
1401
+ : action === "comments" || action === "comment" || action === "request-changes"
1402
+ ? "comments"
1403
+ : action === "done"
1404
+ ? "done"
1405
+ : null;
1406
+ if (!decision) return refusal(422, "validation_failed", `unknown plan action '${action}'`);
1407
+ const planPath = path.join(deckDir, "plan.md");
1408
+ if (!fs.existsSync(planPath)) return refusal(404, "plan_missing", `no plan.md in ${deckSlug(deckDir)}`);
1409
+ const prev = loadPlanState(deckDir);
1410
+ if (operationId && prev.operationId === operationId) {
1411
+ if (prev.decision !== decision || (expectedHash != null && bareHash(expectedHash) !== prev.planHash)) {
1412
+ return refusal(409, "operation_mismatch", "this operation id already recorded a different response");
1413
+ }
1414
+ return {
1415
+ status: 200,
1416
+ body: { ok: true, decision: prev.decision, comments: prev.comments, edited: prev.edited, design: prev.design, planHash: prev.planHash, hash: "sha256:" + prev.planHash, at: prev.at, replayed: true },
1417
+ };
1418
+ }
1419
+ if (expectedHash != null && bareHash(expectedHash) !== hashPlan(planPath)) {
1420
+ return refusal(409, "stale_subject", "plan.md changed since this plan was reviewed", {
1421
+ expected: expectedHash,
1422
+ current: planSubjectHash(deckDir),
1423
+ });
1424
+ }
1425
+ if (decision === "done") {
1426
+ writePlanPage(deckDir);
1427
+ return { status: 200, body: { ok: true, decision, hash: planSubjectHash(deckDir), at } };
1428
+ }
1429
+ // A design picked at the gate rides with the answer; checked before anything
1430
+ // is written, so an unknown one refuses the whole answer, not half of it.
1431
+ const wantDesign = typeof payload?.design === "string" && payload.design ? payload.design : null;
1432
+ if (wantDesign && !cachedDesign(wantDesign)) {
1433
+ return refusal(422, "unknown_design", `design '${wantDesign}' is not cached on this machine`);
1434
+ }
1435
+ const { frontmatterRaw, body } = splitPlanFrontmatter(fs.readFileSync(planPath, "utf8"));
1436
+ const applied = applyPlanEdit({
1437
+ planPath,
1438
+ frontmatterRaw,
1439
+ body,
1440
+ editedPlan: typeof payload?.editedPlan === "string" ? payload.editedPlan : null,
1441
+ });
1442
+ // `payload.edited`: the workspace committed the human's edit just before this
1443
+ // answer (so the answer is about the edited plan); the record still says so.
1444
+ const edited = applied.edited || payload?.edited === true;
1445
+ const comments = decision === "comments" ? normalizePlanComments(payload?.comments) : [];
1446
+ // An approved revision makes the plan the source of approved intent again:
1447
+ // every image use a targeted edit recorded out-of-band is folded into its
1448
+ // slide's storyboard and cleared. BEFORE the hash, so the planHash recorded
1449
+ // with the approval is the hash of the plan that was folded into — otherwise
1450
+ // the very next command would read the plan as changed since sign-off.
1451
+ if (decision === "approve") noteImageUseReconcile(deckDir);
1452
+ const switched = wantDesign ? switchPlanDesign(deckDir, wantDesign) : null;
1453
+ const design = switched && !switched.error ? switched : null;
1454
+ const planHash = hashPlan(planPath);
1455
+ write(deckDir, {
1456
+ version: 3,
1457
+ decision,
1458
+ comments,
1459
+ edited,
1460
+ planHash,
1461
+ storyHash: planStoryHash(deckDir),
1462
+ at,
1463
+ ...(design ? { design } : {}),
1464
+ ...(operationId ? { operationId } : {}),
1465
+ ...(actor ? { actor } : {}),
1466
+ });
1467
+ return { status: 200, body: { ok: true, decision, comments, edited, design, planHash, hash: "sha256:" + planHash, at } };
1468
+ }
1469
+
990
1470
  export async function runPlanReview({
991
1471
  deckDir,
992
1472
  port = null,
993
1473
  timeoutMs = DEFAULT_TIMEOUT_MS,
1474
+ // null = decide (gateUsesWorkspace); tests force either path
1475
+ useWorkspace = null,
994
1476
  // quiet: serveAndOpen's BROWSER: line owns all open/failed messaging.
995
1477
  open = (url) => openInBrowser(url, { quiet: true }),
996
1478
  openFailedGraceMs = OPEN_FAILED_GRACE_MS,
997
1479
  } = {}) {
998
- const base = path.basename(deckDir);
1480
+ const base = deckSlug(deckDir);
999
1481
  const planPath = path.join(deckDir, "plan.md");
1000
1482
  if (!fs.existsSync(planPath)) {
1001
1483
  process.stderr.write(
@@ -1005,31 +1487,67 @@ export async function runPlanReview({
1005
1487
  return;
1006
1488
  }
1007
1489
 
1008
- for (const line of planDeliverableWarnings(deckDir, fs.readFileSync(planPath, "utf8"))) {
1009
- process.stdout.write(` ${line}\n`);
1010
- }
1490
+ // A plan that fails the plan checks opens no round (refuse). Only a NEW round
1491
+ // is checked: handing over an answer the user already gave never is.
1492
+ const refuse = () => {
1493
+ const why = planRefusal(deckDir);
1494
+ if (why) {
1495
+ process.stdout.write(why);
1496
+ process.exitCode = 1;
1497
+ }
1498
+ return why;
1499
+ };
1011
1500
 
1012
1501
  // Headless simulation runs (scripts/codex-sim.mjs): approve without a browser.
1013
1502
  if (process.env.SLIDEMIND_GATE_AUTOAPPROVE === "1") {
1503
+ if (refuse()) return null;
1504
+ noteImageUseReconcile(deckDir);
1014
1505
  savePlanState(deckDir, {
1015
1506
  version: 3,
1016
1507
  decision: "approve",
1017
1508
  comments: [],
1018
1509
  edited: false,
1019
1510
  planHash: hashPlan(planPath),
1511
+ storyHash: planStoryHash(deckDir),
1020
1512
  at: new Date().toISOString(),
1021
1513
  });
1022
1514
  process.stdout.write(
1023
1515
  "DECISION: approved\n" +
1024
1516
  "Auto-approved via SLIDEMIND_GATE_AUTOAPPROVE — no human reviewed this plan. " +
1025
- "Proceed to author the slides.\n",
1517
+ `Next: ${NEXT_AFTER_APPROVAL}\n`,
1026
1518
  );
1027
1519
  return { decision: "approve", comments: [] };
1028
1520
  }
1029
1521
 
1030
- const { frontmatterRaw, body, bodyStartLine } = splitPlanFrontmatter(
1031
- fs.readFileSync(planPath, "utf8"),
1032
- );
1522
+ // The human answers in the workspace: ask there and return at once (ASK + SAY;
1523
+ // the agent ends its turn). The user's pasted message names this command again,
1524
+ // which then hands their answer over. A workspace that cannot be used is a
1525
+ // blocker — never a second, standalone page (doc 21). Only headless runs and
1526
+ // SLIDEMIND_WORKSPACE=0 reach the standalone gate below.
1527
+ const gate = await import("../workspace/gate-client.mjs");
1528
+ if (useWorkspace ?? gate.gateUsesWorkspace(deckDir, "plan-approval")) {
1529
+ const r = await gate.runWorkspaceGate({
1530
+ deckDir,
1531
+ kind: "plan-approval",
1532
+ approvedNow: () => planApprovedExactly(deckDir),
1533
+ refuseOpen: () => planRefusal(deckDir),
1534
+ // the plan as it stands is the one the user last saved in the workspace's editor: they edited it
1535
+ format: (task, { note }) => process.stdout.write(formatPlanTaskDecision(deckDir, task, { note, editedInPage: gate.planLastSavedInPage(deckDir) })),
1536
+ });
1537
+ if (r.refused) {
1538
+ process.exitCode = 1;
1539
+ return null;
1540
+ }
1541
+ if (r.blocked) {
1542
+ process.stdout.write(`BLOCKED: ${r.blocked.code}\n${r.blocked.message}\n`);
1543
+ process.exitCode = 2;
1544
+ return null;
1545
+ }
1546
+ return r.decision === "pending" ? null : { decision: r.decision };
1547
+ }
1548
+
1549
+ if (refuse()) return null;
1550
+ const { body, bodyStartLine } = splitPlanFrontmatter(fs.readFileSync(planPath, "utf8"));
1033
1551
 
1034
1552
  // The plan window's logo is the add-in's genuine per-brand orb when a bundle
1035
1553
  // is materialized (dark variant preferred, matching the dark UI). Absent a
@@ -1037,7 +1555,7 @@ export async function runPlanReview({
1037
1555
  const mark = resolveBrandAsset(deckDir, ["logoMarkDark", "logoMark", "logoMarkLight"]);
1038
1556
  const logoMark = mark ? "/brand/logo" : null;
1039
1557
  const brandAssetPath = mark?.path ?? null;
1040
- const { layouts, chromeDir } = resolvePlanLayouts(deckDir);
1558
+ const { layouts } = resolvePlanLayouts(deckDir);
1041
1559
 
1042
1560
  const prev = loadPlanState(deckDir);
1043
1561
 
@@ -1052,7 +1570,7 @@ export async function runPlanReview({
1052
1570
  // (the plan gate's analogue of review.json's ghost pins).
1053
1571
  ledgerSeed: prev.comments || [],
1054
1572
  layouts,
1055
- chromeDir,
1573
+ designs: planDesignOptions(deckDir),
1056
1574
  port: wantPort,
1057
1575
  });
1058
1576
  // A waiting tab can only exist when the previous round ended in comments — so
@@ -1103,40 +1621,18 @@ export async function runPlanReview({
1103
1621
  server.close();
1104
1622
  }
1105
1623
 
1106
- const { edited } = applyPlanEdit({
1107
- planPath,
1108
- frontmatterRaw,
1109
- body,
1110
- editedPlan: decision.editedPlan,
1111
- });
1112
-
1113
- const comments = normalizePlanComments(decision.comments);
1114
- savePlanState(deckDir, {
1115
- version: 3,
1116
- decision: decision.decision,
1117
- comments,
1118
- edited,
1119
- planHash: hashPlan(planPath),
1120
- at: new Date().toISOString(),
1624
+ const answered = respondPlan(deckDir, {
1625
+ action: decision.decision,
1626
+ payload: { comments: decision.comments, editedPlan: decision.editedPlan, design: decision.design },
1121
1627
  });
1628
+ if (answered.status !== 200) throw new Error(answered.body.error.message);
1629
+ const { edited, comments, design } = answered.body;
1122
1630
 
1123
1631
  if (decision.decision === "approve") {
1124
- if (edited) {
1125
- process.stdout.write(
1126
- "DECISION: approved\n" +
1127
- "The user approved the plan — and edited plan.md directly before approving. " +
1128
- `Re-read ${deckDir}/plan.md: the edited text IS the approved plan. Author from ` +
1129
- "it, not from your earlier draft.\n",
1130
- );
1131
- } else {
1132
- process.stdout.write(
1133
- "DECISION: approved\n" +
1134
- "The user approved the plan. Proceed to author the slides — the plan is agreed.\n",
1135
- );
1136
- }
1632
+ process.stdout.write(planApprovedText(deckDir, { edited, design }));
1137
1633
  } else {
1138
1634
  process.stdout.write(
1139
- formatPlanCommentsForHarness(deckDir, comments, { edited }),
1635
+ formatPlanCommentsForHarness(deckDir, comments, { edited, design }),
1140
1636
  );
1141
1637
  }
1142
1638
  return decision;
@@ -1153,6 +1649,13 @@ function parseArgs(argv) {
1153
1649
  args.deck = argv[++i];
1154
1650
  } else if (a === "--port" && i + 1 < argv.length) {
1155
1651
  args.port = parseInt(argv[++i], 10) || 0;
1652
+ } else if (a === "--wait" && i + 1 < argv.length) {
1653
+ // retired with the bounded wait (a question returns at once); accepted so a
1654
+ // stale skill or script that still passes it does not fail
1655
+ i++;
1656
+ } else if (a === "--json") {
1657
+ // Accepted like every deck command's --json (SKILL.md); the gate's
1658
+ // output already is its machine contract — the one DECISION: line.
1156
1659
  } else if (a === "-h" || a === "--help") {
1157
1660
  process.stdout.write(usage);
1158
1661
  process.exit(0);