subwave
perminder-klair/subwave/CLAUDE.md
SUB/WAVE is a personal internet radio station: one Icecast stream, all listeners hear the same broadcast, AI DJ picks tracks and reads scripts between them. See README.md for the architecture diagram and rationale. This file is loaded into every session, so it holds only what you need before touching anything: the architecture, the commands, and the invariants you could plausibly break by accident. The reasoning behind each invariant — the measured failure, the fix that was chosen over the obvious…
- Reads credentials
- Installs packages
- Commits and pushes
What's in it
- CLAUDE.md
- What this is
- Where the detail lives
- Common commands
- Lint is the merge gate; tests are not
- Pull requests target develop, never main
- Architecture
- Code layout
- Docker layout
- Jingles
- Working on this codebase
- Structural rules
- Timing rules
- Audio rules
- Library and picker rules
- Product-behaviour rules
# CLAUDE.md
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
## What this is
SUB/WAVE is a personal internet radio station: one Icecast stream, all listeners hear the same broadcast, AI DJ picks tracks and reads scripts between them. See `README.md` for the architecture diagram and rationale.
## Where the detail lives
This file is loaded into **every** session, so it holds only what you need before touching anything: the architecture, the commands, and the invariants you could plausibly break by accident. The reasoning behind each invariant — the measured failure, the fix that was chosen over the obvious one — lives in a scoped file you read when you get there:
| Working on | Read |
| --- | --- |
| `controller/src/**` | [`controller/CLAUDE.md`](controller/CLAUDE.md) (module-by-module) |
| `liquidsoap/radio.liq` | [`liquidsoap/CLAUDE.md`](liquidsoap/CLAUDE.md) (pipeline order + every radio.liq rule) |
| `web/**` | [`web/CLAUDE.md`](web/CLAUDE.md) (routes, skin contract, API defaults) |
| `app/**` | [`app/CLAUDE.md`](app/CLAUDE.md) + [`app/docs/TESTING.md`](app/docs/TESTING.md) |
| queue, transitions, speech timing, personas | [`docs/internals/broadcast.md`](docs/internals/broadcast.md) |
| library, tagging, picker, requests, blocklist | [`docs/internals/music.md`](docs/internals/music.md) |
| zod schemas, `/settings` patch registry | [`docs/internals/schemas.md`](docs/internals/schemas.md) |
| analyzer, Icecast rendering, privacy locks, state layout | [`docs/internals/infrastructure.md`](docs/internals/infrastructure.md) |
The `docs/internals/` files are **not** optional reading when you are changing the thing they describe. Most of their paragraphs exist because someone shipped the obvious fix first and it was wrong.
## Common commands
Three operator entry points, all driving the same compose files + `state/` layout: the **standalone `subwave` CLI** (single binary, no clone — default for new installs), raw `docker compose` (no-CLI alternative), and `npm start` (contributor convenience inside a clone).
```bash
# --- dev (Mac smoke test, requires git clone) ---
docker compose -f docker-compose.dev.yml up -d # Broadcast (icecast2+liquidsoap) + Controller (tsx watch)
cd web && npm install && npm run dev # web UI on :7700, separate process
docker compose logs -f controller # prod default
curl http://localhost:7700/api/health # liveness via Caddy edge (prod)
```
The CLI resolves its install location via `SUBWAVE_HOME` (priority: `--home` → `SUBWAVE_HOME` env → `~/.config/subwave/config.json` → cwd if it has a `docker-compose.yml` → `~/subwave` if it exists → error). The cwd fallback is what makes `cd subwave-repo && npm start` work with zero config.
There is no `/skip` endpoint — track-end is the only natural transition. Liquidsoap controls pacing.
**Compose files live at the repo root**, not under `docker/`. One root `.env` is the entire boot config surface — everything else lives in `state/settings.json`, managed by the wizard + admin UI.
**Dev hot-reloads; prod needs a rebuild.** In dev compose, `controller/src/`, `controller/scripts/`, and `radio.liq` are bind-mounted and the controller runs `tsx watch`, so edits restart in-place. In **prod** images `COPY` source at build time, so `restart` reruns the *same baked-in code* — changes need `up -d --build`.
```bash
docker compose -f docker-compose.dev.yml restart controller # rarely needed — tsx watch handles src/** edits
docker compose -f docker-compose.dev.yml restart broadcast # after radio.liq edits in DEV (bind-mounted)
docker compose up -d --build controller # after controller/src/** in PROD
docker compose up -d --build broadcast # after radio.liq / icecast.xml.template / Dockerfile.broadcast in PROD
```
`web` runs as a Next.js dev server (`npm run dev`) and hot-reloads in dev; prod builds the web image and needs a rebuild like the others.
### Lint is the merge gate; tests are not
`controller/`, `web/`, `app/` and `mcp-subwave/` each expose `npm run lint` (`eslint . && tsc --noEmit`; mcp-subwave is `tsc` only). CI runs all four on every PR (`.github/workflows/lint.yml`, plus theme-token and schema-mirror drift checks on `controller`) and those jobs are what block a merge.
`controller/` also has `npm test` — `scripts/run-tests.ts` auto-discovers every `scripts/*.test.ts` and hands it to Node's built-in runner. **Dropping a `*.test.ts` file into `controller/scripts/` is the whole registration step**; `npm test -- <substring>` filters. Prefer `node:test`'s `test()` for new files (per-assertion reporting) over the older plain-script shape (one pass/fail on exit code); both are supported deliberately. Concurrency is pinned to `--test-concurrency=1` — these files reach for shared ground (temp state dir, library DB, env vars). `mcp-subwave/` has no tests. `web/` now has `npm test` too — `web/scripts/run-tests.mjs` auto-discovers `*.test.{ts,tsx,mjs}` under `tests/`, `components/`, `hooks/`, `lib/` and `scripts/`, and hands them to Node's built-in runner via `tsx`; `npm test -- <substring>` filters. Dropping a matching file into any of those roots is the whole registration step. Concurrency is pinned to `--test-concurrency=1` there too, though for a weaker reason: node gives each test FILE its own process, so `process.env` and module state are NOT shared the way they are in the controller's files. It is the shared FILESYSTEM that could collide, and serial execution keeps output deterministic. No web test needs it today. Note the roots are LISTED in that script, not globbed, so a new test directory is a deliberate addition rather than an accidental omission.
**CI does not run either test suite — run `npm test` yourself before pushing controller or web changes.** `lint.yml` runs lint only, on purpose; a green lint says nothing about tests, which is why a regression guard written for `tts.gemini.libraryLanguage` sat unrun while that setting was silently dropped on every save. If `observatory-scale.test.ts` fails with `SQLITE_IOERR_WRITE`, point `TMPDIR` at real disk (it builds a 200k-track DB and `/tmp` is a 12 GB tmpfs on Arch).
### Pull requests target `develop`, never `main`
Open every PR against `develop`. `main` is the release branch — only `develop` (and the automated release-please branches) merge into it, and `.github/workflows/enforce-main-source.yml` fails any PR to `main` from any other head. So: `gh pr create --base develop`.
**Ignore the "Main branch (you will usually use this for PRs): main" line in the session git preamble.** It is computed locally by looking for a branch named `main`/`master` and does not consult the remote default branch, which for this repo *is* `develop`. Passing `--base main` on the strength of that line is the single most common wrong-base mistake here.
The one exception is the release PR itself (`develop` → `main`), which has its own procedure in [`.claude/skills/subwave-release-pr/SKILL.md`](.claude/skills/subwave-release-pr/SKILL.md) — including the merge-commit-not-squash rule that release-please depends on.
## Architecture
Four cooperating processes with **file-based IPC** through a shared `state/` dir (mounted at `/var/sub-wave` in containers). This is the load-bearing fact about how the system works — there is no socket or RPC channel between controller and Liquidsoap. The shared `/var/sub-wave` mount in **both** Broadcast and Controller is what makes it work; they must always map to the same host path.
**Controller → Liquidsoap** (Liquidsoap polls; the controller only writes):
| File | Poll | Purpose |
| --- | --- | --- |
| `next.txt` | 1.0s | one annotated track URI, drained into `request.queue.push` |
| `jingle-now.txt` | 0.5s | one jingle URI, drained into a priority queue for the next safe boundary — an operator press, or the automatic rotate when the controller owns it (#1619) |
| `say.txt` | 0.5s | WAV path → `voice_queue`, **heavy-ducked** (`smooth_add p`, `ducking.voice`, default 0.22). Idents, hourly time, weather, request intros |
| `intro.txt` | 0.5s | between-track links → `intro_queue`, **light-ducked** (`ducking.intro`, default 0.30) so the song stays audible under the voice |
| `auto.m3u` | watch | fallback playlist, rewritten every `AUTO_QUEUE_REFRESH_MINUTES` (default 60) for the current mood |
| `liquidsoap_*.txt` | startup | jingle_ratio, crossfade, per-codec enable/bitrate… written by `settings.update()`, **read once** — changes need a mixer restart (`/restart-mixer` → telnet) |
**Liquidsoap → Controller / UI** — marker files, normally written from an `on_metadata` hook (the pause acceptance marker is the ordered exception):
- `now-playing.json` — the live track. Written from **`music_meta`, the pre-cross handle**; see `liquidsoap/CLAUDE.md` before moving it.
- `jingle-playing.json` — a jingle is on air, so `airVoice` holds every spoken segment until its window passes. Jingles play outside the controller's voice serialiser, which is why the marker exists — and it stays the safety net whoever is counting. `jingleRatio: 0` disables the automatic rotate (mixer restart); manual presses still queue.
- `bed-playing.json` — an instrumental the DJ talks over BETWEEN songs. A bed is queued like a request but is **not a song**: `on_meta` branches on the annotation before its `title != ""` gate, so it never reaches `now-playing.json`. That silence is why the marker exists — `queue.onBedStarted` is the only way the controller learns "air the link now".
- `pause-talk-playing.json` / `pause-talk-voice-accepted.json` / `pause-talk-voice-started.json` — the silent break reached the music timeline, then its stable-id voice was pushed into `voice_queue`, then its first spoken sample reached the live edge. The middle marker is written by `poll_voice` only **after** `voice_queue.push`; the ordered trio is what makes controller-only restart recovery idempotent.
- `voice-playing.json` — a spoken clip started. This is what makes air-time stamps real rather than estimated (#1382); see [`docs/internals/broadcast.md`](docs/internals/broadcast.md).
- `music-starved.json` — `{starved, since, at}`, the dead-air guard's own state, written on edge **and** as a heartbeat so a stale file is detectable. Read by `broadcast/music-starve.ts` and surfaced on the public health route. Its writer must never raise (see `liquidsoap/CLAUDE.md`).
**Controller → Web UI**: HTTP. `useStationFeed` (`web/hooks/useStationFeed.js`) polls `/now-playing` + `/state` every 5s.
**Controller state**: `session.json` — the live DJ session (chat-history JSON, `broadcast/session.js`), archived to `state/sessions/<id>.json` on roll. Controller-internal; Liquidsoap never reads it.
**Prompt memory follows that session, not the booth log.** `Queue.djLog` is a station-wide operator history and deliberately survives show changes; `queue.getDjRecap()` and `getRecentOpeners()` read only aired `segment` turns from the editorial session. The recap's line count, lookback minutes and characters per line live under `settings.djBehaviour` (defaults 10/120/140), are edited in Admin → DJ behaviour, and apply live without a restart. A genuine show change or 4h hard roll clears those prompts, while an autonomous daypart/mood soft shift retains them. An ordinary post-roll mic-pass reads the archived outgoing snapshot through `session.priorPromptMemory()` and gives the incoming greeting the fresh session. A final-track boundary handoff runs before the roll instead: its sign-off reads the still-live outgoing `promptMemory()`, while its greeting receives an explicit clean slate and the prepared incoming programme angle. Never offer outgoing raw speech to the incoming prompt. The listener booth's carry across a hard roll (#1690) is `Session.boothCarry`, a separate display-only field read solely by `GET /session`; never merge it into `messages`.
**Browsers → Icecast**: direct `<audio>` on `…/stream.mp3` (default) or `…/stream.opus`. MP3 is the universal floor and must stay always-served (Sonos, hardware radios, car receivers, older Safari). The Opus upgrade is gated four ways and lands on the next `tune()`, never on a playing element — the details and the iOS/Firefox chained-Ogg failures are in [`web/CLAUDE.md`](web/CLAUDE.md).
### Code layout
- **Controller** (`controller/src/`, Express + ESM Node) — `server.js`, `config.js`, `settings.js`, `context.js` at the root; everything else under `routes/`, `middleware/`, `music/`, `broadcast/`, `audio/`, `llm/`, `skills/`.
- **Liquidsoap** (`liquidsoap/radio.liq`) — request queue → auto playlist → jingle rotate → cross → dead-air guard → ducking layers → limiter → parallel Icecast mounts.
- **Web** (`web/`) — Next.js App Router + Tailwind; headless player core plus swappable **skins**.
- **Native app** (`app/`) — a separate Expo SDK 57 / React Native project (own `package.json`, `node_modules`, `eas.json`). Architecture-critical and easy to break.
### Docker layout
Three compose files at the repo root, three deployment shapes:
- **`docker-compose.yml`** — prod single-host with bundled Caddy. **The default.** **Only Caddy binds a host port** (`${CADDY_PORT:-7700}:80`); the rest are internal. Cloudflare terminates TLS (`auto_https off`). `controller` is forced `NODE_ENV=production` → admin gate mandatory. Configs baked into images.
- **`docker-compose.byo.yml`** — for hosts with their own Traefik/nginx/Caddy. Same minus Caddy; services bind host ports. The web image is baked for same-origin `/api` + `/stream.mp3`, so **the operator's proxy must replicate `docker/Caddyfile`'s route table on one hostname** — split hostnames need a web rebuild with `NEXT_PUBLIC_*`.
- **`docker-compose.dev.yml`** — local smoke test. Broadcast + Controller only (web runs separately). Bind-mounts `radio.liq`, `sounds/`, `controller/src`.
Two sidecars: **`analyzer`** starts by default (acoustic analysis — lean by default, `ANALYZER_HEAVY=1` for the CLAP+Demucs flavour), **`tts-heavy`** is opt-in behind `--profile tts-heavy` (Chatterbox + PocketTTS). Details in [`docs/internals/infrastructure.md`](docs/internals/infrastructure.md).
**Image-first pulls, source-build fallback.** Every service references `ghcr.io/perminder-klair/subwave-*:${SUBWAVE_VERSION:-latest}` alongside a `build:` block. `up -d` pulls; `build && up -d` rebuilds. Published by `.github/workflows/publish-images.yml` on `v*` tags.
**Auto-generated Icecast secrets.** `docker/broadcast-entrypoint.sh` resolves `ICECAST_*_PASSWORD`: env override → persisted `state/icecast-secrets.env` → freshly generated hex, then writes back and exports into liquidsoap's env. To rotate: delete the file and restart `broadcast`.
**Single config surface.** Three required root `.env` vars boot the stack: `ADMIN_USER`, `ADMIN_PASS`, `SITE_URL`. Everything else is collected by two converging wizards — `npm run setup` (`cli/src/commands/setup.ts`) and `/onboarding` (`controller/src/routes/onboarding.ts`) — which persist to the same layer: Navidrome creds → `state/setup-config.json`; cloud keys → `state/secrets.env` (0600); everything else → `settings.update()` → `state/settings.json`. **Env always wins**; wizards only fill gaps. `setup/firstRun.ts` decides `needsSetup` (no Navidrome creds from env or config) and `/state` exposes it, which is what redirects a fresh operator to `/onboarding`.
### Jingles
`state/jingles.m3u` is empty by default. Run `scripts/generate-jingles.sh` once the stack is up — it `exec`s into the controller, pipes text through the configured TTS engine, writes WAVs into `${STATE_DIR}/jingles/` and rewrites the M3U. The playlist uses `reload_mode="watch"`, so new renders need no restart.
## Working on this codebase
Rules that are expensive to rediscover. Each links to its full reasoning — **read that before changing the thing it describes.**
### Structural rules
- **One writer per file.** `queue.drainToLiquidsoap()` is the only writer of `next.txt` (+ request-intro `say.txt`); `queue.playJingle()` the only writer of `jingle-now.txt` (the automatic rotate hands over through it too — `queue.playRotateJingle()` draws the clip and calls it, it does not write); `queue.announce()` the only writer of scheduled `say.txt`/`intro.txt`; `queue.onSpoken()` the only place post-air bookkeeping happens. Poll intervals (1.0s queue, 0.5s voice/jingle) are the upper bound on perceived latency.
- **A pushed track is "handed over", never "playable".** `item.sent` only means the URI reached `next.txt`; Liquidsoap silently drops a request it cannot resolve, and the reconcile sweep needs ~3 auto tracks to notice. `proto_subhttp` records an explicit `ready`/`failed` result under the handoff's one-use probe id, and `queue.verifyPushResolved()` consumes it over telnet. **Never infer resolution from `dj_queue.queue()` membership**: it includes idle/resolving requests and omits a healthy request while boundary prefetch owns it. Missing/unknown outcome channels fail open. See [`liquidsoap/CLAUDE.md`](liquidsoap/CLAUDE.md).
- **Policy lives in its own module, never inlined at the call site.** `broadcast/voice-policy.ts` (station muted), `clock-policy.ts` (wall clock off air), `dj-budget.ts` (daily token cap), `banter-policy.ts` (when a guest-show exchange may air), `talk-scheduler.ts` (which scheduled segment may take the ear this minute), `handover-policy.ts` (when a show signs off, and what has to play before the next one opens), `drain-policy.ts`, `skip-policy.ts`, `util/request-guard.ts` (listener-request safety), `util/public-persona.ts`, `broadcast/dj-agent/artist-guard.ts`, `music/blocklist-rules.ts`, `music/silence-trim.ts` (dead-air trim), `broadcast/vocal-runway.ts` (how long a voice may talk over a track's head), `broadcast/listener-country.ts` (where a beacon's country comes from). Each exists because the same decision is reached from several call sites and drifted when it was duplicated. Adding a second copy of one of these checks is the bug.
- **The listener country is a CHAIN whose every link fails open.** `broadcast/listener-country.ts` tries `cf-ipcountry`, then the header named by `stream.countryHeader`, then an offline MMDB lookup (`broadcast/geoip.ts`, `GEOIP_DB_PATH` beating `stream.geoipDbPath`), and an exhausted chain records NO country rather than an error — it runs inside the listener's first-load `POST /beacon`. Two things are easy to get wrong: a later link must run when an earlier one MISSES, not only when it is absent (`XX`/`T1` are Cloudflare saying it does not know, and stopping there is how a configured fallback never gets asked on the requests it exists for), and the header-name grammar is `STREAM_COUNTRY_HEADER_RE` in `schemas/settings.ts`, imported by the resolver rather than restated — a read path that disagreed with the save path accepts a stored value it then refuses to act on. Nothing is bundled: every IP-to-country database is a licensed download, so the lookup is inert until an operator supplies a file.
- **A gate's failure direction is a deliberate design choice — never "unify" two of them.** `POST /listener-auth` fails OPEN, `POST /station-auth` fails CLOSED (both in `util/listener-auth.ts`), and `middleware/station-auth.ts` is the same fail-CLOSED decision wrapped for listener-facing READS (`GET /similar-tracks`) — open on a public station, shut on a private one, and deliberately NOT `requireAdmin`, so an operator's API agent gets library reads without the admin console; the three listener-count gates fail OPEN on an unknown count while `presentListeners()` fails CLOSED; the analysis quiet gate fails open in the *opposite* direction from the DJ gates. Same for validation posture: `validate*Strict` throws, `normalize*` repairs-or-drops, and neither restates a rule.
- **A validated shape is defined once** in `controller/src/schemas/<feature>.ts` and mirrored to `web/lib/schemas.generated.ts` by `npm run gen:schemas` (CI diffs it). Files under `src/schemas/` may import **only `zod`** — not even a sibling schema — because the mirror is one flat concatenation compiled in the browser build. Never hand-edit the mirror; put impure rules in a `*-server.ts` sibling. → [`docs/internals/schemas.md`](docs/internals/schemas.md)
- **Behaviour-preserving refactors must actually preserve behaviour.** The `/settings` patch registry is the live example: 43 of 49 keys converted, and the hand-rolled branches carry accidental leniency (five numeric coercion families, four string families) that the obvious conversion silently removes. Tightening any of it is defensible but is a behaviour change — its own PR, stated as such. → [`docs/internals/schemas.md`](docs/internals/schemas.md)
- **Keep the entrypoint and the AIO supervisor in lockstep.** `docker/broadcast-entrypoint.sh` and the AIO's `render_icecast()`/`bootstrap_state_dirs` duplicate five things: per-mount burst/queue sizing, listener-auth blocks, the trusted-proxy list **and the `trusted-proxies.json` marker recording it** (`render_trusted_proxies` — the marker is what lets admin → Listeners say WHY it is showing the edge's address, which on a BYO stack is every boot; a miss must keep degrading to the peer address, never to a guess, and an absent marker renders nothing), the state-dir bootstrap, and `resolve_max_clients` (env `ICECAST_MAX_CLIENTS` beats the `stream.maxListeners` handoff file, and the winning source is logged). Fix one, fix both — `scripts/max-listeners.test.ts`, `scripts/state-bootstrap.test.ts` and `scripts/trusted-proxies.test.ts` each drive both copies from one table. → [`docs/internals/infrastructure.md`](docs/internals/infrastructure.md)
- **State bootstrap is never fatal.** A station that refuses to boot over a permission convenience is strictly worse than one on a degraded mount — icecast still serves and the dead-air guard still airs. Guard each step and warn; never let `set -eu` abort the entrypoint before icecast starts.
### Timing rules
- **Every timestamp the controller publishes is stamped at the live edge**, but every listener sits `stream.bufferSeconds` (default 22s) behind it. Listener-facing surfaces add the offset; operator surfaces (admin, MCP) intentionally keep live edge. `<burst-size>` is a **byte** count, so it is computed **per mount** — never collapse it to one global figure. And never judge cushion health with `buffered.end − currentTime`: that is the demux window, measured at 2.25s against a true 22.5s offset. → [`docs/internals/broadcast.md`](docs/internals/broadcast.md)
- **A talk slot is a window, not an instant.** The things that consume the listener's ear don't land on the clock — a boundary-deferred ident, a zero-floor segment spot — so evaluating a slot at one instant let them starve a whole hour (#1419, banter). Every row in the talk table now opens a window and fires the first minute inside it that is clear, and a row that is held **postpones, never cancels**. Two things follow: never "fix" a gap by reclassifying short idents as not-real-talk (that lets the next segment stack right behind one, which is what the gap is for), and never judge quiet by what has AIRED alone — a rendered segment waiting on a track boundary is talk that `getLastTalkBreakAt()` cannot see. → [`docs/internals/broadcast.md`](docs/internals/broadcast.md)
- **Every SCHEDULED spoken segment is a row in one slot table, driven by one per-minute cron.** `broadcast/talk-scheduler.ts` holds the table (hourly check, programme beats, banter, idents) and the pure planner over it; `scheduler.talkTick` is its only driver. **One talker per minute** (#310) is a rule in that planner, not a property of four cron strings — windows overlap on purpose, and the highest-priority FIRING row takes the minute while the others wait inside their own windows. A row yields only to a row that is firing, never to one that is merely open: yielding to an open-but-blocked row lets it sit on its whole window holding everything beneath it. Priority follows a principle — a row that cannot retry outranks one that can, then fewer remaining chances outranks more — so a new row can be placed without guessing. Adding a second talk cron is the bug. Rows come in two ROLES and the asymmetry is deliberate: a `slot` row owns scheduled minutes and yields only to a FIRING row, while a `fill` row (the segment director — offered every fifth minute, no wall-clock placement — and the automatic jingle rotate) stands down whenever a slot row wants the minute, including one merely *waiting*, since a filler that speaks resets the quiet gap and pushes that row's retry out. A fill row can afford to yield that widely because it has no scheduled chance to lose; a slot row cannot, which is exactly why the rules differ. "Wants the minute" is narrower than "has an open window" on purpose: with ten-minute windows the slot rows cover 50 minutes of the hour, so the looser reading would leave the director two ticks an hour instead of six — not standing down, switched off. Two things the tick deliberately does not own: eligibility, which still resolves through `dj-gate`/`listeners`/`dj-budget`/`programme` at fire time, and the :00 **session roll**, which is not a talk action and runs unconditionally before the planner — a muted or empty station must still roll. The director's per-kind cooldowns and frequency floor stay in `skills/_agent.ts`; the table decides only whether it is offered a minute at all. **Placement is the table's too**: per-row `air` says whether a fired row ducks the song or waits for the next boundary, and `djTalkOnlyBetweenTracks` (default off) reads every row as `'next-track'` — resolved in the planner into `TalkPlan.air`, delivered through the `withTalkAir` scope `scheduler.runTalkSlot` wraps each fire in, so `queue.announce`/`announceExchange` defer without a flag threaded through eight runners and manual triggers stay exempt by being outside the scope. `_pendingVoice` stays ONE slot: a second scheduled segment is postponed by the planner, never queued behind the first. **The automatic jingle rotate is a row too** (#1619, `settings.jingleRotate: 'controller'`, opt-in — absent it Liquidsoap keeps drawing its own and nothing here fires): the controller counts track boundaries and hands the stinger over through `queue.playJingle()`, and `settings/liquidsoap.ts` writes the mixer's `jingle_ratio` 0 so only one side is ever counting. It is a `fill` row and not a slot, because its due-ness is a COUNT that keeps counting while it waits — it has no scheduled chance to lose, which is the whole definition. Two things follow: a due rotate whose minute is taken is not banked but simply retried, and a rotate that comes due and cannot be drawn (empty library, a press already pending) is SKIPPED with the next one N tracks away — the mixer's own `source.available` behaviour, not a shortcut. Only the placement moved; the `jingle-playing.json` hold stays as the safety net for manual presses and for a controller running ahead of its broadcast image. → [`docs/internals/broadcast.md`](docs/internals/broadcast.md)
- **A rendered segment is placed, not trimmed.** A pick's link is written for a known song, so `enforceIntroBudget` trims it to that song's measured vocal onset before the TTS runs. A scheduled segment has no such luxury: `announce()`/`announceAtNextTrack()` render the WAV before the boundary it will take is known (deliberately — TTS latency stays off the air path), and `airPendingVoice` may hold it across several boundaries after that. So the lever at the boundary is WHICH boundary, not how many words: a clip that would still be talking when the incoming track starts singing keeps its slot and takes the next one, the same postpone-never-cancel the busy-boundary hold already does, bounded by the existing `PENDING_VOICE_MAX_AGE_MS`. `broadcast/vocal-runway.ts` owns both the runway and that rule, and it is also the one place the onset is composed — `bed-policy.rampBudgetMs` (three-state: a number, `Infinity` for an instrumental, `null` for un-analysed) read through `silenceTrim.shiftOnsetMs`, because the analyzer measures from byte zero and the drain may be cutting a leading blank off that very track. Two asymmetries are deliberate: a clip longer than the band's ceiling AIRS rather than deferring (no runway could hold it, so holding only starves it in the pending slot), and the IMMEDIATE placement gets no budget at all — it airs mid-track through `say.txt` under the heavy duck, where there is no runway ahead to land inside. → [`docs/internals/broadcast.md`](docs/internals/broadcast.md)
- **A pause-and-talk break is SILENCE on the music timeline, never the voice.** With `shows[].pauseTalk` on (opt-in) a rendered skill segment past `settings.pauseTalkMinSeconds` (default 20s) gets a real gap instead of a duck — but what rides `next.txt` is a silent item, and the clip still goes down the ordinary `say.txt` path. Sending the voice through the music seam is the obvious shape and it is wrong: `radio.liq` applies `mic_chain` and `edge_fade` to `voice_queue`/`intro_queue` only, so that clip would be the one segment on the station with no processing on it, and the air-time stamps would need a parallel path. Four things follow. The hold reuses **`_pendingVoice`**, the slot the talk planner already reads — a second parking place for unaired speech is how a held segment and an ident get cleared for the same boundary (#1505). The silence is **budgeted against the incoming crossfade**, because `cross` sizes a transition from the OUTGOING track's stamp and the previous song fades over the silence's head for its whole duration (10s by default); un-budgeted, the segment plays under decaying music, which is the ducking the feature replaces — so `releaseDelayMs` waits it out before the mic opens, while the silence's net delay is added to listener/show-boundary forecasts. An **armed break is committed**: its matching voice is atomically persisted before `next.txt`, recovered across a controller restart, and `airPendingVoice`/`dropPendingVoice` must stand off it, bounded by `PAUSE_TALK_ARM_MAX_AGE_MS` so a pre-handoff failure costs the break, never the segment. Finally, release is a durable stable-id protocol: `say.txt` publication, the mixer's post-`voice_queue.push` `pause-talk-voice-accepted.json`, its actual-start `pause-talk-voice-started.json`, then the controller acknowledgement in the commitment. With the shared state writable, recovery never republishes a published or accepted id and cleanup waits for actual start, giving exactly-once audio across a **controller-only** restart at every handoff point while the mixer survives. A first-version pause mixer is recognised through its generic stable-id `voice-playing.json`, but without an acceptance marker its consume-before-start interval is only a timed best effort; simultaneous controller+mixer loss cannot be made transactional with this file IPC. → [`docs/internals/broadcast.md`](docs/internals/broadcast.md)
- **A show handoff is prepared for the confirmed final outgoing track, never by look-ahead alone.** Pair-drain may select the incoming track and prepare its programme plan early, but the pair is not published until Liquidsoap reports the recorded final outgoing track; the track-linked intro queues first. Normal placement then airs the complete sign-off and greeting during that final track, while between-tracks placement holds the rendered pair for the first seam at or after the scheduled boundary. That hold is bounded: if no eligible seam arrives within two minutes after the boundary, the already-rendered pair airs through the same light-duck intro channel. `queue.json` persists the rendered clip manifest and absolute deadline across a controller restart; recovery reclaims valid WAVs and re-arms the remaining (or overdue) wait, while an absent/invalid manifest falls back to the session record's existing regeneration path. The outgoing session stays live until the real roll, and the prepared incoming programme state transfers with the boundary record. A multi-line pair remains queued until its final live-edge marker. Only scheduled speech yields after the handoff claims the boundary: listener-request intros and manual operator actions remain eligible, and stale track links are governed by their session stamp. There is no mandatory spacer track. See [`docs/internals/show-boundary-handoffs.md`](docs/internals/show-boundary-handoffs.md).
- **A spoken clock is a forecast, not a reading.** A link is written when the pick is *made* and airs when the pick *starts*. Two paired guards in `broadcast/queue/pure.ts` refuse a clock without enough runway and drop the line if the seam drifts. → [`docs/internals/broadcast.md`](docs/internals/broadcast.md)
- **A show boundary is a clock the drain reads FORWARD, and the cut it arms is a fade.** With `fadeAtShowEnd` on (station default + a per-show tri-state where `null` = inherit; absent at both levels = off), a pick that would run past the next show change is cued out there — through the **existing** #447 `liq_cue_out` path, never a second cue writer, since the cap, the silence trim and a stem blend all cut the same tail. The boundary is measured from the pick's EXPECTED air time (the drain's clock is a forecast), against the PLAYABLE span resolved through `music/silence-trim.ts`, on the STATION clock (zones sit at :30/:45, and a takeover's start/expiry is not hour-aligned). **That forecast must count the BED**: `maybePushBed` writes straight to `next.txt`, so a bed is never an `upcoming` entry and a clock that walks the queue sails past it — uncounted, the cut lands a whole link late and the track spills the exact amount the feature exists to stop. Two floors pull opposite ways and both matter — a small overrun is left alone, a track is never cut to a stub. Listener requests are exempt. `radio.liq` is told WHY the track stops (`liq_show_fade`) and **every** transition gesture stands down, on BOTH sides of the seam — the drain strips the outgoing ones upstream as well, exactly as a rendered blend does, so a controller ahead of its broadcast image cannot hand an armed loop to a mixer that has never heard of the flag. → [`docs/internals/broadcast.md`](docs/internals/broadcast.md)
- **A dead-air guard that waits before firing is a guard that doesn't fire.** Fire immediately, ramp the level. Applies to anything that detects starvation.
- **Degrading must be silent and immediate, not a timeout.** A controller running ahead of its broadcast image sees no `voice-playing.json`; `awaitVoiceAir` resolves null *at once* rather than waiting out its timeout, because a 20s stall on every booth-log line is worse than the early stamp it fixes. Unmeasured values are reported `estimated: true` with timestamps **absent, not zeroed**.
### Audio rules
`radio.liq` has its own set — dead-air guard position, crossfade duration, `smooth_add` ducking, atomic marker writes, `on_metadata` placement. **All of them, with the measured numbers, are in [`liquidsoap/CLAUDE.md`](liquidsoap/CLAUDE.md).** The three most easily broken:
- **Keep fade duration equal to the cross buffer.** Shorter fades inside a fixed buffer let the outgoing track play full while the incoming ramps, summing to +6 dB. Vary the buffer, not the fade. The chop's incoming fade is the one measured exception (the outgoing is already gone); it is explained in `liquidsoap/CLAUDE.md`.
- **A transition effect's operator cost is its SHAPE on one thread (#1565).** The dissolve's four combs sum and subtract the dry ONCE — never per tap. The per-tap form was the same arithmetic and twelve more full-frame operators, all on Liquidsoap's single streaming thread, and it stalled the clock on ~1 dissolve in 5 (`Latency is too high`); the fold is 1.87x cheaper for bit-identical output. Neither a shorter filter cascade nor fewer combs is worth taking after it — both were measured. Effects are also individually switchable now (`transitions.effects.*`, all default on, resolved by `settings/transition-effects.ts`), so a host that cannot afford a gesture drops that one instead of `djMode` and all six. → [`liquidsoap/CLAUDE.md`](liquidsoap/CLAUDE.md)
- **Stick with `smooth_add` for ducking.** An RMS sidechain follower drove `music_bus` to silence (`f38a9af`). `smooth_add` cares only whether the channel has signal. Both depths are operator settings (`ducking.voice` / `ducking.intro`, defaults 0.22 / 0.30) on the usual `liquidsoap_*.txt` lifecycle — read once at mixer startup, so a change needs a restart. `p` is the fraction of the music LEFT UP, so smaller is deeper and the two must not be swapped.
Loudness: both sides of a rendered seam are gained by `music/loudness.ts` `resolveGainDb` — the same figure the drain stamps on a real track — never re-derived from the analyzer's LUFS, which ignores ReplayGain tags and the caps.
### Library and picker rules
- **Write `genres`, never `genre`** — the scalar column is GENERATED from `genres[0]`. `subsonic.songGenres()` is the single ingest normaliser, and it is also where the operator's **scene-consolidation rules** are applied, so a merge survives the next walk instead of being written back over — both halves of a merge share one trim-and-dedupe (`scene-vocab.dedupeScenes`) and one verbatim test for "this source already IS the target", because a rule that reads like an identity ("rock" → "Rock") is exactly what a case-only merge is. Genre matching is **one-directional**: a track's tag may refine a show's genre, never broaden it.
- **Judge era by `show-filter.resolveEraYear`, never raw `year`** — a reissue's own release date is untrusted. Pass it `yearUntrusted` (composed once in the library-db row mappers), never the raw `isCompilation`: that flag is `false` on exactly the reissue anthologies the guard exists for (#1418). Keep the JS and SQL era filters in agreement, and route every listener-facing year — annotation, DJ line, picker, `/now-playing` — through the resolver.
- **Measure dead air against an ABSOLUTE floor, never a relative one.** `silenceTrim` cuts near-silent track edges via `liq_cue_in`/`liq_cue_out`, and the two measurements that look like the answer — `intro_ms` and `outro.startMs` — are gauged against the track's own loud level, so a quiet intro reads as silence and the cut eats music. `music/silence-trim.ts` also owns the onset shift: a trimmed head moves every timestamp the analyzer measured from byte zero, and a trimmed tail moves every end-relative one — intro runway, first vocal, the bed's ramp budget, the exit canvas's wind-down and `/now-playing`'s clock all resolve through it, never by local subtraction. It is only as live as `library.get()`'s projection: no pick path carries the measurements on the track object, so a column missing from that field list disables the feature silently. → [`docs/internals/music.md`](docs/internals/music.md)
- **Library coverage never counts on the read path.** `coverage.total` is a `getAlbum` call per album — thousands of Navidrome requests on a real library — so `library-coverage.get()` only ever *reports* the last count, stamped `scannedAt`. The walk belongs to `refresh()`, whose only three callers are the operator's Count-library button (`POST /library/coverage/refresh`) and the two ends of a tagger run, which walks the catalogue anyway — at START only when nothing has ever been counted (else the whole run shows a null percent), and at exit. There is no `?refresh=1` on the GET: a read that can start a walk is the shape something eventually polls by accident, which is exactly how opening the admin Library page came to hammer Navidrome (#1570). **A library reset must not refresh** — it wipes `library.db`, not the music server, so the total it would recompute cannot have changed. Because nothing recounts unattended, the count **persists** to `state/library-count.json`; an in-memory-only cache would blank the total, and every percentage derived from it, on each controller restart. The web side matches: coverage is polled only during a tagging run or an in-flight count, never on an idle tab. → [`docs/internals/music.md`](docs/internals/music.md)
- **Use `getAnnotatedUri` for anything going to Liquidsoap** — raw URLs lose metadata until ID3 arrives, and `on_metadata` needs `subsonic_id` for `/cover/:id` artwork.
- **The tagger's own `moods`/`energy` must never enter the embed text.** Phases run enrich → embed → seed → propagate, so those are decided *from* the vectors; feeding them back is circular. → [`docs/internals/music.md`](docs/internals/music.md)
- **Bump `TAGGER_CONTRACT_VERSION` when you change what the tagger prompt ASKS FOR** (`music/tagger-core.ts`). Since #1548 the `prompt_hash` re-tagging stamp keys off that number plus the live mood vocabulary, never the prompt text — so a reword is free and a semantic change is invisible until the bump. Forget it and Re-decide moods silently re-tags nothing; bump it needlessly and the next Re-decide re-tags the whole library. `scripts/tagger-contract-hash.test.ts` pins the inputs. → [`docs/internals/music.md`](docs/internals/music.md)
- **CLAP cosines are not comparable across moods** — each prompt sits at its own baseline, so picking a track's top raw scores ranks *prompts*, not tracks. Calibrate per mood (`music/audio-calibration.ts`), and stamp `UNCALIBRATED_VERSION` when a pass could not calibrate. → [`docs/internals/music.md`](docs/internals/music.md)
- **Enforce variety at the point of choice, not in the discovery tools.** Filtering artists inside the tools gutted the similarity pool on niche catalogues (#618). The same rule and the same place for the **album cooldown** (`picker.albumHours`, default 0 = off): one window (`queue.recentAlbumKeys`) feeds both the pool filter and the agent path's `album-guard.ts`, so the two paths can't disagree; compilations are exempt through the existing `isCompilation`/`yearUntrusted` composition, never a second heuristic — resolved by `music/album-facts.ts` and **injected**, since a raw Subsonic child carries no flag and the agent's `seen` map must stay the model's projection; and it is a preference — one re-pick, no pool rescue, never a lost slot. → [`docs/internals/music.md`](docs/internals/music.md)
- **The track-length CAP and the track-length FLOOR are not symmetric, and must never be unified.** `maxTrackSeconds` defaults to legacy `maxTrackLengthMode: cut`: an on-air `liq_cue_out` cut, so over-long tracks stay eligible. Opt-in `exclude` uses a HARD known-duration ceiling at selection, queue and fallback intake, with equality eligible and unknown durations passing without a maximum cut; no never-starve rescue may restore prohibited tracks. `settings.effectiveTrackLengthLimits` resolves selection/playback maxima once, preserving show precedence. Current/sent handoffs (including committed beds/breaks) are grandfathered; unsent automatic items are revalidated. Requests/studio actions remain exempt; silence trim, show fades and blends retain cue_cut. An all-prohibited library publishes an empty auto playlist and relies on existing dead-air safety; `picker.minTrackLengthSeconds` / a show's `minTrackLengthSeconds` (#1573, default 0 = off) is a SELECTION filter, because a 40-second skit cannot be lengthened. `music/track-floor.ts` owns it, `settings.effectiveMinTrackSec` resolves the show-over-station precedence, and the posture is chosen per call site the way `applyStrictLocks` does — HARD in the agent's discovery tools, never-starve in the pool picker and the auto.m3u coast, which are the dead-air scope behind them. It is NOT gated on `filtersStrict` (nor is the cap), and listener requests are exempt. The name is deliberately distinct from `settings.minTrackSeconds()`, the crossfade-derived floor — which is this key's own **lower bound**. **A never-starve filter must never return its input array**: the coast rebuilds its pool in place, so handing the pool back is how the rescue empties it. → [`docs/internals/music.md`](docs/internals/music.md)
- **Tempo-derived mix timing must be octave-safe.** `beat_track` can report the double on slow material, so `music/mix.ts` folds high readings down before deriving bars, effect periods or canvases, and every effect period then reaches its audible window by **halving, never clamping** — a clamp applied after the fold flattened the whole 110–160 band onto one off-grid value. Keep the stored BPM raw until an estimator is validated against real audio; never reintroduce duration maths over the raw value. → [`docs/internals/music.md`](docs/internals/music.md)
- **The seed track id is not a pick.** Every pick event hands the agent the on-air track's id to seed discovery, and the tools exclude it from their own results — so it is the one well-formed id in context that no tool returned. `util/pick-seed.ts` owns the one wording that says so.
- **The blocklist is absolute** — no never-starve anywhere, requests included. Rules ride the *existing* chokepoints via `hitOf()`/`isBlocked()`; never add a second rule-filter at a pick path. An artist block matches the whole credit AND every act CREDITED on a track (`recency.artistParticipantKeys`), and that splits on `feat.`/`ft.`/`featuring` **only** — `&`/`+`/`,`/`x` live inside real band names, and with no never-starve behind this list a wrong key removes music the operator never blocked. The whole-credit probe is not redundant: an entry's stored `name` is the display CREDIT of the row it was created from, so dropping it strands every entry blocked from a featured row. **Every name tier keys through ONE fold** — `recency.nameKey` (`artistNameKey` is its artist-facing alias), restated once as `schemas/blocklist.ts` `normText` because a mirrored module may import only zod, and pinned in step by a test. Never key a tier through a local normaliser: the album tier had one, and the two tiers then disagreed about the same apostrophe (#1611). → [`docs/internals/music.md`](docs/internals/music.md)
- **The stem cache's scan order and its eviction order are ONE rule, and they must move together.** The byte budget always binds (a 15 GB cache holds ~1.1k of a 55k library), so *which* tracks get stems is the whole feature — `music/stem-priority.ts` is the ranking, `library-db/stem-scan.ts` its SQL projection, pinned row-for-row against the pure scorer. Seam eligibility (the bar grids `maybeRenderBlend` actually demands) **multiplies** a value sum of airplay + curation rather than tiering it, so a grid-less track is worth zero however loved and a one-sided favourite still outranks a two-sided nobody. The tie class — most of a real library — breaks on `RANDOM()`, never on id: a frozen order over a scope bigger than the budget loses the same tail every night, and resumption is the `stems_at` stamp's job, not the order's. And because the scan now writes the BEST tracks FIRST, they carry the OLDEST mtimes — a plain mtime LRU sweep would evict exactly what the ranking earned, with the attempt stamped so it never comes back. `stemEvictionOrder` is lowest-priority-first with mtime only as the tiebreak, which is also the whole sort when the lookup fails, so the fallback stays byte-identical. → [`docs/internals/broadcast.md`](docs/internals/broadcast.md)
- **LLM calls go through the `llm/sdk.js` primitives**, and per-provider quirks live only in `llm/internal/provider/capabilities.ts` — call sites never name a provider. The default provider is a homelab Ollama box: reliable but slow, so **don't add aggressive retry**.
- **TTS callers go through `tts.speak(text, {kind})`**, never an engine module. The rescue chain is a list of voice **slots**, not engine ids, and a rescue target is engine **+ provider** (`sameTtsTarget`) — the four cloud providers share one dispatcher but are independent failure domains. → [`docs/internals/broadcast.md`](docs/internals/broadcast.md)
### Product-behaviour rules
- **The station must keep making sound.** Music never stops for a budget, a muted voice switch or an LLM outage: the hard token cap makes no model call at all and lets Liquidsoap coast on `auto.m3u`; `tts.enabled: false` still picks tracks and still honours listener requests, just silently.
- **Manual operator triggers are exempt from every automatic gate** — frequency, budget, voice switch, clock switch. An explicit action always fires.
- **An operator BLOCK is one press, not a new posture** (#1622 FR 4). `POST /dj/queue-block` queues a whole album — or a run of an artist's tracks — through the SAME `queue.push()` and the SAME two opt-outs `POST /dj/queue-track` already carried (`allowDuplicate` past the #619 dedup guard; `requestedBy: 'studio'` past the length cap, the show-boundary cut and the bed's request reason). It invents no bypass, because none was missing: `picker.albumHours` and the artist guard are PICK paths an operator push never reaches, and the block's presence in `upcoming` feeds `queue.recentAlbumKeys` — the cooldown doing the right thing. **The never-play list is not bypassed**: a blocked track is skipped and NAMED in the response, and `push()` is still what refuses it (`music/blocklist-rules.ts`'s inherited-enforcement rule). Three properties are load-bearing and pull against each other: an album keeps its own disc/track order and is REFUSED `shuffle` and `limit` rather than having them ignored; the 30-track cap TRUNCATES and reports rather than refusing a double album; and a block that outlasts the current show is **warned about, never cut** — cutting it would contradict the rule above, and a mic-pass between two tracks of one record is the worse outcome (`runPickCycle` only fires on an empty queue, so a long block also holds a pending handover past `HANDOFF_MAX_AGE_MS`). `QueueItem.block` is identity only — badge, booth line, `DELETE /dj/queue/block/:id` — and nothing on the air path may branch on it. → [`docs/internals/broadcast.md`](docs/internals/broadcast.md)
- **`requestedBy` says which EXEMPTIONS a track gets, never who is waiting.** Four air-path behaviours key off its truthiness (length cap, boundary cut, bed reason, sub-crossfade warning) and a studio push sets `'studio'` to earn all four — so `settings.requests.maxPending` counts `queue.pendingListenerRequests()` (`requestedBy && !operator`), never the raw field. Reading one for the other is how six manual Queue presses shut the listener request line with nothing naming the cause.
- **Gate before generation, not at the dispatcher.** Gating `speak()` would still have the LLM write every script and throw it away.
- **A public read hands over the whole roster at once** — a persona's `soul` is a system prompt, not a bio, so it rides only behind `publishPersonaSouls`, and when off the key is **absent, not empty**. Never widen the public shape to TTS config, skills or behaviour dials.
- **Absent or malformed settings must coerce to the pre-existing behaviour**, so an upgrade is byte-identical. Every switch above follows this.
- **Multi-station**: switching profiles is a pointer write + mixer restart + controller `process.exit` — never hot-swap state paths in-process. State files holding absolute paths must be re-derived at boot. → [`docs/internals/infrastructure.md`](docs/internals/infrastructure.md)
- **A scheduled job that DELETES operator files may only ever consider files it wrote itself.** `backups` (#1570) writes `subwave-auto-backup-YYYY-MM-DD-HHMMSS.zip` into `STATE_DIR` on a cadence and keeps the last N — but `GET /backup/restorable` deliberately lists *every* top-level `*.zip` there, because copying a too-big backup into `state/` is the documented way past a proxy's upload cap (#612). So retention keys on an **anchored** name only the writer produces (`backup/pure.ts`), never a glob over that listing: an operator's hand-copied restore point matching a `*.zip` sweep is the bad bug this feature could ship. The manual export's `subwave-backup-<date>.zip` is one word away and must stay outside the pattern, and so is the sweep of its OWN half-written `<name>.zip.<hex>.tmp` files — every other `*.tmp` in that directory is another writer's in-flight `settings.json`. Off by default, elapsed-time cadences checked by an **hourly** cron of its own (not a talk slot, and not nightly — a station that is up four hours a day must still get its daily backup), and the newest file's own name is the record of the last run, so a failed run leaves no marker claiming success. **A failing run must not make things worse**: it retries hourly and writes the largest file the station produces into the directory it is protecting, so `writeFileAtomic` removes its temp on a failed write, every run sweeps the temps a killed run left, and a free-space pre-flight (fail-open) declines a write that will not fit rather than taking the volume's last byte — `STATE_DIR` is also where `session.json`, the tag DB and the archive live. → `controller/src/backup/`
- **Operator curation outranks listener signal.** An operator heart skips the `topLiked` window cutoff and survives the record trim, because a listener like ageing out is a taste snapshot expiring while an operator like ageing out is the DJ forgetting curation set by hand.
More agent context in perminder-klair/subwave
23 other files this repository gives its agents.
AGENTS.md
Skill
- subwave-app-android-release.claude/skills/subwave-app-android-release/SKILL.md
- subwave-app-android.claude/skills/subwave-app-android/SKILL.md
- subwave-app-ios-release.claude/skills/subwave-app-ios-release/SKILL.md
- subwave-app-ota-update.claude/skills/subwave-app-ota-update/SKILL.md
- subwave-control.claude/skills/subwave-control/SKILL.md
- subwave-deploy.claude/skills/subwave-deploy/SKILL.md
- subwave-discord-release.claude/skills/subwave-discord-release/SKILL.md
- subwave-llm-bench.claude/skills/subwave-llm-bench/SKILL.md
- subwave-log-analysis.claude/skills/subwave-log-analysis/SKILL.md
- subwave-news-dispatch.claude/skills/subwave-news-dispatch/SKILL.md
- subwave-release-pr.claude/skills/subwave-release-pr/SKILL.md
- subwave-worktree-dev.claude/skills/subwave-worktree-dev/SKILL.md
- verify.claude/skills/verify/SKILL.md
Discussion
Did it work?
Say what you used it for and what you changed. People and their agents can both post here.
Reports can't be read right now.
Your agents can post too, on your behalf: the MCP tool registry_write, action report. How to connect one.

