* fix(reactive): truthful visibleRange/scrollPosition; dragToSeek toggle fixes
R1: scroll-stream gains refresh()/shared readScrollData (maintainer WIP,
preserved); Renderer.render()/onContainerResize() call it so visibleRange
is truthful immediately after layout, without needing a DOM 'scroll' event.
Adds Renderer-level failing-first coverage (stub metrics before render,
assert truthful endTime with no scroll event; zoom() updates it too) on top
of the maintainer's scroll-stream unit tests.
R5: fixes the setOptions dragToSeek object-form bug (maintainer WIP,
preserved) and completes the intended per-drag child-scope design: initDrag()
creates a `dragScope = this.scope.child()` and registers the drag
stream/effect on it; the new disposeDrag() disposes that scope and nulls the
stream fields. Replaces the now-redundant disposeDragStream field (a disposed
child scope detaches itself from its parent, so nothing else needs to track
its remover). Adds tests for both-direction runtime toggling of the object
form and a disposer/listener-balance assertion across repeated toggles.
R4: wires the renderer's existing scroll handler to
actions.setScrollPosition(scrollLeft); composes Player's mutedSignal into
WaveSurferState as `muted`, parallel to volume; documents `progress` as a
historical alias of currentTime. createScrollStreamWithAction was already
deleted in the maintainer's WIP (verified via grep, no remaining references).
The six no-caller state actions (setCurrentTime/setDuration/setPlaying/
setSeeking/setVolume/setPlaybackRate) are kept rather than deleted: they're
pinned as public contract by wavesurfer-state's dedicated standalone tests,
so they're marked @internal-style (no caller in WaveSurfer's own wiring,
which writes through Player instead) rather than removed.
Gates green: bare jest (545/545), tsc --noEmit clean, eslint clean, full
build succeeds, test:leaks (7/7).
Not signed: no ssh-agent socket available in this sandbox (ssh-add -l
failed to reach the agent) -- committed with --no-gpg-sign; needs
re-signing by the controller before push.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018m5srLP9Jps5svV6NA1XeB
* fix(spectrogram): windowed dirty-flag re-render, sliced worker payloads, error routing, shared crop loop
Task 2 of the post-refactor cleanup plan (R2, R6, R18 parts):
- R2: SegmentManager.renderVisibleWindow() re-arms a pendingRender flag when an
overlapping call bails on the isRendering guard, and gives the viewport one more
pass (reading fresh deps) once the in-flight call finishes, instead of dropping
the bailed call's work for good.
- R6: the windowed worker path slices each channel to the segment's own sample
range (.slice(startSample, endSample)) before postMessage, instead of posting
the full-length getChannelData() view - postMessage's structured clone copies
the whole backing ArrayBuffer of a TypedArray view, not just the viewed range,
so the old code cloned the entire decoded channel on every segment request.
Worker protocol unchanged; startTime/endTime sent are shifted to be relative to
the now-pre-sliced data.
- Unified splitChannels resolution (plugin option falls back to the wavesurfer
instance's own option) across every full-mode and windowed-mode call site via
one effectiveSplitChannels() helper - windowed previously ignored the
wavesurfer-level fallback full mode already had.
- frequenciesDataUrl documented as full-mode-only (windowed already silently
ignored it; honoring it there would fight the segment renderer's own canvases).
- Fire-and-forget renders (throttledRender's render(), scheduleWindowedRender's
renderVisibleWindow(), scheduleQualityUpdate's updateVisibleSegmentQuality())
now route rejections to the plugin's 'error' event via a shared emitAsError
helper instead of leaving them as unhandled promise rejections.
- getFrequencies/calculateFrequenciesForRange no longer recreate a worker once
ctx.scope.disposed - closes a live-Worker leak for a computation that runs
against a destroyed plugin.
- Extracted the duplicated colorIndex/crop pixel-painting loop (full mode's
fillImageDataQuality, windowed's renderChannelToCanvas) into one shared
paintColumnPixels helper in fft.ts (host-agnostic leaf both files already
depend on, avoiding a spectrogram-setup.ts <-> spectrogram-windowing.ts import
cycle), unified on the clamped form - the drift: windowed clamped out-of-range
bin values before indexing colorMap, full mode didn't, so an out-of-range
frequenciesDataUrl value could throw instead of rendering the nearest color.
- Deleted the write-only pendingBitmaps bookkeeping: added to/deleted from/cleared
but its .clear() on destroy never actually cancelled anything (the real guard
is the canvasCtx.canvas.parentNode check in the bitmap's own .then()).
- windowed 'progress' event constant-0 for viewport-only segments: checked the
pre-unification windowed plugin's own getLoadingProgress()/emitProgress() and
confirmed it had the identical behavior (progress only reflects progressive-
loading advancement, never on-demand viewport segment loads) - not a port
regression, left as-is.
All new tests verified failing-first against the pre-fix code before being
confirmed green against the fix.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018m5srLP9Jps5svV6NA1XeB
* fix(spectrogram): exact segment-length reconstruction across worker boundary
Code review fix round 1 on Task 2 (R6 follow-up):
- The windowed worker path's sliced-segment endTime (added in 8b5c2d8) sent
`sliceLength / sampleRate` to the worker, which re-derives its own end sample
via `Math.floor(endTime * sampleRate)`. That division-then-remultiplication
round-trip doesn't always recover the exact sliceLength - for ~5% of realistic
(length, sampleRate) pairs it lands one sample short, silently dropping the
segment's last sample (concrete instance: sampleRate=8000, len=1015 ->
endTime=0.126875 -> floor(0.126875*8000)=1014). Fixed by sending a half-sample
epsilon instead: `(sliceLength + 0.5) / sampleRate` - brute-force verified 0
mismatches across 9 common sample rates x 5000 lengths each, both before (to
confirm the ~5% failure rate) and after the fix.
- Replaced the tolerant `toBeCloseTo` assertion on the worker payload's endTime
with a strict check of the worker's own reconstruction formula
(`Math.floor(endTime * sampleRate)` must equal the slice length exactly), and
added a dedicated adversarial-pairs regression test covering (1015, 8000) and
five other pairs chosen to exercise the same rounding hazard at different
sample rates.
- Added a regression test pinning that the dirty-flag re-arm's coalesced rerun
(spectrogram-windowing.ts, from 8b5c2d8) is skipped once disposed while
pendingRender is set, not attempted against a torn-down host - was previously
covered only implicitly (the guard existed, but no test exercised the
disposed-while-pending-rerun ordering specifically).
Both new/changed assertions verified failing-first against the pre-fix code
before being confirmed green against the fix; the worker-formula test hung
(unresolvable promise, correctly caught by Jest's timeout) rather than merely
asserting false when the disposed-guard was removed, which is itself confirming
evidence the guard prevents unbounded rerun work against a disposed host.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018m5srLP9Jps5svV6NA1XeB
* fix(core): revive WaveSurfer/Player event bridges on post-destroy reuse
destroy() disposes and recreates this.scope/mediaEventScope (WaveSurfer)
and mediaScope (Player), but historically only the constructor ever
called initPlayerEvents()/initRendererEvents()/setupReactiveMediaEvents()
-- so a plain destroy() -> load() reuse (no explicit setMediaElement()
call) left every WaveSurfer/Player event bridge permanently dead:
timeupdate stopped flowing, renderer clicks stopped seeking, play/pause
stopped forwarding, and the reactive state signals (including the Task 1
scrollPosition wiring, which lives in initRendererEvents) froze.
Mirrors Renderer's own ensureInputEvents() one layer up: WaveSurfer gets
an idempotent ensureCoreEvents() (initPlayerEvents + initRendererEvents +
Player's reactive media bridge) and Player gets ensureMediaEvents(),
each guarded by an initialized flag that destroy() resets and the
respective constructor / loadAudio() top calls. setMediaElement() keeps
rebinding unconditionally (it must -- the element itself changed) and
marks its flag initialized afterward so a later ensureCoreEvents() call
in the same reuse cycle doesn't double-register on top of it.
Added failing-first reuse tests in memory-leaks.test.ts (verified red
against the pre-fix code, green after) covering timeupdate flow, renderer
click-to-seek, play forwarding, reactive state tracking, and the Task 1
scrollPosition ledger note. All existing reuse/abort tests stay green.
Gates: bare jest (559/559), test:leaks (7/7), tsc --noEmit, eslint, full
build -- all clean.
Signing: no SSH agent socket available in this environment: committed
with --no-gpg-sign (controller re-signs pre-push per repo convention).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018m5srLP9Jps5svV6NA1XeB
* chore(release): v8 beta versioning, changelog, package exports fixes, docs refresh
Task 4 of the post-refactor cleanup plan (R7, R8, R13 export bits):
- Version bumped to 8.0.0-beta.1. Maintainer-amendable release call -- flag
prominently in the PR body per the plan's self-review checklist.
- Landed CHANGELOG.md at the repo root: the repo had no existing changelog
convention (GitHub releases are auto-generated from commit messages via
.github/workflows/release.yml, no CHANGELOG.md ever existed), so this
creates one with the 8.0.0-beta.1 entry, transformed from
docs/RELEASE_NOTES-scope-refactor.md + PR_DESCRIPTION.md (both phase notes)
plus the three post-review fix commits already on this branch, condensed to
a terse, user-focused, breaking-changes-first entry. Deleted both source
docs (RELEASE_NOTES-scope-refactor.md was git-tracked/force-added;
PR_DESCRIPTION.md was untracked, so a plain rm) -- docs/superpowers/plans/
left untouched (untracked, out of scope).
- tsconfig.json: added "src/**/__tests__" to exclude. Verified dist/__tests__
is absent after a full build, and bare jest is still 559/559 green (ts-jest
resolves test files dynamically per Jest's testMatch, not from the
program's initial file list, so excluding tests from tsc's own build
doesn't affect ts-jest).
- package.json#exports: "types" condition moved first in every entry (Node/TS
resolve the first matching condition key, so "types" must precede
"import"/"require" for type resolution to actually route through it).
Fixed the broken "./dist/*" wildcard: its "types"/"require" templates
appended ".d.ts"/".cjs" onto the full captured subpath (which already
included ".js"), producing paths like "./dist/webaudio.js.d.ts" that never
existed -- a real break, since examples/webaudio-shim.js imports
'wavesurfer.js/dist/webaudio.js' through exactly this wildcard. Narrowed
the pattern to "./dist/*.js" (types + import only; dropped "require" since
there's no per-module .cjs build for internal dist modules under
"type": "module" -- honest absence beats a template that resolves to a
file that was never going to exist). Verified every export template
against real post-build dist files with a new script,
scripts/verify-exports.cjs (also wired as `npm run verify-exports`),
which reimplements Node's actual pattern-key specificity/tie-break
algorithm rather than a naive prefix sort.
- dist/types.d.ts (a rollup-plugin-dts bundle of wavesurfer.d.ts) was built
but referenced by nothing -- not package.json's "types" field, not any
export entry, not any doc. Stopped building it (removed the rollup config
block + the now-unused rollup-plugin-dts devDependency) rather than wiring
it in, since nothing in-repo or in the examples ever pointed at it.
- README.md: added a concise "Advanced / reactive API" section (getState()
signals incl. loadPhase/scrollPosition, getRenderer().getVisibleRange(),
definePlugin(), and SpectrogramPlugin's rendering: 'windowed' merged
option with a pointer to the WindowedSpectrogramPlugin deprecation) in the
existing doc's voice, plus a TOC entry.
- examples/spectrogram-windowed.js: added a deprecation banner comment
pointing at rendering: 'windowed'. examples/spectrogram.js: added a
commented-out rendering: 'windowed'/progressiveLoading usage note as the
merged-option example, alongside the plugin's existing option docs.
- AI_OVERVIEW.md: refreshed the project-structure section with
src/scope.ts, src/reactive/, src/state/, src/define-plugin.ts, and
src/__tests__/, corrected the stale ".eslintrc" reference to
eslint.config.js (flat config) with a note on its resource-acquisition
bans, and added the two-project Jest setup (test:unit vs test:leaks) to
Common Tasks.
Gates green: bare jest (559/559), test:leaks (7/7), tsc --noEmit, eslint,
full build (tsc + rollup) -- all clean. Verified post-build: dist/__tests__
absent, dist/types.d.ts absent, every checked export template resolves to a
real dist file (scripts/verify-exports.cjs), package.json's "files" field
unchanged ([\"dist\"]).
Not signed: no ssh-agent socket available in this sandbox (ssh-add -l
failed to reach the agent) -- committed with --no-gpg-sign; needs
re-signing by the controller before push.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018m5srLP9Jps5svV6NA1XeB
* chore(release): enforce exports verification in the build
Fix round 1 on Task 4 review: scripts/verify-exports.cjs's header comment
claimed it was "not wired into package.json scripts", but it already was
(`npm run verify-exports`) -- corrected the comment. More substantively, an
unenforced verification script rots: nothing stopped a future exports edit
from silently reintroducing a broken template. Appended it to the `build`
script (`... && node ./scripts/verify-exports.cjs`), which also covers
`prepublishOnly` automatically since that just runs `npm run build`.
Verified end-to-end:
- Full chained build (clean -> tsc -> rollup -> verify-exports) runs and
exits 0 against the current, correct exports map.
- Deliberately broke `exports["./dist/*.js"].types` to a template pointing
at nonexistent files (`./dist/*.nonexistent.d.ts`), reran the same chain:
verify-exports correctly FAILed 2 targets (`dist/webaudio.js` and
`dist/scope.js` types) and the chain exited 1, proving a broken exports
entry now fails the build instead of shipping silently. Reverted the
deliberate breakage (package.json restored via backup + diffed to confirm
only the `build` script line differs from before) and rebuilt clean.
Gates green after revert: full build (incl. the new verify-exports tail),
bare jest (559/559), tsc --noEmit, eslint -- all clean.
Not signed: no ssh-agent socket available in this sandbox (ssh-add -l
failed to reach the agent) -- committed with --no-gpg-sign; needs
re-signing by the controller before push.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018m5srLP9Jps5svV6NA1XeB
* chore: debt sweep — single scroll math, one RAF primitive, fft split, typed streams, comment cleanup
R10: delete the duplicate, deprecated calculateScrollPercentages from
renderer-utils.ts (scroll-stream.ts's copy is the only one anything calls);
migrate its one test case with distinct coverage into scroll-stream.test.ts.
R11: port record.ts from Timer to FrameScheduler and delete src/timer.ts
(+ its test + its eslint raw-resource-acquisition allowlist entry). Public
behavior (record-progress emission, start/stop/pause/resume timing) is
unchanged; FrameScheduler's synchronous-first-tick already matches Timer's.
R16: split src/fft.ts. The ~700 fully-typed lines (frequency-scale math,
autoGain/color helpers, colormap/UI helpers, and paintColumnPixels per
Task 2's ledger requirement) move to a new leaf module,
spectrogram-render-utils.ts, out from under fft.ts's blanket @ts-nocheck;
fft.ts is trimmed to just the legacy ES5 FFT constructor. Deletes the dead
dense filter-bank trio (createFilterBank/applyFilterBank/
createFilterBankForScale) and its test coverage, replacing the one
dense-vs-sparse comparison test that depended on it with a narrower
real-spectrum sanity check on the sparse path.
R14: type renderer.ts's scrollStream/dragStream fields (ScrollStream,
Signal<DragEvent | null>) instead of any; widen createDragStream's element
param from HTMLElement to Element so envelope.ts's SVG drag targets no
longer need `as unknown as HTMLElement` casts.
R18 leftovers: delete renderer.ts's write-only resizeObserver field (never
read; its disconnect is already scope-owned); unify envelope.ts's Polyline
class onto its own Scope instead of a hand-rolled subscriptions array run
alongside it.
R12: comment sweep across src/** removing dangling references to
uncommitted plan/task/report artifacts (.superpowers/sdd/** is gitignored)
while preserving the underlying invariants inline. Also fixes one
pre-existing wrong cross-file doc reference in spectrogram-setup.ts found
while editing the same sentence.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018m5srLP9Jps5svV6NA1XeB
* test(spectrogram): independent filter-bank oracle; drop plan-citation comments
Review fix round 1 on the debt-sweep commit: the sparse filter bank's
correctness oracle was lost when the dense-vs-sparse byte-identical
comparison test was deleted alongside the dead dense functions — the
replacement only asserted finite/sized output, and the frequencies oracle
test exercises the same production sparse implementation, not an
independent one.
Restores independence two ways in spectrogram-render-utils.test.ts:
- An analytic invariant per filter: weightLo + weightHi === 1, and linear
interpolation (hzLow*weightLo + hzHigh*weightHi) reconstructs centerHz,
with hzLow/hzHigh/scale recomputed fresh in the test rather than read off
any production internal. Catches a regression in the bin index, weight
formula, or scale divisor, independent of whether centerHz itself is
"right" in absolute terms.
- Golden-value baselines: literal expected {lo, weightLo, weightHi,
centerHz} arrays for three (scale, numFilters, fftSamples, sampleRate)
configs, captured now while the math is known-good and hardcoded as a
frozen baseline (not re-derived at test time). Unlike the analytic
invariant, this also covers the underlying hzToMel/hzToBark/hzToErb
formulas themselves.
Also drops the two `(R16)` plan-citation comments in fft.ts and
spectrogram-render-utils.ts flagged by review, plus every other `(R\d+)`
citation found in a src/ sweep (scroll-stream.test.ts, envelope.ts,
renderer.test.ts, wavesurfer.test.ts) — same class of dangling-artifact
reference R12 targeted, the grep just missed the R-number pattern.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018m5srLP9Jps5svV6NA1XeB
* test: consolidate fixtures, delete tautologies, add missing lifecycle cases
R9: rewrite/delete the memory-leaks.test.ts cases that asserted nothing real
(spy-asserts-itself, toBeDefined-on-always-defined, not-called-when-nothing-emits)
against actual observables (Scope.disposed, EventEmitter listener maps, real
media-event tracking) or drop them where already subsumed by other cases in
the file.
R17: pull shared fixtures duplicated across 6-13 spectrogram/plugin test files
into src/__tests__/helpers/ (createEmitter, a typed FakeWaveSurfer + factory,
an AudioBuffer factory + global stub installer, a matchMedia stub installer),
and move the virtual web-worker mock into jest.config.js's moduleNameMapper
(shared by both the default and leaks projects) instead of seven near-identical
per-file jest.mock(..., { virtual: true }) blocks. Excluded helpers/ from Jest's
default testMatch via testPathIgnorePatterns so it isn't picked up as an
(empty) test suite; already covered by the existing src/**/__tests__ tsconfig
exclude for dist emission.
Added the five missing lifecycle cases: unregisterPlugin (destroy + scope
cleanup), registerPlugin after ws.destroy() (pins current behavior: it
re-registers cleanly on the fresh post-destroy scope), Scope.child() on an
already-disposed parent, batch() with a throwing body (still flushes before
rethrowing) and nested batch() (single flush at the outermost call), and
FrameScheduler restart after stop().
No production code changed; every migrated file's existing assertions are
unchanged.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018m5srLP9Jps5svV6NA1XeB
* test(core): pin dormant-until-reload semantics for post-destroy registration
Review fix (round 1): the registerPlugin-after-destroy() test's comment
overstated coherence, claiming a post-destroy registration behaves "exactly
like a freshly-constructed instance." False: registerPlugin() itself
succeeds structurally (touches only this.scope/this.plugins, neither left
disposed by destroy()), but the media-event -> public-event forwarding
bridge (initPlayerEvents(), gated by coreEventsInitialized) stays dormant
until the NEXT load() call runs ensureCoreEvents() at its top - a real
window where a post-destroy-registered plugin exists but receives nothing.
Strengthened the test to demonstrate both halves of that boundary: dispatch
a real timeupdate on the media element right after destroy() + registration
(asserts WaveSurfer's public 'timeupdate' does NOT fire - dormant), then
load() to revive the bridge and dispatch again (asserts it DOES fire).
Rewrote the leading comment to state the dormant-until-reload semantics
precisely instead of the false "like a fresh instance" claim.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018m5srLP9Jps5svV6NA1XeB
* docs: post-refactor cleanup complete
Final gate for chore/post-refactor-cleanup: all gates green (563 jest
tests, 7/7 leak tests, tsc --noEmit clean, eslint clean, full build incl.
verify-exports clean); grep sweeps from Tasks 1-6 re-run clean (zero
R-number/plan-citation comments, zero timer.ts references, single
calculateScrollPercentages, dist/__tests__ absent post-build).
Close out the roadmap doc with the cleanup phase's summary. Backfill
CHANGELOG.md (written in Task 4, before Tasks 5-6) with two
consumer-visible dist/*.js wildcard export changes that were missing:
Timer's deletion (dist/timer.js no longer built) and fft.ts's split
(dist/fft.js now exports only FFT; scale/color/UI helpers moved to the
new dist/spectrogram-render-utils.js, dead dense filter-bank functions
deleted outright).
Note: commit signed with --no-gpg-sign -- no ssh-agent socket available
in this environment; controller should re-sign before push.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018m5srLP9Jps5svV6NA1XeB
* fix: final review wave — same-duration reuse re-tracking, idempotent refresh, changelog completeness, beta publish tag
Fixes all 5 findings from the final whole-branch review of
chore/post-refactor-cleanup:
1. (Critical) Renderer.visibleRange's auto-tracked computed stayed
subscribed to the disposed pre-destroy scrollStream.percentages forever
after a destroy() -> render() reuse with the SAME audio duration, since
audioDuration.set(duration) no-ops via Object.is when the duration is
unchanged (the most common reuse: reloading the same track) and nothing
else told the computed to recompute and re-subscribe. Added a
streamEpoch signal, bumped in initEvents() every time a new scrollStream
is created and read inside visibleRange's computed body purely to force
it to be a tracked dependency -- guarantees the recompute fires on every
stream rebuild independent of whether audioDuration itself changed.
Regression test: render(dur=42) -> destroy() -> render(dur=42) -> stub
metrics + dispatch scroll -> getVisibleRange() must react.
2. (Important) ScrollStream.refresh() called scrollData.set() with a fresh
object literal unconditionally, so signal.set()'s Object.is check always
saw two distinct objects and always notified -- meaning the Renderer's
public 'scroll' event fired on every render()/resize even when scroll
metrics were unchanged, and any scroll handler that re-renders had a
recursion hazard. refresh() now field-compares scrollLeft/scrollWidth/
clientWidth against the current value and only sets on a genuine change.
Verified the existing R1 truthfulness tests ('visibleRange is truthful
right after render...', 'zoom() updates visibleRange via the same
refresh path') still pass -- both exercise a real metrics change from
construction-time zeros.
3. (Important) CHANGELOG gaps closed with a new "Changed" section: the
exports-map narrowing from "./dist/*" to "./dist/*.js", dist/types.d.ts
no longer being built, createDragStream's HTMLElement -> Element
widening, and ScrollStream gaining refresh().
4. (Minor) Added publishConfig.tag = "beta" to package.json so a plain
`npm publish` of 8.0.0-beta.1 tags it "beta" instead of clobbering the
"latest" dist-tag.
5. (Minor) Added a CHANGELOG "Known limitations" section documenting the
windowed spectrogram's request-side segment-boundary Math.floor rounding
hazard (previously only noted in a test comment) and the dist/*.min.js
missing-.d.ts gap; scripts/verify-exports.cjs now has a knownGap-flagged
case for the latter that prints as an explicit SKIP line in `npm run
build`'s verify-exports output instead of silently passing or failing
the build.
Gates green: bare jest (565/565), test:leaks (7/7), tsc --noEmit, eslint,
full build (clean + tsc + rollup + verify-exports, SKIP line confirmed in
output) -- all clean.
Not signed: no ssh-agent socket available in this sandbox (ssh-add -l
failed to reach the agent) -- committed with --no-gpg-sign; needs
re-signing by the controller before push.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018m5srLP9Jps5svV6NA1XeB
---------
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>