Skip to content

feat(music): spec 03 brain-harness — Claude-driven music discovery + guide harness [spec 03-01] - #8

Merged
wine-fall merged 3 commits into
mainfrom
zachg-0702--spec-03-01-brain-harness
Jul 4, 2026
Merged

feat(music): spec 03 brain-harness — Claude-driven music discovery + guide harness [spec 03-01]#8
wine-fall merged 3 commits into
mainfrom
zachg-0702--spec-03-01-brain-harness

Conversation

@wine-fall

Copy link
Copy Markdown
Owner

What

Spec 03 is the "radio feel" milestone. This PR ships two of its three parts:

  • 03-01 — brain-harness + Claude-driven music discovery. Turns murmur's isolated Claude brain into a tool-using agent (a real, harnessed Claude Code agent — not a one-shot LLM call), and uses that harness to drive music discovery: Claude picks and searches for music habit-first via yt-dlp, rather than a hardcoded search pipeline. See specs/spec03/03-01-brain-harness.md.
  • 03-03 — guide-harness for interactive yt-dlp repair. When a dependency (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 CA yt-dlp doesn't trust. It's a real natural-language, multi-turn conversation (ClaudeSDKClient), and consent is per-action (permission_mode="default"), never bypassPermissions. See specs/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

  • Original request: Define and build spec 03 (the "radio feel" milestone). It grew into two shipped parts — 03-01: a general brain-harness (turn the isolated Claude brain into a tool-using agent) with Claude-driven, habit-based music discovery (yt-dlp); 03-03: a guide-harness where the native Claude Code agent diagnoses and, with user consent, fixes why a dependency (yt-dlp) fails in the user's environment (e.g. a corporate Cloudflare-Gateway proxy whose CA yt-dlp doesn't trust). Why: make murmur's music actually work, and establish that "the brain is a harnessed agent, not a one-shot LLM call."
  • Manual interventions: The human steered repeatedly — the brain is a complete, harnessed Claude Code agent (not a crippled LLM), isolated from the user's local Claude env but given murmur's own tools; music search fully Claude-driven; "we don't build agents, we build harnesses — Claude Code IS the agent"; do the harness before ducking; consent is per-action (permission_mode="default"), never bypassPermissions; the guide must be a real natural-language conversation (ClaudeSDKClient multi-turn), not Y/N-only; and the guide-harness belongs under spec 03 (03-03), not a separate spec.
  • Retro: Stating up front "the brain = a harnessed, isolated, tool-capable agent; we only configure the system prompt + SDK mode, Claude Code drives ask/execute" would have saved several redesign rounds. Two real bugs were only found by running it live (fakes bypass the SDK): tool results needed the MCP content shape, and can_use_tool requires streaming mode (ClaudeSDKClient, not one-shot query()). Lesson: for SDK-integrated behavior, add a live smoke earlier.

🤖 Generated with Claude Code

wine-fall and others added 3 commits July 2, 2026 17:28
…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
wine-fall merged commit 8ec5341 into main Jul 4, 2026
3 checks passed
@wine-fall
wine-fall deleted the zachg-0702--spec-03-01-brain-harness branch July 4, 2026 13:31
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>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant