diff --git a/packages/core/src/script/assemble.ts b/packages/core/src/script/assemble.ts
index 42bccb752..cafd23f58 100644
--- a/packages/core/src/script/assemble.ts
+++ b/packages/core/src/script/assemble.ts
@@ -10,6 +10,14 @@ import type { ImageEntry, ImageManifest } from "../images/index.js";
import type { VisualDirectionPlan } from "./visualDirector.js";
import { readSfxManifest, resolveSfxStartForScene, type SfxEntry } from "./sfx/manifest.js";
import { readMusicManifest, resolveMusicSpan, type SceneSpan } from "./music/manifest.js";
+import { getCoreVersion } from "./coreVersion.js";
+
+/**
+ * Stamp constants. Studio-side staleness detection looks for these exact
+ * names — keep them in lockstep with `assembleStaleness.ts`.
+ */
+export const ASSEMBLED_AT_META = "hyperframes:assembled-at";
+export const CORE_VERSION_META = "hyperframes:core-version";
export interface AssembleOptions {
projectDir: string;
@@ -259,11 +267,21 @@ export function assembleMaster(planned: PlannedScript, opts: AssembleOptions): A
// We do not write our own master timeline JS — the runtime + studio player
// drive playback, scrubbing, and audio sync. Per-scene timelines are
// registered by each template's inline
diff --git a/packages/core/src/script/assembleStaleness.test.ts b/packages/core/src/script/assembleStaleness.test.ts
new file mode 100644
index 000000000..9e8cb7857
--- /dev/null
+++ b/packages/core/src/script/assembleStaleness.test.ts
@@ -0,0 +1,245 @@
+import { describe, it, expect, beforeEach, afterEach } from "vitest";
+import { mkdirSync, mkdtempSync, rmSync, utimesSync, writeFileSync } from "node:fs";
+import { tmpdir } from "node:os";
+import { join } from "node:path";
+import { ASSEMBLED_AT_META, CORE_VERSION_META } from "./assemble.js";
+import { computeAssemblyStatus, extractMetaContent, readStamp } from "./assembleStaleness.js";
+
+let tmp: string;
+
+beforeEach(() => {
+ tmp = mkdtempSync(join(tmpdir(), "hf-staleness-"));
+});
+
+afterEach(() => {
+ rmSync(tmp, { recursive: true, force: true });
+});
+
+function writeHtml(version: string, assembledAt: string, extraHead = ""): void {
+ const html = `
+
+
+
+
+${extraHead}
+`;
+ writeFileSync(join(tmp, "index.html"), html);
+}
+
+function setMtime(absPath: string, msSinceEpoch: number): void {
+ const seconds = msSinceEpoch / 1000;
+ utimesSync(absPath, seconds, seconds);
+}
+
+describe("extractMetaContent", () => {
+ it("extracts the content of a named meta", () => {
+ expect(extractMetaContent('', "x")).toBe("hello");
+ });
+
+ it("returns null when the meta is absent", () => {
+ expect(extractMetaContent('', "x")).toBeNull();
+ });
+
+ it("tolerates single-quoted attributes", () => {
+ expect(extractMetaContent("", "x")).toBe("hi");
+ });
+
+ it("is case-insensitive on the tag/attr names", () => {
+ expect(extractMetaContent('', "x")).toBe("ok");
+ });
+
+ it("never matches when the name has special regex chars but is escaped on lookup", () => {
+ // Confirms we don't treat the name as a regex pattern.
+ expect(extractMetaContent('', "a*b")).toBeNull();
+ expect(extractMetaContent('', "a.b")).toBe("x");
+ });
+});
+
+describe("readStamp", () => {
+ it("returns nulls when the file does not exist", () => {
+ expect(readStamp(join(tmp, "missing.html"))).toEqual({
+ assembledAt: null,
+ coreVersion: null,
+ });
+ });
+
+ it("reads both stamps from a freshly assembled file", () => {
+ writeHtml("1.2.3", "2026-01-01T00:00:00.000Z");
+ expect(readStamp(join(tmp, "index.html"))).toEqual({
+ assembledAt: "2026-01-01T00:00:00.000Z",
+ coreVersion: "1.2.3",
+ });
+ });
+
+ it("returns nulls when stamps are missing (older HTML)", () => {
+ writeFileSync(join(tmp, "index.html"), "");
+ expect(readStamp(join(tmp, "index.html"))).toEqual({
+ assembledAt: null,
+ coreVersion: null,
+ });
+ });
+});
+
+describe("computeAssemblyStatus", () => {
+ it("flags missing index.html as stale with reason no-html", () => {
+ const status = computeAssemblyStatus({
+ projectDir: tmp,
+ currentCoreVersion: "1.0.0",
+ });
+ expect(status.exists).toBe(false);
+ expect(status.stale).toBe(true);
+ expect(status.reasons).toEqual(["no-html"]);
+ });
+
+ it("returns stale=false when stamps match and no source file is newer", () => {
+ writeHtml("1.0.0", "2026-01-01T00:00:00.000Z");
+ const status = computeAssemblyStatus({
+ projectDir: tmp,
+ currentCoreVersion: "1.0.0",
+ });
+ expect(status.exists).toBe(true);
+ expect(status.stale).toBe(false);
+ expect(status.reasons).toEqual([]);
+ expect(status.coreVersionChanged).toBe(false);
+ });
+
+ it("flags core-version-changed when stamp != current", () => {
+ writeHtml("1.0.0", "2026-01-01T00:00:00.000Z");
+ const status = computeAssemblyStatus({
+ projectDir: tmp,
+ currentCoreVersion: "1.1.0",
+ });
+ expect(status.coreVersion).toBe("1.0.0");
+ expect(status.currentCoreVersion).toBe("1.1.0");
+ expect(status.coreVersionChanged).toBe(true);
+ expect(status.reasons).toContain("core-version-changed");
+ expect(status.stale).toBe(true);
+ });
+
+ it("flags no-stamp when an older HTML lacks meta tags", () => {
+ writeFileSync(join(tmp, "index.html"), "");
+ const status = computeAssemblyStatus({
+ projectDir: tmp,
+ currentCoreVersion: "1.0.0",
+ });
+ expect(status.coreVersion).toBeNull();
+ expect(status.coreVersionChanged).toBe(false); // no stamp → no comparison
+ expect(status.reasons).toContain("no-stamp");
+ expect(status.stale).toBe(true);
+ });
+
+ it("flags source-files-newer when script.json is touched after assembly", () => {
+ writeHtml("1.0.0", "2026-01-01T00:00:00.000Z");
+ const htmlPath = join(tmp, "index.html");
+ const scriptPath = join(tmp, "script.json");
+ writeFileSync(scriptPath, "{}");
+ setMtime(htmlPath, Date.now() - 60_000);
+ setMtime(scriptPath, Date.now()); // newer
+ const status = computeAssemblyStatus({
+ projectDir: tmp,
+ currentCoreVersion: "1.0.0",
+ });
+ expect(status.reasons).toContain("source-files-newer");
+ expect(status.sourceFilesNewer).toContain("script.json");
+ expect(status.stale).toBe(true);
+ });
+
+ it("flags newer files inside tracked directories (e.g. assets/images)", () => {
+ writeHtml("1.0.0", "2026-01-01T00:00:00.000Z");
+ const htmlPath = join(tmp, "index.html");
+ mkdirSync(join(tmp, "assets", "images"), { recursive: true });
+ const imgPath = join(tmp, "assets", "images", "hero.webp");
+ writeFileSync(imgPath, "fake bytes");
+ setMtime(htmlPath, Date.now() - 60_000);
+ setMtime(imgPath, Date.now());
+ const status = computeAssemblyStatus({
+ projectDir: tmp,
+ currentCoreVersion: "1.0.0",
+ });
+ expect(status.reasons).toContain("source-files-newer");
+ // The relative path can use forward slashes on POSIX or backslashes on
+ // Windows; we only assert membership in a normalized form.
+ expect(status.sourceFilesNewer.length).toBeGreaterThan(0);
+ expect(
+ status.sourceFilesNewer.some((p) =>
+ p.replace(/\\/g, "/").endsWith("assets/images/hero.webp"),
+ ),
+ ).toBe(true);
+ });
+
+ it("ignores dotfiles inside tracked directories", () => {
+ writeHtml("1.0.0", "2026-01-01T00:00:00.000Z");
+ const htmlPath = join(tmp, "index.html");
+ mkdirSync(join(tmp, "voiceovers"), { recursive: true });
+ writeFileSync(join(tmp, "voiceovers", ".DS_Store"), "garbage");
+ setMtime(htmlPath, Date.now() - 60_000);
+ setMtime(join(tmp, "voiceovers", ".DS_Store"), Date.now());
+ const status = computeAssemblyStatus({
+ projectDir: tmp,
+ currentCoreVersion: "1.0.0",
+ });
+ expect(status.sourceFilesNewer).toEqual([]);
+ expect(status.stale).toBe(false);
+ });
+
+ it("collects multiple reasons when both core-version and source-files apply", () => {
+ writeHtml("1.0.0", "2026-01-01T00:00:00.000Z");
+ const htmlPath = join(tmp, "index.html");
+ const scriptPath = join(tmp, "script.json");
+ writeFileSync(scriptPath, "{}");
+ setMtime(htmlPath, Date.now() - 60_000);
+ setMtime(scriptPath, Date.now());
+ const status = computeAssemblyStatus({
+ projectDir: tmp,
+ currentCoreVersion: "1.5.0",
+ });
+ expect(status.reasons).toContain("core-version-changed");
+ expect(status.reasons).toContain("source-files-newer");
+ expect(status.message).toContain("core 1.0.0 → 1.5.0");
+ expect(status.message).toContain("script.json");
+ });
+
+ it("caps reported source files to a small list", () => {
+ writeHtml("1.0.0", "2026-01-01T00:00:00.000Z");
+ const htmlPath = join(tmp, "index.html");
+ setMtime(htmlPath, Date.now() - 60_000);
+ // Touch many tracked files.
+ const filesToTouch = [
+ "script.json",
+ "script.generated.json",
+ "visual-direction.json",
+ "DESIGN.md",
+ "DESIGN-ART.md",
+ "RESEARCH.md",
+ ];
+ for (const f of filesToTouch) {
+ const p = join(tmp, f);
+ writeFileSync(p, "x");
+ setMtime(p, Date.now());
+ }
+ const status = computeAssemblyStatus({
+ projectDir: tmp,
+ currentCoreVersion: "1.0.0",
+ });
+ expect(status.sourceFilesNewer.length).toBeLessThanOrEqual(5);
+ });
+
+ it("respects custom htmlPath when project keeps a non-default name", () => {
+ const html = `
+
+
+
+`;
+ mkdirSync(join(tmp, "build"), { recursive: true });
+ writeFileSync(join(tmp, "build", "render.html"), html);
+ const status = computeAssemblyStatus({
+ projectDir: tmp,
+ htmlPath: "build/render.html",
+ currentCoreVersion: "1.0.0",
+ });
+ expect(status.exists).toBe(true);
+ expect(status.htmlPath).toBe("build/render.html");
+ expect(status.coreVersion).toBe("1.0.0");
+ expect(status.stale).toBe(false);
+ });
+});
diff --git a/packages/core/src/script/assembleStaleness.ts b/packages/core/src/script/assembleStaleness.ts
new file mode 100644
index 000000000..c632472d8
--- /dev/null
+++ b/packages/core/src/script/assembleStaleness.ts
@@ -0,0 +1,300 @@
+/**
+ * Assembly-staleness detection — answers the question "is the project's
+ * `index.html` out of date?" The studio shows a "Regenerate" CTA when this
+ * returns stale, with a tooltip explaining why.
+ *
+ * Two signals collapse into one stale flag:
+ *
+ * 1. **Source-files-newer-than-html** — at least one tracked input file
+ * has an mtime later than `index.html`. This catches the common case:
+ * user edited script.json, added an image, generated music, etc., and
+ * forgot to re-assemble.
+ * 2. **Core-version-changed** — `index.html`'s embedded core-version
+ * stamp doesn't match the running `@hyperframes/core` version. This
+ * catches feature/bug-fix drift: e.g. PR #20 fixed letter-dropouts
+ * in hook-bigtext, but a project assembled before that merge still
+ * ships the broken version.
+ *
+ * Both signals are collected (not collapsed) so the UI can explain
+ * precisely what's stale, and the user can decide whether the drift
+ * matters for their workflow.
+ *
+ * The module is pure — file-system reads only, no writes, no LLM calls.
+ * Failure modes (missing index.html, missing stamp, unparseable file)
+ * collapse to a graceful "no stamp / unknown" rather than throwing.
+ */
+
+import { existsSync, readdirSync, readFileSync, statSync } from "node:fs";
+import { join, relative } from "node:path";
+import { ASSEMBLED_AT_META, CORE_VERSION_META } from "./assemble.js";
+import { getCoreVersion } from "./coreVersion.js";
+
+/**
+ * Project-relative paths whose mtime we compare against `index.html`.
+ * Order matters only for the human-readable message — the FIRST file in
+ * this list that's newer wins the "primary culprit" slot. Putting
+ * `script.json` first means the most common case (user edited the
+ * script) is shown clearly.
+ */
+const TRACKED_FILES_RELATIVE: ReadonlyArray = [
+ "script.json",
+ "script.generated.json",
+ "visual-direction.json",
+ "assets/images.json",
+ "assets/sfx/sfx.manifest.json",
+ "assets/music/music.manifest.json",
+ "DESIGN.md",
+ "DESIGN-ART.md",
+ "design-art.md",
+ "RESEARCH.md",
+ "research.md",
+];
+
+/**
+ * Directories scanned recursively for newer files. Adding a new image,
+ * generating SFX, or regenerating voiceovers should mark the HTML stale
+ * even if the manifest itself wasn't touched (e.g. deduped or replaced
+ * in place).
+ */
+const TRACKED_DIRECTORIES_RELATIVE: ReadonlyArray = [
+ "voiceovers",
+ "assets/sfx",
+ "assets/music",
+ "assets/images",
+];
+
+/** Maximum number of "newer source files" we report. The UI shows them
+ * inline — five is more than enough to communicate the issue. */
+const MAX_NEWER_FILES_REPORTED = 5;
+
+export type StaleReason = "no-html" | "no-stamp" | "core-version-changed" | "source-files-newer";
+
+export interface AssemblyStatus {
+ /** Project-relative path of the assembled HTML (default: index.html). */
+ htmlPath: string;
+ /** True iff the HTML file exists at htmlPath. */
+ exists: boolean;
+ /** ISO timestamp from the `hyperframes:assembled-at` meta. null when
+ * the HTML is missing or doesn't carry the stamp. */
+ assembledAt: string | null;
+ /** Core version recorded in the HTML's stamp. null when missing. */
+ coreVersion: string | null;
+ /** Core version of the currently-running `@hyperframes/core`. */
+ currentCoreVersion: string;
+ /** Project-relative paths of files whose mtime exceeds the HTML's. */
+ sourceFilesNewer: ReadonlyArray;
+ /** True when coreVersion is set AND differs from currentCoreVersion. */
+ coreVersionChanged: boolean;
+ /** Top-level summary: should the UI show the Regenerate CTA? */
+ stale: boolean;
+ /** Machine-readable list of reasons (UI maps these to copy). */
+ reasons: ReadonlyArray;
+ /** Human-readable summary, suitable for a tooltip. */
+ message: string;
+}
+
+export interface AssemblyStatusOptions {
+ projectDir: string;
+ /** Default: "index.html". */
+ htmlPath?: string;
+ /**
+ * Test seam — overrides the running core version detection. Production
+ * callers leave this undefined.
+ */
+ currentCoreVersion?: string;
+}
+
+/**
+ * Compute the staleness status for a project's assembled HTML. Pure with
+ * respect to the project (no writes). Designed to run on every studio
+ * refresh and on every Storyline tab mount — no I/O beyond a handful of
+ * stat() calls and one readFileSync() of the HTML head.
+ */
+export function computeAssemblyStatus(opts: AssemblyStatusOptions): AssemblyStatus {
+ const htmlPath = opts.htmlPath ?? "index.html";
+ const absHtml = join(opts.projectDir, htmlPath);
+ const currentCoreVersion = opts.currentCoreVersion ?? getCoreVersion();
+
+ if (!existsSync(absHtml)) {
+ return {
+ htmlPath,
+ exists: false,
+ assembledAt: null,
+ coreVersion: null,
+ currentCoreVersion,
+ sourceFilesNewer: [],
+ coreVersionChanged: false,
+ stale: true,
+ reasons: ["no-html"],
+ message: `No ${htmlPath} found. Generate or assemble the project to produce one.`,
+ };
+ }
+
+ const stamp = readStamp(absHtml);
+ const htmlMtimeMs = safeStatMs(absHtml);
+ const sourceFilesNewer = htmlMtimeMs
+ ? listSourceFilesNewerThan(opts.projectDir, htmlMtimeMs).slice(0, MAX_NEWER_FILES_REPORTED)
+ : [];
+
+ const coreVersionChanged = Boolean(stamp.coreVersion && stamp.coreVersion !== currentCoreVersion);
+ const reasons: StaleReason[] = [];
+ if (!stamp.coreVersion && !stamp.assembledAt) reasons.push("no-stamp");
+ if (coreVersionChanged) reasons.push("core-version-changed");
+ if (sourceFilesNewer.length > 0) reasons.push("source-files-newer");
+
+ const stale = reasons.length > 0;
+ return {
+ htmlPath,
+ exists: true,
+ assembledAt: stamp.assembledAt,
+ coreVersion: stamp.coreVersion,
+ currentCoreVersion,
+ sourceFilesNewer,
+ coreVersionChanged,
+ stale,
+ reasons,
+ message: buildMessage({
+ reasons,
+ stamp,
+ currentCoreVersion,
+ sourceFilesNewer,
+ }),
+ };
+}
+
+interface ParsedStamp {
+ assembledAt: string | null;
+ coreVersion: string | null;
+}
+
+/**
+ * Parse the `` and core-version
+ * meta tags out of the HTML's head. Reads at most the first 16KB so the
+ * scan stays cheap even on multi-MB assembled files. Exported for tests.
+ */
+export function readStamp(absHtmlPath: string): ParsedStamp {
+ try {
+ // The head fits comfortably in 16KB even for projects with 30+ scenes.
+ // We only need the meta tags so don't bother streaming the whole file.
+ const head = readFileSync(absHtmlPath, "utf-8").slice(0, 16_384);
+ return {
+ assembledAt: extractMetaContent(head, ASSEMBLED_AT_META),
+ coreVersion: extractMetaContent(head, CORE_VERSION_META),
+ };
+ } catch {
+ return { assembledAt: null, coreVersion: null };
+ }
+}
+
+/**
+ * Extract `` value from an HTML head. Tolerates
+ * single/double-quoted attrs and whitespace variation. Returns null when
+ * the meta is absent.
+ */
+export function extractMetaContent(html: string, metaName: string): string | null {
+ // Build the regex from a fixed metaName — never user-controlled — so we
+ // don't risk regex injection.
+ const escaped = metaName.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
+ const re = new RegExp(``, "i");
+ const m = re.exec(html);
+ return m && m[1] ? m[1] : null;
+}
+
+function safeStatMs(absPath: string): number | null {
+ try {
+ return statSync(absPath).mtimeMs;
+ } catch {
+ return null;
+ }
+}
+
+/**
+ * Walk the curated tracked-files / tracked-directories list and return
+ * project-relative paths whose mtime is later than the threshold. Pure;
+ * doesn't follow symlinks or descend into non-default directories.
+ */
+function listSourceFilesNewerThan(projectDir: string, htmlMtimeMs: number): string[] {
+ const out: string[] = [];
+
+ for (const rel of TRACKED_FILES_RELATIVE) {
+ const abs = join(projectDir, rel);
+ const m = safeStatMs(abs);
+ if (m !== null && m > htmlMtimeMs) {
+ out.push(rel);
+ }
+ }
+
+ for (const dirRel of TRACKED_DIRECTORIES_RELATIVE) {
+ const newest = newestFileInDirectory(projectDir, dirRel, htmlMtimeMs);
+ if (newest) out.push(newest);
+ }
+
+ // Stable order so tests can assert without sorting.
+ return out;
+}
+
+/**
+ * Return the project-relative path of the newest file under `dirRel`
+ * whose mtime exceeds the threshold, or null if the directory is missing
+ * / empty / older than the threshold. We only need ONE representative
+ * file per directory — listing every regenerated voiceover is noise in
+ * the UI.
+ *
+ * One level deep is sufficient for current layout (voiceovers/, sfx/,
+ * music/, images/ — all flat). Recursing further isn't worth the cost.
+ */
+function newestFileInDirectory(
+ projectDir: string,
+ dirRel: string,
+ thresholdMs: number,
+): string | null {
+ const absDir = join(projectDir, dirRel);
+ if (!existsSync(absDir)) return null;
+ let bestMs = thresholdMs;
+ let bestPath: string | null = null;
+ let entries: string[] = [];
+ try {
+ // These directories have at most ~50 entries — sync read is fine.
+ entries = readdirSync(absDir);
+ } catch {
+ return null;
+ }
+ for (const name of entries) {
+ if (name.startsWith(".")) continue;
+ const abs = join(absDir, name);
+ const m = safeStatMs(abs);
+ if (m !== null && m > bestMs) {
+ bestMs = m;
+ bestPath = relative(projectDir, abs);
+ }
+ }
+ return bestPath;
+}
+
+/** Pretty human-readable message for the studio tooltip. */
+function buildMessage(args: {
+ reasons: StaleReason[];
+ stamp: ParsedStamp;
+ currentCoreVersion: string;
+ sourceFilesNewer: ReadonlyArray;
+}): string {
+ if (args.reasons.length === 0) return "Up to date.";
+ const parts: string[] = [];
+ if (args.reasons.includes("core-version-changed") && args.stamp.coreVersion) {
+ parts.push(
+ `core ${args.stamp.coreVersion} → ${args.currentCoreVersion}; new features and bug-fixes have shipped since this index.html was assembled.`,
+ );
+ }
+ if (args.reasons.includes("no-stamp")) {
+ parts.push(
+ `index.html was assembled by an older Hyperframes version that didn't stamp itself; we can't tell which features it includes.`,
+ );
+ }
+ if (args.reasons.includes("source-files-newer") && args.sourceFilesNewer.length > 0) {
+ const list = args.sourceFilesNewer.slice(0, 3).join(", ");
+ const suffix =
+ args.sourceFilesNewer.length > 3 ? ` and ${args.sourceFilesNewer.length - 3} more` : "";
+ parts.push(`source files newer than index.html: ${list}${suffix}.`);
+ }
+ return parts.join(" ");
+}
diff --git a/packages/core/src/script/coreVersion.test.ts b/packages/core/src/script/coreVersion.test.ts
new file mode 100644
index 000000000..00606df14
--- /dev/null
+++ b/packages/core/src/script/coreVersion.test.ts
@@ -0,0 +1,20 @@
+import { describe, it, expect } from "vitest";
+import { getCoreVersion } from "./coreVersion.js";
+
+describe("getCoreVersion", () => {
+ it("returns the core package version (or the dev fallback)", () => {
+ const v = getCoreVersion();
+ expect(typeof v).toBe("string");
+ // Either a real semver-ish version (e.g. 0.4.27) or the explicit
+ // fallback. The exact value drifts across releases, so we assert
+ // shape only.
+ expect(v.length).toBeGreaterThan(0);
+ expect(/^\d+\.\d+\.\d+/.test(v) || v === "0.0.0-dev").toBe(true);
+ });
+
+ it("is memoized — repeated calls return the same value", () => {
+ const a = getCoreVersion();
+ const b = getCoreVersion();
+ expect(a).toBe(b);
+ });
+});
diff --git a/packages/core/src/script/coreVersion.ts b/packages/core/src/script/coreVersion.ts
new file mode 100644
index 000000000..3fd510546
--- /dev/null
+++ b/packages/core/src/script/coreVersion.ts
@@ -0,0 +1,70 @@
+/**
+ * Resolve the running `@hyperframes/core` package version. Used by
+ * `assemble.ts` to stamp the assembled HTML so the studio can detect
+ * version drift, and by the staleness route to compare against the
+ * stamp.
+ *
+ * Reads from `packages/core/package.json` at module-load time. We avoid
+ * the simpler `import pkg from "../../package.json" assert { type: "json" }`
+ * because Node's import-attribute support varies across the bun + tsx
+ * + node combinations the CLI runs under, and the studio dev server
+ * bundles this file with vite which has its own preferences.
+ *
+ * The fallback string is `0.0.0-dev` — emitted when the package.json read
+ * fails for any reason (test fixtures, source-mode where the file path
+ * doesn't resolve cleanly). Any non-default value indicates a real
+ * shipped package.
+ */
+
+import { readFileSync } from "node:fs";
+import { dirname, join } from "node:path";
+import { fileURLToPath } from "node:url";
+
+const FALLBACK = "0.0.0-dev";
+
+let cached: string | null = null;
+
+export function getCoreVersion(): string {
+ if (cached !== null) return cached;
+ cached = resolveCoreVersion();
+ return cached;
+}
+
+function resolveCoreVersion(): string {
+ // Walk upward from this module's directory until we hit the nearest
+ // package.json. Source layout: packages/core/src/script/coreVersion.ts
+ // → look at packages/core/package.json. After tsc build the file lives
+ // at packages/core/dist/script/coreVersion.js, so the same walk works.
+ try {
+ const here = dirname(fileURLToPath(import.meta.url));
+ let dir = here;
+ for (let i = 0; i < 6; i++) {
+ try {
+ const candidate = join(dir, "package.json");
+ const pkg = JSON.parse(readFileSync(candidate, "utf-8")) as {
+ name?: string;
+ version?: string;
+ };
+ if (pkg.name === "@hyperframes/core" && typeof pkg.version === "string") {
+ return pkg.version;
+ }
+ } catch {
+ /* not here, walk up */
+ }
+ const parent = dirname(dir);
+ if (parent === dir) break;
+ dir = parent;
+ }
+ } catch {
+ /* fallthrough */
+ }
+ return FALLBACK;
+}
+
+/**
+ * Test seam — lets unit tests inject a deterministic version without
+ * monkey-patching the cache. NOT exported via the package index.
+ */
+export function __setCoreVersionForTesting(version: string | null): void {
+ cached = version;
+}
diff --git a/packages/core/src/script/index.ts b/packages/core/src/script/index.ts
index a96a37af5..a3946586c 100644
--- a/packages/core/src/script/index.ts
+++ b/packages/core/src/script/index.ts
@@ -38,8 +38,11 @@ export type {
VisualDirectionEntry,
VisualDirectionPlan,
} from "./visualDirector.js";
-export { assembleMaster } from "./assemble.js";
+export { assembleMaster, ASSEMBLED_AT_META, CORE_VERSION_META } from "./assemble.js";
export type { AssembleOptions, AssembleResult } from "./assemble.js";
+export { computeAssemblyStatus, readStamp, extractMetaContent } from "./assembleStaleness.js";
+export type { AssemblyStatus, AssemblyStatusOptions, StaleReason } from "./assembleStaleness.js";
+export { getCoreVersion } from "./coreVersion.js";
export { BUILTIN_TEMPLATES, getTemplate, DEFAULT_TOKENS } from "./templates/index.js";
export type { Template, TemplateRenderContext, DesignTokens } from "./templates/index.js";
export { RETENTION_PLAYBOOK } from "./playbook.js";
diff --git a/packages/core/src/studio-api/routes/script.ts b/packages/core/src/studio-api/routes/script.ts
index 5641aae12..edef09472 100644
--- a/packages/core/src/studio-api/routes/script.ts
+++ b/packages/core/src/studio-api/routes/script.ts
@@ -28,6 +28,7 @@ import {
type ScriptFidelity,
type VisualDirectionPlan,
} from "../../script/index.js";
+import { computeAssemblyStatus } from "../../script/assembleStaleness.js";
import { readManifest as readImagesManifest } from "../../images/index.js";
import { validateAgainstSchema } from "../../script/themes/validateProps.js";
import { CostLogger, loggerSink } from "../../telemetry/cost.js";
@@ -758,6 +759,97 @@ export function registerScriptRoutes(api: Hono, adapter: StudioApiAdapter): void
return c.json({ error: err instanceof Error ? err.message : String(err) }, 500);
}
});
+
+ // Staleness check: is the project's index.html out of date relative to
+ // the running @hyperframes/core or any source file? Studio polls this
+ // on Storyline tab mount and after every mutation that could affect
+ // the rendered HTML. Cheap — handful of stat() calls plus a 16KB read
+ // of the head. No LLM or write involved.
+ api.get("/projects/:id/script/assembly-status", async (c) => {
+ const project = await adapter.resolveProject(c.req.param("id"));
+ if (!project) return c.json({ error: "not found" }, 404);
+ const htmlPath = c.req.query("path") ?? "index.html";
+ if (!isSafePath(project.dir, join(project.dir, htmlPath))) {
+ return c.json({ error: "forbidden html path" }, 403);
+ }
+ const status = computeAssemblyStatus({ projectDir: project.dir, htmlPath });
+ return c.json(status);
+ });
+
+ // Re-assemble index.html from the existing script.generated.json without
+ // re-synthesizing audio. Mirrors the CLI `hyperframes script assemble`
+ // verb so the studio "Regenerate" CTA can fix staleness in one click
+ // (~2s on a 25-scene project — no API calls, just templating).
+ api.post("/projects/:id/script/assemble", async (c) => {
+ const project = await adapter.resolveProject(c.req.param("id"));
+ if (!project) return c.json({ error: "not found" }, 404);
+
+ let body: { outFile?: string } = {};
+ try {
+ // Body is optional — the studio sends `{}` for the default path.
+ body = (await c.req.json().catch(() => ({}))) as { outFile?: string };
+ } catch {
+ /* ignore — empty body is fine */
+ }
+ const outFile = body.outFile ?? "index.html";
+ const absOut = join(project.dir, outFile);
+ if (!isSafePath(project.dir, absOut)) {
+ return c.json({ error: "forbidden outFile path" }, 403);
+ }
+
+ const plannedPath = join(project.dir, PLANNED_FILE);
+ if (!existsSync(plannedPath)) {
+ return c.json({ error: "no script.generated.json — run script generate first" }, 400);
+ }
+ let planned: import("../../script/types.js").PlannedScript;
+ try {
+ planned = JSON.parse(readFileSync(plannedPath, "utf-8"));
+ } catch (err) {
+ return c.json(
+ {
+ error: `script.generated.json is unreadable: ${err instanceof Error ? err.message : String(err)}`,
+ },
+ 500,
+ );
+ }
+
+ const ops = new OpsLogger(project.dir);
+ const start = Date.now();
+ try {
+ const briefForAssemble = loadDesignBrief(project.dir);
+ const tokens = resolveProjectTokens(project.dir, briefForAssemble);
+ const templates = resolveTemplateRegistry(project.dir, briefForAssemble);
+ const directionPath = join(project.dir, DIRECTION_FILE);
+ let directionPlan: VisualDirectionPlan | undefined;
+ if (existsSync(directionPath)) {
+ try {
+ directionPlan = JSON.parse(readFileSync(directionPath, "utf-8")) as VisualDirectionPlan;
+ } catch (err) {
+ console.warn("[script] visual-direction.json is malformed; ignoring", err);
+ }
+ }
+ const imagesManifest = readImagesManifest(project.dir);
+ const result = assembleMaster(planned, {
+ projectDir: project.dir,
+ outFile,
+ tokens,
+ templates,
+ ...(directionPlan ? { directionPlan } : {}),
+ imagesManifest,
+ });
+ const status = computeAssemblyStatus({ projectDir: project.dir, htmlPath: outFile });
+ opsFireAndForget(ops, {
+ op: "script.assemble",
+ message: `${planned.scenes.length} scenes → ${outFile} (re-emit only)`,
+ wallMs: Date.now() - start,
+ meta: { sceneCount: planned.scenes.length, outFile },
+ });
+ return c.json({ ok: true, result, status });
+ } catch (err) {
+ void ops.logError("script.assemble", err);
+ return c.json({ error: err instanceof Error ? err.message : String(err) }, 500);
+ }
+ });
}
function writeJson(path: string, data: unknown): void {
diff --git a/packages/studio/src/App.tsx b/packages/studio/src/App.tsx
index e132d6383..37fdee529 100644
--- a/packages/studio/src/App.tsx
+++ b/packages/studio/src/App.tsx
@@ -6,6 +6,7 @@ import { SourceEditor } from "./components/editor/SourceEditor";
import { LeftSidebar } from "./components/sidebar/LeftSidebar";
import { ProjectSwitcher } from "./components/ProjectSwitcher";
import { CostBadge } from "./components/CostBadge";
+import { StaleAssemblyBanner } from "./components/StaleAssemblyBanner";
import { RenderQueue } from "./components/renders/RenderQueue";
import { useRenderQueue } from "./components/renders/useRenderQueue";
import { CompositionThumbnail, VideoThumbnail, usePlayerStore } from "./player";
@@ -575,6 +576,16 @@ export function StudioApp() {
es.addEventListener("file-change", handler);
return () => es.close();
});
+
+ // Listen for "user clicked Regenerate in the staleness banner" events.
+ // Bumping refreshKey forces the preview iframe to remount with the
+ // freshly assembled HTML — keeps the user's mental model "click → see
+ // updated preview" intact.
+ useMountEffect(() => {
+ const handler = () => setRefreshKey((k) => k + 1);
+ window.addEventListener("hf:assembly-regenerated", handler as EventListener);
+ return () => window.removeEventListener("hf:assembly-regenerated", handler as EventListener);
+ });
projectIdRef.current = projectId;
// Load file tree when projectId changes.
@@ -1388,10 +1399,11 @@ export function StudioApp() {
>
{/* Header bar */}
- {/* Left: project switcher + cost badge */}
+ {/* Left: project switcher + cost badge + staleness chip */}
{/* Right: toolbar buttons */}
diff --git a/packages/studio/src/components/StaleAssemblyBanner.tsx b/packages/studio/src/components/StaleAssemblyBanner.tsx
new file mode 100644
index 000000000..48e6bf918
--- /dev/null
+++ b/packages/studio/src/components/StaleAssemblyBanner.tsx
@@ -0,0 +1,166 @@
+import { memo, useCallback, useEffect, useRef, useState } from "react";
+
+/**
+ * Mirrors `AssemblyStatus` from packages/core/src/script/assembleStaleness.ts.
+ * Kept as a local type to avoid the studio package taking a runtime import on
+ * core internals.
+ */
+type StaleReason = "no-html" | "no-stamp" | "core-version-changed" | "source-files-newer";
+
+interface AssemblyStatus {
+ htmlPath: string;
+ exists: boolean;
+ assembledAt: string | null;
+ coreVersion: string | null;
+ currentCoreVersion: string;
+ sourceFilesNewer: ReadonlyArray
;
+ coreVersionChanged: boolean;
+ stale: boolean;
+ reasons: ReadonlyArray;
+ message: string;
+}
+
+interface StaleAssemblyBannerProps {
+ projectId: string;
+ /** Bumped externally to force a re-poll (e.g. after a script mutation). */
+ refreshKey?: number;
+}
+
+const POLL_INTERVAL_MS = 30_000;
+
+/**
+ * Lightweight chip for the studio topbar that surfaces "your index.html is
+ * stale, click to re-assemble" without nagging.
+ *
+ * Behavior:
+ * - Polls /script/assembly-status every 30s + on mount + when refreshKey
+ * changes. Cheap call, no LLM.
+ * - When `stale === true`, renders an amber chip with the reason. Click to
+ * POST /script/assemble; chip flips to "Regenerating…" then back to
+ * "Up to date" on success (or shows the error if it fails).
+ * - Renders nothing when status === up-to-date — keeps the topbar clean
+ * for the 99% case.
+ *
+ * The reason copy is deliberately compact: full detail lives in the
+ * tooltip so the topbar stays scannable.
+ */
+export const StaleAssemblyBanner = memo(function StaleAssemblyBanner({
+ projectId,
+ refreshKey,
+}: StaleAssemblyBannerProps) {
+ const [status, setStatus] = useState(null);
+ const [regenerating, setRegenerating] = useState(false);
+ const [error, setError] = useState(null);
+ const acRef = useRef(null);
+
+ const fetchStatus = useCallback(async () => {
+ acRef.current?.abort();
+ const ac = new AbortController();
+ acRef.current = ac;
+ try {
+ const res = await fetch(
+ `/api/projects/${encodeURIComponent(projectId)}/script/assembly-status`,
+ { signal: ac.signal },
+ );
+ if (ac.signal.aborted) return;
+ if (!res.ok) {
+ // 404 (no project) or 500 — silently swallow; banner just hides.
+ setStatus(null);
+ return;
+ }
+ const data = (await res.json()) as AssemblyStatus;
+ if (ac.signal.aborted) return;
+ setStatus(data);
+ } catch (err) {
+ if (err instanceof Error && err.name === "AbortError") return;
+ // Network error — hide rather than scream.
+ setStatus(null);
+ }
+ }, [projectId]);
+
+ // eslint-disable-next-line no-restricted-syntax
+ useEffect(() => {
+ void fetchStatus();
+ const id = window.setInterval(fetchStatus, POLL_INTERVAL_MS);
+ return () => {
+ window.clearInterval(id);
+ acRef.current?.abort();
+ };
+ }, [fetchStatus, refreshKey]);
+
+ const onRegenerate = useCallback(async () => {
+ setRegenerating(true);
+ setError(null);
+ try {
+ const res = await fetch(`/api/projects/${encodeURIComponent(projectId)}/script/assemble`, {
+ method: "POST",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({}),
+ });
+ const data = (await res.json().catch(() => ({}))) as {
+ status?: AssemblyStatus;
+ error?: string;
+ };
+ if (!res.ok) {
+ setError(data.error ?? `HTTP ${res.status}`);
+ return;
+ }
+ if (data.status) setStatus(data.status);
+ else await fetchStatus();
+ // Force the preview iframe to reload — the assembled HTML changed.
+ // Listeners can subscribe to the same event from elsewhere.
+ try {
+ window.dispatchEvent(new CustomEvent("hf:assembly-regenerated"));
+ } catch {
+ /* ignore */
+ }
+ } catch (err) {
+ setError(err instanceof Error ? err.message : String(err));
+ } finally {
+ setRegenerating(false);
+ }
+ }, [projectId, fetchStatus]);
+
+ if (!status || !status.stale) return null;
+
+ // Map reasons to a 1-word leading label. The full sentence lives in
+ // status.message and is shown via the title attribute.
+ const headline = pickHeadline(status);
+
+ return (
+
+
+ ⚠
+
+
+ {headline}
+
+
+
+ );
+});
+
+function pickHeadline(status: AssemblyStatus): string {
+ if (status.reasons.includes("no-html")) return "No index.html";
+ if (status.reasons.includes("core-version-changed")) {
+ return `Core ${status.coreVersion} → ${status.currentCoreVersion}`;
+ }
+ if (status.reasons.includes("source-files-newer")) {
+ const n = status.sourceFilesNewer.length;
+ if (n === 0) return "Stale";
+ return `${n} source file${n === 1 ? "" : "s"} newer than index.html`;
+ }
+ if (status.reasons.includes("no-stamp")) return "Older index.html (no stamp)";
+ return "Stale";
+}