feat(music): spec 03 brain-harness — Claude-driven music discovery + guide harness [spec 03-01] - #8
Merged
Conversation
…03-01] Build spec 03-01 (find + pull only; playback/scheduling deferred to 03-02): - harness.py: BrainTool + Harness (run_task) seam — the general tool-using agent capability (master §3.2), separate from the tool-less Brain so talk brains are not burdened. - brain.py: ClaudeBrain.run_task — a bounded, isolated in-process tool loop. Isolation hardened and verified live against the real model: setting_sources=[], strict_mcp_config=True (else the surrounding environment's MCP servers leak in — observed 135 tools -> 21 -> only ours), tools=[] (no built-ins), so only murmur's mcp tools are present and loaded directly (no wasted ToolSearch turn). Tool results are wrapped in the MCP content shape the SDK requires; the query generator is closed on break for deterministic subprocess teardown. An opt-in MURMUR_HARNESS_DEBUG env var dumps the loop's messages. - contracts.py: widen MusicProvider to search + resolve; add TrackCandidate. - music/: context (render_context cache-split: persona->system, situation->turn), provider (yt-dlp adapter + pure parse helpers), tools (search_music + terminal submit_pick), programmer (next_track find-and-pull). - prompts/music.py: centralized find-music instruction + context header. - Re-scope specs 03-01/03-02 + DESIGN §10: 03-01 is find+pull; playback, scheduling, and DJ announce move to 03-02. Tests (test-first, fakes only — no network/model): render_context split, yt-dlp parse helpers, music tools, next_track flow (happy/retry/none), harness isolation options (incl. strict_mcp_config + tools=[]), and the MCP tool-result shape. Real yt-dlp and the live Claude harness loop are tagged integration; the live loop was run manually and confirms the isolation + the find->pick->resolve path end to end. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…e [spec 03-03] Second capability on the brain-harness (spec 03-01): shape the native Claude Code agent to diagnose and, with the user's consent, fix why the music dependency (yt-dlp) isn't working in the user's environment. - brain.py: ClaudeBrain.run_guide — a multi-turn conversation via ClaudeSDKClient (streaming, so can_use_tool + natural-language back-and-forth work); built-in tools enabled + isolated (setting_sources=[], strict_mcp_config=True); default permission mode (step-by-step confirm, never bypassPermissions). - harness.py: GuideCapable seam (run_guide) alongside Harness (run_task). - music/preflight.py: deterministic yt-dlp preflight (0 tokens; no LLM). - guide.py: SetupGuide.fix_music (persona + high-level task; consent per action, fix not prescribed — the agent diagnoses the uncertain cause). - prompts/guide.py: GUIDE_PERSONA + build_fix_music_prompt (explain -> ask to confirm -> act -> verify; never weaken security). - setup.py: run_music_setup + CLI-Host wiring — can_use_tool (per-action y/N) and a natural-language reply channel over print/stdin. - cli_host.py: add info() to the Host seam. app.py: --setup-music entry. - specs: add 03-03; 03-02 now owns assembling MusicContext.situation (first real content) + "what is a reasonable music context"; DESIGN §10 lists 03-03. Tests (fakes, no network/model): preflight classification, run_music_setup flow (noop/decline/confirm+recheck), can_use_tool y/N, guide isolation options. The live interactive repair is human-run; verified by hand on a Cloudflare-Gateway- proxied machine. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
wine-fall
added a commit
that referenced
this pull request
Jul 6, 2026
…amp todo 03-01 and 03-03 status blocks still said "Not started" / "not wired into the run loop" while the code shipped in #8. Record the real state: 03-01 implemented (find+pull, unconsumed by the Director until 03-02); 03-03 implemented behind --setup-music with can_use_tool routing settled, auto preflight trigger deferred to 03-02's music wiring. Mark 03-01 in the master build table and point the master status at 03-02 as next. Also fold the unbuilt optional musicdl adapter and the cliamp auth reference into one deferred work item (03-01 open questions) so neither gets lost. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
wine-fall
added a commit
that referenced
this pull request
Jul 6, 2026
…[spec 03-02] (#12) * docs(specs): true up spec-03 status to merged code; group musicdl+cliamp todo 03-01 and 03-03 status blocks still said "Not started" / "not wired into the run loop" while the code shipped in #8. Record the real state: 03-01 implemented (find+pull, unconsumed by the Director until 03-02); 03-03 implemented behind --setup-music with can_use_tool routing settled, auto preflight trigger deferred to 03-02's music wiring. Mark 03-01 in the master build table and point the master status at 03-02 as next. Also fold the unbuilt optional musicdl adapter and the cliamp auth reference into one deferred work item (03-01 open questions) so neither gets lost. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * docs(specs): settle 03-02 build decisions [spec 03-02] Recorded from the clarify gate: cadence is a switchable CadencePolicy seam (every_n default / random / brain opt-in — the brain mode is the sanctioned exception to master §7 pillar 1, noted there); the "up next" announce is in scope and written by the pick task itself (submit_pick gains title/artist/announce; next_track widens to TrackPick); music is on by default behind an extensible startup-checks seam (first check wraps 03-03's run_music_setup — this is where its auto-trigger lands); gap stays with the Director; the 03-01 interim-player question is moot. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * feat(music): thread track metadata + announce through the pick task [spec 03-02] AudioClip gains optional title/artist; submit_pick accepts and returns title/artist/announce (the DJ intro the same Haiku task writes — no extra LLM call, no hardcoded copy per master §0); next_track widens to TrackPick(clip, announce). No consumer existed, so the widening breaks nobody; the Director picks it up with the 03-02 music branch. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * feat(engine): mixing audio engine with gain-envelope ducking [spec 03-02] The source-agnostic engine replacing the afplay AudioPlayer: one output stream (sounddevice), two channels — music fed by a per-track decoder (real: ffmpeg -f f32le pipe) through a thread-safe ring, voice clips mixed on top. play(voice) AUTO-DUCKS live music via the MusicHandle protocol (one duck path — a future ControlledHandle slots in without touching the mixer, proven by test). stop() keeps spec-01 interjection semantics (voice channel only); handle.stop()/aclose() tear down with no orphaned ffmpeg. Underrun pads silence. Mixer math is pure numpy, unit-tested sample-for-sample; real ffmpeg/sounddevice are integration-tagged and pass locally. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * feat(cadence): switchable CadencePolicy seam — every_n / random / brain [spec 03-02] The Director consults one seam per segment boundary and never knows the mode. every_n (default) and random (guardrailed, seeded-RNG-injectable) are pure local policy — 0 tokens; brain is the opt-in master §7 pillar-1 exception: a one-shot cheap-model judgment that hard-falls-back to the local policy on any failure, timeout, or invalid answer. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * feat(director): music branch — duck-not-stop, first real MusicContext [spec 03-02] The spec-01 arbitration forks (03-02 §3.5): talk segments keep cancel-and-resume; a music segment races the handle's completion against typed lines and plays replies OVER the still-ducked song — the handle is stopped only on /quit/shutdown. The Director builds the first real MusicContext.situation (session recent turns + intent, prompt text centralized), and the pick's announce rides the ducked song head via the same auto-duck path. Without music wiring the loop is byte-for-byte the spec-01 talk-only path. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * feat(app): extensible startup checks; wire the engine + music into the loop [spec 03-02] Startup runs registered StartupChecks before broadcasting (spec 03-02 §2.4) — the seam onboarding will hang future preflights on. The first check wraps 03-03's run_music_setup (deterministic preflight -> offer the guide), landing its auto-trigger; a failed/declined check degrades the session to talk-only and the radio still starts. The app now constructs the mixing engine (afplay player + --player retired; ffmpeg_cmd replaces player_cmd), music defaults ON behind the check (--no-music to skip, --cadence to pick the mode), and CliHost.start is idempotent so checks and Director share the stdin reader. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * docs(specs): align spec 03 with the built 03-02 [spec 03-02] Record what the build refined: engine stop() targets the voice channel only (a chained interjection must not kill the song) with aclose() as the shutdown path; MusicHandle gains the awaitable wait(); config knob swap (ffmpeg_cmd replaces player_cmd) + the new music/cadence knobs. Status: 03-02 code-implemented with real-seam smoke verified; 03-03 auto-trigger landed via the startup checks; master marks spec 03 done pending the L1 by-ear acceptance. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(review): closing-review fixes for spec 03-02 [spec 03-02] Verified findings from the closing review, each locked by a test: - the DJ announce now races typed lines like every on-air spoken segment (user turns take priority; cancelling it never touches the song underneath) - a music-machinery failure (device/provider) degrades the segment to talk instead of crashing the radio loop - play_music stops an adopted external handle before starting its own track (sole audio authority) and reads the voice state under the right lock for the born-ducked decision - play() gets a dead-sink timeout so a stopped output stream cannot freeze the Director forever - render() reuses preallocated scratch buffers (no per-block allocation on the audio callback thread) - the three Director race helpers collapse into one _race_line; the programmer trusts the tool's already-normalized metadata - --setup-music forwards config.ytdlp_cmd so setup repairs the same binary the radio runs - yt-dlp resolve falls back bestaudio/best (JS-runtime-gated YouTube formats broke bestaudio-only; found by the live integration test) - docs: README/spec-01 no longer present AudioPlayer / --player as the live audio path Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(voice): survive TTS failures; normalize text for the MLX splitter [spec 03-02] Two fixes from the first live run: - A single failed synthesis used to unwind the whole radio loop. Talk, reply, and announce synthesis now degrade per-segment (info line, nothing aired/recorded, loop continues) — the same never-crash principle the music branch already had. Segment text also prints at air time instead of generation time, so the text/audio gap no longer reads as a glitch, and the silent music->talk fallback got an info line (it was invisible). - Root cause of the crash itself: mlx-audio's Spark splits input into segments and stacks per-segment audio; a fragment with nothing speakable (blank paragraph line, ellipsis-only line) yields an empty batch -> "[stack] No arrays provided for stacking". normalize_tts_text collapses whitespace runs and drops unspeakable lines before generate; verified against the exact text shape that crashed live (12 s of audio). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * chore(scripts): memwatch — live RSS of the murmur process tree murmur's memory lives in three processes (main loop, warm TTS sidecar with the multi-GB model, per-track ffmpeg decoder), so watching one pid tells you little. Stdlib-only: samples ps, auto-finds the murmur tree (the program itself, not processes merely mentioning the word), prints total/peak/per-process RSS per tick. --pid/--interval/--once knobs; deterministic parts (parsing, tree, labels, formatting) unit-tested. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * chore(scripts): memwatch labels the uv wrapper as launcher `uv run murmur` puts a uv shell at the top of the tree whose command line also matches "murmur", so ticks showed two "main" entries. The executable name now decides first: uv/uvx -> "launcher". Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * test: fix stale ppid assertion after the launcher fixture change The uv-wrapper row (pid 99) became main's parent in the fixture but the parse assertion still expected ppid 1; caught after a piped pytest masked the failure. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> --------- Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
This was referenced Sep 3, 2026
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
What
Spec 03 is the "radio feel" milestone. This PR ships two of its three parts:
yt-dlp, rather than a hardcoded search pipeline. Seespecs/spec03/03-01-brain-harness.md.yt-dlp) fails in the user's environment, the native Claude Code agent diagnoses the failure and, with per-action user consent, repairs it — e.g. a corporate Cloudflare-Gateway proxy whose CAyt-dlpdoesn't trust. It's a real natural-language, multi-turn conversation (ClaudeSDKClient), and consent is per-action (permission_mode="default"), neverbypassPermissions. Seespecs/spec03/03-03-guide-harness.md.Note: 03-02 (ducking/playback) is spec-only in this milestone (
specs/spec03/03-02-ducking.md) and is not part of this PR. This PR is intended to be squash-merged.AI coding brief
permission_mode="default"), neverbypassPermissions; the guide must be a real natural-language conversation (ClaudeSDKClientmulti-turn), not Y/N-only; and the guide-harness belongs under spec 03 (03-03), not a separate spec.contentshape, andcan_use_toolrequires streaming mode (ClaudeSDKClient, not one-shotquery()). Lesson: for SDK-integrated behavior, add a live smoke earlier.🤖 Generated with Claude Code