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
@@ -46,9 +46,10 @@ import path from "node:path";
46
46
  import { fileURLToPath, pathToFileURL } from "node:url";
47
47
 
48
48
  import { buildDeck, openInBrowser } from "./build.mjs";
49
- import { draftCheckBlocker, verdictBlocker } from "./draft-check.mjs";
49
+ import { draftCheckBlocker } from "./draft-check.mjs";
50
+ import { isEditRouteDeck } from "./edit-route.mjs";
50
51
  import { resolveSkillForDeck } from "./resolve-skill.mjs";
51
- import { resolveBrandAsset } from "../bridge/bundle.mjs";
52
+ import { resolveBrandAsset, resolveBundleDirForDeck } from "../bridge/bundle.mjs";
52
53
  import {
53
54
  MIME,
54
55
  serveStaticFile,
@@ -66,10 +67,16 @@ import {
66
67
  POLL_MS,
67
68
  POLL_TTL_MS,
68
69
  } from "./gate-server.mjs";
70
+ import {
71
+ bundleOwnedFiles,
72
+ deckSlug,
73
+ previewPath,
74
+ statePath,
75
+ } from "../lib/deck-paths.mjs";
69
76
 
70
77
  const __filename = fileURLToPath(import.meta.url);
71
78
  const __dirname = path.dirname(__filename);
72
- const OVERLAY_PATH = path.join(__dirname, "review-overlay.js");
79
+ export const OVERLAY_PATH = path.join(__dirname, "review-overlay.js");
73
80
  const REVIEW_STATE_FILE = "review.json";
74
81
  // No decision timeout: the gate returns only on the user's decision (or when
75
82
  // no browser could be opened at all — see openFailedGraceMs). An agent cannot
@@ -78,6 +85,36 @@ const REVIEW_STATE_FILE = "review.json";
78
85
  // lands. Pass timeoutMs explicitly to cap the wait (tests).
79
86
  const DEFAULT_TIMEOUT_MS = null;
80
87
 
88
+ // The bundle's assets dir, or null when no design is materialized for the deck
89
+ // (the preview then carries no brand chrome and the page has nothing to rewrite).
90
+ export function brandAssetsDirForDeck(deckDir) {
91
+ try {
92
+ const dir = resolveSkillForDeck(deckDir)?.brand?.assetsDir;
93
+ return dir && fs.existsSync(dir) ? dir : null;
94
+ } catch {
95
+ return null;
96
+ }
97
+ }
98
+
99
+ // A browser on an http page may not load file: subresources, so the absolute
100
+ // bundle URLs buildDeck wrote for the disk view (chrome-*, logos, decor) become
101
+ // `route`, which the serving page answers from that same directory. Both the
102
+ // standalone review server and the workspace serve the page through this.
103
+ export function routeBrandAssets(pageHtml, brandAssetsDir, route) {
104
+ if (!brandAssetsDir) return pageHtml;
105
+ const brandUrl = pathToFileURL(brandAssetsDir).href.replace(/\/$/, "");
106
+ return pageHtml.split(brandUrl).join(route);
107
+ }
108
+
109
+ // The review window's logo is the add-in's genuine per-brand orb: a PNG
110
+ // shipped on the bundle as a role-keyed asset, served from the bundle cache
111
+ // (never copied into the deck). The UI is dark, so prefer the dark variant. When absent
112
+ // (brand predates the feature / bundle not re-materialized), the overlay shows
113
+ // a plain slide glyph — the CLI no longer re-derives an orb.
114
+ export function reviewLogoFile(deckDir) {
115
+ return resolveBrandAsset(deckDir, ["logoMarkDark", "logoMark", "logoMarkLight"])?.filename ?? null;
116
+ }
117
+
81
118
  function clamp01(n) {
82
119
  return n < 0 ? 0 : n > 1 ? 1 : n;
83
120
  }
@@ -196,7 +233,7 @@ export function formatCommentsForHarness(deckDir, comments) {
196
233
  // persisted round is what survives.
197
234
  out.push(
198
235
  `This round (all ${n} comment${n === 1 ? "" : "s"}) is also saved verbatim in ` +
199
- `${path.join(deckDir, REVIEW_STATE_FILE)} — if this output looks truncated or ` +
236
+ `${statePath(deckDir, REVIEW_STATE_FILE)} — if this output looks truncated or ` +
200
237
  `you can't see every numbered comment above, read that file for the full list ` +
201
238
  `instead of guessing or asking the user to re-type anything.`,
202
239
  );
@@ -204,22 +241,58 @@ export function formatCommentsForHarness(deckDir, comments) {
204
241
  // Imperative, stateful tail (mirrors plan.mjs's formatPlanCommentsForHarness):
205
242
  // the reviewer's tab is waiting on the next `slidemind review` run — nothing
206
243
  // reaches them until it runs. An agent that ends its turn here strands them.
244
+ // The draft check sits between edit and re-run because `review` refuses a
245
+ // slide edited since its last draft check (`[draft:stale]`, draft-check.mjs):
246
+ // telling the agent to re-run `review` straight away costs a refused call.
247
+ const slug = deckSlug(deckDir);
248
+ const cited = [
249
+ ...new Set(
250
+ rows
251
+ .map((c) => c.jsxPath)
252
+ .filter((p) => p.startsWith("slides/") && p.endsWith(".jsx"))
253
+ .map((p) => path.basename(p)),
254
+ ),
255
+ ];
207
256
  out.push("Fix exactly what the comments ask for at the cited lines — read the file");
208
257
  out.push("before you edit. Then re-run the gate in the same turn:");
209
258
  out.push(" 1. Edit the cited slides/*.jsx file(s) at the given lines.");
210
- out.push(` 2. Re-run: slidemind review ${path.basename(deckDir)}`);
259
+ out.push(
260
+ ` 2. Draft-check every slide you edited: slidemind view-draft ${slug}` +
261
+ (cited.length ? ` --only ${cited.join(",")}` : "") +
262
+ " — and fix what it measures (review refuses a slide edited since its last draft check).",
263
+ );
264
+ out.push(` 3. Re-run: slidemind review ${slug}`);
211
265
  out.push("The re-run rebuilds the preview and reopens their same tab (addressed pins");
212
266
  out.push("show as ghost pins), and the user reviews again — several rounds are normal.");
213
267
  out.push("Do NOT end your turn, do NOT export anything, and do NOT send the user back");
214
- out.push("to the browser before step 2.");
268
+ out.push("to the browser before step 3.");
215
269
  return out.join("\n") + "\n";
216
270
  }
217
271
 
272
+ const REVIEW_APPROVED_TEXT =
273
+ "DECISION: approved\n" +
274
+ "The user approved the preview. Proceed to produce the output they asked " +
275
+ "for (slidemind push / slidemind build-pptx). One approval covers the task.\n";
276
+
277
+ /**
278
+ * A workspace task's answer, printed exactly as the standalone gate prints its
279
+ * own: the pins come from the gate record the answer wrote (review.json, the
280
+ * same operation), else the task's own comment text.
281
+ */
282
+ export function formatReviewTaskDecision(deckDir, task, { note = null } = {}) {
283
+ const head = note ? `NOTE: ${note}\n` : "";
284
+ if (task?.response?.action === "approve") return head + REVIEW_APPROVED_TEXT;
285
+ const state = loadReviewState(deckDir);
286
+ const mine = Boolean(state.operationId) && task?.response?.operationId === state.operationId;
287
+ const comments = mine ? state.comments : task?.response?.comment ? [{ text: task.response.comment }] : [];
288
+ return head + formatCommentsForHarness(deckDir, comments);
289
+ }
290
+
218
291
  // Render the no-answer outcome for the harness — same contract as plan.mjs's
219
292
  // formatPlanPendingForHarness: an expected state with resume instructions, not
220
293
  // a failure and never an implicit approval.
221
294
  export function formatReviewPendingForHarness(deckDir, { timeoutMs, pageLoaded, url } = {}) {
222
- const base = path.basename(deckDir);
295
+ const base = deckSlug(deckDir);
223
296
  const waited = timeoutMs
224
297
  ? `the gate waited ${Math.max(1, Math.round(timeoutMs / 60000))} minutes and the user hasn't answered.`
225
298
  : "the gate closed without an answer from the user.";
@@ -237,7 +310,7 @@ export function formatReviewPendingForHarness(deckDir, { timeoutMs, pageLoaded,
237
310
  ].join("\n") + "\n";
238
311
  }
239
312
 
240
- function injectReview(html, cfgExtra, overlayJs, logoMark = null, round = null) {
313
+ export function injectReview(html, cfgExtra, overlayJs, logoMark = null, round = null) {
241
314
  const cfg = safeInlineJson({
242
315
  endpoint: "/review",
243
316
  heartbeat: "/__review/heartbeat",
@@ -264,7 +337,7 @@ const EMPTY_STATE = { version: 2, decision: null, comments: [] };
264
337
  // drives the tab-reuse grace window in runReview (a waiting tab can only exist
265
338
  // after a prior comments round).
266
339
  export function loadReviewState(deckDir) {
267
- const f = path.join(deckDir, REVIEW_STATE_FILE);
340
+ const f = statePath(deckDir, REVIEW_STATE_FILE);
268
341
  if (!fs.existsSync(f)) return { ...EMPTY_STATE };
269
342
  try {
270
343
  const data = JSON.parse(fs.readFileSync(f, "utf8"));
@@ -275,19 +348,41 @@ export function loadReviewState(deckDir) {
275
348
  comments: normalizeReviewComments(data.comments),
276
349
  deckHash: data.deckHash ?? null,
277
350
  at: data.at ?? null,
351
+ // Workspace provenance (respondPreview): which operation wrote this round
352
+ // and who answered. Absent on rounds the standalone gate recorded.
353
+ operationId: typeof data.operationId === "string" ? data.operationId : null,
354
+ actor: data.actor ?? null,
355
+ approval: readApproval(data),
278
356
  };
279
357
  } catch {
280
358
  return { ...EMPTY_STATE };
281
359
  }
282
360
  }
283
361
 
362
+ // The deck's standing sign-off: the round that approved, carried through any
363
+ // later comments round (respondPreview writes it forward). An approving round
364
+ // is its own record, so only a later round stores the field.
365
+ function readApproval(data) {
366
+ const a = data.approval;
367
+ if (a && typeof a === "object" && typeof a.deckHash === "string") {
368
+ return { at: a.at ?? null, deckHash: a.deckHash, actor: a.actor ?? null };
369
+ }
370
+ if (data.decision === "approve") return { at: data.at ?? null, deckHash: data.deckHash ?? null, actor: data.actor ?? null };
371
+ return null;
372
+ }
373
+
284
374
  // What the user reviewed: every file under slides/ (jsx + reused native .pptx)
285
- // and assets/ (downloaded imagery), hashed by path + bytes. Recorded with the
286
- // decision and re-checked by push/build-pptx, so an approval binds to exactly
287
- // the content the user saw — any edit after the checkmark sends the deck back
288
- // through the gate.
375
+ // and DECK-OWNED assets/ (downloaded imagery), hashed by path + bytes. Copies of
376
+ // the bundle a pre-workspace deck still carries are skipped — verified copies, by
377
+ // bundle manifest and byte identity, so re-materializing the design with the same
378
+ // content cannot invalidate an approval while a user's own `master-data.png` still
379
+ // counts. Recorded with the decision, so a round binds to exactly the content the
380
+ // user saw: an answer on since-edited slides is refused as stale, and a gate
381
+ // asked again on unchanged slides answers at once. It does not gate delivery
382
+ // after the approval — see requireReviewApproved.
289
383
  export function hashDeckContent(deckDir) {
290
384
  const h = crypto.createHash("sha256");
385
+ const bundleOwned = bundleOwnedFiles(deckDir, resolveBundleDirForDeck(deckDir));
291
386
  const walk = (dir) => {
292
387
  let entries = [];
293
388
  try {
@@ -299,7 +394,9 @@ export function hashDeckContent(deckDir) {
299
394
  const p = path.join(dir, e.name);
300
395
  if (e.isDirectory()) walk(p);
301
396
  else if (e.isFile()) {
302
- h.update(path.relative(deckDir, p).split(path.sep).join("/"));
397
+ const rel = path.relative(deckDir, p).split(path.sep).join("/");
398
+ if (bundleOwned.has(rel)) continue;
399
+ h.update(rel);
303
400
  h.update("\0");
304
401
  h.update(fs.readFileSync(p));
305
402
  h.update("\0");
@@ -311,31 +408,115 @@ export function hashDeckContent(deckDir) {
311
408
  }
312
409
 
313
410
  // The human checkmark push/build-pptx require — enforced by the CLI, not by the
314
- // skill's prose. null = approved and unchanged since; otherwise the agent-facing
411
+ // skill's prose. ONE per deck: the approval builds the deck, and every change
412
+ // after it (an edit, a comment on the delivered deck, a reconcile fold, a
413
+ // repair) goes straight into the .pptx, where the user sees it, without a second
414
+ // review. So this asks whether the deck ever passed the review, never whether
415
+ // the slides still match it. A deck adopted from the user's own .pptx (the edit
416
+ // route) never asks at all: it was a deck before any agent touched it, so its
417
+ // edits are already "afterwards". null = passed; otherwise the agent-facing
315
418
  // reason plus the command that clears it.
316
419
  export function requireReviewApproved(deckDir) {
317
- const base = path.basename(deckDir);
420
+ if (loadReviewState(deckDir).approval) return null;
421
+ if (isEditRouteDeck(deckDir)) return null;
422
+ const base = deckSlug(deckDir);
423
+ return (
424
+ `${base} has not passed the preview review — a human must approve it first: ` +
425
+ `run \`slidemind review ${base}\` and wait for DECISION: approved.`
426
+ );
427
+ }
428
+
429
+ // The latest round approved exactly these slides: a gate asked again on
430
+ // unchanged content answers at once instead of asking the user twice.
431
+ export function previewApprovedAsIs(deckDir) {
318
432
  const state = loadReviewState(deckDir);
319
- if (state.decision !== "approve") {
320
- return (
321
- `${base} has not passed the preview review — a human must approve it first: ` +
322
- `run \`slidemind review ${base}\` and wait for DECISION: approved.`
323
- );
324
- }
325
- if (state.deckHash !== hashDeckContent(deckDir)) {
326
- return (
327
- `${base}'s slides changed since the user approved the preview — ` +
328
- `re-run \`slidemind review ${base}\` for a fresh sign-off.`
329
- );
330
- }
331
- return null;
433
+ return state.decision === "approve" && state.deckHash === hashDeckContent(deckDir);
332
434
  }
333
435
 
334
- function saveReviewState(deckDir, state) {
335
- fs.writeFileSync(
336
- path.join(deckDir, REVIEW_STATE_FILE),
337
- JSON.stringify(state, null, 2) + "\n",
338
- );
436
+ // Sidecar write as temp + rename: a gate record is either the whole round or
437
+ // absent — a crash mid-write must not leave a half-parsed approval behind.
438
+ export function writeStateAtomic(deckDir, name, contents) {
439
+ const file = statePath(deckDir, name);
440
+ fs.mkdirSync(path.dirname(file), { recursive: true });
441
+ const tmp = `${file}.${process.pid}.${crypto.randomBytes(4).toString("hex")}.tmp`;
442
+ fs.writeFileSync(tmp, contents);
443
+ fs.renameSync(tmp, file);
444
+ return file;
445
+ }
446
+
447
+ export function saveReviewState(deckDir, state) {
448
+ writeStateAtomic(deckDir, REVIEW_STATE_FILE, JSON.stringify(state, null, 2) + "\n");
449
+ }
450
+
451
+ /** The preview subject as the workspace names it: `sha256:<hashDeckContent>`. */
452
+ export function previewHash(deckDir) {
453
+ return "sha256:" + hashDeckContent(deckDir);
454
+ }
455
+
456
+ function bareHash(h) {
457
+ return typeof h === "string" ? h.replace(/^sha256:/, "") : null;
458
+ }
459
+
460
+ function refusal(status, code, message, details) {
461
+ return { status, body: { ok: false, error: { code, message, retryable: false, ...(details ? { details } : {}) } } };
462
+ }
463
+
464
+ // The ONE write path of a preview decision — the standalone gate's tail and the
465
+ // workspace task response both land here, so review.json carries the same
466
+ // evidence whichever page answered. `expectedHash` is the subject the reviewer
467
+ // saw (`previewHash` or its bare hex); a deck edited since is refused, never
468
+ // approved by accident. `operationId` makes a repeat idempotent: the recorded
469
+ // round comes back unchanged, and a different decision under the same id is
470
+ // refused. `write` is the seam a crash test injects; the record is written last
471
+ // and atomically, so a throw here leaves no approval behind.
472
+ export function respondPreview(
473
+ deckDir,
474
+ {
475
+ action,
476
+ expectedHash = null,
477
+ payload = {},
478
+ actor = null,
479
+ operationId = null,
480
+ at = new Date().toISOString(),
481
+ write = saveReviewState,
482
+ } = {},
483
+ ) {
484
+ const decision =
485
+ action === "approve" ? "approve" : action === "comment" || action === "comments" ? "comments" : null;
486
+ if (!decision) return refusal(422, "validation_failed", `unknown preview action '${action}'`);
487
+ const prev = loadReviewState(deckDir);
488
+ if (operationId && prev.operationId === operationId) {
489
+ if (prev.decision !== decision || (expectedHash != null && bareHash(expectedHash) !== prev.deckHash)) {
490
+ return refusal(409, "operation_mismatch", "this operation id already recorded a different response");
491
+ }
492
+ return {
493
+ status: 200,
494
+ body: { ok: true, decision: prev.decision, comments: prev.comments, deckHash: prev.deckHash, hash: "sha256:" + prev.deckHash, at: prev.at, replayed: true },
495
+ };
496
+ }
497
+ const deckHash = hashDeckContent(deckDir);
498
+ if (expectedHash != null && bareHash(expectedHash) !== deckHash) {
499
+ return refusal(409, "stale_subject", "the slides changed since this preview was reviewed", {
500
+ expected: expectedHash,
501
+ current: "sha256:" + deckHash,
502
+ });
503
+ }
504
+ const comments = decision === "comments" ? normalizeReviewComments(payload?.comments) : [];
505
+ // A comments round after the approval keeps it: the review is one-time per
506
+ // deck, and asking for a change is not taking the sign-off back.
507
+ const kept = decision === "comments" ? prev.approval : null;
508
+ const state = {
509
+ version: 2,
510
+ decision,
511
+ comments,
512
+ deckHash,
513
+ at,
514
+ ...(kept ? { approval: { at: kept.at, deckHash: kept.deckHash, ...(kept.actor ? { actor: kept.actor } : {}) } } : {}),
515
+ ...(operationId ? { operationId } : {}),
516
+ ...(actor ? { actor } : {}),
517
+ };
518
+ write(deckDir, state);
519
+ return { status: 200, body: { ok: true, decision, comments, deckHash, hash: "sha256:" + deckHash, at } };
339
520
  }
340
521
 
341
522
  // Deterministic per-deck port so a tab left waiting from the previous round can
@@ -347,12 +528,13 @@ export function preferredPortForDeck(deckDir) {
347
528
  }
348
529
 
349
530
  // Start the local review server. Returns { url, port, waitForDecision, waitForHeartbeat, close }.
350
- // presentation.html must already exist in deckDir (build it first).
531
+ // preview/presentation.html must already exist in the workspace (build it first).
351
532
  export async function createReviewServer({
352
533
  deckDir,
353
534
  ledgerSeed = [],
354
535
  overlayJs,
355
536
  logoMark = null,
537
+ brandAssetsDir = null,
356
538
  port = 0,
357
539
  }) {
358
540
  const gate = createDecisionGate();
@@ -362,8 +544,11 @@ export async function createReviewServer({
362
544
  const hb = createHeartbeat({ service: "slidemind-review" });
363
545
  const overlay =
364
546
  overlayJs ?? fs.readFileSync(OVERLAY_PATH, "utf8");
547
+ // The bundle's file: URLs become /brand/assets, served below from the same
548
+ // directory. The deck itself still carries no copy.
549
+ const pageHtml = fs.readFileSync(previewPath(deckDir, "presentation.html"), "utf8");
365
550
  const indexHtml = injectReview(
366
- fs.readFileSync(path.join(deckDir, "presentation.html"), "utf8"),
551
+ routeBrandAssets(pageHtml, brandAssetsDir, "/brand/assets"),
367
552
  {
368
553
  // deck: the sessionStorage namespace for this deck's pin ledger.
369
554
  // ledgerSeed: the previous round's pins — a FRESH tab (no session) renders
@@ -372,14 +557,14 @@ export async function createReviewServer({
372
557
  // a content hash: a fix round that edited nothing hashes identically, and
373
558
  // an unreadable slides/ dir hashed to "" — both made the transition
374
559
  // undetectable, which stranded pins in "sent" forever.
375
- deck: path.basename(path.resolve(deckDir)),
560
+ deck: deckSlug(deckDir),
376
561
  ledgerSeed: normalizeReviewComments(ledgerSeed),
377
562
  },
378
563
  overlay,
379
564
  logoMark,
380
565
  hb.round,
381
566
  );
382
- const deckRoot = path.resolve(deckDir);
567
+ const workspaceRoot = path.resolve(deckDir);
383
568
 
384
569
  const server = http.createServer((req, res) => {
385
570
  const u = new URL(req.url, "http://127.0.0.1");
@@ -437,10 +622,27 @@ export async function createReviewServer({
437
622
  return;
438
623
  }
439
624
 
440
- // Traversal-guarded static serving from the deck root; a miss or an escape
625
+ // Traversal-guarded static serving from the workspace root; a miss or an escape
441
626
  // attempt falls through to the caller's 404 (serveStaticFile never writes on
442
627
  // a non-match).
443
- if (serveStaticFile({ roots: [deckRoot], pathname, res })) return;
628
+ // Brand files come from the bundle cache; everything else (slides/, the
629
+ // deck's own assets/, preview/fonts/) from the workspace.
630
+ if (pathname.startsWith("/brand/assets/")) {
631
+ if (
632
+ brandAssetsDir &&
633
+ serveStaticFile({
634
+ roots: [brandAssetsDir],
635
+ pathname: pathname.slice("/brand/assets".length),
636
+ res,
637
+ })
638
+ ) {
639
+ return;
640
+ }
641
+ res.writeHead(404);
642
+ res.end("not found");
643
+ return;
644
+ }
645
+ if (serveStaticFile({ roots: [workspaceRoot], pathname, res })) return;
444
646
  res.writeHead(404);
445
647
  res.end("not found");
446
648
  });
@@ -503,23 +705,23 @@ export async function runReview({
503
705
  // quiet: serveAndOpen's BROWSER: line owns all open/failed messaging.
504
706
  open = (url) => openInBrowser(url, { quiet: true }),
505
707
  openFailedGraceMs = OPEN_FAILED_GRACE_MS,
506
- allowDraftDefects = false,
708
+ // null = decide (gateUsesWorkspace); tests force either path
709
+ useWorkspace = null,
507
710
  } = {}) {
508
- await buildDeck(deckDir);
509
-
510
- // The gate does not open on defects the last draft check MEASURED (fill
511
- // bands, dropped connectors, unused assets) while the slide files are
512
- // unchanged since: a warning printed after "task done" is a warning nobody
513
- // acts on. Editing the slide re-arms the check; --allow-draft-defects is
514
- // the explicit override.
515
- if (!allowDraftDefects) {
516
- const standing = draftCheckBlocker(deckDir) ?? verdictBlocker(deckDir);
517
- if (standing) {
518
- throw new Error(
519
- `review not opened — ${standing}\n` +
520
- "(pass --allow-draft-defects to open the gate anyway)",
521
- );
522
- }
711
+ const gate = await import("../workspace/gate-client.mjs");
712
+ const viaWorkspace = useWorkspace ?? gate.gateUsesWorkspace(deckDir, "preview-review");
713
+ // Asked again about this exact, still-current build (the build and its checks
714
+ // passed when it was asked — the user said "continue" without answering yet),
715
+ // or receiving an answer the user already gave: nothing to rebuild first.
716
+ const resuming = viaWorkspace && ((await gate.previewAnswerWaiting(deckDir)) || (await gate.openPreviewQuestionIsCurrent(deckDir)));
717
+ if (!resuming) await buildDeck(deckDir);
718
+
719
+ // The user reviews only slides the draft check passed: every slide checked
720
+ // at its current bytes, no defect of the shared table (draft-lines.mjs)
721
+ // standing. There is no override — the same rule holds delivery.
722
+ if (!resuming) {
723
+ const standing = draftCheckBlocker(deckDir);
724
+ if (standing) throw new Error(`review not opened — ${standing}`);
523
725
  }
524
726
 
525
727
  // Headless simulation runs (scripts/codex-sim.mjs): approve without a browser.
@@ -540,23 +742,38 @@ export async function runReview({
540
742
  return { decision: "approve", comments: [] };
541
743
  }
542
744
 
745
+ // The human answers in the workspace: the preview built above is what the page
746
+ // shows; ask there and return at once (ASK + SAY; the agent ends its turn). The
747
+ // user's pasted message names this command again, which hands their answer
748
+ // over. A workspace that cannot be used is a blocker — never a second,
749
+ // standalone page (doc 21).
750
+ if (viaWorkspace) {
751
+ const r = await gate.runWorkspaceGate({
752
+ deckDir,
753
+ kind: "preview-review",
754
+ approvedNow: () => previewApprovedAsIs(deckDir),
755
+ format: (task, { note }) => process.stdout.write(formatReviewTaskDecision(deckDir, task, { note })),
756
+ });
757
+ if (r.blocked) {
758
+ process.stdout.write(`BLOCKED: ${r.blocked.code}\n${r.blocked.message}\n`);
759
+ process.exitCode = 2;
760
+ return null;
761
+ }
762
+ return r.decision === "pending" ? null : { decision: r.decision };
763
+ }
764
+
543
765
  const prev = loadReviewState(deckDir);
544
- // Hashed after buildDeck (it copies brand assets into assets/) and before the
545
- // gate opens: the agent is blocked while the user reviews, so this IS the
546
- // content the decision refers to.
766
+ // Hashed before the gate opens: the agent is blocked while the user reviews,
767
+ // so this IS the content the decision refers to.
547
768
  const deckHash = hashDeckContent(deckDir);
548
- // The review window's logo is the add-in's genuine per-brand orb: a PNG
549
- // shipped on the bundle as a role-keyed asset (copied into the deck by
550
- // buildDeck). The review UI is dark, so prefer the dark variant. When absent
551
- // (brand predates the feature / bundle not re-materialized), the overlay shows
552
- // a plain slide glyph — the CLI no longer re-derives an orb.
553
- const mark = resolveBrandAsset(deckDir, ["logoMarkDark", "logoMark", "logoMarkLight"]);
554
- const logoMark = mark ? "/assets/" + mark.filename : null;
769
+ const mark = reviewLogoFile(deckDir);
770
+ const logoMark = mark ? "/brand/assets/" + mark : null;
555
771
  const wantPort = port == null ? preferredPortForDeck(deckDir) : port;
556
772
  const server = await createReviewServer({
557
773
  deckDir,
558
774
  ledgerSeed: prev.comments || [],
559
775
  logoMark,
776
+ brandAssetsDir: brandAssetsDirForDeck(deckDir),
560
777
  port: wantPort,
561
778
  });
562
779
  // A waiting tab can only exist when the previous round ended in comments — so
@@ -604,21 +821,20 @@ export async function runReview({
604
821
  server.close();
605
822
  }
606
823
 
607
- const comments = normalizeReviewComments(decision.comments);
608
- saveReviewState(deckDir, {
609
- version: 2,
610
- decision: decision.decision,
611
- comments,
612
- deckHash,
613
- at: new Date().toISOString(),
824
+ // No `expectedHash` here, exactly as `runPlanReview` does it. The hash binds the
825
+ // gate's ROUND (the page the user answered); binding the write too would make
826
+ // any write into slides/ or assets/ during the review refuse with stale_subject
827
+ // and throw away the comments the user just typed. The agent is blocked while
828
+ // the user reviews, so the hash this call records is the content they answered.
829
+ const answered = respondPreview(deckDir, {
830
+ action: decision.decision,
831
+ payload: { comments: decision.comments },
614
832
  });
833
+ if (answered.status !== 200) throw new Error(answered.body.error.message);
834
+ const comments = answered.body.comments;
615
835
 
616
836
  if (decision.decision === "approve") {
617
- process.stdout.write(
618
- "DECISION: approved\n" +
619
- "The user approved the preview. Proceed to produce the output they asked " +
620
- "for (slidemind push / slidemind build-pptx). One approval covers the task.\n",
621
- );
837
+ process.stdout.write(REVIEW_APPROVED_TEXT);
622
838
  } else {
623
839
  process.stdout.write(
624
840
  formatCommentsForHarness(deckDir, comments),
@@ -630,19 +846,24 @@ export async function runReview({
630
846
  function parseArgs(argv) {
631
847
  // port null ⇒ derive the deterministic per-deck port; an explicit --port
632
848
  // (incl. --port 0 for OS-random) overrides it.
633
- const args = { deck: null, port: null, allowDraftDefects: false };
849
+ const args = { deck: null, port: null, wait: null };
634
850
  for (let i = 0; i < argv.length; i++) {
635
851
  const a = argv[i];
636
852
  if (a === "--deck" && i + 1 < argv.length) {
637
853
  args.deck = argv[++i];
638
854
  } else if (a === "--port" && i + 1 < argv.length) {
639
855
  args.port = parseInt(argv[++i], 10) || 0;
640
- } else if (a === "--allow-draft-defects") {
641
- args.allowDraftDefects = true;
856
+ } else if (a === "--wait" && i + 1 < argv.length) {
857
+ // retired with the bounded wait (a question returns at once); accepted so a
858
+ // stale skill or script that still passes it does not fail
859
+ i++;
860
+ } else if (a === "--json") {
861
+ // Accepted like every deck command's --json (SKILL.md); the gate's
862
+ // output already is its machine contract — the one DECISION: line.
642
863
  } else if (a === "-h" || a === "--help") {
643
864
  process.stdout.write(
644
- "Usage: slidemind review <deck> [--port <n>] [--allow-draft-defects]\n" +
645
- " node src/builders/review.mjs --deck <deck> [--port <n>] [--allow-draft-defects]\n",
865
+ "Usage: slidemind review <deck> [--port <n>]\n" +
866
+ " node src/builders/review.mjs --deck <deck> [--port <n>]\n",
646
867
  );
647
868
  process.exit(0);
648
869
  } else {
@@ -660,7 +881,7 @@ function parseArgs(argv) {
660
881
  async function main() {
661
882
  const args = parseArgs(process.argv.slice(2));
662
883
  const deckDir = path.resolve(args.deck);
663
- await runReview({ deckDir, port: args.port, allowDraftDefects: args.allowDraftDefects });
884
+ await runReview({ deckDir, port: args.port });
664
885
  }
665
886
 
666
887
  if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) {