feat(cli): give stale-pinned projects a path to the latest CLI (#2304)

## What

Already-scaffolded HyperFrames projects pin an exact `hyperframes@X.Y.Z` in `package.json` scripts (`init.ts`), frozen at scaffold time — and the update-available notice is suppressed on non-TTY shells, exactly how agents invoke the CLI. So a large tail of projects sits on months-old versions, invisible and stuck, never seeing later render/router fixes.

The pin itself is deliberate (a video project should re-render identically across CLI versions), so this PR keeps it and instead gives projects a path off it:

1. **`rewriteProjectPinnedScripts` / `readPinnedHyperframesVersions`** (`packages/cli/src/utils/projectPin.ts`) — pure helpers that rewrite/read `hyperframes@<version>` pins in a `package.json` scripts object.
2. **`hyperframes upgrade --project [dir]`** — bumps a project's pinned scripts to npm-latest in one command (`--check` reports the delta without writing, `--json` for `{ changed, from, to, path }`).
3. **`printStalePinNotice`** — a throttled (once/24h), non-TTY-visible notice (unlike the existing update notice, which non-TTY shells suppress) that fires when the *current* project's pin is stale, pointing at `upgrade --project`.
4. **Skill instruction** (`skills/hyperframes-cli/SKILL.md` + reference) — tells agents to check for and bump a stale project pin via the **unpinned** `npx hyperframes@latest upgrade --project`.
5. **Scaffold templates** (`CLAUDE.md`/`AGENTS.md`) — new projects get the same guidance baked in from day one.

## Why

Only the global skill (piece 4) invoking the unpinned `npx hyperframes@latest upgrade --project` (piece 2) reaches projects that are *already* frozen on an old pin — a project pinned to an old CLI version never runs the new notice code (piece 3) or sees the new template text (piece 5). Those two are forward-only: they stop the bleed on projects scaffolded from here on, but the skill instruction is the only lever that reaches the existing backlog.

## How

`isSafeVersion` was extracted out of `updateCheck.ts` into its own `safeVersion.ts` module — `projectPin.ts` needs it and `updateCheck.ts` needs `projectPin.ts`'s `readPinnedHyperframesVersions`, so keeping `isSafeVersion` in `updateCheck.ts` created a circular import between the two files.

## Test plan

- [x] Unit tests added/updated (`projectPin.test.ts`, `upgrade.project.test.ts`, `updateCheck.stalepin.test.ts`) — TDD, all passing
- [x] Full `packages/cli` suite green (132 files / 1651 tests), `tsc --noEmit` clean, `bun run build` succeeds
- [x] Manual smoke test: `upgrade --project --check --json` reports the delta without writing; `upgrade --project` rewrites the pinned scripts in place
- [ ] Documentation updated — skill + scaffold templates updated in this PR; `CLAUDE.md`/`AGENTS.md` template parity verified with `diff -q`

Deferred (left for a separate decision, not in this PR): `npm deprecate hyperframes@"<0.7.53"` — reaches frozen projects with no skill loaded, but is a live, hard-to-reverse action against published packages that needs an explicit human call on cutoff version + message.

🤖 Generated with [Claude Code](https://claude.com/claude-code)
This commit is contained in:
Vance Ingalls
2026-07-14 15:44:50 -07:00
committed by GitHub
14 changed files with 367 additions and 13 deletions
+4
View File
@@ -206,6 +206,7 @@ let _trackCommandResult:
}) => void)
| undefined;
let _printUpdateNotice: (() => void) | undefined;
let _printStalePinNotice: (() => void) | undefined;
let _printSkillsUpdateNotice: (() => void) | undefined;
// `events` is a telemetry-internal beacon: it self-tracks + self-flushes, so it
@@ -245,6 +246,7 @@ if (
import("./utils/updateCheck.js").then(async (mod) => {
_printUpdateNotice = mod.printUpdateNotice;
_printStalePinNotice = mod.printStalePinNotice;
const result = await mod.checkForUpdate().catch(() => null);
if (result?.updateAvailable) {
const auto = await import("./utils/autoUpdate.js").catch(() => null);
@@ -268,10 +270,12 @@ const runId = getRunId();
// work — so a plain `on` listener would print the update notice (and
// re-flush) once per drain (the user-reported double-print). `once`
// detaches after first invocation, which is what we want for both.
// fallow-ignore-next-line complexity
process.once("beforeExit", () => {
_flush?.().catch(() => {});
if (!hasJsonFlag) {
_printUpdateNotice?.();
_printStalePinNotice?.();
_printSkillsUpdateNotice?.();
}
});
@@ -0,0 +1,44 @@
import { afterEach, describe, expect, it, vi } from "vitest";
import { mkdtempSync, readFileSync, writeFileSync, rmSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
vi.mock("../utils/updateCheck.js", async (orig) => ({
...(await orig<typeof import("../utils/updateCheck.js")>()),
checkForUpdate: vi.fn(async () => ({
current: "0.7.48",
latest: "0.7.55",
updateAvailable: true,
})),
}));
import { upgradeProjectPins } from "./upgrade.js";
describe("upgradeProjectPins", () => {
const dirs: string[] = [];
afterEach(() => dirs.forEach((d) => rmSync(d, { recursive: true, force: true })));
function project(scripts: Record<string, string>): string {
const d = mkdtempSync(join(tmpdir(), "hf-proj-"));
dirs.push(d);
writeFileSync(join(d, "package.json"), JSON.stringify({ name: "x", scripts }, null, 2));
return d;
}
it("rewrites pinned scripts to latest and reports the delta", async () => {
const d = project({ render: "npx --yes hyperframes@0.7.48 render" });
const r = await upgradeProjectPins(d, { json: false, check: false });
expect(r.changed).toBe(true);
expect(r.from).toEqual(["0.7.48"]);
expect(r.to).toBe("0.7.55");
const pkg = JSON.parse(readFileSync(join(d, "package.json"), "utf-8"));
expect(pkg.scripts.render).toBe("npx --yes hyperframes@0.7.55 render");
});
it("--check reports without writing", async () => {
const d = project({ render: "npx --yes hyperframes@0.7.48 render" });
const before = readFileSync(join(d, "package.json"), "utf-8");
const r = await upgradeProjectPins(d, { json: false, check: true });
expect(r.changed).toBe(true);
expect(readFileSync(join(d, "package.json"), "utf-8")).toBe(before);
});
});
+64
View File
@@ -2,12 +2,16 @@ import { defineCommand } from "citty";
import type { Example } from "./_examples.js";
import * as clack from "@clack/prompts";
import { execFileSync } from "node:child_process";
import { existsSync, readFileSync, writeFileSync, renameSync } from "node:fs";
import { resolve } from "node:path";
import { c } from "../ui/colors.js";
import { rewriteProjectPinnedScripts } from "../utils/projectPin.js";
export const examples: Example[] = [
["Check for updates interactively", "hyperframes upgrade"],
["Check for updates without prompting", "hyperframes upgrade --check"],
["Upgrade non-interactively", "hyperframes upgrade --yes"],
["Bump a project's pinned CLI scripts", "hyperframes upgrade --project"],
];
import { VERSION } from "../version.js";
import {
@@ -24,12 +28,28 @@ export default defineCommand({
yes: { type: "boolean", alias: "y", description: "Show upgrade commands without prompting" },
check: { type: "boolean", description: "Check for updates and exit (no prompt)" },
json: { type: "boolean", description: "Output as JSON", default: false },
project: {
type: "string",
description:
"Bump this project's package.json hyperframes@<version> script pins to latest (default: current dir)",
},
},
// fallow-ignore-next-line complexity
async run({ args }) {
const useJson = args.json === true;
const checkOnly = args.check === true;
if (args.project !== undefined) {
const dir = typeof args.project === "string" && args.project.length ? args.project : ".";
const res = await upgradeProjectPins(resolve(dir), { json: useJson, check: checkOnly });
if (useJson) {
console.log(JSON.stringify(withMeta(res), null, 2));
return;
}
printProjectPinResult(res, checkOnly);
return;
}
// JSON mode: always force-check and output structured data
if (useJson) {
const result = await checkForUpdate(true);
@@ -137,6 +157,50 @@ function printManualCommands(displayCmd: string, npxFallback: string): void {
clack.outro(c.success("Run one of the commands above to upgrade."));
}
export async function upgradeProjectPins(
dir: string,
opts: { json: boolean; check: boolean },
): Promise<{ changed: boolean; from: string[]; to: string; path: string }> {
const pkgPath = resolve(dir, "package.json");
if (!existsSync(pkgPath)) {
return { changed: false, from: [], to: "", path: pkgPath };
}
const raw = JSON.parse(readFileSync(pkgPath, "utf-8")) as {
scripts?: Record<string, string>;
};
const scripts = raw.scripts ?? {};
const { latest } = await checkForUpdate(true);
if (!isSafeVersion(latest)) return { changed: false, from: [], to: latest, path: pkgPath };
const rewrite = rewriteProjectPinnedScripts(scripts, latest);
if (rewrite.changed && !opts.check) {
raw.scripts = rewrite.scripts;
const tmp = `${pkgPath}.tmp`;
writeFileSync(tmp, `${JSON.stringify(raw, null, 2)}\n`, "utf-8");
renameSync(tmp, pkgPath);
}
return { changed: rewrite.changed, from: rewrite.fromVersions, to: latest, path: pkgPath };
}
function printProjectPinResult(
res: { changed: boolean; from: string[]; to: string; path: string },
checkOnly: boolean,
): void {
if (!existsSync(res.path)) {
console.log(` ${c.dim("No package.json found — nothing to upgrade.")}`);
return;
}
if (!res.changed) {
console.log(` ${c.success("◇")} Project scripts already on hyperframes@${res.to}`);
return;
}
const verb = checkOnly ? "would bump" : "bumped";
console.log(
` ${c.success("◇")} ${verb} project scripts ${res.from.join(", ")}${c.accent(res.to)}`,
);
if (checkOnly)
console.log(` ${c.dim("Run `npx hyperframes@latest upgrade --project` to apply.")}`);
}
export function runDetectedInstall(
invocation: { bin: string; args: string[] },
displayCmd: string,
+3
View File
@@ -27,6 +27,8 @@ export interface HyperframesConfig {
lastUpdateCheck?: string;
/** Latest version found on npm */
latestVersion?: string;
/** Throttle for the non-TTY stale-project-pin notice (ms epoch). */
lastStalePinNoticeAt?: number;
/**
* Auto-update marker. Set when a background install is spawned so a
* subsequent run can skip re-triggering it. Cleared once
@@ -122,6 +124,7 @@ export function readConfig(): HyperframesConfig {
lastFeedbackPromptAt: parsed.lastFeedbackPromptAt ?? DEFAULT_CONFIG.lastFeedbackPromptAt,
lastUpdateCheck: parsed.lastUpdateCheck,
latestVersion: parsed.latestVersion,
lastStalePinNoticeAt: parsed.lastStalePinNoticeAt,
pendingUpdate: parsed.pendingUpdate,
completedUpdate: parsed.completedUpdate,
lastSkillsCheck: parsed.lastSkillsCheck,
@@ -45,6 +45,8 @@ npx hyperframes docs <topic> # reference docs in terminal
> In Claude Code, always run it with `run_in_background: true`. Never run it as a foreground
> command — it will time out and the server will die, breaking the browser preview.
> **Pinned CLI version.** These scripts pin an exact `hyperframes@X.Y.Z` so this project re-renders identically over time. Weeks later that pin lags fixes shipped since. To move up: `npx hyperframes@latest upgrade --project --check` (shows the delta), then `npx hyperframes@latest upgrade --project` to rewrite the pins. Always unpinned — the pinned script re-runs the old version against itself.
## Documentation
**For quick reference**, use the local CLI docs command (no network required):
@@ -45,6 +45,8 @@ npx hyperframes docs <topic> # reference docs in terminal
> In Claude Code, always run it with `run_in_background: true`. Never run it as a foreground
> command — it will time out and the server will die, breaking the browser preview.
> **Pinned CLI version.** These scripts pin an exact `hyperframes@X.Y.Z` so this project re-renders identically over time. Weeks later that pin lags fixes shipped since. To move up: `npx hyperframes@latest upgrade --project --check` (shows the delta), then `npx hyperframes@latest upgrade --project` to rewrite the pins. Always unpinned — the pinned script re-runs the old version against itself.
## Documentation
**For quick reference**, use the local CLI docs command (no network required):
+58
View File
@@ -0,0 +1,58 @@
import { describe, expect, it } from "vitest";
import {
rewriteProjectPinnedScripts,
readPinnedHyperframesVersions,
HYPERFRAMES_PIN_RE,
} from "./projectPin.js";
describe("HYPERFRAMES_PIN_RE", () => {
it("matches a hyperframes@<semver> token and captures the version", () => {
const match = "npx --yes hyperframes@1.2.3 render".match(HYPERFRAMES_PIN_RE);
expect(match?.[0]).toBe("hyperframes@1.2.3");
});
});
describe("rewriteProjectPinnedScripts", () => {
const scripts = {
dev: "npx --yes hyperframes@0.7.48 preview",
check: "npx --yes hyperframes@0.7.48 check",
render: "npx --yes hyperframes@0.7.48 render",
unrelated: "echo hi",
unpinned: "npx hyperframes render",
};
it("bumps every pinned hyperframes script to the target, leaving others untouched", () => {
const r = rewriteProjectPinnedScripts(scripts, "0.7.55");
expect(r.changed).toBe(true);
expect(r.fromVersions).toEqual(["0.7.48"]);
expect(r.scripts.render).toBe("npx --yes hyperframes@0.7.55 render");
expect(r.scripts.dev).toBe("npx --yes hyperframes@0.7.55 preview");
expect(r.scripts.unrelated).toBe("echo hi");
expect(r.scripts.unpinned).toBe("npx hyperframes render");
});
it("is a no-op when already at target", () => {
const at = rewriteProjectPinnedScripts(
{ render: "npx --yes hyperframes@0.7.55 render" },
"0.7.55",
);
expect(at.changed).toBe(false);
expect(at.fromVersions).toEqual([]);
});
it("refuses an unsafe target version (no rewrite)", () => {
const r = rewriteProjectPinnedScripts(scripts, "0.7.55; rm -rf /");
expect(r.changed).toBe(false);
expect(r.scripts.render).toBe(scripts.render);
});
it("reads distinct pinned versions across scripts", () => {
expect(
readPinnedHyperframesVersions({
a: "npx --yes hyperframes@0.7.48 render",
b: "npx hyperframes@0.7.50 check",
c: "npx hyperframes render",
}),
).toEqual(["0.7.48", "0.7.50"]);
});
});
+44
View File
@@ -0,0 +1,44 @@
import { isSafeVersion } from "./safeVersion.js";
// Matches `hyperframes@<semver>` as a whole token inside a script string. The
// version class mirrors isSafeVersion's semver shape; capturing group 1 is the
// old version. `(?=\s|$)` keeps it from matching a longer package name.
export const HYPERFRAMES_PIN_RE =
/\bhyperframes@([0-9]+\.[0-9]+\.[0-9]+(?:-[0-9A-Za-z.-]+)?(?:\+[0-9A-Za-z.-]+)?)(?=\s|$)/g;
export interface PinRewriteResult {
changed: boolean;
scripts: Record<string, string>;
fromVersions: string[];
}
export function readPinnedHyperframesVersions(scripts: Record<string, string>): string[] {
const found = new Set<string>();
for (const cmd of Object.values(scripts ?? {})) {
for (const m of cmd.matchAll(HYPERFRAMES_PIN_RE)) if (m[1]) found.add(m[1]);
}
return [...found].sort();
}
export function rewriteProjectPinnedScripts(
scripts: Record<string, string>,
targetVersion: string,
): PinRewriteResult {
// Never emit an unverified version into a script the user (or npx) will run.
if (!isSafeVersion(targetVersion)) {
return { changed: false, scripts: { ...scripts }, fromVersions: [] };
}
const fromVersions = new Set<string>();
const next: Record<string, string> = {};
for (const [name, cmd] of Object.entries(scripts ?? {})) {
next[name] = cmd.replace(HYPERFRAMES_PIN_RE, (_full, version: string) => {
if (version !== targetVersion) fromVersions.add(version);
return `hyperframes@${targetVersion}`;
});
}
return {
changed: [...fromVersions].length > 0,
scripts: next,
fromVersions: [...fromVersions].sort(),
};
}
+16
View File
@@ -0,0 +1,16 @@
/**
* True when `v` is a strict semver-shaped string. Registry-supplied versions
* flow into commands that are displayed AND executed (the `upgrade` command and
* the background auto-installer both run them), so a poisoned `latest` carrying
* shell metacharacters must never reach them. This is enforced at the registry
* boundary in `checkForUpdate` an unsafe `data.version` is never cached so
* every consumer (notice, upgrade, background auto-install, and any future one)
* is covered by this single gate; the per-consumer checks are defense in depth.
*
* Lives in its own module (rather than updateCheck.ts) so utils/projectPin.ts
* can depend on it without a projectPin.ts <-> updateCheck.ts import cycle
* updateCheck.ts imports readPinnedHyperframesVersions from projectPin.ts.
*/
export function isSafeVersion(v: string): boolean {
return /^[0-9]+\.[0-9]+\.[0-9]+(?:-[0-9A-Za-z.-]+)?(?:\+[0-9A-Za-z.-]+)?$/.test(v);
}
@@ -0,0 +1,74 @@
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { mkdtempSync, writeFileSync, rmSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
// Drive `latest` through the REAL getUpdateMeta (defined in the module under
// test) via the mocked config store — self-mocking getUpdateMeta on
// "./updateCheck.js" would only override the export binding, not the internal
// call printStalePinNotice makes to it from within the same module.
let store: Record<string, unknown> = {};
// isDevMode() is true under vitest (module path ends in .ts), which would
// suppress the notice unconditionally — mock ./env.js like updateCheck.test.ts does.
vi.mock("./env.js", () => ({ isDevMode: () => false }));
vi.mock("../telemetry/config.js", () => ({
readConfig: () => ({ ...store }),
writeConfig: (c: Record<string, unknown>) => {
store = { ...c };
return true;
},
}));
import { printStalePinNotice } from "./updateCheck.js";
describe("printStalePinNotice", () => {
let dir: string;
let writes: string[];
const origWrite = process.stderr.write.bind(process.stderr);
beforeEach(() => {
store = { latestVersion: "0.7.55" };
writes = [];
dir = mkdtempSync(join(tmpdir(), "hf-pin-"));
process.stderr.write = ((s: unknown) => {
writes.push(String(s));
return true;
}) as typeof process.stderr.write;
delete process.env.CI;
delete process.env.HYPERFRAMES_NO_UPDATE_CHECK;
});
afterEach(() => {
process.stderr.write = origWrite;
rmSync(dir, { recursive: true, force: true });
});
it("warns once when the project pins an older version", () => {
writeFileSync(
join(dir, "package.json"),
JSON.stringify({ scripts: { render: "npx --yes hyperframes@0.7.48 render" } }),
);
printStalePinNotice(dir);
printStalePinNotice(dir); // throttled — second call silent
expect(writes.join("")).toContain("0.7.48");
expect(writes.join("")).toContain("upgrade --project");
expect(writes.filter((w) => w.includes("upgrade --project")).length).toBe(1);
});
it("silent when project pin is current", () => {
writeFileSync(
join(dir, "package.json"),
JSON.stringify({ scripts: { render: "npx --yes hyperframes@0.7.55 render" } }),
);
printStalePinNotice(dir);
expect(writes.join("")).toBe("");
});
it("silent under CI", () => {
process.env.CI = "true";
writeFileSync(
join(dir, "package.json"),
JSON.stringify({ scripts: { render: "npx --yes hyperframes@0.7.48 render" } }),
);
printStalePinNotice(dir);
expect(writes.join("")).toBe("");
});
});
+52 -12
View File
@@ -1,21 +1,14 @@
import { existsSync, readFileSync } from "node:fs";
import { join } from "node:path";
import { compareVersions } from "compare-versions";
import { readConfig, writeConfig } from "../telemetry/config.js";
import { VERSION } from "../version.js";
import { isDevMode } from "./env.js";
import { detectInstaller } from "./installerDetection.js";
import { readPinnedHyperframesVersions } from "./projectPin.js";
import { isSafeVersion } from "./safeVersion.js";
/**
* True when `v` is a strict semver-shaped string. Registry-supplied versions
* flow into commands that are displayed AND executed (the `upgrade` command and
* the background auto-installer both run them), so a poisoned `latest` carrying
* shell metacharacters must never reach them. This is enforced at the registry
* boundary in `checkForUpdate` an unsafe `data.version` is never cached so
* every consumer (notice, upgrade, background auto-install, and any future one)
* is covered by this single gate; the per-consumer checks are defense in depth.
*/
export function isSafeVersion(v: string): boolean {
return /^[0-9]+\.[0-9]+\.[0-9]+(?:-[0-9A-Za-z.-]+)?(?:\+[0-9A-Za-z.-]+)?$/.test(v);
}
export { isSafeVersion } from "./safeVersion.js";
const NPM_REGISTRY_URL = "https://registry.npmjs.org/hyperframes/latest";
const CHECK_INTERVAL_MS = 24 * 60 * 60 * 1000; // 24 hours
@@ -195,3 +188,50 @@ export function printUpdateNotice(): void {
` Run: ${command}\n\n`,
);
}
const STALE_PIN_THROTTLE_MS = 24 * 60 * 60 * 1000;
/**
* Actionable, throttled notice for a project whose package.json still pins an
* OLD hyperframes version. Unlike printUpdateNotice this DOES fire on non-TTY
* (agents render with piped stderr) \u2014 but only when there's a concrete stale
* pin to act on, at most once/24h per install, and never under --json/CI/dev/
* opt-out. The whole cli.ts update block is already skipped for --json, so a
* JSON stdout stays clean regardless.
*/
export function printStalePinNotice(cwd: string = process.cwd()): void {
if (isDevMode()) return;
if (process.env["CI"] === "true" || process.env["CI"] === "1") return;
if (process.env["HYPERFRAMES_NO_UPDATE_CHECK"] === "1") return;
const latest = getUpdateMeta().latestVersion;
if (!latest || !isSafeVersion(latest)) return;
let scripts: Record<string, string> = {};
try {
const pkgPath = join(cwd, "package.json");
if (!existsSync(pkgPath)) return;
scripts = (JSON.parse(readFileSync(pkgPath, "utf-8")).scripts ?? {}) as Record<string, string>;
} catch {
return;
}
const stale = readPinnedHyperframesVersions(scripts).filter((v) => {
try {
return compareVersions(latest, v) > 0;
} catch {
return false;
}
});
if (stale.length === 0) return;
const config = readConfig();
const last = config.lastStalePinNoticeAt ?? 0;
if (Date.now() - last < STALE_PIN_THROTTLE_MS) return;
config.lastStalePinNoticeAt = Date.now();
writeConfig(config);
process.stderr.write(
`\n This project pins hyperframes@${stale.join(", ")} (latest ${latest}).\n` +
` Bump it: npx hyperframes@latest upgrade --project\n\n`,
);
}
+1 -1
View File
@@ -26,7 +26,7 @@
"files": 102
},
"hyperframes-cli": {
"hash": "f78d0c936fd3499f",
"hash": "fbe17780248d5bec",
"files": 8
},
"hyperframes-core": {
+1
View File
@@ -40,6 +40,7 @@ Cross-cutting rules that hold for every command:
- **Use Studio context for user-directed edits.** When the user says "this selected element", "the thing I clicked", "current selection", or similar, ask them to select it in Studio, then run `npx hyperframes preview --context --json --context-fields selection`. Use the returned `selection.target.hfId` / `selector`, `selection.sourceFile`, `selection.currentTime`, and `selection.thumbnailUrl` to anchor the edit. If `selection` is `null` and `errors.selection.code` is `no-selection`, ask the user to click the element and rerun; do not guess from screenshots.
- **Keep Studio context compact.** `preview --context --json` returns compact selection by default. Add `--context-fields selection`, `--context-fields selection,lint`, or `--context-fields lint` to avoid bloating agent context. Use `--context-detail full` only when you need heavy fields like computed styles, inline styles, or text-field metadata.
- **Post-render verification.** After `render` returns exit 0, confirm the output file exists and has plausible size before reporting success: `[ -s "$OUTPUT" ] || echo "render produced no output"`. The CLI prints `◇ <path>` on success; for long renders also sanity-check duration with `ffprobe -i "$OUTPUT" -show_format -v error`.
- **Keep the project's pinned CLI fresh.** `hyperframes init` writes an exact version into `package.json` scripts (`npx --yes hyperframes@X.Y.Z render`) so a project re-renders identically over time — but a project scaffolded weeks ago is frozen on a CLI that predates render, capture-router, and correctness fixes it would silently benefit from. Once per project, check whether the pin is stale and bump it: `npx hyperframes@latest upgrade --project --check` (reports the delta, writes nothing), then `npx hyperframes@latest upgrade --project` to apply. Use the **unpinned** `hyperframes@latest` form — the pinned script would just re-run the old version against itself. Skip if the user deliberately pinned a version.
- **Report feedback after a successful render.** Once the output is verified, run `npx hyperframes feedback --rating <0-10> --comment "..."` once per task to report how likely you are to recommend the run — `10` for a clean run, lower when you hit friction. Put any bug, workaround, missing feature, or confusing behaviour you encountered in `--comment`. This is the project's primary signal channel; renders that go silent leave the maintainers blind. Skip only when telemetry is disabled or the user opted out.
- **For a reproducible bug, add `--file-issue`** — it publishes a minimal repro of the project to a public URL and opens a pre-filled GitHub issue with your comment + environment. A comment alone almost never lets a maintainer reproduce; the published composition is what makes the report actionable. Point `--dir <project>` at the failing composition if it isn't the current directory.
- **Write bug comments a maintainer can reproduce from — a paraphrase they can't act on wastes the report.** The CLI already attaches os/arch/mem/ffmpeg/CLI-version, so don't repeat those; spend the comment on what only you saw. For a bug, include every one of these that applies:
@@ -23,6 +23,8 @@ npx hyperframes upgrade --yes # print upgrade commands without promptin
Compares the installed CLI version against npm latest.
`--project [dir]` bumps a **project's** pinned scripts instead of the global install: it rewrites every `npx …hyperframes@<version>…` in `<dir>/package.json` (default cwd) to npm-latest. Always invoke it unpinned (`npx hyperframes@latest upgrade --project`) — a project scaffolded on an old CLI stays frozen otherwise. `--project --check` reports the delta without writing; add `--json` for `{ changed, from, to, path }`.
## compositions, docs
```bash