From fba5cb9c93e462c06afba79b2705ec5e38dffa71 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Miguel=20=C3=81ngel?= Date: Mon, 13 Jul 2026 22:33:21 -0400 Subject: [PATCH] fix(pr-to-video): bound first-run workspace and context (#2382) * fix(pr-to-video): bound first-run workspace and context * fix(cli): expose validation gate in help * fix(pr-to-video): harden workflow guardrails * style(pr-to-video): apply repository formatting * chore(skills): refresh pr-to-video manifest --- .../HyperframesRenderStack.contract.test.ts | 9 +- packages/cli/src/cli.commands.test.ts | 8 + packages/cli/src/help.ts | 1 + skills-manifest.json | 4 +- skills/pr-to-video/SKILL.md | 43 ++++-- skills/pr-to-video/scripts/frame-packets.mjs | 142 +++++++++++++++++ skills/pr-to-video/scripts/preflight.mjs | 38 +++++ skills/pr-to-video/scripts/project-dir.mjs | 79 ++++++++++ .../scripts/workflow-guardrails.test.mjs | 146 ++++++++++++++++++ skills/pr-to-video/sub-agents/frame-worker.md | 16 +- 10 files changed, 464 insertions(+), 22 deletions(-) create mode 100644 skills/pr-to-video/scripts/frame-packets.mjs create mode 100644 skills/pr-to-video/scripts/preflight.mjs create mode 100644 skills/pr-to-video/scripts/project-dir.mjs create mode 100644 skills/pr-to-video/scripts/workflow-guardrails.test.mjs diff --git a/packages/aws-lambda/src/cdk/HyperframesRenderStack.contract.test.ts b/packages/aws-lambda/src/cdk/HyperframesRenderStack.contract.test.ts index ef2e3f493..9db00548d 100644 --- a/packages/aws-lambda/src/cdk/HyperframesRenderStack.contract.test.ts +++ b/packages/aws-lambda/src/cdk/HyperframesRenderStack.contract.test.ts @@ -17,7 +17,8 @@ import { App, Stack } from "aws-cdk-lib"; import { Template } from "aws-cdk-lib/assertions"; import { HyperframesRenderStack } from "./HyperframesRenderStack.js"; -// CDK synth is slow on cold start (~5-8s on the slowest CI runner). The +// CDK synth is slow on cold start and reached 32s under full-workspace CI +// contention. The // default bun:test 5s timeout trips the first `it()` that calls it. Cache // the default-args synth in `beforeAll` so each test is pure assertions. // Tests that need non-default props still synth on demand and bump their @@ -129,9 +130,11 @@ describe("HyperframesRenderStack — contract", () => { projectName: "demo", }); const t = Template.fromStack(stack); - t.hasResourceProperties("AWS::Lambda::Function", { FunctionName: "demo-render" }); + t.hasResourceProperties("AWS::Lambda::Function", { + FunctionName: "demo-render", + }); t.hasResourceProperties("AWS::StepFunctions::StateMachine", { StateMachineName: "demo-render", }); - }, 30000); + }, 60000); }); diff --git a/packages/cli/src/cli.commands.test.ts b/packages/cli/src/cli.commands.test.ts index cc62420cf..d161ef9ae 100644 --- a/packages/cli/src/cli.commands.test.ts +++ b/packages/cli/src/cli.commands.test.ts @@ -27,6 +27,14 @@ describe("CLI command registration", () => { ); }); + it("shows the check command used by workflow capability preflight in root help", () => { + const loaders = commandLoaderBlock(); + expect(loaders).toMatch(/\bcheck:\s*\(\)\s*=>\s*import\("\.\/commands\/check\.js"\)/); + expect(helpSource).toContain( + '["check", "Run lint, runtime validation, and layout inspection as one gate"]', + ); + }); + // A command actively reconciling skills (`skills check`/`skills update`) // must not also nudge the user to go reconcile skills — that nudge is // either redundant (it just ran) or misleading (a stale cached count from diff --git a/packages/cli/src/help.ts b/packages/cli/src/help.ts index 11f0e15de..e876f0a53 100644 --- a/packages/cli/src/help.ts +++ b/packages/cli/src/help.ts @@ -33,6 +33,7 @@ const GROUPS: Group[] = [ title: "Project", commands: [ ["lint", "Validate a composition for common mistakes"], + ["check", "Run lint, runtime validation, and layout inspection as one gate"], [ "validate", "Runtime-validate a composition in headless Chrome (JS errors, missing assets, contrast)", diff --git a/skills-manifest.json b/skills-manifest.json index e6780a95e..ab6498d24 100644 --- a/skills-manifest.json +++ b/skills-manifest.json @@ -58,8 +58,8 @@ "files": 132 }, "pr-to-video": { - "hash": "2132f6f9bd474f52", - "files": 22 + "hash": "9fe5b5fecfc78f38", + "files": 26 }, "product-launch-video": { "hash": "b1b41c74a52e28f1", diff --git a/skills/pr-to-video/SKILL.md b/skills/pr-to-video/SKILL.md index 73ac26482..94352251f 100644 --- a/skills/pr-to-video/SKILL.md +++ b/skills/pr-to-video/SKILL.md @@ -13,7 +13,7 @@ Use this skill to ingest a GitHub pull request, understand the change, plan a co > **Confirm the route before Step 0.** You are the orchestrator. Run each step, verify its gate, and only then continue. This skill is for a **GitHub pull request** (a code change). Route other intents elsewhere: a product launch/promo → `/product-launch-video`; a general website tour → `/website-to-video`; a topic explainer with no PR → `/faceless-explainer`; captions on existing footage → `/embedded-captions`; a short unnarrated motion graphic → `/motion-graphics`; a whole-repo or multi-PR release walkthrough → `/general-video`. **Out of scope:** live / at-render-time data — PR facts are read once at author time and baked in. If the user says only "make a video" or the route is uncertain, read `/hyperframes` first. -You are the orchestrator. Work in `videos//`. Run steps in order and pass each gate before continuing. User-gated steps are Step 0, Step 3, and Step 6. Read `../hyperframes-core/references/brief-contract.md` before Step 0 — it defines the two modes, the gate types, and the brief fields; the mode governs the Step 0/3/6 gates. Do every step yourself except Step 5, where you dispatch one sub-agent per frame. Do not put design or motion rules here; those live in the frame-worker sub-agent, this skill's local `../hyperframes-animation/rules/` + `../hyperframes-animation/blueprints/`, and `hyperframes-creative`. +You are the orchestrator. Work in the resolved external `PROJECT_DIR`, never in the caller repository by default. Run steps in order and pass each gate before continuing. User-gated steps are Step 0, Step 3, and Step 6. Read `../hyperframes-core/references/brief-contract.md` before Step 0 — it defines the two modes, the gate types, and the brief fields; the mode governs the Step 0/3/6 gates. Do every step yourself except Step 5, where you dispatch a bounded pool of frame workers. Do not put design or motion rules here; those live in the frame-worker sub-agent, this skill's local `../hyperframes-animation/rules/` + `../hyperframes-animation/blueprints/`, and `hyperframes-creative`. Workflow: Step 0 setup → `hyperframes.json`; Step 1 ingest → `capture/extracted/` + `assets/.png`; Step 2 design system → `frame.md`; Step 3 storyboard/script → `STORYBOARD.md` and `SCRIPT.md`; Step 3.1 audio → `audio_meta.json`; Step 4 visual design → enriched `STORYBOARD.md`; Step 5 frames → `compositions/frames/NN-*.html` and `index.html`; Step 6 final render → `renders/video.mp4`. @@ -58,9 +58,26 @@ Pick the tier from `additions + deletions` (nudged up by `changedFiles`) and lea State the basis in one phrase when you propose it (e.g. "~40s — small change, +44/−13 across 12 files"). A huge PR doesn't mean a long video — the tier is a **ceiling** on how much story the diff can support, never a floor to fill. When the story is **one headline change**, recommend inside the 30–90s sweet spot regardless of the size tier, and say so (the tier's range can still appear as a non-recommended option for a fuller walkthrough). -Initialize only if `hyperframes.json` is missing. Name `` from the PR in kebab-case, such as `acme-sdk-pr-1842`; never use the workspace name or a timestamp. +Resolve the project directory before doing any other work. Preserve a user-supplied project directory; otherwise use the durable external cache location printed by the resolver. Never create `videos/` in the caller repository: -`npx hyperframes init "videos/" --non-interactive --example=blank` — `init` checks the installed skills against the latest on GitHub and updates the global set if any are out of date. +```bash +PR="" +if [ -n "${EXPLICIT_PROJECT_DIR:-}" ]; then + PROJECT_DIR="$(node /scripts/project-dir.mjs --pr "$PR" --project-dir "$EXPLICIT_PROJECT_DIR")" +else + PROJECT_DIR="$(node /scripts/project-dir.mjs --pr "$PR")" +fi +echo "PR-to-video project: $PROJECT_DIR" +node /scripts/preflight.mjs +``` + +The capability preflight runs before fetch, story work, audio, or frame dispatch. If the installed CLI cannot run the validation command required by this skill, stop with its upgrade instruction rather than spending the run's context first. + +Initialize only if `$PROJECT_DIR/hyperframes.json` is missing. Its basename comes from the PR, such as `acme-sdk-pr-1842`; never use the workspace name or a timestamp. + +`npx hyperframes init "$PROJECT_DIR" --non-interactive --example=blank` — `init` checks the installed skills against the latest on GitHub and updates the global set if any are out of date. + +Every relative-path command below runs with `$PROJECT_DIR` as its working directory. Examples without an explicit subshell mean `(cd "$PROJECT_DIR" && …)`; never change the caller repository's working tree. **Show sign-in status before the brief** — run `npx hyperframes auth status` and **relay its output verbatim (don't paraphrase or rewrite it).** It reports whether voice/BGM will use HeyGen or local engines and, when not signed in, how to sign in. **If not signed in, STOP and wait for the user to choose — sign in, or say "go"/"offline" to continue with local engines — before asking the brief or anything else.** Treat it as a real decision point, not a passing note; don't fold the choice into the brief question, and don't write keys into a per-repo `.env`. (In autonomous mode, note the status and continue offline.) See `../media-use` → Preflight for the canonical guidance. @@ -78,16 +95,16 @@ PR="" # Fetch the PR deterministically: runs gh, completes the files list via paginated # gh api (so a big PR doesn't truncate at ~100 files), writes only capture/pr.json + # capture/diff.patch — no scratch dir. gh auth / not-found / private errors exit 1 here. -(cd "videos/" && node /scripts/fetch-pr.mjs --pr "$PR" --out-dir ./capture) +(cd "$PROJECT_DIR" && node /scripts/fetch-pr.mjs --pr "$PR" --out-dir ./capture) # Offline transform → capture/extracted/{tokens.json (colors:[] → claude palette), # visible-text.txt (the brief), people.json (contributors, bot-filtered, avatarFile=assets/.png)}. -(cd "videos/" && node /scripts/ingest.mjs \ +(cd "$PROJECT_DIR" && node /scripts/ingest.mjs \ --pr-json ./capture/pr.json --diff ./capture/diff.patch --out-dir ./capture/extracted) # The people front's one network step — download each contributor's GitHub avatar to # assets/.png for the credits close. Best-effort; always exits 0. -(cd "videos/" && node /scripts/fetch-people-avatars.mjs \ +(cd "$PROJECT_DIR" && node /scripts/fetch-people-avatars.mjs \ --people ./capture/extracted/people.json) ``` @@ -153,7 +170,7 @@ Edit `STORYBOARD.md` in place. Do not create another storyboard. Use `frame.md` Read `references/visual-design.md`, `../hyperframes-animation/blueprints-index.md`, `references/motion-language.md`, `references/code-vocabulary.md`, and `../hyperframes-animation/rules-index.md`. Use `visual-design.md` for the method (the time-coded shot sequence, the inline Layout vocabulary, and the code-beat treatment), plus the required `## Video direction` block. Use `../hyperframes-animation/blueprints-index.md` to pick each frame's shot shape. Use `code-vocabulary.md` to pick the right `code-*` block per code beat (diff = `code-diff`, refactor = `code-morph`, new code = `code-typing`, …). Use `motion-language.md` (the motion vocabulary + the motion doctrine) and `../hyperframes-animation/rules-index.md` (valid rule names) for motion — do not invent motion or block/blueprint names. -For every frame, write a **time-coded shot sequence** into `STORYBOARD.md` per `visual-design.md`'s method: pick the frame's blueprint (or compose), instantiate it with THIS frame's content, and pace each Scene's reveal to the voiceover so the frame develops across its full duration instead of front-loading then freezing. **For a code beat, the `code-*` block is the frame's `focal`** and the Scenes choreograph the surrounding claude Code Surface (the entry of the file/header, the camera onto the hunk, the landing line) — **not** the code animation itself, which the block owns. State layout and motion **inline** per Scene (vocabularies in `visual-design.md` and `motion-language.md`). Add one video-wide `## Video direction` block. +For every frame, write a **time-coded shot sequence** into `STORYBOARD.md` per `visual-design.md`'s method: pick the frame's blueprint (or compose), instantiate it with THIS frame's content, and pace each Scene's reveal to the voiceover so the frame develops across its full duration instead of front-loading then freezing. **For a code beat, the `code-*` block is the frame's `focal`** and the Scenes choreograph the surrounding claude Code Surface (the entry of the file/header, the camera onto the hunk, the landing line) — **not** the code animation itself, which the block owns. Immediately after each code frame's fields, add a `### Source excerpt` fenced `diff` block containing only the exact real hunk the worker must render (12 lines maximum). Select it here from `capture/diff.patch`; workers are forbidden from reopening that full diff. State layout and motion **inline** per Scene (vocabularies in `visual-design.md` and `motion-language.md`). Add one video-wide `## Video direction` block. Do not change story, script, `transition_in`, `asset_candidates`, or the PR source. Do not write HTML in this step. There is **no asset-staging step** — the only real assets are the credits avatars, already in `assets/`. @@ -177,7 +194,15 @@ Duration sync is mechanical: real voice duration wins; silent frames keep estima `for b in ; do npx hyperframes add "$b"; done` -Before dispatch, read `sub-agents/frame-worker.md` and `../hyperframes-core/references/subagent-dispatch.md`. Dispatch one sub-agent per frame, in parallel if possible; otherwise run workers in waves. Each worker gets exactly one frame. Each worker's context must include `PROJECT_DIR`, `frame_id`, canvas size, caption status and keep-out band if captions are enabled, `RULES_DIR` (absolute path to this skill's `../hyperframes-animation/rules/`), and the absolute path to `references/code-vocabulary.md`. Each worker reads `frame.md`, its own `## Frame N` block from `STORYBOARD.md`, the local rule recipe (`../hyperframes-animation/rules/.md`) for each cited motion, the frame's blueprint template (`../hyperframes-animation/blueprints/.md`), and — for a code beat — `code-vocabulary.md` for the named block's inputs. Each worker writes only `compositions/frames/NN-*.html`; workers never edit `STORYBOARD.md`. +Before dispatch, read `sub-agents/frame-worker.md` and `../hyperframes-core/references/subagent-dispatch.md`. Build bounded packets: + +```bash +node /scripts/frame-packets.mjs --project "$PROJECT_DIR" --storyboard "$PROJECT_DIR/STORYBOARD.md" +``` + +The packet builder hard-fails a code frame without the upstream-selected `### Source excerpt`, and hard-caps packet bytes. Dispatch **at most three workers total**, balanced across the packet paths; each worker may build multiple assigned frames sequentially and reads shared instructions once. Workers read only their packet(s) and `frame.md`. They never open the full `STORYBOARD.md`, `capture/diff.patch`, or `capture/extracted/visible-text.txt`. Each worker writes only its assigned `compositions/frames/NN-*.html`; workers never edit `STORYBOARD.md`. + +On a failed frame, re-dispatch **that frame only**, with its existing packet plus the exact validator/lint finding. One retry maximum. Do not replay a whole batch and do not retry without a concrete finding. **Full-bleed backgrounds ride on a `class="clip"` layer, never the `#root`.** A frame's ground (color field / gradient / grid) is its own full-duration background clip — a `background` set on the `#root` / `data-composition-id` element is clip-gated to the frame's window and is not a dependable ground, so dark content can land on the black host `body` and render invisible. The video's base ground is painted by the assembler from `frame.md`'s `canvas` color onto the index `#root`. (Full rule + self-check: `sub-agents/frame-worker.md`.) @@ -209,8 +234,6 @@ Inject transitions, run checks, pause for review, then render. `npx hyperframes check` -`npx hyperframes check` - `npx hyperframes snapshot --at ` `snapshot` stitches the captured frames into one contact sheet (`snapshots/contact-sheet.jpg`). Glance at it; if nothing is obviously broken, move on — don't linger here. diff --git a/skills/pr-to-video/scripts/frame-packets.mjs b/skills/pr-to-video/scripts/frame-packets.mjs new file mode 100644 index 000000000..1e45d246a --- /dev/null +++ b/skills/pr-to-video/scripts/frame-packets.mjs @@ -0,0 +1,142 @@ +#!/usr/bin/env node + +import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs"; +import { basename, dirname, join, resolve } from "node:path"; +import { fileURLToPath, pathToFileURL } from "node:url"; + +const SCRIPT_DIR = dirname(fileURLToPath(import.meta.url)); +const SKILL_DIR = resolve(SCRIPT_DIR, ".."); +const ANIMATION_DIR = resolve(SKILL_DIR, "../hyperframes-animation"); + +function field(block, name) { + const match = block.match(new RegExp(`^-\\s+${name}:\\s*(.+)$`, "im")); + return match?.[1]?.trim() ?? null; +} + +function splitFrames(storyboard) { + const matches = [...storyboard.matchAll(/^## Frame\s+([^\n]+)$/gm)]; + return matches.map((match, index) => { + const start = match.index; + const end = matches[index + 1]?.index ?? storyboard.length; + return { + heading: match[1].trim(), + block: storyboard.slice(start, end).trim(), + }; + }); +} + +function frameId(frame) { + const src = field(frame.block, "src"); + if (!src) throw new Error(`${frame.heading}: missing src`); + return basename(src).replace(/\.html?$/i, ""); +} + +function sourceExcerpt(block) { + const match = block.match(/^### Source excerpt\s*\n+(```[^\n]*\n[\s\S]*?\n```)/im); + return match?.[1] ?? null; +} + +function selectedFile(path, heading) { + if (!path || !existsSync(path)) return ""; + return `\n## ${heading}\n\n${readFileSync(path, "utf8").trim()}\n`; +} + +function codeVocabularySection(block) { + const focal = field(block, "focal") ?? ""; + const codeId = focal.match(/\b(code-[a-z0-9-]+)\b/i)?.[1]; + if (!codeId) return ""; + const vocabPath = join(SKILL_DIR, "references", "code-vocabulary.md"); + if (!existsSync(vocabPath)) { + return `\n## Code block\n\nUse registry block \`${codeId}\`.\n`; + } + const vocab = readFileSync(vocabPath, "utf8"); + const lines = vocab.split("\n"); + const exactToken = `\`${codeId.toLowerCase()}\``; + const matchingLines = lines.filter((line) => line.toLowerCase().includes(exactToken)); + if (matchingLines.length === 0) { + return `\n## Code block\n\nUse registry block \`${codeId}\`.\n`; + } + return `\n## Code block excerpt (${codeId})\n\n${matchingLines.join("\n").trim()}\n`; +} + +function resourceSections(block) { + let sections = ""; + const blueprint = field(block, "blueprint"); + if (blueprint && blueprint.toLowerCase() !== "compose") { + sections += selectedFile( + join(ANIMATION_DIR, "blueprints", `${blueprint}.md`), + `Selected blueprint: ${blueprint}`, + ); + } + const rules = (field(block, "rules") ?? "") + .split(/[,\s]+/) + .map((rule) => rule.trim()) + .filter(Boolean); + for (const rule of rules) { + sections += selectedFile( + join(ANIMATION_DIR, "rules", `${rule}.md`), + `Selected motion rule: ${rule}`, + ); + } + return sections; +} + +const COMPACT_CONTRACT = `- Output exactly one bare \`\` fragment; never emit DOCTYPE, html, head, or body. +- The first composition root must carry the exact frame id, positive duration, width, and height. +- Register exactly one paused GSAP timeline under the exact frame id. +- Write only the requested frame file. Do not read the full PR diff or the full storyboard.`; + +export function buildFramePackets({ + projectDir, + storyboardPath = join(projectDir, "STORYBOARD.md"), + outDir = join(projectDir, ".hyperframes", "frame-packets"), + maxPacketBytes = 48_000, +}) { + const storyboard = readFileSync(storyboardPath, "utf8"); + const frames = splitFrames(storyboard); + if (frames.length === 0) throw new Error("STORYBOARD.md has no frame blocks"); + + const packets = frames.map((frame) => { + const id = frameId(frame); + const codeFrame = /\bcode-[a-z0-9-]+\b/i.test(field(frame.block, "focal") ?? ""); + const excerpt = sourceExcerpt(frame.block); + if (codeFrame && !excerpt) { + throw new Error(`${frame.heading}: code frame requires an upstream-selected Source excerpt`); + } + const packet = `# Frame packet: ${id}\n\n## Structural contract\n\n${COMPACT_CONTRACT}\n\n## Project inputs\n\n- Project: ${resolve(projectDir)}\n- Design tokens: ${join(resolve(projectDir), "frame.md")}\n\n## Assigned storyboard block\n\n${frame.block}\n${resourceSections(frame.block)}${codeVocabularySection(frame.block)}`; + const bytes = Buffer.byteLength(packet); + if (bytes > maxPacketBytes) { + throw new Error(`${id}: frame packet is ${bytes} bytes (limit ${maxPacketBytes})`); + } + return { frameId: id, path: join(outDir, `${id}.md`), bytes, packet }; + }); + + mkdirSync(outDir, { recursive: true }); + for (const { path, packet } of packets) writeFileSync(path, packet); + return packets.map(({ packet: _packet, ...result }) => result); +} + +function flag(argv, name, fallback) { + const index = argv.indexOf(`--${name}`); + return index >= 0 && argv[index + 1] ? argv[index + 1] : fallback; +} + +function main() { + const argv = process.argv.slice(2); + const projectDir = resolve(flag(argv, "project", ".")); + try { + const packets = buildFramePackets({ + projectDir, + storyboardPath: resolve(flag(argv, "storyboard", join(projectDir, "STORYBOARD.md"))), + outDir: resolve(flag(argv, "out-dir", join(projectDir, ".hyperframes", "frame-packets"))), + }); + console.log(`✓ frame packets: ${packets.length} bounded packet(s)`); + for (const packet of packets) + console.log(` ${packet.frameId}: ${packet.bytes} bytes → ${packet.path}`); + } catch (error) { + console.error(`✗ frame packets: ${error.message}`); + process.exit(1); + } +} + +if (pathToFileURL(process.argv[1] ?? "").href === import.meta.url) main(); diff --git a/skills/pr-to-video/scripts/preflight.mjs b/skills/pr-to-video/scripts/preflight.mjs new file mode 100644 index 000000000..0b5c967ea --- /dev/null +++ b/skills/pr-to-video/scripts/preflight.mjs @@ -0,0 +1,38 @@ +#!/usr/bin/env node + +import { spawnSync } from "node:child_process"; +import { pathToFileURL } from "node:url"; + +export function hasCliCommand(helpText, command) { + const escaped = command.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); + return new RegExp(`^\\s+${escaped}(?:\\s+|$)`, "m").test(String(helpText)); +} + +export function runCliPreflight({ command = "check", spawn = spawnSync } = {}) { + const result = spawn("npx", ["hyperframes", "--help"], { + encoding: "utf8", + shell: process.platform === "win32", + }); + const output = `${result.stdout ?? ""}\n${result.stderr ?? ""}`; + if (result.status !== 0) { + throw new Error(`unable to inspect HyperFrames CLI capabilities\n${output.trim()}`); + } + if (!hasCliCommand(output, command)) { + throw new Error( + `the installed HyperFrames CLI does not provide \`${command}\`, but the current pr-to-video skill requires it. Upgrade the CLI before starting frame work.`, + ); + } + return true; +} + +function main() { + try { + runCliPreflight(); + console.log("✓ pr-to-video preflight: required CLI capabilities are available"); + } catch (error) { + console.error(`✗ pr-to-video preflight: ${error.message}`); + process.exit(1); + } +} + +if (pathToFileURL(process.argv[1] ?? "").href === import.meta.url) main(); diff --git a/skills/pr-to-video/scripts/project-dir.mjs b/skills/pr-to-video/scripts/project-dir.mjs new file mode 100644 index 000000000..b59bbb72b --- /dev/null +++ b/skills/pr-to-video/scripts/project-dir.mjs @@ -0,0 +1,79 @@ +#!/usr/bin/env node + +import { homedir } from "node:os"; +import { join, resolve } from "node:path"; +import { pathToFileURL } from "node:url"; + +function safeSegment(value) { + const normalized = value + .normalize("NFKD") + .toLowerCase() + .replace(/[^a-z0-9]+/g, "-") + .replace(/^-+|-+$/g, ""); + if (!normalized || normalized === "." || normalized === "..") { + throw new Error("Invalid GitHub PR reference: owner/repository is empty after sanitization"); + } + return normalized; +} + +export function parsePrReference(raw) { + const input = String(raw ?? "").trim(); + let match = input.match(/^https?:\/\/github\.com\/([^/]+)\/([^/]+)\/pull\/(\d+)(?:[/?#].*)?$/i); + if (!match) match = input.match(/^([A-Za-z0-9_.-]+)\/([A-Za-z0-9_.-]+)#(\d+)$/); + if (!match) { + throw new Error(`Invalid GitHub PR reference: ${JSON.stringify(input)}`); + } + const number = Number(match[3]); + if (!Number.isSafeInteger(number) || number <= 0) { + throw new Error(`Invalid GitHub PR reference: ${JSON.stringify(input)}`); + } + return { owner: safeSegment(match[1]), repo: safeSegment(match[2]), number }; +} + +export function resolvePrToVideoProjectDir({ + pr, + explicitDir, + cwd = process.cwd(), + env = process.env, +}) { + if (explicitDir?.trim()) return resolve(cwd, explicitDir.trim()); + const ref = parsePrReference(pr); + const cacheRoot = env.XDG_CACHE_HOME?.trim() + ? resolve(env.XDG_CACHE_HOME) + : join(env.HOME?.trim() ? resolve(env.HOME) : homedir(), ".cache"); + return join( + cacheRoot, + "hyperframes", + "pr-to-video", + ref.owner, + ref.repo, + `${ref.repo}-pr-${ref.number}`, + ); +} + +function flag(argv, name) { + const index = argv.indexOf(`--${name}`); + return index >= 0 ? argv[index + 1] : undefined; +} + +function main() { + const argv = process.argv.slice(2); + const pr = flag(argv, "pr"); + if (!pr) { + console.error('usage: node project-dir.mjs --pr "" [--project-dir ]'); + process.exit(2); + } + try { + console.log( + resolvePrToVideoProjectDir({ + pr, + explicitDir: flag(argv, "project-dir") ?? process.env.PR_TO_VIDEO_PROJECT_DIR, + }), + ); + } catch (error) { + console.error(`✗ project-dir: ${error.message}`); + process.exit(1); + } +} + +if (pathToFileURL(process.argv[1] ?? "").href === import.meta.url) main(); diff --git a/skills/pr-to-video/scripts/workflow-guardrails.test.mjs b/skills/pr-to-video/scripts/workflow-guardrails.test.mjs new file mode 100644 index 000000000..d3112e015 --- /dev/null +++ b/skills/pr-to-video/scripts/workflow-guardrails.test.mjs @@ -0,0 +1,146 @@ +import assert from "node:assert/strict"; +import { existsSync, mkdtempSync, mkdirSync, readFileSync, writeFileSync } from "node:fs"; +import { homedir, tmpdir } from "node:os"; +import { dirname, isAbsolute, join, relative, resolve } from "node:path"; +import test from "node:test"; + +import { parsePrReference, resolvePrToVideoProjectDir } from "./project-dir.mjs"; +import { buildFramePackets } from "./frame-packets.mjs"; +import { hasCliCommand } from "./preflight.mjs"; + +function write(path, contents) { + mkdirSync(dirname(path), { recursive: true }); + writeFileSync(path, contents); +} + +test("default project directory is durable and outside the caller repository", () => { + const caller = mkdtempSync(join(tmpdir(), "p2v-caller-")); + const cache = mkdtempSync(join(tmpdir(), "p2v-cache-")); + const result = resolvePrToVideoProjectDir({ + pr: "https://github.com/EveryInc/compound-engineering-plugin/pull/1092", + cwd: caller, + env: { XDG_CACHE_HOME: cache, HOME: homedir() }, + }); + + assert.equal( + result, + join( + cache, + "hyperframes", + "pr-to-video", + "everyinc", + "compound-engineering-plugin", + "compound-engineering-plugin-pr-1092", + ), + ); + assert.ok(isAbsolute(result)); + assert.ok(relative(caller, result).startsWith("..")); +}); + +test("explicit project directory is preserved exactly after absolute resolution", () => { + const caller = mkdtempSync(join(tmpdir(), "p2v-explicit-caller-")); + assert.equal( + resolvePrToVideoProjectDir({ + pr: "EveryInc/compound-engineering-plugin#1092", + cwd: caller, + explicitDir: "../my-video", + env: {}, + }), + resolve(caller, "../my-video"), + ); +}); + +test("distinct owner and repository segments cannot collide in the durable cache", () => { + const cache = mkdtempSync(join(tmpdir(), "p2v-cache-collision-")); + const first = resolvePrToVideoProjectDir({ + pr: "foo-bar/baz#1", + env: { XDG_CACHE_HOME: cache }, + }); + const second = resolvePrToVideoProjectDir({ + pr: "foo/bar-baz#1", + env: { XDG_CACHE_HOME: cache }, + }); + + assert.notEqual(first, second); +}); + +test("PR parsing sanitizes owner and repository path traversal", () => { + assert.deepEqual( + parsePrReference("https://github.com/EveryInc/compound-engineering-plugin/pull/1092"), + { + owner: "everyinc", + repo: "compound-engineering-plugin", + number: 1092, + }, + ); + assert.throws(() => parsePrReference("../../outside#1092"), /valid GitHub PR reference/i); +}); + +test("#1092 packets contain selected excerpts but never the full diff", () => { + const project = mkdtempSync(join(tmpdir(), "p2v-packets-")); + const largeDiff = `diff --git a/noise b/noise\n${"+unselected noise\n".repeat(10_000)}`; + write(join(project, "capture", "diff.patch"), largeDiff); + 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`, + ); + + const result = buildFramePackets({ + projectDir: project, + storyboardPath: join(project, "STORYBOARD.md"), + outDir: join(project, ".hyperframes", "frame-packets"), + maxPacketBytes: 32_000, + }); + + assert.equal(result.length, 2); + const codePacket = readFileSync(result[0].path, "utf8"); + assert.match(codePacket, /newCall\(\{ attested: true \}\)/); + assert.doesNotMatch(codePacket, /unselected noise/); + assert.doesNotMatch(codePacket, /code-scroll/); + assert.ok(Buffer.byteLength(codePacket) < 32_000); + assert.ok(result.every((packet) => packet.path.endsWith(".md"))); +}); + +test("packet validation is atomic and leaves no partial output on overflow", () => { + const project = mkdtempSync(join(tmpdir(), "p2v-packets-atomic-")); + const outDir = join(project, ".hyperframes", "frame-packets"); + write(join(project, "frame.md"), "# frame\n"); + write( + join(project, "STORYBOARD.md"), + `---\nformat: 1920x1080\n---\n\n## Frame 1 — Intro\n\n- duration: 2s\n- src: compositions/frames/01-intro.html\n\n## Frame 2 — Diff\n\n- duration: 4s\n- src: compositions/frames/02-diff.html\n- focal: code-diff\n\n### Source excerpt\n\n\`\`\`diff\n${"+oversized line\n".repeat(300)}\`\`\`\n`, + ); + + assert.throws( + () => buildFramePackets({ projectDir: project, outDir, maxPacketBytes: 2_000 }), + /limit 2000/, + ); + assert.equal(existsSync(outDir), false); +}); + +test("code frames without an upstream-selected excerpt fail before dispatch", () => { + const project = mkdtempSync(join(tmpdir(), "p2v-packets-missing-")); + write(join(project, "frame.md"), "# frame\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`, + ); + + assert.throws( + () => + buildFramePackets({ + projectDir: project, + storyboardPath: join(project, "STORYBOARD.md"), + outDir: join(project, ".hyperframes", "frame-packets"), + }), + /Source excerpt/i, + ); +}); + +test("CLI capability detection rejects skills newer than the available command surface", () => { + const stableHelp = `Project:\n lint Validate a composition\n snapshot Capture frames\n\nUnknown command check`; + const currentHelp = `Project:\n lint Validate a composition\n check Run the full project validation gate\n snapshot Capture frames`; + + assert.equal(hasCliCommand(stableHelp, "check"), false); + assert.equal(hasCliCommand(currentHelp, "check"), true); +}); diff --git a/skills/pr-to-video/sub-agents/frame-worker.md b/skills/pr-to-video/sub-agents/frame-worker.md index ea674c857..1405b2027 100644 --- a/skills/pr-to-video/sub-agents/frame-worker.md +++ b/skills/pr-to-video/sub-agents/frame-worker.md @@ -1,12 +1,14 @@ # Frame worker — PR-to-video per-frame composition author -> You build **one** frame's composition HTML and nothing else. You run N-up, one frame each — siblings build the others. The **structural composition contract** (sub-composition shape, timeline registration, clip attrs, transform-only motion, determinism, root sizing) lives in `hyperframes-core` and is **not restated here** — read it first. This file carries only what's specific to a PR-to-video frame. Tempted to add a generic GSAP / timeline rule here? Wrong home — it belongs in `hyperframes-core`. +> You build the small batch of frame composition files assigned to you and nothing else. At most three workers run; each reads shared context once, then builds its packet paths sequentially. The **structural composition contract** is compacted into each packet. This file carries only what's specific to a PR-to-video frame. -**INPUT** — your dispatch context provides: +**INPUT** — your dispatch context provides `PROJECT_DIR` plus one or more bounded packet paths under `.hyperframes/frame-packets/`. Read shared `frame.md` once, then process the packets in order. Never open the full `STORYBOARD.md`, `capture/diff.patch`, or `capture/extracted/visible-text.txt`; the orchestrator already selected the exact source excerpt and put it in each code frame's packet. + +Each packet provides: - `PROJECT_DIR` — the project root; all paths are relative to it. - `frame_id` — e.g. `04-the-fix`. Use it **verbatim** as the composition id, the `window.__timelines` key, and the file name (`compositions/frames/04-the-fix.html`) — that path **is** the frame's `src` in `STORYBOARD.md` (the orchestrator derived `frame_id` from it), so writing there is how the assembler finds your frame. -- Your **`## Frame N` block** in `STORYBOARD.md` (read it; never write to that file — see below): +- Your exact **`## Frame N` block** (already extracted from `STORYBOARD.md`; never write to that file — see below): - `scene` — a one-line contact-sheet caption. **Design intent, never visible DOM text.** - `voiceover` — the narration line. **Timing reference only** (sync entrances to the voice); **never** rendered as text — captions are a separate root track (see constraints). - `duration` — your render length in seconds. **Fixed upstream; never change it or tween to fill a different length.** @@ -24,7 +26,7 @@ **Retry** — if your context carries lint / validate feedback from a prior pass, read it first and re-author so none of those findings recur; treat each as a hard constraint. -**OUTPUT** — `compositions/frames/.html`, one self-contained sub-composition. Writing it (past the self-check below) is your **terminal action** — you do not edit `STORYBOARD.md`, mint audio, assemble the index, run the CLI, or report back. The orchestrator picks up the file and marks the frame's `status`. +**OUTPUT** — one `compositions/frames/.html` per assigned packet, each a self-contained sub-composition. After the last assigned file passes the self-check, stop — you do not edit `STORYBOARD.md`, mint audio, assemble the index, run the CLI, or report back. The orchestrator picks up the files and marks their `status`. ## Mostly invented — you build the visual (except code blocks + the credits avatars) @@ -33,7 +35,7 @@ A PR video is **mostly invented**: there are **no screenshots and no captured UI ## PR code beats, mechanism beats + the credits close - **Code beats (`diff` / `before_after` / a new-code reveal) — use the named `code-*` block, don't hand-build code motion.** Your `## Frame N` `scene` / `focal` names which block (e.g. `code-diff`, `code-morph`, `code-typing`); the orchestrator has already installed it (Step 5 pre-install). Read **`code-vocabulary.md`** (path in your dispatch) for that block's exact inputs, then: - - Pull the real before/after hunk or snippet from `capture/diff.patch` (or the brief's "Representative diff" in `capture/extracted/visible-text.txt`). + - Use only the packet's `### Source excerpt`. It is the real before/after hunk selected upstream. Never reopen the full diff or brief. - Fill the block's `window.__TOKENS` with that real code (the baked Shiki tokens) and set `window.__BLOCK` (effect, `line`, `duration`) **so the full block completes within the frame's `data-duration`** — a long snippet at the block's default per-character cadence overruns a short frame (the code never finishes typing). `code-diff` / `code-morph` need **2 states** (before, after); the others take one. **Line indexing differs — `code-highlight` is 0-based, `code-scroll` 1-based** — don't off-by-one. - Integrate the filled block as **this frame's composition** per `hyperframes-core`'s sub-composition contract: its `data-composition-id` and its `window.__timelines[...]` key must both be your **``** (the block ships its own id + paused timeline; rename both to match the frame contract). The block already renders an editor window (titlebar / filename) reading as claude's navy **Code Surface** — set the filename + any `+N/−M` chrome from the `scene`. - **The block owns the code animation; your Scene windows choreograph the surrounding Code Surface** — the navy window seating in, the file header typing on, the camera settling onto the hunk, a coral underline on the landed line. **Do not re-specify the code motion** (the block is the development beat). A code beat is usually `blueprint: compose`. @@ -69,10 +71,10 @@ Generic seek-safety + structure live in `hyperframes-core` (read it; not restate ## Workflow -1. **Read** — `hyperframes-core`'s composition contract (the structural law), then `frame.md` (the look) and your `## Frame N` block (the shot sequence + `blueprint:` / `focal:` / `roles:`). **Then read the blueprint template** `../hyperframes-animation/blueprints/.md` (skip if `compose`) and **open the rule recipe `RULES_DIR/.md` for every named motion** in the Scene lines; **for a code beat, read `code-vocabulary.md`** for the block's inputs. You reproduce these mechanics, not improvise them. Internalize the self-check codes below before you write — most lethal is **template transport**: every `