slidemind 0.2.3 → 0.2.4
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 +31 -10
- package/README.md +4 -0
- package/bin/slidemind.mjs +1170 -199
- package/package.json +1 -1
- package/skills/create-slides/SKILL.md +18 -14
- package/skills/search/SKILL.md +24 -18
- package/src/bridge/build-and-push.mjs +19 -6
- package/src/bridge/client.mjs +890 -101
- package/src/bridge/engine-download.mjs +61 -8
- package/src/builders/resolve-skill.mjs +64 -2
- package/src/pptx/fill/engine.mjs +112 -134
|
@@ -141,6 +141,25 @@ export function resolveAssetExt(mimeType, buf) {
|
|
|
141
141
|
return extFromBytes(buf) ?? declared;
|
|
142
142
|
}
|
|
143
143
|
|
|
144
|
+
// The recolour descriptor, with a pane-free TINT REFUSAL folded in: the bytes
|
|
145
|
+
// on disk are then the original ones, so `applied` has to read false and the
|
|
146
|
+
// note has to name the one route that does bake a colour in.
|
|
147
|
+
function describeRecolorWithRefusal(markup, mimeType, iconColor, refusedColor) {
|
|
148
|
+
const described = describeRecolor(
|
|
149
|
+
markup,
|
|
150
|
+
mimeType,
|
|
151
|
+
refusedColor ? null : (iconColor ?? null),
|
|
152
|
+
);
|
|
153
|
+
if (!refusedColor) return described;
|
|
154
|
+
return {
|
|
155
|
+
...described,
|
|
156
|
+
note:
|
|
157
|
+
`Saved in its ORIGINAL colours — recolouring to ${refusedColor} runs in the slidemind add-in. ` +
|
|
158
|
+
"Re-run this download with --to <presId> (a PowerPoint window with the slidemind pane open) " +
|
|
159
|
+
"to bake the colour in.",
|
|
160
|
+
};
|
|
161
|
+
}
|
|
162
|
+
|
|
144
163
|
// Stream one result into assetsDir via the add-in's streaming route.
|
|
145
164
|
//
|
|
146
165
|
// Returns the same shape as `downloadOneResult`, or null when the paired add-in
|
|
@@ -156,21 +175,29 @@ async function streamOneResult({
|
|
|
156
175
|
iconColor,
|
|
157
176
|
}) {
|
|
158
177
|
if (typeof client.engineDownloadToFile !== "function") return null;
|
|
178
|
+
// `presId: null` + a resolved backendURL is the pane-free route (the client
|
|
179
|
+
// picks the direct stream); a presId relays through that window's pane.
|
|
159
180
|
// Write under a provisional name: the true extension is only knowable from the
|
|
160
181
|
// bytes, and buffering the image just to sniff its header would defeat the
|
|
161
182
|
// purpose of streaming.
|
|
162
183
|
const tempPath = path.join(assetsDir, `.engine-${searchId}-${selection}.part`);
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
res = await client.engineDownloadToFile({
|
|
184
|
+
const fetchOnce = (color) =>
|
|
185
|
+
client.engineDownloadToFile({
|
|
166
186
|
presId,
|
|
167
187
|
backendURL,
|
|
168
188
|
searchId,
|
|
169
189
|
selection,
|
|
170
190
|
destPath: tempPath,
|
|
171
|
-
iconColor:
|
|
191
|
+
iconColor: color ?? null,
|
|
172
192
|
onProgress: createTransferProgress(`downloading result ${selection}`),
|
|
173
193
|
});
|
|
194
|
+
let res;
|
|
195
|
+
// Set when the pane-free route refused the TINT (not the asset): the download
|
|
196
|
+
// then repeats without a colour so the caller still gets the icon, and the
|
|
197
|
+
// recolour is reported as not applied instead of silently claimed.
|
|
198
|
+
let recolorRefused = null;
|
|
199
|
+
try {
|
|
200
|
+
res = await fetchOnce(iconColor);
|
|
174
201
|
} catch (err) {
|
|
175
202
|
fs.rmSync(tempPath, { force: true });
|
|
176
203
|
// Route absent → this add-in predates streaming; let the caller fall back.
|
|
@@ -178,7 +205,13 @@ async function streamOneResult({
|
|
|
178
205
|
// for a stale selection), not Express saying "no such path" — retrying that
|
|
179
206
|
// on the capped path would just fail again, more slowly.
|
|
180
207
|
if ((err?.status === 404 && !err?.code) || err?.status === 501) return null;
|
|
181
|
-
|
|
208
|
+
// The recolour classifier is DOM-based and lives in the add-in, so the
|
|
209
|
+
// pane-free route refuses a tint rather than guessing at one. Refusing the
|
|
210
|
+
// whole DOWNLOAD over it would be worse: the icon is fetchable, only the
|
|
211
|
+
// colour is not. Take the bytes and say so.
|
|
212
|
+
if (err?.code !== "recolor_needs_host" || !iconColor) throw err;
|
|
213
|
+
recolorRefused = iconColor;
|
|
214
|
+
res = await fetchOnce(null);
|
|
182
215
|
}
|
|
183
216
|
|
|
184
217
|
try {
|
|
@@ -193,7 +226,12 @@ async function streamOneResult({
|
|
|
193
226
|
path: target,
|
|
194
227
|
reference: `./assets/${filename}`,
|
|
195
228
|
mimeType: res.mimeType ?? "image/svg+xml",
|
|
196
|
-
recolor:
|
|
229
|
+
recolor: describeRecolorWithRefusal(
|
|
230
|
+
res.svgMarkup,
|
|
231
|
+
res.mimeType,
|
|
232
|
+
iconColor,
|
|
233
|
+
recolorRefused,
|
|
234
|
+
),
|
|
197
235
|
size: svgSizeFromMarkup(res.svgMarkup),
|
|
198
236
|
};
|
|
199
237
|
}
|
|
@@ -222,7 +260,12 @@ async function streamOneResult({
|
|
|
222
260
|
path: target,
|
|
223
261
|
reference: `./assets/${filename}`,
|
|
224
262
|
mimeType: res.mimeType ?? null,
|
|
225
|
-
recolor:
|
|
263
|
+
recolor: describeRecolorWithRefusal(
|
|
264
|
+
null,
|
|
265
|
+
res.mimeType,
|
|
266
|
+
iconColor,
|
|
267
|
+
recolorRefused,
|
|
268
|
+
),
|
|
226
269
|
size: imageSizeFromBuffer(probe),
|
|
227
270
|
};
|
|
228
271
|
} catch (err) {
|
|
@@ -237,7 +280,7 @@ async function streamOneResult({
|
|
|
237
280
|
// the rest.
|
|
238
281
|
export async function downloadOneResult({
|
|
239
282
|
client,
|
|
240
|
-
presId,
|
|
283
|
+
presId = null,
|
|
241
284
|
backendURL = null,
|
|
242
285
|
searchId,
|
|
243
286
|
assetsDir,
|
|
@@ -259,6 +302,16 @@ export async function downloadOneResult({
|
|
|
259
302
|
});
|
|
260
303
|
if (streamed) return streamed;
|
|
261
304
|
|
|
305
|
+
// The capped base64 fallback below is a PANE route. On the pane-free path
|
|
306
|
+
// there is no window to relay through — and an add-in that serves the direct
|
|
307
|
+
// routes always serves the direct stream — so a missing stream there is a real
|
|
308
|
+
// failure, not a reason to reach for a window that may not exist.
|
|
309
|
+
if (!presId) {
|
|
310
|
+
throw new Error(
|
|
311
|
+
"the add-in's engine routes returned no bytes for that result — re-run the search, then pick again",
|
|
312
|
+
);
|
|
313
|
+
}
|
|
314
|
+
|
|
262
315
|
// Fallback for an add-in that predates the streaming route: bytes ride the
|
|
263
316
|
// pane's Socket.IO ack, capped by its 10 MB frame limit.
|
|
264
317
|
const res = await client.engineDownload({
|
|
@@ -68,8 +68,11 @@ function firstExistingOr(candidates, fallback) {
|
|
|
68
68
|
return firstExisting(candidates) ?? fallback;
|
|
69
69
|
}
|
|
70
70
|
|
|
71
|
-
export function resolveSkillForDeck(deckDir) {
|
|
71
|
+
export function resolveSkillForDeck(deckDir, opts = {}) {
|
|
72
72
|
const planMeta = readPlanFrontmatter(deckDir);
|
|
73
|
+
if (opts.packDir) {
|
|
74
|
+
return resolveTemplatePack(opts.packDir, planMeta, { deckDir });
|
|
75
|
+
}
|
|
73
76
|
const skillName = planMeta.skill || DEFAULT_SKILL;
|
|
74
77
|
const bundleDir = deckDir ? resolveBundleDirForDeck(deckDir) : null;
|
|
75
78
|
return resolveSkill(skillName, planMeta, { deckDir, bundleDir });
|
|
@@ -80,7 +83,12 @@ export function resolveSkillForDeck(deckDir) {
|
|
|
80
83
|
// fill-template) must not pay resolveSkill's design-root requirement — a
|
|
81
84
|
// compose deck with an unmaterialized bundle self-heals inside the render
|
|
82
85
|
// path, and an early resolver throw here would break that.
|
|
83
|
-
export function resolveDeckMode(deckDir) {
|
|
86
|
+
export function resolveDeckMode(deckDir, opts = {}) {
|
|
87
|
+
if (opts.packDir) {
|
|
88
|
+
const planMeta = readPlanFrontmatter(deckDir);
|
|
89
|
+
const pack = resolveTemplatePack(opts.packDir, planMeta, { deckDir });
|
|
90
|
+
return { skillName: pack.skillName, mode: pack.mode, skillDir: pack.skillDir };
|
|
91
|
+
}
|
|
84
92
|
try {
|
|
85
93
|
const planMeta = readPlanFrontmatter(deckDir);
|
|
86
94
|
const skillName = planMeta.skill || DEFAULT_SKILL;
|
|
@@ -92,6 +100,60 @@ export function resolveDeckMode(deckDir) {
|
|
|
92
100
|
}
|
|
93
101
|
}
|
|
94
102
|
|
|
103
|
+
// Resolve a runtime template pack from an explicit directory. This path is
|
|
104
|
+
// deliberately independent of agent/harness installation and of the compose
|
|
105
|
+
// design-bundle resolver below. A plugin can locate its own pack and pass this
|
|
106
|
+
// path to the CLI without the CLI knowing the plugin exists.
|
|
107
|
+
export function resolveTemplatePack(packDir, planMeta = {}, opts = {}) {
|
|
108
|
+
const skillDir = path.resolve(String(packDir));
|
|
109
|
+
if (!fs.existsSync(skillDir) || !fs.statSync(skillDir).isDirectory()) {
|
|
110
|
+
throw new Error(`template pack directory does not exist: ${skillDir}`);
|
|
111
|
+
}
|
|
112
|
+
const manifest = readManifest(skillDir);
|
|
113
|
+
if (!manifest || typeof manifest !== "object" || !manifest.mode) {
|
|
114
|
+
throw new Error(`template pack ${skillDir} has no valid skill.json manifest`);
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
let templateKey = null;
|
|
118
|
+
let templatePath = null;
|
|
119
|
+
if (manifest.templates) {
|
|
120
|
+
templateKey =
|
|
121
|
+
planMeta.template ||
|
|
122
|
+
manifest.defaultTemplate ||
|
|
123
|
+
Object.keys(manifest.templates)[0];
|
|
124
|
+
const rel = templateKey ? manifest.templates[templateKey] : null;
|
|
125
|
+
if (rel) {
|
|
126
|
+
const candidate = path.resolve(skillDir, rel);
|
|
127
|
+
const relative = path.relative(skillDir, candidate);
|
|
128
|
+
if (relative.startsWith("..") || path.isAbsolute(relative)) {
|
|
129
|
+
throw new Error(`template path escapes pack directory: ${rel}`);
|
|
130
|
+
}
|
|
131
|
+
if (fs.existsSync(candidate)) templatePath = candidate;
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
return {
|
|
136
|
+
skillName: manifest.name || path.basename(skillDir),
|
|
137
|
+
skillDir,
|
|
138
|
+
manifest,
|
|
139
|
+
mode: manifest.mode || "compose",
|
|
140
|
+
// Fill-template callers only need the pack payload. These legacy design
|
|
141
|
+
// fields remain null so callers can distinguish this path from compose
|
|
142
|
+
// skill resolution without paying a design-root requirement.
|
|
143
|
+
cssFile: null,
|
|
144
|
+
layoutsDir: null,
|
|
145
|
+
layoutsCssFile: null,
|
|
146
|
+
assetsDir: opts.deckDir ? path.join(opts.deckDir, "assets") : null,
|
|
147
|
+
brand: null,
|
|
148
|
+
templateKey,
|
|
149
|
+
templatePath,
|
|
150
|
+
locale: planMeta.locale || null,
|
|
151
|
+
planMeta,
|
|
152
|
+
packageRoot: PACKAGE_ROOT,
|
|
153
|
+
bundleDir: opts.deckDir ? resolveBundleDirForDeck(opts.deckDir) : null,
|
|
154
|
+
};
|
|
155
|
+
}
|
|
156
|
+
|
|
95
157
|
// Skill packs can live outside the npm package: private, org-internal packs
|
|
96
158
|
// (templates, brand-specific assets) are distributed on their own and this
|
|
97
159
|
// package never carries them. Lookup order:
|
package/src/pptx/fill/engine.mjs
CHANGED
|
@@ -35,69 +35,11 @@ export async function buildFillTemplateDeck({ deckDir, skill, outPath, onLog })
|
|
|
35
35
|
const locale = skill.locale || "en";
|
|
36
36
|
const fieldsByNo = pack.fields[locale] ?? {};
|
|
37
37
|
const repeatsByNo = pack.repeats[locale] ?? {};
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
}
|
|
44
|
-
|
|
45
|
-
// ---- deck reading ---------------------------------------------------------
|
|
46
|
-
const slidesDir = path.join(deckDir, "slides");
|
|
47
|
-
const entries = fs.readdirSync(slidesDir)
|
|
48
|
-
.filter((f) => f.endsWith(".jsx") || f.endsWith(".pptx"))
|
|
49
|
-
.sort((a, b) => a.localeCompare(b, "en", { numeric: true }));
|
|
50
|
-
if (entries.length === 0) throw new Error(`no .jsx or .pptx slides in ${slidesDir}`);
|
|
51
|
-
|
|
52
|
-
const problems = [];
|
|
53
|
-
const warnings = [];
|
|
54
|
-
const sequence = []; // { kind: "master", no, props, file } | { kind: "insert", file, buf }
|
|
55
|
-
|
|
56
|
-
// In-deck freeform pairing: NN-name.jsx (create-slides source) renders to a
|
|
57
|
-
// sibling NN-name.pptx artifact — when both exist, the .pptx carries the
|
|
58
|
-
// slide and the .jsx is source only.
|
|
59
|
-
const renderedStems = new Set(
|
|
60
|
-
entries.filter((f) => f.endsWith(".pptx")).map((f) => f.replace(/\.pptx$/, "")),
|
|
61
|
-
);
|
|
62
|
-
|
|
63
|
-
for (const file of entries) {
|
|
64
|
-
if (file.endsWith(".pptx")) {
|
|
65
|
-
sequence.push({ kind: "insert", file, buf: fs.readFileSync(path.join(slidesDir, file)) });
|
|
66
|
-
continue;
|
|
67
|
-
}
|
|
68
|
-
if (renderedStems.has(file.replace(/\.jsx$/, ""))) continue;
|
|
69
|
-
const src = fs.readFileSync(path.join(slidesDir, file), "utf8");
|
|
70
|
-
let parsed;
|
|
71
|
-
try {
|
|
72
|
-
parsed = parseSlideFile(src, file);
|
|
73
|
-
} catch (err) {
|
|
74
|
-
problems.push(err.message);
|
|
75
|
-
continue;
|
|
76
|
-
}
|
|
77
|
-
const layout = layoutNumber(parsed.component, locale, pack.components);
|
|
78
|
-
if (!layout) {
|
|
79
|
-
problems.push(`${file}: <${parsed.component}> is a freeform (create-slides) slide with no ` +
|
|
80
|
-
`rendered artifact next to it — 'slidemind build-pptx' renders slides/${file.replace(/\.jsx$/, "")}.pptx ` +
|
|
81
|
-
`automatically when a paired add-in backend is reachable`);
|
|
82
|
-
continue;
|
|
83
|
-
}
|
|
84
|
-
if (layout.warn) warnings.push(`${file}: ${layout.warn}`);
|
|
85
|
-
const no = String(layout.no);
|
|
86
|
-
|
|
87
|
-
if ((pack.policy.freeform ?? []).includes(no)) {
|
|
88
|
-
if (Object.keys(parsed.props).length > 0) {
|
|
89
|
-
problems.push(`${file}: slide ${no} is a freeform placeholder — it takes no props. ` +
|
|
90
|
-
`Keep it bare to keep the master placeholder, or delete the file to drop the slide.`);
|
|
91
|
-
continue;
|
|
92
|
-
}
|
|
93
|
-
warnings.push(`${file}: keeping the master's slide-${no} placeholder (freeform replacement pending)`);
|
|
94
|
-
}
|
|
95
|
-
|
|
96
|
-
validateProps(parsed.props, fieldsByNo[no] ?? {}, repeatsByNo[no] ?? {}, pack.policy, no, file, problems);
|
|
97
|
-
sequence.push({ kind: "master", no: Number(no), props: parsed.props, file });
|
|
98
|
-
}
|
|
99
|
-
|
|
100
|
-
checkIdentities(sequence, warnings, problems);
|
|
38
|
+
const { sequence, warnings, problems } = inspectFillTemplateDeck({
|
|
39
|
+
deckDir,
|
|
40
|
+
skill,
|
|
41
|
+
includeBuffers: true,
|
|
42
|
+
});
|
|
101
43
|
if (problems.length) {
|
|
102
44
|
const err = new Error(`deck has ${problems.length} authoring error(s):\n - ${problems.join("\n - ")}`);
|
|
103
45
|
err.problems = problems;
|
|
@@ -174,6 +116,113 @@ export async function buildFillTemplateDeck({ deckDir, skill, outPath, onLog })
|
|
|
174
116
|
return { outPath: finalOut, slideCount: output.length, warnings };
|
|
175
117
|
}
|
|
176
118
|
|
|
119
|
+
// Parse and structurally validate a fill-template deck without mutating it.
|
|
120
|
+
// External packs use this generic inspection surface to run their own domain
|
|
121
|
+
// checks before invoking build-pptx. Buffers are opt-in because JSON callers
|
|
122
|
+
// must receive a compact, serializable sequence.
|
|
123
|
+
export function inspectFillTemplateDeck({
|
|
124
|
+
deckDir,
|
|
125
|
+
skill,
|
|
126
|
+
allowUnrenderedFreeform = false,
|
|
127
|
+
includeBuffers = false,
|
|
128
|
+
}) {
|
|
129
|
+
const pack = loadPack(skill);
|
|
130
|
+
const locale = skill.locale || "en";
|
|
131
|
+
const fieldsByNo = pack.fields[locale] ?? {};
|
|
132
|
+
const repeatsByNo = pack.repeats[locale] ?? {};
|
|
133
|
+
const slidesDir = path.join(deckDir, "slides");
|
|
134
|
+
const problems = [];
|
|
135
|
+
const warnings = [];
|
|
136
|
+
const sequence = [];
|
|
137
|
+
|
|
138
|
+
if (!skill.templatePath || !fs.existsSync(skill.templatePath)) {
|
|
139
|
+
problems.push(
|
|
140
|
+
`template "${skill.templateKey}" not found in pack ${skill.skillDir} — check the pack manifest templates`,
|
|
141
|
+
);
|
|
142
|
+
}
|
|
143
|
+
if (!fs.existsSync(slidesDir) || !fs.statSync(slidesDir).isDirectory()) {
|
|
144
|
+
problems.push(`expected 'slides/' folder in ${deckDir}`);
|
|
145
|
+
return { sequence, warnings, problems, locale, templateKey: skill.templateKey };
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
const entries = fs.readdirSync(slidesDir)
|
|
149
|
+
.filter((f) => f.endsWith(".jsx") || f.endsWith(".pptx"))
|
|
150
|
+
.sort((a, b) => a.localeCompare(b, "en", { numeric: true }));
|
|
151
|
+
if (entries.length === 0) {
|
|
152
|
+
problems.push(`no .jsx or .pptx slides in ${slidesDir}`);
|
|
153
|
+
return { sequence, warnings, problems, locale, templateKey: skill.templateKey };
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
const renderedStems = new Set(
|
|
157
|
+
entries.filter((f) => f.endsWith(".pptx")).map((f) => f.replace(/\.pptx$/, "")),
|
|
158
|
+
);
|
|
159
|
+
|
|
160
|
+
for (const file of entries) {
|
|
161
|
+
const full = path.join(slidesDir, file);
|
|
162
|
+
if (file.endsWith(".pptx")) {
|
|
163
|
+
sequence.push({
|
|
164
|
+
kind: "insert",
|
|
165
|
+
file,
|
|
166
|
+
...(includeBuffers ? { buf: fs.readFileSync(full) } : {}),
|
|
167
|
+
});
|
|
168
|
+
continue;
|
|
169
|
+
}
|
|
170
|
+
if (renderedStems.has(file.replace(/\.jsx$/, ""))) continue;
|
|
171
|
+
|
|
172
|
+
const src = fs.readFileSync(full, "utf8");
|
|
173
|
+
const component = src.match(/<([A-Z][A-Za-z0-9]*)/)?.[1] ?? null;
|
|
174
|
+
let layout = null;
|
|
175
|
+
if (component) {
|
|
176
|
+
try {
|
|
177
|
+
layout = layoutNumber(component, locale, pack.components);
|
|
178
|
+
} catch (err) {
|
|
179
|
+
problems.push(err.message);
|
|
180
|
+
continue;
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
// A pack validator needs to see freeform source positions even before the
|
|
185
|
+
// paired add-in artifact exists. The build path still rejects such a
|
|
186
|
+
// source until ensureFreeformRenders has produced its .pptx sibling.
|
|
187
|
+
if (!layout && allowUnrenderedFreeform) {
|
|
188
|
+
sequence.push({ kind: "freeform", file, component });
|
|
189
|
+
warnings.push(`${file}: freeform source has no rendered .pptx artifact yet`);
|
|
190
|
+
continue;
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
let parsed;
|
|
194
|
+
try {
|
|
195
|
+
parsed = parseSlideFile(src, file);
|
|
196
|
+
} catch (err) {
|
|
197
|
+
problems.push(err.message);
|
|
198
|
+
continue;
|
|
199
|
+
}
|
|
200
|
+
layout = layoutNumber(parsed.component, locale, pack.components);
|
|
201
|
+
if (!layout) {
|
|
202
|
+
problems.push(`${file}: <${parsed.component}> is a freeform (create-slides) slide with no ` +
|
|
203
|
+
`rendered artifact next to it — render slides/${file.replace(/\.jsx$/, "")}.pptx ` +
|
|
204
|
+
`through the paired add-in before building`);
|
|
205
|
+
continue;
|
|
206
|
+
}
|
|
207
|
+
if (layout.warn) warnings.push(`${file}: ${layout.warn}`);
|
|
208
|
+
const no = String(layout.no);
|
|
209
|
+
|
|
210
|
+
if ((pack.policy.freeform ?? []).includes(no)) {
|
|
211
|
+
if (Object.keys(parsed.props).length > 0) {
|
|
212
|
+
problems.push(`${file}: slide ${no} is a freeform placeholder — it takes no props. ` +
|
|
213
|
+
`Keep it bare to keep the master placeholder, or delete the file to drop the slide.`);
|
|
214
|
+
continue;
|
|
215
|
+
}
|
|
216
|
+
warnings.push(`${file}: keeping the master's slide-${no} placeholder (freeform replacement pending)`);
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
validateProps(parsed.props, fieldsByNo[no] ?? {}, repeatsByNo[no] ?? {}, pack.policy, no, file, problems);
|
|
220
|
+
sequence.push({ kind: "master", no: Number(no), props: parsed.props, file });
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
return { sequence, warnings, problems, locale, templateKey: skill.templateKey };
|
|
224
|
+
}
|
|
225
|
+
|
|
177
226
|
// ---------------------------------------------------------------------------
|
|
178
227
|
// Pack
|
|
179
228
|
// ---------------------------------------------------------------------------
|
|
@@ -266,77 +315,6 @@ function editDistance(a, b) {
|
|
|
266
315
|
return dp[a.length][b.length];
|
|
267
316
|
}
|
|
268
317
|
|
|
269
|
-
// ---------------------------------------------------------------------------
|
|
270
|
-
// Cross-slide identities (the catalog's consistency table, as code)
|
|
271
|
-
// ---------------------------------------------------------------------------
|
|
272
|
-
|
|
273
|
-
const asLines = (v) => (Array.isArray(v) ? v : String(v ?? "").split("\n")).map((s) => s.trim()).filter(Boolean);
|
|
274
|
-
|
|
275
|
-
function checkIdentities(sequence, warnings, problems) {
|
|
276
|
-
const slide = (no) => sequence.filter((s) => s.kind === "master" && s.no === no);
|
|
277
|
-
const approach = slide(10)[0];
|
|
278
|
-
const details = slide(11);
|
|
279
|
-
const gantt = slide(13)[0];
|
|
280
|
-
const calc = slide(16)[0];
|
|
281
|
-
|
|
282
|
-
const wpItems = approach?.props?.repeat?.workPackages ?? null;
|
|
283
|
-
const ganttItems = gantt?.props?.repeat?.workPackages ?? null;
|
|
284
|
-
|
|
285
|
-
if (wpItems && details.length && wpItems.length !== details.length) {
|
|
286
|
-
problems.push(`WP count mismatch: slide 10 declares ${wpItems.length} work packages but there are ${details.length} WP-detail files (slide 11)`);
|
|
287
|
-
}
|
|
288
|
-
// Every WP detail must carry FULL bullet lists — a section left empty (or a
|
|
289
|
-
// one-liner) is a defect in a client deliverable, not a style choice.
|
|
290
|
-
details.forEach((d, i) => {
|
|
291
|
-
for (const section of ["services", "assumptions", "deliverables"]) {
|
|
292
|
-
const lines = asLines(d.props?.[section]);
|
|
293
|
-
if (lines.length === 0) {
|
|
294
|
-
problems.push(`${d.file}: WP detail ${i + 1} has no "${section}" — every WP detail needs all three bullet lists filled`);
|
|
295
|
-
} else if (lines.length < 3) {
|
|
296
|
-
warnings.push(`${d.file}: "${section}" has only ${lines.length} bullet(s) — WP details expect full lists (3–5)`);
|
|
297
|
-
}
|
|
298
|
-
}
|
|
299
|
-
});
|
|
300
|
-
if (wpItems && ganttItems && wpItems.length !== ganttItems.length) {
|
|
301
|
-
problems.push(`WP count mismatch: slide 10 has ${wpItems.length} work packages, the project plan (13) has ${ganttItems.length} rows`);
|
|
302
|
-
}
|
|
303
|
-
if (ganttItems && details.length === ganttItems.length) {
|
|
304
|
-
ganttItems.forEach((item, i) => {
|
|
305
|
-
const canonical = asLines(details[i]?.props?.deliverables);
|
|
306
|
-
const row = asLines(item.deliverable);
|
|
307
|
-
if (canonical.length && row.length && canonical.join("\n") !== row.join("\n")) {
|
|
308
|
-
problems.push(`deliverables drift: gantt row ${i + 1} must repeat WP-${i + 1}'s deliverables verbatim (slide 11 owns them)`);
|
|
309
|
-
}
|
|
310
|
-
});
|
|
311
|
-
}
|
|
312
|
-
if (wpItems && ganttItems) {
|
|
313
|
-
ganttItems.forEach((item, i) => {
|
|
314
|
-
if (item.title && wpItems[i]?.title && item.title !== wpItems[i].title) {
|
|
315
|
-
problems.push(`WP name drift: slide 10 card ${i + 1} is "${wpItems[i].title}" but gantt row ${i + 1} is "${item.title}"`);
|
|
316
|
-
}
|
|
317
|
-
});
|
|
318
|
-
}
|
|
319
|
-
for (const [frameId, rows] of Object.entries(calc?.props?.tables ?? {})) {
|
|
320
|
-
if (!Array.isArray(rows) || !wpItems) continue;
|
|
321
|
-
const labels = rows.slice(1, -1).map((r) => String(r[0] ?? ""));
|
|
322
|
-
labels.forEach((label, i) => {
|
|
323
|
-
if (wpItems[i]?.title && label && label !== wpItems[i].title) {
|
|
324
|
-
problems.push(`WP name drift: calculation table ${frameId} row ${i + 1} is "${label}" but slide 10 card ${i + 1} is "${wpItems[i].title}"`);
|
|
325
|
-
}
|
|
326
|
-
});
|
|
327
|
-
}
|
|
328
|
-
// Price echo: the contracted total should appear in the management summary.
|
|
329
|
-
const costRows = calc?.props?.tables?.["15"];
|
|
330
|
-
if (Array.isArray(costRows)) {
|
|
331
|
-
const grand = String(costRows[costRows.length - 1]?.slice(-1)[0] ?? "");
|
|
332
|
-
const digits = grand.replace(/[^\d.,]/g, "");
|
|
333
|
-
const summary = slide(7)[0];
|
|
334
|
-
if (digits && summary?.props?.approach && !String(summary.props.approach).includes(digits)) {
|
|
335
|
-
warnings.push(`slide 7's approach paragraph does not mention the contracted total ${grand} from table 15`);
|
|
336
|
-
}
|
|
337
|
-
}
|
|
338
|
-
}
|
|
339
|
-
|
|
340
318
|
// ---------------------------------------------------------------------------
|
|
341
319
|
// Per-slide ops
|
|
342
320
|
// ---------------------------------------------------------------------------
|