diff --git a/.agents/hooks/core/serena-primer.ts b/.agents/hooks/core/serena-primer.ts
index df2aabc..07eaca2 100644
--- a/.agents/hooks/core/serena-primer.ts
+++ b/.agents/hooks/core/serena-primer.ts
@@ -117,6 +117,7 @@ export function primerContext(): string {
"- Code discovery / reading: `get_symbols_overview`, `find_symbol`, `find_referencing_symbols`, `search_for_pattern`.",
"- Code edits: `replace_symbol_body`, `insert_after_symbol`, `insert_before_symbol`, `replace_content`.",
"- Native grep/glob: only for initial filename/path discovery. Do not fall back to grep + Read for code navigation just because Serena's tools aren't loaded yet — load them.",
+ '- Result size: omit `max_answer_chars` on Serena tools (uses the configured default, typically 150000). Never pass small caps like `3000` on broad searches. If a call returns "The answer is too long (N characters)", retry with `max_answer_chars` > N or narrow path/glob — do not keep the low cap.',
"- Exception — MCP timeout: if a Serena MCP call times out or hangs (seen mainly in OpenCode Desktop's long-lived sidecar), stop retrying MCP for this session: use native search/read for code, and access `.serena/memories/` files directly (or `serena memories read|write` when Serena CLI ≥ 1.5 is installed) for memory work. A full app relaunch restores Serena MCP.",
].join("\n");
}
diff --git a/.agents/skills/_version.json b/.agents/skills/_version.json
index 0070ef0..e9d075d 100644
--- a/.agents/skills/_version.json
+++ b/.agents/skills/_version.json
@@ -1,6 +1,6 @@
{
- "version": "11.1.1",
+ "version": "11.6.0",
"schemaVersion": 2,
"mode": "project",
- "installedAt": "2026-07-27T12:13:46.129Z"
+ "installedAt": "2026-08-03T12:14:26.889Z"
}
diff --git a/.agents/skills/oma-frontend/SKILL.md b/.agents/skills/oma-frontend/SKILL.md
index 22d5095..05d905e 100644
--- a/.agents/skills/oma-frontend/SKILL.md
+++ b/.agents/skills/oma-frontend/SKILL.md
@@ -129,7 +129,8 @@ Then run the project's frontend verification commands, typically lint, typecheck
5. Run the execution checklist before handoff and include relevant verification results.
6. **Self-describing file names**: every new file follows the File Naming convention in `../../rules/frontend.md` §Naming Conventions — domain + role readable from the basename alone (`order-summary-card.tsx`, `use-order-polling.ts`, `cart.atoms.ts`). Grab-bag names (`utils.ts`, `helpers.ts`, `misc.ts`) and version suffixes (`*-v2`, `*-final`) are banned.
7. **Next.js 16 `proxy.ts` is mandatory; `middleware.ts` is BANNED**: this project is Next.js 16+. `middleware.ts` is NOT "deprecated"; it is forbidden, touch it and you die. The canonical request-proxy / auth-gate file is `proxy.ts` (root or `src/`) exporting a `proxy` function. NEVER create, recommend, suggest, or "restore" `middleware.ts`. NEVER flag `proxy.ts` as dead code, unused, or not-wired. Any such finding is a fatal self-error: retract it immediately and write `proxy.ts`.
-8. **Angular projects follow `resources/angular-rules.md`**: standalone components + `OnPush` + signals-first, `inject()` DI, lazy routes, new control flow. **Any non-trivial RxJS pipeline MUST ship with a marble test (`TestScheduler` from `rxjs/testing`)** — a stream without a marble test fails review. React/Next.js-specific rules (shadcn workflow, `proxy.ts`, Libraries table below) do not apply in Angular projects.
+8. **`next/link` defaults to `prefetch={false}`**: every `` MUST pass `prefetch={false}` unless there is a stated reason not to. Next.js's default prefetching fires a request per link entering the viewport, which hammers container CPU/memory and origin bandwidth on list-heavy or nav-heavy pages. Opt back in (`prefetch` omitted, or `prefetch` / `prefetch="unstable_forceStale"`) ONLY for a small, deliberate set of high-intent targets (primary CTA, next step in a funnel), and note the reason inline. A `` without an explicit prefetch decision fails review.
+9. **Angular projects follow `resources/angular-rules.md`**: standalone components + `OnPush` + signals-first, `inject()` DI, lazy routes, new control flow. **Any non-trivial RxJS pipeline MUST ship with a marble test (`TestScheduler` from `rxjs/testing`)** — a stream without a marble test fails review. React/Next.js-specific rules (shadcn workflow, `proxy.ts`, Libraries table below) do not apply in Angular projects.
### Libraries
diff --git a/.agents/skills/oma-frontend/resources/checklist.md b/.agents/skills/oma-frontend/resources/checklist.md
index abfb82c..dbe518e 100644
--- a/.agents/skills/oma-frontend/resources/checklist.md
+++ b/.agents/skills/oma-frontend/resources/checklist.md
@@ -8,6 +8,7 @@ Run through every item before submitting your work.
- [ ] Do NOT flag `src/proxy.ts` as dead code or recommend renaming to `middleware.ts`; `proxy.ts` is the canonical Next.js 16+ convention
- [ ] Config flags use the `Proxy` form (e.g. `skipProxyUrlNormalize`), not the legacy `Middleware` form
+- [ ] Every `` passes `prefetch={false}`; any prefetching link is a deliberate high-intent target with an inline reason
## TypeScript
- [ ] Strict mode, no `any` types
diff --git a/.agents/skills/oma-frontend/resources/snippets.md b/.agents/skills/oma-frontend/resources/snippets.md
index 4930d41..7e0402d 100644
--- a/.agents/skills/oma-frontend/resources/snippets.md
+++ b/.agents/skills/oma-frontend/resources/snippets.md
@@ -8,8 +8,13 @@ Copy-paste ready patterns. Use these as starting points, adapt to the specific t
```tsx
// Internal nav: , never
+// prefetch={false} is the DEFAULT — viewport prefetching eats container CPU/RAM.
import Link from "next/link";
-View gallery
+View gallery
+
+// Opt back in only for a few high-intent targets, and say why:
+// primary funnel CTA — prefetch is intentional
+Checkout
// Custom font: next/font, never
import { Inter } from "next/font/google";
diff --git a/.agents/skills/oma-video/SKILL.md b/.agents/skills/oma-video/SKILL.md
index 7f016c4..7bd9b6f 100644
--- a/.agents/skills/oma-video/SKILL.md
+++ b/.agents/skills/oma-video/SKILL.md
@@ -203,7 +203,7 @@ Before invoking `oma video generate`, the calling agent runs this checklist. **I
- [ ] **Locale**: narration + caption language (default from config; translated via oma-translator when non-source).
- [ ] **Captions**: `tiktok` (centered, static windowed cues), `lower-third`, or `none`.
- [ ] **Duration**: target seconds (<= 180) or `auto` (derived from the script).
-- [ ] **Voice / music**: voice profile or `none`; music `upbeat` / `calm` / `none`. **The default voice is `none` → a silent video with estimated caption timing.** Pass `--voice ` (a Voicebox profile) whenever narration is expected. Music mixing is currently deferred (recorded + warned, not mixed).
+- [ ] **Voice / music**: voice profile or `none`; music `upbeat` / `calm` / `none`. **The default voice is `none` → a silent video with estimated caption timing.** Pass `--voice ` (a Voicebox profile) whenever narration is expected. Music is rendered offline by Strudel and mixed at −18 dB; it needs a one-time `oma video doctor --install-strudel` and degrades to no music without it.
**Amplification shortcut.** For a one-line brief (e.g. "shorts about Jeju coffee"), do not pop a questionnaire if the request is genuinely simple. Instead **amplify inline and show the user** the inferred plan before invoking:
@@ -247,7 +247,7 @@ oma video generate "" [--mode shorts|explainer|demo] \
[--aspect 9:16|16:9|1:1|auto] [--locale ] \
[--captions tiktok|lower-third|none] \
[--visual auto|generate|stock|aigc|slide] \
- [--voice |none] [--music upbeat|calm|none] \
+ [--voice |none] [--music upbeat|calm|cinematic|lofi|piano|none] \
[--duration |auto] [--compositor remotion|mpt] \
[--capture ] \
[--source file|web] [--url ] [--device ] \
@@ -298,7 +298,13 @@ Other skills call `oma video generate --format json` and parse the JSON envelope
- **Narration is one wav**: oma-voice joins every scene line into a single `audio/narration-01.wav`, referenced by `render-spec.audio.narration`. There are no per-scene `narration-NN.wav` files.
- **Timing**: per-line offsets live in `timing.json` (voicebox-stt -> estimated; the `tts-native` and `whisper-cpp` source values are reserved but not yet wired — `TODO(oma-deferred): whisper-cpp`); scene boundaries and caption cues are derived from it.
- **Captions**: key-free `.srt` (+ `.vtt`) built from `timing.json`; `render-spec.captions.file` points at the `.srt`. The compositor renders **static windowed cues** — the cue active at the current frame, CSS-wrapped (no per-word animation).
-- **Music**: deferred (`TODO(oma-deferred): music`) — no music asset source is wired yet, so a requested `--music` mode is recorded in `script.json` and surfaced as a warning, but no music is mixed. When implemented, it mixes under narration at `render-spec.audio.musicGainDb` (default −18 dB).
+- **Music**: `--music ` renders a BGM bed with **Strudel** and mixes it under narration at `render-spec.audio.musicGainDb` (default −18 dB). The bed is generated offline (headless Chrome + `OfflineAudioContext`), so it needs no key, no network, and no audio device — a 30s bed renders in well under a second.
+ - **Presets**: `calm` (sustained pad + arpeggio), `upbeat` (bright plucks), `cinematic` (drone build to a lead), `lofi` (warm chords, swung ticks), `piano` (neoclassical arpeggio). Each preset picks its key and mode from the run `seed`, so the same preset sounds different run to run without a second pattern.
+ - **Artifacts** in the run dir: `music/bgm.wav` (mixed by the compositor), `music/bgm.mp3` (preview), `music/bgm-raw.wav` (pre-master), and `music/pattern.strudel` — the source that produced them, editable and re-renderable by hand.
+ - **Level**: every bed is normalised to −14 LUFS with a static gain before a peak limiter, so `musicGainDb` means the same thing for every preset. Normalisation is deliberately *not* `loudnorm`'s one-pass mode, which flattens the arrangement arc.
+ - **Opt-in install**: `@strudel/*` is AGPL-3.0-or-later while the oma CLI is MIT, so the deps are never bundled and never installed implicitly. Run `oma video doctor --install-strudel` once. The CLI never imports Strudel — it spawns `resources/strudel/render.mjs` as a subprocess, the same boundary the Remotion / Playwright projects use.
+ - **Fallback**: a missing install, a missing Chrome, or a failed render degrades to *no music* with a warning. The run still succeeds and `audio.music` stays unset (never a dangling `staticFile()` ref).
+ - **Determinism**: the built-in beds are oscillator-only (sine / triangle / square / sawtooth), which render byte-identically on replay. Noise sounds (`white` / `pink` / `brown`) draw from `Math.random()` and would break that, so the templates avoid them.
## References
diff --git a/.agents/skills/oma-video/resources/execution-protocol.md b/.agents/skills/oma-video/resources/execution-protocol.md
index bafb9c1..e739574 100644
--- a/.agents/skills/oma-video/resources/execution-protocol.md
+++ b/.agents/skills/oma-video/resources/execution-protocol.md
@@ -15,7 +15,7 @@ plan when the brief is a one-liner.
- `mode` ∈ {`shorts`, `explainer`, `demo`}.
- `aspect` ∈ {`9:16`, `16:9`, `1:1`, `auto`} (`auto` snaps to the mode default: shorts -> 9:16, explainer/demo -> 16:9).
- `captions` ∈ {`tiktok`, `lower-third`, `none`}; `visual` ∈ {`auto`, `generate`, `stock`, `aigc`, `slide`}.
- - `music` ∈ {`upbeat`, `calm`, `none`}; `compositor` ∈ {`remotion`, `mpt`}.
+ - `music` ∈ {`upbeat`, `calm`, `cinematic`, `lofi`, `piano`, `none`}; `compositor` ∈ {`remotion`, `mpt`}.
- `duration` ≤ `limits.max_duration_sec` (180); resulting `scenes` ≤ `limits.max_scenes` (40).
- `out` is inside `$PWD` unless `--allow-external-out`.
- For `demo`: `--capture` (if given) exists, is absolute + `$PWD`-guarded, and is a valid video format.
diff --git a/.agents/skills/oma-video/resources/prompt-tips.md b/.agents/skills/oma-video/resources/prompt-tips.md
index 69b53b1..6bc6c33 100644
--- a/.agents/skills/oma-video/resources/prompt-tips.md
+++ b/.agents/skills/oma-video/resources/prompt-tips.md
@@ -53,7 +53,7 @@ Scene/backdrop → Subject → Details → Constraints
- Anchor the **arc**: hook → body → payoff. A short without a hook gets swiped past.
- Match **aspect to mode** (9:16 shorts, 16:9 explainer/demo) or use `auto`.
- Keep narration **per-scene and short** so caption pages and scene boundaries align.
-- Pick **music** that matches pacing (`upbeat` for shorts, `calm` for explainer) — note music mixing is currently deferred: the choice is recorded in `script.json` and warned, but not yet audible.
+- Pick **music** that matches pacing (`upbeat` for shorts, `calm` for explainer) — Strudel renders the bed offline and mixes it at −18 dB under narration. Needs a one-time `oma video doctor --install-strudel`; without it the run still succeeds, just silent.
## Don'ts
diff --git a/.agents/skills/oma-video/resources/script-schema.md b/.agents/skills/oma-video/resources/script-schema.md
index 86c515d..b927f03 100644
--- a/.agents/skills/oma-video/resources/script-schema.md
+++ b/.agents/skills/oma-video/resources/script-schema.md
@@ -15,7 +15,7 @@ required** — omitting it is the most common authoring failure.
| `locale` | string (min 1) | ✅ | narration/caption language tag, e.g. `en`, `ko` |
| `title` | string (min 1) | ✅ | also drives the output filename slug: `-.mp4` |
| `scenes` | array (min 1, ≤ 40) | ✅ | see per-scene fields below |
-| `music` | `upbeat` \| `calm` \| `none` | ✅ | recorded only — music mixing is deferred (`TODO(oma-deferred): music`) |
+| `music` | `upbeat` \| `calm` \| `cinematic` \| `lofi` \| `piano` \| `none` | ✅ | drives the Strudel BGM bed; mixed at −18 dB under narration |
| `brand` | object | — | free-form; defaults to `{}` |
### Per-scene fields (`scenes[]`)
@@ -77,5 +77,5 @@ required** — omitting it is the most common authoring failure.
- [ ] `aspect` is concrete (`9:16` / `16:9` / `1:1`) — never `auto`.
- [ ] Scene count ≤ 40, total `durationSec` ≤ 180.
- [ ] Every scene has a `visual.kind` from the enum; `still` scenes carry an English `visual.prompt`.
-- [ ] `music` is set (use `"none"` unless the user asked — mixing is deferred either way).
+- [ ] `music` is set (use `"none"` unless the user asked for a bed).
- [ ] Validate cheaply before rendering: pass the file via `--script` with `--dry-run` first.
diff --git a/.agents/skills/oma-video/resources/strudel/package.json b/.agents/skills/oma-video/resources/strudel/package.json
new file mode 100644
index 0000000..000fce4
--- /dev/null
+++ b/.agents/skills/oma-video/resources/strudel/package.json
@@ -0,0 +1,11 @@
+{
+ "name": "oma-video-strudel",
+ "version": "0.0.0",
+ "private": true,
+ "type": "module",
+ "description": "Vendored Strudel BGM renderer for oma-video. Deps are installed on demand by `oma video doctor --install-strudel`. @strudel/* is AGPL-3.0-or-later and is NEVER imported by the MIT-licensed oma CLI — render.mjs runs as a subprocess (same boundary as the Remotion / Playwright projects).",
+ "license": "MIT",
+ "dependencies": {
+ "@strudel/web": "^1.3.0"
+ }
+}
diff --git a/.agents/skills/oma-video/resources/strudel/page.html b/.agents/skills/oma-video/resources/strudel/page.html
new file mode 100644
index 0000000..afe58bf
--- /dev/null
+++ b/.agents/skills/oma-video/resources/strudel/page.html
@@ -0,0 +1,87 @@
+
+
+
+
+ oma-video strudel offline render
+
+
+
+
+
+
+
diff --git a/.agents/skills/oma-video/resources/strudel/render.mjs b/.agents/skills/oma-video/resources/strudel/render.mjs
new file mode 100644
index 0000000..e9a67c6
--- /dev/null
+++ b/.agents/skills/oma-video/resources/strudel/render.mjs
@@ -0,0 +1,295 @@
+#!/usr/bin/env node
+// Strudel BGM renderer — boundary-safe subprocess entrypoint.
+//
+// oma-video NEVER imports Strudel. @strudel/* is AGPL-3.0-or-later while the
+// oma CLI is MIT, so the TypeScript provider (`providers/music-strudel.ts` +
+// `internal/strudel-project.ts`) only ever LOCATES this project on disk and
+// spawns *this* script. Same boundary the Remotion / Playwright projects use.
+//
+// MECHANISM: `@strudel/webaudio.renderPatternAudio()` renders a pattern through
+// an OfflineAudioContext — faster than realtime, no audio device, no autoplay
+// gesture. A 30s bed renders in well under a second. The bundle only runs in a
+// browser, so we drive a headless Chrome over raw CDP (Node's global WebSocket;
+// zero npm deps here beyond @strudel/web itself) and serve the bundle from a
+// loopback-only HTTP server.
+//
+// DETERMINISM: oscillator sounds (sine/triangle/square/sawtooth) render
+// byte-identically across runs. Noise-based sounds (white/pink/brown) do NOT —
+// superdough fills those buffers from Math.random(). Patterns that must be
+// reproducible should stay oscillator-only.
+//
+// Contract — flags in, ONE JSON result line out (stdout's LAST line):
+// IN --pattern-file file holding the strudel pattern code (required)
+// --out output wav, inside a run dir (required)
+// --chrome Chrome/Chromium executable (required)
+// --seconds bed length in seconds (required)
+// --cps cycles per second (default 0.5)
+// --sample-rate default 44100
+// --timeout hard ceiling for the whole render (default 120000)
+// OUT {"ok":true,"output":"","seconds":,"bytes":,"sha256":""}
+// | {"ok":false,"error":"","code":""}
+//
+// Exit code is 0 on success, 1 on failure; the JSON result line is authoritative.
+import { spawn } from "node:child_process";
+import { createHash } from "node:crypto";
+import { existsSync, readFileSync } from "node:fs";
+import { mkdtemp, readFile, rm, writeFile } from "node:fs/promises";
+import { createServer } from "node:http";
+import { tmpdir } from "node:os";
+import path from "node:path";
+import process from "node:process";
+import { fileURLToPath } from "node:url";
+
+const HERE = path.dirname(fileURLToPath(import.meta.url));
+const DIST = path.join(HERE, "node_modules", "@strudel", "web", "dist");
+
+const MIME = {
+ ".js": "text/javascript",
+ ".mjs": "text/javascript",
+ ".html": "text/html",
+ ".json": "application/json",
+ ".wasm": "application/wasm",
+};
+
+let server;
+let chrome;
+let userDataDir;
+let hardTimer;
+
+function fail(message, code = "render_failed") {
+ process.stdout.write(`${JSON.stringify({ ok: false, error: message, code })}\n`);
+ cleanup().finally(() => process.exit(1));
+}
+
+async function cleanup() {
+ if (hardTimer) clearTimeout(hardTimer);
+ try {
+ chrome?.kill("SIGTERM");
+ } catch {}
+ try {
+ server?.close();
+ } catch {}
+ if (userDataDir) {
+ await rm(userDataDir, { recursive: true, force: true }).catch(() => {});
+ }
+}
+
+function parseArgs(argv) {
+ const out = {};
+ for (let i = 0; i < argv.length; i++) {
+ const token = argv[i];
+ if (!token.startsWith("--")) continue;
+ const eq = token.indexOf("=");
+ if (eq !== -1) {
+ out[token.slice(2, eq)] = token.slice(eq + 1);
+ } else {
+ out[token.slice(2)] = argv[i + 1]?.startsWith("--") ? "" : argv[++i];
+ }
+ }
+ return out;
+}
+
+const args = parseArgs(process.argv.slice(2));
+const patternFile = args["pattern-file"];
+const outPath = args.out;
+const chromePath = args.chrome;
+const seconds = Number(args.seconds);
+const cps = Number(args.cps ?? 0.5);
+const sampleRate = Number(args["sample-rate"] ?? 44100);
+const timeoutMs = Number(args.timeout ?? 120000);
+
+if (!patternFile || !outPath || !chromePath) {
+ fail("missing required flag: --pattern-file / --out / --chrome", "bad_args");
+} else if (!Number.isFinite(seconds) || seconds <= 0) {
+ fail(`--seconds must be a positive number (got ${args.seconds})`, "bad_args");
+} else if (!existsSync(DIST)) {
+ fail(
+ `@strudel/web is not installed in ${HERE} — run \`oma video doctor --install-strudel\``,
+ "not_installed",
+ );
+} else if (!existsSync(chromePath)) {
+ fail(`chrome executable not found: ${chromePath}`, "no_chrome");
+} else {
+ main().catch((err) => fail(err?.message ?? String(err)));
+}
+
+async function main() {
+ hardTimer = setTimeout(
+ () => fail(`render exceeded ${timeoutMs}ms`, "timeout"),
+ timeoutMs,
+ );
+ hardTimer.unref?.();
+
+ const code = await readFile(patternFile, "utf8");
+
+ // 1. Loopback-only static server for the page + the strudel bundle. Paths are
+ // confined to this dir and the resolved dist (no traversal).
+ server = createServer(async (req, res) => {
+ const pathname = new URL(req.url, "http://127.0.0.1").pathname;
+ let file;
+ if (pathname === "/" || pathname === "/page.html") {
+ file = path.join(HERE, "page.html");
+ } else if (pathname === "/strudel.js") {
+ file = path.join(DIST, "index.js");
+ } else {
+ const resolved = path.resolve(DIST, `.${pathname}`);
+ file = resolved.startsWith(DIST) ? resolved : null;
+ }
+ if (!file) {
+ res.writeHead(403).end("forbidden");
+ return;
+ }
+ try {
+ const body = await readFile(file);
+ res.writeHead(200, {
+ "content-type": MIME[path.extname(file)] ?? "application/octet-stream",
+ "cross-origin-opener-policy": "same-origin",
+ "cross-origin-embedder-policy": "require-corp",
+ "cross-origin-resource-policy": "cross-origin",
+ });
+ res.end(body);
+ } catch {
+ res.writeHead(404).end("not found");
+ }
+ });
+ await new Promise((resolve) => server.listen(0, "127.0.0.1", resolve));
+ const httpPort = server.address().port;
+
+ // 2. Headless Chrome. No audio device is needed — OfflineAudioContext renders
+ // into a buffer, so this works on a bare CI box.
+ userDataDir = await mkdtemp(path.join(tmpdir(), "oma-strudel-"));
+ chrome = spawn(
+ chromePath,
+ [
+ "--headless=new",
+ "--disable-gpu",
+ "--no-first-run",
+ "--no-default-browser-check",
+ "--disable-dev-shm-usage",
+ `--user-data-dir=${userDataDir}`,
+ "--remote-debugging-port=0",
+ "about:blank",
+ ],
+ { stdio: ["ignore", "ignore", "pipe"] },
+ );
+ chrome.on("error", (err) => fail(`chrome failed to start: ${err.message}`, "no_chrome"));
+
+ const devtoolsPort = await waitForDevtoolsPort(
+ path.join(userDataDir, "DevToolsActivePort"),
+ );
+
+ // 3. Drive it over raw CDP.
+ const targets = await (
+ await fetch(`http://127.0.0.1:${devtoolsPort}/json/list`)
+ ).json();
+ const page = targets.find((t) => t.type === "page");
+ if (!page) throw new Error("chrome exposed no page target");
+
+ const cdp = await connect(page.webSocketDebuggerUrl);
+ await cdp.send("Runtime.enable");
+ await cdp.send("Page.enable");
+ await cdp.send("Page.navigate", {
+ url: `http://127.0.0.1:${httpPort}/page.html`,
+ });
+ await cdp.waitForLoad();
+
+ const base64Length = await cdp.evaluate(
+ `window.__omaRender(${JSON.stringify(code)}, ${seconds}, ${cps}, ${sampleRate})`,
+ );
+ if (!base64Length || base64Length <= 0) {
+ throw new Error("strudel produced an empty buffer");
+ }
+
+ // 4. Pull the wav back in slices so no single CDP message gets huge.
+ const CHUNK = 4 * 1024 * 1024;
+ let b64 = "";
+ for (let offset = 0; offset < base64Length; offset += CHUNK) {
+ b64 += await cdp.evaluate(
+ `window.__omaWav.slice(${offset}, ${offset + CHUNK})`,
+ );
+ }
+ const wav = Buffer.from(b64, "base64");
+ await writeFile(outPath, wav);
+
+ process.stdout.write(
+ `${JSON.stringify({
+ ok: true,
+ output: outPath,
+ seconds,
+ bytes: wav.length,
+ sha256: createHash("sha256").update(wav).digest("hex"),
+ })}\n`,
+ );
+ await cleanup();
+ process.exit(0);
+}
+
+/** Chrome writes its chosen debugger port here once it is listening. */
+async function waitForDevtoolsPort(portFile) {
+ for (let i = 0; i < 200; i++) {
+ if (existsSync(portFile)) {
+ const [line] = readFileSync(portFile, "utf8").split("\n");
+ const port = Number(line);
+ if (Number.isFinite(port) && port > 0) return port;
+ }
+ await new Promise((resolve) => setTimeout(resolve, 50));
+ }
+ throw new Error("chrome never reported a devtools port");
+}
+
+/** Minimal CDP client over Node's global WebSocket. */
+async function connect(wsUrl) {
+ const ws = new WebSocket(wsUrl);
+ await new Promise((resolve, reject) => {
+ ws.onopen = resolve;
+ ws.onerror = () => reject(new Error("cdp websocket failed to open"));
+ });
+
+ let nextId = 0;
+ const pending = new Map();
+ let loaded = false;
+ const loadWaiters = [];
+
+ ws.onmessage = (event) => {
+ const msg = JSON.parse(event.data);
+ if (msg.id && pending.has(msg.id)) {
+ const { resolve, reject } = pending.get(msg.id);
+ pending.delete(msg.id);
+ if (msg.error) reject(new Error(JSON.stringify(msg.error)));
+ else resolve(msg.result);
+ return;
+ }
+ if (msg.method === "Page.loadEventFired") {
+ loaded = true;
+ while (loadWaiters.length) loadWaiters.shift()();
+ }
+ };
+
+ const send = (method, params = {}) =>
+ new Promise((resolve, reject) => {
+ const id = ++nextId;
+ pending.set(id, { resolve, reject });
+ ws.send(JSON.stringify({ id, method, params }));
+ });
+
+ return {
+ send,
+ waitForLoad: () =>
+ loaded ? Promise.resolve() : new Promise((resolve) => loadWaiters.push(resolve)),
+ async evaluate(expression) {
+ const res = await send("Runtime.evaluate", {
+ expression,
+ awaitPromise: true,
+ returnByValue: true,
+ });
+ if (res.exceptionDetails) {
+ const detail =
+ res.exceptionDetails.exception?.description ??
+ res.exceptionDetails.text ??
+ "unknown page error";
+ throw new Error(detail.split("\n")[0]);
+ }
+ return res.result.value;
+ },
+ };
+}
diff --git a/.agents/workflows/orchestrate.md b/.agents/workflows/orchestrate.md
index 313ca4a..29076e8 100644
--- a/.agents/workflows/orchestrate.md
+++ b/.agents/workflows/orchestrate.md
@@ -37,12 +37,26 @@ The detected runtime vendor and each agent's target vendor determine how agents
## Step 1: Load or Create Plan
+### 1a. Load
+
Look for a plan file:
1. Check `.agents/results/plan-{sessionId}.json` (current session's plan).
2. If not found: find the most recent `.agents/results/plan-*.json` file.
-3. If none exist: ask the user to run `/plan` first, or ask them to describe the tasks to execute.
-- **Do NOT proceed without a plan.**
+3. A plan is **usable** only when every task carries an agent assignment, a priority tier, its dependencies, and acceptance criteria. A plan missing any of these is not execution-ready — fall through to 1b rather than fanning out against it.
+
+### 1b. Create (no usable plan)
+
+A missing plan is not a stop condition. `/orchestrate` creates the plan itself instead of handing the request back to the user:
+
+1. Generate the session ID now (format: `session-YYYYMMDD-HHMMSS`). Step 2 reuses this id verbatim — do not generate a second one.
+2. Read and follow `.agents/workflows/plan.md` step by step, passing this session ID as its `{sessionId}` so the artifact lands at `.agents/results/plan-{sessionId}.json`.
+3. **Do NOT skip `plan.md` Step 6 (Review Plan with User).** It is this run's approval gate — the Step 3 fan-out is authorized by it. Delegation never removes a user gate.
+4. Once the plan is saved and approved, load it and continue to Step 2 with the same session ID.
+
+Stop and report only when the plan cannot be produced: the user declines to plan, or `plan.md` blocks because the request is too underspecified to decompose.
+
+- **Do NOT spawn agents without a usable plan.**
---
@@ -66,7 +80,7 @@ Look for a plan file:
└──────────┴───────────────────┘
```
-3. Generate session ID (format: `session-YYYYMMDD-HHMMSS`).
+3. Session ID: reuse the id generated in Step 1b when the plan was created in this run; otherwise generate one now (format: `session-YYYYMMDD-HHMMSS`).
4. **Domain gate**: for each planned task, classify it into `domain_tags` by matching against the `Intent signature` block of each installed `.agents/skills/oma-*/SKILL.md`, and derive `exposed_skill_set` (skills whose name is in `domain_tags`). If fewer than 2 skills match confidently, fall back to the full installed set and mark `exposure_fallback: true`. See `.agents/skills/oma-orchestrator/SKILL.md` (PHASE 1.5) for the full rules.
5. Use memory write tool to create `orchestrator-session.md` and `task-board.md` in the memory base path. Record `Exposed Skills` and `Exposure Fallback` per task in `task-board.md`.
6. Set session status to RUNNING.
diff --git a/.agents/workflows/video.md b/.agents/workflows/video.md
index b1754a9..fbc78e7 100644
--- a/.agents/workflows/video.md
+++ b/.agents/workflows/video.md
@@ -57,7 +57,7 @@ For `demo`, also resolve the **source**: a recorded file or Cap → `--source fi
| stock video | Pexels (`PEXELS_API_KEY`) | oma-image stills + Ken Burns | `TODO(oma-deferred): pexels` |
| AIGC video | Pixelle-MCP + RunningHub (`RUNNINGHUB_API_KEY`) | oma-image stills | `TODO(oma-deferred): pixelle` |
| caption timing | voicebox-stt (MCP `voicebox_transcribe` → REST) | estimate | `TODO(oma-deferred): whisper-cpp` |
- | music mixing | (not wired — recorded + warned only) | render without music | `TODO(oma-deferred): music` |
+ | music mixing | Strudel offline render (`oma video doctor --install-strudel`) | render without music | — |
| premium TTS | (not needed — oma-voice is local) | — | — |
- **Pixelle AIGC is a community MCP**: off by default, requires one-time explicit user consent plus a source review before connecting, and is always cost-gated on RunningHub credits.
@@ -113,7 +113,7 @@ The agent writes the script — this is the start of the determinism boundary. D
```bash
oma video generate "" --mode --aspect --locale \
--captions --visual \
- --voice --music --duration \
+ --voice --music --duration \
--compositor --seed \
--script --dry-run --format json
```
@@ -193,7 +193,7 @@ Review the finished video against the brief and the quality bars. Iterate by re-
- Narration audio is present (or intentionally silent) and aligns to scenes.
- Captions are synced to `timing.json`, within the safe area, and legible (static windowed cues, CSS-wrapped, Pretendard, design rule 2).
- Visuals match each scene's intent; no placeholder leakage unless the run intentionally used the fallback.
- - Aspect / dimensions are correct for the mode; branding applied as requested. (Music mixing is deferred — a requested music mode only produces a warning, never audio.)
+ - Aspect / dimensions are correct for the mode; branding applied as requested. (A requested music mode yields `music/bgm.wav` mixed at −18 dB, or a fallback warning and a silent render when Strudel is not installed.)
2. **Route each defect to its stage:**
- script/narration/scene-count → **Step 3** (re-author script).
- audio/timing → **Step 4** voice track (check oma-voice, re-synthesize).
diff --git a/.gitignore b/.gitignore
index 3b645f6..f4db8f3 100644
--- a/.gitignore
+++ b/.gitignore
@@ -98,3 +98,5 @@ docs/generated/
.agents/backup/
docs/plans/
+
+.qwen/tmp/
diff --git a/.opencode/plugins/oma/serena-primer.ts b/.opencode/plugins/oma/serena-primer.ts
index df2aabc..07eaca2 100644
--- a/.opencode/plugins/oma/serena-primer.ts
+++ b/.opencode/plugins/oma/serena-primer.ts
@@ -117,6 +117,7 @@ export function primerContext(): string {
"- Code discovery / reading: `get_symbols_overview`, `find_symbol`, `find_referencing_symbols`, `search_for_pattern`.",
"- Code edits: `replace_symbol_body`, `insert_after_symbol`, `insert_before_symbol`, `replace_content`.",
"- Native grep/glob: only for initial filename/path discovery. Do not fall back to grep + Read for code navigation just because Serena's tools aren't loaded yet — load them.",
+ '- Result size: omit `max_answer_chars` on Serena tools (uses the configured default, typically 150000). Never pass small caps like `3000` on broad searches. If a call returns "The answer is too long (N characters)", retry with `max_answer_chars` > N or narrow path/glob — do not keep the low cap.',
"- Exception — MCP timeout: if a Serena MCP call times out or hangs (seen mainly in OpenCode Desktop's long-lived sidecar), stop retrying MCP for this session: use native search/read for code, and access `.serena/memories/` files directly (or `serena memories read|write` when Serena CLI ≥ 1.5 is installed) for memory work. A full app relaunch restores Serena MCP.",
].join("\n");
}