From 5805f45358fc769c39f0a4f03ba937221ce0e7ba Mon Sep 17 00:00:00 2001 From: David Ibia Date: Sun, 18 Oct 2026 12:55:57 +0100 Subject: [PATCH] feat(bin): add sfx to render notification sounds offline from TOML recipes sfx is a single-file uv script that synthesises sounds from sine partials and seeded noise, so its output contains no samples, SoundFonts or model output, renders without a network once uv has installed numpy and mido, and comes out byte-identical on every run. Each sound in a recipe is normalised to a target loudness (ITU-R BS.1770, default -26 LUFS, the level of the macOS Glass alert) and written as WAV, with optional MP3 through ffmpeg and a MIDI file of the same notes. Unknown keys and bad note names stop the render with an error naming the table. The new sound-design skill documents the recipe format and how to design notification sounds; link-ai-configs.sh links it for omo and Claude Code. --- .config/omo/skills/README.md | 1 + .config/omo/skills/sound-design/SKILL.md | 61 +++++ .local/bin/sfx | 329 +++++++++++++++++++++++ README.md | 5 +- link-ai-configs.sh | 4 +- 5 files changed, 396 insertions(+), 4 deletions(-) create mode 100644 .config/omo/skills/sound-design/SKILL.md create mode 100755 .local/bin/sfx diff --git a/.config/omo/skills/README.md b/.config/omo/skills/README.md index 5163b32..9b86675 100644 --- a/.config/omo/skills/README.md +++ b/.config/omo/skills/README.md @@ -17,6 +17,7 @@ OpenCode-era evaluation notes and rollback steps are in git history at | `web-design-guidelines` | [vercel-labs/agent-skills](https://github.com/vercel-labs/agent-skills) (`skills/web-design-guidelines/SKILL.md`) | `063bee94c3f4df8453406c830b0a7df0f2860278` | per upstream | | ten marketing and copy skills | [coreyhaines31/marketingskills](https://github.com/coreyhaines31/marketingskills) (`skills/`) | `b9ba399dd88b082b926e261e8ccfb843d20aa066` | MIT, copied into each skill | | `frontend-workflow` | local | - | - | +| `sound-design` | local | - | - | The ten marketing skills are `product-marketing`, `copywriting`, `copy-editing`, `emails`, `cold-email`, `content-strategy`, `seo-audit`, `ai-seo`, `launch` and diff --git a/.config/omo/skills/sound-design/SKILL.md b/.config/omo/skills/sound-design/SKILL.md new file mode 100644 index 0000000..e8295f9 --- /dev/null +++ b/.config/omo/skills/sound-design/SKILL.md @@ -0,0 +1,61 @@ +--- +name: sound-design +description: Creates original notification sounds, UI sounds, short sound effects and MIDI offline with the sfx recipe renderer, so the audio contains no third-party material and works on macOS and Linux. Use when asked to make, tweak or wire up a ding, chime, alert, notification or effect sound, including omo's own notification sounds. +--- + +# Sound design with sfx + +`sfx` (`~/.local/bin/sfx`, a uv script in the dotfiles) renders sounds from a TOML recipe. Everything is synthesised from sine partials and seeded noise, and the same recipe renders to byte-identical files on every run and machine. `sfx --help` lists the options. + +## Rules + +- Synthesise only. Never use samples, SoundFonts, sound libraries, online generators or AI audio models: the point is audio with no third-party material that renders without a network. +- You cannot hear. Read the numbers `sfx` prints, then ask the user to audition the file before calling a sound done, and never change a sound they approved without them listening again. +- Keep the recipe next to the rendered files and commit both. The recipe is the source. + +## Workflow + +1. Write or edit a recipe file (format below). +2. Run `sfx recipes.toml [OUT_DIR] [--only NAME ...]`. Files go next to the recipe unless OUT_DIR is given. The first run lets uv install numpy and mido once. +3. Check each report line: length, loudness and peak, e.g. `omo-done: 2.22s, -26.0 LUFS, peak -15.8 dBFS`. A note saying the peak ceiling held it back means the sound is quieter than the target; lower `bright` or spread the notes out. +4. Ask the user to listen with `omo-sound path/to/NAME.wav` (afplay on macOS; pw-play, paplay, aplay or ffplay on Linux), then iterate on the notes and tone. + +## Recipe format + +```toml +loudness = -26.0 # target LUFS for every sound (default -26, the level of the macOS Glass alert) +extras = ["mp3", "mid"] # optional: MP3 needs ffmpeg; MIDI exports the notes only +program = 11 # General MIDI program for the .mid files (default 11, vibraphone) + +[sound.example-chime] +about = "what it is for" +notes = [[0.00, "G5", 0.75, -0.15], [0.11, "E6", 1.00, 0.15]] # [start s, note, velocity 0..1, pan -1..1] +tone = { bright = 0.6, decay = 1.3 } +room = { rt60 = 0.7, wet = 0.22 } +whoosh = { start = 0.0, duration = 0.38, from_hz = 300, to_hz = 5000, gain = 0.6, pan = 0.0 } +loudness = -24.0 # optional per-sound override +``` + +- **notes**: a note is a name (`E6`, `C#5`, `Bb4`; C4 is middle C, 60) or a MIDI number. Each one is a struck-bar ding. +- **tone**: `bright` sets how much of the metallic strike comes through (0.35 is muted, 1.0 neutral, 1.25 sparkly). `decay` scales the ring time; at 1.0 a note rings out in about two seconds. +- **room**: a small synthetic room. `rt60` is its decay time (0.05 to 5 s) and `wet` its level; set `wet = 0` for a dry sound. +- **whoosh**: band-passed noise sweeping from `from_hz` to `to_hz` (20 to 8000), for air, swishes and sent effects. A sound needs notes, a whoosh, or both. + +Unknown keys and bad notes stop the render with an error naming the table and key. + +## Designing notification sounds + +- Keep the audible part under a second, with two or three notes. A rising contour reads as positive or as a call for attention; a falling one reads as a problem. Brighter tones feel more urgent. +- Tell events apart by contour and timbre, not just pitch: for example a rising arpeggio for done, a brighter rising pair for a question, and a muted falling pair for an error. +- Keep notes between about C5 and C7. Higher gets shrill and lower gets muddy on laptop speakers. +- Give a set of sounds one loudness and let their shape carry the meaning. + +## Formats + +- **WAV** (always written, 48 kHz 16-bit stereo): desktop playback and native notifications. macOS plays a custom notification sound only from inside the app bundle. +- **MP3** (`extras = ["mp3"]`): browsers. Browsers refuse to play audio until the user has interacted with the page, and web push notifications cannot carry a custom sound at all. +- **MIDI** (`extras = ["mid"]`): the same notes for a DAW. A whoosh has no MIDI form. + +## omo's own sounds + +`~/dotfiles/.config/omo/sounds/recipes.toml` renders `omo-done`, `omo-input` and `omo-error`. `omo-notify` (local hooks) and `omo-remote` (events from flowstate-remote) play them through `omo-sound NAME`. To change one, edit the recipe, run `sfx recipes.toml` in that directory, and the next event uses the new sound. Keep the three names, because the scripts map events to them. diff --git a/.local/bin/sfx b/.local/bin/sfx new file mode 100755 index 0000000..c6c6f44 --- /dev/null +++ b/.local/bin/sfx @@ -0,0 +1,329 @@ +#!/usr/bin/env -S uv run --script +# /// script +# requires-python = ">=3.11" +# dependencies = ["numpy>=1.26", "mido>=1.3"] +# /// +"""Render notification sounds and short effects from a TOML recipe file. + +Every sound is synthesised from sine partials and seeded noise: no samples, +SoundFonts or model output go in, nothing touches the network once uv has +installed numpy and mido, and a recipe renders to the same audio on every run +and machine. Each sound is normalised to a target loudness in LUFS +(ITU-R BS.1770), so a set of sounds plays at one consistent level. +""" + +import argparse +import re +import shutil +import subprocess +import sys +import tomllib +import wave +import zlib +from pathlib import Path + +import mido +import numpy as np + +SR = 48_000 +PEAK_CEILING_DBFS = -1.0 +TAIL_DB = -55.0 +DEFAULT_LOUDNESS = -26.0 +DEFAULT_PROGRAM = 11 +NOTE_NAME = re.compile(r"([A-Ga-g])([#b]?)(-?\d)") +SEMITONES = {"C": 0, "D": 2, "E": 4, "F": 5, "G": 7, "A": 9, "B": 11} + +# BS.1770 K-weighting at 48 kHz as ((b0, b1, b2), (a1, a2)): a high shelf, then a high-pass. +K_WEIGHTING = ( + ((1.53512485958697, -2.69169618940638, 1.19839281085285), (-1.69065929318241, 0.73248077421585)), + ((1.0, -2.0, 1.0), (-1.99004745483398, 0.99007225036621)), +) + +TOP_KEYS = {"loudness", "extras", "program", "sound"} +SOUND_KEYS = {"about", "notes", "tone", "whoosh", "room", "loudness"} +TONE_KEYS = {"bright", "decay"} +ROOM_KEYS = {"rt60", "wet"} +WHOOSH_KEYS = {"start", "duration", "from_hz", "to_hz", "gain", "pan"} +EXTRAS = {"mp3", "mid"} + + +class RecipeError(Exception): + pass + + +def check_keys(table, allowed, where): + if not isinstance(table, dict): + raise RecipeError(f"{where} must be a table") + unknown = sorted(set(table) - allowed) + if unknown: + raise RecipeError(f"{where}: unknown key {', '.join(unknown)} (allowed: {', '.join(sorted(allowed))})") + + +def midi_note(value, where): + if isinstance(value, int) and not isinstance(value, bool): + number = value + else: + match = NOTE_NAME.fullmatch(str(value)) + if not match: + raise RecipeError(f"{where}: {value!r} is neither a MIDI note number nor a note name like E6") + letter, accidental, octave = match.groups() + number = 12 * (int(octave) + 1) + SEMITONES[letter.upper()] + {"#": 1, "b": -1, "": 0}[accidental] + if not 0 <= number <= 127: + raise RecipeError(f"{where}: note {value!r} is outside the MIDI range") + return number + + +def hz(note): + return 440.0 * 2 ** ((note - 69) / 12) + + +def fade_in(x, seconds): + n = min(len(x), int(seconds * SR)) + x[:n] *= 0.5 - 0.5 * np.cos(np.linspace(0, np.pi, n)) + return x + + +def ding(rng, note, bright, decay): + """Struck-bar tone: decaying partials, a few inharmonic ones for the strike.""" + partials = ( # (frequency ratio, amplitude, decay time constant in seconds) + (1.0, 1.0, 0.32 * decay), + (2.0, 0.30 * bright, 0.16 * decay), + (2.76, 0.16 * bright, 0.07 * decay), + (5.40, 0.07 * bright, 0.03 * decay), + (8.93, 0.035 * bright, 0.015 * decay), + ) + t = np.arange(int(7 * partials[0][2] * SR)) / SR # long enough to decay to about -60 dB + f0 = hz(note) + out = np.zeros_like(t) + for ratio, amp, tau in partials: + if f0 * ratio < SR * 0.45: + out += amp * np.sin(2 * np.pi * f0 * ratio * t + rng.uniform(0, 2 * np.pi)) * np.exp(-t / tau) + n = int(0.004 * SR) # soft mallet tick + tick = np.convolve(rng.standard_normal(n), np.ones(8) / 8, mode="same") + out[:n] += 0.04 * bright * tick * np.linspace(1, 0, n) + return fade_in(out, 0.003) + + +def whoosh(rng, duration, from_hz, to_hz, q=2.0): + """Noise through a band-pass whose centre sweeps from from_hz to to_hz.""" + n = int(duration * SR) + noise = rng.standard_normal(n).tolist() + coeff = (2 * np.sin(np.pi * np.geomspace(from_hz, to_hz, n) / SR)).tolist() + damp = 1 / q + low = band = 0.0 + out = [0.0] * n + for i in range(n): # Chamberlin state-variable filter + high = noise[i] - low - damp * band + band += coeff[i] * high + low += coeff[i] * band + out[i] = band + y = np.array(out) * np.sin(np.pi * np.linspace(0, 1, n)) ** 2 + return y / np.abs(y).max() + + +def fftconv(a, b): + n = len(a) + len(b) - 1 + size = 1 << (n - 1).bit_length() + return np.fft.irfft(np.fft.rfft(a, size) * np.fft.rfft(b, size), size)[:n] + + +def room(rng, rt60): + """Synthetic small-room impulse response: decaying, darkened noise.""" + t = np.arange(int(rt60 * SR)) / SR + ir = rng.standard_normal(len(t)) * np.exp(-6.91 * t / rt60) # -60 dB at rt60 + ir = fade_in(np.convolve(ir, np.ones(12) / 12, mode="same"), 0.01) + return ir / np.sqrt(np.sum(ir**2)) + + +def mix(rng, events, rt60, wet): + """events: (start seconds, mono signal, gain, pan from -1 to 1), mixed to stereo.""" + length = max(int(start * SR) + len(sig) for start, sig, _, _ in events) + dry = np.zeros((length, 2)) + for start, sig, gain, pan in events: + i = int(start * SR) + theta = (pan + 1) * np.pi / 4 # constant-power pan + dry[i : i + len(sig), 0] += sig * gain * np.cos(theta) + dry[i : i + len(sig), 1] += sig * gain * np.sin(theta) + mono = dry.mean(axis=1) + out = np.zeros((length + int(rt60 * SR) - 1, 2)) + out[:length] = dry + if wet > 0: + out[:, 0] += wet * fftconv(mono, room(rng, rt60)) + out[:, 1] += wet * fftconv(mono, room(rng, rt60)) + # Trim the inaudible tail, then fade the last 30 ms so the end never clicks. + level = np.abs(out).max(axis=1) + out = out[: np.nonzero(level > level.max() * 10 ** (TAIL_DB / 20))[0][-1] + 1] + n = min(len(out), int(0.03 * SR)) + out[-n:] *= np.linspace(1, 0, n)[:, None] + return out + + +def biquad(x, b, a): + b0, b1, b2 = b + a1, a2 = a + y = np.empty_like(x) + for ch in range(x.shape[1]): + x1 = x2 = y1 = y2 = 0.0 + out = [] + for xi in x[:, ch].tolist(): + yi = b0 * xi + b1 * x1 + b2 * x2 - a1 * y1 - a2 * y2 + x2, x1, y2, y1 = x1, xi, y1, yi + out.append(yi) + y[:, ch] = out + return y + + +def loudness(x): + """Integrated loudness in LUFS per ITU-R BS.1770-4: K-weighting, 400 ms blocks, two gates.""" + z = x + for b, a in K_WEIGHTING: + z = biquad(z, b, a) + block, hop = int(0.4 * SR), int(0.1 * SR) + z = np.pad(z, ((0, max(0, block - len(z))), (0, 0))) + power = np.array([np.mean(z[i : i + block] ** 2, axis=0).sum() for i in range(0, len(z) - block + 1, hop)]) + + def lufs(p): + return -0.691 + 10 * np.log10(np.maximum(p, 1e-20)) + + power = power[lufs(power) > -70] + if not len(power): + raise RecipeError("the sound is silent") + power = power[lufs(power) > lufs(power.mean()) - 10] + return float(lufs(power.mean())) + + +def normalise(x, target): + """Scale to the target loudness, or lower if that would push the peak past the ceiling.""" + measured = loudness(x) + gain = target - measured + peak = 20 * np.log10(np.abs(x).max()) + gain + capped = peak > PEAK_CEILING_DBFS + if capped: + gain -= peak - PEAK_CEILING_DBFS + return x * 10 ** (gain / 20), measured + gain, capped + + +def build(name, spec): + where = f"[sound.{name}]" + check_keys(spec, SOUND_KEYS, where) + tone = {"bright": 1.0, "decay": 1.0, **spec.get("tone", {})} + check_keys(tone, TONE_KEYS, f"{where} tone") + space = {"rt60": 0.7, "wet": 0.22, **spec.get("room", {})} + check_keys(space, ROOM_KEYS, f"{where} room") + if not 0.05 <= space["rt60"] <= 5: + raise RecipeError(f"{where} room: rt60 must be between 0.05 and 5 seconds") + rng = np.random.default_rng(zlib.crc32(name.encode())) + events, notes = [], [] + for i, entry in enumerate(spec.get("notes", [])): + if not (isinstance(entry, list) and len(entry) == 4): + raise RecipeError(f"{where} notes[{i}] must be [start, note, velocity, pan]") + start, note, velocity, pan = entry + number = midi_note(note, f"{where} notes[{i}]") + notes.append((float(start), number, float(velocity))) + events.append((float(start), ding(rng, number, tone["bright"], tone["decay"]), float(velocity), float(pan))) + if "whoosh" in spec: + w = spec["whoosh"] + check_keys(w, WHOOSH_KEYS, f"{where} whoosh") + missing = sorted({"duration", "from_hz", "to_hz"} - set(w)) + if missing: + raise RecipeError(f"{where} whoosh is missing {', '.join(missing)}") + if not 20 <= min(w["from_hz"], w["to_hz"]) <= max(w["from_hz"], w["to_hz"]) <= 8000: + raise RecipeError(f"{where} whoosh: from_hz and to_hz must be between 20 and 8000") + signal = whoosh(rng, float(w["duration"]), float(w["from_hz"]), float(w["to_hz"])) + events.append((float(w.get("start", 0.0)), signal, float(w.get("gain", 1.0)), float(w.get("pan", 0.0)))) + if not events: + raise RecipeError(f"{where} needs notes, a whoosh, or both") + return mix(rng, events, space["rt60"], space["wet"]), notes + + +def write_wav(path, x): + pcm = (np.clip(x, -1, 1) * 32767).astype(" {', '.join(written)}") + except (OSError, tomllib.TOMLDecodeError, RecipeError, subprocess.CalledProcessError) as e: + sys.exit(f"sfx: {e}") + + +if __name__ == "__main__": + main() diff --git a/README.md b/README.md index 484ae2d..6be3df7 100644 --- a/README.md +++ b/README.md @@ -103,7 +103,7 @@ The script creates individual relative symlinks, skips existing correct links, a - `.config/omo/omo.jsonc` supplies `~/.omo/omo.jsonc` with OmO's model routing, including the `design-critique-a` and `design-critique-b` categories that the Impeccable critique delegates its two assessments to. - `.config/ai/AGENTS.md` holds shared personal preferences. OmO reads it through `~/.config/opencode/AGENTS.md`, which is hard-wired in OmO's rules engine ahead of `~/.claude/CLAUDE.md`; the engine stops at the first file it finds, so this link both supplies these preferences and keeps Claude's OMC orchestration instructions out of OmO. Keep it even though OpenCode is retired. Claude imports the same file from its own `CLAUDE.md`. - `.config/claude/` supplies Claude's global instructions, settings, and OMC preferences. The machine-specific OMC `nodeBinary` cache is intentionally omitted; install Node.js and run OMC setup on each new machine to configure its runtime and HUD. -- `.config/omo/skills/` holds the vendored Impeccable, Vercel `web-design-guidelines`, `frontend-workflow` and marketing skills. The script links every one into `~/.omo/agent/skills/`, and all but `frontend-workflow` into `~/.claude/skills/`; see [the skills README](.config/omo/skills/README.md) for provenance and upgrades. Portable Claude plugins and unrelated user skills remain enabled. +- `.config/omo/skills/` holds the vendored Impeccable, Vercel `web-design-guidelines`, `frontend-workflow`, `sound-design` and marketing skills. The script links every one into `~/.omo/agent/skills/`, and all but `frontend-workflow` into `~/.claude/skills/`; see [the skills README](.config/omo/skills/README.md) for provenance and upgrades. Portable Claude plugins and unrelated user skills remain enabled. Credentials, `settings.local.json`, plugin installations, unrelated user skills, histories, caches, and runtime state are not copied into this repository or managed by this script. Install other desired plugins and portable skills separately. OmO's MCP servers live in `~/.omo/agent/mcp.json`, which this repository does not manage; the local ones (headroom, agcanvas, uvx, ast-grep-mcp, portfolio-mcp) must be installed on a new machine before they resolve. @@ -180,7 +180,7 @@ OpenCode is retired. Its agents, commands and plugins are left in `.config/openc - `/owt-done [notes]` finishes a workspace after its PR merges: it comments on each issue and moves it to Done, then runs `owt done --detach`. It asks before discarding uncommitted or unmerged work. - `/impeccable [command] [target]` runs the Impeccable design skill. - `/setup-notes`, `/sync-notes`, `/ingest-notes`, `/teleport-notes`, `/analyze-vault`, `/reorganize-notes`, `/preview-notes-delegation` and `/validate-notes-config` manage with-context documentation delegation. They came over from OpenCode and need the with-context MCP server, which is not in `~/.omo/agent/mcp.json`. -- `skills/` (linked into `~/.omo/agent/skills/`, and into `~/.claude/skills/` for the shared ones): the vendored Impeccable, Vercel `web-design-guidelines`, `frontend-workflow` and marketing skills. See [the skills README](.config/omo/skills/README.md) for provenance, local modifications and upgrades. +- `skills/` (linked into `~/.omo/agent/skills/`, and into `~/.claude/skills/` for the shared ones): the vendored Impeccable, Vercel `web-design-guidelines`, `frontend-workflow`, `sound-design` and marketing skills. See [the skills README](.config/omo/skills/README.md) for provenance, local modifications and upgrades. - `archive/`: the OpenCode-era `DEVELOPMENT-WORKFLOW.md` and `UI-WORKFLOW.md`, kept for reference and loaded by nothing. MCP servers (in `~/.omo/agent/mcp.json`, which this repository does not manage): Playwright, Chrome DevTools, Linear, reoclo, time, ast-grep, headroom, agcanvas and portfolio. @@ -232,6 +232,7 @@ Custom scripts and binaries are stored in `.local/bin/`: - `omo-notify`: Sound, tab marker and notification for OmO's hooks. Ghostty posts the notification, so clicking it opens the session's tab and pane - `omo-status`: Coloured marker in front of the Ghostty tab title for each tmux session running omo: ๐Ÿ”ด stopped with an error, ๐ŸŸก waiting for your answer, ๐ŸŸข finished, ๐Ÿ”ต working. A tab with several agents shows the most urgent one; focusing the tab clears red, amber and green, and undoes any title you gave the tab by hand so it always shows its session name - `omo-remote`: The same sound, tab marker and notification for omo sessions on a remote server (flowstate-remote). The server's `omo-notify` (kept on the server in `~/.local/bin`, not in this repository) writes the session's state into its terminal title, marker first (`๐ŸŸข studio ยท omo:done:...`). mosh drops notifications but carries titles, and so does ssh, so tmux's `pane-title-changed` hook passes the event to `omo-notify` for the pane running mosh or ssh. Everything else reaches you through ntfy: `omo-remote install` starts a launchd agent that subscribes to the topic and plays the sound and posts the notification for omo pushes, unless a tmux pane here already handled that event through its title, so nothing arrives twice. That covers sessions no terminal is showing and `fs` in a plain Ghostty tab, whose title shows the marker and which posts the notification itself (the coloured tab marker needs tmux). The agent reads `NTFY_URL`, `NTFY_TOPIC` and `NTFY_TOKEN` from `~/.config/ntfy/omo-remote.env` (chmod 600, not in this repository) and needs `brew "ntfy"` +- `sfx`: Renders notification sounds and short effects from a TOML recipe, offline and from synthesis alone, normalised to one loudness, as WAV plus optional MP3 and MIDI. A uv script; `sfx --help` lists the options and the `sound-design` skill documents the recipe format - `docker-nuke`: Force-restart a wedged Docker Desktop, capturing diagnostics to `~/.local/state/docker-nuke/` first (`--dry-run` to preview, `--no-restart` to leave it down, `--deep` for root helpers) - `impeccable-browser`: The browser for Impeccable's URL scans (`impeccable detect `) on flowstate-remote, set as `IMPECCABLE_BROWSER` in the server's `~/.zshenv`. Public URLs render on Browserless. Its key, `BROWSERLESS_API_KEY` from the Reoclo project `flowstate-tools`, is cached for 12 hours in `~/.impeccable/browserless.token`. Localhost and private addresses reach Browserless through a cloudflared quick tunnel that lasts only for the scan, and reports still name the local URL. Other launches, and any scan whose tunnel or Browserless connection fails, run on the headless Chrome in the Puppeteer cache with `--no-sandbox`, because the server's AppArmor blocks Chrome's sandbox. `IMPECCABLE_TUNNEL=0` keeps private addresses on local Chrome, which pages that call a second local port (a separate API, say) need. Every decision is logged to `~/.impeccable/browser.log` diff --git a/link-ai-configs.sh b/link-ai-configs.sh index e2f8e88..1a02d0f 100644 --- a/link-ai-configs.sh +++ b/link-ai-configs.sh @@ -50,8 +50,8 @@ targets=( # steps are in .config/omo/skills/README.md. frontend-workflow is the OmO # delegation process skill and is not linked into Claude Code; the rest are # shared by both harnesses. -omo_skills=(frontend-workflow impeccable web-design-guidelines product-marketing copywriting copy-editing emails cold-email content-strategy seo-audit ai-seo launch social) -claude_skills=(impeccable web-design-guidelines product-marketing copywriting copy-editing emails cold-email content-strategy seo-audit ai-seo launch social) +omo_skills=(frontend-workflow impeccable web-design-guidelines product-marketing copywriting copy-editing emails cold-email content-strategy seo-audit ai-seo launch social sound-design) +claude_skills=(impeccable web-design-guidelines product-marketing copywriting copy-editing emails cold-email content-strategy seo-audit ai-seo launch social sound-design) for skill in "${omo_skills[@]}"; do sources+=(".config/omo/skills/$skill") targets+=(".omo/agent/skills/$skill")