fix(cli): persist authoring skill in hyperframes.json for durable render attribution - #2762
fix(cli): persist authoring skill in hyperframes.json for durable render attribution#2762WaterrrForever wants to merge 4 commits into
Conversation
|
Preview deployment for your docs. Learn more about Mintlify Previews.
💡 Tip: Enable Workflows to automatically generate PRs for you. |
b4dfc06 to
dc4edc9
Compare
…der attribution authoring_skill was stamped only on the first render through a workflow passing --skill, so re-renders, `npm run render`, --batch, existing-project renders, and general-video lost it — leaving 77-96% of real-human render volume un-attributed and the skills-penetration metric misleadingly low. Persist the owning skill in hyperframes.json: `init --skill` stamps it at creation, `render` resolves the flag then falls back to the stored value, and an explicit --skill seeds it (seed-once, never overwriting the creating workflow's identity). Activate all render-producing creation workflows to declare their skill at init. Forward-only: does not rewrite historical telemetry.
dc4edc9 to
ed1927e
Compare
jrusso1020
left a comment
There was a problem hiding this comment.
Verified the mechanism claims at source rather than from the description. All three hold:
- Flag-less renders inherit.
createRenderPlanresolvesflagSkill ?? loadProjectConfig(project.dir).authoringSkill, so a render with no--skillpicks up the persisted owner. Precedence is the right way round — an explicit flag still wins — and both directions are pinned by the new plan tests. --batchinherits.batchPathis a field on the single render plan andauthoringSkillis resolved once on that same plan, so every item in a batch carries the one resolved value. No per-item gap.- Seed-once is genuinely forward-only.
seedProjectAuthoringSkillreturns early on an existing stamp, and an invalid slug is dropped rather than written. Worth noting the read path normalizes too (readProjectConfig→normalizeConfig→normalizeSkillSlug), so a hand-edited garbage value can't reach telemetry and doesn't wedge the seed — the guard sees the normalizedundefinedand heals the stamp on the next--skillrender. That interaction is easy to get wrong and it's right here.
One thing worth fixing before merge: this is the first writer that overwrites an existing hyperframes.json.
Every pre-existing writeProjectConfig call site is guarded to write only when the file is absent — init.ts behind if (!existsSync(...)), and both add.ts sites behind !hasConfig / !existsSync(projectConfigPath(...)). So until now the whole-file overwrite in writeProjectConfig was safe by construction: there was never anything on disk to lose. seedProjectAuthoringSkill breaks that invariant — it reads, normalizes, and rewrites a file the user already has, on the first render --skill in any existing project.
The problem is the round-trip. normalizeConfig builds its result from an explicit whitelist — $schema, registry, paths.{blocks,components,assets}, media.autoProxy, authoringSkill — with no ...partial spread, and writeProjectConfig replaces the file wholesale. So the seed writes back only what the whitelist knows about.
Being precise about severity, because it splits in two:
- Immediate, every affected project: the render rewrites key order and materializes a
media: { autoProxy: ... }block into projects that never had one.hyperframes.jsonis normally committed, so users get a config diff they didn't ask for, produced as a side effect of rendering rather than by any config command. Cosmetic, but surprising in exactly the way that generates bug reports. - Latent, and the reason I'd fix it now: any key outside the whitelist is silently dropped. That's no loss for a strictly conforming config today, but it makes the seed a standing hazard as the schema grows — and this PR is itself extending the schema. A field added in a later version gets deleted by any render running a CLI that predates it, silently, on a file under version control.
The fix keeps the seed surgical: read and parse the raw JSON, set authoringSkill on it, write it back, rather than round-tripping through normalizeConfig. Then unknown keys and existing formatting survive, and the only delta is the key you meant to add. The try/catch best-effort posture and the seed-once guard both stay as they are.
One claim I did not verify: the 77–96% weekly-attribution-gap figure is telemetry-side and not checkable from the diff, so I'm taking it as given rather than confirming it. The code change is consistent with closing that class of gap regardless of the exact magnitude.
Approving — the write-path change above is a small, contained follow-up and everything else here is clean. CI is fully green at ed1927e. Leaving the deeper attribution-chain pass to the parallel review rather than duplicating it.
— Rames Jusso
miga-heygen
left a comment
There was a problem hiding this comment.
SSOT -- Single Resolution Chain, Correct Seed-Once
The attribution resolution is clean: ONE chain, no forks.
explicit --skill flag → persisted authoringSkill in hyperframes.json → undefined
In plan.ts:
const flagSkill = normalizeSkillSlug(args.skill);
const authoringSkill = flagSkill ?? loadProjectConfig(project.dir).authoringSkill;No second copy of this decision exists anywhere in the codebase. normalizeSkillSlug is the single gate for all slug input — init, render, and config read all funnel through it. The regex matches the JSON Schema pattern in docs/schema/hyperframes.json exactly: ^[a-z0-9][a-z0-9-]{0,63}$.
Seed-once semantics — verified
| Scenario | Expected | Actual |
|---|---|---|
init --skill=X |
Stamps config | scaffoldProject spreads { ...DEFAULT_PROJECT_CONFIG, authoringSkill: skill } ✓ |
render --skill=X (no existing stamp) |
Seeds config, uses X | seedProjectAuthoringSkill sees !current?.authoringSkill, writes ✓ |
render --skill=X (existing stamp Y) |
Does NOT overwrite, uses X | Early return on truthy authoringSkill; plan uses flagSkill ✓ |
render (no flag, existing stamp Y) |
Uses Y | Falls back to loadProjectConfig().authoringSkill ✓ |
render (no flag, no config) |
No attribution | Both paths return undefined ✓ |
The ordering in render.ts is correct: createRenderPlan resolves the plan's authoringSkill for THIS render first, THEN seedProjectAuthoringSkill persists for future renders.
Creation workflow coverage
All init-using workflows now pass --skill: embedded-captions, faceless-explainer, general-video, motion-graphics, music-to-video, pr-to-video, product-launch-video. website-to-video and graphic-overlays don't use hyperframes init — appears intentional.
Schema
authoringSkill properly added as top-level field. additionalProperties: false on the root means this addition is required for validation. Covered.
Standards
No bare as T in production code. No non-null assertions. rawSkill: unknown is the correct widening. normalizeSkillSlug is reused at all 4 call sites — no duplicated validation.
CI
All 44 checks pass.
Finding: seedProjectAuthoringSkill lossy round-trip via normalizeConfig
Converges with Rames's review — this is real and worth fixing before merge.
seedProjectAuthoringSkill reads the existing config, runs it through normalizeConfig, sets authoringSkill, and writes the result back. normalizeConfig builds from an explicit field whitelist (no rest-spread), so the round-trip is lossy:
- Immediate: The render rewrites key order and materializes a
mediablock into projects that never had one.hyperframes.jsonis normally committed, so users get a config diff they didn't ask for — caused by a render, not a config command. - Latent: Any key outside the whitelist is silently dropped. No loss for a conforming config today, but it's a standing hazard as the schema grows. A field added by a newer CLI version gets deleted by any render on an older CLI that predates it.
Fix: In the seed path, parse the raw JSON, set authoringSkill on the parsed object, write it back — bypass normalizeConfig entirely. Unknown keys and formatting survive, and the only delta is the key you meant to add.
Verdict
The attribution chain, seed-once semantics, and test coverage are all solid. Fix the lossy normalizeConfig round-trip in the seed path, then ship it.
— Miga
…skill seedProjectAuthoringSkill is the only writer that touches an already existing hyperframes.json — every other writeProjectConfig call site is guarded to write only when the file is absent, which made the whole-file overwrite safe by construction. Round-tripping the seed through normalizeConfig broke that: it rebuilds the object from a field whitelist with no rest-spread, so any key outside the schema was silently dropped, a media block was materialized in projects that never had one, and key order was rewritten. hyperframes.json is normally committed, so a render introduced a diff the user never asked for, and any field added to the schema later would be deleted by a render on an older CLI. Parse the raw JSON, set authoringSkill, write it back, reusing the file's own indentation. Unknown keys and formatting survive; the only delta is the key being added. A corrupt config is now left untouched instead of clobbered. Seed-once semantics are unchanged, still normalized so a hand-edited garbage slug neither reaches telemetry nor wedges the seed. Reported independently by both reviewers on #2762.
|
Good catch — you were both right, and the whitelist rebuild was worse than cosmetic. Fixed in Reproduced it first to pin the severity. Given a hand-written config: {
"registry": "https://example.com/my-registry",
"paths": { "blocks": "src/blocks", "components": "src/fx", "assets": "media" },
"myTeamSetting": { "reviewer": "wenbo", "keep": true },
"futureSchemaKey": 42
}one flag-less render silently deleted Rames — your framing of the broken invariant was the useful part: I had not noticed that every other Fix is your prescription: parse the raw JSON, set
On a real Seed-once semantics unchanged, including the normalize interaction you called out — a garbage slug still neither reaches telemetry nor wedges the seed. 4 regression tests added, all locking the failure mode rather than the fix: unknown keys survive, no Miga — that leaves your deep attribution-chain pass as the only thing outstanding from my side; happy to hold for it. And noted on the 77–96%: correct to take as given, it is not derivable from the diff. It is |
Addressed in f10cb5d — seed now patches raw JSON in place.
miga-heygen
left a comment
There was a problem hiding this comment.
Re-review — normalizeConfig round-trip fixed
The new commit (f10cb5d0) addresses the blocking finding cleanly:
- No more lossy round-trip: Seed now parses raw JSON, sets
authoringSkillon the parsed object, writes back. Unknown keys survive. ✓ - No materialized defaults: Only
authoringSkillis added — nomediablock, no$schema, no key reordering. ✓ - Indentation preserved: Detects the file's own indent from existing content (
/ ([ ]+)"/). ✓ - Corrupt config guarded: Non-object, null, or array configs leave the file untouched (early return, not a clobber). ✓
- New-file path preserved: When no file exists, still uses
writeProjectConfigwithDEFAULT_PROJECT_CONFIG— correct, nothing to preserve. ✓ as Record<string, unknown>: Behind a proper type guard (!== null && typeof === "object" && !Array.isArray). Valid. ✓
Four new tests directly lock down the exact issues Rames and I flagged: unknown keys preserved, no media block materialization, indentation reuse, corrupt config untouched. Thorough.
CI note
CodeQL flags existsSync → readFileSync as a TOCTOU race (projectConfig.ts:184). The catch block makes this safe in practice (file disappears between check and read → readFileSync throws → catch swallows → seed skipped → render unaffected). If you want to silence it cleanly: drop the existsSync, try readFileSync directly, catch ENOENT to fall through to the write-new-file path.
Windows tests still in progress at review time. 43/45 checks pass.
Ship it once CI clears.
— Miga
jrusso1020
left a comment
There was a problem hiding this comment.
Re-reviewed at f10cb5d0. The write-path issue is fixed properly, and the fix is tighter than what I asked for.
The lossy rewrite is gone. The seed now patches the parsed JSON in place instead of round-tripping through normalizeConfig, so unknown keys survive, no defaults get materialized into a file the user never wrote, existing key order is preserved with the new key appended last, and the file's own indentation is reused. Two cases I didn't ask for and am glad to see handled: a non-object top-level JSON value (a valid JSON array or scalar) returns early rather than having a property stapled onto it, and a corrupt file is left byte-exact rather than clobbered by a render.
The four new tests are real enforcement, not comments that assert a guarantee. Each one fails if the normalizer round-trip comes back: preserves config keys outside the known schema loses myTeamSetting/futureSchemaKey, does not materialize a media block pins the exact key set and order via Object.keys(...)).toEqual([...]), the indentation test pins 4-space input, and the corrupt-config test asserts the original bytes. That's the distinction that matters, and these are on the right side of it.
The subtle thing that survived the rewrite: the seed-once guard still normalizes what it reads (normalizeSkillSlug(raw.authoringSkill)) rather than testing the raw value for truthiness. So a hand-edited garbage slug still neither reaches telemetry nor wedges the seed permanently, and the next --skill render heals the stamp. Switching to raw-JSON access is exactly where that behavior would have been easy to drop by accident.
One new item, non-gating. The added existsSync(path) followed by readFileSync(path) trips CodeQL's file-system-race rule at the seed, and that scan is now red where it was green before this commit. The practical impact is nil: a delete inside that window throws ENOENT straight into the existing catch, so attribution is skipped and the render is unaffected, and this is the user's own project directory with no privilege boundary in play. But rather than annotate around it, the rule's own recommendation is to not check-then-use. Dropping existsSync and letting a single readFileSync throw, treating ENOENT as "no config yet" and falling through to the create path, collapses it to one filesystem operation and clears the alert in about two lines.
Worth being precise that this does not block: CodeQL is not among the required status checks, and all eight that are required are green at this head.
One trivial edge, purely for completeness. The indent probe falls back to two spaces when there's no \n<indent>" match, so a single-line or minified config would come back pretty-printed. That's strictly better than the previous behavior and vanishingly unlikely for a generated file, so I'd leave it alone.
Approving. Nice, contained fix.
— Rames Jusso
miguel-heygen
left a comment
There was a problem hiding this comment.
Re-review at exact head f10cb5d0dfc57052467aab45d695c0eea4889a2f.
Audited: the ed1927e → f10cb5d0 delta in packages/cli/src/utils/projectConfig.ts and projectConfig.test.ts end-to-end, plus current required-check rules/status.
Trusting: the unchanged attribution-chain/schema/skill-callsite surface to the prior Rames + Miga passes; I re-checked its integration points into this seed path.
The lossy round-trip blocker is closed:
seedProjectAuthoringSkillnow parses and patches the raw object, preserving unknown keys, original key order, and absence of defaults instead of rebuilding throughnormalizeConfig.- Non-object/corrupt configs remain untouched; fresh configs still take the normal default-config writer path.
- The seed-once guard still normalizes the existing slug, so an invalid hand edit cannot reach telemetry or permanently wedge later healing.
- The four new tests pin the actual failure modes: unknown-key survival, exact non-materialized key set/order, indentation reuse, and byte-preserved corrupt input.
Non-blocking CI note: CodeQL is the sole red check, and the repository ruleset confirms it is not one of the eight required contexts; the PR is correctly MERGEABLE / UNSTABLE, not CI-blocked. Its existsSync → readFileSync alert can be silenced by trying the read directly and handling absence, while the current catch already preserves the best-effort render contract.
Verdict: APPROVE
Reasoning: The existing-config writer is now surgical and regression-pinned, the attribution semantics remain intact, and every required status check is green at this exact head.
— Magi
The `--docker` build context was created at a guessable path derived from `Date.now()` in the world-writable OS temp dir. Another local user can pre-create or symlink that path and have the build read a Dockerfile they control. mkdtempSync gets a random suffix and 0o700 from the kernel, and it creates the directory itself, so the separate mkdirSync goes away. Pre-existing on main (alert #432, 2026-06-04, packages/cli/src/commands/render.ts), surfaced against this branch only because the seed commit shifted line numbers in the same file. Fixed here to unblock the CodeQL gate on #2762 rather than left for a follow-up; the remaining 10 js/insecure-temporary-file alerts elsewhere in the repo are untouched and still want their own pass.
The seed tested for the config with existsSync and then wrote, which is a check-then-use race: the file can be created or swapped between the check and the write (CodeQL js/file-system-race). Read once and branch on the failure reason instead. Only ENOENT creates a config from scratch; any other read failure (permissions, I/O) now leaves an existing file alone rather than overwriting it with a default, so this is also strictly safer than the version it replaces. Also replaces the `as Record<string, unknown>` assertion with an isJsonObject type guard, per the repo's no-assertion convention. Behaviour unchanged: all 4 seed regression tests still pass, and the create/preserve/seed-once/corrupt-untouched paths were re-verified end to end.
Summary
The "Skills penetration" tile on the growth dashboard looked like it dropped week-over-week. It's not a skills regression —
authoring_skilltelemetry is structurally incomplete. Today it's stamped only on the first render that goes through a workflow passing--skill, so re-renders,npm run render,--batch, existing-project renders, andgeneral-videolose it or never had it. Across recent weeks 77–96% of every week's real-human render volume carries no attribution, which pins the (render-weighted) metric in the single digits and makes it swing on batch/re-render volume.This makes attribution durable: the owning skill is persisted once in the project's
hyperframes.json, and every later render inherits it without re-passing--skill.Root cause
authoring_skill(packages/cli/src/telemetry/events.ts) is derived solely from the--skillflag increateRenderPlan(packages/cli/src/commands/render/plan.ts). Nothing persisted it, so attribution was lost on the highest-volume paths:npm run renderwrapper (packages/cli/src/commands/init.ts), and--batchnever re-pass the flag;hyperframes-cli(skills/hyperframes/SKILL.md), whose render examples omit--skill;general-videonever passed--skillat all.Verified against PostHog (project 356858): high-volume installs are overwhelmingly 0%-attributed, and the 07-12 → 07-19 "drop" is a partial current week plus a ~4× surge in distinct rendering installs (a broad long tail, not a few whales) — not a regression.
Fix
Persist the owning skill in
hyperframes.json(ProjectConfig.authoringSkill):hyperframes init --skill=<slug>stamps it into the project config at creation.hyperframes renderresolves the explicit--skillflag → else the persisted value, so flag-less re-renders /--batch/npm run renderstay attributed to the workflow that created the project.--skillon render seeds the stamp — seed-once: it never overwrites an existing owner (a one-off--skillstill governs that render's telemetry but does not rewrite the project's identity).initcall.Test plan / Validation
packages/cli/src/utils/projectConfig.test.ts— +6 tests (slug-gate on read, seed-once, create-if-missing, preserves other config fields)packages/cli/src/commands/render/plan.test.ts— +2 tests (flag-less render inherits the persisted stamp; explicit--skilloverrides it)oxlintandoxfmtclean on all changed files;tsc --noEmitclean on all changed filesdocs/schema/hyperframes.jsonupdated (schema isadditionalProperties:false, so the new field is required there)Scope / limitations
slideshowis intentionally out of scope: it outputs apresentdeck /snapshotstills, never an MP4, so it emits norender_complete. Domain skills (hyperframes-core, media-use, …) are correctly not counted — they are building blocks, not authoring workflows.