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
@@ -2,6 +2,11 @@ import fs from "node:fs";
2
2
  import path from "node:path";
3
3
  import { pathToFileURL } from "node:url";
4
4
  import { fail, requirePlanApproved, resolveDeckArg, freshenDeckBundle, base64FromDataUri, SRC_DIR, BUILDERS_DIR } from "./common.mjs";
5
+ import { deckSlug } from "../lib/deck-paths.mjs";
6
+ import { pidAlive } from "../lib/revision.mjs";
7
+ import { imageDigest, mockupFileFor, replaceMockup, specSlidesByState, specState } from "../lib/concept-spec-binding.mjs";
8
+ import { engineFailure } from "../builders/asset-fetch.mjs";
9
+ import { SPECS_UNSUPPORTED, specsUnsupportedPayload, specsUnsupportedWarning } from "./concept-spec-support.mjs";
5
10
 
6
11
  export const REFERENCE_MIME = {
7
12
  ".png": "image/png",
@@ -11,8 +16,10 @@ export const REFERENCE_MIME = {
11
16
  ".gif": "image/gif",
12
17
  };
13
18
 
14
- // `--concept <image>[@N,M]` / `--style <image>[@N,M]` → one inline reference
15
- // `{ role, image, slides?, name }` for the concept-images route.
19
+ // `--concept <image>[@N,M]` / `--style <image>[@N,M]` → the flag, parsed:
20
+ // `{ role, file, slides?, name }`. The image-intent adapter imports `file`
21
+ // (src/builders/legacy-reference-uses.mjs) and records the use in plan.md; the
22
+ // request then carries it in the image-intent envelope, never as a flag.
16
23
  export function readReferenceArg(role, spec) {
17
24
  if (!spec) fail(`--${role} expects <image>[@slides]`);
18
25
  const at = spec.lastIndexOf("@");
@@ -28,26 +35,169 @@ export function readReferenceArg(role, spec) {
28
35
  if (at > 0 && slides.length === 0) {
29
36
  fail(`--${role} ${spec}: expected slide numbers after '@', e.g. sketch.png@2,3`);
30
37
  }
31
- const mime = REFERENCE_MIME[path.extname(file).toLowerCase()];
32
- if (!mime) fail(`--${role} ${file}: expected a png/jpg/webp/gif image`);
33
- let bytes;
34
- try {
35
- bytes = fs.readFileSync(file);
36
- } catch {
37
- fail(`--${role} ${file}: file not found`);
38
- }
38
+ if (!REFERENCE_MIME[path.extname(file).toLowerCase()]) fail(`--${role} ${file}: expected a png/jpg/webp/gif image`);
39
+ if (!fs.existsSync(file)) fail(`--${role} ${file}: file not found`);
39
40
  return {
40
41
  role,
41
- image: `data:${mime};base64,${bytes.toString("base64")}`,
42
+ file,
42
43
  ...(slides.length > 0 ? { slides } : {}),
43
44
  name: path.basename(file),
44
45
  };
45
46
  }
46
47
 
48
+ /**
49
+ * The plan's image obligations as the trailing block of `concepts`' human
50
+ * output (`imageObligations()` builds the records; `--json` carries the same
51
+ * ones as `imageUses`).
52
+ *
53
+ * It prints on every exit that has any, including the ones with no mockup and
54
+ * no spec at all: a frame slide, and a deck whose backend cannot generate or
55
+ * extract. The source path is what the slide's `<img>` references, so the agent
56
+ * can author the picture straight from the plan.
57
+ */
58
+ export function imageUseLines(uses) {
59
+ if (!uses?.length) return "";
60
+ const lines = [
61
+ `IMAGE USES: ${uses.length} from the plan — an \`original\` must appear as that exact file; it is never searched for, regenerated or recoloured.`,
62
+ ];
63
+ for (const u of uses) {
64
+ const where = u.frame ? `slide ${u.slide} (frame slide, no mockup)` : `slide ${u.slide}`;
65
+ const src = u.source
66
+ ? `./${u.source}${u.displayName ? ` ("${u.displayName}")` : ""}`
67
+ : `source ${u.assetId} is not in this deck's registry`;
68
+ lines.push(` ${where} · ${u.useId} · ${u.obligation} · ${src} — "${u.instruction}"`);
69
+ }
70
+ return lines.join("\n") + "\n";
71
+ }
72
+
73
+ /**
74
+ * The image-intent half of one `slides[]` entry of a spec request: the
75
+ * fingerprint RECORDED beside that slide's mockup, or nothing.
76
+ *
77
+ * Two rules, and its own function so that a test can hold it to them.
78
+ *
79
+ * **Recorded, never recomputed.** At request time the recorded value and a
80
+ * freshly computed one are always equal — the local staleness guard either
81
+ * regenerates a slide whose intent moved (rewriting the record) or skips it, so
82
+ * no reachable state has them differ. Nothing observable would therefore
83
+ * distinguish `recordedIntent[slide]` from a fresh `slideIntentFingerprints()`
84
+ * call, and swapping one for the other would quietly turn the server's
85
+ * staleness check into a no-op that always passes. This signature cannot
86
+ * compute a fingerprint, so a unit test can hand it a value no computation
87
+ * could produce and see it arrive verbatim.
88
+ *
89
+ * **No envelope, no fingerprint.** The server checks a declared fingerprint
90
+ * against the intent THIS request carries; declaring one without the envelope
91
+ * compares it against "no uses" and refuses the slide.
92
+ */
93
+ export function specIntentFields(recordedIntent, imageIntent, slide) {
94
+ const recorded = imageIntent ? (recordedIntent?.[slide] ?? null) : null;
95
+ return recorded ? { intentFingerprint: recorded } : {};
96
+ }
97
+
98
+ // Two spec requests of four slides, not one of eight: a sync request died at the
99
+ // ingress's ~240 s on dense slides, and one 8-slide deck run sequentially cost 9
100
+ // minutes of empty polling (x27). Four keeps each body under ~8 MB.
101
+ export const SPEC_BATCH = 4;
102
+
103
+ // Is a failed spec read worth another `concepts` run? The one rule, for both
104
+ // kinds of failure:
105
+ // - `thrown`: the request itself failed (a client timeout, a 429, a relay or
106
+ // gateway error) — it says nothing about the mockup: re-run.
107
+ // - `answer`: a per-slide failure inside a response the server did give. Only
108
+ // a rate limit is a re-run; an abort or timeout there means the server
109
+ // spent its whole per-slide budget on that slide, so on the retry pass —
110
+ // the second time — the slide is authored from its concept by hand
111
+ // (`workflow/create.md`, step 2).
112
+ // `wireFailed` carries a first pass that threw: its slides were never read at
113
+ // all, so nothing the retry answers can make them unreadable. A transient
114
+ // failure is never sent to hand authoring: `transcribe` refuses while a spec is
115
+ // still owed (x69: a timed-out POST said "author from its PNG", transcribe
116
+ // exited 1, a plain re-run read 3/3).
117
+ export function transientSpecFailure({ wireFailed = false, thrown = null, answer = null } = {}) {
118
+ const text = (x) => String(x?.message ?? x ?? "");
119
+ const rateLimited = (x) => x?.status === 429 || x?.code === "rate_limited" || /\b429\b|rate.?limit|too many requests/i.test(text(x));
120
+ if (wireFailed) return true;
121
+ if (thrown) {
122
+ return rateLimited(thrown) || thrown.code === "bridge_timeout" || engineFailure(thrown).retryable ||
123
+ /timed out|abort|connection error/i.test(text(thrown));
124
+ }
125
+ return rateLimited(answer);
126
+ }
127
+
128
+ export function specFailureLine(f, deck) {
129
+ if (f.code === "image_intent_stale") return `slidemind: slide ${f.slide} — ${f.error}`;
130
+ return f.transient
131
+ ? `slidemind: concept spec for slide ${f.slide} not read (${f.error}) — a transient failure, not the mockup; re-run \`slidemind concepts ${deck}\` before transcribe (it asks only for the missing slides).`
132
+ : `slidemind: concept spec for slide ${f.slide} failed on both passes (${f.error}) — author that slide by hand from its concept against its layout (workflow/create.md, step 2); write its file before \`slidemind transcribe ${deck}\`, which then writes the others.`;
133
+ }
134
+
135
+ // The same two remedies, folded into the one CONCEPTS line.
136
+ export function specFailureNote(failed, deck) {
137
+ const slides = (transient) => failed
138
+ .filter((f) => f.code !== "image_intent_stale" && Boolean(f.transient) === transient)
139
+ .map((f) => f.slide)
140
+ .sort((a, b) => a - b);
141
+ const [rerun, unreadable] = [slides(true), slides(false)];
142
+ const s = (list) => (list.length === 1 ? "" : "s");
143
+ return [
144
+ rerun.length ? `slide${s(rerun)} ${rerun.join(", ")} not read (transient) — re-run \`slidemind concepts ${deck}\` before transcribe` : "",
145
+ unreadable.length ? `slide${s(unreadable)} ${unreadable.join(", ")} unreadable — author ${unreadable.length === 1 ? "it" : "them"} by hand from the concept (workflow/create.md, step 2)` : "",
146
+ ].filter(Boolean).join("; ");
147
+ }
148
+
149
+ /**
150
+ * Cut the spec-extraction work into requests, grouped by SLOT and never by
151
+ * slide number.
152
+ *
153
+ * `slot` is a REQUEST-level field — every slide in one request is measured
154
+ * against it — and consecutive slides rarely share a layout (cover, content,
155
+ * divider). A batch cut by index therefore mapped every element rect of its
156
+ * 2nd..4th slide onto geometry belonging to its 1st. The tone was already
157
+ * resolved per slide; the slot has to be too.
158
+ *
159
+ * Slot identity is the slot's VALUE, not the layout's name: two layouts with
160
+ * the same content rect measure identically and may share a request.
161
+ */
162
+ export function batchBySlot(items, slotOf, size = SPEC_BATCH, { weightOf = null, maxBytes = 0 } = {}) {
163
+ const bySlot = new Map();
164
+ for (const item of items) {
165
+ const slot = slotOf(item);
166
+ const key = JSON.stringify(slot ?? null);
167
+ if (!bySlot.has(key)) bySlot.set(key, { slot, items: [] });
168
+ bySlot.get(key).items.push(item);
169
+ }
170
+ const batches = [];
171
+ for (const { slot, items: group } of bySlot.values()) {
172
+ let batch = [];
173
+ let bytes = 0;
174
+ for (const item of group) {
175
+ const weight = weightOf ? weightOf(item) : 0;
176
+ // Two ceilings, whichever comes first: the slide COUNT (four dense slides
177
+ // is what the server parallelises well) and the encoded BYTE size of what
178
+ // this request will actually carry. The count alone stopped being a proxy
179
+ // for the size the moment a request could also carry source images — four
180
+ // slides of ordinary mockups is a few MB, four with screenshots attached
181
+ // is not. An item heavier than the whole budget still gets its own
182
+ // request: the list is never shortened to make it fit (guide §4).
183
+ if (batch.length && (batch.length >= size || (maxBytes > 0 && bytes + weight > maxBytes))) {
184
+ batches.push({ slot, batch });
185
+ batch = [];
186
+ bytes = 0;
187
+ }
188
+ batch.push(item);
189
+ bytes += weight;
190
+ }
191
+ if (batch.length) batches.push({ slot, batch });
192
+ }
193
+ return batches;
194
+ }
195
+
47
196
  export async function cmdConceptImages(rest) {
48
197
  let deck = null;
49
198
  let force = false;
50
199
  let asJson = false;
200
+ let statusOnly = false;
51
201
  const references = [];
52
202
  for (let i = 0; i < rest.length; i++) {
53
203
  const a = rest[i];
@@ -58,6 +208,8 @@ export async function cmdConceptImages(rest) {
58
208
  force = true;
59
209
  } else if (a === "--json") {
60
210
  asJson = true;
211
+ } else if (a === "--status") {
212
+ statusOnly = true;
61
213
  } else if (a === "--concept" || a === "--style") {
62
214
  references.push(readReferenceArg(a.slice(2), rest[++i]));
63
215
  // A new user reference changes what the mockups should be.
@@ -73,6 +225,29 @@ export async function cmdConceptImages(rest) {
73
225
  if (!deck) fail("usage: slidemind concepts <deck>");
74
226
 
75
227
  const deckDir = resolveDeckArg(deck);
228
+ // Whether a run is in progress and how far it got, read from disk — the
229
+ // agent's sandbox blocks ps, and without this it started concepts again
230
+ // (x51, x53, x58: four to five runs on one deck).
231
+ if (statusOnly) {
232
+ let running = null;
233
+ try {
234
+ const holder = JSON.parse(fs.readFileSync(path.join(deckDir, "state", "concepts.lock"), "utf8"));
235
+ if (pidAlive(holder.pid)) running = holder;
236
+ } catch {}
237
+ const count = (dir, re) => { try { return fs.readdirSync(dir).filter((f) => re.test(f)).length; } catch { return 0; } };
238
+ const mockups = count(path.join(deckDir, "concepts"), /^concept-slide-\d+\.png$/);
239
+ // Only a spec read from the mockup now on disk counts; one read from an
240
+ // earlier picture is named, not counted (concept-spec-binding.mjs).
241
+ const { current, outdated } = specSlidesByState(deckDir);
242
+ const specs = current.length;
243
+ const status = { deck: deckSlug(deckDir), running: Boolean(running), since: running?.at ?? null, mockups, specs, outdatedSpecs: outdated };
244
+ process.stdout.write(asJson
245
+ ? `${JSON.stringify(status)}\n`
246
+ : `CONCEPTS: ${running ? `running since ${running.at}` : "not running"} — ${mockups} mockup${mockups === 1 ? "" : "s"}, ${specs} spec${specs === 1 ? "" : "s"} on disk` +
247
+ (outdated.length ? ` (slide${outdated.length === 1 ? "" : "s"} ${outdated.join(", ")}: spec not read from the mockup on disk — the next \`concepts\` run reads ${outdated.length === 1 ? "it" : "them"} again)` : "") +
248
+ `${running ? "; wait, do not start another run" : ""}\n`);
249
+ return;
250
+ }
76
251
  const planPath = path.join(deckDir, "plan.md");
77
252
  if (!fs.existsSync(planPath)) {
78
253
  fail(
@@ -80,42 +255,522 @@ export async function cmdConceptImages(rest) {
80
255
  `Run \`slidemind plan ${deck}\` to compose and sign off a plan first.`,
81
256
  );
82
257
  }
258
+ // The legacy flags become durable image uses BEFORE anything reads the plan:
259
+ // the file is imported into the deck's registry and the use is recorded in
260
+ // plan.md, where the user reviews it. A changed plan then re-arms the
261
+ // existing sign-off gate below — this never approves anything itself.
262
+ if (references.length) await adaptLegacyReferences(deckDir, references, deck);
263
+
83
264
  const planMarkdown = fs.readFileSync(planPath, "utf8");
84
265
  // Every exit ends on ONE machine-readable line — `CONCEPTS: n/m …` — so a
85
266
  // harness that cut the output short, and an agent reading a summary, can
86
267
  // tell "generated", "none available" and "still running" apart without a
87
268
  // double-evidence ritual. --json prints the same facts as a payload.
88
- const finish = ({ paths = [], planned = 0, status, reason = null, missing = [], seeded = false, extra = "" }) => {
269
+ const finish = async ({ paths = [], planned = 0, status, reason = null, missing = [], extra = "" }) => {
89
270
  const conceptsDir = path.join(deckDir, "concepts");
271
+ // The spec step runs on every exit that has mockups on disk — generated
272
+ // now or re-printed from disk — so a deck whose specs failed last time
273
+ // (or predate a newer add-in) gets them on the next `concepts` call.
274
+ const specs = paths.length ? await ensureConceptSpecs(paths) : { written: [], failed: [], skipped: null, unsupported: null };
275
+ // An add-in that cannot read mockups at all is said on stderr in every
276
+ // mode: the `--json` field alone is what let a designer agent walk past it.
277
+ if (specs.unsupported) for (const line of specsUnsupportedWarning(specs.unsupported, deck)) process.stderr.write(`${line}\n`);
278
+ const imageUses = await imageObligations();
279
+ const notes = conceptIntent.readConceptNotes(deckDir);
280
+ const onDisk = paths.map((p) => Number(/concept-slide-(\d+)\.png$/.exec(p)?.[1])).filter(Boolean);
281
+ const noteLines = conceptIntent.conceptNoteLines(notes, onDisk);
90
282
  if (asJson) {
283
+ const conceptNotes = Object.fromEntries(onDisk.filter((n) => notes[n]).map((n) => [n, notes[n]]));
91
284
  process.stdout.write(
92
- `${JSON.stringify({ deck: path.basename(deckDir), status, generated: paths.length, planned, paths, missing, reason, conceptsDir, assetPlan: path.join(deckDir, "asset-plan.md"), seededAssetPlan: seeded }, null, 2)}\n`,
285
+ `${JSON.stringify({ deck: deckSlug(deckDir), status, generated: paths.length, planned, paths, specs: specs.written, specsFailed: specs.failed, specsSkipped: specs.skipped, specsUnsupported: specsUnsupportedPayload(specs.unsupported, deck), missing, imageUses, conceptNotes, notes: noteLines, reason, conceptsDir }, null, 2)}\n`,
93
286
  );
94
287
  } else {
95
288
  if (paths.length) process.stdout.write(paths.join("\n") + "\n");
289
+ const usage = specUsageTotals(deckDir);
290
+ const unread = specFailureNote(specs.failed, deck);
291
+ // A skipped read keeps the specs already on disk (a re-run in an outage,
292
+ // an unbound deck): the PNG is the source only while there are none.
293
+ const specNote = paths.length
294
+ ? specs.skipped
295
+ ? specs.written.length
296
+ ? ` — ${specs.written.length}/${paths.length} specs on disk (${specs.skipped}); next: \`slidemind transcribe ${deck}\``
297
+ : ` — no specs (${specs.skipped}) — Read each PNG, then rebuild as slide JSX (workflow/create.md).`
298
+ : (specs.written.length ? ` — ${specs.written.length}/${paths.length} read into specs${usage.slides ? ` (${usage.model ?? "vision model"}: ${Math.round(usage.inputTokens / 1000)}k in / ${Math.round(usage.outputTokens / 1000)}k out)` : ""}` : "") +
299
+ (unread ? ` — ${unread}` : "") +
300
+ (specs.written.length && !specs.failed.some((f) => f.transient) ? `; next: \`slidemind transcribe ${deck}\`` : "")
301
+ : "";
96
302
  process.stdout.write(
97
303
  `CONCEPTS: ${paths.length}/${planned} ${status}${reason ? ` — ${reason}` : ""}` +
98
304
  (missing.length ? ` (no mockup for slide${missing.length > 1 ? "s" : ""} ${missing.join(", ")} — author from the storyboard)` : "") +
99
- (paths.length ? ` — Read each PNG, then rebuild as slide JSX (workflow/create.md).` : "") +
100
- `\n${extra}`,
305
+ specNote +
306
+ `\n${imageUseLines(imageUses)}${noteLines.map((l) => `${l}\n`).join("")}${extra}`,
101
307
  );
102
308
  }
103
309
  process.exit(0);
104
310
  };
311
+
312
+ // Concept specs: the add-in's vision inventory of each mockup (elements with
313
+ // slot-pixel rects, verbatim text, icons, connectors, charts). Written once
314
+ // per PNG as .spec.json for mechanical transcription; `--force` rewrites.
315
+ // Never fatal: a slide without a spec gets a re-run or its storyboard
316
+ // (specFailureLine), never its PNG.
317
+ const specMod = await import(pathToFileURL(path.join(BUILDERS_DIR, "concept-spec.mjs")).href);
318
+ const bundleForSpecs = await import(pathToFileURL(path.join(SRC_DIR, "bridge", "bundle.mjs")).href);
319
+ // The deck's image intent: what each slide's approved uses fingerprint to
320
+ // (src/bridge/image-intent-envelope.mjs) and what the mockups on disk were
321
+ // generated under (src/builders/concept-intent.mjs).
322
+ const intentMod = await import(pathToFileURL(path.join(SRC_DIR, "bridge", "image-intent-envelope.mjs")).href);
323
+ const conceptIntent = await import(pathToFileURL(path.join(BUILDERS_DIR, "concept-intent.mjs")).href);
324
+ // The slides whose mockup on disk was generated under a DIFFERENT image
325
+ // intent than the plan now carries (filled in below, once the planned set is
326
+ // known). Marking, not deleting: the bytes stay — they may be the only copy,
327
+ // and one of them may be a mockup a human supplied — but every path that
328
+ // would REUSE them is closed against this set (design decision 6).
329
+ const staleSlides = new Set();
330
+ // The slides whose mockup THIS RUN actually rewrote. A stale slide is only
331
+ // safe to read into a spec once it is in here: everywhere else the file on
332
+ // disk is still the one the mark refers to.
333
+ const regeneratedSlides = new Set();
334
+ // The fingerprint each slide's SPEC is keyed by — the same approved uses in
335
+ // the `spec` context, plus the generation of the persisted spec shape. It is
336
+ // a second fingerprint on purpose: a mockup and the inventory read out of it
337
+ // are separate derived outputs, and `concepts` routinely hands a slide a
338
+ // current mockup (adopted, or regenerated in an earlier run) beside a spec
339
+ // that was read before the plan carried the image at all. Filled in below,
340
+ // once the planned set is known.
341
+ let specFingerprints = new Map();
342
+ const sbForSpecs = await import(pathToFileURL(path.join(BUILDERS_DIR, "storyboard-markdown.mjs")).href);
343
+ const storyboardBySlide = new Map();
344
+ for (const segment of sbForSpecs.splitPlanMarkdownIntoSegments(planMarkdown)) {
345
+ if (segment.type === "storyboard" && segment.data?.slide) storyboardBySlide.set(segment.data.slide, segment.data);
346
+ }
347
+ /**
348
+ * Every image obligation the PLAN carries, for every slide it names — frame
349
+ * slides included.
350
+ *
351
+ * A frame slide (cover, divider, closing) gets no mockup and no spec by
352
+ * design, and a backend that cannot generate or extract leaves a body slide
353
+ * in exactly the same position: no picture to read, no inventory to read it
354
+ * into. The obligation is the plan's either way, so it is reported FROM the
355
+ * plan on every exit. Nothing here needs a spec to exist, nothing invents one,
356
+ * and a missing concept service cannot relax what the user approved
357
+ * (design decision 4; guide §5, A19).
358
+ */
359
+ async function imageObligations() {
360
+ const slides = [...storyboardBySlide.keys()].sort((a, b) => a - b);
361
+ if (!slides.length) return [];
362
+ const uses = intentMod.effectiveUsesForSlides(deckDir, slides, { planMarkdown });
363
+ if (![...uses.values()].some((list) => list.length)) return [];
364
+ const { describeUse } = await import(pathToFileURL(path.join(SRC_DIR, "lib", "image-intent.mjs")).href);
365
+ const { readImageAssetManifest } = await import(pathToFileURL(path.join(SRC_DIR, "lib", "image-assets.mjs")).href);
366
+ // A registry that cannot be read costs the lines their file name, never the
367
+ // obligation itself — the one thing this must not be able to drop.
368
+ let byId = new Map();
369
+ try {
370
+ byId = new Map(readImageAssetManifest(deckDir).assets.map((a) => [a.assetId, a]));
371
+ } catch {}
372
+ const frames = new Set(frameSlides.map((f) => f.slideNumber));
373
+ const out = [];
374
+ for (const slide of slides) {
375
+ for (const use of uses.get(slide) ?? []) {
376
+ const asset = byId.get(use.assetId) ?? null;
377
+ out.push({
378
+ slide,
379
+ frame: frames.has(slide),
380
+ useId: use.useId,
381
+ assetId: use.assetId,
382
+ fidelity: use.fidelity,
383
+ obligation: describeUse(use),
384
+ instruction: use.instruction,
385
+ source: asset?.workspacePath ?? null,
386
+ displayName: asset?.displayName ?? null,
387
+ });
388
+ }
389
+ }
390
+ return out;
391
+ }
392
+
393
+ // The model tokens the deck's specs cost, summed from the spec files.
394
+ function specUsageTotals(dir) {
395
+ const t = { slides: 0, inputTokens: 0, outputTokens: 0, model: null };
396
+ for (const n of specMod.listSpecSlides(dir)) {
397
+ const u = specMod.readSpec(dir, n)?.usage;
398
+ if (!u) continue;
399
+ t.slides++;
400
+ t.inputTokens += u.inputTokens ?? 0;
401
+ t.outputTokens += u.outputTokens ?? 0;
402
+ t.model ??= u.model ?? null;
403
+ }
404
+ return t;
405
+ }
406
+ async function ensureConceptSpecs(pngPaths) {
407
+ const conceptsDir = path.join(deckDir, "concepts");
408
+ const todo = [];
409
+ const written = [];
410
+ const staleUnreplaced = [];
411
+ // Specs on disk whose recorded identity no longer matches the plan's.
412
+ const refused = [];
413
+ // Specs on disk that were not read from the mockup now beside them.
414
+ const rebound = [];
415
+ for (const png of pngPaths) {
416
+ const m = /concept-slide-(\d+)\.png$/.exec(png);
417
+ if (!m) continue;
418
+ const n = Number(m[1]);
419
+ // The mockup on disk is the one whose image intent changed, and nothing
420
+ // replaced it this run (generation was unavailable, or failed for this
421
+ // slide). Reading a spec from it would inventory a picture the plan no
422
+ // longer describes AND record that inventory as current — the same silent
423
+ // reuse the fast path and the spec reuse below already refuse. The guard
424
+ // is structural on purpose: it must not rest on the extraction backend
425
+ // happening to be unreachable too, which is only true while
426
+ // `conceptSpecs` and `conceptImages` travel together.
427
+ if (staleSlides.has(n) && !regeneratedSlides.has(n)) {
428
+ staleUnreplaced.push(n);
429
+ continue;
430
+ }
431
+ // A spec read from a mockup whose image intent has since changed cannot
432
+ // be reused either: nothing is deleted, so the old spec stays readable
433
+ // until a new one replaces it. Nor can one read from other bytes than
434
+ // the mockup on disk, or with no record of its bytes: a spec is the
435
+ // inventory of one picture (concept-spec-binding.mjs, x112 GTM).
436
+ const state = force || staleSlides.has(n) ? null : specState(deckDir, n);
437
+ const onDisk = state?.status === "current" ? state.spec : null;
438
+ if (state && state.status !== "current" && state.status !== "absent") rebound.push(n);
439
+ if (onDisk) {
440
+ // The spec's own identity, checked against the plan as it stands. Since
441
+ // NOTHING is deleted on a mismatch, a reusable-looking file is sitting
442
+ // right there and the refusal has to be explicit — an absent spec means
443
+ // "not read yet", never "the stale one was cleaned up".
444
+ //
445
+ // null === null is the legacy case and stays reusable: a slide with no
446
+ // image uses records no fingerprint, and neither did any spec written
447
+ // before this contract existed.
448
+ const want = specFingerprints.get(n) ?? null;
449
+ if ((onDisk.intentFingerprint ?? null) === want) {
450
+ written.push(specMod.specFileFor(deckDir, n));
451
+ continue;
452
+ }
453
+ refused.push(n);
454
+ }
455
+ todo.push({ n, png });
456
+ }
457
+ if (staleUnreplaced.length) {
458
+ const one = staleUnreplaced.length === 1;
459
+ process.stderr.write(
460
+ `slidemind: slide${one ? "" : "s"} ${staleUnreplaced.join(", ")} still show${one ? "s" : ""} the earlier image intent — ` +
461
+ `no spec is read from ${one ? "that mockup" : "those mockups"}, and the spec${one ? "" : "s"} already on disk ` +
462
+ `${one ? "is" : "are"} left as ${one ? "it is" : "they are"}. Re-run once a backend can regenerate ${one ? "it" : "them"}.\n`,
463
+ );
464
+ }
465
+ if (rebound.length) {
466
+ const one = rebound.length === 1;
467
+ process.stderr.write(
468
+ `slidemind: the spec${one ? "" : "s"} on disk for slide${one ? "" : "s"} ${rebound.join(", ")} ` +
469
+ `${one ? "was" : "were"} not read from the mockup now on disk — read again below.\n`,
470
+ );
471
+ }
472
+ if (refused.length) {
473
+ const one = refused.length === 1;
474
+ process.stderr.write(
475
+ `slidemind: the spec${one ? "" : "s"} on disk for slide${one ? "" : "s"} ${refused.join(", ")} ` +
476
+ `${one ? "was" : "were"} read under a different image intent — ${one ? "it is" : "they are"} not reused; ` +
477
+ `${one ? "the file stays" : "the files stay"} on disk until a new read replaces ${one ? "it" : "them"}.\n`,
478
+ );
479
+ }
480
+ // The plan's layout, resolved against the bundle as the add-in resolves it,
481
+ // and its content slot. A slide on a layout with no slot (or one the design
482
+ // does not carry) takes no inventory: it is said once, and transcribe owes
483
+ // it nothing either.
484
+ const bundleDir = bundleForSpecs.resolveBundleDirForDeck(deckDir);
485
+ const slotBySlide = new Map();
486
+ for (const item of [...todo]) {
487
+ const planned = storyboardBySlide.get(item.n)?.layout;
488
+ const resolved = specMod.plannedLayoutSlot(bundleDir, planned);
489
+ if (resolved.slot) {
490
+ slotBySlide.set(item.n, resolved.slot);
491
+ continue;
492
+ }
493
+ todo.splice(todo.indexOf(item), 1);
494
+ process.stderr.write(`slidemind: slide ${item.n}: ${specMod.layoutSlotNote(planned, resolved)}\n`);
495
+ }
496
+ if (todo.length === 0) return { written, failed: [], skipped: null };
497
+ const brandIdForSpecs = bundleForSpecs.readDeckBundleRef(deckDir)?.presFP ?? null;
498
+ if (!brandIdForSpecs) return { written, failed: [], skipped: "deck not bound to a design" };
499
+ const clientForSpecs = await import(pathToFileURL(path.join(SRC_DIR, "bridge", "client.mjs")).href);
500
+ let backendForSpecs;
501
+ try {
502
+ backendForSpecs = await clientForSpecs.resolveConceptBackend();
503
+ } catch {
504
+ return { written, failed: [], skipped: "this add-in has no concept route" };
505
+ }
506
+ if (!backendForSpecs) {
507
+ // Several capable backends: like the mockup generation, the design's
508
+ // brandId is globally unique, so the first backend that answers 2xx
509
+ // for it is the one (getDesignBundleForBrandAnyBackend does the same).
510
+ const transport = await import(pathToFileURL(path.join(SRC_DIR, "bridge", "client", "transport.mjs")).href);
511
+ for (const sess of transport.sessionsInScope()) {
512
+ try {
513
+ await clientForSpecs.getDesignBundleForBrand(brandIdForSpecs, { backendURL: sess.backendURL });
514
+ backendForSpecs = sess.backendURL;
515
+ break;
516
+ } catch {}
517
+ }
518
+ if (!backendForSpecs) return { written, failed: [], skipped: "no connected backend serves this design" };
519
+ }
520
+ // Asked BEFORE the "reading N mockups" line: an add-in without the reader
521
+ // used to be announced as a 2–6 minute vision pass and then dropped to a
522
+ // JSON string (2026-10-06 proposal sim). `unknown` keeps the old path —
523
+ // the extraction call asks again and decides.
524
+ const specSupport = await clientForSpecs.conceptSpecSupport(backendForSpecs);
525
+ const unsupportedDetail = () => ({ ...specSupport, slides: todo.map((t) => t.n) });
526
+ if (specSupport.status === "unsupported") {
527
+ return { written, failed: [], skipped: SPECS_UNSUPPORTED, unsupported: unsupportedDetail() };
528
+ }
529
+ // This design's own colours, by name, travel with the request: the reader
530
+ // NAMES the colour it means instead of leaving a hex for the CLI to match
531
+ // by nearest-neighbour afterwards. Whatever tokens the bundle ships are
532
+ // what it gets — nothing here knows a colour or a token name.
533
+ const { readColorTokens, readTypeRamp } = await import(pathToFileURL(path.join(BUILDERS_DIR, "design-tokens.mjs")).href);
534
+ // Where the content slot sits on a stage this wide: the reader leaves out
535
+ // the template's own marks outside it.
536
+ const stageW = readTypeRamp(bundleDir).stageWidthPx;
537
+ // Preserve semantic aliases: equal hex values do not imply equal roles.
538
+ const palette = readColorTokens(bundleDir).map(({ name, hex }) => ({ name, hex }));
539
+ const failed = [];
540
+ process.stderr.write(
541
+ `slidemind: reading ${todo.length} mockup${todo.length === 1 ? "" : "s"} into specs — a vision pass of 2–6 minutes; a progress line follows every 30 s. Do not interrupt: each spec is saved as it arrives, and a re-run asks only for the rest.\n`,
542
+ );
543
+ // What the extraction request carries for the deck's approved images: the
544
+ // sources INLINE (the add-in's model cannot reach this machine) and, per
545
+ // slide, the fingerprint RECORDED beside its mockup when it was generated.
546
+ //
547
+ // The two travel together and are never split. The server checks a declared
548
+ // fingerprint against the intent THIS request carries, so a fingerprint
549
+ // without an envelope would compare against "no uses" and refuse every
550
+ // slide that has one. And it must be the RECORDED value, never one computed
551
+ // here: a fresh one always matches, which would quietly turn the server's
552
+ // staleness check into a no-op that no test of ours could catch.
553
+ const recordedIntent = conceptIntent.readConceptIntent(deckDir);
554
+ // Every batch in flight at once: the server inventories a batch's slides
555
+ // in parallel, and one 8-slide deck run sequentially cost 9 minutes of
556
+ // empty polling (x27). Two requests of four keep each body under ~8 MB.
557
+ let batches;
558
+ const envelopes = new Map();
559
+ try {
560
+ // The second term of the byte budget: the sources a slide's uses put on
561
+ // the wire, base64'd (+1/3). An asset used by two slides of one batch
562
+ // travels once, so this over-counts it — the safe direction for a
563
+ // ceiling, and the batch is not known until after the cut.
564
+ const intentBytes = new Map();
565
+ const uses = intentMod.effectiveUsesForSlides(deckDir, todo.map((item) => item.n), { planMarkdown });
566
+ if ([...uses.values()].some((list) => list.length)) {
567
+ const { readImageAssetManifest } = await import(pathToFileURL(path.join(SRC_DIR, "lib", "image-assets.mjs")).href);
568
+ const sizeOf = new Map(readImageAssetManifest(deckDir).assets.map((a) => [a.assetId, a.byteLength]));
569
+ for (const [slide, list] of uses) {
570
+ const bytes = [...new Set(list.map((use) => use.assetId))].reduce((sum, id) => sum + (sizeOf.get(id) ?? 0), 0);
571
+ if (bytes) intentBytes.set(slide, Math.ceil((bytes * 4) / 3));
572
+ }
573
+ }
574
+ batches = batchBySlot(
575
+ todo,
576
+ (item) => ({ ...slotBySlide.get(item.n), ...(stageW ? { stageW } : {}) }),
577
+ SPEC_BATCH,
578
+ {
579
+ // What each slide actually weighs on the wire: its mockup, base64'd
580
+ // (+1/3), plus the sources its image uses carry inline.
581
+ weightOf: (item) => {
582
+ let bytes = intentBytes.get(item.n) ?? 0;
583
+ try {
584
+ bytes += Math.ceil((fs.statSync(item.png).size * 4) / 3);
585
+ } catch {}
586
+ return bytes;
587
+ },
588
+ maxBytes: intentMod.IMAGE_INTENT_REQUEST_BYTE_BUDGET,
589
+ },
590
+ );
591
+ // Null for every batch of a deck with no image uses, so those requests
592
+ // serialize exactly as they did before this contract existed.
593
+ for (const { batch } of batches) {
594
+ envelopes.set(
595
+ batch,
596
+ intentMod.buildImageIntentEnvelope(deckDir, { slides: batch.map((item) => item.n), planMarkdown }),
597
+ );
598
+ }
599
+ } catch (err) {
600
+ // A registered source that is gone or no longer hashes to what the
601
+ // registry recorded. Extraction without it would inventory the mockup's
602
+ // approximation of the user's picture as slide elements — the exact
603
+ // substitution the contract forbids — so this stops, naming the repair.
604
+ if (!err?.code) throw err;
605
+ fail(`${err.code} — ${err.message}${err.issue?.action ? `\n${err.issue.action}` : ""}`);
606
+ }
607
+ // A capability answer belongs to the command, not to one batch: returning it
608
+ // from inside the map only filled an array nobody reads.
609
+ let unsupported = null;
610
+ // The bytes each slide's request carried, hashed: the spec records them, so
611
+ // it is only ever paired with this exact mockup.
612
+ const sentDigest = new Map();
613
+ await Promise.all(batches.map(async ({ slot, batch }) => {
614
+ const imageIntent = envelopes.get(batch) ?? null;
615
+ const slides = batch.map(({ n, png }) => {
616
+ const sb = storyboardBySlide.get(n);
617
+ const bytes = fs.readFileSync(png);
618
+ sentDigest.set(n, imageDigest(bytes));
619
+ return {
620
+ slide: n,
621
+ image: `data:image/png;base64,${bytes.toString("base64")}`,
622
+ // Read from the sidecar, never recomputed, and only alongside the
623
+ // envelope it is checked against (`specIntentFields`). A slide with no
624
+ // uses records none and declares none — the server deliberately does
625
+ // not check an entry that declares nothing, so a legacy slide's
626
+ // request is unchanged.
627
+ ...specIntentFields(recordedIntent, imageIntent, n),
628
+ // The whole storyboard: the add-in projects what the reader sees
629
+ // (`readerStoryboard` in concept-spec.ts), so this request and an
630
+ // in-app delegation show it the same fields — imagePrompts included,
631
+ // whose words the content check counts as stated.
632
+ storyboard: sb ? JSON.stringify(sb) : null,
633
+ };
634
+ });
635
+ const writeSpecs = (res) => {
636
+ const done = new Set();
637
+ for (const entry of res?.specs ?? []) {
638
+ const n = Number(entry?.slide);
639
+ if (!Number.isFinite(n) || !entry?.spec) continue;
640
+ // Preserve the mapped reader response BEFORE local normalization.
641
+ // Re-running the normalizer on its output only proves idempotence.
642
+ const rawDir = path.join(deckDir, "state", "reader-specs");
643
+ fs.mkdirSync(rawDir, { recursive: true });
644
+ fs.writeFileSync(path.join(rawDir, `concept-slide-${String(n).padStart(2, "0")}.json`), JSON.stringify({ ...entry.spec, imageSha256: sentDigest.get(n) ?? null }, null, 2) + "\n");
645
+ const { layout } = specMod.plannedLayoutSlot(bundleDir, storyboardBySlide.get(n)?.layout);
646
+ const spec = specMod.normalizeForLayout(entry.spec, {
647
+ tone: specMod.layoutToneFor(bundleDir, layout),
648
+ });
649
+ // What this inventory was read under, recorded IN the spec rather than
650
+ // beside it: a spec is JSON, so its identity travels with its content
651
+ // and a deleted spec takes its record with it — no sidecar to prune.
652
+ // Null for a slide with no image uses, which is what keeps a legacy
653
+ // spec reusable forever (design decision 6).
654
+ spec.intentFingerprint = specFingerprints.get(n) ?? null;
655
+ // The exact mockup this inventory was read from (concept-spec-binding.mjs):
656
+ // a spec whose mockup is later replaced is no spec of it.
657
+ spec.imageSha256 = sentDigest.get(n) ?? null;
658
+ // An original the reader could not place (or bound twice) comes back
659
+ // as an issue and the spec is persisted anyway — the obligation is
660
+ // the plan's; transcribe tells the author (specAuthorNotes), who
661
+ // places the picture by hand.
662
+ written.push(specMod.writeSpec(deckDir, n, spec));
663
+ done.add(n);
664
+ }
665
+ return done;
666
+ };
667
+ let res;
668
+ let pending = slides;
669
+ // A first pass that died on the wire never ANSWERED for its slides, so
670
+ // the second pass alone cannot call one of them unreadable.
671
+ let wireFailed = false;
672
+ try {
673
+ const streamedDone = new Set();
674
+ let lastLine = "";
675
+ res = await clientForSpecs.extractConceptSpecsForBrand(brandIdForSpecs, {
676
+ slides,
677
+ slot,
678
+ palette,
679
+ imageIntent,
680
+ backendURL: backendForSpecs,
681
+ onSpec: (entry) => {
682
+ for (const n of writeSpecs({ specs: [entry] })) streamedDone.add(n);
683
+ },
684
+ onProgress: ({ done, total, elapsedMs }) => {
685
+ const line = `slidemind: concept specs ${done}/${total} read (${Math.round((elapsedMs ?? 0) / 1000)}s)…`;
686
+ if (line !== lastLine) process.stderr.write(`${line}\n`);
687
+ lastLine = line;
688
+ },
689
+ });
690
+ if (res === null) {
691
+ unsupported = SPECS_UNSUPPORTED;
692
+ return;
693
+ }
694
+ const done = res?.streamed ? streamedDone : writeSpecs(res);
695
+ pending = slides.filter((s) => !done.has(s.slide));
696
+ // A slide the server refused because its mockup predates the intent
697
+ // this request carries is not one a second, cheaper read can rescue:
698
+ // extraction never ran for it, and the same bytes would be refused
699
+ // again. It leaves the retry with its own words — the repair is a new
700
+ // mockup, not a coarser inventory.
701
+ for (const f of res?.failures ?? []) {
702
+ const n = Number(f?.slide);
703
+ if (f?.code !== "image_intent_stale" || !pending.some((s) => s.slide === n)) continue;
704
+ pending = pending.filter((s) => s.slide !== n);
705
+ failed.push({ slide: n, code: f.code, error: f.error ?? "the mockup predates this request's image intent" });
706
+ }
707
+ } catch (err) {
708
+ // A whole-batch failure (a gateway timeout, a network drop): every
709
+ // slide is still pending and gets the retry below.
710
+ wireFailed = transientSpecFailure({ thrown: err });
711
+ process.stderr.write(`slidemind: concept spec batch failed (${err?.message ?? err}); reading the slides once more…\n`);
712
+ }
713
+ if (pending.length) {
714
+ // Once more before a slide is given up, at the effort the admin set:
715
+ // the reader is never coarsened to fit a budget (a lower effort misses
716
+ // structure — rings, repeat sets, whole rows); the server's hedged
717
+ // re-reads already answer a slow read.
718
+ try {
719
+ process.stderr.write(`slidemind: concept spec${pending.length === 1 ? "" : "s"} for slide${pending.length === 1 ? "" : "s"} ${pending.map((s) => s.slide).join(", ")} not read yet; one more pass…\n`);
720
+ const retry = await clientForSpecs.extractConceptSpecsForBrand(brandIdForSpecs, {
721
+ slides: pending,
722
+ slot,
723
+ palette,
724
+ imageIntent,
725
+ backendURL: backendForSpecs,
726
+ onProgress: ({ done, total, elapsedMs }) => {
727
+ process.stderr.write(`slidemind: second pass ${done}/${total} read (${Math.round((elapsedMs ?? 0) / 1000)}s)…\n`);
728
+ },
729
+ });
730
+ const done = writeSpecs(retry);
731
+ for (const s of pending) {
732
+ if (done.has(s.slide)) continue;
733
+ const f = (retry?.failures ?? []).find((x) => Number(x?.slide) === s.slide);
734
+ const error = f?.error ?? "no spec returned";
735
+ failed.push({ slide: s.slide, error, transient: transientSpecFailure({ wireFailed, answer: error }) });
736
+ }
737
+ } catch (err) {
738
+ const transient = transientSpecFailure({ wireFailed, thrown: err });
739
+ for (const s of pending) failed.push({ slide: s.slide, error: err?.message ?? String(err), transient });
740
+ }
741
+ }
742
+ }));
743
+ for (const f of failed) process.stderr.write(`${specFailureLine(f, deck)}\n`);
744
+ written.sort();
745
+ return { written, failed, skipped: unsupported, unsupported: unsupported ? unsupportedDetail() : null };
746
+ }
105
747
  // Mockups are the first post-plan step, so this is where an unapproved or
106
748
  // since-edited plan stops the agent — before any authoring, and before the
107
749
  // fast path below re-prints mockups that would belong to a stale plan.
108
750
  await requirePlanApproved(deckDir);
751
+ // One run per deck: a second `concepts` started while the first was still
752
+ // reading rewrote a spec under a running `transcribe` (x54 cloud 4 — the
753
+ // slide was built from a spec no longer on disk).
754
+ const lockFile = path.join(deckDir, "state", "concepts.lock");
755
+ try {
756
+ const holder = JSON.parse(fs.readFileSync(lockFile, "utf8"));
757
+ if (holder.pid !== process.pid && pidAlive(holder.pid)) fail(`concepts: already running for this deck (pid ${holder.pid}, since ${holder.at}). Wait for it to finish — \`slidemind concepts ${deck} --status\` shows its progress; a second run would rewrite specs another step is reading.`);
758
+ } catch (err) {
759
+ if (err?.code !== "ENOENT" && !(err instanceof SyntaxError)) throw err;
760
+ }
761
+ fs.mkdirSync(path.dirname(lockFile), { recursive: true });
762
+ fs.writeFileSync(lockFile, JSON.stringify({ pid: process.pid, at: new Date().toISOString() }));
763
+ process.on("exit", () => { try { if (JSON.parse(fs.readFileSync(lockFile, "utf8")).pid === process.pid) fs.rmSync(lockFile); } catch {} });
109
764
  await freshenDeckBundle(deckDir);
110
765
 
111
766
  // The slides the plan's storyboards name — the set every step below works
112
767
  // against (fast-path completeness, request waves, pruning, the missing
113
768
  // note). The server's slideCount is a progress figure only: it counts the
114
769
  // beats IT parsed, and a beat it drops must not shrink the request range.
115
- const families = await import(
116
- pathToFileURL(path.join(BUILDERS_DIR, "visual-families.mjs")).href
770
+ const conceptWaves = await import(
771
+ pathToFileURL(path.join(BUILDERS_DIR, "concept-waves.mjs")).href
117
772
  );
118
- const planned = new Set(families.plannedSlideNumbers(planMarkdown));
773
+ const planned = new Set(conceptWaves.plannedSlideNumbers(planMarkdown));
119
774
  // Frame slides (cover / agenda / divider / closing by the layout's job) get
120
775
  // no mockup: they are chrome with text slots, authored from the plan. The
121
776
  // server skips them too; taking them out of `planned` keeps them out of the
@@ -126,19 +781,121 @@ export async function cmdConceptImages(rest) {
126
781
  const jobs = jobsMod.layoutJobs(bundleMod.resolveBundleDirForDeck(deckDir));
127
782
  const frameSlides = [];
128
783
  for (const segment of sbMod.splitPlanMarkdownIntoSegments(planMarkdown)) {
129
- if (segment.type !== "storyboard" || !segment.data?.layout) continue;
784
+ if (segment.type !== "storyboard" || !segment.data) continue;
130
785
  const job = jobsMod.plannedJob(segment.data, jobs);
131
786
  if (jobsMod.isFrameJob(job) && planned.has(segment.data.slide)) {
132
787
  planned.delete(segment.data.slide);
133
788
  frameSlides.push({ slideNumber: segment.data.slide, job, layout: segment.data.layout });
134
789
  }
135
790
  }
791
+ // Every body slide is built from its full content draft — the approved key
792
+ // points alone let the mockup decide how much the slide says.
793
+ const planMod = await import(pathToFileURL(path.join(BUILDERS_DIR, "plan.mjs")).href);
794
+ const withoutContent = planMod.slidesWithoutContent(deckDir, planMarkdown);
795
+ if (withoutContent.length) {
796
+ fail(
797
+ `concepts: slide${withoutContent.length === 1 ? "" : "s"} ${withoutContent.join(", ")} ha${withoutContent.length === 1 ? "s" : "ve"} no \`content\` in plan.md. ` +
798
+ `Write ${planMod.CONTENT_DRAFT}. ` +
799
+ "Leave `contentNotes` as approved; the draft needs no new sign-off. Then run concepts again.",
800
+ );
801
+ }
136
802
  if (frameSlides.length) {
137
803
  process.stderr.write(
138
804
  `slidemind: ${frameSlides.length} frame slide${frameSlides.length === 1 ? "" : "s"} (${frameSlides.map((f) => `${f.slideNumber}:${f.job}`).join(", ")}) — no mockup; author from the plan using the layout's slots and content areas, including required images.\n`,
139
805
  );
140
806
  }
141
807
 
808
+ // Mockups a human (or a fixture step) dropped at the deck ROOT —
809
+ // decks/<slug>/concepts/concept-slide-NN.png, the folder next to plan.html —
810
+ // are the deck's mockups too: adopt them into .slidemind/concepts before the
811
+ // on-disk check, or the command generates new ones over the supplied images
812
+ // (x29: both sessions copied to the root and got fresh lorem-ipsum decks).
813
+ const adoptedNums = new Set();
814
+ {
815
+ const conceptsDirNow = path.join(deckDir, "concepts");
816
+ const rootDir = path.basename(deckDir) === ".slidemind" ? path.join(path.dirname(deckDir), "concepts") : null;
817
+ if (rootDir && rootDir !== conceptsDirNow && fs.existsSync(rootDir)) {
818
+ let adopted = 0;
819
+ for (const f of fs.readdirSync(rootDir)) {
820
+ if (!/^concept-slide-\d+\.(png|jpe?g)$/i.test(f)) continue;
821
+ const dest = path.join(conceptsDirNow, f);
822
+ if (fs.existsSync(dest)) continue;
823
+ fs.mkdirSync(conceptsDirNow, { recursive: true });
824
+ fs.copyFileSync(path.join(rootDir, f), dest);
825
+ adoptedNums.add(Number(f.match(/(\d+)/)[1]));
826
+ adopted++;
827
+ }
828
+ if (adopted) {
829
+ process.stderr.write(
830
+ `slidemind: adopted ${adopted} mockup${adopted === 1 ? "" : "s"} from ${path.relative(process.cwd(), rootDir) || rootDir} into the deck's concepts/ — they are the deck's mockups now; nothing is generated over them.\n`,
831
+ );
832
+ }
833
+ }
834
+ }
835
+
836
+ // What image intent each planned slide carries NOW — the plan's approved uses
837
+ // for that slide, or the targeted edit that superseded them, fingerprinted
838
+ // canonically (design decision 6: instruction, fit, crop and source digest
839
+ // count; a renamed file does not).
840
+ let intentFingerprints;
841
+ try {
842
+ intentFingerprints = intentMod.slideIntentFingerprints(deckDir, [...planned], {
843
+ planMarkdown,
844
+ context: { output: "concept" },
845
+ });
846
+ // The spec's own key: the same uses, in the `spec` context, plus the
847
+ // generation of the persisted spec shape — so a spec written before the
848
+ // shape could carry a binding is refused even when its mockup is current.
849
+ specFingerprints = intentMod.slideIntentFingerprints(deckDir, [...planned], {
850
+ planMarkdown,
851
+ context: { output: "spec", schema: specMod.SPEC_SCHEMA_VERSION },
852
+ });
853
+ } catch (err) {
854
+ if (!err?.code) throw err;
855
+ fail(`${err.code} — ${err.message}${err.issue?.action ? `\n${err.issue.action}` : ""}`);
856
+ }
857
+ // A mockup a human just handed the deck is the mockup for the plan AS IT
858
+ // STANDS — that is what supplying it means, and the adoption note above says
859
+ // as much ("nothing is generated over them"). So it records the current
860
+ // intent: without this, a slide whose plan carries image uses would find an
861
+ // adopted file with no fingerprint, call it stale, and regenerate over the
862
+ // picture the user chose — the x29 failure in a new disguise.
863
+ if (adoptedNums.size) {
864
+ conceptIntent.recordConceptIntent(
865
+ deckDir,
866
+ new Map(
867
+ [...adoptedNums]
868
+ .filter((n) => planned.has(n))
869
+ .map((n) => [n, intentFingerprints.get(n) ?? null]),
870
+ ),
871
+ );
872
+ // A supplied picture continues nothing it was not generated from.
873
+ conceptIntent.recordConceptNotes(deckDir, [...adoptedNums].map((slideNumber) => ({ slideNumber })), planned);
874
+ }
875
+
876
+ // A mockup generated before the user attached a screenshot — or before they
877
+ // changed what that screenshot is FOR — depicts a plan that no longer exists.
878
+ // Those slides are MARKED, not cleaned up: deleting the mockup and its spec
879
+ // would destroy the only copy of an image nobody can regenerate while the
880
+ // backend is down (and a mockup a human supplied is a file this command did
881
+ // not create). The mark is what stops the reuse — the fast path below and the
882
+ // spec reuse above both refuse it — and it survives in the sidecar, because
883
+ // only a slide that actually gets a NEW mockup records a new fingerprint
884
+ // (design decision 6, guide §6 `image_intent_stale`).
885
+ for (const n of conceptIntent.staleConceptSlides(deckDir, intentFingerprints)) {
886
+ staleSlides.add(n);
887
+ }
888
+ if (staleSlides.size) {
889
+ const list = [...staleSlides].join(", ");
890
+ const one = staleSlides.size === 1;
891
+ process.stderr.write(
892
+ `slidemind: the image intent of slide${one ? "" : "s"} ${list} changed since ` +
893
+ `${one ? "its mockup was" : "their mockups were"} generated — ` +
894
+ `${one ? "it is" : "they are"} regenerated below, and the file${one ? "" : "s"} on disk ` +
895
+ `${one ? "is" : "are"} kept until a new one replaces ${one ? "it" : "them"}. Slide files are untouched.\n`,
896
+ );
897
+ }
898
+
142
899
  // Idempotent fast path: mockups already on disk are re-printed IMMEDIATELY.
143
900
  // Generation takes 1-2 minutes and agent harnesses time commands out and
144
901
  // show the model only the output-so-far — while the detached process still
@@ -146,39 +903,45 @@ export async function cmdConceptImages(rest) {
146
903
  // gets the full path list within any timeout, instead of racing generation
147
904
  // again (measured: sessions that never saw the paths authored without the
148
905
  // concepts — the single biggest fidelity loss). `--force` regenerates.
149
- if (!force) {
150
- const conceptsDirEarly = path.join(deckDir, "concepts");
151
- let existing = [];
152
- try {
153
- existing = fs
154
- .readdirSync(conceptsDirEarly)
155
- .filter((f) => /^concept-slide-\d+\.png$/.test(f))
156
- .sort();
157
- } catch {}
158
- // Only when the on-disk set EXACTLY matches the plan's slide numbering —
159
- // a partial or stale set (leftover concept-slide-03.png on a 2-slide
160
- // plan) falls through to regeneration, which prunes and refills.
161
- const existingNums = new Set(
162
- existing.map((f) => Number(f.match(/(\d+)/)[1])),
163
- );
164
- const matchesPlan =
165
- planned.size > 0 &&
166
- planned.size === existingNums.size &&
167
- [...planned].every((n) => existingNums.has(n));
168
- if (existing.length > 0 && matchesPlan) {
169
- finish({
170
- paths: existing.map((f) => path.join(conceptsDirEarly, f)),
171
- planned: planned.size,
172
- status: "on disk",
173
- reason: "already generated for this plan (--force regenerates)",
174
- });
906
+ //
907
+ // The run makes only the mockups the plan lacks: a planned slide with no
908
+ // mockup on disk, or one whose image intent changed (the mark above);
909
+ // `--force` remakes them all. A mockup already on disk for the plan as it
910
+ // stands IS that slide's concept — regenerating it would hand the slide a
911
+ // different picture than the spec and any slide built from it describe
912
+ // (x112 GTM: 3 of 4 on disk, all four regenerated, slides 1–3 transcribed
913
+ // from pictures that no longer existed).
914
+ const conceptsDirEarly = path.join(deckDir, "concepts");
915
+ const onDiskNums = new Set();
916
+ try {
917
+ for (const f of fs.readdirSync(conceptsDirEarly)) {
918
+ const m = /^concept-slide-(\d+)\.png$/.exec(f);
919
+ if (m) onDiskNums.add(Number(m[1]));
175
920
  }
921
+ } catch {}
922
+ const needed = new Set([...planned].filter((n) => force || staleSlides.has(n) || !onDiskNums.has(n)));
923
+ // Mockups of slides the plan no longer names (a longer earlier plan).
924
+ const pruneUnplanned = () => {
925
+ for (const n of onDiskNums) {
926
+ if (!planned.has(n)) fs.rmSync(mockupFileFor(deckDir, n), { force: true });
927
+ }
928
+ };
929
+ const plannedOnDisk = () =>
930
+ [...planned].sort((a, b) => a - b).map((n) => mockupFileFor(deckDir, n)).filter((f) => fs.existsSync(f));
931
+ if (planned.size > 0 && needed.size === 0) {
932
+ pruneUnplanned();
933
+ await finish({
934
+ paths: plannedOnDisk(),
935
+ planned: planned.size,
936
+ status: "on disk",
937
+ reason: "already generated for this plan (--force regenerates)",
938
+ });
176
939
  }
177
940
 
178
- // No storyboards, nothing to generate: the server builds its beats from the
179
- // same blocks, so a request would come back with zero images anyway.
941
+ // No slide records, nothing to generate: the server builds its beats from the
942
+ // same fences, so a request would come back with zero images anyway.
180
943
  if (planned.size === 0) {
181
- finish({ status: "none", reason: "plan.md has no storyboard-canvas blocks (re-run `slidemind plan` to compose the storyboards)" });
944
+ await finish({ status: "none", reason: "plan.md has no storyboard-canvas fences (re-run `slidemind plan` to draft the slides)" });
182
945
  }
183
946
 
184
947
  const bundle = await import(
@@ -187,7 +950,7 @@ export async function cmdConceptImages(rest) {
187
950
  const brandId = bundle.readDeckBundleRef(deckDir)?.presFP ?? null;
188
951
  if (!brandId) {
189
952
  fail(
190
- `deck ${deck} is not bound to a corporate design (no .bundle-ref.json). ` +
953
+ `deck ${deck} is not bound to a corporate design (no state/bundle-ref.json). ` +
191
954
  `Run \`slidemind ready ${deck}\` to bind it first.`,
192
955
  );
193
956
  }
@@ -196,8 +959,36 @@ export async function cmdConceptImages(rest) {
196
959
  pathToFileURL(path.join(SRC_DIR, "bridge", "client.mjs")).href
197
960
  );
198
961
 
199
- const skipNote = () =>
200
- finish({ planned: planned.size, status: "unavailable", reason: "this add-in does not generate concept mockups; author from the storyboards" });
962
+ // No backend can generate right now. Whatever is already on disk is still the
963
+ // deck's best mockup set and is reported as such — a stale slide must not
964
+ // cost the agent the mockups of every OTHER slide, which is what happens if
965
+ // this exits with an empty path list because one slide fell out of the fast
966
+ // path. A stale slide is named, not hidden: it still shows the earlier image
967
+ // intent, and nothing recorded a new fingerprint for it, so the next run with
968
+ // a reachable backend regenerates it.
969
+ const skipNote = async () => {
970
+ const dir = path.join(deckDir, "concepts");
971
+ let onDisk = [];
972
+ try {
973
+ onDisk = fs
974
+ .readdirSync(dir)
975
+ .filter((f) => /^concept-slide-\d+\.png$/.test(f))
976
+ .filter((f) => planned.has(Number(f.match(/(\d+)/)[1])))
977
+ .sort()
978
+ .map((f) => path.join(dir, f));
979
+ } catch {}
980
+ const stale = [...staleSlides].join(", ");
981
+ return finish({
982
+ paths: onDisk,
983
+ planned: planned.size,
984
+ status: onDisk.length ? "on disk" : "unavailable",
985
+ reason:
986
+ (onDisk.length
987
+ ? "this add-in does not generate concept mockups; the mockups already on disk are unchanged"
988
+ : "this add-in does not generate concept mockups; author from the storyboards") +
989
+ (stale ? ` — slide${staleSlides.size === 1 ? "" : "s"} ${stale} still show${staleSlides.size === 1 ? "s" : ""} the earlier image intent` : ""),
990
+ });
991
+ };
201
992
 
202
993
  // Generation is silent for 1–2 min otherwise, and a harness that yields a
203
994
  // long-running command early then hands the agent an empty output it reads
@@ -216,11 +1007,80 @@ export async function cmdConceptImages(rest) {
216
1007
  try {
217
1008
  backendURL = await client.resolveConceptBackend();
218
1009
  } catch (err) {
219
- if (err?.code === "concept_route_unavailable") skipNote();
1010
+ if (err?.code === "concept_route_unavailable") await skipNote();
220
1011
  throw err;
221
1012
  }
222
1013
 
223
- const slideCount = planned.size;
1014
+ // A deck whose plan records approved image uses needs an add-in that speaks
1015
+ // the image-intent contract — end to end, from the mockup to the rendered
1016
+ // picture. Checked HERE, before the first mockup is generated, because a run
1017
+ // that discovers it at the spec step has already spent minutes of image
1018
+ // generation on pixels that cannot be delivered. Nothing is written on the
1019
+ // refusal: plan.md, the registry and the imported sources are untouched, and
1020
+ // any mockup already on disk stays there.
1021
+ //
1022
+ // A deck with no uses never reaches this, which is what keeps every legacy
1023
+ // deck free of the capability entirely. `backendURL` is null only when
1024
+ // SEVERAL environments generate mockups (developer machines): the waves then
1025
+ // fan out, and each request is gated by its own transport.
1026
+ let deckHasUses = false;
1027
+ try {
1028
+ const planUses = intentMod.effectiveUsesForSlides(deckDir, [...planned], { planMarkdown });
1029
+ deckHasUses = [...planUses.values()].some((list) => list.length);
1030
+ } catch (err) {
1031
+ // A registry or override file that does not parse. Generating against it
1032
+ // would build mockups from obligations nobody can read — the deck's own
1033
+ // state is the repair, and it is named rather than stack-traced. A deck
1034
+ // with no registry at all reads as an empty one and never lands here.
1035
+ if (!err?.code) throw err;
1036
+ fail(`${err.code} — ${err.message}${err.issue?.action ? `\n${err.issue.action}` : ""}`);
1037
+ }
1038
+ if (deckHasUses && backendURL) {
1039
+ const { assertImageIntentSupported } = await import(
1040
+ pathToFileURL(path.join(SRC_DIR, "bridge", "client", "transport.mjs")).href
1041
+ );
1042
+ try {
1043
+ await assertImageIntentSupported(backendURL);
1044
+ } catch (err) {
1045
+ if (err?.code !== "image_intent_unsupported") throw err;
1046
+ fail(`${err.code} — ${err.message}`);
1047
+ }
1048
+ }
1049
+
1050
+ // The envelope each slide's generation request carries: the sources its
1051
+ // approved uses name, inline, so the model sees the user's own picture rather
1052
+ // than a description of it. Built HERE rather than inside the worker for the
1053
+ // same reason the capability is checked here — the builder resolves and
1054
+ // re-hashes every registered source on the way, so a deleted or overwritten
1055
+ // original stops the run before one mockup exists (A14), instead of failing
1056
+ // slide 7 after six minutes of generation.
1057
+ //
1058
+ // Empty map for a deck with no uses: those requests then serialize exactly as
1059
+ // they did before this contract existed.
1060
+ //
1061
+ // A continuing slide is generated from the slide it continues, and the add-in
1062
+ // keys that predecessor's concept by its own chain's uses: the envelope
1063
+ // carries the chain (`continuationChain`), so the later wave finds the
1064
+ // predecessor the earlier one made instead of painting a second one.
1065
+ const intentEnvelopes = new Map();
1066
+ if (deckHasUses) {
1067
+ try {
1068
+ for (const n of planned) {
1069
+ const slides = conceptWaves.continuationChain(planMarkdown, n);
1070
+ const envelope = intentMod.buildImageIntentEnvelope(deckDir, { slides, planMarkdown });
1071
+ if (envelope) intentEnvelopes.set(n, envelope);
1072
+ }
1073
+ } catch (err) {
1074
+ if (!err?.code) throw err;
1075
+ fail(`${err.code} — ${err.message}${err.issue?.action ? `\n${err.issue.action}` : ""}`);
1076
+ }
1077
+ }
1078
+
1079
+ // The `--concept` / `--style` flags never travel as such: by this point every
1080
+ // one has been imported and recorded as a canonical use (adaptLegacyReferences),
1081
+ // so the envelope carries the same bytes with the approved obligation attached.
1082
+
1083
+ const slideCount = needed.size;
224
1084
  const images = [];
225
1085
  const collect = (res) => {
226
1086
  if (!Array.isArray(res?.images)) return;
@@ -232,11 +1092,16 @@ export async function cmdConceptImages(rest) {
232
1092
  }
233
1093
  };
234
1094
 
235
- // Family variants depend on their lowest-numbered anchor. Run independent
236
- // slides + anchors first, then variants; the server's per-family anchor cache
237
- // (keyed by brand + CLI user) is what makes the anchor cross the two waves'
238
- // separate requests.
239
- const waves = families.conceptGenerationWaves(planMarkdown);
1095
+ // A slide whose storyboard `continues` an earlier one is generated from that
1096
+ // slide's concept, so it runs one wave later; everything else runs in the
1097
+ // first wave. The server's continued-slide cache (keyed by brand + CLI user)
1098
+ // is what carries a predecessor across the waves' separate requests.
1099
+ // Only the slides this run must make; a kept predecessor is not remade for
1100
+ // its successor (the add-in finds or rebuilds it server-side).
1101
+ const waves = conceptWaves
1102
+ .conceptGenerationWaves(planMarkdown)
1103
+ .map((wave) => wave.filter((n) => needed.has(n)))
1104
+ .filter((wave) => wave.length);
240
1105
  const CONCURRENCY = 3;
241
1106
  async function runWave(slides) {
242
1107
  let cursor = 0;
@@ -249,7 +1114,7 @@ export async function cmdConceptImages(rest) {
249
1114
  const res = await client.generateConceptImagesForBrand(brandId, {
250
1115
  planMarkdown,
251
1116
  slideNumbers: [n],
252
- references,
1117
+ imageIntent: intentEnvelopes.get(n) ?? null,
253
1118
  backendURL,
254
1119
  force,
255
1120
  });
@@ -265,71 +1130,105 @@ export async function cmdConceptImages(rest) {
265
1130
  Array.from({ length: Math.min(CONCURRENCY, slides.length) }, () => worker()),
266
1131
  );
267
1132
  }
268
- await runWave(waves.firstWave);
269
- await runWave(waves.variants);
1133
+ for (const wave of waves) await runWave(wave);
270
1134
 
1135
+ // The mockups kept from disk are still this deck's: they are reported (and
1136
+ // their specs ensured) on every exit, a generation that failed included.
1137
+ const kept = [...planned].filter((n) => !needed.has(n) && onDiskNums.has(n)).sort((a, b) => a - b);
271
1138
  if (images.length === 0) {
272
- finish({ planned: planned.size, status: "generated", reason: "the add-in returned no image for any slide (see the per-slide failures above); author from the storyboards" });
1139
+ await finish({ paths: plannedOnDisk(), planned: planned.size, status: "generated", reason: "the add-in returned no image for any slide (see the per-slide failures above); author from the storyboards", missing: [...needed].sort((a, b) => a - b) });
273
1140
  }
274
1141
 
275
- const conceptsDir = path.join(deckDir, "concepts");
276
- fs.mkdirSync(conceptsDir, { recursive: true });
277
1142
  const written = [];
278
1143
  const writtenNums = new Set();
279
1144
  for (const img of images) {
280
1145
  const n = Number(img?.slideNumber);
281
- if (!Number.isFinite(n) || n < 1) continue;
1146
+ if (!Number.isFinite(n) || n < 1 || !needed.has(n)) continue;
282
1147
  const b64 = base64FromDataUri(img?.dataUri);
283
1148
  if (!b64) continue;
284
- const name = `concept-slide-${String(n).padStart(2, "0")}.png`;
285
- const abs = path.join(conceptsDir, name);
286
- fs.writeFileSync(abs, Buffer.from(b64, "base64"));
287
- written.push(abs);
1149
+ // Atomic, and the slide's old spec goes in the same step: nothing can pair
1150
+ // the new picture with the inventory of the one it replaces.
1151
+ written.push(replaceMockup(deckDir, n, Buffer.from(b64, "base64")));
288
1152
  writtenNums.add(n);
289
1153
  }
290
1154
 
291
- // Drop stale mockups from a previous, longer plan — any concept-slide-NN.png
292
- // the plan no longer names (fallback: beyond the server's slideCount).
293
- const isStale = (n) =>
294
- planned.size > 0 ? !planned.has(n) : n > slideCount;
295
- for (const entry of fs.readdirSync(conceptsDir)) {
296
- const m = /^concept-slide-(\d+)\.png$/.exec(entry);
297
- if (m && isStale(Number(m[1]))) {
298
- fs.rmSync(path.join(conceptsDir, entry), { force: true });
299
- }
300
- }
1155
+ // Drop mockups from a previous, longer plan — any concept-slide-NN.png the
1156
+ // plan no longer names.
1157
+ pruneUnplanned();
1158
+ // The mark lifts for exactly the slides that got a new mockup; the rest keep
1159
+ // it, so nothing downstream reads a spec out of a picture that did not change.
1160
+ for (const n of writtenNums) regeneratedSlides.add(n);
1161
+
1162
+ // Each fresh mockup records the intent it was made under, so the next run can
1163
+ // tell "already generated for this plan" from "generated before the user's
1164
+ // image existed". A slide with no uses records nothing — that absence IS what
1165
+ // a legacy no-intent concept looks like, and it stays reusable.
1166
+ conceptIntent.recordConceptIntent(
1167
+ deckDir,
1168
+ new Map([...writtenNums].map((n) => [n, intentFingerprints.get(n) ?? null])),
1169
+ );
1170
+ conceptIntent.pruneConceptIntent(deckDir, planned);
1171
+ conceptIntent.recordConceptNotes(
1172
+ deckDir,
1173
+ images.filter((img) => writtenNums.has(Number(img?.slideNumber))),
1174
+ planned,
1175
+ );
1176
+
301
1177
  // A planned slide the server returned nothing for is invisible otherwise —
302
1178
  // the agent would author it blind without knowing a mockup was expected.
303
- const missing = [...planned].filter((n) => !writtenNums.has(n));
1179
+ const missing = [...needed].filter((n) => !writtenNums.has(n)).sort((a, b) => a - b);
304
1180
 
305
1181
  if (written.length === 0) {
306
- finish({ planned: planned.size, status: "generated", reason: "the add-in returned images without usable bytes; author from the storyboards", missing });
1182
+ await finish({ paths: plannedOnDisk(), planned: planned.size, status: "generated", reason: "the add-in returned images without usable bytes; author from the storyboards", missing });
307
1183
  }
308
1184
 
309
- written.sort();
310
- // The inventory form is seeded next to the mockups (never over a filled
311
- // one): the organism/connector list becomes a file the draft check counts,
312
- // not a step the agent may skip.
313
- const check = await import(
314
- pathToFileURL(path.join(BUILDERS_DIR, "draft-check.mjs")).href
315
- );
316
- const seeded = check.seedAssetPlan(
317
- deckDir,
318
- [
319
- ...images
320
- .filter((img) => writtenNums.has(Number(img?.slideNumber)))
321
- .map((img) => ({ slideNumber: Number(img.slideNumber), label: img.label })),
322
- ...frameSlides.map((f) => ({ slideNumber: f.slideNumber, label: `${f.job} (${f.layout})`, frame: f.job })),
323
- ],
324
- );
325
- finish({
326
- paths: written,
1185
+ await finish({
1186
+ paths: plannedOnDisk(),
327
1187
  planned: planned.size,
328
1188
  status: "generated",
1189
+ reason: kept.length ? `slide${kept.length === 1 ? "" : "s"} ${kept.join(", ")} kept as on disk` : null,
329
1190
  missing,
330
- seeded,
331
- extra: seeded
332
- ? `Seeded ${path.join(deckDir, check.ASSET_PLAN_FILE)} — fill its regions/organisms/connectors/icons rows per slide from the mockups BEFORE writing any slide.\n`
333
- : "",
334
1191
  });
335
1192
  }
1193
+
1194
+ // ---------------------------------------------------------------------------
1195
+ // legacy --concept / --style → image-intent uses
1196
+ // ---------------------------------------------------------------------------
1197
+
1198
+ async function adaptLegacyReferences(deckDir, references, deckArg) {
1199
+ const { applyLegacyReferenceFlags, writePlanMarkdown } = await import(
1200
+ pathToFileURL(path.join(BUILDERS_DIR, "legacy-reference-uses.mjs")).href
1201
+ );
1202
+ const planMarkdown = fs.readFileSync(path.join(deckDir, "plan.md"), "utf8");
1203
+ let result;
1204
+ try {
1205
+ result = await applyLegacyReferenceFlags(deckDir, references, { planMarkdown });
1206
+ } catch (err) {
1207
+ // A typed import failure (unsupported format, unreadable file) reads as
1208
+ // itself; anything else is re-thrown for the generic handler.
1209
+ if (!err?.code) throw err;
1210
+ fail(`${err.code} — ${err.message}${err.issue?.action ? `\n${err.issue.action}` : ""}`);
1211
+ }
1212
+ if (result.issues.length) {
1213
+ // Nothing is written: the plan and the flags disagree about what an image
1214
+ // is for, and guessing which one wins is exactly the failure this contract
1215
+ // exists to prevent.
1216
+ fail(
1217
+ `image_intent_conflict — ${result.issues.length} conflicting image use(s):\n` +
1218
+ result.issues.map((i) => ` - ${i.message}`).join("\n") +
1219
+ `\nResolve them in ${path.join(deckDir, "plan.md")} (or drop the flag) and re-run.`,
1220
+ );
1221
+ }
1222
+ for (const entry of result.imported) {
1223
+ process.stderr.write(
1224
+ `slidemind: ${entry.reused ? "already imported" : "imported"} ${entry.file} as ${entry.assetId}\n`,
1225
+ );
1226
+ }
1227
+ if (!result.changed) return;
1228
+ writePlanMarkdown(deckDir, result.planMarkdown);
1229
+ process.stderr.write(
1230
+ `slidemind: recorded ${result.added.length} image use${result.added.length === 1 ? "" : "s"} in plan.md ` +
1231
+ `(slide${result.added.length === 1 ? "" : "s"} ${[...new Set(result.added.map((a) => a.slide))].sort((a, b) => a - b).join(", ")}). ` +
1232
+ `The plan now needs a fresh sign-off: run \`slidemind plan ${deckArg}\`, then re-run this command.\n`,
1233
+ );
1234
+ }