From 2503affcfd8ff543b1b8111e82e100180eabfbe6 Mon Sep 17 00:00:00 2001 From: Miguel Angel Simon Sierra Date: Tue, 18 Aug 2026 19:47:14 -0400 Subject: [PATCH 1/3] fix(skills): resolve the blueprint id from a qualified `blueprint:` field visual-design.md documents `blueprint:` as the id plus a `(Reproduce)` / `(Adapt)` qualifier, and prints `dataviz-countup (Adapt)` as its worked example. The packet builder used that raw field as a filename, so a qualified blueprint looked for ` (Adapt).md`, found nothing, and inlined an empty string: `selectedFile()` returns "" for a missing path. Every packet shipped without the one document the frame was designed against, and the run still exited 0 with nothing on stderr. `compose (Adapt)` missed the `compose` check the same way. Parse the field into the id it names, once, so no caller resolves a raw field value against the blueprints directory. A blueprint that resolves to no file is now a named error rather than an empty section, matching how the builder already treats a missing `src` and an oversize packet. The existing tests only used bare ids, which is how the qualified form escaped; they now cover both, and the missing-file case. One owner: product-launch-video, faceless-explainer, pr-to-video and general-video all delegate to frame-packets-core.mjs. Co-Authored-By: anikam13 <22992075+anikam13@users.noreply.github.com> --- skills-manifest.json | 4 +- .../scripts/lib/frame-packets-core.mjs | 31 +++++++--- .../scripts/frame-packets.test.mjs | 59 +++++++++++++++++++ 3 files changed, 84 insertions(+), 10 deletions(-) diff --git a/skills-manifest.json b/skills-manifest.json index 39217fc336..c360144043 100644 --- a/skills-manifest.json +++ b/skills-manifest.json @@ -34,7 +34,7 @@ "files": 11 }, "hyperframes-core": { - "hash": "ec542db377d8b213", + "hash": "f533d6baea755fa2", "files": 20 }, "hyperframes-creative": { @@ -66,7 +66,7 @@ "files": 30 }, "product-launch-video": { - "hash": "d562efe00647c14b", + "hash": "f3322d09a17122c7", "files": 28 }, "remotion-to-hyperframes": { diff --git a/skills/hyperframes-core/scripts/lib/frame-packets-core.mjs b/skills/hyperframes-core/scripts/lib/frame-packets-core.mjs index 123b59c9fb..29e98140a9 100644 --- a/skills/hyperframes-core/scripts/lib/frame-packets-core.mjs +++ b/skills/hyperframes-core/scripts/lib/frame-packets-core.mjs @@ -83,14 +83,29 @@ export function citedRules(block, ruleIds) { return [...new Set([...explicit, ...mentioned])].filter((id) => ruleIds.includes(id)); } -export function resourceSections(block, { animationDir, ruleIds }) { +// visual-design.md tells the author to write the blueprint as ` (Reproduce)` +// or ` (Adapt)` — the qualifier is direction for the frame worker, not part of +// the filename. Parse the field into the id it names (or null for `compose`), so +// no caller ever resolves a raw field value against the blueprints directory. +export function blueprintId(block) { + const raw = field(block, "blueprint"); + if (!raw) return null; + const id = raw.replace(/\s*\([^)]*\)\s*$/, "").trim(); + return id && id.toLowerCase() !== "compose" ? id : null; +} + +export function resourceSections(block, { animationDir, ruleIds, frameId }) { let sections = ""; - const blueprint = field(block, "blueprint"); - if (blueprint && blueprint.toLowerCase() !== "compose") { - sections += selectedFile( - join(animationDir, "blueprints", `${blueprint}.md`), - `Selected blueprint: ${blueprint}`, - ); + const blueprint = blueprintId(block); + if (blueprint) { + const path = join(animationDir, "blueprints", `${blueprint}.md`); + // A blueprint that resolves to nothing used to inline an empty string, so the + // packet shipped without the one document the frame was designed against and + // the run still reported success. Name it instead. + if (!existsSync(path)) { + throw new Error(`${frameId ?? "frame"}: blueprint "${blueprint}" has no file at ${path}`); + } + sections += selectedFile(path, `Selected blueprint: ${blueprint}`); } for (const rule of citedRules(block, ruleIds)) { sections += selectedFile( @@ -135,7 +150,7 @@ export function buildFramePackets({ const packets = frames.map((frame) => { const id = frameId(frame); if (validateFrame) validateFrame(frame, id); - const packet = `# Frame packet: ${id}\n\n## Project inputs\n\n- Project: ${resolve(projectDir)}\n${designTruthLine(projectDir)}\n- RULES_DIR: ${join(animationDir, "rules")}\n\n## Assigned storyboard block\n\n${frame.block}\n${resourceSections(frame.block, { animationDir, ruleIds })}${extraSections ? extraSections(frame.block) : ""}`; + const packet = `# Frame packet: ${id}\n\n## Project inputs\n\n- Project: ${resolve(projectDir)}\n${designTruthLine(projectDir)}\n- RULES_DIR: ${join(animationDir, "rules")}\n\n## Assigned storyboard block\n\n${frame.block}\n${resourceSections(frame.block, { animationDir, ruleIds, frameId: id })}${extraSections ? extraSections(frame.block) : ""}`; const bytes = Buffer.byteLength(packet); if (bytes > maxPacketBytes) { throw new Error(`${id}: frame packet is ${bytes} bytes (limit ${maxPacketBytes})`); diff --git a/skills/product-launch-video/scripts/frame-packets.test.mjs b/skills/product-launch-video/scripts/frame-packets.test.mjs index 30c5a493c0..bffbd854b8 100644 --- a/skills/product-launch-video/scripts/frame-packets.test.mjs +++ b/skills/product-launch-video/scripts/frame-packets.test.mjs @@ -64,3 +64,62 @@ test("packet validation is atomic and leaves no partial output on overflow", () ); assert.equal(existsSync(outDir), false); }); + +// ── the blueprint qualifier ────────────────────────────────────────────────── +// Regression: visual-design.md documents `blueprint:` as the id plus a +// `(Reproduce)` / `(Adapt)` qualifier, and prints `dataviz-countup (Adapt)` as +// its worked example. The resolver used the raw field as the filename, so every +// qualified blueprint looked for a file that cannot exist and inlined "" — +// packets shipped without the document the frame was designed against, and the +// run still reported success. The cases above only ever used bare ids. + +test("a qualified blueprint resolves to the same body as the bare id", () => { + const project = mkdtempSync(join(tmpdir(), "plv-blueprint-qualified-")); + write(join(project, "frame.md"), "# tokens\n"); + write( + join(project, "STORYBOARD.md"), + `---\nformat: 1920x1080\n---\n\n## Frame 1 — Adapted\n\n- duration: 3s\n- src: compositions/frames/01-adapted.html\n- blueprint: device-surface-showcase (Adapt)\n\n## Frame 2 — Reproduced\n\n- duration: 3s\n- src: compositions/frames/02-reproduced.html\n- blueprint: device-surface-showcase (Reproduce)\n\n## Frame 3 — Bare\n\n- duration: 3s\n- src: compositions/frames/03-bare.html\n- blueprint: device-surface-showcase\n`, + ); + + const packets = buildFramePackets({ projectDir: project }); + const blueprintSections = packets.map((packet) => { + const body = readFileSync(packet.path, "utf8"); + const start = body.indexOf("## Selected blueprint:"); + assert.notEqual(start, -1, `${packet.frameId} inlined no blueprint`); + return body.slice(start); + }); + + assert.match(blueprintSections[0], /## Selected blueprint: device-surface-showcase\n/); + // The qualifier is direction for the worker, not a different document: all + // three frames must inline byte-identical blueprint bodies. + assert.equal(new Set(blueprintSections).size, 1); +}); + +test("a qualified `compose` still selects no blueprint", () => { + const project = mkdtempSync(join(tmpdir(), "plv-blueprint-compose-")); + write(join(project, "frame.md"), "# tokens\n"); + write( + join(project, "STORYBOARD.md"), + `---\nformat: 1920x1080\n---\n\n## Frame 1 — Freeform\n\n- duration: 3s\n- src: compositions/frames/01-freeform.html\n- blueprint: compose (Adapt)\n`, + ); + + const [packet] = buildFramePackets({ projectDir: project }); + + assert.doesNotMatch(readFileSync(packet.path, "utf8"), /## Selected blueprint/); +}); + +test("a blueprint with no file fails the run instead of shipping an empty section", () => { + const project = mkdtempSync(join(tmpdir(), "plv-blueprint-missing-")); + const outDir = join(project, ".hyperframes", "frame-packets"); + write(join(project, "frame.md"), "# tokens\n"); + write( + join(project, "STORYBOARD.md"), + `---\nformat: 1920x1080\n---\n\n## Frame 1 — Typo\n\n- duration: 3s\n- src: compositions/frames/01-typo.html\n- blueprint: device-surface-showcses\n`, + ); + + assert.throws( + () => buildFramePackets({ projectDir: project, outDir }), + /01-typo: blueprint "device-surface-showcses" has no file/, + ); + assert.equal(existsSync(outDir), false); +}); From 1917247cda532fe68a79ef7c2ea66f83530299c3 Mon Sep 17 00:00:00 2001 From: Miguel Angel Simon Sierra Date: Tue, 18 Aug 2026 19:57:16 -0400 Subject: [PATCH 2/3] fix(skills): degrade, not fail, when the blueprints library is absent MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Self-review catch on the previous commit. hyperframes-animation installs on demand, so its blueprints/ directory can legitimately be missing — that is a skill that isn't installed yet, not a frame naming a bad id. Throwing there turned a silent degrade into a hard failure for a valid setup. Distinguish the two: an absent blueprints/ warns and inlines nothing, exactly as an absent rules/ already does in knownRuleIds; a present library that has no file for this id still throws, because that is a typo or an unstripped qualifier. Co-Authored-By: anikam13 <22992075+anikam13@users.noreply.github.com> --- skills-manifest.json | 4 ++-- .../scripts/lib/frame-packets-core.mjs | 19 ++++++++++++++----- .../scripts/frame-packets.test.mjs | 19 +++++++++++++++++++ 3 files changed, 35 insertions(+), 7 deletions(-) diff --git a/skills-manifest.json b/skills-manifest.json index c360144043..519032b5f8 100644 --- a/skills-manifest.json +++ b/skills-manifest.json @@ -34,7 +34,7 @@ "files": 11 }, "hyperframes-core": { - "hash": "f533d6baea755fa2", + "hash": "35eb12d7e0062a61", "files": 20 }, "hyperframes-creative": { @@ -66,7 +66,7 @@ "files": 30 }, "product-launch-video": { - "hash": "f3322d09a17122c7", + "hash": "b3c7ed11831f51d3", "files": 28 }, "remotion-to-hyperframes": { diff --git a/skills/hyperframes-core/scripts/lib/frame-packets-core.mjs b/skills/hyperframes-core/scripts/lib/frame-packets-core.mjs index 29e98140a9..1a182f17ff 100644 --- a/skills/hyperframes-core/scripts/lib/frame-packets-core.mjs +++ b/skills/hyperframes-core/scripts/lib/frame-packets-core.mjs @@ -98,14 +98,23 @@ export function resourceSections(block, { animationDir, ruleIds, frameId }) { let sections = ""; const blueprint = blueprintId(block); if (blueprint) { - const path = join(animationDir, "blueprints", `${blueprint}.md`); - // A blueprint that resolves to nothing used to inline an empty string, so the + const blueprintsDir = join(animationDir, "blueprints"); + const path = join(blueprintsDir, `${blueprint}.md`); + // A blueprint that resolved to nothing used to inline an empty string, so the // packet shipped without the one document the frame was designed against and - // the run still reported success. Name it instead. - if (!existsSync(path)) { + // the run still reported success. Name it instead — but only when the library + // is actually there to be named against. The animation skill installs on + // demand, so an absent blueprints/ is a missing install, not a bad id, and it + // degrades with a warning exactly like an absent rules/ (see knownRuleIds). + if (!existsSync(blueprintsDir)) { + console.warn( + `frame-packets: no blueprints dir at ${blueprintsDir} — packets will inline no blueprint`, + ); + } else if (!existsSync(path)) { throw new Error(`${frameId ?? "frame"}: blueprint "${blueprint}" has no file at ${path}`); + } else { + sections += selectedFile(path, `Selected blueprint: ${blueprint}`); } - sections += selectedFile(path, `Selected blueprint: ${blueprint}`); } for (const rule of citedRules(block, ruleIds)) { sections += selectedFile( diff --git a/skills/product-launch-video/scripts/frame-packets.test.mjs b/skills/product-launch-video/scripts/frame-packets.test.mjs index bffbd854b8..6f45ef85fb 100644 --- a/skills/product-launch-video/scripts/frame-packets.test.mjs +++ b/skills/product-launch-video/scripts/frame-packets.test.mjs @@ -123,3 +123,22 @@ test("a blueprint with no file fails the run instead of shipping an empty sectio ); assert.equal(existsSync(outDir), false); }); + +test("an uninstalled animation skill degrades with a warning, it does not fail the run", () => { + // hyperframes-animation installs on demand, so an absent blueprints/ means the + // library isn't there yet — not that the frame named a bad id. Matches how an + // absent rules/ already behaves. + const project = mkdtempSync(join(tmpdir(), "plv-blueprint-uninstalled-")); + write(join(project, "frame.md"), "# tokens\n"); + write( + join(project, "STORYBOARD.md"), + `---\nformat: 1920x1080\n---\n\n## Frame 1 — Hook\n\n- duration: 3s\n- src: compositions/frames/01-hook.html\n- blueprint: dataviz-countup (Adapt)\n`, + ); + + const [packet] = buildFramePackets({ + projectDir: project, + animationDir: join(project, "absent-animation-skill"), + }); + + assert.doesNotMatch(readFileSync(packet.path, "utf8"), /## Selected blueprint/); +}); From 8b9ffe0789572d578041e637210844c7cd8f7807 Mon Sep 17 00:00:00 2001 From: Miguel Angel Simon Sierra Date: Tue, 18 Aug 2026 20:15:46 -0400 Subject: [PATCH 3/3] fix(skills): point two dead blueprint references at real shapes CI surfaced these once an unresolvable blueprint stopped being silent. Both named ids that have never existed in hyperframes-animation/blueprints/: - faceless-explainer's frame template taught `messaging-multi-phase`, so an agent copying the template verbatim tagged a blueprint that resolves to nothing. dataviz-countup is what the same skill already uses in its own visual-design template and tests. - pr-to-video's diff-excerpt guardrail fixture used `number-lockup`. The test is about diff excerpting and the id was incidental; the frame's own `counting-dynamic-scale` rule makes dataviz-countup the natural real shape. A sweep of every `blueprint:` value across skills/ finds no others. Co-Authored-By: anikam13 <22992075+anikam13@users.noreply.github.com> --- skills-manifest.json | 4 ++-- skills/faceless-explainer/references/story-design.md | 2 +- skills/pr-to-video/scripts/workflow-guardrails.test.mjs | 2 +- 3 files changed, 4 insertions(+), 4 deletions(-) diff --git a/skills-manifest.json b/skills-manifest.json index 519032b5f8..c01aa914e5 100644 --- a/skills-manifest.json +++ b/skills-manifest.json @@ -6,7 +6,7 @@ "files": 138 }, "faceless-explainer": { - "hash": "1eb3772e62dd71bb", + "hash": "881427da270043e3", "files": 24 }, "figma": { @@ -62,7 +62,7 @@ "files": 132 }, "pr-to-video": { - "hash": "01f46da1e17577ea", + "hash": "264e82ff07a16900", "files": 30 }, "product-launch-video": { diff --git a/skills/faceless-explainer/references/story-design.md b/skills/faceless-explainer/references/story-design.md index 7e68428cd0..956d6b36af 100644 --- a/skills/faceless-explainer/references/story-design.md +++ b/skills/faceless-explainer/references/story-design.md @@ -233,7 +233,7 @@ Use the exact fields required by the core storyboard format. This is the narrati - type: feature_showcase - persuasion: Progressive disclosure - beat: comprehension -- blueprint: messaging-multi-phase — candidate shape from the role→blueprint menu; omit when none fits +- blueprint: dataviz-countup — candidate shape from the role→blueprint menu; omit when none fits narrativeRole: What this frame does in the viewer's understanding. keyMessage: The one idea the viewer should remember. diff --git a/skills/pr-to-video/scripts/workflow-guardrails.test.mjs b/skills/pr-to-video/scripts/workflow-guardrails.test.mjs index e73dc74f64..85f5be78c5 100644 --- a/skills/pr-to-video/scripts/workflow-guardrails.test.mjs +++ b/skills/pr-to-video/scripts/workflow-guardrails.test.mjs @@ -83,7 +83,7 @@ test("#1092 packets contain selected excerpts but never the full diff", () => { write(join(project, "frame.md"), "# compact frame tokens\n"); write( join(project, "STORYBOARD.md"), - `---\nformat: 1920x1080\n---\n\n## Frame 1 — Diff\n\n- duration: 4s\n- src: compositions/frames/01-diff.html\n- focal: code-diff\n- blueprint: compose\n- rules: text-reveal\n\n### Source excerpt\n\n\`\`\`diff\n-oldCall()\n+newCall({ attested: true })\n\`\`\`\n\n## Frame 2 — Impact\n\n- duration: 3s\n- src: compositions/frames/02-impact.html\n- blueprint: number-lockup\n- rules: counting-dynamic-scale\n`, + `---\nformat: 1920x1080\n---\n\n## Frame 1 — Diff\n\n- duration: 4s\n- src: compositions/frames/01-diff.html\n- focal: code-diff\n- blueprint: compose\n- rules: text-reveal\n\n### Source excerpt\n\n\`\`\`diff\n-oldCall()\n+newCall({ attested: true })\n\`\`\`\n\n## Frame 2 — Impact\n\n- duration: 3s\n- src: compositions/frames/02-impact.html\n- blueprint: dataviz-countup\n- rules: counting-dynamic-scale\n`, ); const result = buildFramePackets({