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
@@ -0,0 +1,854 @@
1
+ // `slidemind transcribe <deck> [--only NN] [--force]` — write each slide's JSX
2
+ // skeleton from its concept spec (src/builders/transcribe.mjs). Runs after
3
+ // `slidemind concepts` wrote the specs; a slide file that already exists is
4
+ // left alone unless --force. Prints, per slide, the file and the icon list the
5
+ // fetcher fills (`download-result --name <file>`).
6
+ import fs from "node:fs";
7
+ import path from "node:path";
8
+ import { pathToFileURL } from "node:url";
9
+ import { fail, requirePlanApproved, resolveDeckArg, SRC_DIR, BUILDERS_DIR } from "./common.mjs";
10
+ import { SPECS_UNSUPPORTED_CODE, transcribeSpecsRefusal } from "./concept-spec-support.mjs";
11
+ import { deckSlug } from "../lib/deck-paths.mjs";
12
+ import crypto from "node:crypto";
13
+ import { attachSpecAsset, pendingSpecAssets, recordSpecAssets, resolveBoundImageUses } from "../builders/spec-assets.mjs";
14
+ import { resolveEngineRouting } from "./engine-helpers.mjs";
15
+ import { conversionProvenance } from "../lib/conversions.mjs";
16
+ import { pidAlive } from "../lib/revision.mjs";
17
+ import { currentSpec, specSlidesByState, specState } from "../lib/concept-spec-binding.mjs";
18
+ import { createBatcher, createEngineRetry, engineFailure, fetchAssetGroups } from "../builders/asset-fetch.mjs";
19
+
20
+ // Ownership: transcribe stamps the hash of every JSX it writes
21
+ // (state/transcribed.json). While a file still matches, the machine owns it:
22
+ // counts apply and --force may rebuild it from the spec. Once it differs, a
23
+ // person or an agent has taken it over and transcribe keeps it byte-for-byte:
24
+ // --only is a slide SELECTION, never repair authorization, and --force refuses
25
+ // unless told to discard. Bounded repair is the separate (not yet available)
26
+ // --repair.
27
+ const sha = (s) => crypto.createHash("sha256").update(s).digest("hex");
28
+ const DELIVERY = "transcribed-delivery.json";
29
+ function readStamps(deckDir, name = "transcribed.json") {
30
+ try {
31
+ return JSON.parse(fs.readFileSync(path.join(deckDir, "state", name), "utf8"));
32
+ } catch {
33
+ return {};
34
+ }
35
+ }
36
+ function writeStamps(deckDir, stamps, name = "transcribed.json") {
37
+ const file = path.join(deckDir, "state", name);
38
+ fs.mkdirSync(path.dirname(file), { recursive: true });
39
+ fs.writeFileSync(file, JSON.stringify(stamps, null, 2) + "\n");
40
+ }
41
+ export function ownership(deckDir, file, stamps = readStamps(deckDir)) {
42
+ const name = path.basename(file);
43
+ let src;
44
+ try {
45
+ src = fs.readFileSync(file, "utf8");
46
+ } catch {
47
+ return "missing";
48
+ }
49
+ if (!stamps[name]) return "hand"; // no stamp: not ours
50
+ return stamps[name] === sha(src) ? "machine" : "hand";
51
+ }
52
+
53
+ // The spec's icons, fetched by the CLI itself: one engine search per distinct
54
+ // (query, colour), semantic match, downloaded under the file name the skeleton
55
+ // already references. The hold-out run (2026-09-19) showed the parent agent
56
+ // skipping its fetcher subagents outright — 71 icon rows, one file on disk,
57
+ // "the icon engine is unavailable" — so the step that cannot be skipped runs
58
+ // here. A miss keeps the unresolved reference and is reported for repair.
59
+ // Pane-relayed add-ins (no `engineRoutes`) are skipped with a note.
60
+ export async function fetchSpecIcons({ rows, out = () => {}, ...opts }) {
61
+ if (!rows.length) return { fetched: [], missed: [], skipped: null };
62
+ return attachSpecIcons(rows, [await downloadSpecIcons({ rows, out, ...opts })], out);
63
+ }
64
+
65
+ // The key `fetchAssetGroups` groups rows by: one search per (kind, query, colour).
66
+ const assetGroupKey = (r) => `${r.kind ?? "icon"}|${String(r.query).toLowerCase()}|${r.colorHex ?? "source"}`;
67
+
68
+ // Search and download only — no slide is touched, so it can run while settling
69
+ // rewrites the sources (the add-in prefetches the same way). `attachSpecIcons`
70
+ // then points each settled row at its group's file.
71
+ export async function downloadSpecIcons({ deckDir, bundleDir, brandId, rows, out = () => {}, dependencies = {} }) {
72
+ const client = dependencies.client ?? await import(pathToFileURL(path.join(SRC_DIR, "bridge", "client.mjs")).href);
73
+ const bundle = dependencies.bundle ?? await import(pathToFileURL(path.join(SRC_DIR, "bridge", "bundle.mjs")).href);
74
+ const { downloadOneResult } = dependencies.downloads ?? await import(pathToFileURL(path.join(SRC_DIR, "bridge", "engine-download.mjs")).href);
75
+ const retry = createEngineRetry({
76
+ sleep: dependencies.sleep, now: dependencies.now,
77
+ onRetry: ({ code, delay }) => out(`ASSETS: ${code}; retrying the same request after ${Math.ceil(delay / 1000)}s (not a missing motif).\n`),
78
+ });
79
+ // Routing rides the same ladder as the engine calls: a probe that got no
80
+ // answer (`capability_unknown`) is a transport failure, retried here and —
81
+ // if it stays unknown — recorded per row as retryable, so a bare re-run picks
82
+ // the assets up (pendingSpecAssets). Only a KNOWN answer skips: an add-in
83
+ // without the routes, no window to relay through, an ambiguous environment.
84
+ let routing;
85
+ try {
86
+ routing = await retry(() => (dependencies.resolveRouting ?? resolveEngineRouting)(client, { brandId, pick: "any" }));
87
+ } catch (error) {
88
+ if (error?.retryable) return { failure: { ...engineFailure(error), attempts: error.attempts, retryAfterMs: error.retryAfterMs } };
89
+ return { skipped: error?.message ?? String(error) };
90
+ }
91
+ if (!routing?.direct) {
92
+ return { skipped: "this add-in relays engine calls through a PowerPoint window; fetch the icons with the search skill" };
93
+ }
94
+ const backendURL = routing.backendURL;
95
+ let live;
96
+ try { live = await retry(() => client.listEngines({ subtype: "image", backendURL })); }
97
+ catch (error) {
98
+ return { failure: { ...engineFailure(error), attempts: error.attempts, retryAfterMs: error.retryAfterMs } };
99
+ }
100
+ const engines = (live.engines ?? []).filter((e) => bundle.pickImageEngine([e]));
101
+ if (!engines.length) {
102
+ return { skipped: "this corporate design has no image engine" };
103
+ }
104
+ // Recolour only with the per-element paint resolved against the emitted
105
+ // background by the transcriber. Missing evidence leaves the source intact.
106
+ const assetsDir = path.join(deckDir, "assets");
107
+ fs.mkdirSync(assetsDir, { recursive: true });
108
+ const tempDirs = [];
109
+ // The collage decides, as it does for the agent: each search's numbered
110
+ // contact sheet goes to the backend's reader, several motifs per call. A
111
+ // backend without the reader leaves the pick to the file names.
112
+ let canPick = false;
113
+ try { canPick = Array.isArray(await client.pickAssets?.({ items: [], backendURL })); } catch { canPick = false; }
114
+ const pickBatch = createBatcher(async (items) => {
115
+ const picks = await client.pickAssets({ items, backendURL });
116
+ if (!Array.isArray(picks)) throw new Error("the collage reader is unavailable");
117
+ return picks;
118
+ }, { max: 8 });
119
+ const groups = await fetchAssetGroups({
120
+ rows,
121
+ engines,
122
+ retry,
123
+ search: async ({ engine, query }) => {
124
+ const res = await client.engineSearch({ engineId: engine.id, query, subtype: "image", resultType: null, intent: null, top: 9, backendURL });
125
+ if (!res?.ok) return res;
126
+ return { ...res, collage: res.gridDataUri, results: Array.isArray(res.results) ? res.results : (res.thumbnails ?? []).map((t, i) => ({ selection: i + 1 })) };
127
+ },
128
+ pick: canPick
129
+ ? ({ group, results, collage }) => pickBatch({ query: group.query, keywords: group.keywords, kind: group.kind, collage, count: results.length })
130
+ : null,
131
+ download: async ({ searchId, selection, recolor }) => {
132
+ const downloadDir = fs.mkdtempSync(path.join(assetsDir, ".fetch-"));
133
+ tempDirs.push(downloadDir);
134
+ const got = await downloadOneResult({ client, presId: null, backendURL, searchId, assetsDir: downloadDir, selection, iconColor: recolor });
135
+ return got?.path && fs.existsSync(got.path) ? got : null;
136
+ },
137
+ onError: (query, err) => process.stderr.write(`slidemind: icon "${query}": ${err?.message ?? err}\n`),
138
+ });
139
+ return { groups, assetsDir, tempDirs };
140
+ }
141
+
142
+ /** Place downloaded groups into their rows' slides; a row no download covers is a miss. */
143
+ export function attachSpecIcons(rows, downloads, out = () => {}) {
144
+ const summary = { fetched: [], missed: [], skipped: null };
145
+ const groups = new Map(downloads.flatMap((dl) => (dl.groups ?? []).map((g) => [assetGroupKey(g.group), { ...g, assetsDir: dl.assetsDir }])));
146
+ const blocked = downloads.find((dl) => dl.skipped || dl.failure);
147
+ if (blocked?.skipped && !groups.size) return { ...summary, skipped: blocked.skipped };
148
+ for (const r of rows) {
149
+ const g = groups.get(assetGroupKey(r));
150
+ if (g?.result) {
151
+ try { summary.fetched.push(attachSpecAsset(r, g.result, g.assetsDir)); }
152
+ catch (err) { summary.missed.push({ ...r, error: err.message }); }
153
+ } else {
154
+ summary.missed.push({ ...r, ...(g ? g.failure : blocked?.failure ?? { error: blocked?.skipped }) });
155
+ }
156
+ }
157
+ for (const dir of downloads.flatMap((dl) => dl.tempDirs ?? [])) fs.rmSync(dir, { recursive: true, force: true });
158
+ if (!groups.size) {
159
+ // Nothing was searched: the fetch failed before its first engine call. Say
160
+ // why — the rows are recorded as retryable, and the cause is what tells a
161
+ // reader to re-run rather than to source every motif by hand.
162
+ if (blocked?.failure) {
163
+ summary.failure = blocked.failure;
164
+ out(
165
+ `ASSETS: not fetched — ${blocked.failure.error}` +
166
+ (blocked.failure.retryable
167
+ ? ` (${blocked.failure.code}; retryable — a bare re-run of transcribe retries every unresolved reference without touching the slides)`
168
+ : ` (${blocked.failure.code})`) +
169
+ "\n",
170
+ );
171
+ }
172
+ return summary;
173
+ }
174
+ // Missing assets remain explicit unresolved references; never silently
175
+ // remove approved content to make a build pass.
176
+ out(
177
+ `ASSETS: ${summary.fetched.length}/${rows.length} fetched from the available image engines into assets/` +
178
+ (summary.missed.length ? ` — ${summary.missed.length} miss${summary.missed.length === 1 ? "" : "es"} (unresolved references kept; source another motif for what each element stands for — a miss of one motif never drops, letters or numbers its element or its set): ${summary.missed.map((r) => r.file).join(", ")}` : "") +
179
+ "\n",
180
+ );
181
+ return summary;
182
+ }
183
+
184
+ // Blocks while another process holds the deck's `concepts` lock (up to
185
+ // SLIDEMIND_CONCEPTS_WAIT_MS, default 30 min), with a heartbeat on stderr.
186
+ export async function waitForConcepts(deckDir, { sleep = (ms) => new Promise((r) => setTimeout(r, ms)), limitMs = Number(process.env.SLIDEMIND_CONCEPTS_WAIT_MS) || 30 * 60_000 } = {}) {
187
+ const lockFile = path.join(deckDir, "state", "concepts.lock");
188
+ const holder = () => {
189
+ try {
190
+ const h = JSON.parse(fs.readFileSync(lockFile, "utf8"));
191
+ return h.pid !== process.pid && pidAlive(h.pid) ? h : null;
192
+ } catch {
193
+ return null;
194
+ }
195
+ };
196
+ const start = Date.now();
197
+ let told = 0;
198
+ for (let h = holder(); h; h = holder()) {
199
+ if (Date.now() - start > limitMs) fail(`concepts (pid ${h.pid}) is still running after ${Math.round(limitMs / 60000)} min; re-run transcribe when its CONCEPTS line has printed.`);
200
+ if (Date.now() - told >= 30_000) {
201
+ process.stderr.write(`slidemind: waiting for \`concepts\` (pid ${h.pid}) to finish reading this deck's mockups before transcribing…\n`);
202
+ told = Date.now();
203
+ }
204
+ await sleep(2000);
205
+ }
206
+ }
207
+
208
+ const slugify = (s) =>
209
+ String(s ?? "")
210
+ .toLowerCase()
211
+ .replace(/[^a-z0-9]+/g, "-")
212
+ .replace(/^-+|-+$/g, "")
213
+ .split("-")
214
+ .filter(Boolean)
215
+ .slice(0, 4)
216
+ .join("-") || "slide";
217
+
218
+ // Which planned slides this run can neither transcribe nor leave standing: no
219
+ // concept spec on disk AND no slide file of their own. A spec read is a polled
220
+ // job that saves each slide as it arrives, so a deck legitimately sits at
221
+ // "3 of 4 read" for minutes; transcribing then is what x44 measured — a clean
222
+ // `TRANSCRIBED: 3/3`, nothing said about the fourth slide, and an agent that
223
+ // hand-authored it from the mockup WITHOUT the icon fetch, which only runs for
224
+ // the slides this command writes. That slide lost all 14 of its icons and
225
+ // scored the batch's worst fidelity. A slide someone is deliberately writing by
226
+ // hand has a file, so it is not reported here.
227
+ // A spec on disk read from another picture than the mockup now beside it (the
228
+ // mockup was regenerated, or the spec predates the binding): `concepts` reads
229
+ // it again from the mockup on disk.
230
+ export function staleSpecMessage(deck, slides) {
231
+ const one = slides.length === 1;
232
+ return (
233
+ `the concept spec${one ? "" : "s"} of slide${one ? "" : "s"} ${slides.join(", ")} ${one ? "was" : "were"} not read from the mockup now on disk — ` +
234
+ `building from ${one ? "it" : "them"} would follow a picture that no longer exists. Run \`slidemind concepts ${deck}\`: it reads ` +
235
+ `${one ? "that spec" : "those specs"} again from the mockup${one ? "" : "s"} on disk, then transcribe.`
236
+ );
237
+ }
238
+
239
+ export function plannedWithoutSource(planned, specSlides, existingFiles, only = null) {
240
+ return planned
241
+ .filter((n) => Number.isInteger(n) && (only === null || n === only))
242
+ .filter((n) => !specSlides.includes(n))
243
+ .filter((n) => !existingFiles.some((f) => new RegExp(`^${String(n).padStart(2, "0")}[-_.]`).test(f)))
244
+ .sort((a, b) => a - b);
245
+ }
246
+
247
+ export async function cmdTranscribe(rest, { dependencies = {} } = {}) {
248
+ let deck = null;
249
+ let only = null;
250
+ let force = false;
251
+ let asJson = false;
252
+ let icons = true;
253
+ let settle = true;
254
+ let discardEdits = false;
255
+ let repair = false;
256
+ let readSpecOnly = false;
257
+ for (let i = 0; i < rest.length; i++) {
258
+ const a = rest[i];
259
+ if (a === "--only") {
260
+ only = Number(rest[++i]);
261
+ if (!Number.isFinite(only)) fail("--only expects a slide number");
262
+ } else if (a === "--force") force = true;
263
+ else if (a === "--discard-edits") discardEdits = true;
264
+ else if (a === "--repair") repair = true;
265
+ else if (a === "--json") asJson = true;
266
+ else if (a === "--read-spec") readSpecOnly = true;
267
+ else if (a === "--no-icons" || a === "--no-assets") icons = false;
268
+ else if (a === "--no-settle") settle = false;
269
+ else if (a.startsWith("-")) fail(`unknown flag: ${a}`);
270
+ else if (deck === null) deck = a;
271
+ else fail(`unexpected argument: ${a}`);
272
+ }
273
+ if (!deck) fail("usage: slidemind transcribe <deck> [--only NN] [--force [--discard-edits]] [--no-assets] [--no-settle] [--read-spec] [--json]");
274
+ // Repair (keep the source, fix what the render contradicts) and regeneration
275
+ // (replace the source from the spec) are opposite intents — refuse before
276
+ // anything is read, so neither can half-run.
277
+ if (repair && (force || discardEdits))
278
+ fail("--repair cannot be combined with --force/--discard-edits: repair keeps the existing source, regeneration replaces it from the spec");
279
+ const deckDir = resolveDeckArg(deck);
280
+ if (repair) return await runRepair({ deck, deckDir, only, asJson, dependencies });
281
+ if (readSpecOnly) {
282
+ const specs = await import(pathToFileURL(path.join(BUILDERS_DIR, "concept-spec.mjs")).href);
283
+ const slides = only === null ? specs.listSpecSlides(deckDir) : [only];
284
+ if (!slides.length) fail("no concept specs for this deck");
285
+ for (const slide of slides) {
286
+ const { status, spec } = specState(deckDir, slide);
287
+ if (spec && status !== "current") fail(staleSpecMessage(deck, [slide]));
288
+ if (!spec) fail(`no valid complete spec for slide ${slide}`);
289
+ process.stdout.write(JSON.stringify({ slide, spec, complete: true }, null, 2) + "\n");
290
+ process.stdout.write(`END OF SPEC ${slide} — ${spec.elements?.length ?? 0} elements, ${spec.connectors?.length ?? 0} connectors\n`);
291
+ }
292
+ return;
293
+ }
294
+ await requirePlanApproved(deckDir);
295
+ // A `concepts` run still writing this deck's specs: wait for it rather than
296
+ // refuse. x56 QBR started concepts in the background, ran transcribe early,
297
+ // read "no concept specs" as a dead end and hand-authored all six slides
298
+ // (judged 0.59 against 0.88 for the transcribed decks).
299
+ await waitForConcepts(deckDir);
300
+
301
+ const specMod = await import(pathToFileURL(path.join(BUILDERS_DIR, "concept-spec.mjs")).href);
302
+ const tr = await import(pathToFileURL(path.join(BUILDERS_DIR, "transcribe.mjs")).href);
303
+ const sbMod = await import(pathToFileURL(path.join(BUILDERS_DIR, "storyboard-markdown.mjs")).href);
304
+ const bundleMod = await import(pathToFileURL(path.join(SRC_DIR, "bridge", "bundle.mjs")).href);
305
+ const bundleDir = bundleMod.resolveBundleDirForDeck(deckDir);
306
+ const planMarkdown = fs.readFileSync(path.join(deckDir, "plan.md"), "utf8");
307
+ const layoutBySlide = new Map();
308
+ const beatBySlide = new Map();
309
+ // Frame slides (cover, agenda, divider, closing) are chrome with text slots,
310
+ // authored from the plan — `concepts` generates no mockup and no spec for
311
+ // them by design (docs/…/17-deck-frame.md §2.5). Classify with the bundle's
312
+ // own job table, the same way `concepts` takes them out of `planned`, so the
313
+ // missing-spec gate below cannot fire on a slide that is never meant to have
314
+ // one.
315
+ const jobsMod = await import(pathToFileURL(path.join(BUILDERS_DIR, "layout-jobs.mjs")).href);
316
+ const jobs = jobsMod.layoutJobs(bundleDir);
317
+ const bodySlides = [];
318
+ // Body slides whose layout takes no inventory (none in the bundle, or no
319
+ // content slot): `concepts` reads no spec for them, so none is owed here.
320
+ const noInventory = new Map();
321
+ for (const segment of sbMod.splitPlanMarkdownIntoSegments(planMarkdown)) {
322
+ if (segment.type !== "storyboard" || !segment.data?.slide) continue;
323
+ // Resolved as the add-in resolves it: a near-miss name is the design's own.
324
+ const resolved = segment.data.layout ? specMod.plannedLayoutSlot(bundleDir, segment.data.layout) : { layout: null, slot: null };
325
+ const data = { ...segment.data, layout: resolved.layout ?? segment.data.layout };
326
+ layoutBySlide.set(data.slide, data.layout ?? null);
327
+ beatBySlide.set(data.slide, data);
328
+ const job = data.layout ? jobsMod.plannedJob(data, jobs) : null;
329
+ if (jobsMod.isFrameJob(job)) continue;
330
+ if (resolved.slot) bodySlides.push(data.slide);
331
+ else noInventory.set(data.slide, specMod.layoutSlotNote(segment.data.layout, resolved));
332
+ }
333
+ // NOT created here: a run that refuses (no specs, every slide hand-owned or
334
+ // converted) must leave the deck exactly as it was, and an empty `slides/`
335
+ // is a change like any other. The directory is made where the first file is
336
+ // actually written, below.
337
+ const slidesDir = path.join(deckDir, "slides");
338
+ // Only a spec read from the mockup on disk is one; a spec read from an
339
+ // earlier picture describes nothing that exists (concept-spec-binding.mjs).
340
+ const { current: specSlides, outdated: outdatedSpecs } = specSlidesByState(deckDir);
341
+ // Both refusals below say "run concepts again". Against an add-in that has
342
+ // no spec reader that advice loops forever, so the agent gives up and
343
+ // hand-builds — exactly what the 2026-10-06 proposal sim did after `concepts`
344
+ // came back with mockups and `specs: []`. Ask the add-in first, and only on
345
+ // the way to a refusal: a deck with every spec it needs never pays a probe.
346
+ const existingBefore = fs.existsSync(slidesDir)
347
+ ? fs.readdirSync(slidesDir).filter((f) => f.endsWith(".jsx"))
348
+ : [];
349
+ const unread = plannedWithoutSource(bodySlides, specSlides, existingBefore, only);
350
+ if (specSlides.length === 0 || unread.length) {
351
+ const support = await (dependencies.conceptSpecs ?? (async () => {
352
+ const client = await import(pathToFileURL(path.join(SRC_DIR, "bridge", "client.mjs")).href);
353
+ const backendURL = await client.resolveConceptBackend().catch(() => null);
354
+ return client.conceptSpecSupport(backendURL);
355
+ }))().catch(() => ({ status: "unknown" }));
356
+ if (support?.status === "unsupported") {
357
+ const message = transcribeSpecsRefusal(support, { deck, slides: unread });
358
+ if (asJson) {
359
+ process.stdout.write(JSON.stringify({
360
+ ok: false,
361
+ error: { code: SPECS_UNSUPPORTED_CODE, message, retryable: false, env: support.env ?? null, backendURL: support.backendURL ?? null, slides: unread },
362
+ }) + "\n");
363
+ }
364
+ fail(message, 2);
365
+ }
366
+ }
367
+ // A planned slide whose spec on disk belongs to another picture: built from
368
+ // it, the slide would follow a mockup that no longer exists (x112 GTM 1–3).
369
+ const outdatedToWrite = outdatedSpecs.filter((n) => unread.includes(n));
370
+ if (outdatedToWrite.length) fail(staleSpecMessage(deck, outdatedToWrite));
371
+ if (specSlides.length === 0) {
372
+ fail(`no concept specs for this deck yet. Run \`slidemind concepts ${deck}\` in the foreground and let it finish (it prints one CONCEPTS line), then transcribe again — do not author the slides by hand while mockups are waiting to be read.`);
373
+ }
374
+ // A spec read is a polled job that saves each slide as it arrives, so a
375
+ // deck can legitimately sit at "3 of 4 read" for minutes. Transcribing then
376
+ // is the failure x44 measured: the run reported a clean `TRANSCRIBED: 3/3`,
377
+ // said nothing about the fourth slide, and the agent hand-authored it from
378
+ // the mockup — without the icon fetch, which only runs for slides this
379
+ // command transcribes. That slide lost all 14 of its icons and scored the
380
+ // batch's worst fidelity. A planned slide with neither a spec nor a file of
381
+ // its own has nothing to transcribe from and nothing already standing, so
382
+ // stop rather than report success over the gap. A slide the author is
383
+ // deliberately writing by hand HAS a file and does not trip this. (`unread`
384
+ // is computed above, where the add-in is asked whether a re-run can help.)
385
+ if (unread.length) {
386
+ fail(
387
+ `the plan has ${bodySlides.length} body slide${bodySlides.length === 1 ? "" : "s"} and slide${unread.length === 1 ? "" : "s"} ${unread.join(", ")} ${unread.length === 1 ? "has" : "have"} no concept spec yet. ` +
388
+ `Run \`slidemind concepts ${deck}\` until it prints CONCEPTS: ${bodySlides.length}/${bodySlides.length} — the read saves each spec as it arrives and a re-run asks only for the rest. ` +
389
+ `Transcribing now would skip ${unread.length === 1 ? "that slide" : "those slides"} silently, and its assets are fetched only for the slides this command writes. ` +
390
+ `Only a slide \`concepts\` called unreadable is authored by hand from its concept (workflow/create.md, step 2): write its file, then transcribe the rest.`,
391
+ );
392
+ }
393
+ // Every line is written as a native line directive (nativeLinesV1) and there
394
+ // is no older markup to fall back to: an add-in that answered without the
395
+ // flag stops the run before the first slide is written. One that could not
396
+ // be asked (offline, several backends) is named and the run goes on — the
397
+ // bundle's lint refuses the directives at view-draft if it predates them.
398
+ // Asked once, and only when a slide is about to be written.
399
+ let lineSupport = null;
400
+ const requireNativeLines = async () => {
401
+ if (lineSupport) return;
402
+ lineSupport = await (dependencies.nativeLines ?? (async () => {
403
+ const client = await import(pathToFileURL(path.join(SRC_DIR, "bridge", "client.mjs")).href);
404
+ const backendURL = await client.resolveConceptBackend().catch(() => null);
405
+ return client.nativeLinesSupport(backendURL);
406
+ }))();
407
+ if (lineSupport === "unsupported") {
408
+ const message = "[line:error] this add-in predates native lines — update the add-in (it does not advertise nativeLinesV1; nothing was written)";
409
+ if (asJson) process.stdout.write(JSON.stringify({ ok: false, error: { code: "native_lines_unsupported", message, retryable: false } }) + "\n");
410
+ fail(message, 2);
411
+ }
412
+ if (lineSupport !== "supported") process.stderr.write("slidemind: [line:note] whether the add-in exports native lines (nativeLinesV1) could not be checked; the lines are written in that vocabulary regardless\n");
413
+ };
414
+ const { readColorTokens, readTypeRamp } = await import(pathToFileURL(path.join(BUILDERS_DIR, "design-tokens.mjs")).href);
415
+ const tokens = readColorTokens(bundleDir);
416
+ const ramp = readTypeRamp(bundleDir);
417
+ // The deck's approved originals, resolved once for every slide this run
418
+ // touches. A deck with no image uses gets an empty map and an empty issue
419
+ // list, and everything below behaves exactly as it did before the contract.
420
+ const wanted = only === null ? specSlides : [only];
421
+ const { bound: boundUses, issues: boundIssues, reconstructed } = resolveBoundImageUses(deckDir, wanted, { planMarkdown });
422
+ const notesBySlide = new Map();
423
+ const noteFor = (slide, text) => {
424
+ if (!notesBySlide.has(slide)) notesBySlide.set(slide, []);
425
+ notesBySlide.get(slide).push(text);
426
+ };
427
+ for (const [slide, note] of noInventory) if (only === null || slide === only) noteFor(slide, note);
428
+ for (const issue of boundIssues) noteFor(issue.slide, specMod.unresolvedUseNote(issue));
429
+ // Rebuilding a slide (--force) from a spec read off another picture is the
430
+ // same mistake as a first build from one; without --force such a slide keeps
431
+ // its file and nothing is built from the spec.
432
+ const outdatedInScope = outdatedSpecs.filter((n) => only === null || n === only);
433
+ if (force && outdatedInScope.length) fail(staleSpecMessage(deck, outdatedInScope));
434
+ const stamps = readStamps(deckDir);
435
+ const existing = existingBefore;
436
+ const results = [];
437
+ for (const n of specSlides) {
438
+ if (only !== null && n !== only) continue;
439
+ const nn = String(n).padStart(2, "0");
440
+ const have = existing.find((s) => new RegExp(`^${nn}[-_.]`).test(s));
441
+ if (have) {
442
+ const source = fs.readFileSync(path.join(slidesDir, have), "utf8");
443
+ // Two signals, and the deck's own record is the one a worker cannot
444
+ // delete: removing the pragma does not make a converted slide semantic
445
+ // (E15), and a record that cannot be READ leaves the question open —
446
+ // which is not authorization to regenerate over it either.
447
+ const provenance = conversionProvenance(deckDir, have);
448
+ if (provenance === undefined) {
449
+ results.push({ slide: n, file: path.join(slidesDir, have), skipped: `conversions/${path.basename(have, ".jsx")}.json cannot be read, so whether this slide is converted is unknown — repair or remove that record` });
450
+ continue;
451
+ }
452
+ if (provenance || /^\s*\/\/\s*SLIDEMIND_DIALECT:\s*converted\b/m.test(source)) {
453
+ results.push({ slide: n, file: path.join(slidesDir, have), skipped: "converted slide — edit its JSX directly; transcription and settling do not apply" });
454
+ continue;
455
+ }
456
+ const owner = ownership(deckDir, path.join(slidesDir, have), stamps);
457
+ if (owner === "hand" && !(force && discardEdits)) {
458
+ // Someone edited this slide since it was transcribed: keep their file
459
+ // exactly as it is. --only selects slides, it does not authorize a
460
+ // rewrite, and --force refuses unless the edits are to be discarded.
461
+ results.push({
462
+ slide: n,
463
+ file: path.join(slidesDir, have),
464
+ skipped: force
465
+ ? "hand-edited since transcribe — --force refuses; add --discard-edits to rebuild it from the spec"
466
+ : "hand-edited since transcribe — kept unchanged; `--force --discard-edits` rebuilds it from the spec",
467
+ });
468
+ continue;
469
+ }
470
+ if (!force) {
471
+ results.push({ slide: n, file: path.join(slidesDir, have), skipped: "exists (--force rebuilds it from the spec)" });
472
+ continue;
473
+ }
474
+ }
475
+ const spec = currentSpec(deckDir, n);
476
+ if (!spec) continue;
477
+ await requireNativeLines();
478
+ // The plan's layout, always: the concept is drawn for it, never the other
479
+ // way round. One the bundle does not carry is a plan defect, reported.
480
+ const usedLayout = layoutBySlide.get(n);
481
+ // Skipped, not fatal: a run that exits here would leave the slides it
482
+ // already wrote unstamped, and the next run would keep them as hand-edited.
483
+ if (!usedLayout) {
484
+ results.push({ slide: n, skipped: "the plan names no layout for this slide; correct the plan before transcription" });
485
+ continue;
486
+ }
487
+ let layoutSource;
488
+ try {
489
+ layoutSource = fs.readFileSync(path.join(bundleDir, "layouts", `${usedLayout}.jsx`), "utf8");
490
+ } catch {
491
+ results.push({ slide: n, skipped: `the plan's layout ${usedLayout} is not in this design's bundle — restore the planned layout or correct the plan; never substitute a layout from the concept image` });
492
+ continue;
493
+ }
494
+ // A concept drawn against its layout's tone is a concept-generation defect;
495
+ // a reader-flagged flaw and the template's own marks the reader left out
496
+ // are what the agent corrects or keeps out.
497
+ const notes = specMod.specAuthorNotes(spec);
498
+ const scaffolded = tr.scaffoldSlideJsx(spec, { slide: n, layout: usedLayout, layoutSource, tokens, ramp, bound: boundUses, literal: reconstructed.has(n) });
499
+ const { jsx, icons } = scaffolded;
500
+ const warnings = [...notes, ...scaffolded.warnings];
501
+ const file = have ? path.join(slidesDir, have) : path.join(slidesDir, `${nn}-${slugify(spec.chrome?.kicker || spec.chrome?.headline)}.jsx`);
502
+ fs.mkdirSync(slidesDir, { recursive: true });
503
+ fs.writeFileSync(file, jsx);
504
+ results.push({ slide: n, file, spec, layout: usedLayout, layoutSource, elements: spec.counts?.elements ?? spec.elements?.length ?? 0, connectors: spec.connectors?.length ?? 0, icons, warnings, dropped: scaffolded.dropped, connectorLines: scaffolded.connectorLines });
505
+ }
506
+ // Settle: render the skeletons, measure them, fit each text to the room the
507
+ // image gives it, re-render — then fit every connector once on the final
508
+ // geometry (src/builders/settle-driver.mjs, shared with the add-in). Without
509
+ // a render (--no-settle, or a bundle with no capture engine) connectors are
510
+ // fitted against the spec's own rectangles.
511
+ let settled = { passes: 0, changes: 0 };
512
+ const written = results.filter((r) => !r.skipped);
513
+ // Icon searches do not wait for settling: a row's query is the spec's, which
514
+ // settling never changes. Downloaded now, placed below once the settled
515
+ // source is final (settling rescaffolds it, references included).
516
+ const assetOut = (t) => (asJson ? process.stderr : process.stdout).write(t);
517
+ const iconDeps = { deckDir, bundleDir, brandId: bundleMod.readDeckBundleRef(deckDir)?.presFP ?? null, out: assetOut, dependencies: dependencies.icons ?? {} };
518
+ const firstRows = written.flatMap((r) => r.icons.map((ic) => ({ ...ic, slide: r.slide, slideFile: r.file })));
519
+ const prefetch = icons && firstRows.length ? downloadSpecIcons({ ...iconDeps, rows: firstRows }).catch(() => null) : null;
520
+ if (written.length) {
521
+ const cap = await import(pathToFileURL(path.join(SRC_DIR, "bridge", "capture.mjs")).href);
522
+ const { settleWrittenSlides } = await import(pathToFileURL(path.join(BUILDERS_DIR, "settle-driver.mjs")).href);
523
+ const byKey = new Map(written.map((r) => [r.file, r]));
524
+ const slides = written.map((r) => ({ key: r.file, spec: r.spec, plan: beatBySlide.get(r.slide) ?? null }));
525
+ // Words that still do not fit after the ladder's step down are shortened
526
+ // by the add-in's model (copy-fit.mjs): the CLI holds no model key.
527
+ const client = await import(pathToFileURL(path.join(SRC_DIR, "bridge", "client.mjs")).href);
528
+ const brandId = bundleMod.readDeckBundleRef(deckDir)?.presFP ?? null;
529
+ const copyBackend = settle && brandId ? await client.resolveConceptBackend().catch(() => null) : null;
530
+ const driverOpts = {
531
+ slides,
532
+ ramp,
533
+ fitCopy: copyBackend
534
+ ? async ({ items, context, plan }) => (await client.fitCopyForBrand(brandId, { items, context, plan, backendURL: copyBackend })) ?? []
535
+ : null,
536
+ capture: settle && cap.bundleHasCaptureEngine(bundleDir)
537
+ ? async (keys) => {
538
+ const render = await cap.captureDeckLocally({ deckDir, bundleDir, only: keys.map((k) => path.basename(k)) });
539
+ return new Map(keys.map((k) => [k, (render?.slides ?? []).find((sl) => sl.fileName === path.basename(k))?.render]));
540
+ }
541
+ : null,
542
+ // `normalize: false`: the row/centre normalizers already ran on the first
543
+ // write and re-running them here would snap the geometry this pass just
544
+ // measured and moved straight back to where it overlapped.
545
+ // `bound` rides along: without it this rewrite would drop every
546
+ // binding the first write placed and re-open the pictures to search.
547
+ rescaffold: (s, spec) => {
548
+ const r = byKey.get(s.key);
549
+ r.spec = spec;
550
+ const again = tr.scaffoldSlideJsx(spec, { slide: r.slide, layout: r.layout, layoutSource: r.layoutSource, tokens, ramp, normalize: false, bound: boundUses, literal: reconstructed.has(r.slide) });
551
+ r.icons = again.icons;
552
+ r.dropped = again.dropped;
553
+ r.connectorLines = again.connectorLines;
554
+ r.warnings = [...new Set([...r.warnings, ...again.warnings])];
555
+ fs.writeFileSync(r.file, again.jsx);
556
+ },
557
+ readSource: (key) => fs.readFileSync(key, "utf8"),
558
+ writeSource: (key, src) => fs.writeFileSync(key, src),
559
+ onPass: (pass, n) => process.stderr.write(`slidemind: settle pass ${pass} — rendering ${n} slide${n === 1 ? "" : "s"}…\n`),
560
+ onError: (step, err) => process.stderr.write(`slidemind: ${step === "settle" ? "settle skipped" : step === "connectors" ? "connector fit uses the spec's rectangles" : step} — ${err?.message ?? err}\n`),
561
+ };
562
+ const run = await settleWrittenSlides(driverOpts);
563
+ settled = { passes: run.passes, changes: run.changes };
564
+ for (const s of slides) {
565
+ const r = byKey.get(s.key);
566
+ if (run.bySlide.has(s.key)) r.settleChanges = run.bySlide.get(s.key);
567
+ if (s.routeChanges) r.routeChanges = s.routeChanges;
568
+ const beyond = (s.routeChanges ?? []).filter((line) => line.includes("beyond tolerance"));
569
+ if (beyond.length) r.warnings = [...(r.warnings ?? []), ...beyond];
570
+ }
571
+ }
572
+
573
+ // Fetch only the visual assets the spec requests (--no-assets skips fetching).
574
+ //
575
+ // Two sources, and the second is what makes a resume work (task 7.3 / E22).
576
+ // The rows THIS run wrote are the fresh work; `pendingSpecAssets` adds the
577
+ // ones an earlier run could not resolve — a slide that is skipped here
578
+ // ("exists", or hand-edited) still gets its missing picture retried, without
579
+ // its spec being re-extracted, its source regenerated or its geometry
580
+ // settled. A reference the author repointed at their own file is not in that
581
+ // list: the ledger matches on the exact string the machine wrote.
582
+ let iconSummary = null;
583
+ const fresh = results
584
+ .filter((r) => !r.skipped)
585
+ .flatMap((r) => r.icons.map((ic) => ({ ...ic, slide: r.slide, slideFile: r.file })));
586
+ recordSpecAssets(deckDir, fresh.map((row) => ({ ...row, resolved: false })));
587
+ const freshKeys = new Set(fresh.map((r) => `${r.slide}|${r.id}`));
588
+ const retried = pendingSpecAssets(
589
+ deckDir,
590
+ new Map(results.filter((r) => r.file).map((r) => [r.slide, r.file])),
591
+ ).filter((r) => !freshKeys.has(`${r.slide}|${r.id}`));
592
+ const iconRows = [...fresh, ...retried];
593
+ // Ownership BEFORE the fetch: resolving a reference rewrites one token of a
594
+ // slide's source, and a slide the machine still owned must not be demoted to
595
+ // hand-owned by our own edit. A hand-owned slide stays hand-owned.
596
+ // Only the SKIPPED slides: a slide this run wrote is stamped below anyway,
597
+ // and `stamps` still holds the pre-run values here, so asking about one
598
+ // would answer about the file it replaced.
599
+ const ownedBefore = new Map(
600
+ results.filter((r) => r.skipped && r.file).map((r) => [r.slide, ownership(deckDir, r.file, stamps)]),
601
+ );
602
+ if (icons && iconRows.length) {
603
+ if (retried.length) {
604
+ // stderr: a progress note, never stdout — a --json payload stays data.
605
+ const slides = new Set(retried.map((r) => r.slide)).size;
606
+ process.stderr.write(
607
+ `slidemind: retrying ${retried.length} unresolved asset reference${retried.length === 1 ? "" : "s"} on ` +
608
+ `${slides} already-written slide${slides === 1 ? "" : "s"} — no spec re-read, no source regenerated, no geometry settled\n`,
609
+ );
610
+ }
611
+ // Under --json the payload owns stdout; the fetch's progress line is a
612
+ // note, so it goes to stderr (`assetOut`) instead of corrupting it.
613
+ const early = await prefetch;
614
+ const usable = early && !early.skipped && !early.failure ? [early] : [];
615
+ const covered = new Set(usable.flatMap((dl) => dl.groups.map((g) => assetGroupKey(g.group))));
616
+ const rest = iconRows.filter((r) => !covered.has(assetGroupKey(r)));
617
+ const later = rest.length ? [await downloadSpecIcons({ ...iconDeps, rows: rest })] : [];
618
+ iconSummary = attachSpecIcons(iconRows, [...usable, ...later], assetOut);
619
+ for (const result of results) if (result.icons) result.icons = result.icons.map((row) =>
620
+ iconSummary.fetched.find((asset) => asset.slide === result.slide && asset.id === row.id) ?? row);
621
+ if (iconSummary.skipped) (asJson ? process.stderr : process.stdout).write(`ASSETS: not fetched — ${iconSummary.skipped}; the rows below name what each slide needs.\n`);
622
+ // The outcome, per row: a resolved row records the file name the bytes
623
+ // actually produced, a miss keeps its reference and stays retriable.
624
+ recordSpecAssets(deckDir, [
625
+ ...iconSummary.fetched.map((row) => ({ ...row, resolved: true, error: null })),
626
+ ...iconSummary.missed.map((row) => ({ ...row, resolved: false })),
627
+ ]);
628
+ }
629
+ // Every approved original the plan gives a slide must actually be placed on
630
+ // it. An obligation added after the slide was written — or an edit that
631
+ // dropped the picture — is REPORTED here and never repaired by rewriting the
632
+ // file: a slide someone edited is theirs, and a missing binding is not
633
+ // authorization to reset it. The render-side proof is a separate, stricter
634
+ // check on the captured pictures; this one only needs to tell the author.
635
+ for (const r of results) {
636
+ let source = "";
637
+ try {
638
+ source = r.file ? fs.readFileSync(r.file, "utf8") : "";
639
+ } catch {
640
+ /* the slide was never written — the note is the right answer */
641
+ }
642
+ for (const note of specMod.unplacedUseNotes(source, boundUses, r.slide)) noteFor(r.slide, note);
643
+ }
644
+ for (const r of results) {
645
+ const notes = notesBySlide.get(r.slide);
646
+ if (!notes?.length) continue;
647
+ if (r.skipped) r.warnings = [...(r.warnings ?? []), ...notes];
648
+ else r.warnings = [...new Set([...(r.warnings ?? []), ...notes])];
649
+ notesBySlide.delete(r.slide);
650
+ }
651
+
652
+ // Stamp the final JSX after settling and resolving asset formats. A run that
653
+ // wrote nothing (every slide kept or refused) rewrites no stamp either —
654
+ // except where a RETRY resolved a reference on a slide the machine still
655
+ // owned. That rewrite is ours, so the stamp follows it; a hand-owned slide
656
+ // keeps its file and its ownership, and gets no stamp from us.
657
+ const restamped = results.filter(
658
+ (r) => r.skipped && r.file && ownedBefore.get(r.slide) === "machine"
659
+ && (iconSummary?.fetched ?? []).some((a) => a.slide === r.slide),
660
+ );
661
+ if (written.length || restamped.length) {
662
+ for (const r of [...results.filter((x) => !x.skipped), ...restamped]) {
663
+ if (!r.file) continue;
664
+ try {
665
+ stamps[path.basename(r.file)] = sha(fs.readFileSync(r.file, "utf8"));
666
+ } catch {}
667
+ }
668
+ writeStamps(deckDir, stamps);
669
+ // Beside each stamp, what that write left out and the line id each
670
+ // connector went under: preservation seeding binds the concept's
671
+ // obligations to it (src/lib/edit-state.mjs `bindDelivered`).
672
+ const delivery = readStamps(deckDir, DELIVERY);
673
+ for (const r of results.filter((x) => !x.skipped && x.file)) {
674
+ delivery[path.basename(r.file)] = { dropped: r.dropped ?? [], connectorLines: r.connectorLines ?? [] };
675
+ }
676
+ writeStamps(deckDir, delivery, DELIVERY);
677
+ }
678
+ const imageIntent = boundUses.size || boundIssues.length
679
+ ? {
680
+ bound: [...boundUses.values()].map(({ notes, ...b }) => b),
681
+ issues: boundIssues,
682
+ }
683
+ : null;
684
+ if (asJson) {
685
+ process.stdout.write(JSON.stringify({ deck: deckSlug(deckDir), results: results.map(({ spec, layoutSource, ...r }) => r), icons: iconSummary, retried: retried.map(({ slideFile: _f, ...r }) => r), settled, ...(imageIntent ? { imageIntent } : {}) }, null, 2) + "\n");
686
+ return;
687
+ }
688
+ // Notes about a slide this run never reached (and the deck-wide ones) still
689
+ // have to be said: an unresolvable source is not less true for being found
690
+ // outside the slide set.
691
+ for (const [slide, notes] of notesBySlide) {
692
+ for (const note of notes) process.stdout.write(`${slide ? `slide ${slide}` : "deck"}: note: ${note}\n`);
693
+ }
694
+ for (const r of results) {
695
+ if (r.skipped) {
696
+ process.stdout.write(`slide ${r.slide}: skipped — ${r.skipped}${r.file ? ` (${path.relative(process.cwd(), r.file)})` : ""}\n`);
697
+ for (const w of r.warnings ?? []) process.stdout.write(` note: ${w}\n`);
698
+ continue;
699
+ }
700
+ process.stdout.write(`slide ${r.slide}: ${path.relative(process.cwd(), r.file)} — ${r.elements} elements, ${r.connectors} connectors, ${r.icons.length} assets${r.settleChanges?.length ? `, settled ${r.settleChanges.length} (${r.settleChanges.slice(0, 3).join("; ")}${r.settleChanges.length > 3 ? "; …" : ""})` : ""}${r.routeChanges?.length ? `, routed ${r.routeChanges.length} (${r.routeChanges.slice(0, 2).join("; ")})` : ""}\n`);
701
+ for (const u of r.routeUnresolved ?? []) process.stdout.write(` note: connector ${u}\n`);
702
+ if (!iconSummary || iconSummary.skipped || iconSummary.failure) for (const ic of r.icons) process.stdout.write(` ${ic.kind ?? "icon"} ${ic.file} | ${ic.query} | ${ic.color}\n`);
703
+ for (const w of r.warnings) process.stdout.write(` note: ${w}\n`);
704
+ }
705
+ const iconsDone = !iconRows.length || (iconSummary && !iconSummary.skipped && !iconSummary.missed.length);
706
+ if (settled.passes) process.stdout.write(`SETTLED: ${settled.changes} geometry change${settled.changes === 1 ? "" : "s"} over ${settled.passes} render pass${settled.passes === 1 ? "" : "es"}\n`);
707
+ process.stdout.write(
708
+ `TRANSCRIBED: ${written.length}/${results.length}` +
709
+ (written.length
710
+ ? iconsDone
711
+ ? ` — next: \`slidemind view-draft ${deck} --only NN\` per slide, fix what it prints in the JSX, then compare each PNG with its draft once. A slide you edit is yours: transcribe leaves it alone from then on — edit its JSX directly; \`--force --discard-edits\` rebuilds it from the mockup.`
712
+ : iconSummary?.failure?.retryable
713
+ ? ` — next: re-run \`slidemind transcribe ${deck}\` — the asset fetch failed (${iconSummary.failure.code}) before any motif was searched, and a bare re-run retries every unresolved reference without re-reading specs or touching the slides. Do not hand-draw or letter the missing icons.`
714
+ : ` — next: resolve the asset rows above (download-result --name <file>), then \`slidemind view-draft ${deck} --only NN\` per slide and fix what it prints.`
715
+ : "") +
716
+ "\n",
717
+ );
718
+ }
719
+
720
+ // ---- `slidemind transcribe <deck> --repair` (safeguard-slide-editing 6.4)
721
+ //
722
+ // Bounded repair of source that already exists: keep the author's slide, fix
723
+ // only what its own render contradicts, inside the budget its operation
724
+ // declares (src/builders/repair.mjs). It is the opposite intent from
725
+ // regeneration — which is why the flags refuse each other above — and it is
726
+ // never something a plain `transcribe` slips into: the authorization is the
727
+ // recorded scope, not the flag.
728
+ //
729
+ // Deliberately NOT gated on `requirePlanApproved`: repair authors no content
730
+ // from the plan. It moves what is already there, under a scope a human opened
731
+ // with `revision begin`, which is the authorization that matters here — and an
732
+ // adopted deck being repaired may have no plan at all.
733
+ async function runRepair({ deck, deckDir, only, asJson, dependencies = {} }) {
734
+ const { openOperations } = await import(pathToFileURL(path.join(SRC_DIR, "lib", "edit-state.mjs")).href);
735
+ const { repairOperation } = await import(pathToFileURL(path.join(BUILDERS_DIR, "repair.mjs")).href);
736
+
737
+ let open;
738
+ try {
739
+ open = openOperations(deckDir);
740
+ } catch (err) {
741
+ fail(err.message, 2);
742
+ }
743
+ const scopeHint =
744
+ `Open one first: \`slidemind revision begin ${deck} --operation repair --slides <sel>\`, ` +
745
+ `export the printed SLIDEMIND_OWNER, then run this command inside that session.`;
746
+ const nn = only === null ? null : String(only).padStart(2, "0");
747
+ const namesSlide = (op) => op.targets.some((t) => new RegExp(`(^|/)${nn}[-_.]`).test(t.jsxPath));
748
+ const repairs = open.filter((o) => o.kind === "repair");
749
+ const scoped = nn === null ? repairs : repairs.filter(namesSlide);
750
+ if (scoped.length === 0) {
751
+ fail(
752
+ `--repair needs an open repair scope${nn === null ? "" : ` covering slide ${only}`} — this deck has ` +
753
+ `${repairs.length === 0 ? "none" : `${repairs.length} that do not name it (${repairs.flatMap((o) => o.targets.map((t) => t.jsxPath)).join(", ")})`}. ${scopeHint}`,
754
+ 2,
755
+ );
756
+ }
757
+ if (scoped.length > 1) {
758
+ fail(`this deck has ${scoped.length} open repair scopes (${scoped.map((o) => o.operationId).join(", ")}); finish one before running another.`, 2);
759
+ }
760
+ const operation = scoped[0];
761
+ // Unsupported scope: the scope is the unit of repair. `--only` may confirm a
762
+ // single-slide scope, never carve a smaller one out of a wider one — a
763
+ // partial run would commit its operation record as done for slides it never
764
+ // touched.
765
+ if (nn !== null && operation.targets.length !== 1) {
766
+ fail(
767
+ `--only ${only} asks for part of a repair scope that covers ${operation.targets.length} slides ` +
768
+ `(${operation.targets.map((t) => t.jsxPath).join(", ")}): repair runs the whole scope. ` +
769
+ `Re-open it with \`--slides ${only}\` to repair that slide alone.`,
770
+ 2,
771
+ );
772
+ }
773
+ // Unrecorded source: bounded repair rewrites source this deck wrote down as
774
+ // transcribed. Never having been recorded is not a syntax verdict — that is
775
+ // `validateSettleTarget`'s job, on the bytes, a moment later — it is the
776
+ // ancestry question, and an unknown answer refuses.
777
+ const stamps = readStamps(deckDir);
778
+ const unrecorded = operation.targets.filter((t) => !stamps[path.basename(t.jsxPath)]);
779
+ if (unrecorded.length) {
780
+ fail(
781
+ `bounded repair only rewrites source this deck recorded as transcribed; ` +
782
+ `${unrecorded.map((t) => t.jsxPath).join(", ")} ${unrecorded.length === 1 ? "has" : "have"} no entry in state/transcribed.json. ` +
783
+ `Edit the JSX directly instead.`,
784
+ 2,
785
+ );
786
+ }
787
+
788
+ // The renderer is the one injectable seam here (the test harness measures
789
+ // without Chrome); everything above and below it is the real command.
790
+ let capture = dependencies.repair?.capture ?? null;
791
+ if (!capture) {
792
+ const cap = await import(pathToFileURL(path.join(SRC_DIR, "bridge", "capture.mjs")).href);
793
+ const st = await import(pathToFileURL(path.join(BUILDERS_DIR, "settle.mjs")).href);
794
+ const bundleMod = await import(pathToFileURL(path.join(SRC_DIR, "bridge", "bundle.mjs")).href);
795
+ const bundleDir = bundleMod.resolveBundleDirForDeck(deckDir);
796
+ if (!cap.bundleHasCaptureEngine(bundleDir)) {
797
+ fail(`bounded repair measures each slide by rendering it, and this deck's design bundle carries no capture engine. Run \`slidemind download-bundle ${deck}\` first.`, 2);
798
+ }
799
+ // The candidate is rendered, never the deck: `workspaceDir` is the staged
800
+ // copy repair.mjs hands us. A slide the capture does not return is a failed
801
+ // render, not an empty measurement — measuring nothing would settle nothing
802
+ // and report the slide as fine.
803
+ let pass = 0;
804
+ capture = async ({ workspaceDir, files }) => {
805
+ process.stderr.write(`slidemind: repair pass ${++pass} — rendering ${files.length} slide${files.length === 1 ? "" : "s"}…\n`);
806
+ const render = await cap.captureDeckLocally({ deckDir: workspaceDir, bundleDir, only: files.map((f) => path.basename(f)) });
807
+ const out = new Map();
808
+ for (const file of files) {
809
+ const slide = (render?.slides ?? []).find((sl) => sl.fileName === path.basename(file));
810
+ if (!slide?.render?.boxes) throw new Error(`${file} did not render; its geometry cannot be measured`);
811
+ out.set(file, st.measuredFromRender(slide.render));
812
+ }
813
+ return out;
814
+ };
815
+ }
816
+
817
+ const result = await repairOperation(deckDir, { operation, capture });
818
+ // Exit codes, refusal side: 1 is a malformed invocation (the flag conflict
819
+ // above), 2 is "blocked, not malformed" — the same code `deck_busy`,
820
+ // `needs_reconcile` and a rebase conflict use. A repair the budget or the
821
+ // representation refuses is state, not usage, so it exits 2.
822
+ if (asJson) {
823
+ process.stdout.write(JSON.stringify({ deck: deckSlug(deckDir), operation: operation.operationId, repair: result }, null, 2) + "\n");
824
+ if (!result.ok) process.exitCode = 2;
825
+ return;
826
+ }
827
+ for (const s of result.slides) {
828
+ const scopes = [...new Set(s.elements.flatMap((e) => e.properties))].sort();
829
+ process.stdout.write(
830
+ `${s.jsxPath} (${s.slideKey}): ${s.verdict}` +
831
+ (s.elements.length ? ` — ${s.elements.length} element${s.elements.length === 1 ? "" : "s"} [${scopes.join(", ") || "no named scope"}]` : " — nothing to change") +
832
+ "\n",
833
+ );
834
+ for (const line of [...s.changes, ...s.routeChanges]) process.stdout.write(` ${line}\n`);
835
+ for (const e of s.elements) process.stdout.write(` changed: ${e.elementId} ${e.action} [${e.properties.join(", ")}]\n`);
836
+ for (const u of s.unknown) process.stdout.write(` note: ${u.elementId}: ${u.detail}\n`);
837
+ // The router has already established that the only move left is relocating
838
+ // a node, which the budget forbids: reported, never repaired.
839
+ for (const u of s.unresolved) process.stdout.write(` note: connector ${u}\n`);
840
+ }
841
+ if (!result.ok) {
842
+ for (const r of result.reasons) process.stderr.write(` [${r.code}]${r.elementId ? ` ${r.elementId}:` : ""} ${r.message}\n`);
843
+ fail(`REPAIR REFUSED after ${result.passes} pass${result.passes === 1 ? "" : "es"} — the deck is exactly as it was; nothing was written.`, 2);
844
+ }
845
+ const changed = result.slides.filter((s) => s.verdict === "repaired");
846
+ process.stdout.write(
847
+ `REPAIRED: ${changed.length}/${result.slides.length} slide${result.slides.length === 1 ? "" : "s"} over ${result.passes} render pass${result.passes === 1 ? "" : "es"}, verified` +
848
+ (changed.length
849
+ ? ` — next: \`slidemind view-draft ${deck}\` to see the repaired slides, then deliver: the review is one-time per deck.`
850
+ : " — nothing moved, so nothing was published.") +
851
+ "\n",
852
+ );
853
+ }
854
+