fix(skill): w2h-verify v2 — kill false positives from real-agent debrief

A fresh agent session ran the v1 verify script and the disclosure pasted
into their final summary showed 3 FAIL rows for things that weren't
actually defects:

  Headline font-size: flagged Beat 2 (wordmark SVG), Beat 3 (UI grid),
    Beat 5 (terminal). None of these legitimately have text headlines.
  Timeline coverage: flagged 5/6 beats because the script's regex only
    saw `tl.X(..., 2.5)` literal positions and missed forEach loops,
    variable-position tweens, and long-duration scaler tweens.
  Beat durations: flagged 2 beats because my "duration X.Xs near beat
    label" fallback false-matched non-beat durations
    (e.g., "shader runs — duration 0.7s" near a "Beat 1" mention).

The agent had to write ~5 paragraphs defensively justifying each false
FAIL. That's friction we can fix.

Tested against the agent's actual project (huly-launch-v4): went from
4 FAIL (3 false positives + 1 real bare-table parser miss) to 0 FAIL.
Also re-verified huly-v3 still correctly catches its 3 real issues
(48px wordmark, missing shaders, 3 SFX drifts) — no regression.

**Brand visuals check**

Switched from "≥30% asset usage" (gameable, rewards quantity over quality)
to "at least 1 beat references a captured hero/image/svg" — quality
signal that's cheap to satisfy when real, hard to fake. Excludes fonts,
logos, favicons, contact-sheets.

**Headline check**

Now only flags beats where the LARGEST font-size is in the 40–<80px
range — the "aspiring headline but too small" zone. Below 40px = beat
has no text headline by design (terminal, UI labels, SVG-only); skip.
≥80px = proper headline; pass. Eliminates the false positives on
SVG-dominated and UI-grid beats while still catching the real "headline
too small" failure (Beat 4 at 72px in this run; Beat 1 wordmark at 48px
in another).

**Timeline coverage check**

Three improvements:
1. Detects forEach loops + for-loops containing tl.X() calls — beats
   with these have events at positions the static parser can't read;
   mark as INFO-skipped rather than failed.
2. Detects long-duration tweens — if a single tween's duration covers
   ≥70% of the beat duration (camera dolly, breathing animation), the
   beat has full coverage via persistent motion; skip the position check.
3. New paren-balanced parser for extracting tl.X() position arguments —
   the v1 regex was matching `rgba(86,131,218,0.35)` and capturing 0.35
   as a tween position. The new parser walks paren depth and only
   captures top-level trailing numeric args. No more rgba false matches.

**Shader transitions check**

Two fixes:
1. Filter out inventory lines — lines listing 3+ shader names are
   "what's available," not "what's planned for use." Real use
   mentions one or two shaders per line.
2. Apply the same SFX-context exclusion to the declared side that the
   present-check side already had — "glitch" inside `sfx/glitch-1.mp3`
   no longer counts as a declared shader transition.

For huly-v3: was 6 declared (1 phantom from inventory + 5 + glitch
from SFX), now 2 declared (light-leak, cinematic-zoom) — matches the
storyboard's actual plan.

**Beat duration check**

1. Dropped the "duration X.Xs within 200 chars of beat label" fallback
   — too loose; matched shader durations, animation durations, anything
   labeled "duration". This was the source of the 0.70s misread in the
   debrief.
2. Added a bare-number timing-table parser for the format
   `| 1 | 0.00s | 5.20s | 5.20s | ... |` (with optional `>` blockquote
   prefix). Computes duration = end - start.
3. Added a negative lookahead so `\bB3\b` doesn't false-match "B3.1"
   sub-beats and grab the wrong row.
4. Filter buildBeatIds to only numbered beats — skips the root
   composition (`data-composition-id="main"`) so it doesn't inflate
   "parseable" count.

**Brand visuals + asset count**

Excluded fonts/ subdirectory (always-used via @font-face → would
always pass) and contact-sheet-*.jpg (pipeline outputs, not website
inputs). Both inflated the denominator and weakened the signal.

**Edge case fixes**

- Removed `basename` unused import (oxlint).
- Fixed shader-name substring overlap: longest-name-first matching so
  "cross-warp-morph" doesn't double-count as "cross-warp".
- SFX timestamps now collect ALL audio tags per file (multi-timestamp
  SFX like click×3); picks closest index timestamp to each storyboard
  timestamp instead of just keeping the last.

**Step 6 doc**

Updated the skill's "w2h-verify — the source of truth" section to
describe the new checks accurately and what failure mode each catches.

2 files changed, +486/-109. Format + lint clean.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
ukimsanov
2026-05-22 09:03:35 -07:00
co-authored by Claude Opus 4.7
parent 190f1ec71a
commit f4a7961bc1
2 changed files with 490 additions and 113 deletions
@@ -27,12 +27,14 @@ Score each item 15. If any item scores below 3, fix it before continuing. **D
The skill ran for months on agents reading "REQUIRED" and skipping anyway. The verify script ends that — it computes facts the agent cannot fudge:
- **Asset usage %** (assets referenced in compositions ÷ assets captured)
- **Shader transitions consistency** (shaders declared in STORYBOARD.md vs shaders present in index.html)
- **SFX timestamp drift** (storyboard `t=X.Xs` vs index.html `data-start=X.X`)
- **animation-map.json existence**
- **Rendered MP4 existence**
- **Required artifacts present** (STORYBOARD.md, DESIGN.md, SCRIPT.md, index.html)
- **Brand visuals used** — at least 1 beat must reference a captured `hero-*`, `image-*`, or `svgs/*.svg` asset (logo doesn't count). Catches the "9% asset usage / brand isn't visually present" failure.
- **Headline font-size** — per-beat, the largest CSS `font-size` must be ≥80px. Catches the "headlines too small to read" failure that surfaces later as inspect `clipped_text` errors.
- **Timeline coverage** — per-beat, GSAP event positions must span ≥70% of the beat's `data-duration`. Catches "webpage not shot" failures where the beat has entrance tweens then goes static.
- **Shader transitions consistency** — shaders declared in STORYBOARD.md must appear in index.html (with HyperShader runtime present, not just as SFX file references).
- **SFX timestamp drift** — storyboard `t=X.Xs` vs index.html `data-start=X.X`, picks the closest index timestamp per file for multi-timestamp SFX.
- **Beat duration consistency** — storyboard's beat ranges (`B4 — Name | 16.600 21.000s |`) must match `data-duration` in index.html within ±0.5s. Catches storyboard staleness.
- **Rendered MP4 existence** — INFO only; flagged when claiming verified motion without rendering.
Run it as the LAST gate in your DoD pass, after fixing everything else:
@@ -1,33 +1,33 @@
#!/usr/bin/env node
// w2h-verify.mjs — verification report for a website-to-hyperframes project.
//
// Outputs facts the agent cannot fudge: asset usage %, storyboard-vs-build
// consistency, artifact existence. Result becomes the Step 6 deliverable —
// paste verbatim into the final user-facing summary.
// Computes quality signals the agent cannot fudge. Each check is designed to
// catch a specific failure mode observed across three real agent debriefs.
// Result becomes the Step 6 deliverable — paste verbatim into the final
// user-facing summary so the user sees exactly what shipped.
//
// This script does PURE FILE ANALYSIS — it does not spawn lint/inspect.
// Run those separately via the CLI and include their summaries alongside.
// Pure file analysis. No shell spawns. Run lint + inspect separately and
// paste alongside.
//
// Usage:
// node skills/website-to-hyperframes/scripts/w2h-verify.mjs <project-dir>
//
// Exit codes:
// 0 = all gates pass
// 1 = one or more gates failed (final summary must disclose)
// 2 = script error (project-dir not found, etc.)
// 1 = one or more gates failed
// 2 = script error (project-dir missing, etc.)
import { readFile, readdir } from "node:fs/promises";
import { join, resolve, basename } from "node:path";
import { join, resolve } from "node:path";
import { existsSync } from "node:fs";
const PROJECT_DIR = resolve(process.argv[2] || ".");
// Thresholds — adjust here, not by interpretation.
const ASSET_USAGE_MIN_PCT = 30;
// Thresholds — change here, not by interpretation.
const HEADLINE_MIN_PX = 80; // 80px floor for primary headline at 1920×1080
const TIMELINE_COVERAGE_MIN = 0.7; // max GSAP event position must reach ≥70% of beat duration
const SFX_DRIFT_TOLERANCE_S = 0.5;
const ASSET_EXTS = new Set([".jpg", ".jpeg", ".png", ".svg", ".webp", ".gif", ".mp4", ".webm"]);
const ASSET_EXCLUDE_PATTERNS = [/favicon/i, /apple-touch-icon/i];
const BEAT_DURATION_DRIFT_TOLERANCE_S = 0.5;
const SHADER_NAMES = [
"cross-warp-morph",
@@ -56,12 +56,14 @@ async function main() {
}
const results = [];
results.push(await checkAssetUsage());
results.push(await checkRequiredArtifacts());
results.push(await checkBrandVisualsUsed());
results.push(await checkPerBeatHeadlineSize());
results.push(await checkPerBeatTimelineCoverage());
results.push(await checkShaderTransitionsConsistency());
results.push(await checkSfxTimestampConsistency());
results.push(await checkAnimationMapExists());
results.push(await checkBeatDurationConsistency());
results.push(await checkMp4Exists());
results.push(await checkStoryboardArtifactExists());
printReport(results);
@@ -71,48 +73,220 @@ async function main() {
// ─── Checks ──────────────────────────────────────────────────────────────────
async function checkAssetUsage() {
const captureDir = join(PROJECT_DIR, "capture", "assets");
if (!existsSync(captureDir)) {
return {
name: "Asset usage",
status: "INFO",
detail: "No capture/assets/ directory — capture may not have run",
};
async function checkRequiredArtifacts() {
const required = ["STORYBOARD.md", "DESIGN.md", "SCRIPT.md", "index.html"];
const missing = required.filter((f) => !existsSync(join(PROJECT_DIR, f)));
if (missing.length === 0) {
return { name: "Required artifacts", status: "PASS", detail: required.join(", ") };
}
return {
name: "Required artifacts",
status: "FAIL",
detail: `missing: ${missing.join(", ")}`,
};
}
// Catches the biggest failure mode: agent uses only the logo and rebuilds
// every "hero illustration" from CSS, ignoring the brand's actual visual identity.
async function checkBrandVisualsUsed() {
const compositions = await readBeatCompositions();
if (compositions.length === 0) {
return { name: "Brand visuals used", status: "INFO", detail: "no beat compositions found" };
}
const captured = await listAssetFiles(captureDir);
const compositionsDir = join(PROJECT_DIR, "compositions");
const referencedSet = new Set();
const filesToScan = [join(PROJECT_DIR, "index.html")];
if (existsSync(compositionsDir)) {
const compFiles = (await readdir(compositionsDir)).filter((f) => f.endsWith(".html"));
for (const f of compFiles) filesToScan.push(join(compositionsDir, f));
}
for (const f of filesToScan) {
if (!existsSync(f)) continue;
const content = await readFile(f, "utf-8");
for (const asset of captured) {
if (content.includes(asset.path) || content.includes(basename(asset.path))) {
referencedSet.add(asset.path);
}
// A "brand visual" is anything captured under capture/assets/ that ISN'T:
// - a font file
// - a logo file (filename contains "logo")
// - a favicon / apple-touch-icon
// i.e., hero-*.jpg, image-*.png, illustrations, photographs, svgs/<icons>.svg
const brandRegex =
/capture\/assets\/(?!fonts\/)(?:svgs\/)?(?!.*(?:logo|favicon|apple-touch-icon))[\w-]+\.(?:jpg|jpeg|png|svg|webp|gif)/gi;
const beatsUsingBrand = [];
for (const beat of compositions) {
const matches = beat.content.match(brandRegex) || [];
if (matches.length > 0) {
beatsUsingBrand.push({ beat: beat.name, count: new Set(matches).size });
}
}
const total = captured.length;
const used = referencedSet.size;
const pct = total > 0 ? Math.round((used * 100) / total) : 0;
const pass = pct >= ASSET_USAGE_MIN_PCT;
const pass = beatsUsingBrand.length >= 1;
if (pass) {
return {
name: "Brand visuals used",
status: "PASS",
detail: `${beatsUsingBrand.length}/${compositions.length} beats reference a hero/image/svg captured asset`,
extra:
beatsUsingBrand.length === 1
? "Only 1 beat uses a captured visual — consider whether the brand's hero illustrations or signature graphics fit other beats too."
: null,
};
}
return {
name: "Asset usage",
name: "Brand visuals used",
status: "FAIL",
detail: `0/${compositions.length} beats reference any hero-*/image-*/svgs/ captured asset (logo doesn't count)`,
extra:
"Open capture/assets/contact-sheet-*.jpg and capture/assets/svgs/contact-sheet-*.jpg. The brand's actual visuals are sitting there. Rebuilding them in CSS erases what makes the brand recognizable.",
};
}
// Catches "headlines too small to read" — inspect typically catches the
// overflow afterward, but this catches the cause earlier.
async function checkPerBeatHeadlineSize() {
const compositions = await readBeatCompositions();
if (compositions.length === 0) {
return { name: "Headline font-size", status: "INFO", detail: "no beat compositions found" };
}
// Only flag beats where the LARGEST font-size is in the "aspiring headline
// but too small" range (40<80px). Below 40px = the beat has no headline by
// design (UI labels, code text, decorative). ≥80px = proper headline.
// This skips legitimate non-headline beats (terminal beats, SVG-only beats,
// pure-image beats) without losing the real "headline too small" signal.
const HEADLINE_FLOOR_FOR_CHECK = 40;
const offenders = [];
const skipped = [];
for (const beat of compositions) {
const sizes = [...beat.content.matchAll(/font-size:\s*(\d+(?:\.\d+)?)px/g)].map((m) =>
parseFloat(m[1]),
);
if (sizes.length === 0) {
skipped.push({ beat: beat.name, reason: "no font-size declared" });
continue;
}
const max = Math.max(...sizes);
if (max < HEADLINE_FLOOR_FOR_CHECK) {
skipped.push({ beat: beat.name, reason: `largest font is ${max}px — no headline by design` });
continue;
}
if (max < HEADLINE_MIN_PX) {
offenders.push({ beat: beat.name, maxSize: max });
}
}
const pass = offenders.length === 0;
const checked = compositions.length - skipped.length;
return {
name: "Headline font-size",
status: pass ? "PASS" : "FAIL",
detail: `${used}/${total} (${pct}%) — target ≥${ASSET_USAGE_MIN_PCT}%`,
detail: pass
? `${checked}/${compositions.length} beats with a headline-sized text element, all ≥${HEADLINE_MIN_PX}px`
: `${offenders.length} beat(s) with headline-sized text below ${HEADLINE_MIN_PX}px floor`,
extra: pass
? null
: `Unused brand assets are sitting in capture/assets/. The brand isn't visually present at ${pct}%.`,
: [
...offenders.map((o) => `${o.beat}: largest font-size is ${o.maxSize}px`),
...(skipped.length > 0
? [
`(skipped ${skipped.length} beat(s) with no headline-sized text: ${skipped.map((s) => s.beat).join(", ")})`,
]
: []),
].join("\n "),
};
}
// Catches "webpage not shot" failures — entrance tweens in the first second
// then nothing. Snapshots look fine (static end state) but motion is dead.
async function checkPerBeatTimelineCoverage() {
const compositions = await readBeatCompositions();
const beatDurations = await readBeatDurationsFromIndex();
if (compositions.length === 0) {
return { name: "Timeline coverage", status: "INFO", detail: "no beat compositions found" };
}
const offenders = [];
const skipped = [];
// Pattern detectors: beats that use these idioms have events at positions
// the static parser can't read (loop iterators, variable arithmetic).
// Don't flag them as "webpage not shot" — they have events the parser
// simply can't see. Note: single-tween yoyo/repeat is NOT enough to skip
// — it only oscillates one element, not the whole beat.
const dynamicPatterns = [
{
name: "forEach with tweens",
re: /\.forEach\s*\([^{]*\{[\s\S]{0,2000}?\btl\.(?:to|set|fromTo|from)\(/,
},
{
name: "for-loop with tweens",
re: /\bfor\s*\([^)]*\)\s*\{[\s\S]{0,2000}?\btl\.(?:to|set|fromTo|from)\(/,
},
];
for (const beat of compositions) {
const beatId = beat.name.replace(/\.html$/, "");
const dur = beatDurations[beatId] ?? beatDurations[beat.name];
if (!dur) {
skipped.push({ beat: beat.name, reason: "no data-duration in index.html" });
continue;
}
// Check for dynamic patterns first — if present, coverage cannot be
// statically determined; treat as informational, not a failure.
const matchedDynamic = dynamicPatterns.find((p) => p.re.test(beat.content));
if (matchedDynamic) {
skipped.push({
beat: beat.name,
reason: `dynamic event pattern detected (${matchedDynamic.name}) — coverage not statically measurable`,
});
continue;
}
// Long-duration tween check: if there's a tween with duration ≥ 70% of
// beat duration, that single tween covers the whole beat — likely a
// camera dolly, breathing animation, or persistent motion. Skip the
// position-based coverage check.
const durationRe = /\btl\.(?:to|set|fromTo|from)\([\s\S]{0,500}?duration:\s*([0-9.]+)/g;
let dm;
let hasLongTween = false;
let longTweenDur = 0;
while ((dm = durationRe.exec(beat.content)) !== null) {
const d = parseFloat(dm[1]);
if (d >= dur * TIMELINE_COVERAGE_MIN) {
hasLongTween = true;
longTweenDur = d;
break;
}
}
if (hasLongTween) {
skipped.push({
beat: beat.name,
reason: `long-duration tween covers ${longTweenDur.toFixed(2)}s of ${dur.toFixed(2)}s beat — full coverage via persistent motion`,
});
continue;
}
// Extract GSAP event positions for static cases.
// Use balanced-paren scanning so multi-line calls and rgba(...) values
// inside option objects don't false-match.
const positions = extractTopLevelPositionArgs(beat.content);
if (positions.length === 0) {
offenders.push({ beat: beat.name, reason: "no GSAP events with explicit position found" });
continue;
}
const maxPos = Math.max(...positions);
const coverage = maxPos / dur;
if (coverage < TIMELINE_COVERAGE_MIN) {
offenders.push({
beat: beat.name,
reason: `static events span 0${maxPos.toFixed(2)}s of ${dur.toFixed(2)}s beat (${Math.round(coverage * 100)}%)`,
});
}
}
const pass = offenders.length === 0;
const checked = compositions.length - skipped.length;
return {
name: "Timeline coverage",
status: pass ? "PASS" : "FAIL",
detail: pass
? `${checked}/${compositions.length} statically-measurable beats span ≥${Math.round(TIMELINE_COVERAGE_MIN * 100)}% of duration`
: `${offenders.length} beat(s) below ${Math.round(TIMELINE_COVERAGE_MIN * 100)}% static coverage — likely "webpage not shot" failures`,
extra:
[
...offenders.map((o) => `${o.beat}: ${o.reason}`),
...(skipped.length > 0 ? skipped.map((s) => `(skipped ${s.beat}: ${s.reason})`) : []),
].join("\n ") || null,
};
}
@@ -130,14 +304,33 @@ async function checkShaderTransitionsConsistency() {
const storyboard = await readFile(storyboardPath, "utf-8");
const index = await readFile(indexPath, "utf-8");
// Match longest names first so "cross-warp-morph" doesn't double-count as "cross-warp" too.
// For each shader name, count it as "declared" only if it appears in a
// transition-use context — NOT in an inventory list (3+ names on one line)
// and NOT exclusively as part of an SFX filename (sfx/glitch-1.mp3).
const sbLines = storyboard.split("\n");
// Longest-name matching to avoid substring double-counting.
const sortedNames = [...SHADER_NAMES].sort((a, b) => b.length - a.length);
let sbScratch = storyboard;
const seen = new Set();
const declared = [];
for (const name of sortedNames) {
if (sbScratch.includes(name)) {
let foundInUseContext = false;
for (const line of sbLines) {
if (seen.has(name)) break;
if (!line.includes(name)) continue;
// Skip inventory listing lines (3+ shader names on one line).
const namesOnLine = SHADER_NAMES.filter((n) => line.includes(n)).length;
if (namesOnLine >= 3) continue;
// Skip lines where the shader name only appears in SFX filename context.
const sfxContext = line.includes(`sfx/${name}`) || line.includes(`sfx-${name}`);
// Count this name if it appears in a non-SFX, non-inventory line.
if (!sfxContext) {
foundInUseContext = true;
break;
}
}
if (foundInUseContext) {
declared.push(name);
sbScratch = sbScratch.split(name).join(""); // strip all matches before next probe
seen.add(name);
}
}
if (declared.length === 0) {
@@ -148,8 +341,20 @@ async function checkShaderTransitionsConsistency() {
};
}
const present = declared.filter((name) => index.includes(name));
const missing = declared.filter((name) => !index.includes(name));
// A shader is "present" only if HyperShader runtime is in index.html AND the
// shader name appears in a line that is NOT an SFX reference.
const hasHyperShader = /HyperShader\s*[(.]/.test(index);
const indexLines = index.split("\n");
const present = !hasHyperShader
? []
: declared.filter((name) =>
indexLines.some((line) => {
if (!line.includes(name)) return false;
if (line.includes(`sfx/${name}`) || line.includes(`sfx-${name}`)) return false;
return true;
}),
);
const missing = declared.filter((name) => !present.includes(name));
const pass = missing.length === 0;
return {
@@ -158,7 +363,7 @@ async function checkShaderTransitionsConsistency() {
detail: `STORYBOARD declared ${declared.length}, ${present.length} present in index.html, ${missing.length} missing`,
extra: pass
? null
: `Missing from build: ${missing.join(", ")}. STORYBOARD.md and index.html disagree.`,
: `Missing from build: ${missing.join(", ")}. STORYBOARD.md and index.html disagree — either re-add the transitions or update STORYBOARD.md.`,
};
}
@@ -166,7 +371,11 @@ async function checkSfxTimestampConsistency() {
const storyboardPath = join(PROJECT_DIR, "STORYBOARD.md");
const indexPath = join(PROJECT_DIR, "index.html");
if (!existsSync(storyboardPath) || !existsSync(indexPath)) {
return { name: "SFX timestamps", status: "INFO", detail: "STORYBOARD.md or index.html missing" };
return {
name: "SFX timestamps",
status: "INFO",
detail: "STORYBOARD.md or index.html missing",
};
}
const storyboard = await readFile(storyboardPath, "utf-8");
@@ -175,14 +384,19 @@ async function checkSfxTimestampConsistency() {
const sfxRefs = [];
for (const line of storyboard.split("\n")) {
const fileMatch = line.match(/sfx\/([\w-]+\.mp3)/);
const timeMatch = line.match(/\|\s*(\d+(?:\.\d+)?)s?\s*\|/);
// Require trailing `s` so we capture the time column, not the volume column.
const timeMatch = line.match(/\|\s*(\d+(?:\.\d+)?)s\b/);
if (fileMatch && timeMatch) {
sfxRefs.push({ file: fileMatch[1], storyboardT: parseFloat(timeMatch[1]) });
}
}
if (sfxRefs.length === 0) {
return { name: "SFX timestamps", status: "INFO", detail: "No SFX entries detected in STORYBOARD.md" };
return {
name: "SFX timestamps",
status: "INFO",
detail: "No SFX entries detected in STORYBOARD.md",
};
}
const indexSfx = new Map();
@@ -190,7 +404,8 @@ async function checkSfxTimestampConsistency() {
/<audio[^>]*src=["'](?:[^"']*\/)?sfx\/([\w-]+\.mp3)["'][^>]*?data-start=["']([0-9.]+)["']/g;
let m;
while ((m = audioRegex.exec(index)) !== null) {
indexSfx.set(m[1], parseFloat(m[2]));
if (!indexSfx.has(m[1])) indexSfx.set(m[1], []);
indexSfx.get(m[1]).push(parseFloat(m[2]));
}
const drifts = [];
@@ -200,39 +415,154 @@ async function checkSfxTimestampConsistency() {
missing.push(ref.file);
continue;
}
const indexT = indexSfx.get(ref.file);
const drift = Math.abs(indexT - ref.storyboardT);
const indexTs = indexSfx.get(ref.file);
const closest = indexTs.reduce((best, t) =>
Math.abs(t - ref.storyboardT) < Math.abs(best - ref.storyboardT) ? t : best,
);
const drift = Math.abs(closest - ref.storyboardT);
if (drift > SFX_DRIFT_TOLERANCE_S) {
drifts.push({ file: ref.file, storyboardT: ref.storyboardT, indexT, drift });
drifts.push({ file: ref.file, storyboardT: ref.storyboardT, indexT: closest, drift });
}
}
const indexCount = [...indexSfx.values()].reduce((sum, arr) => sum + arr.length, 0);
const pass = missing.length === 0 && drifts.length === 0;
return {
name: "SFX timestamps",
status: pass ? "PASS" : "FAIL",
detail: `${sfxRefs.length} SFX in STORYBOARD · ${indexSfx.size} in index.html · ${missing.length} missing · ${drifts.length} drifted >${SFX_DRIFT_TOLERANCE_S}s`,
detail: `${sfxRefs.length} SFX in STORYBOARD · ${indexCount} in index.html · ${missing.length} missing · ${drifts.length} drifted >${SFX_DRIFT_TOLERANCE_S}s`,
extra: pass
? null
: [
...missing.map((f) => `MISSING in index.html: ${f}`),
...drifts.map(
(d) =>
`DRIFT: ${d.file} storyboard=${d.storyboardT}s index=${d.indexT}s drift=${d.drift.toFixed(2)}s`,
`DRIFT: ${d.file} storyboard=${d.storyboardT}s closest index=${d.indexT}s drift=${d.drift.toFixed(2)}s`,
),
].join("\n "),
};
}
async function checkAnimationMapExists() {
const path = join(PROJECT_DIR, "animation-map.json");
if (existsSync(path)) {
return { name: "animation-map.json", status: "PASS", detail: "exists" };
// Catches storyboard staleness on beat timings — agent shipped with different
// beat durations than the storyboard documented, leaving the spec lying.
async function checkBeatDurationConsistency() {
const storyboardPath = join(PROJECT_DIR, "STORYBOARD.md");
if (!existsSync(storyboardPath)) {
return { name: "Beat durations", status: "INFO", detail: "STORYBOARD.md missing" };
}
const storyboard = await readFile(storyboardPath, "utf-8");
const indexDurations = await readBeatDurationsFromIndex();
// Filter to only numbered beats (beat-1, beat-2, ...). Skip "main" / root
// composition and any non-numbered placeholders.
const buildBeatIds = Object.keys(indexDurations).filter((id) => /^beat-\d+/i.test(id));
if (buildBeatIds.length === 0) {
return {
name: "Beat durations",
status: "INFO",
detail: "no numbered beat data-duration in index.html",
};
}
// Parse storyboard beat durations. The expected pattern is a timing-table row:
// | B4 — MetaBrain | 16.600 21.000s | ... |
// We extract startend and compute duration = end - start. Falls back to a
// direct duration mention ("duration: X.Xs") if the range format isn't found.
// A beat with no parseable duration is skipped — we don't flag missing rows.
const drifts = [];
const unparseable = [];
for (const beatId of buildBeatIds) {
const num = beatId.match(/beat-(\d+)/i)?.[1];
if (!num) continue;
let storyT = null;
// Strategy 1: standalone beat row "B4 — Name | 16.600 21.000s |".
// Require `B${num}` to be followed by a non-digit-non-dot char so "B3.1"
// doesn't false-match for B3.
const rangeRe = new RegExp(
`\\bB${num}(?![\\.\\d])[^\\n|]*\\|\\s*(\\d+(?:\\.\\d+)?)s?\\s*[-]\\s*(\\d+(?:\\.\\d+)?)s`,
"i",
);
const rm = storyboard.match(rangeRe);
if (rm) {
storyT = parseFloat(rm[2]) - parseFloat(rm[1]);
} else {
// Strategy 2: sum sub-beats "B${num}.X — ... | start end s |".
// Useful when a beat is broken into sub-rows (B3.1, B3.2, ...) instead
// of having a standalone row.
const subRe = new RegExp(
`\\bB${num}\\.\\d+\\b[^\\n|]*\\|\\s*(\\d+(?:\\.\\d+)?)s?\\s*[-]\\s*(\\d+(?:\\.\\d+)?)s`,
"gi",
);
let sum = 0;
let count = 0;
let mm;
while ((mm = subRe.exec(storyboard)) !== null) {
sum += parseFloat(mm[2]) - parseFloat(mm[1]);
count++;
}
if (count > 0) storyT = sum;
}
if (storyT === null) {
// Strategy 3: bare-number timing table — "| 1 | 0.00s | 5.20s | 5.20s |"
// (Beat | Start | End | Duration). The duration column is the 4th cell,
// OR derive from end - start (columns 2 and 3). Match a row that starts
// with `| <num> |` and has at least 2 time cells.
// Allow optional leading `>` (blockquote) before the pipe.
const tableRe = new RegExp(
`^\\s*>?\\s*\\|\\s*${num}\\s*\\|\\s*(\\d+(?:\\.\\d+)?)s?\\s*\\|\\s*(\\d+(?:\\.\\d+)?)s`,
"im",
);
const tm = storyboard.match(tableRe);
if (tm) {
const start = parseFloat(tm[1]);
const end = parseFloat(tm[2]);
storyT = end - start;
}
}
// Note: removed the previous "duration: X.Xs near beat label" fallback —
// it false-matched non-beat durations (e.g., "shader runs — duration 0.7s"
// near a "Beat 1" mention). If a storyboard's timing format isn't a clean
// range or table row, the beat is reported as unparseable rather than
// guessed at.
if (storyT === null) {
unparseable.push(beatId);
continue;
}
const buildT = indexDurations[beatId];
const drift = Math.abs(storyT - buildT);
if (drift > BEAT_DURATION_DRIFT_TOLERANCE_S) {
drifts.push({ beat: beatId, storyboardDuration: storyT, buildDuration: buildT, drift });
}
}
const parseable = buildBeatIds.length - unparseable.length;
if (drifts.length === 0) {
return {
name: "Beat durations",
status: "PASS",
detail: `${parseable}/${buildBeatIds.length} beats parseable, storyboard durations match within ±${BEAT_DURATION_DRIFT_TOLERANCE_S}s`,
extra:
unparseable.length > 0
? `(skipped: ${unparseable.join(", ")} — could not find duration in STORYBOARD.md)`
: null,
};
}
return {
name: "animation-map.json",
name: "Beat durations",
status: "FAIL",
detail: "missing — run `node <repo>/skills/hyperframes/scripts/animation-map.mjs <project-dir>`",
detail: `${drifts.length} beat(s) drift between STORYBOARD.md and index.html > ${BEAT_DURATION_DRIFT_TOLERANCE_S}s`,
extra: [
...drifts.map(
(d) =>
`${d.beat}: storyboard=${d.storyboardDuration.toFixed(2)}s build=${d.buildDuration}s drift=${d.drift.toFixed(2)}s`,
),
...(unparseable.length > 0
? [`(skipped: ${unparseable.join(", ")} — could not find duration in STORYBOARD.md)`]
: []),
].join("\n "),
};
}
@@ -261,45 +591,89 @@ async function checkMp4Exists() {
};
}
async function checkStoryboardArtifactExists() {
const required = ["STORYBOARD.md", "DESIGN.md", "SCRIPT.md", "index.html"];
const missing = required.filter((f) => !existsSync(join(PROJECT_DIR, f)));
if (missing.length === 0) {
return { name: "Required artifacts", status: "PASS", detail: required.join(", ") };
}
return {
name: "Required artifacts",
status: "FAIL",
detail: `missing: ${missing.join(", ")}`,
};
}
// ─── Helpers ─────────────────────────────────────────────────────────────────
async function listAssetFiles(dir) {
async function readBeatCompositions() {
const dir = join(PROJECT_DIR, "compositions");
if (!existsSync(dir)) return [];
const files = await readdir(dir);
const beats = files.filter((f) => /^beat-/i.test(f) && f.endsWith(".html"));
const out = [];
async function walk(d) {
let entries;
try {
entries = await readdir(d, { withFileTypes: true });
} catch {
return;
}
for (const e of entries) {
const full = join(d, e.name);
if (e.isDirectory()) {
await walk(full);
for (const f of beats) {
const content = await readFile(join(dir, f), "utf-8");
out.push({ name: f, content });
}
return out;
}
// Extract the trailing numeric position argument from each `tl.<method>(...)`
// call in a script. Uses balanced-paren scanning so multi-line calls and
// nested patterns like rgba(86,131,218,0.35) don't false-match.
function extractTopLevelPositionArgs(content) {
const positions = [];
const methodRe = /\btl\.(?:to|set|fromTo|from|call|add)\(/g;
let startMatch;
while ((startMatch = methodRe.exec(content)) !== null) {
let i = startMatch.index + startMatch[0].length;
let depth = 1;
let lastTopLevelCommaIdx = -1;
let inString = false;
let stringChar = null;
while (i < content.length && depth > 0) {
const c = content[i];
if (inString) {
if (c === "\\") {
i += 2;
continue;
}
if (c === stringChar) inString = false;
i++;
continue;
}
const ext = e.name.slice(e.name.lastIndexOf(".")).toLowerCase();
if (!ASSET_EXTS.has(ext)) continue;
if (ASSET_EXCLUDE_PATTERNS.some((re) => re.test(e.name))) continue;
const rel = full.replace(PROJECT_DIR + "/", "");
out.push({ path: rel, name: e.name });
if (c === '"' || c === "'" || c === "`") {
inString = true;
stringChar = c;
i++;
continue;
}
if (c === "(" || c === "{" || c === "[") depth++;
else if (c === ")" || c === "}" || c === "]") {
depth--;
if (depth === 0) break;
} else if (c === "," && depth === 1) lastTopLevelCommaIdx = i;
i++;
}
if (depth === 0 && lastTopLevelCommaIdx > 0) {
const lastArg = content.substring(lastTopLevelCommaIdx + 1, i).trim();
const numMatch = lastArg.match(/^([0-9.]+)$/);
if (numMatch) positions.push(parseFloat(numMatch[1]));
}
}
await walk(dir);
return out;
return positions;
}
// Returns a map of { "beat-1-name": durationInSeconds, ... } from index.html.
async function readBeatDurationsFromIndex() {
const indexPath = join(PROJECT_DIR, "index.html");
if (!existsSync(indexPath)) return {};
const content = await readFile(indexPath, "utf-8");
const map = {};
// Pattern: <div data-composition-id="beat-N-name" ... data-duration="5.5" ...>
// OR: data-composition-src="compositions/beat-N-name.html" ... data-duration="5.5"
const divRegex = /<div[^>]*?>/g;
let m;
while ((m = divRegex.exec(content)) !== null) {
const tag = m[0];
const idMatch = tag.match(/data-composition-id=["']([^"']+)["']/);
const srcMatch = tag.match(/data-composition-src=["'][^"']*?\/?(beat-[\w-]+)\.html["']/);
const durMatch = tag.match(/data-duration=["']([0-9.]+)["']/);
if (!durMatch) continue;
const dur = parseFloat(durMatch[1]);
if (idMatch) map[idMatch[1]] = dur;
if (srcMatch) map[srcMatch[1]] = dur;
}
return map;
}
// ─── Report ──────────────────────────────────────────────────────────────────
@@ -311,9 +685,7 @@ function printReport(results) {
console.log("");
console.log(`w2h-verify · ${PROJECT_DIR}`);
console.log(line);
console.log(
"Check".padEnd(cols.name) + " │ " + "Status".padEnd(cols.status) + " │ " + "Detail",
);
console.log("Check".padEnd(cols.name) + " │ " + "Status".padEnd(cols.status) + " │ " + "Detail");
console.log(line);
for (const r of results) {
@@ -326,12 +698,13 @@ function printReport(results) {
(r.detail || ""),
);
if (r.extra) {
const indent = " ".repeat(cols.name + cols.status + 6 + 4);
console.log(
"".padEnd(cols.name) +
" │ " +
"".padEnd(cols.status) +
" │ " +
r.extra.split("\n").join("\n" + " ".repeat(cols.name + cols.status + 6 + 4)),
r.extra.split("\n").join("\n" + indent),
);
}
}
@@ -348,7 +721,9 @@ function printReport(results) {
console.log("summary's \"What I did NOT verify\" section so the user knows what's broken.");
} else {
console.log("");
console.log("All gates pass. Paste this report into your final user-facing summary as evidence.");
console.log(
"All gates pass. Paste this report into your final user-facing summary as evidence.",
);
}
console.log("");
}