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