slidemind 0.3.13 → 0.3.14

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (49) hide show
  1. package/.claude-plugin/plugin.json +1 -1
  2. package/.codex-plugin/plugin.json +1 -1
  3. package/AGENTS.md +86 -21
  4. package/README.md +14 -17
  5. package/bin/slidemind.mjs +65 -4969
  6. package/package.json +1 -1
  7. package/skills/create-slides/SKILL.md +113 -74
  8. package/skills/search/SKILL.md +25 -7
  9. package/src/bridge/build-and-push.mjs +108 -15
  10. package/src/bridge/bundle.mjs +69 -0
  11. package/src/bridge/capture.mjs +19 -0
  12. package/src/bridge/client/brands.mjs +622 -0
  13. package/src/bridge/client/engine-routing.mjs +237 -0
  14. package/src/bridge/client/engine-search.mjs +310 -0
  15. package/src/bridge/client/engine-slides.mjs +435 -0
  16. package/src/bridge/client/pane-ops.mjs +335 -0
  17. package/src/bridge/client/presentations.mjs +587 -0
  18. package/src/bridge/client/render.mjs +317 -0
  19. package/src/bridge/client/transport.mjs +695 -0
  20. package/src/bridge/client.mjs +80 -3452
  21. package/src/bridge/config.mjs +1 -1
  22. package/src/bridge/engine-download.mjs +15 -2
  23. package/src/builders/build.mjs +11 -804
  24. package/src/builders/draft-check.mjs +376 -0
  25. package/src/builders/lint.mjs +38 -2
  26. package/src/builders/preview.html +799 -0
  27. package/src/builders/review.mjs +23 -4
  28. package/src/builders/visual-families.mjs +6 -8
  29. package/src/cli/common.mjs +179 -0
  30. package/src/cli/concepts.mjs +310 -0
  31. package/src/cli/connection.mjs +147 -0
  32. package/src/cli/deck.mjs +608 -0
  33. package/src/cli/designs.mjs +113 -0
  34. package/src/cli/diagnose.mjs +141 -0
  35. package/src/cli/download-bundle.mjs +144 -0
  36. package/src/cli/engine-helpers.mjs +429 -0
  37. package/src/cli/engine-results.mjs +320 -0
  38. package/src/cli/help.mjs +201 -0
  39. package/src/cli/install.mjs +257 -0
  40. package/src/cli/read.mjs +120 -0
  41. package/src/cli/ready.mjs +805 -0
  42. package/src/cli/search.mjs +608 -0
  43. package/src/cli/session.mjs +177 -0
  44. package/src/cli/setup.mjs +282 -0
  45. package/src/cli/slide-reuse.mjs +334 -0
  46. package/src/cli/status.mjs +118 -0
  47. package/src/cli/workspace.mjs +302 -0
  48. package/src/installers/codex.mjs +1 -1
  49. package/src/update-check.mjs +231 -0
@@ -0,0 +1,334 @@
1
+ import fs from "node:fs";
2
+ import path from "node:path";
3
+ import { pathToFileURL } from "node:url";
4
+ import { fail, LOCAL_PPTX_RE, resolveDeckArg, SRC_DIR } from "./common.mjs";
5
+ import { expiredSearchMessage, resolveEngineRouting, deckBrandId, loginRequiredMessage, failOnEngineBlocker, describeEngineByteFailure, parseSearchIdArg, parsePicks, describeInsertSlideFailure, writeAddSlideResult } from "./engine-helpers.mjs";
6
+
7
+ export async function cmdInsertSlide(rest) {
8
+ let searchId = null;
9
+ let picks = null;
10
+ let to = null;
11
+ let at = null;
12
+ for (let i = 0; i < rest.length; i++) {
13
+ const a = rest[i];
14
+ if (a === "--search") {
15
+ searchId = parseSearchIdArg(rest[++i]);
16
+ } else if (a === "--pick") {
17
+ picks = parsePicks(rest[++i]);
18
+ } else if (a === "--to") {
19
+ to = rest[++i];
20
+ if (!to) fail("--to expects a presId");
21
+ } else if (a === "--at") {
22
+ const next = rest[++i];
23
+ const n = parseInt(next, 10);
24
+ if (!Number.isFinite(n) || n < 0) {
25
+ fail(`--at expects a non-negative integer, got '${next}'`);
26
+ }
27
+ at = n;
28
+ } else if (a.startsWith("-")) {
29
+ fail(`unknown flag: ${a}`);
30
+ } else {
31
+ fail(`unexpected argument: ${a}`);
32
+ }
33
+ }
34
+ if (searchId === null || !picks) {
35
+ fail("usage: slidemind insert-slide --search <id> --pick <n[,n,...]> --to <presId> [--at <N>]");
36
+ }
37
+ const client = await import(
38
+ pathToFileURL(path.join(SRC_DIR, "bridge", "client.mjs")).href
39
+ );
40
+ // Live insert types into an OPEN presentation, so it stays window- and
41
+ // pane-bound even where the rest of the engine surface has moved off the pane.
42
+ const registry = await client.requirePaneHost();
43
+ const entry = client.pickPresentationEntry(registry, to);
44
+ // Window identity for the client's single retry: a pane reconnect mints a NEW
45
+ // presId for the same window, and the design fingerprint re-matches it — but
46
+ // only against windows we did NOT already see next to the target, or a second
47
+ // deck on the same corporate design would take the insert.
48
+ const identity = {
49
+ fileName: entry.fileName ?? entry.title ?? null,
50
+ brandId: entry.brandId ?? null,
51
+ otherPresIds: registry
52
+ .map((p) => p.presId)
53
+ .filter((id) => id && id !== entry.presId),
54
+ };
55
+ // WHERE the searchId lives decides how the reuse runs. A number came from
56
+ // that window's own pane search and the pane resolves it; an opaque id is the
57
+ // backend's (the pane-free search) and the pane has never seen it — so those
58
+ // picks are extracted pane-free and inserted as bytes instead.
59
+ //
60
+ // Which backend extracts them is the RECORD's, not the window's. Those are
61
+ // different environments as soon as two are connected, and spending a
62
+ // server-owned id against the window's origin 404s as `unknown_search` — read
63
+ // as "the search expired", which sends the agent round a re-search loop that
64
+ // can never resolve. The window stays the INSERT target either way.
65
+ let engineBackendURL = null;
66
+ if (client.isOpaqueSearchId?.(searchId)) {
67
+ try {
68
+ engineBackendURL = await client.resolveSearchBackend(searchId);
69
+ } catch (err) {
70
+ await failOnEngineBlocker(client, err, { to: entry.presId });
71
+ }
72
+ if (!engineBackendURL) fail(expiredSearchMessage(searchId));
73
+ }
74
+ let res;
75
+ try {
76
+ res = engineBackendURL
77
+ ? await client.insertSlidesFromSearch({
78
+ presId: entry.presId,
79
+ searchId,
80
+ selections: picks,
81
+ at,
82
+ backendURL: entry.backendURL ?? null,
83
+ engineBackendURL,
84
+ ...identity,
85
+ })
86
+ : await client.insertSlideLive({
87
+ presId: entry.presId,
88
+ searchId,
89
+ selections: picks,
90
+ at,
91
+ backendURL: entry.backendURL ?? null,
92
+ ...identity,
93
+ });
94
+ } catch (err) {
95
+ fail(
96
+ (await loginRequiredMessage(client, err, { to: entry.presId })) ??
97
+ describeInsertSlideFailure(err, searchId),
98
+ );
99
+ }
100
+ if (!res?.ok) {
101
+ fail(`insert-slide failed: ${res?.error ?? "unknown error"}`);
102
+ }
103
+ process.stdout.write(
104
+ `Inserted ${res.insertedCount ?? picks.length} slide(s) into presentation ${res.presId ?? entry.presId}\n`,
105
+ );
106
+ }
107
+
108
+ export async function cmdAddSlide(rest) {
109
+ let searchId = null;
110
+ let picks = null;
111
+ let deck = null;
112
+ let at = null;
113
+ let to = null;
114
+ let from = null;
115
+ let asJson = false;
116
+ for (let i = 0; i < rest.length; i++) {
117
+ const a = rest[i];
118
+ if (a === "--json") {
119
+ asJson = true;
120
+ } else if (a === "--search") {
121
+ searchId = parseSearchIdArg(rest[++i]);
122
+ } else if (a === "--from") {
123
+ from = rest[++i];
124
+ if (!from) fail("--from expects a path to a .pptx file");
125
+ } else if (a === "--pick") {
126
+ picks = parsePicks(rest[++i]);
127
+ } else if (a === "--deck") {
128
+ deck = rest[++i];
129
+ if (!deck) fail("--deck expects a deck name or path");
130
+ } else if (a === "--to") {
131
+ to = rest[++i];
132
+ if (!to) fail("--to expects a presId");
133
+ } else if (a === "--at") {
134
+ const next = rest[++i];
135
+ const n = parseInt(next, 10);
136
+ if (!Number.isFinite(n) || n < 0) {
137
+ fail(`--at expects a non-negative integer, got '${next}'`);
138
+ }
139
+ at = n;
140
+ } else if (a.startsWith("-")) {
141
+ fail(`unknown flag: ${a}`);
142
+ } else {
143
+ fail(`unexpected argument: ${a}`);
144
+ }
145
+ }
146
+
147
+ // Host-free local source: fold picked slides from a .pptx on disk (no bridge
148
+ // client, no requirePaneHost). Mutually exclusive with --search; --to is
149
+ // accepted-but-ignored here (symmetric with the other reuse commands — there's
150
+ // no window to target when the source is a local file).
151
+ if (from !== null) {
152
+ if (searchId !== null) fail("--from and --search are mutually exclusive");
153
+ if (!picks || !deck) {
154
+ fail("usage: slidemind add-slide --from <file.pptx> --pick <n[,n,...]> --deck <deck> [--at <N>]");
155
+ }
156
+ const fromPath = path.isAbsolute(from)
157
+ ? from
158
+ : path.resolve(process.cwd(), from);
159
+ let st = null;
160
+ try {
161
+ st = fs.statSync(fromPath);
162
+ } catch {
163
+ st = null;
164
+ }
165
+ if (!st || !st.isFile() || !LOCAL_PPTX_RE.test(fromPath)) {
166
+ fail(`--from: '${from}' is not an existing PowerPoint file (.pptx/.pptm/.potx/.potm/.ppsx/.ppsm)`);
167
+ }
168
+ const deckDir = resolveDeckArg(deck);
169
+ const slidesDir = path.join(deckDir, "slides");
170
+ fs.mkdirSync(slidesDir, { recursive: true });
171
+
172
+ const { foldPickedSlides } = await import(
173
+ pathToFileURL(path.join(SRC_DIR, "pptx", "slide-swap.mjs")).href
174
+ );
175
+ let results;
176
+ try {
177
+ results = foldPickedSlides(fs.readFileSync(fromPath), picks, {
178
+ sourceName: path.basename(fromPath),
179
+ });
180
+ } catch (err) {
181
+ fail(`add-slide failed: ${err.message}`);
182
+ }
183
+
184
+ // Same filename math + write loop as the --search branch; results already
185
+ // carry a `.slideId` and `.bytes` (a Uint8Array of a one-slide package).
186
+ const reused = await import(
187
+ pathToFileURL(path.join(SRC_DIR, "lib", "reused-slides.mjs")).href
188
+ );
189
+ const existing = fs
190
+ .readdirSync(slidesDir)
191
+ .filter((f) => f.endsWith(".jsx") || f.endsWith(".pptx"));
192
+ const targets = reused.reusedSlideTargets(existing, results, at);
193
+ const written = [];
194
+ const slides = [];
195
+ results.forEach((r, i) => {
196
+ const target = path.join(slidesDir, targets[i].filename);
197
+ fs.writeFileSync(target, Buffer.from(r.bytes));
198
+ written.push(path.relative(process.cwd(), target));
199
+ slides.push({
200
+ selection: picks[i] ?? null,
201
+ slideId: r.slideId ?? null,
202
+ file: path.relative(process.cwd(), target),
203
+ });
204
+ });
205
+ writeAddSlideResult({
206
+ asJson,
207
+ searchId: null,
208
+ deckDir,
209
+ deck,
210
+ written,
211
+ slides,
212
+ });
213
+ return;
214
+ }
215
+
216
+ if (searchId === null || !picks || !deck) {
217
+ fail("usage: slidemind add-slide (--search <id> | --from <file.pptx>) --pick <n[,n,...]> --deck <deck> [--to <presId>] [--at <N>] [--json]");
218
+ }
219
+ const deckDir = resolveDeckArg(deck);
220
+ const slidesDir = path.join(deckDir, "slides");
221
+ fs.mkdirSync(slidesDir, { recursive: true });
222
+
223
+ const client = await import(
224
+ pathToFileURL(path.join(SRC_DIR, "bridge", "client.mjs")).href
225
+ );
226
+ // Direct path: an opaque searchId routes to the environment that minted the
227
+ // record; the deck's bound design only decides when the id can't. Pane path:
228
+ // the searchId is presentation-scoped, so --to targets the window the search
229
+ // ran on (without it, a registry with >1 open window is a dead-end).
230
+ const routing = await resolveEngineRouting(client, {
231
+ to,
232
+ brandId: await deckBrandId(deckDir),
233
+ searchId,
234
+ });
235
+ const presId = routing.presId;
236
+ const backendURL = routing.backendURL;
237
+
238
+ // Extract each picked slide's bytes over the bridge, then let the shared
239
+ // helper compute the play-order filenames (NN-reused-<shortid>.pptx). The
240
+ // prefix math lives in one shared unit (src/lib/reused-slides.mjs). Existing
241
+ // files are not renumbered.
242
+ const reused = await import(
243
+ pathToFileURL(path.join(SRC_DIR, "lib", "reused-slides.mjs")).href
244
+ );
245
+ const { createTransferProgress } = await import(
246
+ pathToFileURL(path.join(SRC_DIR, "bridge", "engine-download.mjs")).href
247
+ );
248
+ // Stream each pick to a provisional file: the play-order filename can only be
249
+ // computed once every pick is in hand, and buffering a multi-megabyte slide in
250
+ // memory just to name it would defeat the streaming path.
251
+ const extracted = [];
252
+ const cleanupTemps = () => {
253
+ for (const s of extracted) {
254
+ if (s.tempPath) fs.rmSync(s.tempPath, { force: true });
255
+ }
256
+ };
257
+ // A searchId can be an opaque server id on the direct path — keep it usable as
258
+ // a file name component.
259
+ const searchKey = String(searchId).replace(/[^A-Za-z0-9._-]/g, "_");
260
+ try {
261
+ for (const pick of picks) {
262
+ const tempPath = path.join(slidesDir, `.reused-${searchKey}-${pick}.part`);
263
+ if (typeof client.extractSlideToFile === "function") {
264
+ try {
265
+ const res = await client.extractSlideToFile({
266
+ presId,
267
+ searchId,
268
+ selection: pick,
269
+ destPath: tempPath,
270
+ backendURL,
271
+ onProgress: createTransferProgress(`extracting slide ${pick}`),
272
+ });
273
+ if (!res?.bytesWritten) throw new Error("stream produced no bytes");
274
+ extracted.push({ selection: pick, slideId: res.slideId ?? null, tempPath });
275
+ continue;
276
+ } catch (err) {
277
+ fs.rmSync(tempPath, { force: true });
278
+ const stale = await loginRequiredMessage(client, err, { to });
279
+ if (stale) {
280
+ cleanupTemps();
281
+ fail(stale);
282
+ }
283
+ // Route absent (bodyless 404 / 501) → this add-in predates the streaming
284
+ // route; fall through to the capped base64 ack path below. On the
285
+ // pane-free path there is no ack path to fall back to.
286
+ const routeMissing =
287
+ (err?.status === 404 && !err?.code) || err?.status === 501;
288
+ if (!routeMissing || !presId) {
289
+ const described = await describeEngineByteFailure(client, err, {
290
+ backendURL,
291
+ });
292
+ cleanupTemps();
293
+ fail(`add-slide failed for selection ${pick}: ${described ?? err.message}`);
294
+ }
295
+ }
296
+ }
297
+ const res = await client.extractSlide({
298
+ presId,
299
+ searchId,
300
+ selection: pick,
301
+ backendURL,
302
+ });
303
+ if (!res?.ok || !res.base64) {
304
+ cleanupTemps();
305
+ fail(`add-slide failed for selection ${pick}: ${res?.error ?? "no slide bytes returned"}`);
306
+ }
307
+ extracted.push({ selection: pick, slideId: res.slideId ?? null, base64: res.base64 });
308
+ }
309
+ } catch (err) {
310
+ cleanupTemps();
311
+ throw err;
312
+ }
313
+ const existing = fs
314
+ .readdirSync(slidesDir)
315
+ .filter((f) => f.endsWith(".jsx") || f.endsWith(".pptx"));
316
+ const targets = reused.reusedSlideTargets(existing, extracted, at);
317
+ const written = [];
318
+ const slides = [];
319
+ extracted.forEach((slide, i) => {
320
+ const target = path.join(slidesDir, targets[i].filename);
321
+ if (slide.tempPath) {
322
+ fs.renameSync(slide.tempPath, target);
323
+ } else {
324
+ fs.writeFileSync(target, Buffer.from(slide.base64, "base64"));
325
+ }
326
+ written.push(path.relative(process.cwd(), target));
327
+ slides.push({
328
+ selection: slide.selection,
329
+ slideId: slide.slideId ?? null,
330
+ file: path.relative(process.cwd(), target),
331
+ });
332
+ });
333
+ writeAddSlideResult({ asJson, searchId, deckDir, deck, written, slides });
334
+ }
@@ -0,0 +1,118 @@
1
+ // The deck's stage, read from disk — the one answer an agent needs after a
2
+ // context summarization, an interruption, or a hand-over: where does this deck
3
+ // stand and what is the next command. `ready <deck>` prints it (the skill
4
+ // already names `ready` as the first command after any continuation), so the
5
+ // stage → re-read table that used to live in the skill's prose is a line of
6
+ // output instead. Pure disk: no bridge call, no bundle read.
7
+ import fs from "node:fs";
8
+ import path from "node:path";
9
+ import { pathToFileURL } from "node:url";
10
+ import { BUILDERS_DIR } from "./common.mjs";
11
+
12
+ const load = (name) => import(pathToFileURL(path.join(BUILDERS_DIR, name)).href);
13
+
14
+ export async function deckStage(deckDir) {
15
+ const slug = path.basename(deckDir);
16
+ const [plan, review, check, families] = await Promise.all([
17
+ load("plan.mjs"),
18
+ load("review.mjs"),
19
+ load("draft-check.mjs"),
20
+ load("visual-families.mjs"),
21
+ ]);
22
+ const slidesDir = path.join(deckDir, "slides");
23
+ const files = fs.existsSync(slidesDir) ? fs.readdirSync(slidesDir).sort() : [];
24
+ const jsx = files.filter((f) => f.endsWith(".jsx"));
25
+ const native = files.filter((f) => /\.pptx$/i.test(f));
26
+ const numberOf = (f) => Number(/^(\d{1,3})[-_.]/.exec(f)?.[1]);
27
+ const authored = new Set([...jsx, ...native].map(numberOf).filter(Number.isFinite));
28
+
29
+ const planPath = path.join(deckDir, "plan.md");
30
+ const planExists = fs.existsSync(planPath);
31
+ const planMarkdown = planExists ? fs.readFileSync(planPath, "utf8") : "";
32
+ const planned = planExists ? [...families.plannedSlideNumbers(planMarkdown)].sort((a, b) => a - b) : [];
33
+ const planState = plan.loadPlanState(deckDir);
34
+ const planApproved = planExists && plan.requirePlanApproved(deckDir) === null;
35
+
36
+ const conceptsDir = path.join(deckDir, "concepts");
37
+ const concepts = fs.existsSync(conceptsDir)
38
+ ? fs.readdirSync(conceptsDir).filter((f) => /^concept-slide-\d+\.png$/.test(f)).length
39
+ : 0;
40
+ const assets = check.assetPlanSummary(deckDir);
41
+
42
+ const record = check.loadDraftCheck(deckDir);
43
+ const draftFresh =
44
+ Boolean(record?.slides) &&
45
+ jsx.every((f) => record.slides[f] && record.slides[f].hash === check.hashFile(path.join(slidesDir, f)));
46
+ const standing = check.draftCheckBlocker(deckDir) ? Object.values(record?.slides ?? {}).reduce((n, e) => n + (e.defects?.length ?? 0), 0) : 0;
47
+ const selfCheck = check.verdictBlocker(deckDir) ? "incomplete" : "ok";
48
+
49
+ const reviewState = review.loadReviewState(deckDir);
50
+ const reviewCurrent = review.requireReviewApproved(deckDir) === null;
51
+
52
+ const missing = planned.filter((n) => !authored.has(n));
53
+ let name;
54
+ let next;
55
+ // No plan.md and no slides: nothing exists yet. No plan.md but slides: an
56
+ // adopted or hand-made deck on the edit route — no plan, no concepts.
57
+ if (authored.size === 0 && (!planExists || planned.length === 0)) {
58
+ name = "intake";
59
+ next = `write ${slug}/plan.md (## Red thread + ## Per-slide storyboards), then \`slidemind plan ${slug}\``;
60
+ } else if (planExists && !planApproved) {
61
+ name = "plan gate";
62
+ next = planState.decision === "comments"
63
+ ? `apply the comments in ${slug}/plan-review.json to plan.md, then \`slidemind plan ${slug}\``
64
+ : `\`slidemind plan ${slug}\` (wait for DECISION: approved)`;
65
+ } else if (planExists && concepts === 0 && authored.size === 0) {
66
+ name = "concepts";
67
+ next = `\`slidemind concepts ${slug}\`, then Read every concepts/concept-slide-NN.png and fill asset-plan.md`;
68
+ } else if (assets && (assets.unfilled > 0 || assets.iconsResolved < assets.icons) && missing.length > 0) {
69
+ name = "sourcing";
70
+ next = assets.unfilled > 0
71
+ ? `fill the regions/organisms/connectors/icons rows in ${slug}/asset-plan.md from the concept PNGs (${assets.unfilled} slide section${assets.unfilled === 1 ? "" : "s"} still empty)`
72
+ : `search + download the ${assets.icons - assets.iconsResolved} unresolved icon row${assets.icons - assets.iconsResolved === 1 ? "" : "s"} in ${slug}/asset-plan.md (\`slidemind search\` → \`download-result\`), append each file to its row`;
73
+ } else if (missing.length > 0) {
74
+ name = "authoring";
75
+ next = `Read concepts/concept-slide-${String(missing[0]).padStart(2, "0")}.png (batches of ≤3), then write slides/${missing.map((n) => `${String(n).padStart(2, "0")}-*.jsx`).join(", ")}; then \`slidemind view-draft ${slug}\``;
76
+ } else if (!draftFresh || standing > 0 || selfCheck !== "ok") {
77
+ name = "draft check";
78
+ next = !draftFresh
79
+ ? `\`slidemind view-draft ${slug}\` (slides changed since the last check${record ? "" : ", or never checked"})`
80
+ : standing > 0
81
+ ? `fix the ${standing} measured defect${standing === 1 ? "" : "s"} in the JSX, then \`slidemind view-draft ${slug}\``
82
+ : `tick every organism under \`draft-check:\` in ${slug}/asset-plan.md (concept vs .draft-preview pair), then \`slidemind review ${slug}\``;
83
+ } else if (!reviewCurrent) {
84
+ name = "review";
85
+ next = reviewState.decision === "comments"
86
+ ? `edit the files cited in ${slug}/review.json, \`slidemind view-draft ${slug}\`, then \`slidemind review ${slug}\``
87
+ : `\`slidemind review ${slug}\` (wait for DECISION: approved)`;
88
+ } else {
89
+ name = "deliver";
90
+ next = `\`slidemind push ${slug}\` (into PowerPoint) or \`slidemind build-pptx ${slug}\` (to a file) — whichever target was agreed at intake; then stop`;
91
+ }
92
+
93
+ return {
94
+ name,
95
+ next,
96
+ plan: { exists: planExists, approved: planApproved, decision: planState.decision ?? null, planned: planned.length },
97
+ concepts,
98
+ assets,
99
+ slides: { jsx: jsx.length, native: native.length, missing },
100
+ draftCheck: { ran: Boolean(record), fresh: draftFresh, standingDefects: standing, selfCheck },
101
+ review: { decision: reviewState.decision ?? null, current: reviewCurrent },
102
+ };
103
+ }
104
+
105
+ export function stageHumanLines(stage) {
106
+ const a = stage.assets;
107
+ const parts = [
108
+ `plan: ${!stage.plan.exists ? "none" : stage.plan.approved ? "approved" : stage.plan.decision ?? "not gated"}`,
109
+ `concepts: ${stage.concepts}/${stage.plan.planned}`,
110
+ a
111
+ ? `asset plan: ${a.unfilled > 0 ? `${a.unfilled} section${a.unfilled === 1 ? "" : "s"} empty` : `${a.iconsResolved}/${a.icons} icon rows resolved`}`
112
+ : "asset plan: none",
113
+ `slides: ${stage.slides.jsx + stage.slides.native}/${stage.plan.planned}${stage.slides.native ? ` (${stage.slides.native} native)` : ""}`,
114
+ `draft check: ${!stage.draftCheck.ran ? "not run" : !stage.draftCheck.fresh ? "stale" : stage.draftCheck.standingDefects > 0 ? `${stage.draftCheck.standingDefects} defect${stage.draftCheck.standingDefects === 1 ? "" : "s"} standing` : stage.draftCheck.selfCheck === "ok" ? "clean" : "self-check incomplete"}`,
115
+ `review: ${stage.review.decision ? `${stage.review.decision}${stage.review.current ? "" : " (stale)"}` : "none"}`,
116
+ ];
117
+ return ` stage: ${stage.name} — ${parts.join(" · ")}\n next: ${stage.next}\n`;
118
+ }