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.
- package/.claude-plugin/plugin.json +1 -1
- package/.codex-plugin/plugin.json +1 -1
- package/AGENTS.md +592 -16
- package/LICENSE +4 -0
- package/README.md +203 -8
- package/THIRD-PARTY-NOTICES.md +201 -0
- package/bin/slidemind.mjs +50 -2
- package/package.json +5 -3
- package/skills/create-slides/SKILL.md +484 -886
- package/skills/search/SKILL.md +46 -23
- package/src/bridge/build-and-push.mjs +548 -73
- package/src/bridge/bundle.mjs +100 -31
- package/src/bridge/capture.mjs +14 -17
- package/src/bridge/client/brands.mjs +157 -18
- package/src/bridge/client/engine-routing.mjs +48 -13
- package/src/bridge/client/engine-search.mjs +24 -1
- package/src/bridge/client/render.mjs +27 -1
- package/src/bridge/client/transport.mjs +499 -23
- package/src/bridge/client.mjs +10 -0
- package/src/bridge/config.mjs +67 -6
- package/src/bridge/engine-download.mjs +0 -10
- package/src/bridge/graph-auth.mjs +55 -3
- package/src/bridge/image-intent-envelope.mjs +239 -0
- package/src/bridge/package-preview.mjs +67 -0
- package/src/bridge/sharepoint.mjs +914 -0
- package/src/builders/asset-fetch.mjs +203 -0
- package/src/builders/block-dividers.mjs +83 -0
- package/src/builders/build.mjs +171 -87
- package/src/builders/card-height.mjs +294 -0
- package/src/builders/chart-hole.mjs +183 -0
- package/src/builders/chart-jsx.mjs +410 -0
- package/src/builders/chart-labels.mjs +326 -0
- package/src/builders/concept-geometry.mjs +370 -0
- package/src/builders/concept-intent.mjs +200 -0
- package/src/builders/concept-layout.mjs +79 -0
- package/src/builders/concept-lines.mjs +509 -0
- package/src/builders/concept-lists.mjs +270 -0
- package/src/builders/concept-marks.mjs +84 -0
- package/src/builders/concept-relationships.mjs +12 -0
- package/src/builders/concept-spec.mjs +645 -0
- package/src/builders/concept-text.mjs +140 -0
- package/src/builders/concept-waves.mjs +57 -0
- package/src/builders/copy-fit.mjs +139 -0
- package/src/builders/design-tokens.mjs +347 -0
- package/src/builders/draft-check.mjs +552 -340
- package/src/builders/draft-lines.mjs +369 -0
- package/src/builders/edit-route.mjs +29 -0
- package/src/builders/figure-sketch.mjs +375 -0
- package/src/builders/fold-sets.mjs +639 -0
- package/src/builders/gate-server.mjs +9 -4
- package/src/builders/gate-session.js +273 -12
- package/src/builders/gate-shell.js +1494 -0
- package/src/builders/image-intent-check.mjs +308 -0
- package/src/builders/image-use-reconcile.mjs +107 -0
- package/src/builders/kit-helpers.mjs +121 -0
- package/src/builders/layout-names.mjs +32 -0
- package/src/builders/legacy-reference-uses.mjs +179 -0
- package/src/builders/line-fit.mjs +224 -0
- package/src/builders/line-native.mjs +895 -0
- package/src/builders/line-relations.mjs +230 -0
- package/src/builders/lint-fix.mjs +117 -9
- package/src/builders/lint.mjs +25 -20
- package/src/builders/list-jsx.mjs +88 -0
- package/src/builders/placement.mjs +133 -0
- package/src/builders/plan-checks.mjs +135 -0
- package/src/builders/plan-comments.mjs +19 -0
- package/src/builders/plan-handlers.mjs +95 -0
- package/src/builders/plan-markdown-edit.mjs +3 -3
- package/src/builders/plan-overlay.js +1530 -1608
- package/src/builders/plan.mjs +689 -186
- package/src/builders/preset-definitions.json +1 -0
- package/src/builders/preset-eval.mjs +244 -0
- package/src/builders/preview.html +51 -120
- package/src/builders/red-thread-backbone.mjs +135 -36
- package/src/builders/repair.mjs +205 -0
- package/src/builders/repeat-groups.mjs +540 -0
- package/src/builders/resolve-skill.mjs +5 -4
- package/src/builders/review-overlay.js +296 -293
- package/src/builders/review.mjs +305 -84
- package/src/builders/rule-shift.mjs +96 -0
- package/src/builders/seat.mjs +354 -0
- package/src/builders/settle-driver.mjs +236 -0
- package/src/builders/settle.mjs +1448 -0
- package/src/builders/slide-prep.mjs +67 -0
- package/src/builders/spec-assets.mjs +236 -0
- package/src/builders/spec-match.mjs +56 -0
- package/src/builders/stack.mjs +413 -0
- package/src/builders/storyboard-markdown.mjs +70 -27
- package/src/builders/storyboard-schema.mjs +54 -114
- package/src/builders/text-blocks.mjs +212 -0
- package/src/builders/text-card-fit.mjs +552 -0
- package/src/builders/text-stack.mjs +276 -0
- package/src/builders/transcribe.mjs +1069 -0
- package/src/cli/assets.mjs +164 -0
- package/src/cli/common.mjs +173 -12
- package/src/cli/concept-spec-support.mjs +76 -0
- package/src/cli/concepts.mjs +1002 -103
- package/src/cli/deck.mjs +401 -65
- package/src/cli/diagnose.mjs +4 -2
- package/src/cli/engine-helpers.mjs +17 -10
- package/src/cli/engine-results.mjs +76 -3
- package/src/cli/help.mjs +232 -28
- package/src/cli/install.mjs +9 -0
- package/src/cli/maintenance.mjs +104 -0
- package/src/cli/read.mjs +2 -1
- package/src/cli/ready.mjs +6 -3
- package/src/cli/record.mjs +522 -0
- package/src/cli/remote.mjs +818 -0
- package/src/cli/revision.mjs +686 -0
- package/src/cli/search.mjs +30 -11
- package/src/cli/session.mjs +56 -13
- package/src/cli/setup.mjs +46 -2
- package/src/cli/status.mjs +142 -33
- package/src/cli/tasks.mjs +246 -0
- package/src/cli/transcribe.mjs +854 -0
- package/src/cli/workspace-ui.mjs +260 -0
- package/src/cli/workspace.mjs +212 -34
- package/src/install-channel.mjs +64 -7
- package/src/lib/adopt.mjs +346 -25
- package/src/lib/concept-spec-binding.mjs +96 -0
- package/src/lib/conversions.mjs +70 -6
- package/src/lib/convert-folded.mjs +107 -0
- package/src/lib/deck-paths.mjs +418 -0
- package/src/lib/durable-ids.mjs +47 -0
- package/src/lib/edit-operations.mjs +316 -0
- package/src/lib/edit-state.mjs +1244 -0
- package/src/lib/edit-transitions.mjs +120 -0
- package/src/lib/extensions.mjs +501 -0
- package/src/lib/image-assets.mjs +465 -0
- package/src/lib/image-derivatives.mjs +234 -0
- package/src/lib/image-intent-fingerprint.mjs +70 -0
- package/src/lib/image-intent-tags.mjs +176 -0
- package/src/lib/image-intent.mjs +802 -0
- package/src/lib/image-use-overrides.mjs +279 -0
- package/src/lib/inline-components.mjs +613 -0
- package/src/lib/lint-converted.mjs +68 -4
- package/src/lib/merge-json.mjs +163 -0
- package/src/lib/overwrite-guard.mjs +3 -3
- package/src/lib/presentation-context.mjs +531 -0
- package/src/lib/preservation-tags.mjs +157 -0
- package/src/lib/preservation-verdict.mjs +1342 -0
- package/src/lib/publish.mjs +1557 -0
- package/src/lib/rebase.mjs +945 -0
- package/src/lib/reconcile-ack.mjs +4 -6
- package/src/lib/reconcile.mjs +62 -24
- package/src/lib/record-client.mjs +175 -0
- package/src/lib/record-state.mjs +151 -0
- package/src/lib/remote.mjs +661 -0
- package/src/lib/revision.mjs +1210 -0
- package/src/lib/slide-guids.mjs +4 -6
- package/src/lib/slide-preservation.mjs +423 -0
- package/src/lib/slide-selection.mjs +9 -0
- package/src/lib/workspace-transaction.mjs +117 -0
- package/src/pptx/content-digest.mjs +334 -0
- package/src/pptx/deck-assemble.mjs +1278 -0
- package/src/pptx/fill/engine.mjs +2 -3
- package/src/pptx/fill/freeform.mjs +6 -3
- package/src/pptx/ooxml.mjs +20 -0
- package/src/pptx/package-evidence.mjs +383 -0
- package/src/pptx/tags.mjs +163 -11
- package/src/update-check.mjs +22 -4
- package/src/workspace/adapters.mjs +222 -0
- package/src/workspace/auth.mjs +256 -0
- package/src/workspace/autostart.mjs +204 -0
- package/src/workspace/daemon.mjs +360 -0
- package/src/workspace/discovery.mjs +180 -0
- package/src/workspace/edit.mjs +167 -0
- package/src/workspace/events.mjs +52 -0
- package/src/workspace/gate-client.mjs +290 -0
- package/src/workspace/handback.mjs +73 -0
- package/src/workspace/lifecycle.mjs +635 -0
- package/src/workspace/operations.mjs +200 -0
- package/src/workspace/pick-folder.mjs +152 -0
- package/src/workspace/registry.mjs +359 -0
- package/src/workspace/remote-links.mjs +397 -0
- package/src/workspace/remote-status.mjs +359 -0
- package/src/workspace/routes.mjs +1331 -0
- package/src/workspace/server.mjs +159 -0
- package/src/workspace/sync.mjs +1119 -0
- package/src/workspace/task-actions.mjs +215 -0
- package/src/workspace/tasks.mjs +507 -0
- package/src/workspace/ui/app.js +1886 -0
- package/src/workspace/ui/index.html +141 -0
- package/src/workspace/ui/plan-bridge.js +120 -0
- package/src/workspace/ui/sharepoint.svg +59 -0
- package/src/workspace/ui/styles.css +423 -0
- 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
|
+
|