* fix(capture): let vision captioning authenticate the way a server can
Three defects in one phase, all of which end with a capture that reports
"Captioned N/N images" and then "0 images captioned with Gemini" — a
successful-looking run that hands the agent nothing to see by.
1. Credential. The captioner only accepted an API key. A server deployment
holds a service account, not a key, and a rejected key is indistinguishable
from an unset one here: every request returns empty text and no error. Vertex
is now a first-class provider, ranked above the bare key and below an explicit
OPENROUTER_API_KEY opt-in, configured by HYPERFRAMES_VERTEX_SERVICE_ACCOUNT +
HYPERFRAMES_VERTEX_PROJECT_ID (region via HYPERFRAMES_VERTEX_LOCATION). It
carries its own model default because the Gemini API's flash-lite preview id
is not resolvable on Vertex.
2. Empty captions. Thinking tokens are drawn from maxOutputTokens, so a model
left free to think can spend the whole budget and return no text — a
successful request with no caption. thinkingBudget is pinned to 0; a one-line
factual caption needs no reasoning.
3. Native abort. Rasterizing a batch of SVGs concurrently drove up to SVG_BATCH
simultaneous librsvg renders through libvips and corrupted the heap:
`free(): unaligned chunk detected in tcache 2` (SIGABRT) during this phase,
twice in fourteen days, losing the whole capture each time. A native abort
cannot be caught, so the concurrency is removed rather than handled —
rasterization is serialized and libvips' worker pool is bounded, while the
vision requests, which are the slow leg, stay parallel. Throughput barely
moves: 225 captions across three real captures, 0 failures, 13-25s each.
* test(capture): pin the rasterization loop to one render at a time
The serialization fix shipped without a regression test on the grounds that native
heap corruption is not unit-testable. The corruption is not, but the property that
prevents it is: `sharp` is mocked to record how many renders are in flight, and a
six-SVG batch must never reach two. A deliberately slow caption stub makes
overlapping renders the faster path, so a future refactor that "optimises" the loop
back to `Promise.all` fails here instead of aborting in production.
Also covered: `sharp.concurrency(1)` is applied — serializing the loop while leaving
libvips' pool at the host core count still fans one render across every core — and an
unrasterizable SVG is skipped without breaking serialization for its siblings.
Verified as a real guard: reverting only contentExtractor.ts to origin/main fails 7 of
the 22 cases in this file.
* fix(capture): tell the truth in the asset-descriptions header when Vertex captioned
The provider gate in `contentExtractor` accepts Vertex when a project and a
service account are both set -- which is the configuration a server
deployment actually has. The header written next to the captions still
tested only for an API key, so a capture whose captions Vertex had just
generated was labelled "GEMINI_API_KEY not set -- descriptions below are
catalog-derived".
That header is not cosmetic: it travels into the context the template
editor reads, telling it to distrust captions that are real.
Mirror the same two variables here, and name every provider in the fallback
text instead of only the API key.
* fix(capture): hand libvips' worker pool back after the renders
`sharp.concurrency(1)` is process-global and was set once, for the whole life of
the process. The bound is right for the rasterize loop -- a native abort in
libvips cannot be caught, so the renders must not overlap -- but its scope was
every later sharp caller in the process, none of which asked for captioning, all
of them pinned to one thread from then on.
Now the host's value is read first and restored in a `finally` around the
rasterize loop, so a skipped SVG cannot cost the process its threads either. The
vision requests below are network work and gain nothing from a pinned pool.
The mock had to grow the getter half of sharp's API -- `concurrency()` with no
argument reports the current value -- since save-and-restore is untestable
without it. Verified as a real guard: dropping only the restore fails both new
cases.
Raised by Rames Jusso in review of #3561 and concurred by Magi.
A card centered at y=.860 can still cover the painted V2A pill. Intersect the element's getBoundingClientRect with the keepout instead of testing whether its center sits inside the band.
Rebase #3529 onto current main. Preserve all 16 issue-scoped Studio server, lint, and CLI child-process windowsHide options, including main's PowerShell null guards and stderr suppression in orphanCleanup.
Regression tests continue to assert windowsHide at each scoped spawn site. #3476 and #3430 remain out of scope.
Co-authored-by: heygengenesis[bot] <262951085+heygengenesis[bot]@users.noreply.github.com>
Co-authored-by: miguel.sierra <229591595+miguel-heygen@users.noreply.github.com>
## Summary
`processIdentity` and `processParentPid` call `Get-CimInstance Win32_Process` to look up process metadata on Windows. When the target process has already exited, `Get-CimInstance` returns null and calling `.CreationDate.ToFileTimeUtc()` or `.ParentProcessId` on it throws `InvokeMethodOnNull`. The try/catch handles it, but PowerShell writes the error to stderr, which pollutes the test runner's output and causes spurious exit code 1 on Windows CI.
Two fixes per call site:
- Null-check the CimInstance before accessing properties (`$p = ...; if ($p) { $p.Property }`)
- `-ErrorAction SilentlyContinue` + `stdio: ["pipe", "pipe", "ignore"]` to suppress any residual stderr
Fixes the recurring `Tests on windows-latest` flake on main.
## Test plan
- [x] All 196 CLI test files pass locally
- [ ] Windows CI should no longer exit 1 from PowerShell stderr noise
— Miga
* fix(cli): keep phrase-level CJK and Thai transcripts as separate cues
wordsToCues inferred whether entries were already grouped into phrases
by testing for internal whitespace. Chinese, Japanese, Thai and the
other scripts written without inter-word spaces never satisfy that
test, so their phrase-level transcripts were treated as word-level and
re-grouped into a single cue covering the whole clip.
A three-phrase Chinese transcript produced one cue; the same transcript
in English produced three. The failure was silent: the export
succeeded, and the user found out by watching the captions.
For entries with no whitespace at all, fall back to entry length when
they are in a spaceless script. Whisper emits word-level tokens for
those scripts one or two characters at a time, while a phrase-level cue
runs to several times that. The median is used so one long token cannot
declare word-level input pre-grouped, and a couple of short cues cannot
declare a real transcript word-level.
--preserve-cues still forces the same thing, and behaviour for
space-separated scripts is unchanged.
Fixes#3353
* test(cli): pin the spaceless phrase length threshold
Root cause: the per-worker capture calls in captureFrameRange
(parallelCoordinator.ts) take no abort signal of their own, and only
checked `signal.aborted` BEFORE starting each frame — a no-op once a
worker is already awaiting an in-flight call. On WSL2, the native
drawElement/BeginFrame capture call can hang indefinitely at frame 0
with no error. The DE parallel-router's existing stall watchdog
(captureStreamingStage.ts) correctly fires `stallController.abort()`
after HF_DE_STALL_MS, but that abort had no way to reach a
worker already wedged inside a hung capture call — so
executeParallelCapture's Promise.all waited forever, the render hung
indefinitely, and the CLI's circuit breaker (which only runs after
executeRenderJob settles) never got a chance to trip.
Fix: race each per-frame capture call against the signal actually
firing (raceAgainstAbort), the same "can't cancel, only race" pattern
already used by the sequential capture path. Once the watchdog's abort
is observed, the wedged worker rejects, executeParallelCapture settles,
and the existing pinned-fallback retry / "reverted" outcome / circuit
breaker machinery (already correct) runs end to end.
Also widen the CLI breaker's trip condition from the literal string
"reverted" to "not a clean routed success", so any future non-success
outcome the observability layer records also latches the breaker
instead of silently falling through.
Closes#3441
Co-authored-by: Miga <noreply@anthropic.com>
Classify each <audio> element before Web Audio capture: same-origin,
CORS-opted-in, or a non-http(s) scheme stays on the primary
createMediaElementSource() path; cross-origin media without a
crossorigin opt-in withholds that call (the Web Audio spec makes such a
node output silence without throwing) and falls back to fetch +
decodeAudioData, preserving the FX graph whenever the server allows
CORS. Recheck the route at the transport's irreversible capture
boundary, and account for currentSrc, src, and <source> candidates the
same way the HTML resource-selection algorithm does.
Emit a stable preview diagnostic (`runtime_web_audio_bypass`) at media
discovery time, not only from playback scheduling, so `hyperframes
check` — which seeks but never plays — can surface it as a
`web_audio_bypass` finding. Diagnostics are suppressed during export
rendering, where the producer mixes audio offline and already applies
the FX chain. The existing non-unit-rate fail-closed rule stays scoped
to fx-chain/automation so this fix does not newly mute grouped or
above-unity tracks.
Takes over #3459 with the data-native-audio escape hatch removed per
review feedback: the automatic cross-origin detection already covers
the cases that mattered, so the extra per-element opt-in attribute,
its route-classifier branch, and its diagnostic path are dropped in
favor of a single automatic behavior.
Fixes#3458
Original-Author: desenmeng
Co-Authored-By: desenmeng <desenmeng@users.noreply.github.com>
Co-Authored-By: Miga <noreply@anthropic.com>
`registry_item_added` fires when a catalog block is installed and
`render_complete` fires when a video is produced, but nothing joined them, so
"did this video use the catalog?" had no answer.
`hyperframes add` now records each installed item in `hyperframes.json`
(installed files are plain composition HTML with no provenance marker, so this
manifest is the only record that a file came from the registry), and
`render_complete` reports both the items the project installed and the blocks
the rendered composition actually reaches. An item installed and then never
mounted was tried and dropped, which no add-time event can express.
The scan answering "which sub-compositions does this file mount" now has one
owner, `collectSubCompositionSrcs` in `@hyperframes/parsers`, shared with
lint's `lintMissingOrEmptySubComposition`. It holds two invariants that were
previously restated per call site and got re-derived wrongly: it is a text scan
rather than a DOM query, because `<template>` content is inert and every
sub-composition except the render entry is wrapped in one; and references
resolve root-relative at every nesting level, matching `parseSubCompositions`.
It walks tag by tag rather than running open-ended spans across the whole file,
so a malformed composition cannot stall the render plan.
Also: `registryItems` is declared in the config schema, which closes with
`additionalProperties: false`, with an ajv-backed test pinning every key the CLI
writes; counts are never truncated by the name cap, and the reported used blocks
stay a subset of the reported installed ones, with `registry_items_truncated`
marking a windowed list; and an unreadable manifest reports itself rather than
posing as a project that never used the catalog.
The runtime absorbed a series of authoring mistakes over time and `runtime/init.ts`
says so in its own comments, but the skills kept teaching the old rules. Four of
them actively cost an agent a failing run: add `crossorigin` (lint rejects it
unconditionally), never build a timeline inside `async` (lint calls that the
documented contract), never `gsap.set` later-scene clips (two fixHints instruct
exactly that), and 12 copyable media snippets with no `id`, which render silent.
Corrected in every place each claim appeared, including `hyperframes-animation`,
three workflow scripts, the scaffolded project instructions, the CLI `docs`
command, and the public docs site: `data-track-index` is a Studio display lane
the render never reads, `class="clip"` is a layout convention rather than a
visibility requirement, timed elements may nest, the visibility window is
half-open, sub-composition host dimensions are backfilled, and the root-fill rule
applies only to the layered-composite path.
Behaviour changes, each backed by a render rather than by reading code:
- `timeline_registry_missing_init` deleted. The runtime creates the registry
before any inline script; a composition without the guard line renders and
animates correctly.
- `video_nested_in_timed_element` kept, message corrected. A rendered repro shows
the nested-with-local-start case really does break, so the rule guards a real
defect, but nothing is "FROZEN": the extractor ignores the wrapper's offset
while visibility uses it, so the clip shows wrong frames and then vanishes.
- `mediaRenderIds` now stamps media whose source is a `<source>` child, closing a
duplicate-id gap the old `[src]`-only selector left open.
- Stale messages fixed on `subcomposition_root_styled_by_class` and
`deprecated_data_layer`.
`coreSkillContent.test.ts` pinned the literal sentence that made root
`data-start` look required, so it is narrowed to structure plus the regression it
genuinely catches.
Not covered, and flagged in the PR: the media global-vs-local start heuristic in
`runtime/init.ts` is the root cause behind the nested-video defect. Removing it
changes the meaning of existing compositions and needs its own deprecation.
* fix(core): harden audio FX and group identity
* fix(core): address audio group review feedback
* fix(core): align preview transport with grouped audio
* test(core): pin audio group gain ceiling
* fix(core): preserve solo bridge through stack
* fix(engine): harden grouped audio rendering
* docs(engine): explain grouped mix fallback invariant
* test(engine): allow grouped mixes to finish on Windows
* feat(lint): validate audio group membership and timing
* test(lint): pin audio group membership guards
* fix(studio): unify audio IDs and group state
* fix(studio): make audio-group edits transactional
* fix(studio): keep preview state synchronized
* fix(studio): align audio rows, automation lanes and headers
* fix(studio): stabilize timeline audio derivations
* refactor(studio): simplify group metadata memoization
* style(studio): keep timeline layout within size gate
* fix(studio): keep timeline preset apply off auditions
* fix(studio): harden carve and FX rack behavior
* fix(studio): repeat audio FX reveal requests
* fix(studio): reconnect property-panel audio controls
* fix(studio): unify property panel audio detection
* fix(studio): satisfy panel and deletion gates
* feat(studio,core)!: remove solo and the group meter
* docs(audio): keep removal rationale current
* refactor(core): retire studio solo bridge
* docs(audio): document grouped audio and its guardrails
* docs(audio): point handoff at replacement stack
Closes#3370
## What
When `hyperframeRuntimeLoader` could not locate `hyperframe.manifest.json`, the loader reported a single fallback path that was never searched for (`/usr/local/lib/core/dist/hyperframe.manifest.json`). Inside a Docker render the user is then told to look at the wrong directory; the file that was actually missing (`/usr/local/lib/node_modules/hyperframes/dist/hyperframe.manifest.json`) was nowhere in the message.
## Why
`resolveHyperframeManifestPath()` built a 5-element `candidates` array, walked it with `existsSync`, and on total miss returned the last candidate. The error then quoted that candidate verbatim. The reporter even shows the exact reproducing command from a published image.
A second issue rode the same failure path: `packages/cli/src/commands/render.ts:902` keeps attaching the hint `"Try --docker for containerized rendering"` to users who are *already inside* the container. The container sets `ENV CONTAINER=true` and nothing reads it.
A third small thing came along: `CWD_RELATIVE_MANIFEST_PATHS[0]` was a byte-identical duplicate of `SIBLING_MANIFEST_PATH` — same path, two names.
## How
1. Hoist the candidate list to a single `MANIFEST_CANDIDATES` owner in `hyperframeRuntimeLoader.ts` and share it between the resolver and the error reporter. De-duplicate while doing it.
2. Add `triedManifestPaths()` as a tiny export so callers (and tests) can see what was actually searched.
3. Replace the source-text regex test that asserted on string positions inside `const candidates = [...]` with a behaviour test that points `PRODUCER_HYPERFRAME_MANIFEST_PATH` at a missing file and asserts the thrown error names it. Also exercise the no-override branch to confirm the sibling path is the first entry.
4. In `render.ts`, check `process.env.CONTAINER === "true"` before attaching the `--docker` hint. The chrome-launch and macos-old-chrome remediation branches already short-circuit before the hint, so an empty string is a safe value when the user is in the container.
## Test plan
- [x] `bunx vitest run src/services/hyperframeRuntimeLoader.test.ts` — 7/7 pass (`hyperframeRuntimeLoader error path (#3370)` describe covers the missing-manifest message and the tried-paths export).
- [x] `bunx tsc --noEmit` in `packages/producer` and `packages/cli` — clean.
- [x] `bunx oxfmt --check` and `bunx oxlint` on the touched files — clean.
- [x] `bunx fallow audit --base origin/main` — no new findings on the touched files.
- [x] Targeted producer unit lane: `node scripts/run-test-lane.mjs unit` — same 7 pre-existing failures as `origin/main` before the change (htmlCompiler.parity, audioPadTrim.integration); no regressions introduced.
Files touched:
- `packages/producer/src/services/hyperframeRuntimeLoader.ts`
- `packages/producer/src/services/hyperframeRuntimeLoader.test.ts`
- `packages/cli/src/commands/render.ts`
A render that produced and validated its artifact still exited 1. Reported
again from the field on 0.8.7: the MP4 was on disk and an independent ffprobe
and full decode both passed, and the CLI exited 1 immediately after logging
`artifact validated`.
`render-success-state.ts` exists for exactly this and documents three earlier
cases, so the sentinel was already there. Its gap is which paths read it: the
uncaughtException and unhandledRejection handlers both consult
`isRenderSucceeded()`, but a post-render throw that the command wrapper CATCHES
never reaches either. It becomes an ordinary non-zero CommandResult, and
`finalizeCli` wrote that straight to `process.exitCode`.
The result was a run that disagreed with itself: `commandSucceededForTelemetry()`
already lets a validated render override a failure, so telemetry recorded
success while the shell saw exit 1.
Sanitize once in `finalizeCli`, where every command result funnels through,
rather than wrapping the individual steps. Which step threw does not matter;
that the artifact is committed does. The throw is still printed, so it stays
visible for diagnosis without being fatal.
Reproduced first as a failing test (`expected 1 to be +0`) on macOS, so this is
not Windows-specific — the field reports are one instance of it. A second test
pins the other side: a command that throws with no validated render still exits
non-zero, so the sanitizer cannot swallow a genuine failure.
* fix(studio): invalidate the preview signature off the watcher that sees project writes
The preview ETag is a hash of the project's files, memoised per project
directory. That cache was cleared from Vite's own watcher, which
`server.watch.ignored` deliberately excludes `data/projects/**` from, so
nothing ever cleared it: the ETag stayed frozen for the life of the dev
server, the preview answered every revalidation with 304, and the browser
went on serving the composition as it was when it first loaded.
The visible cost is thumbnails. Their disk cache key already content-hashes
the composition, so an edit correctly asks for a fresh capture, but the
capture is taken against the stale page, and a clip's filmstrip keeps
showing frames of a layout that no longer exists until the dev server is
restarted.
Studio already runs its own chokidar watcher over exactly these
directories, because Vite's would answer a composition edit with a full
page reload. That watcher now owns the invalidation, and the cache asks it
to follow any project directory it has not seen. All five event types
count: an added or deleted asset changes the signature as surely as an
edited one.
The cache moves behind `createProjectSignatureCache` so the invalidation
rule is a unit under test rather than a subscription buried in the adapter.
* fix(studio): filter signature invalidation, and stop the CLI server missing motion saves
Review follow-up on the unfiltered invalidation.
The watcher fired on everything under a project dir, but the signature walk
skips 14 directories and `.thumbnails` is one of them. That directory is
where the thumbnail route keeps its disk cache, and every capture also reads
the preview, so populating a timeline row discarded the memo on roughly every
request of the one workload it exists for.
The filter is a single exported predicate beside the exclusion set it reads,
and it is applied inside `invalidate` rather than at the watcher, so no caller
can subscribe and forget it. It is deliberately not `WATCHER_EXCLUDED_DIRS`:
that set is character-identical but drops all of `.hyperframes/`, and the
signature reads two manifest files back out of there.
Which is the same bug, still live, in the CLI server: its watcher filters
through `shouldWatchProjectFile`, so `.hyperframes/studio-motion.json` never
reached the listener that clears the cached signature. Studio writes that file
at runtime, so saving motion state left the preview ETag stale until restart.
The watcher now admits signature-relevant paths and the reload listener
re-applies its own filter, so what triggers a browser reload is unchanged.
Also from review: drop the `createViteAdapter` signature-cache default, which
produced exactly the memo-nothing-clears bug this PR fixes, and correct the
docstring — the content hash is already gated behind a stat fingerprint, so
what the memo saves is the walk.
Windows users see a console window per chrome-headless-shell worker during a
render. Those windows come from Puppeteer's own launcher, not from any spawn
in this repo, so the windowsHide work on our ffmpeg spawns could not reach
them.
@puppeteer/browsers added windowsHide: true to its spawn in 3.2.1. It is
absent in 3.1.0 and 3.2.0. puppeteer-core pins that dependency exactly, and
25.8.0 is the first release pinning 3.2.1 (25.5.0 -> 3.1.0, 25.6.0 and
25.7.0 -> 3.2.0), so 25.8.0 is the minimum that carries the fix rather than
a preference for the latest.
Verified after install that exactly one copy resolves, at 3.2.1, and that its
launcher carries the flag. A draft render still completes.
Refs #3379
Picks up crbug 522872457's fix (CL 8032671), which landed after the
152.0.7935.0 canary cut and so was absent from the old 152.0.7928.2 pin.
Re-probed every 3D signal the compile gate matches, drawElementImage vs a
CDP screenshot of the identical state, on the shipping headless-shell
binary. PSNR, old pin -> new pin:
backface-visibility:hidden 1.4 dB -> 14.8 dB still DAMAGED
preserve-3d (no backface) 46.7 dB -> 46.7 dB clean
perspective() 45.2 dB -> 45.2 dB clean
matrix3d() 45.2 dB -> 45.2 dB clean
rotate3d() 45.3 dB -> 45.3 dB clean
translateZ under perspective 29.9 dB -> 29.9 dB marginal
The upstream fix repaired the collateral damage only: dropped sibling
content and lost backgrounds now render, but a culled backface is still
painted. So the 3D gate stays. Beta rather than Canary because
153.0.8000.0 measured identical on every variant.
Follow-up filed as PRINFRA-486: four of the five signals the gate matches
were never broken on any build tested, so it may be able to narrow to
backface-visibility alone. Needs a corpus eval first — this probe covers
static angles only, and animated 3D subtrees take a different path.
* fix(cli): surface HYPERFRAMES_BROWSER_PATH hint on Windows chrome-headless-shell launch crashes
Field feedback (#hyperframes-cli-feedback ts=1784116246, win32/x64, CLI 0.7.58) hit
`Failed to launch the browser process ... Code: 3221225595` with no stderr. Exit
code 3221225595 = 0xC0000409 = STATUS_STACK_BUFFER_OVERRUN, a Windows stack-
corruption fatal from the pinned chrome-headless-shell binary. The reporter
recovered by pointing HYPERFRAMES_BROWSER_PATH at system Chrome; render then
used the screenshot fallback and produced the MP4 cleanly.
The generic "Try --docker" hint the CLI already emits didn't name that env var,
so the workaround was undiscoverable. Add a Windows-scoped launch-crash
remediation sibling to `chromeLaunchRemediation` (Linux, `linuxDeps.ts`) and
`wrapDownloadFailureWithBrowserPathHint` (download-time, `manager.ts` — #2443).
Fresh concrete case for the #2078 lineage (closed with explicit invite to
resubmit on a concrete case).
- New `packages/cli/src/browser/windowsCrash.ts` — `isWindowsChromeCrashError`
gates on Puppeteer's `Failed to launch the browser process` wrapper AND the
specific crash code (decimal `3221225595`, hex `0xC0000409`, or symbol
`STATUS_STACK_BUFFER_OVERRUN`), so unrelated Windows launch failures don't
mis-fire this hint. `windowsChromeCrashRemediation` returns the actionable
block scoped to win32.
- `render.ts` `handleRenderError` calls it after the existing
`chromeLaunchRemediation` (Linux) check; both fall through to the generic
errorBox if neither matches.
- Tests: 9 vitest cases covering positive matches on all three code forms,
negative on Linux-shared-lib launch failures, negative on the code alone
without the launch wrapper, and off-platform / non-launch short-circuits.
— Via
* fix(cli): fail the Windows crash branch through failCommand, not process.exit
`scripts/check-cli-process-ownership.mjs` AST-walks every non-test file
under `packages/cli/src` (bar `cli.ts`) and forbids direct process
termination — only the CLI entrypoint owns exit. The new Windows
chrome-headless-shell arm called `process.exit(1)` while both sibling arms
(Linux shared-lib, macOS) and the generic fallback call `failCommand()`,
so the required Lint job failed on that line and preview-regression failed
downstream of its preflight.
`failCommand()` carries the central failure-hook wiring, so this is the
behaviour the branch already wanted.
* feat(telemetry): measure which lint rules fire, cost, and fail to converge
Lint rule changes are currently argued from anecdote. This adds the three
measurements needed to argue them from data.
`lint_report`, once per `hyperframes lint` or `hyperframes check`:
- `code_counts` / `codes` — which rules actually fire, and how often
- `rule_group_ms` — milliseconds per rule-source module (core, gsap, media, ...)
- `slowest_rule` / `slowest_rule_ms` — slowest single rule as `<group>#<index>`
- `rule_count` — how many rules this build ran
`lint_rule_streak`, once per finding that survives an edit to its file:
- `edits` — how many edits the finding survived
- `cleared` — whether it eventually went away
The streak event is the one that matters. A lint pass costs about 5ms, so
per-rule CPU is not what makes the authoring loop slow; a rule an agent cannot
satisfy is, because every failed attempt costs a full edit-and-relint cycle. A
single run cannot see that, so `lint_rule_streak` reconstructs it across runs:
high `edits` with `cleared: false` is a rule nobody can fix, and the
`cleared: true` distribution is the baseline to judge it against.
An iteration is counted only when the file's content digest CHANGED and the
finding is still there. Re-linting an untouched project is not an attempt,
which is what stops `check` (which lints on every invocation) from inflating
the numbers.
Rule identity is the source module plus an index within it. Naming all 86
rules would make the timings prettier but it is a refactor this measurement
does not need: the group locates the file, and the index locates the rule.
Version, agent runtime, CI flag, and invocation id are already attached to
every event by `trackEvent`, so lint pain can be split by CLI version and by
which agent produced it without adding anything here.
Privacy: only rule codes, counts, and timings are sent. Streak state lives in
~/.hyperframes/lint-streaks.json alongside config.json (so `rm -rf
~/.hyperframes` is still a full reset) and stores digests only — no file
paths, no project names, no composition source. Nothing is written and nothing
is emitted when telemetry is off. Entries expire after 14 days and are capped
at 500 files.
`EventProperties` gains string arrays and numeric maps. `codes` and
`code_counts` are inherently a set and a histogram; flattening them into
dynamic top-level keys would make them unqueryable. PostHog stores both
natively.
`trackLintRun` is the single call site shared by `lint` and `check`, and it
swallows every error — telemetry must never turn a green lint red.
* feat(telemetry): emit per-group rule counts so slowest_rule stays comparable
Review catch on #3367: `slowest_rule` is the one positional key in either
event. It is `<group>#<index>`, so adding or removing a rule renumbers every
later slot in that group and the same string means different rules in two
builds. #3366 does exactly that to 34 of 81 surviving slots, and `rule_count`
alone says only THAT the ruleset moved, not which groups.
`rule_group_counts` carries the per-group sizes alongside it, so a consumer
comparing two builds can tell which groups' indices still mean the same thing
without anyone having to remember which release dropped rules. `codes`,
`code_counts` and `rule_group_ms` are keyed by name and were never affected.
Also corrects the rule count in the RULE_GROUPS comment: 86, not ~60, as
LINT_RULE_COUNT in the same file computes.