diff --git a/.claude-plugin/marketplace.json b/.claude-plugin/marketplace.json
index 6489b0e..71f61fd 100644
--- a/.claude-plugin/marketplace.json
+++ b/.claude-plugin/marketplace.json
@@ -9,7 +9,7 @@
{
"name": "orc",
"source": "./orc",
- "description": "81 curated skills (incl. workspace-mode for cross-repo flows), 30 composite slash commands, 14 specialist subagents with per-agent model + reasoning-effort tuning, and 10 hook scripts that quietly enforce discipline (confirm-to-commit on protected branches, destructive-git gating, no AI-attribution trailers in commits/PRs, dependency pre-flight, iron rules injected at session start). Covers the whole SDLC — including post-PR: CI diagnosis, releases, dependency audits, live incident response. Drives the senior-IC loop end-to-end via /orc:flow — workspace-aware: from a parent dir with multiple sibling repos, one flow spans them all with N linked PRs."
+ "description": "84 curated skills (incl. workspace-mode for cross-repo flows), 31 composite slash commands, 14 specialist subagents with per-agent model + reasoning-effort tuning, and 10 hook scripts that quietly enforce discipline (confirm-to-commit on protected branches, destructive-git gating, no AI-attribution trailers in commits/PRs, dependency pre-flight, iron rules injected at session start). Covers the whole SDLC — including post-PR: CI diagnosis, releases, dependency audits, live incident response. Drives the senior-IC loop end-to-end via /orc:flow — workspace-aware: from a parent dir with multiple sibling repos, one flow spans them all with N linked PRs."
}
]
}
diff --git a/.github/workflows/validate.yml b/.github/workflows/validate.yml
index b1919db..d27e53b 100644
--- a/.github/workflows/validate.yml
+++ b/.github/workflows/validate.yml
@@ -78,6 +78,21 @@ jobs:
- name: QA report fixtures (orc-report json/html)
run: bash scripts/ci/verify-report.sh
+ - name: Target registry fixtures (orc-targets)
+ run: bash scripts/ci/verify-targets.sh
+
+ - name: Playwright scaffold fixtures (orc-playwright)
+ run: bash scripts/ci/verify-playwright-env.sh
+
+ - name: QA video fixtures (orc-qa-video)
+ run: bash scripts/ci/verify-qa-video.sh
+
+ - name: Explainer toolchain fixtures (orc-explain)
+ run: bash scripts/ci/verify-explain.sh
+
+ - name: PR size budget exclusions
+ run: bash scripts/ci/verify-pr-size.sh
+
- name: statusline render fixtures
run: bash scripts/ci/verify-statusline.sh
diff --git a/README.md b/README.md
index 1aad8bb..f9004ad 100644
--- a/README.md
+++ b/README.md
@@ -36,14 +36,14 @@
- 81 skills · 30 commands ·
+ 84 skills · 31 commands ·
14 agents · 9 hooks ·
0 telemetry · MIT
---
-`orc` is a full-SDLC workflow plugin for Claude Code: **81 curated skills, 30 composite slash commands, 14 specialist subagents, and 10 hook scripts** that quietly enforce discipline (no commits to `main`, no AI-attribution trailers, destructive git commands gated, a dependency pre-flight, core rules injected at every session start). One umbrella command — **`/orc:flow`** — drives the full feature lifecycle from "I want to do X" to "PR merged", with the `orc-implementer` agent writing the code slice-by-slice in between — and the lifecycle no longer stops at PR-open: `/orc:ci`, `/orc:release`, `/orc:deps`, and `/orc:incident` cover what happens after.
+`orc` is a full-SDLC workflow plugin for Claude Code: **84 curated skills, 31 composite slash commands, 14 specialist subagents, and 10 hook scripts** that quietly enforce discipline (no commits to `main`, no AI-attribution trailers, destructive git commands gated, a dependency pre-flight, core rules injected at every session start). One umbrella command — **`/orc:flow`** — drives the full feature lifecycle from "I want to do X" to "PR merged", with the `orc-implementer` agent writing the code slice-by-slice in between — and the lifecycle no longer stops at PR-open: `/orc:ci`, `/orc:release`, `/orc:deps`, and `/orc:incident` cover what happens after.
It exists for one reason: every time a senior developer sits down to work, they should already know how the next hour goes — write the plan, watch the test fail, fix the cause (not the symptom), verify with evidence, ship the PR. orc encodes that loop.
@@ -123,7 +123,7 @@ Claude Code loads every skill/command/agent **description** at session start (th
|------|--------------|
| [docs/install.md](./docs/install.md) | Step-by-step install with verification checkpoints, marketplace-only path, requirements table |
| [docs/commands.md](./docs/commands.md) | Full command table, the specialist agents, the skill library, the CLI reference |
-| [docs/examples](./docs/examples/README.md) | 18 scenario walk-throughs — start here for usage |
+| [docs/examples](./docs/examples/README.md) | 19 scenario walk-throughs — start here for usage |
| [docs/configuration.md](./docs/configuration.md) | Plugin settings, env vars, statusline tuning |
| [docs/safety.md](./docs/safety.md) | The safety case in depth + FAQ |
| [docs/architecture.md](./docs/architecture.md) | Why the layout is what it is; hooks, state, and the `.orc/` lifecycle |
diff --git a/cli/internal/deps/tools.json b/cli/internal/deps/tools.json
index 388672c..8749de0 100644
--- a/cli/internal/deps/tools.json
+++ b/cli/internal/deps/tools.json
@@ -55,7 +55,7 @@
{
"name": "agent-browser",
"tier": "recommended",
- "usedBy": "`/orc:qa` (web mode — required for browser-driven QA evidence)",
+ "usedBy": "`/orc:qa` fallback driver when Node/Playwright is unavailable; `/orc:evidence`",
"hints": {
"macos": "brew install agent-browser && agent-browser install",
"default": "npm install -g agent-browser && agent-browser install"
@@ -153,7 +153,7 @@
{
"name": "ffmpeg",
"tier": "recommended",
- "usedBy": "`/orc:qa`, `/orc:evidence` (motion evidence — agent-browser's `record` wraps ffmpeg; without it a headless QA run captures stills only)",
+ "usedBy": "`/orc:qa` (stitched QA video), `/orc:explain` (explainer render), agent-browser `record` — title cards and captions need an ffmpeg build with libfreetype (`brew install ffmpeg` has it; minimal builds don't)",
"hints": {
"macos": "brew install ffmpeg",
"debian": "sudo apt-get install -y ffmpeg",
diff --git a/docs/architecture.md b/docs/architecture.md
index 6fe6e18..9910b94 100644
--- a/docs/architecture.md
+++ b/docs/architecture.md
@@ -9,8 +9,8 @@
```
orc/ # the plugin
├── .claude-plugin/plugin.json # manifest — what Claude Code reads to discover the plugin
-├── skills/ # 81 skills, namespaced /orc:
-├── commands/ # 30 composite slash commands /orc: (incl. /orc:flow umbrella)
+├── skills/ # 84 skills, namespaced /orc:
+├── commands/ # 31 composite slash commands /orc: (incl. /orc:flow umbrella)
├── agents/ # 14 specialist subagents (orc-)
├── hooks/ # SessionStart + PreToolUse(Bash) + PostToolUse + PreCompact + WorktreeCreate/Remove
│ └── mod/ # the orc mod (register.ts + tests) — in-process hooks, Claude Code 2.1.287+
@@ -162,7 +162,7 @@ Plus the doc-authoring family for senior/architect practice: `adr-writing` (Arch
orc borrows compozy's session-state idea, hook layout, and YAML-frontmatter conventions. It diverges in two places:
1. Workspace state is **hidden + gitignored** (`.orc/`) instead of committed (`compozy/`). orc is a personal plugin; nothing needs sharing.
-2. Command surface covers the full SDLC (30 commands, all composing the same plan → debug → verify → ship spine, now extended past PR-open with ci/release/deps/incident), with explicit web-QA evidence as a first-class concern — including the environment it runs against: `/orc:qa` and `/orc:flow` provision a Docker dev environment via `orc-env-provisioner` before browser QA (`orc:env-provisioning` skill; `/orc:env` standalone).
+2. Command surface covers the full SDLC (31 commands, all composing the same plan → debug → verify → ship spine, now extended past PR-open with ci/release/deps/incident), with explicit web-QA evidence as a first-class concern — including the environment it runs against: `/orc:qa` and `/orc:flow` provision a Docker dev environment via `orc-env-provisioner` before browser QA (`orc:env-provisioning` skill; `/orc:env` standalone).
## See also
diff --git a/docs/commands.md b/docs/commands.md
index e865099..1180a92 100644
--- a/docs/commands.md
+++ b/docs/commands.md
@@ -1,8 +1,8 @@
# What orc can do
-The full catalog: every slash command, the specialist agents behind them, the skill library underneath, and the CLI that installs it all. Not sure where to start? The [examples index](examples/README.md) maps 18 real scenarios to the command that handles each.
+The full catalog: every slash command, the specialist agents behind them, the skill library underneath, and the CLI that installs it all. Not sure where to start? The [examples index](examples/README.md) maps 19 real scenarios to the command that handles each.
-## Commands (30)
+## Commands (31)
| Command | Purpose |
|---------|---------|
@@ -10,7 +10,7 @@ The full catalog: every slash command, the specialist agents behind them, the sk
| `/orc:plan` | Plan a feature/refactor; writes a TDD-shaped plan to `.orc//files/` |
| `/orc:start` | Worktree + plan + first failing test (TDD red light) |
| `/orc:debug` | Root-cause investigation, then fix with TDD; never papers over |
-| `/orc:qa` | Pre-PR quality gate; for web changes, full browser QA with screenshots/snapshot/HAR/steps |
+| `/orc:qa` | Pre-PR quality gate; web changes run Playwright-first browser QA against a named target (`--target`) with a stitched step-tagged video, traces, and committed e2e specs/tests |
| `/orc:env` | Provision a containerized dev environment; `up`/`status`/`down`; reused across QA runs |
| `/orc:code-review` | Review someone else's open PR; terse, signal-only output |
| `/orc:address` | Answer reviewer comments on YOUR PR; parallel code-fixer + reply-drafter |
@@ -25,6 +25,7 @@ The full catalog: every slash command, the specialist agents behind them, the sk
| `/orc:jira` | Manage Jira tickets via `acli`; bind/unbind a ticket to the current session |
| `/orc:jira-breakdown` | Build a Jira Epic (micro-PRD) → Stories → concurrency-sliced Tasks from a brief or PRD |
| `/orc:evidence` | Collect browser evidence scoped to a ticket, then upload or keep local |
+| `/orc:explain` | Narrated walkthrough video of a QA packet — Playwright demo clips + animate graphics + local kokoro voice. Graphic segments are authored: orc writes the piece metadata and brief, the session writes `src/scenes.js` per the notes in `orc/lib/explain/animate.lock` (pinned animate commit), then renders with `ORC_ANIMATE_RENDER=1`. |
| `/orc:postmortem` | Author a blameless incident postmortem; files P0 items as tracker issues |
| `/orc:ci` | Watch + diagnose CI after PR-open; classified failures routed to `orc-code-fixer` |
| `/orc:incident` | Live incident triage — Sentry intake, mitigate-vs-root-cause gate, UTC timeline |
@@ -39,9 +40,9 @@ The full catalog: every slash command, the specialist agents behind them, the sk
`orc-implementer` (writes code slice-by-slice), `orc-debug-investigator`, `orc-test-author`, `orc-code-fixer`, `orc-pr-reviewer`, `orc-security-reviewer`, `orc-ci-investigator`, `orc-qa-validator`, `orc-env-provisioner`, `orc-prd-analyzer`, `orc-refactor-architect`, `orc-jira-architect`, `orc-reply-drafter`, `orc-stack-analyzer` — each a read-only investigator or a scoped executor, dispatched by the commands above. Every agent pins both a `model` (haiku for mechanical work, sonnet for senior-dev execution, opus for deep investigation) and a reasoning `effort` matched to its task shape.
-## Skills (81)
+## Skills (84)
-Under the commands and agents sit 81 curated skills — reusable, progressively-disclosed playbooks the model pulls in on demand: process doctrine (`tdd`, `systematic-debugging`, `verification-before-completion`, `grilling`, `codebase-design`, `domain-modeling`), stack packs (Next.js, NestJS, PostgreSQL, SwiftUI, Tailwind, Turborepo, …), authoring guides (PRD/TRD/ADR/RFC/postmortem), and the `orc:map` router when you're not sure which to reach for. Fifteen are vendored or merged from [mattpocock/skills](https://github.com/mattpocock/skills) and other MIT sources with full provenance ([THIRD-PARTY-LICENSES.md](../THIRD-PARTY-LICENSES.md)); their descriptions are deliberately differentiated so co-installing the originals doesn't double-trigger. Each skill is a thin index that loads its detail only when invoked, so they cost almost nothing until used. **Total: 81 skills.**
+Under the commands and agents sit 84 curated skills — reusable, progressively-disclosed playbooks the model pulls in on demand: process doctrine (`tdd`, `systematic-debugging`, `verification-before-completion`, `grilling`, `codebase-design`, `domain-modeling`), stack packs (Next.js, NestJS, PostgreSQL, SwiftUI, Tailwind, Turborepo, …), authoring guides (PRD/TRD/ADR/RFC/postmortem), and the `orc:map` router when you're not sure which to reach for. Fifteen are vendored or merged from [mattpocock/skills](https://github.com/mattpocock/skills) and other MIT sources with full provenance ([THIRD-PARTY-LICENSES.md](../THIRD-PARTY-LICENSES.md)); their descriptions are deliberately differentiated so co-installing the originals doesn't double-trigger. Each skill is a thin index that loads its detail only when invoked, so they cost almost nothing until used. **Total: 84 skills.**
## The orc CLI
diff --git a/docs/configuration.md b/docs/configuration.md
index 0d3f7e6..2100537 100644
--- a/docs/configuration.md
+++ b/docs/configuration.md
@@ -16,6 +16,8 @@ Prompted at plugin enable time (re-run via `/plugin`); exported to hooks and lib
| `statusline_context_reserve` | `0` | 0–50. When >0, the context percentage is normalized against this reserved slice (e.g. your auto-compact reserve) and rendered with a `~` prefix. `0` shows the raw payload percentage. |
| `mod_enabled` | `true` | Load the orc mod (`hooks/mod/`, Claude Code 2.1.287+): in-process event hooks and UI layered on the bash hooks. `false` keeps orc on its bash hooks only — the guardrails are identical either way. |
| `model_profile` | `balanced` | Which model orc agents run on when a dispatch names none (needs the orc mod): `balanced` (each agent's frontmatter), `quality` (investigator on `best` — Fable where available, else Opus — reviewers on Opus), `economy` (Opus-tier agents on Sonnet). An explicit per-dispatch model always wins; the `/orc` cockpit's Model profile select settles a per-branch override (`modelProfile` decision) that wins over this value. |
+| `explainer_style` | `isometric` | animate motion-graphics style for `/orc:explain` segments: cut-paper, crosshatch, riso, sketchbook, math, pixel, isometric. |
+| `explainer_voice` | `af_heart` | kokoro-js voice id for `/orc:explain` narration (local TTS, no key). `af_heart` = English; `pf_dora` / `pm_alex` = Brazilian Portuguese. |
| `alerts_sound` | `false` | A short chime when the orc mod's CI watcher sees a linked PR turn red or green. |
## Environment variables
@@ -25,6 +27,12 @@ Prompted at plugin enable time (re-run via `/plugin`); exported to hooks and lib
| `ORC_SKIP_TOOL_CHECK=1` | Suppress the SessionStart tool-check callout when a recommended dependency is intentionally missing. |
| `ORC_ALLOW_AI_ATTRIBUTION=1` | Allow AI-attribution trailers in commits/PR bodies. The PreToolUse hook refuses them by default (iron rule #5). Set only with explicit user consent. |
| `ORC_ALLOW_DESTRUCTIVE_GIT=1` | Skip the confirm prompt on `git reset --hard`, `git clean -f`, `git branch -D`, `git push --force`. |
+| `ORC_TARGETS_OP_BIN` | 1Password CLI binary used by `orc-targets resolve` for `op://` credential references. Defaults to `op`. |
+| `ORC_QA_USER` | Which named target user the Playwright `setup` project logs in as. |
+| `ORC_PW_OUTPUT_DIR` | Playwright output directory; the `playwright-qa` skill sets it to `/pw`. |
+| `ORC_EXPLAIN_VOICE` | kokoro-js voice id for `orc-explain narrate`; overrides the `explainer_voice` setting. |
+| `ORC_ANIMATE_ROOT` | Path to the installed animate plugin used by `orc-explain graphics`. |
+| `ORC_ANIMATE_RENDER` | `1` builds/exports authored animate pieces; unset renders fallback title cards. |
| `ORC_JIRA_PR_KEYWORD` | PR-body trailer keyword used by `/orc:ship` when the session has a bound Jira ticket. Defaults to `Resolves`. |
The `orc config` CLI edits these tunables (`pr_size_budget`, `protected_branches`, `skip_tool_check`, `allow_ai_attribution`, `jira_pr_keyword`) as `ORC_*` variables in `settings.json`.
diff --git a/docs/examples/10-web-qa-before-shipping.md b/docs/examples/10-web-qa-before-shipping.md
index b90e0b7..b6b19e4 100644
--- a/docs/examples/10-web-qa-before-shipping.md
+++ b/docs/examples/10-web-qa-before-shipping.md
@@ -234,7 +234,7 @@ Then: `/orc:ship` — and the PR body links to `qa/steps.md` so reviewers can ve
- **Multiple devices/breakpoints** — repeat the golden path with `agent-browser set viewport ` for each. Save screenshots per breakpoint (`screenshot-01a-mobile-loaded.png`, `screenshot-01b-desktop-loaded.png`).
- **Watch the QA live** — pick the Claude-in-Chrome driver at the gate (or `--driver chrome`). The walk runs inline in your real browser (real sessions, cookies, extensions), narrated step by step; the evidence packet swaps per-step screenshots for a `gif_creator` recording and the HAR for a distilled `network-summary.md`. Acceptance scoring is identical — each criterion anchors to a numbered step in `steps.md` instead of its own PNG.
- **Behind a feature flag** — boot the app with the flag enabled, document the flag state at the top of `steps.md`.
-- **App not yet boot-able locally** — ask the user for a deployed preview URL (Vercel preview, staging, etc.). Don't fake QA against the prod app.
+- **App not yet boot-able locally** — use `--target ` (`orc:qa-targets`) to point QA at a registered staging or preview target. Never fake QA against prod.
## Iron rules in play
diff --git a/docs/examples/18-playwright-qa-and-explainer.md b/docs/examples/18-playwright-qa-and-explainer.md
new file mode 100644
index 0000000..b31d66b
--- /dev/null
+++ b/docs/examples/18-playwright-qa-and-explainer.md
@@ -0,0 +1,128 @@
+# 18 — Playwright QA and the explainer
+
+## Scenario
+
+`feat/csv-export` (PROJ-123) adds a "Download CSV" button to the reports page. You want browser QA against staging with a login, a single step-tagged video for the PR, and a narrated walkthrough for the reviewer.
+
+## Flow
+
+```mermaid
+flowchart TD
+ qa["/orc:qa --target staging"]
+ target[Target gate
orc:qa-targets]
+ setup[Setup gate
first run: create e2e/]
+ plan[Plan gate
planner agent writes the spec]
+ run["npx playwright test --project=qa"]
+ stitch[orc-qa-video stitch]
+ explain["/orc:explain"]
+ pr[PR comment
hard-outward gate]
+
+ qa --> target --> setup --> plan --> run --> stitch --> explain --> pr
+```
+
+## Walk-through
+
+**1. Target gate.** Remote targets are probed, never provisioned. `staging` is guarded, so mutating scenarios are off.
+
+```
+> ⛔ Gate — QA target
+ local — boots the Docker env
+ staging — https://staging.example.com (read-only) <- selected
+ New remote URL
+```
+
+**2. Setup gate (first run only).**
+
+```
+> ⛔ Gate — Playwright project
+ No Playwright project here. Create e2e/ (config with setup/qa/demo projects) and install
+ @playwright/test + Chromium (~150 MB)? [Create e2e/ and install (Recommended)]
+```
+
+Login runs once in the `setup` project as the target's named user (`ORC_QA_USER`), with video and trace off. Credentials stay as references (`op://…`, `env:VAR`) and never land in a packet file.
+
+**3. Plan gate.** Preview is `e2e/specs/csv-export.md`:
+
+```
+1. AC 3.1 — Download CSV is visible on /reports @golden
+2. AC 3.2 — CSV has one row per visible report row @golden
+3. AC 3.3 — Export of an empty report is disabled
+```
+
+**4. Run.**
+
+```
+$ npx playwright test --project=qa
+ 3 passed (41s)
+```
+
+**5. Stitch.**
+
+```
+$ orc-qa-video stitch --results .orc/feat-csv-export/files/qa/pw/results.json --qa-dir .orc/feat-csv-export/files/qa --branch feat-csv-export
+.orc/feat-csv-export/files/qa/qa-feat-csv-export.webm
+.orc/feat-csv-export/files/qa/chapters.json
+```
+
+Title cards need an ffmpeg with libfreetype. Without it the cards are blank and the titles stay in `chapters.json`.
+
+**6. Manifest excerpt.**
+
+```json
+"driver": "playwright",
+"target": "staging",
+"video": {
+ "file": "qa-feat-csv-export.webm",
+ "chapters": [
+ { "id": "t1", "title": "AC 3.1 — Download CSV is visible", "outcome": "passed", "start": 3.0, "end": 9.4 },
+ { "id": "t2", "title": "AC 3.2 — CSV has one row per row", "outcome": "passed", "start": 12.4, "end": 21.8 }
+ ]
+}
+```
+
+**7. `/orc:explain`.** The Explainer gate previews the script before anything renders:
+
+```
+> ⛔ Gate — explainer
+ 5 segments, ~48s, style isometric, voice af_heart.
+ s1 · title · "CSV export" · "Reports can now be exported as CSV."
+ s2 · clip · AC 3.1 · "The button sits above the table."
+ s3 · graphic · "Row mapping" · "Each visible row becomes one line."
+ [Render (Recommended)] [Edit script] [Cancel]
+```
+
+Clips come from a separate golden-path `demo` pass with overlays off. Graphic scenes are authored by the session per `orc/lib/explain/animate.lock`, then built with `ORC_ANIMATE_RENDER=1`. Narration is local kokoro-js.
+
+**8. PR comment preview.** One hard-outward gate:
+
+```
+> ⛔ Gate — post PR comment
+ | Criterion | Verdict |
+ |-----------|---------|
+ | AC 3.1 | passed |
+ | AC 3.2 | passed |
+ | AC 3.3 | passed |
+ Links: stitched video, explainer (kept local), traces. [Post] [Edit] [Skip]
+```
+
+## Artifacts
+
+`qa/qa-feat-csv-export.webm`, `qa/chapters.json`, `qa/trace-.zip`, `qa/pw/results.json`, `explain/segments.json`, `explainer-feat-csv-export.mp4`, plus committed `e2e/specs/` and `e2e/tests/`.
+
+## Done when
+
+All acceptance criteria are scored from the run, the stitched video and chapters are in the manifest, and the PR comment is posted or skipped on purpose.
+
+## Variants
+
+- **Local instead of staging** — pick `local`; orc boots the Docker env as before.
+- **No Node** — orc announces and falls back to `agent-browser` (see example 10).
+- **No animate plugin** — graphic segments render as fallback cards.
+
+## Iron rules in play
+
+- **No QA claim without artifacts.** The video, traces and results are the evidence.
+- **Guarded targets never run mutating scenarios.**
+- **Secrets never in packet files.**
+- **Outward posts are gated.** The PR comment is shown verbatim first.
+- **No AI attribution** in the posted comment.
diff --git a/docs/examples/README.md b/docs/examples/README.md
index a8fc4d9..a8bbe07 100644
--- a/docs/examples/README.md
+++ b/docs/examples/README.md
@@ -1,6 +1,6 @@
# Examples — pick your scenario
-Eighteen scenario walk-throughs, each mapping a real day-to-day situation to the orc command that handles it.
+Nineteen scenario walk-throughs, each mapping a real day-to-day situation to the orc command that handles it.
| You have ... | Read |
|--------------|------|
@@ -22,5 +22,6 @@ Eighteen scenario walk-throughs, each mapping a real day-to-day situation to the
| Dependency hygiene day (vulns, outdated packages) | [15 — Dependency audit and upgrade](./15-dependency-audit.md) |
| A release to cut | [16 — Cutting a release](./16-cutting-a-release.md) |
| A repo joining orc, then daily issue triage | [17 — Tracker setup and triage](./17-tracker-setup-and-triage.md) |
+| A web change to QA against staging, with a narrated walkthrough | [18 — Playwright QA and the explainer](./18-playwright-qa-and-explainer.md) |
Each example follows the same shape — *Scenario → Flow → Walk-through → Artifacts → Done when → Variants → Iron rules in play* — so you can scan to the relevant section.
diff --git a/docs/install.md b/docs/install.md
index 95d8e52..dc36922 100644
--- a/docs/install.md
+++ b/docs/install.md
@@ -125,13 +125,23 @@ orc's SessionStart pre-flight (`session-start-tool-check.sh`) verifies these CLI
| `git` | required | every command |
| `jq` | required | hook scripts (parse Bash tool input) |
| `gh` | recommended | `/orc:code-review`, `/orc:address`, `/orc:ship`, `/orc:postmortem` |
-| `agent-browser` | recommended | `/orc:qa` (web mode — browser-driven QA evidence) |
+| `agent-browser` | recommended | `/orc:qa` fallback driver when Node/Playwright is unavailable; `/orc:evidence` |
| `acli` | recommended | `/orc:jira`, Jira ticket linking, PRD/TRD `--from-jira` seeding |
| `docker` | recommended | `/orc:env`, `/orc:qa` / `/orc:flow` env provisioning (host-mode fallback applies without it) |
| `graphify` | recommended | `/orc:plan`, `/orc:start`, `/orc:flow`, `/orc:debug` — code discovery via a code graph instead of grep |
| `osv-scanner` | recommended | `/orc:deps` — ecosystem-agnostic vulnerability audit (per-ecosystem scanners as fallback) |
| `gitleaks` | recommended | `/orc:code-review --audit` — secret scanning (regex fallback without it) |
| `sentry-cli` | recommended | `/orc:incident` — pull Sentry issues/events into live triage |
-| `ffmpeg` | recommended | `/orc:qa`, `/orc:evidence` — motion evidence; agent-browser's `record` wraps ffmpeg, so headless QA captures stills only without it |
+| `ffmpeg` | recommended | `/orc:qa` (stitched QA video), `/orc:explain` (explainer render), agent-browser `record`; title cards and captions need a build with libfreetype (`brew install ffmpeg` has it; minimal builds don't) |
+
+### Playwright QA
+
+`/orc:qa` runs Playwright first and `/orc:explain` builds on it. What that needs:
+
+- **Node >= 18** for the Playwright driver. Without it, `/orc:qa` announces and falls back to `agent-browser`.
+- **`e2e/` deps are per-repo.** The first-run setup gate installs them and downloads Chromium into the repo's Playwright cache.
+- **ffmpeg with libfreetype** for title cards and captions. `brew install ffmpeg` has it; minimal builds don't, and `orc-qa-video` / `orc-explain` then degrade to cards without text (chapter titles stay in `chapters.json`).
+- **animate plugin (optional)** for explainer graphics: `/plugin install animate@animate`. The pinned commit lives in `orc/lib/explain/animate.lock`; graphic scenes are authored per its notes and rendered with `ORC_ANIMATE_RENDER=1`. Without it, graphic segments fall back to cards.
+- **kokoro model (~90 MB)** downloads on first narration, then runs locally.
Suppress the check where missing tools are intentional: `export ORC_SKIP_TOOL_CHECK=1`.
diff --git a/orc/.claude-plugin/plugin.json b/orc/.claude-plugin/plugin.json
index b883697..4e89946 100644
--- a/orc/.claude-plugin/plugin.json
+++ b/orc/.claude-plugin/plugin.json
@@ -86,6 +86,18 @@
"title": "Statusline context reserve (%)",
"description": "0-50. When >0, the context percentage is normalized against this reserved slice (e.g. your auto-compact reserve) and rendered with a ~ prefix. 0 shows the raw payload percentage.",
"default": 0
+ },
+ "explainer_style": {
+ "type": "string",
+ "title": "Explainer style",
+ "description": "animate motion-graphics style for /orc:explain segments: cut-paper, crosshatch, riso, sketchbook, math, pixel, isometric.",
+ "default": "isometric"
+ },
+ "explainer_voice": {
+ "type": "string",
+ "title": "Explainer voice",
+ "description": "kokoro-js voice id for /orc:explain narration (local TTS, no key). af_heart = English; pf_dora / pm_alex = Brazilian Portuguese.",
+ "default": "af_heart"
}
}
}
diff --git a/orc/agents/orc-qa-validator.md b/orc/agents/orc-qa-validator.md
index 1444af2..75202e6 100644
--- a/orc/agents/orc-qa-validator.md
+++ b/orc/agents/orc-qa-validator.md
@@ -12,7 +12,7 @@ skills:
You drive a real browser via the `agent-browser` CLI to QA a web app. You are not a unit-test runner. You are not a code reviewer. You open the app, click the buttons, watch what happens, and write down what you saw with evidence anyone can verify.
-(You are the **agent-browser driver** of `/orc:qa` — when the user picks the Claude-in-Chrome driver at the Phase 4.1 gate, that QA runs inline in the main session and you are not dispatched.)
+(You are the **agent-browser driver** of `/orc:qa` — when the user picks the Claude-in-Chrome driver at the `orc:browser-qa` driver gate, that QA runs inline in the main session and you are not dispatched.)
## Pre-flight
diff --git a/orc/bin/orc-explain b/orc/bin/orc-explain
new file mode 100755
index 0000000..80df954
--- /dev/null
+++ b/orc/bin/orc-explain
@@ -0,0 +1,7 @@
+#!/usr/bin/env bash
+# orc-explain — /orc:explain toolchain CLI (plugin bin/, on PATH while orc is
+# enabled). Thin wrapper: all logic lives in lib/explain/explain.sh, whose CLI
+# dispatch activates when the lib is executed rather than sourced.
+set -euo pipefail
+dir="$(cd "$(dirname "${BASH_SOURCE[0]:-$0}")" && pwd)"
+exec bash "${dir}/../lib/explain/explain.sh" "$@"
diff --git a/orc/bin/orc-playwright b/orc/bin/orc-playwright
new file mode 100755
index 0000000..acd80ff
--- /dev/null
+++ b/orc/bin/orc-playwright
@@ -0,0 +1,7 @@
+#!/usr/bin/env bash
+# orc-playwright — Playwright project scaffold CLI (plugin bin/, on PATH while
+# orc is enabled). Thin wrapper: all logic lives in lib/playwright-env.sh, whose
+# CLI dispatch activates when the lib is executed rather than sourced.
+set -euo pipefail
+dir="$(cd "$(dirname "${BASH_SOURCE[0]:-$0}")" && pwd)"
+exec bash "${dir}/../lib/playwright-env.sh" "$@"
diff --git a/orc/bin/orc-qa-video b/orc/bin/orc-qa-video
new file mode 100755
index 0000000..e81f8f3
--- /dev/null
+++ b/orc/bin/orc-qa-video
@@ -0,0 +1,7 @@
+#!/usr/bin/env bash
+# orc-qa-video — QA video stitch CLI (plugin bin/, on PATH while orc is
+# enabled). Thin wrapper: all logic lives in lib/qa-video.sh, whose CLI
+# dispatch activates when the lib is executed rather than sourced.
+set -euo pipefail
+dir="$(cd "$(dirname "${BASH_SOURCE[0]:-$0}")" && pwd)"
+exec bash "${dir}/../lib/qa-video.sh" "$@"
diff --git a/orc/bin/orc-targets b/orc/bin/orc-targets
new file mode 100755
index 0000000..fa8207c
--- /dev/null
+++ b/orc/bin/orc-targets
@@ -0,0 +1,7 @@
+#!/usr/bin/env bash
+# orc-targets — named QA-target registry CLI (plugin bin/, on PATH while orc
+# is enabled). Thin wrapper: all logic lives in lib/targets.sh, whose CLI
+# dispatch activates when the lib is executed rather than sourced.
+set -euo pipefail
+dir="$(cd "$(dirname "${BASH_SOURCE[0]:-$0}")" && pwd)"
+exec bash "${dir}/../lib/targets.sh" "$@"
diff --git a/orc/commands/evidence.md b/orc/commands/evidence.md
index 38cfbd5..c95adc9 100644
--- a/orc/commands/evidence.md
+++ b/orc/commands/evidence.md
@@ -1,6 +1,6 @@
---
description: Collect browser evidence scoped to a ticket (or shared context) and deliver it — evidence packet, then upload to the ticket or keep local (always asks). For proving a ticket's behavior without a code diff/PR. Workspace-aware.
-argument-hint: "[] [--web ] [--driver chrome|agent-browser] [--context \"\"] [--no-env] [--repos a,b | --repo a | --this-repo]"
+argument-hint: "[] [--target ] [--web ] [--driver chrome|agent-browser] [--context \"\"] [--no-env] [--repos a,b | --repo a | --this-repo]"
arguments: [ticket]
allowed-tools:
- Bash(orc-state:*)
@@ -21,20 +21,28 @@ allowed-tools:
- Bash(git branch --show-current:*)
- Bash(agent-browser:*)
- Bash(npx agent-browser:*)
+ - Bash(orc-targets:*)
+ - Bash(npx playwright:*)
+ - Bash(orc-playwright:*)
+ - Bash(orc-qa-video:*)
+ - Bash(gh pr comment:*)
+ - Bash(gh pr view:*)
+ - Bash(gh auth status:*)
---
# /orc:evidence
Collect browser evidence that a ticket's behavior works — navigate, screenshot, record — then deliver it to the ticket or keep it local. Unlike `/orc:qa` (a pre-PR gate scoped to your **diff** that also runs tests/lint), this is scoped to a **ticket** (or a flow you describe) and does only the evidence loop. Use it to attach proof to a ticket, demo a flow, or QA a ticket you didn't necessarily write the code for.
-Collection **reuses `/orc:qa`'s browser drivers verbatim**; delivery reuses the `orc:evidence-publish` skill. This command is only the ticket-first orchestrator — it invents no new browser or upload logic.
+Collection **reuses `orc:browser-qa` (Driver P/A/B)**; delivery reuses the `orc:evidence-publish` skill. This command is only the ticket-first orchestrator — it invents no new browser or upload logic.
## Arguments
- `` — the ticket to scope from and (on upload) deliver to. Omit to use the session's bound `jiraTicket`, or to run context-only.
- `--context ""` — describe the flow to exercise, instead of (or on top of) pulling it from the ticket.
+- `--target ` — a named QA target from `.orc/targets.json` (`orc:qa-targets`): `local` provisions the Docker env; a remote target is probed and used as-is. Omitted ⇒ the Target gate asks.
- `--web ` — app already running at this URL; skip env provisioning.
-- `--driver chrome|agent-browser` — browser driver; default is the same Phase 4.1 gate as `/orc:qa`. `chrome` = Claude-in-Chrome, watch live in your browser; `agent-browser` = headless CLI validator.
+- `--driver playwright|agent-browser|chrome` — browser driver; default is the `orc:browser-qa` driver gate. `playwright` = Playwright Test with a stitched step-tagged video; `chrome` = Claude-in-Chrome, watch live in your browser; `agent-browser` = headless CLI validator.
- `--no-env` — skip Docker provisioning; use `--web` or a legacy boot.
- Workspace flags (`--repos`/`--repo`/`--this-repo`) — as in `/orc:qa`.
@@ -59,13 +67,14 @@ Distill the summary + acceptance criteria into a short list of expected behavior
### Phase 2 — Environment
-Same as `/orc:qa` Phase 4.0 (do **not** duplicate the logic): `orc-docker-env is-ready $(orc-docker-env state-path "$ORC_STATE_DIR" )` → `ready` attaches (echo the reuse line); otherwise dispatch **`orc-env-provisioner`** via `Task`. Skip on `--web` / `--no-env`. Record `appUrl`. The environment stays up after the run (teardown is `/orc:cleanup`).
+Same as `orc:browser-qa` Step 0–1 (do **not** duplicate the logic): `orc-docker-env is-ready $(orc-docker-env state-path "$ORC_STATE_DIR" )` → `ready` attaches (echo the reuse line); otherwise dispatch **`orc-env-provisioner`** via `Task`. Skip on `--web` / `--no-env`. Record `appUrl`. The environment stays up after the run (teardown is `/orc:cleanup`).
### Phase 3 — Collect
Pick the packet directory: an active branch session → `${ORC_STATE_DIR}//files/qa/`; no session → `.orc/evidence//` (context-only → `.orc/evidence/adhoc-/`). Then run the driver:
-- Choose it from `--driver`, else the **Phase 4.1 driver gate from `/orc:qa`** (`⛔ Gate — browser driver`).
+- Choose it from `--driver`, else the `orc:browser-qa` driver gate (`⛔ Gate — browser driver`).
+- **Driver `playwright`** — invoke `orc:playwright-qa` with the ticket criteria as the acceptance list (ids `ticket-`).
- **Driver `chrome`** — follow `/orc:qa` **Driver B** verbatim: load the Claude-in-Chrome tools via one `ToolSearch`, call `tabs_context_mcp` first (if the extension isn't connected, say so and fall back to `agent-browser` — never silently), open a **new** tab, start a `gif_creator` recording named `qa-.gif`, narrate each step under a numbered `### Step ` heading, avoid `alert`/`confirm` elements. Score the Phase 1 expected-behavior list into `qa-manifest.json` exactly as `/orc:qa` requires — the ticket's acceptance criteria are the rubric here, standing in for `slices.json`.
- **Driver `agent-browser`** — dispatch **`orc-qa-validator`** (Driver A) with the Phase 1 scope as the feature description + `appUrl` + the packet dir.
diff --git a/orc/commands/explain.md b/orc/commands/explain.md
new file mode 100644
index 0000000..d9503cd
--- /dev/null
+++ b/orc/commands/explain.md
@@ -0,0 +1,58 @@
+---
+description: Build a narrated "what was made" walkthrough from a QA packet — golden-path demo clips (Playwright, overlays off) + animate motion graphics + local kokoro-js voice, assembled by orc-explain; one preview gate before render; delivered via the evidence-publish gates. Use after /orc:qa passes or on any .orc evidence packet.
+argument-hint: "[--qa-dir ] [--target ] [--style ] [--voice ] [--auto[=guided|full]]"
+allowed-tools:
+ - Bash(orc-state:*)
+ - Bash(orc-report:*)
+ - Bash(orc-explain:*)
+ - Bash(orc-qa-video:*)
+ - Bash(orc-targets:*)
+ - Bash(orc-playwright:*)
+ - Bash(npx playwright:*)
+ - Bash(orc-workspace-detect:*)
+ - Bash(git log:*)
+ - Bash(git branch --show-current:*)
+ - Bash(jq:*)
+ - Bash(ffprobe:*)
+ - Read
+ - Write
+ - Edit
+ - Glob
+ - Grep
+ - Skill
+ - Task
+ - AskUserQuestion
+effort: medium
+---
+
+# /orc:explain
+
+Turn a QA packet into a short narrated walkthrough of what was built and proven: demo footage of the golden path, motion-graphic title/callout/outcome segments, one voice track. Everything renders locally (kokoro-js, ffmpeg, optional animate plugin). Nothing is uploaded without the evidence-publish gates.
+
+## Arguments
+
+- `--qa-dir ` — packet to explain. Default: the active session's `${ORC_STATE_DIR}//files/qa/`; else the newest `.orc/evidence/*/` packet; else ask.
+- `--target ` — target for the demo pass when the packet has no `demo/` clips (`orc:qa-targets`); default: the session's settled `target`.
+- `--style ` — animate style (`cut-paper|crosshatch|riso|sketchbook|math|pixel|isometric`); default userConfig `explainer_style`.
+- `--voice ` — kokoro voice id; default userConfig `explainer_voice` (`af_heart`; `pf_dora` for pt-BR).
+- `--auto[=guided|full]` — autopilot (`orc:gates` §6). Pre-approves the Explainer gate; delivery gates always ask.
+
+## Workflow
+
+### Phase 0 — Context
+!`orc-workspace-detect --banner`
+Resolve the packet dir (above). `qa-manifest.json` missing ⇒ stop: `🛑 No QA packet — run /orc:qa first`.
+
+### Phase 1 — Build
+Invoke `orc:explainer` with the packet dir, the plan path when the session has one, the target name + override (only if the demo pass is needed; the skill resolves credentials inline, never as a standalone command), style, voice. It runs the demo pass if needed, writes `explain/segments.json`, runs the **Explainer gate**, narrates, authors the animate scenes (when animate is installed), renders `explainer-.mp4`, and updates the manifest + `steps.md`.
+
+### Phase 2 — Deliver
+Invoke `orc:evidence-publish` with `qaDir`, the manifest verdict, and `explainerFile`. Its questions (Jira upload / private Artifact / PR comment) are hard-outward — always asked. Keep-local is the default.
+
+### Phase 3 — Record
+Session present ⇒ `orc-state digest write -` with `Explainer: explainer-.mp4 (s)` and append `## Explainer — ` to `progress.md`.
+
+## Iron rules
+- No render without the Explainer gate (unless `--auto`); no upload without the publish gates.
+- The script never quotes code or file names and never includes credentials.
+- Missing ffmpeg ⇒ one line, stop. Missing animate ⇒ title cards, continue, say so.
diff --git a/orc/commands/flow.md b/orc/commands/flow.md
index 17a8b22..4139a5c 100644
--- a/orc/commands/flow.md
+++ b/orc/commands/flow.md
@@ -1,6 +1,6 @@
---
description: End-to-end feature/bug/refactor pipeline (plan → start → implement → QA → ship → address → cleanup) with an interactive gate at every phase. Resumable via /orc:resume. --jira links a ticket. Workspace-aware.
-argument-hint: "[--auto[=guided|full]] [--type=feature|bug|refactor|docs] [--rfc] [--verbose] [--pause-at-implement] [--jira ] [--max-loc ] [--no-size-gate] [--driver agent-browser|chrome] [--repos a,b | --repo a | --all-repos | --this-repo] "
+argument-hint: "[--auto[=guided|full]] [--type=feature|bug|refactor|docs] [--rfc] [--verbose] [--pause-at-implement] [--jira ] [--max-loc ] [--no-size-gate] [--driver playwright|agent-browser|chrome] [--target ] [--explainer] [--repos a,b | --repo a | --all-repos | --this-repo] "
allowed-tools:
- Bash(orc-state:*)
- Bash(orc-report:*)
@@ -46,7 +46,9 @@ Interactivity follows the gate taxonomy (`orc:using-orc`) and the resolved `inte
- `--type=feature|bug|refactor|docs` — optional; pre-answers the triage type question. The type changes which phases run.
- `--rfc` — insert an RFC phase before planning (multi-week, multi-team, or genuine-alternatives work).
- `--verbose` — pass through to `/orc:ship` (long-form PR body; terse `orc:caveman-pr` is the default).
-- `--driver agent-browser|chrome` — pre-answers Phase 6's browser-driver gate.
+- `--driver playwright|agent-browser|chrome` — pre-answers Phase 6's browser-driver gate.
+- `--target ` — pre-answers Phase 6's Target gate (`orc:qa-targets`); recorded as a `target` decision.
+- `--explainer` — pre-answers Phase 6's post-QA explainer gate with "build now".
- `--pause-at-implement` — Phase 5 pauses for the human to write the code (keeps Phase 4's red-confirm gate).
- `--jira ` — link a Jira ticket silently (validate `^[A-Z][A-Z0-9_]*-\d+$`); lands as `Resolves ` in the PR body.
- `--max-loc ` / `--no-size-gate` — pass-through to `/orc:ship`'s Phase 4.5 size gate (single owner; flow never pre-flights it).
@@ -138,7 +140,7 @@ This is the back-pointer `/orc:status` and `/orc:resume` follow when the user `c
The resolved policy (flag > settled decision > env > userConfig > `manual`) sets how much of the run needs you:
- **manual** — every soft-inward gate asks, exactly as the phase playbooks specify.
-- **guided** — mechanical confirms auto-advance with a printed one-liner: the driver defaults to `agent-browser` (recorded as a policy decision), a clean QA pass advances, Phase 9 auto-applies the standard cleanup plan. Plan approval, the size gate, and PR compose still ask.
+- **guided** — mechanical confirms auto-advance with a printed one-liner: the driver defaults to `playwright` and the target to `local` (both recorded as policy decisions), a clean QA pass advances, Phase 9 auto-applies the standard cleanup plan. Plan approval, the size gate, and PR compose still ask.
- **auto (full)** — Phases 1–3 collapse into **one contract gate**: infer type/scope from the description (recorded as inferred decisions; `orc-prd-analyzer` still runs for long briefs and P0 questions still stop the run), draft the plan, then a single `AskUserQuestion` call:
1. **Approve the contract** — the deliverable one-liner, the slice list, and the testable success criteria: suite + lint + type-check green, every `slices.json` acceptance criterion `pass` in `qa-verdict.json`, `orc-state slice list --status pending,red,escalated` clean, PR ≤ budget or stacked, CI green on the PR. Options: approve / iterate the plan / abort.
2. **Repo set** (workspace mode only — asked at every level, never inferred; iron rule 7).
diff --git a/orc/commands/qa.md b/orc/commands/qa.md
index 28618fd..cafb92d 100644
--- a/orc/commands/qa.md
+++ b/orc/commands/qa.md
@@ -1,6 +1,6 @@
---
description: Pre-PR quality gate — browser-driven QA for web changes with a mandatory evidence packet against a provisioned environment. No QA-passed claim without artifacts. Workspace-aware. For a quick behavioral check without the evidence packet, prefer the bundled /verify or /run.
-argument-hint: "[--auto[=guided|full]] [--web ] [--no-web] [--no-env] [--driver agent-browser|chrome] [--repos a,b | --repo a | --all-repos | --this-repo] "
+argument-hint: "[--auto[=guided|full]] [--target ] [--web ] [--no-web] [--no-env] [--driver playwright|agent-browser|chrome] [--repos a,b | --repo a | --all-repos | --this-repo] "
allowed-tools:
- Bash(orc-state:*)
- Bash(orc-report:*)
@@ -27,6 +27,13 @@ allowed-tools:
- Bash(acli:*)
- Bash(jq:*)
- Bash(git branch --show-current:*)
+ - Bash(orc-targets:*)
+ - Bash(npx playwright:*)
+ - Bash(orc-playwright:*)
+ - Bash(orc-qa-video:*)
+ - Bash(gh pr comment:*)
+ - Bash(gh pr view:*)
+ - Bash(gh auth status:*)
effort: high
---
@@ -35,16 +42,17 @@ effort: high
Run a quality gate before opening a PR. Two modes:
- **Code/CLI/library change** — run tests, lint, type-check; verify with `orc:verification-before-completion`; do a self-review with `orc:caveman-review`. No browser.
-- **Web change** — same as above PLUS browser-driven QA, with a **driver chosen per run** (Phase 4.1): the `agent-browser` CLI via the `orc-qa-validator` agent (headless, richest evidence), or the **Claude-in-Chrome extension** run inline in this session (the user watches the test live in their own browser). Evidence is saved to `.orc//files/qa/` either way.
+- **Web change** — same as above PLUS browser-driven QA, with a **driver chosen per run** (the `orc:browser-qa` driver gate): **Playwright** (Driver P, default — real tests plus a stitched, step-tagged video and traces), the `agent-browser` CLI via the `orc-qa-validator` agent (Driver A, headless one-off walk), or the **Claude-in-Chrome extension** run inline in this session (Driver B — the user watches the test live in their own browser). Evidence is saved to `.orc//files/qa/` either way.
## Arguments
- `--auto[=guided|full]` — autopilot level for this run (overrides `interaction_policy`); ladder and hard-outward exemption in `orc:gates` §6.
+- `--target ` — a named QA target from `.orc/targets.json` (`orc:qa-targets`): `local` provisions the Docker env; a remote target (staging, preview) is probed and used as-is. Omitted ⇒ the Target gate asks. `--web ` remains as the ad-hoc remote shorthand.
- `--web ` — explicit URL of a running app (skips env provisioning AND the validator's boot path — you're saying it's already up).
- `--no-web` — force code-only mode even if web files were touched.
- `--no-env` — skip Docker env provisioning; the validator falls back to its legacy dev-script boot.
-- `--driver agent-browser|chrome` — pick the browser driver up front and skip the Phase 4.1 prompt. `agent-browser` = headless CLI via `orc-qa-validator` (criterion-anchored annotated screenshots, WebM recording when `ffmpeg` is installed, HAR, network mocking). `chrome` = Claude-in-Chrome extension, run inline so the user watches live in their real browser (real sessions/extensions, GIF recording).
+- `--driver playwright|agent-browser|chrome` — pick the browser driver up front and skip the `orc:browser-qa` driver gate. `playwright` = Playwright Test with the planner/generator/healer agents (tests committed under `e2e/`, stitched step-tagged video, traces). `agent-browser` = headless CLI via `orc-qa-validator` (criterion-anchored annotated screenshots, WebM recording when `ffmpeg` is installed, HAR, network mocking). `chrome` = Claude-in-Chrome extension, run inline so the user watches live in their real browser (real sessions/extensions, GIF recording).
- The remaining argument is the feature description (used to scope golden-path testing).
## Workflow
@@ -77,7 +85,7 @@ If verification (Phase 2) flagged untested branches — dispatch **`orc-test-aut
### Phase 4 (web mode only) — Browser QA
-Invoke **`orc:browser-qa`** and execute its protocol end-to-end: env attach/provision (step 0), the driver gate (step 1 — `--driver` or a settled `driver` decision skips it), the acceptance-criteria load (step 2 — both drivers), then Driver A (validator dispatch) or Driver B (Claude-in-Chrome inline). Either way the driver writes `qa-manifest.json` into the packet dir. This command adds nothing to the protocol — the skill is the single source of truth shared with `/orc:flow` Phase 6.
+Invoke **`orc:browser-qa`** and execute its protocol end-to-end: target resolution (step 0, `orc:qa-targets`), env attach/provision for local targets (step 1), the driver gate (step 1b — `--driver`/`--target` or settled decisions skip it), the acceptance-criteria load (step 2 — all drivers), then Driver P (Playwright, default), Driver A (validator dispatch) or Driver B (Claude-in-Chrome inline). Either way the driver writes `qa-manifest.json` into the packet dir. This command adds nothing to the protocol — the skill is the single source of truth shared with `/orc:flow` Phase 6.
### Phase 5 — Write the verdict
@@ -105,16 +113,16 @@ When Phase 4 produced a packet, invoke `orc:evidence-publish` — pass `qaDir =
For any web-mode QA, the `qa/` directory MUST contain the driver's full packet:
-| Artifact | Driver A — agent-browser | Driver B — chrome |
-|----------|--------------------------|-------------------|
-| Visual proof | `screenshot-NN-.png` per golden-path step (`--annotate`) + edge-case shots | in-conversation screenshots, referenced by step number in `steps.md` (`Write` cannot emit binary) |
-| Criterion proof | `ac---.png` per scored criterion | the numbered `### Step ` anchor a criterion's `evidence` points at |
-| Motion proof | `qa-.webm` (`agent-browser record` — needs `ffmpeg`) (+ optional `.gif` for ticket embedding) | `qa-.gif` (`gif_creator`, edge cases included) |
-| Manifest | `qa-manifest.json` | `qa-manifest.json` |
-| A11y snapshot | `snapshot-final.txt` (`agent-browser snapshot`) | `snapshot-final.txt` (`read_page` output) |
-| Console | `console.log` (`agent-browser console`) | `console.log` (`read_console_messages`; state any filter used) |
-| Network | `network.har` (`agent-browser network har stop`) | `network-summary.md` (distilled `read_network_requests`) |
-| Narrative | `steps.md` | `steps.md` (same template) |
+| Artifact | Driver P — playwright | Driver A — agent-browser | Driver B — chrome |
+|----------|-----------------------|--------------------------|-------------------|
+| Visual proof | `trace-.zip` per scenario (screenshots inside) | `screenshot-NN-.png` per golden-path step (`--annotate`) + edge-case shots | in-conversation screenshots, referenced by step number in `steps.md` (`Write` cannot emit binary) |
+| Criterion proof | `qa-.webm#t=` + the `test.step` title | `ac---.png` per scored criterion | the numbered `### Step ` anchor a criterion's `evidence` points at |
+| Motion proof | `qa-.webm` stitched (ffmpeg) | `qa-.webm` (`agent-browser record` — needs `ffmpeg`) (+ optional `.gif` for ticket embedding) | `qa-.gif` (`gif_creator`, edge cases included) |
+| Manifest | `qa-manifest.json` (`video.chapters`) | `qa-manifest.json` | `qa-manifest.json` |
+| A11y snapshot | `trace-.zip` (ARIA snapshots per action) | `snapshot-final.txt` (`agent-browser snapshot`) | `snapshot-final.txt` (`read_page` output) |
+| Console | `console-.log` | `console.log` (`agent-browser console`) | `console.log` (`read_console_messages`; state any filter used) |
+| Network | `trace-.zip` | `network.har` (`agent-browser network har stop`) | `network-summary.md` (distilled `read_network_requests`) |
+| Narrative | `steps.md` | `steps.md` | `steps.md` (same template) |
Motion proof is required whenever the change touches a rendered surface. Two exemptions, both of which must be stated as a one-line reason in `steps.md` rather than silently omitted: a non-visual change, or (Driver A only) `ffmpeg` not installed — agent-browser's recorder wraps ffmpeg, so a headless run without it captures stills only. The Claude-in-Chrome driver's `gif_creator` has no such dependency. Every acceptance criterion must appear in `qa-manifest.json` with a result, and a `skipped` row must say why.
diff --git a/orc/commands/ship.md b/orc/commands/ship.md
index 79568d6..2f6fedb 100644
--- a/orc/commands/ship.md
+++ b/orc/commands/ship.md
@@ -10,6 +10,9 @@ allowed-tools:
- AskUserQuestion
- Bash(git:*)
- Bash(gh pr create:*)
+ - Bash(gh pr comment:*)
+ - Bash(gh pr view:*)
+ - Bash(gh auth status:*)
- Bash(gh pr view:*)
- Bash(gh pr list:*)
- Bash(gh pr edit:*)
@@ -129,6 +132,8 @@ gh pr create --title "" --body "" [--draft] [--base ]
Echo the PR URL.
+When `${ORC_STATE_DIR}//files/qa/qa-manifest.json` exists, invoke `orc:evidence-publish` with `qaDir`, the packet's `verdict`, and the new PR — its PR-comment question (hard-outward, header `Post`) offers the verdict table + links on the PR just opened. Keep-local is the default; nothing posts without the answer.
+
In workspace mode, this becomes a **two-pass loop**:
**Pass 1 — push + create per repo** (sequential, since `gh pr create` should observe a clean push). Skip any repo whose Phase 4.5 already produced a stack (its `linkedPRs[]` entries are populated and `gh pr create` would create a duplicate):
diff --git a/orc/lib/explain/animate-piece.mjs b/orc/lib/explain/animate-piece.mjs
new file mode 100644
index 0000000..2b2343b
--- /dev/null
+++ b/orc/lib/explain/animate-piece.mjs
@@ -0,0 +1,31 @@
+// animate-piece.mjs
+// For each kind:"graphic" segment writes /pieces// by copying the pinned example
+// piece (see animate.lock) and applying what animate's format can express as data:
+// piece.json name=id, title=segment title, style=doc.style
+// brief.md headline (title), beat lines (lines[]), narration — the brief an animate scene author works from
+// voice/script.json { lead, tail, lines:[{id,text,after}] } — animate's documented voice-script shape (narration text only;
+// orc narrates via kokoro in `orc-explain narrate`, animate's voice tool is NOT run)
+// Pinned-source facts: piece.json carries no beat/headline fields; visible content is canvas JS in src/scenes.js, so
+// headline/lines cannot be substituted as data. Update with animate.lock on every bump.
+// Prints one JSON line per piece: {id, pieceDir}.
+import fs from 'node:fs'; import path from 'node:path';
+const [, , segmentsPath, workDir, skillRoot] = process.argv;
+if (!segmentsPath || !workDir || !skillRoot) { console.error('usage: animate-piece.mjs '); process.exit(2); }
+const doc = JSON.parse(fs.readFileSync(segmentsPath, 'utf8'));
+const example = path.join(skillRoot, 'examples', 'history-of-ai');
+const style = process.argv[5] || doc.style || 'isometric';
+for (const s of doc.segments.filter((x) => x.kind === 'graphic')) {
+ if (!/^[a-z0-9][a-z0-9-]*$/.test(String(s.id))) { console.error(`animate-piece: bad segment id ${s.id}`); process.exit(2); }
+ const dir = path.join(workDir, 'pieces', s.id);
+ fs.rmSync(dir, { recursive: true, force: true });
+ fs.cpSync(example, dir, { recursive: true });
+ const pj = path.join(dir, 'piece.json');
+ const meta = JSON.parse(fs.readFileSync(pj, 'utf8'));
+ Object.assign(meta, { name: s.id, title: s.title, style });
+ fs.writeFileSync(pj, JSON.stringify(meta, null, 2) + '\n');
+ const lines = (s.lines || []).map((l) => `- ${l}`).join('\n');
+ fs.writeFileSync(path.join(dir, 'brief.md'), `# Brief: ${s.title}\n\nStyle: ${style}. Format: 16:9.\n\n## Beat lines\n\n${lines}\n\n## Narration\n\n${s.narration}\n`);
+ fs.mkdirSync(path.join(dir, 'voice'), { recursive: true });
+ fs.writeFileSync(path.join(dir, 'voice', 'script.json'), JSON.stringify({ lead: 1.2, tail: 1.8, lines: [{ id: s.id, text: s.narration, after: 0.45 }] }, null, 2) + '\n');
+ console.log(JSON.stringify({ id: s.id, pieceDir: dir }));
+}
diff --git a/orc/lib/explain/animate.lock b/orc/lib/explain/animate.lock
new file mode 100644
index 0000000..1372869
--- /dev/null
+++ b/orc/lib/explain/animate.lock
@@ -0,0 +1,16 @@
+repo=https://github.com/cth9191/animate
+commit=7e5eb56feb2dd573f890e1b7b34748af43d58263
+skill_root=plugins/animate/skills/animate
+example=examples/history-of-ai
+tools=tools/build.mjs tools/export.mjs tools/capture.mjs
+# notes: (read from the pinned commit; update together with animate-piece.mjs on every bump)
+# - piece = dir with piece.json + brief.md + src/{head.html,scenes.js,bridges.js,score.js}; build.mjs assembles /index.html.
+# - piece.json is METADATA ONLY: name, title, style (one of cut-paper|crosshatch|isometric|math|pixel|riso|sketchbook),
+# format{width,height,fps,bpm,duration,frames}, storyFormat, review{}. No beats/headline/lines/VO fields exist.
+# - All visible content (text, shapes, timing) is hand-authored canvas JS in src/scenes.js (+ TIMELINE in head.html);
+# the example is a 60s 1080x1920 cut-paper film. Copying it renders history-of-ai, NOT segment text.
+# - Mapped by animate-piece.mjs: piece.json name=, title=, style=;
+# brief.md = headline/lines/narration; voice/script.json = {lead,tail,lines:[{id,text,after}]} (documented in SKILL.md).
+# - Scene code for headline/lines is NOT generated: needs authoring per brief.md (animate's own workflow).
+# - export: node tools/export.mjs --formats 16:9 -> /renders/final-x.mp4 (non-main formats are suffixed;
+# piece.json lacks "formats" so every format is suffixed); main format would be renders/final.mp4. Captions variant: final-captions.mp4.
diff --git a/orc/lib/explain/explain.sh b/orc/lib/explain/explain.sh
new file mode 100755
index 0000000..5df1126
--- /dev/null
+++ b/orc/lib/explain/explain.sh
@@ -0,0 +1,173 @@
+#!/usr/bin/env bash
+# shellcheck disable=SC2016,SC2015
+# explain.sh — the /orc:explain toolchain behind bin/orc-explain:
+# validate segments.json shape (schema in orc:explainer references/segments-schema.md)
+# setup stage narrate.mjs + package.json into ${XDG_CACHE_HOME:-~/.cache}/orc/explain and npm install
+# graphics animate pieces → /graphics/.mp4 for graphic segments (fallback cards when animate is absent)
+# narrate run kokoro-js → /narration/.wav
+# assemble per-segment mp4 (clip | graphic from /graphics/.mp4 | fallback title card),
+# each max(video, narration) long, concat → --out
+# ffmpeg/ffprobe required for assemble (exit 4 when absent — caller reports, never installs).
+
+orc_ex__home() { printf '%s/orc/explain\n' "${XDG_CACHE_HOME:-$HOME/.cache}"; }
+orc_ex__src() { cd "$(dirname "${BASH_SOURCE[0]}")" && pwd; }
+
+orc_ex_validate() { # --segments S
+ local s=""
+ while [ $# -gt 0 ]; do case "$1" in --segments) s="$2"; shift 2 ;; *) echo "orc-explain: unknown argument $1" >&2; return 2 ;; esac; done
+ [ -f "$s" ] || { echo "orc-explain: segments file not found: $s" >&2; return 2; }
+ local bad
+ bad="$(jq -r '
+ (if .schema != 1 then "schema must be 1" else empty end),
+ (.segments // [] | to_entries[] | .value as $g |
+ (if ($g.id // "") == "" then "segment \(.key): missing id"
+ elif ($g.id | test("^[a-z0-9][a-z0-9-]*$") | not) then "segment \(.key): id must be a slug [a-z0-9-]" else empty end),
+ (if ($g.narration // "") == "" then "segment \($g.id): missing narration" else empty end),
+ (if ($g.kind != "graphic" and $g.kind != "clip") then "segment \($g.id): kind must be graphic|clip" else empty end),
+ (if $g.kind == "clip" and ($g.source // "") == "" then "segment \($g.id): clip needs source" else empty end),
+ (if $g.kind == "graphic" and ($g.title // "") == "" then "segment \($g.id): graphic needs title" else empty end))' "$s")"
+ [ -z "$bad" ] || { printf 'orc-explain: invalid segments.json\n%s\n' "$bad" >&2; return 2; }
+ printf 'ok %s segments\n' "$(jq '.segments | length' "$s")"
+}
+
+orc_ex_setup() {
+ local home; home="$(orc_ex__home)"
+ mkdir -p "$home"
+ cp "$(orc_ex__src)/narrate.mjs" "$(orc_ex__src)/package.json" "$(orc_ex__src)/package-lock.json" "$(orc_ex__src)/animate-piece.mjs" "$(orc_ex__src)/animate.lock" "$home/"
+ if [ "${ORC_EXPLAIN_SKIP_NPM:-0}" != "1" ]; then
+ # exact versions from the shipped lockfile; no lifecycle scripts from transitive deps
+ (cd "$home" && npm ci --ignore-scripts --no-fund --no-audit >/dev/null)
+ fi
+ printf '%s\n' "$home"
+}
+
+orc_ex_narrate() { # --segments S --work W
+ local s="" w=""
+ while [ $# -gt 0 ]; do case "$1" in --segments) s="$2"; shift 2 ;; --work) w="$2"; shift 2 ;; *) return 2 ;; esac; done
+ orc_ex_validate --segments "$s" >/dev/null || return 2
+ local home; home="$(orc_ex__home)"
+ export ORC_EXPLAIN_VOICE="${ORC_EXPLAIN_VOICE:-${CLAUDE_PLUGIN_OPTION_EXPLAINER_VOICE:-af_heart}}"
+ [ -d "$home/node_modules/kokoro-js" ] || { echo "orc-explain: kokoro-js not installed — run: orc-explain setup" >&2; return 3; }
+ node "$home/narrate.mjs" "$s" "$w/narration"
+}
+
+orc_ex__animate_root() {
+ local r="${ORC_ANIMATE_ROOT:-}"
+ [ -n "$r" ] || r="$(find "$HOME/.claude/plugins/cache/animate/animate" -mindepth 3 -maxdepth 3 -path '*/skills/animate' 2>/dev/null | sort -V | tail -1)"
+ [ -n "$r" ] && [ -f "$r/tools/build.mjs" ] && printf '%s' "$r"
+}
+
+# animate pieces are hand-authored canvas scenes (see animate.lock notes): the generated piece carries the brief but not
+# scene code, so rendering is opt-in (ORC_ANIMATE_RENDER=1, after the scenes were authored) — default is fallback cards.
+orc_ex_graphics() { # --segments S --work W
+ local s="" w=""
+ while [ $# -gt 0 ]; do case "$1" in --segments) s="$2"; shift 2 ;; --work) w="$2"; shift 2 ;; *) return 2 ;; esac; done
+ orc_ex_validate --segments "$s" >/dev/null || return 2
+ local style; style="$(jq -r '.style // empty' "$s")"; style="${style:-${CLAUDE_PLUGIN_OPTION_EXPLAINER_STYLE:-isometric}}"
+ local root; root="$(orc_ex__animate_root || true)"
+ [ -n "$root" ] || { echo "orc-explain: animate not installed — fallback cards will be used for graphic segments"; return 0; }
+ mkdir -p "$w/graphics"
+ local pieces line id dir out
+ if [ "${ORC_ANIMATE_RENDER:-0}" = "1" ] && [ ! -d "$w/pieces" ]; then
+ echo "orc-explain: ORC_ANIMATE_RENDER=1 but no authored pieces at $w/pieces — run graphics once without it, author src/scenes.js per brief.md, then re-run" >&2
+ return 1
+ fi
+ if [ "${ORC_ANIMATE_RENDER:-0}" = "1" ]; then
+ # render pass: reuse the authored pieces — animate-piece.mjs re-copies the example and would wipe src/scenes.js
+ pieces="$(jq -c --arg w "$w" '.segments[] | select(.kind == "graphic") | {id, pieceDir: "\($w)/pieces/\(.id)"}' "$s")"
+ else
+ pieces="$(node "$(orc_ex__home)/animate-piece.mjs" "$s" "$w" "$root" "$style")" || return 1
+ fi
+ if [ "${ORC_ANIMATE_RENDER:-0}" != "1" ]; then
+ echo "orc-explain: animate pieces written to $w/pieces (scenes need authoring; set ORC_ANIMATE_RENDER=1 to render) — fallback cards will be used"
+ return 0
+ fi
+ while IFS= read -r line; do
+ [ -n "$line" ] || continue
+ id="$(printf '%s' "$line" | jq -r .id)"; dir="$(printf '%s' "$line" | jq -r .pieceDir)"
+ node "$root/tools/build.mjs" "$dir" && node "$root/tools/export.mjs" "$dir" --formats 16:9 || { echo "orc-explain: animate render failed for $id — fallback card" >&2; continue; }
+ out="$(find "$dir/renders" -maxdepth 1 -name 'final-[0-9]*x[0-9]*.mp4' 2>/dev/null | head -1)"
+ [ -n "$out" ] && cp "$out" "$w/graphics/$id.mp4"
+ done <<< "$pieces"
+}
+
+orc_ex__dur() { ffprobe -v error -show_entries format=duration -of csv=p=0 "$1" 2>/dev/null || printf '0'; }
+orc_ex__font() { local f; for f in /System/Library/Fonts/Helvetica.ttc /usr/share/fonts/truetype/dejavu/DejaVuSans.ttf; do [ -f "$f" ] && { printf '%s' "$f"; return; }; done; }
+
+# drawtext needs an ffmpeg built with libfreetype; Homebrew-minimal builds lack it.
+orc_ex__has_drawtext() { ffmpeg -hide_banner -filters 2>/dev/null | grep -q ' drawtext '; }
+
+orc_ex__card() { #
+ local out="$1" title="$2" lines="$3" secs="$4" ff="" font t1 t2
+ mkdir -p "$(dirname "$out")"
+ if ! orc_ex__has_drawtext; then # no text rendering available — plain card, narration still carries the content
+ echo "orc-explain: ffmpeg lacks drawtext — title card rendered without text" >&2
+ ffmpeg -nostdin -v error -y -f lavfi -i "color=c=0x1d1d1b:s=1920x1080:d=$secs" -r 30 -c:v libx264 -pix_fmt yuv420p -an "$out"
+ return 0
+ fi
+ font="$(orc_ex__font)"; [ -n "$font" ] && ff="fontfile=$font:"
+ t1="$(mktemp)"; t2="$(mktemp)"; printf '%s' "$title" > "$t1"; printf '%b' "$lines" > "$t2"
+ ffmpeg -nostdin -v error -y -f lavfi -i "color=c=0x1d1d1b:s=1920x1080:d=$secs" \
+ -vf "drawtext=${ff}textfile=$t1:fontcolor=white:fontsize=72:x=(w-text_w)/2:y=h/2-160,drawtext=${ff}textfile=$t2:fontcolor=0xa29f98:fontsize=40:line_spacing=18:x=(w-text_w)/2:y=h/2-20" \
+ -r 30 -c:v libx264 -pix_fmt yuv420p -an "$out"
+ rm -f "$t1" "$t2"
+}
+
+orc_ex__segment() { # → renders video padded to max(video,narration) with audio
+ local vin="$1" wav="$2" cap="$3" out="$4" vd ad target vf ff="" font tf
+ vd="$(orc_ex__dur "$vin")"; ad=0; [ -n "$wav" ] && [ -f "$wav" ] && ad="$(orc_ex__dur "$wav")"
+ target="$(awk -v a="$vd" -v b="$ad" 'BEGIN{printf "%.2f", (a>b?a:b)+0.3}')"
+ vf="scale=1920:1080:force_original_aspect_ratio=decrease,pad=1920:1080:(ow-iw)/2:(oh-ih)/2,fps=30,tpad=stop_mode=clone:stop_duration=$(awk -v t="$target" -v v="$vd" 'BEGIN{d=t-v; printf "%.2f", (d>0?d:0)}')"
+ if [ -n "$cap" ] && orc_ex__has_drawtext; then
+ font="$(orc_ex__font)"; [ -n "$font" ] && ff="fontfile=$font:"
+ tf="$(mktemp)"; printf '%s' "$cap" > "$tf"
+ vf="$vf,drawtext=${ff}textfile=$tf:fontcolor=white:fontsize=38:box=1:boxcolor=0x00000099:boxborderw=18:x=(w-text_w)/2:y=h-120"
+ fi
+ if [ -n "$wav" ] && [ -f "$wav" ]; then
+ ffmpeg -nostdin -v error -y -i "$vin" -i "$wav" -vf "$vf" -af "apad" -t "$target" -c:v libx264 -pix_fmt yuv420p -c:a aac -b:a 128k -ar 48000 -ac 2 "$out"
+ else
+ ffmpeg -nostdin -v error -y -i "$vin" -f lavfi -i anullsrc=r=48000:cl=stereo -vf "$vf" -t "$target" -c:v libx264 -pix_fmt yuv420p -c:a aac -b:a 128k -shortest "$out"
+ fi
+ [ -n "${tf:-}" ] && rm -f "$tf"
+ return 0
+}
+
+orc_ex_assemble() { # --segments S --work W --qa-dir Q --out O
+ local s="" w="" q="" o=""
+ while [ $# -gt 0 ]; do case "$1" in --segments) s="$2"; shift 2 ;; --work) w="$2"; shift 2 ;; --qa-dir) q="$2"; shift 2 ;; --out) o="$2"; shift 2 ;; *) return 2 ;; esac; done
+ command -v ffmpeg >/dev/null 2>&1 && command -v ffprobe >/dev/null 2>&1 || { echo "orc-explain: ffmpeg not installed — explainer skipped" >&2; return 4; }
+ orc_ex_validate --segments "$s" >/dev/null || return 2
+ mkdir -p "$w/segments"; local list="$w/list.txt"; : > "$list"
+ local id kind title lines src cap wav vin
+ while IFS=$'\x1f' read -r id kind title lines src cap; do
+ wav="$w/narration/$id.wav"; [ -f "$wav" ] || wav=""
+ if [ "$kind" = "clip" ]; then
+ vin="$q/$src"; [ -f "$vin" ] || { echo "orc-explain: clip source missing: $vin" >&2; return 1; }
+ else
+ vin="$w/graphics/$id.mp4"
+ if [ ! -f "$vin" ]; then # fallback card when animate did not render this segment
+ local secs; secs="$(awk -v a="$( [ -n "$wav" ] && orc_ex__dur "$wav" || printf 3 )" 'BEGIN{printf "%.2f", (a<3?3:a)}')"
+ orc_ex__card "$vin" "$title" "$lines" "$secs"
+ fi
+ cap=""
+ fi
+ orc_ex__segment "$vin" "$wav" "$cap" "$w/segments/seg-$id.mp4"
+ printf "file '%s'\n" "$w/segments/seg-$id.mp4" >> "$list"
+ done < <(jq -r '.segments[] | [.id, .kind, (.title // ""), ((.lines // []) | join("\\n")), (.source // ""), (.caption // "")] | join("\u001f")' "$s")
+ ffmpeg -nostdin -v error -y -f concat -safe 0 -i "$list" -c copy "$o"
+ printf '%s\n' "$o"
+}
+
+if [ "${BASH_SOURCE[0]:-}" = "${0:-}" ]; then
+ set -euo pipefail
+ sub="${1:-}"; shift 2>/dev/null || true
+ case "$sub" in
+ validate) orc_ex_validate "$@" ;;
+ setup) orc_ex_setup "$@" ;;
+ graphics) orc_ex_graphics "$@" ;;
+ narrate) orc_ex_narrate "$@" ;;
+ assemble) orc_ex_assemble "$@" ;;
+ --help|-h|help|'') printf 'usage: orc-explain validate --segments S | setup | graphics --segments S --work W | narrate --segments S --work W | assemble --segments S --work W --qa-dir Q --out O\n' ;;
+ *) printf 'orc-explain: unknown subcommand %s\n' "$sub" >&2; exit 2 ;;
+ esac
+fi
diff --git a/orc/lib/explain/narrate.mjs b/orc/lib/explain/narrate.mjs
new file mode 100644
index 0000000..b5d8d86
--- /dev/null
+++ b/orc/lib/explain/narrate.mjs
@@ -0,0 +1,22 @@
+// narrate.mjs — one WAV per segment with narration,
+// via kokoro-js (82M ONNX, CPU, downloads the model once into the HF cache).
+// Prints one JSON line per file: {id, file, seconds}. Voice: segments.voice,
+// else ORC_EXPLAIN_VOICE, else af_heart (English). pf_dora / pm_alex are pt-BR.
+import { KokoroTTS } from 'kokoro-js';
+import fs from 'node:fs';
+import path from 'node:path';
+
+const [, , segmentsPath, outDir] = process.argv;
+if (!segmentsPath || !outDir) { console.error('usage: narrate.mjs '); process.exit(2); }
+const doc = JSON.parse(fs.readFileSync(segmentsPath, 'utf8'));
+const voice = doc.voice ?? process.env.ORC_EXPLAIN_VOICE ?? 'af_heart';
+// Model pinned to a Hugging Face commit so a fresh cache never pulls a moved revision.
+const tts = await KokoroTTS.from_pretrained('onnx-community/Kokoro-82M-v1.0-ONNX', { dtype: 'q8', revision: '1939ad2a8e416c0acfeecc08a694d14ef25f2231' });
+fs.mkdirSync(outDir, { recursive: true });
+for (const s of doc.segments) {
+ if (!s.narration) continue;
+ const audio = await tts.generate(s.narration, { voice });
+ const file = path.join(outDir, `${s.id}.wav`);
+ await audio.save(file);
+ console.log(JSON.stringify({ id: s.id, file, seconds: audio.audio.length / audio.sampling_rate }));
+}
diff --git a/orc/lib/explain/package-lock.json b/orc/lib/explain/package-lock.json
new file mode 100644
index 0000000..53f9957
--- /dev/null
+++ b/orc/lib/explain/package-lock.json
@@ -0,0 +1,1097 @@
+{
+ "name": "orc-explain",
+ "lockfileVersion": 3,
+ "requires": true,
+ "packages": {
+ "": {
+ "name": "orc-explain",
+ "dependencies": {
+ "kokoro-js": "1.2.1"
+ }
+ },
+ "node_modules/@emnapi/runtime": {
+ "version": "1.11.3",
+ "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.11.3.tgz",
+ "integrity": "sha512-Xz4Tpyki7XyrpbUK1jR1AhdAdaXyhhY4lZ3neLodmhpuWfy2PAQN5B46sAiU4liOXGLkHypn/qU+jvfWSCYYLA==",
+ "license": "MIT",
+ "optional": true,
+ "dependencies": {
+ "tslib": "^2.4.0"
+ }
+ },
+ "node_modules/@huggingface/jinja": {
+ "version": "0.5.10",
+ "resolved": "https://registry.npmjs.org/@huggingface/jinja/-/jinja-0.5.10.tgz",
+ "integrity": "sha512-SgS1D1bglQ94ceD4ZCL6eayUDy9uV1xuyk61OjgSxU5GJh7upqZCKII0JoTYMc6wWsg3JUgaPRX0ElQzXPk3Cw==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@huggingface/transformers": {
+ "version": "3.8.1",
+ "resolved": "https://registry.npmjs.org/@huggingface/transformers/-/transformers-3.8.1.tgz",
+ "integrity": "sha512-tsTk4zVjImqdqjS8/AOZg2yNLd1z9S5v+7oUPpXaasDRwEDhB+xnglK1k5cad26lL5/ZIaeREgWWy0bs9y9pPA==",
+ "license": "Apache-2.0",
+ "dependencies": {
+ "@huggingface/jinja": "^0.5.3",
+ "onnxruntime-node": "1.21.0",
+ "onnxruntime-web": "1.22.0-dev.20250409-89f8206ba4",
+ "sharp": "^0.34.1"
+ }
+ },
+ "node_modules/@img/colour": {
+ "version": "1.1.0",
+ "resolved": "https://registry.npmjs.org/@img/colour/-/colour-1.1.0.tgz",
+ "integrity": "sha512-Td76q7j57o/tLVdgS746cYARfSyxk8iEfRxewL9h4OMzYhbW4TAcppl0mT4eyqXddh6L/jwoM75mo7ixa/pCeQ==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@img/sharp-darwin-arm64": {
+ "version": "0.34.5",
+ "resolved": "https://registry.npmjs.org/@img/sharp-darwin-arm64/-/sharp-darwin-arm64-0.34.5.tgz",
+ "integrity": "sha512-imtQ3WMJXbMY4fxb/Ndp6HBTNVtWCUI0WdobyheGf5+ad6xX8VIDO8u2xE4qc/fr08CKG/7dDseFtn6M6g/r3w==",
+ "cpu": [
+ "arm64"
+ ],
+ "license": "Apache-2.0",
+ "optional": true,
+ "os": [
+ "darwin"
+ ],
+ "engines": {
+ "node": "^18.17.0 || ^20.3.0 || >=21.0.0"
+ },
+ "funding": {
+ "url": "https://opencollective.com/libvips"
+ },
+ "optionalDependencies": {
+ "@img/sharp-libvips-darwin-arm64": "1.2.4"
+ }
+ },
+ "node_modules/@img/sharp-darwin-x64": {
+ "version": "0.34.5",
+ "resolved": "https://registry.npmjs.org/@img/sharp-darwin-x64/-/sharp-darwin-x64-0.34.5.tgz",
+ "integrity": "sha512-YNEFAF/4KQ/PeW0N+r+aVVsoIY0/qxxikF2SWdp+NRkmMB7y9LBZAVqQ4yhGCm/H3H270OSykqmQMKLBhBJDEw==",
+ "cpu": [
+ "x64"
+ ],
+ "license": "Apache-2.0",
+ "optional": true,
+ "os": [
+ "darwin"
+ ],
+ "engines": {
+ "node": "^18.17.0 || ^20.3.0 || >=21.0.0"
+ },
+ "funding": {
+ "url": "https://opencollective.com/libvips"
+ },
+ "optionalDependencies": {
+ "@img/sharp-libvips-darwin-x64": "1.2.4"
+ }
+ },
+ "node_modules/@img/sharp-libvips-darwin-arm64": {
+ "version": "1.2.4",
+ "resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-arm64/-/sharp-libvips-darwin-arm64-1.2.4.tgz",
+ "integrity": "sha512-zqjjo7RatFfFoP0MkQ51jfuFZBnVE2pRiaydKJ1G/rHZvnsrHAOcQALIi9sA5co5xenQdTugCvtb1cuf78Vf4g==",
+ "cpu": [
+ "arm64"
+ ],
+ "license": "LGPL-3.0-or-later",
+ "optional": true,
+ "os": [
+ "darwin"
+ ],
+ "funding": {
+ "url": "https://opencollective.com/libvips"
+ }
+ },
+ "node_modules/@img/sharp-libvips-darwin-x64": {
+ "version": "1.2.4",
+ "resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-x64/-/sharp-libvips-darwin-x64-1.2.4.tgz",
+ "integrity": "sha512-1IOd5xfVhlGwX+zXv2N93k0yMONvUlANylbJw1eTah8K/Jtpi15KC+WSiaX/nBmbm2HxRM1gZ0nSdjSsrZbGKg==",
+ "cpu": [
+ "x64"
+ ],
+ "license": "LGPL-3.0-or-later",
+ "optional": true,
+ "os": [
+ "darwin"
+ ],
+ "funding": {
+ "url": "https://opencollective.com/libvips"
+ }
+ },
+ "node_modules/@img/sharp-libvips-linux-arm": {
+ "version": "1.2.4",
+ "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm/-/sharp-libvips-linux-arm-1.2.4.tgz",
+ "integrity": "sha512-bFI7xcKFELdiNCVov8e44Ia4u2byA+l3XtsAj+Q8tfCwO6BQ8iDojYdvoPMqsKDkuoOo+X6HZA0s0q11ANMQ8A==",
+ "cpu": [
+ "arm"
+ ],
+ "libc": [
+ "glibc"
+ ],
+ "license": "LGPL-3.0-or-later",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "funding": {
+ "url": "https://opencollective.com/libvips"
+ }
+ },
+ "node_modules/@img/sharp-libvips-linux-arm64": {
+ "version": "1.2.4",
+ "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm64/-/sharp-libvips-linux-arm64-1.2.4.tgz",
+ "integrity": "sha512-excjX8DfsIcJ10x1Kzr4RcWe1edC9PquDRRPx3YVCvQv+U5p7Yin2s32ftzikXojb1PIFc/9Mt28/y+iRklkrw==",
+ "cpu": [
+ "arm64"
+ ],
+ "libc": [
+ "glibc"
+ ],
+ "license": "LGPL-3.0-or-later",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "funding": {
+ "url": "https://opencollective.com/libvips"
+ }
+ },
+ "node_modules/@img/sharp-libvips-linux-ppc64": {
+ "version": "1.2.4",
+ "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-ppc64/-/sharp-libvips-linux-ppc64-1.2.4.tgz",
+ "integrity": "sha512-FMuvGijLDYG6lW+b/UvyilUWu5Ayu+3r2d1S8notiGCIyYU/76eig1UfMmkZ7vwgOrzKzlQbFSuQfgm7GYUPpA==",
+ "cpu": [
+ "ppc64"
+ ],
+ "libc": [
+ "glibc"
+ ],
+ "license": "LGPL-3.0-or-later",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "funding": {
+ "url": "https://opencollective.com/libvips"
+ }
+ },
+ "node_modules/@img/sharp-libvips-linux-riscv64": {
+ "version": "1.2.4",
+ "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-riscv64/-/sharp-libvips-linux-riscv64-1.2.4.tgz",
+ "integrity": "sha512-oVDbcR4zUC0ce82teubSm+x6ETixtKZBh/qbREIOcI3cULzDyb18Sr/Wcyx7NRQeQzOiHTNbZFF1UwPS2scyGA==",
+ "cpu": [
+ "riscv64"
+ ],
+ "libc": [
+ "glibc"
+ ],
+ "license": "LGPL-3.0-or-later",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "funding": {
+ "url": "https://opencollective.com/libvips"
+ }
+ },
+ "node_modules/@img/sharp-libvips-linux-s390x": {
+ "version": "1.2.4",
+ "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-s390x/-/sharp-libvips-linux-s390x-1.2.4.tgz",
+ "integrity": "sha512-qmp9VrzgPgMoGZyPvrQHqk02uyjA0/QrTO26Tqk6l4ZV0MPWIW6LTkqOIov+J1yEu7MbFQaDpwdwJKhbJvuRxQ==",
+ "cpu": [
+ "s390x"
+ ],
+ "libc": [
+ "glibc"
+ ],
+ "license": "LGPL-3.0-or-later",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "funding": {
+ "url": "https://opencollective.com/libvips"
+ }
+ },
+ "node_modules/@img/sharp-libvips-linux-x64": {
+ "version": "1.2.4",
+ "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-x64/-/sharp-libvips-linux-x64-1.2.4.tgz",
+ "integrity": "sha512-tJxiiLsmHc9Ax1bz3oaOYBURTXGIRDODBqhveVHonrHJ9/+k89qbLl0bcJns+e4t4rvaNBxaEZsFtSfAdquPrw==",
+ "cpu": [
+ "x64"
+ ],
+ "libc": [
+ "glibc"
+ ],
+ "license": "LGPL-3.0-or-later",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "funding": {
+ "url": "https://opencollective.com/libvips"
+ }
+ },
+ "node_modules/@img/sharp-libvips-linuxmusl-arm64": {
+ "version": "1.2.4",
+ "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-arm64/-/sharp-libvips-linuxmusl-arm64-1.2.4.tgz",
+ "integrity": "sha512-FVQHuwx1IIuNow9QAbYUzJ+En8KcVm9Lk5+uGUQJHaZmMECZmOlix9HnH7n1TRkXMS0pGxIJokIVB9SuqZGGXw==",
+ "cpu": [
+ "arm64"
+ ],
+ "libc": [
+ "musl"
+ ],
+ "license": "LGPL-3.0-or-later",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "funding": {
+ "url": "https://opencollective.com/libvips"
+ }
+ },
+ "node_modules/@img/sharp-libvips-linuxmusl-x64": {
+ "version": "1.2.4",
+ "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-x64/-/sharp-libvips-linuxmusl-x64-1.2.4.tgz",
+ "integrity": "sha512-+LpyBk7L44ZIXwz/VYfglaX/okxezESc6UxDSoyo2Ks6Jxc4Y7sGjpgU9s4PMgqgjj1gZCylTieNamqA1MF7Dg==",
+ "cpu": [
+ "x64"
+ ],
+ "libc": [
+ "musl"
+ ],
+ "license": "LGPL-3.0-or-later",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "funding": {
+ "url": "https://opencollective.com/libvips"
+ }
+ },
+ "node_modules/@img/sharp-linux-arm": {
+ "version": "0.34.5",
+ "resolved": "https://registry.npmjs.org/@img/sharp-linux-arm/-/sharp-linux-arm-0.34.5.tgz",
+ "integrity": "sha512-9dLqsvwtg1uuXBGZKsxem9595+ujv0sJ6Vi8wcTANSFpwV/GONat5eCkzQo/1O6zRIkh0m/8+5BjrRr7jDUSZw==",
+ "cpu": [
+ "arm"
+ ],
+ "libc": [
+ "glibc"
+ ],
+ "license": "Apache-2.0",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": "^18.17.0 || ^20.3.0 || >=21.0.0"
+ },
+ "funding": {
+ "url": "https://opencollective.com/libvips"
+ },
+ "optionalDependencies": {
+ "@img/sharp-libvips-linux-arm": "1.2.4"
+ }
+ },
+ "node_modules/@img/sharp-linux-arm64": {
+ "version": "0.34.5",
+ "resolved": "https://registry.npmjs.org/@img/sharp-linux-arm64/-/sharp-linux-arm64-0.34.5.tgz",
+ "integrity": "sha512-bKQzaJRY/bkPOXyKx5EVup7qkaojECG6NLYswgktOZjaXecSAeCWiZwwiFf3/Y+O1HrauiE3FVsGxFg8c24rZg==",
+ "cpu": [
+ "arm64"
+ ],
+ "libc": [
+ "glibc"
+ ],
+ "license": "Apache-2.0",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": "^18.17.0 || ^20.3.0 || >=21.0.0"
+ },
+ "funding": {
+ "url": "https://opencollective.com/libvips"
+ },
+ "optionalDependencies": {
+ "@img/sharp-libvips-linux-arm64": "1.2.4"
+ }
+ },
+ "node_modules/@img/sharp-linux-ppc64": {
+ "version": "0.34.5",
+ "resolved": "https://registry.npmjs.org/@img/sharp-linux-ppc64/-/sharp-linux-ppc64-0.34.5.tgz",
+ "integrity": "sha512-7zznwNaqW6YtsfrGGDA6BRkISKAAE1Jo0QdpNYXNMHu2+0dTrPflTLNkpc8l7MUP5M16ZJcUvysVWWrMefZquA==",
+ "cpu": [
+ "ppc64"
+ ],
+ "libc": [
+ "glibc"
+ ],
+ "license": "Apache-2.0",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": "^18.17.0 || ^20.3.0 || >=21.0.0"
+ },
+ "funding": {
+ "url": "https://opencollective.com/libvips"
+ },
+ "optionalDependencies": {
+ "@img/sharp-libvips-linux-ppc64": "1.2.4"
+ }
+ },
+ "node_modules/@img/sharp-linux-riscv64": {
+ "version": "0.34.5",
+ "resolved": "https://registry.npmjs.org/@img/sharp-linux-riscv64/-/sharp-linux-riscv64-0.34.5.tgz",
+ "integrity": "sha512-51gJuLPTKa7piYPaVs8GmByo7/U7/7TZOq+cnXJIHZKavIRHAP77e3N2HEl3dgiqdD/w0yUfiJnII77PuDDFdw==",
+ "cpu": [
+ "riscv64"
+ ],
+ "libc": [
+ "glibc"
+ ],
+ "license": "Apache-2.0",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": "^18.17.0 || ^20.3.0 || >=21.0.0"
+ },
+ "funding": {
+ "url": "https://opencollective.com/libvips"
+ },
+ "optionalDependencies": {
+ "@img/sharp-libvips-linux-riscv64": "1.2.4"
+ }
+ },
+ "node_modules/@img/sharp-linux-s390x": {
+ "version": "0.34.5",
+ "resolved": "https://registry.npmjs.org/@img/sharp-linux-s390x/-/sharp-linux-s390x-0.34.5.tgz",
+ "integrity": "sha512-nQtCk0PdKfho3eC5MrbQoigJ2gd1CgddUMkabUj+rBevs8tZ2cULOx46E7oyX+04WGfABgIwmMC0VqieTiR4jg==",
+ "cpu": [
+ "s390x"
+ ],
+ "libc": [
+ "glibc"
+ ],
+ "license": "Apache-2.0",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": "^18.17.0 || ^20.3.0 || >=21.0.0"
+ },
+ "funding": {
+ "url": "https://opencollective.com/libvips"
+ },
+ "optionalDependencies": {
+ "@img/sharp-libvips-linux-s390x": "1.2.4"
+ }
+ },
+ "node_modules/@img/sharp-linux-x64": {
+ "version": "0.34.5",
+ "resolved": "https://registry.npmjs.org/@img/sharp-linux-x64/-/sharp-linux-x64-0.34.5.tgz",
+ "integrity": "sha512-MEzd8HPKxVxVenwAa+JRPwEC7QFjoPWuS5NZnBt6B3pu7EG2Ge0id1oLHZpPJdn3OQK+BQDiw9zStiHBTJQQQQ==",
+ "cpu": [
+ "x64"
+ ],
+ "libc": [
+ "glibc"
+ ],
+ "license": "Apache-2.0",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": "^18.17.0 || ^20.3.0 || >=21.0.0"
+ },
+ "funding": {
+ "url": "https://opencollective.com/libvips"
+ },
+ "optionalDependencies": {
+ "@img/sharp-libvips-linux-x64": "1.2.4"
+ }
+ },
+ "node_modules/@img/sharp-linuxmusl-arm64": {
+ "version": "0.34.5",
+ "resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-arm64/-/sharp-linuxmusl-arm64-0.34.5.tgz",
+ "integrity": "sha512-fprJR6GtRsMt6Kyfq44IsChVZeGN97gTD331weR1ex1c1rypDEABN6Tm2xa1wE6lYb5DdEnk03NZPqA7Id21yg==",
+ "cpu": [
+ "arm64"
+ ],
+ "libc": [
+ "musl"
+ ],
+ "license": "Apache-2.0",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": "^18.17.0 || ^20.3.0 || >=21.0.0"
+ },
+ "funding": {
+ "url": "https://opencollective.com/libvips"
+ },
+ "optionalDependencies": {
+ "@img/sharp-libvips-linuxmusl-arm64": "1.2.4"
+ }
+ },
+ "node_modules/@img/sharp-linuxmusl-x64": {
+ "version": "0.34.5",
+ "resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-x64/-/sharp-linuxmusl-x64-0.34.5.tgz",
+ "integrity": "sha512-Jg8wNT1MUzIvhBFxViqrEhWDGzqymo3sV7z7ZsaWbZNDLXRJZoRGrjulp60YYtV4wfY8VIKcWidjojlLcWrd8Q==",
+ "cpu": [
+ "x64"
+ ],
+ "libc": [
+ "musl"
+ ],
+ "license": "Apache-2.0",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": "^18.17.0 || ^20.3.0 || >=21.0.0"
+ },
+ "funding": {
+ "url": "https://opencollective.com/libvips"
+ },
+ "optionalDependencies": {
+ "@img/sharp-libvips-linuxmusl-x64": "1.2.4"
+ }
+ },
+ "node_modules/@img/sharp-wasm32": {
+ "version": "0.34.5",
+ "resolved": "https://registry.npmjs.org/@img/sharp-wasm32/-/sharp-wasm32-0.34.5.tgz",
+ "integrity": "sha512-OdWTEiVkY2PHwqkbBI8frFxQQFekHaSSkUIJkwzclWZe64O1X4UlUjqqqLaPbUpMOQk6FBu/HtlGXNblIs0huw==",
+ "cpu": [
+ "wasm32"
+ ],
+ "license": "Apache-2.0 AND LGPL-3.0-or-later AND MIT",
+ "optional": true,
+ "dependencies": {
+ "@emnapi/runtime": "^1.7.0"
+ },
+ "engines": {
+ "node": "^18.17.0 || ^20.3.0 || >=21.0.0"
+ },
+ "funding": {
+ "url": "https://opencollective.com/libvips"
+ }
+ },
+ "node_modules/@img/sharp-win32-arm64": {
+ "version": "0.34.5",
+ "resolved": "https://registry.npmjs.org/@img/sharp-win32-arm64/-/sharp-win32-arm64-0.34.5.tgz",
+ "integrity": "sha512-WQ3AgWCWYSb2yt+IG8mnC6Jdk9Whs7O0gxphblsLvdhSpSTtmu69ZG1Gkb6NuvxsNACwiPV6cNSZNzt0KPsw7g==",
+ "cpu": [
+ "arm64"
+ ],
+ "license": "Apache-2.0 AND LGPL-3.0-or-later",
+ "optional": true,
+ "os": [
+ "win32"
+ ],
+ "engines": {
+ "node": "^18.17.0 || ^20.3.0 || >=21.0.0"
+ },
+ "funding": {
+ "url": "https://opencollective.com/libvips"
+ }
+ },
+ "node_modules/@img/sharp-win32-ia32": {
+ "version": "0.34.5",
+ "resolved": "https://registry.npmjs.org/@img/sharp-win32-ia32/-/sharp-win32-ia32-0.34.5.tgz",
+ "integrity": "sha512-FV9m/7NmeCmSHDD5j4+4pNI8Cp3aW+JvLoXcTUo0IqyjSfAZJ8dIUmijx1qaJsIiU+Hosw6xM5KijAWRJCSgNg==",
+ "cpu": [
+ "ia32"
+ ],
+ "license": "Apache-2.0 AND LGPL-3.0-or-later",
+ "optional": true,
+ "os": [
+ "win32"
+ ],
+ "engines": {
+ "node": "^18.17.0 || ^20.3.0 || >=21.0.0"
+ },
+ "funding": {
+ "url": "https://opencollective.com/libvips"
+ }
+ },
+ "node_modules/@img/sharp-win32-x64": {
+ "version": "0.34.5",
+ "resolved": "https://registry.npmjs.org/@img/sharp-win32-x64/-/sharp-win32-x64-0.34.5.tgz",
+ "integrity": "sha512-+29YMsqY2/9eFEiW93eqWnuLcWcufowXewwSNIT6UwZdUUCrM3oFjMWH/Z6/TMmb4hlFenmfAVbpWeup2jryCw==",
+ "cpu": [
+ "x64"
+ ],
+ "license": "Apache-2.0 AND LGPL-3.0-or-later",
+ "optional": true,
+ "os": [
+ "win32"
+ ],
+ "engines": {
+ "node": "^18.17.0 || ^20.3.0 || >=21.0.0"
+ },
+ "funding": {
+ "url": "https://opencollective.com/libvips"
+ }
+ },
+ "node_modules/@isaacs/fs-minipass": {
+ "version": "4.0.1",
+ "resolved": "https://registry.npmjs.org/@isaacs/fs-minipass/-/fs-minipass-4.0.1.tgz",
+ "integrity": "sha512-wgm9Ehl2jpeqP3zw/7mo3kRHFp5MEDhqAdwy1fTGkHAwnkGOVsgpvQhL8B5n1qlb01jV3n/bI0ZfZp5lWA1k4w==",
+ "license": "ISC",
+ "dependencies": {
+ "minipass": "^7.0.4"
+ },
+ "engines": {
+ "node": ">=18.0.0"
+ }
+ },
+ "node_modules/@protobufjs/aspromise": {
+ "version": "1.1.2",
+ "resolved": "https://registry.npmjs.org/@protobufjs/aspromise/-/aspromise-1.1.2.tgz",
+ "integrity": "sha512-j+gKExEuLmKwvz3OgROXtrJ2UG2x8Ch2YZUxahh+s1F2HZ+wAceUNLkvy6zKCPVRkU++ZWQrdxsUeQXmcg4uoQ==",
+ "license": "BSD-3-Clause"
+ },
+ "node_modules/@protobufjs/base64": {
+ "version": "1.1.2",
+ "resolved": "https://registry.npmjs.org/@protobufjs/base64/-/base64-1.1.2.tgz",
+ "integrity": "sha512-AZkcAA5vnN/v4PDqKyMR5lx7hZttPDgClv83E//FMNhR2TMcLUhfRUBHCmSl0oi9zMgDDqRUJkSxO3wm85+XLg==",
+ "license": "BSD-3-Clause"
+ },
+ "node_modules/@protobufjs/codegen": {
+ "version": "2.0.5",
+ "resolved": "https://registry.npmjs.org/@protobufjs/codegen/-/codegen-2.0.5.tgz",
+ "integrity": "sha512-zgXFLzW3Ap33e6d0Wlj4MGIm6Ce8O89n/apUaGNB/jx+hw+ruWEp7EwGUshdLKVRCxZW12fp9r40E1mQrf/34g==",
+ "license": "BSD-3-Clause"
+ },
+ "node_modules/@protobufjs/eventemitter": {
+ "version": "1.1.1",
+ "resolved": "https://registry.npmjs.org/@protobufjs/eventemitter/-/eventemitter-1.1.1.tgz",
+ "integrity": "sha512-vW1GmwMZNnL+gMRaovlh9yZX74kc+TTU3FObkkurpMaRtBfLP3ldjS9KQWlwZgraRE0+dheEEoAxdzcJQ8eXZg==",
+ "license": "BSD-3-Clause"
+ },
+ "node_modules/@protobufjs/fetch": {
+ "version": "1.1.1",
+ "resolved": "https://registry.npmjs.org/@protobufjs/fetch/-/fetch-1.1.1.tgz",
+ "integrity": "sha512-GpptLrs57adMSuHi3VNj0mAF8dwh36LMaYF6XyJ6JMWlVsc+t42tm1HSEDmOs3A8fC9yyeisgLhsTVQokOZ0zw==",
+ "license": "BSD-3-Clause",
+ "dependencies": {
+ "@protobufjs/aspromise": "^1.1.1"
+ }
+ },
+ "node_modules/@protobufjs/float": {
+ "version": "1.0.2",
+ "resolved": "https://registry.npmjs.org/@protobufjs/float/-/float-1.0.2.tgz",
+ "integrity": "sha512-Ddb+kVXlXst9d+R9PfTIxh1EdNkgoRe5tOX6t01f1lYWOvJnSPDBlG241QLzcyPdoNTsblLUdujGSE4RzrTZGQ==",
+ "license": "BSD-3-Clause"
+ },
+ "node_modules/@protobufjs/path": {
+ "version": "1.1.2",
+ "resolved": "https://registry.npmjs.org/@protobufjs/path/-/path-1.1.2.tgz",
+ "integrity": "sha512-6JOcJ5Tm08dOHAbdR3GrvP+yUUfkjG5ePsHYczMFLq3ZmMkAD98cDgcT2iA1lJ9NVwFd4tH/iSSoe44YWkltEA==",
+ "license": "BSD-3-Clause"
+ },
+ "node_modules/@protobufjs/pool": {
+ "version": "1.1.0",
+ "resolved": "https://registry.npmjs.org/@protobufjs/pool/-/pool-1.1.0.tgz",
+ "integrity": "sha512-0kELaGSIDBKvcgS4zkjz1PeddatrjYcmMWOlAuAPwAeccUrPHdUqo/J6LiymHHEiJT5NrF1UVwxY14f+fy4WQw==",
+ "license": "BSD-3-Clause"
+ },
+ "node_modules/@protobufjs/utf8": {
+ "version": "1.1.2",
+ "resolved": "https://registry.npmjs.org/@protobufjs/utf8/-/utf8-1.1.2.tgz",
+ "integrity": "sha512-b1UQwcEZ4yCnMCD8DAL1VlbvBJE9/IX4FTIp7BG1xYpf29SLazLSrqUkj4w7Y5y7cCVP6E5tcqqcI0xemPkHug==",
+ "license": "BSD-3-Clause"
+ },
+ "node_modules/@types/node": {
+ "version": "26.6.4",
+ "resolved": "https://registry.npmjs.org/@types/node/-/node-26.6.4.tgz",
+ "integrity": "sha512-ldVPDCzj7fsaGZrLB0NuHuTvJcsNasysBAqMolr/cgxrLd1xbqxIr3XJiPnHHJUCxj5sNF1vnRj9aWnrVh5Jcg==",
+ "license": "MIT",
+ "dependencies": {
+ "undici-types": "~8.9.0"
+ }
+ },
+ "node_modules/boolean": {
+ "version": "3.2.0",
+ "resolved": "https://registry.npmjs.org/boolean/-/boolean-3.2.0.tgz",
+ "integrity": "sha512-d0II/GO9uf9lfUHH2BQsjxzRJZBdsjgsBiW4BvhWk/3qoKwQFjIDVN19PfX8F2D/r9PCMTtLWjYVCFrpeYUzsw==",
+ "deprecated": "Package no longer supported. Contact Support at https://www.npmjs.com/support for more info.",
+ "license": "MIT"
+ },
+ "node_modules/chownr": {
+ "version": "3.0.0",
+ "resolved": "https://registry.npmjs.org/chownr/-/chownr-3.0.0.tgz",
+ "integrity": "sha512-+IxzY9BZOQd/XuYPRmrvEVjF/nqj5kgT4kEq7VofrDoM1MxoRjEWkrCC3EtLi59TVawxTAn+orJwFQcrqEN1+g==",
+ "license": "BlueOak-1.0.0",
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/define-data-property": {
+ "version": "1.1.4",
+ "resolved": "https://registry.npmjs.org/define-data-property/-/define-data-property-1.1.4.tgz",
+ "integrity": "sha512-rBMvIzlpA8v6E+SJZoo++HAYqsLrkg7MSfIinMPFhmkorw7X+dOXVJQs+QT69zGkzMyfDnIMN2Wid1+NbL3T+A==",
+ "license": "MIT",
+ "dependencies": {
+ "es-define-property": "^1.0.0",
+ "es-errors": "^1.3.0",
+ "gopd": "^1.0.1"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/define-properties": {
+ "version": "1.2.1",
+ "resolved": "https://registry.npmjs.org/define-properties/-/define-properties-1.2.1.tgz",
+ "integrity": "sha512-8QmQKqEASLd5nx0U1B1okLElbUuuttJ/AnYmRXbbbGDWh6uS208EjD4Xqq/I9wK7u0v6O08XhTWnt5XtEbR6Dg==",
+ "license": "MIT",
+ "dependencies": {
+ "define-data-property": "^1.0.1",
+ "has-property-descriptors": "^1.0.0",
+ "object-keys": "^1.1.1"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/detect-libc": {
+ "version": "2.1.2",
+ "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz",
+ "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==",
+ "license": "Apache-2.0",
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/detect-node": {
+ "version": "2.1.0",
+ "resolved": "https://registry.npmjs.org/detect-node/-/detect-node-2.1.0.tgz",
+ "integrity": "sha512-T0NIuQpnTvFDATNuHN5roPwSBG83rFsuO+MXXH9/3N1eFbn4wcPjttvjMLEPWJ0RGUYgQE7cGgS3tNxbqCGM7g==",
+ "license": "MIT"
+ },
+ "node_modules/es-define-property": {
+ "version": "1.0.1",
+ "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz",
+ "integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.4"
+ }
+ },
+ "node_modules/es-errors": {
+ "version": "1.3.0",
+ "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz",
+ "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.4"
+ }
+ },
+ "node_modules/es6-error": {
+ "version": "4.1.1",
+ "resolved": "https://registry.npmjs.org/es6-error/-/es6-error-4.1.1.tgz",
+ "integrity": "sha512-Um/+FxMr9CISWh0bi5Zv0iOD+4cFh5qLeks1qhAopKVAJw3drgKbKySikp7wGhDL0HPeaja0P5ULZrxLkniUVg==",
+ "license": "MIT"
+ },
+ "node_modules/escape-string-regexp": {
+ "version": "4.0.0",
+ "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz",
+ "integrity": "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=10"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
+ "node_modules/flatbuffers": {
+ "version": "25.9.23",
+ "resolved": "https://registry.npmjs.org/flatbuffers/-/flatbuffers-25.9.23.tgz",
+ "integrity": "sha512-MI1qs7Lo4Syw0EOzUl0xjs2lsoeqFku44KpngfIduHBYvzm8h2+7K8YMQh1JtVVVrUvhLpNwqVi4DERegUJhPQ==",
+ "license": "Apache-2.0"
+ },
+ "node_modules/global-agent": {
+ "version": "3.0.0",
+ "resolved": "https://registry.npmjs.org/global-agent/-/global-agent-3.0.0.tgz",
+ "integrity": "sha512-PT6XReJ+D07JvGoxQMkT6qji/jVNfX/h364XHZOWeRzy64sSFr+xJ5OX7LI3b4MPQzdL4H8Y8M0xzPpsVMwA8Q==",
+ "license": "BSD-3-Clause",
+ "dependencies": {
+ "boolean": "^3.0.1",
+ "es6-error": "^4.1.1",
+ "matcher": "^3.0.0",
+ "roarr": "^2.15.3",
+ "semver": "^7.3.2",
+ "serialize-error": "^7.0.1"
+ },
+ "engines": {
+ "node": ">=10.0"
+ }
+ },
+ "node_modules/globalthis": {
+ "version": "1.0.4",
+ "resolved": "https://registry.npmjs.org/globalthis/-/globalthis-1.0.4.tgz",
+ "integrity": "sha512-DpLKbNU4WylpxJykQujfCcwYWiV/Jhm50Goo0wrVILAv5jOr9d+H+UR3PhSCD2rCCEIg0uc+G+muBTwD54JhDQ==",
+ "license": "MIT",
+ "dependencies": {
+ "define-properties": "^1.2.1",
+ "gopd": "^1.0.1"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/gopd": {
+ "version": "1.2.0",
+ "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz",
+ "integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/guid-typescript": {
+ "version": "1.0.9",
+ "resolved": "https://registry.npmjs.org/guid-typescript/-/guid-typescript-1.0.9.tgz",
+ "integrity": "sha512-Y8T4vYhEfwJOTbouREvG+3XDsjr8E3kIr7uf+JZ0BYloFsttiHU0WfvANVsR7TxNUJa/WpCnw/Ino/p+DeBhBQ==",
+ "license": "ISC"
+ },
+ "node_modules/has-property-descriptors": {
+ "version": "1.0.2",
+ "resolved": "https://registry.npmjs.org/has-property-descriptors/-/has-property-descriptors-1.0.2.tgz",
+ "integrity": "sha512-55JNKuIW+vq4Ke1BjOTjM2YctQIvCT7GFzHwmfZPGo5wnrgkid0YQtnAleFSqumZm4az3n2BS+erby5ipJdgrg==",
+ "license": "MIT",
+ "dependencies": {
+ "es-define-property": "^1.0.0"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/json-stringify-safe": {
+ "version": "5.0.1",
+ "resolved": "https://registry.npmjs.org/json-stringify-safe/-/json-stringify-safe-5.0.1.tgz",
+ "integrity": "sha512-ZClg6AaYvamvYEE82d3Iyd3vSSIjQ+odgjaTzRuO3s7toCdFKczob2i0zCh7JE8kWn17yvAWhUVxvqGwUalsRA==",
+ "license": "ISC"
+ },
+ "node_modules/kokoro-js": {
+ "version": "1.2.1",
+ "resolved": "https://registry.npmjs.org/kokoro-js/-/kokoro-js-1.2.1.tgz",
+ "integrity": "sha512-oq0HZJWis3t8lERkMJh84WLU86dpYD0EuBPtqYnLlQzyFP1OkyBRDcweAqCfhNOpltyN9j/azp1H6uuC47gShw==",
+ "license": "Apache-2.0",
+ "dependencies": {
+ "@huggingface/transformers": "^3.5.1",
+ "phonemizer": "^1.2.1"
+ }
+ },
+ "node_modules/long": {
+ "version": "5.3.2",
+ "resolved": "https://registry.npmjs.org/long/-/long-5.3.2.tgz",
+ "integrity": "sha512-mNAgZ1GmyNhD7AuqnTG3/VQ26o760+ZYBPKjPvugO8+nLbYfX6TVpJPseBvopbdY+qpZ/lKUnmEc1LeZYS3QAA==",
+ "license": "Apache-2.0"
+ },
+ "node_modules/matcher": {
+ "version": "3.0.0",
+ "resolved": "https://registry.npmjs.org/matcher/-/matcher-3.0.0.tgz",
+ "integrity": "sha512-OkeDaAZ/bQCxeFAozM55PKcKU0yJMPGifLwV4Qgjitu+5MoAfSQN4lsLJeXZ1b8w0x+/Emda6MZgXS1jvsapng==",
+ "license": "MIT",
+ "dependencies": {
+ "escape-string-regexp": "^4.0.0"
+ },
+ "engines": {
+ "node": ">=10"
+ }
+ },
+ "node_modules/minipass": {
+ "version": "7.1.3",
+ "resolved": "https://registry.npmjs.org/minipass/-/minipass-7.1.3.tgz",
+ "integrity": "sha512-tEBHqDnIoM/1rXME1zgka9g6Q2lcoCkxHLuc7ODJ5BxbP5d4c2Z5cGgtXAku59200Cx7diuHTOYfSBD8n6mm8A==",
+ "license": "BlueOak-1.0.0",
+ "engines": {
+ "node": ">=16 || 14 >=14.17"
+ }
+ },
+ "node_modules/minizlib": {
+ "version": "3.1.0",
+ "resolved": "https://registry.npmjs.org/minizlib/-/minizlib-3.1.0.tgz",
+ "integrity": "sha512-KZxYo1BUkWD2TVFLr0MQoM8vUUigWD3LlD83a/75BqC+4qE0Hb1Vo5v1FgcfaNXvfXzr+5EhQ6ing/CaBijTlw==",
+ "license": "MIT",
+ "dependencies": {
+ "minipass": "^7.1.2"
+ },
+ "engines": {
+ "node": ">= 18"
+ }
+ },
+ "node_modules/object-keys": {
+ "version": "1.1.1",
+ "resolved": "https://registry.npmjs.org/object-keys/-/object-keys-1.1.1.tgz",
+ "integrity": "sha512-NuAESUOUMrlIXOfHKzD6bpPu3tYt3xvjNdRIQ+FeT0lNb4K8WR70CaDxhuNguS2XG+GjkyMwOzsN5ZktImfhLA==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.4"
+ }
+ },
+ "node_modules/onnxruntime-common": {
+ "version": "1.21.0",
+ "resolved": "https://registry.npmjs.org/onnxruntime-common/-/onnxruntime-common-1.21.0.tgz",
+ "integrity": "sha512-Q632iLLrtCAVOTO65dh2+mNbQir/QNTVBG3h/QdZBpns7mZ0RYbLRBgGABPbpU9351AgYy7SJf1WaeVwMrBFPQ==",
+ "license": "MIT"
+ },
+ "node_modules/onnxruntime-node": {
+ "version": "1.21.0",
+ "resolved": "https://registry.npmjs.org/onnxruntime-node/-/onnxruntime-node-1.21.0.tgz",
+ "integrity": "sha512-NeaCX6WW2L8cRCSqy3bInlo5ojjQqu2fD3D+9W5qb5irwxhEyWKXeH2vZ8W9r6VxaMPUan+4/7NDwZMtouZxEw==",
+ "hasInstallScript": true,
+ "license": "MIT",
+ "os": [
+ "win32",
+ "darwin",
+ "linux"
+ ],
+ "dependencies": {
+ "global-agent": "^3.0.0",
+ "onnxruntime-common": "1.21.0",
+ "tar": "^7.0.1"
+ }
+ },
+ "node_modules/onnxruntime-web": {
+ "version": "1.22.0-dev.20250409-89f8206ba4",
+ "resolved": "https://registry.npmjs.org/onnxruntime-web/-/onnxruntime-web-1.22.0-dev.20250409-89f8206ba4.tgz",
+ "integrity": "sha512-0uS76OPgH0hWCPrFKlL8kYVV7ckM7t/36HfbgoFw6Nd0CZVVbQC4PkrR8mBX8LtNUFZO25IQBqV2Hx2ho3FlbQ==",
+ "license": "MIT",
+ "dependencies": {
+ "flatbuffers": "^25.1.24",
+ "guid-typescript": "^1.0.9",
+ "long": "^5.2.3",
+ "onnxruntime-common": "1.22.0-dev.20250409-89f8206ba4",
+ "platform": "^1.3.6",
+ "protobufjs": "^7.2.4"
+ }
+ },
+ "node_modules/onnxruntime-web/node_modules/onnxruntime-common": {
+ "version": "1.22.0-dev.20250409-89f8206ba4",
+ "resolved": "https://registry.npmjs.org/onnxruntime-common/-/onnxruntime-common-1.22.0-dev.20250409-89f8206ba4.tgz",
+ "integrity": "sha512-vDJMkfCfb0b1A836rgHj+ORuZf4B4+cc2bASQtpeoJLueuFc5DuYwjIZUBrSvx/fO5IrLjLz+oTrB3pcGlhovQ==",
+ "license": "MIT"
+ },
+ "node_modules/phonemizer": {
+ "version": "1.2.1",
+ "resolved": "https://registry.npmjs.org/phonemizer/-/phonemizer-1.2.1.tgz",
+ "integrity": "sha512-v0KJ4mi2T4Q7eJQ0W15Xd4G9k4kICSXE8bpDeJ8jisL4RyJhNWsweKTOi88QXFc4r4LZlz5jVL5lCHhkpdT71A==",
+ "license": "Apache-2.0"
+ },
+ "node_modules/platform": {
+ "version": "1.3.6",
+ "resolved": "https://registry.npmjs.org/platform/-/platform-1.3.6.tgz",
+ "integrity": "sha512-fnWVljUchTro6RiCFvCXBbNhJc2NijN7oIQxbwsyL0buWJPG85v81ehlHI9fXrJsMNgTofEoWIQeClKpgxFLrg==",
+ "license": "MIT"
+ },
+ "node_modules/protobufjs": {
+ "version": "7.6.6",
+ "resolved": "https://registry.npmjs.org/protobufjs/-/protobufjs-7.6.6.tgz",
+ "integrity": "sha512-dYDWdjSl5RNb7SgPxGQcRU+GtvP7s2fpkrY0r432PcOIaZ0/rBcxEZnQN67iJhFuQiVw754JDoPruPCNdGsbjg==",
+ "hasInstallScript": true,
+ "license": "BSD-3-Clause",
+ "dependencies": {
+ "@protobufjs/aspromise": "^1.1.2",
+ "@protobufjs/base64": "^1.1.2",
+ "@protobufjs/codegen": "^2.0.5",
+ "@protobufjs/eventemitter": "^1.1.1",
+ "@protobufjs/fetch": "^1.1.1",
+ "@protobufjs/float": "^1.0.2",
+ "@protobufjs/path": "^1.1.2",
+ "@protobufjs/pool": "^1.1.0",
+ "@protobufjs/utf8": "^1.1.1",
+ "@types/node": ">=13.7.0",
+ "long": "^5.3.2"
+ },
+ "engines": {
+ "node": ">=12.0.0"
+ }
+ },
+ "node_modules/roarr": {
+ "version": "2.15.4",
+ "resolved": "https://registry.npmjs.org/roarr/-/roarr-2.15.4.tgz",
+ "integrity": "sha512-CHhPh+UNHD2GTXNYhPWLnU8ONHdI+5DI+4EYIAOaiD63rHeYlZvyh8P+in5999TTSFgUYuKUAjzRI4mdh/p+2A==",
+ "license": "BSD-3-Clause",
+ "dependencies": {
+ "boolean": "^3.0.1",
+ "detect-node": "^2.0.4",
+ "globalthis": "^1.0.1",
+ "json-stringify-safe": "^5.0.1",
+ "semver-compare": "^1.0.0",
+ "sprintf-js": "^1.1.2"
+ },
+ "engines": {
+ "node": ">=8.0"
+ }
+ },
+ "node_modules/semver": {
+ "version": "7.8.5",
+ "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.5.tgz",
+ "integrity": "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==",
+ "license": "ISC",
+ "bin": {
+ "semver": "bin/semver.js"
+ },
+ "engines": {
+ "node": ">=10"
+ }
+ },
+ "node_modules/semver-compare": {
+ "version": "1.0.0",
+ "resolved": "https://registry.npmjs.org/semver-compare/-/semver-compare-1.0.0.tgz",
+ "integrity": "sha512-YM3/ITh2MJ5MtzaM429anh+x2jiLVjqILF4m4oyQB18W7Ggea7BfqdH/wGMK7dDiMghv/6WG7znWMwUDzJiXow==",
+ "license": "MIT"
+ },
+ "node_modules/serialize-error": {
+ "version": "7.0.1",
+ "resolved": "https://registry.npmjs.org/serialize-error/-/serialize-error-7.0.1.tgz",
+ "integrity": "sha512-8I8TjW5KMOKsZQTvoxjuSIa7foAwPWGOts+6o7sgjz41/qMD9VQHEDxi6PBvK2l0MXUmqZyNpUK+T2tQaaElvw==",
+ "license": "MIT",
+ "dependencies": {
+ "type-fest": "^0.13.1"
+ },
+ "engines": {
+ "node": ">=10"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
+ "node_modules/sharp": {
+ "version": "0.34.5",
+ "resolved": "https://registry.npmjs.org/sharp/-/sharp-0.34.5.tgz",
+ "integrity": "sha512-Ou9I5Ft9WNcCbXrU9cMgPBcCK8LiwLqcbywW3t4oDV37n1pzpuNLsYiAV8eODnjbtQlSDwZ2cUEeQz4E54Hltg==",
+ "hasInstallScript": true,
+ "license": "Apache-2.0",
+ "dependencies": {
+ "@img/colour": "^1.0.0",
+ "detect-libc": "^2.1.2",
+ "semver": "^7.7.3"
+ },
+ "engines": {
+ "node": "^18.17.0 || ^20.3.0 || >=21.0.0"
+ },
+ "funding": {
+ "url": "https://opencollective.com/libvips"
+ },
+ "optionalDependencies": {
+ "@img/sharp-darwin-arm64": "0.34.5",
+ "@img/sharp-darwin-x64": "0.34.5",
+ "@img/sharp-libvips-darwin-arm64": "1.2.4",
+ "@img/sharp-libvips-darwin-x64": "1.2.4",
+ "@img/sharp-libvips-linux-arm": "1.2.4",
+ "@img/sharp-libvips-linux-arm64": "1.2.4",
+ "@img/sharp-libvips-linux-ppc64": "1.2.4",
+ "@img/sharp-libvips-linux-riscv64": "1.2.4",
+ "@img/sharp-libvips-linux-s390x": "1.2.4",
+ "@img/sharp-libvips-linux-x64": "1.2.4",
+ "@img/sharp-libvips-linuxmusl-arm64": "1.2.4",
+ "@img/sharp-libvips-linuxmusl-x64": "1.2.4",
+ "@img/sharp-linux-arm": "0.34.5",
+ "@img/sharp-linux-arm64": "0.34.5",
+ "@img/sharp-linux-ppc64": "0.34.5",
+ "@img/sharp-linux-riscv64": "0.34.5",
+ "@img/sharp-linux-s390x": "0.34.5",
+ "@img/sharp-linux-x64": "0.34.5",
+ "@img/sharp-linuxmusl-arm64": "0.34.5",
+ "@img/sharp-linuxmusl-x64": "0.34.5",
+ "@img/sharp-wasm32": "0.34.5",
+ "@img/sharp-win32-arm64": "0.34.5",
+ "@img/sharp-win32-ia32": "0.34.5",
+ "@img/sharp-win32-x64": "0.34.5"
+ }
+ },
+ "node_modules/sprintf-js": {
+ "version": "1.1.3",
+ "resolved": "https://registry.npmjs.org/sprintf-js/-/sprintf-js-1.1.3.tgz",
+ "integrity": "sha512-Oo+0REFV59/rz3gfJNKQiBlwfHaSESl1pcGyABQsnnIfWOFt6JNj5gCog2U6MLZ//IGYD+nA8nI+mTShREReaA==",
+ "license": "BSD-3-Clause"
+ },
+ "node_modules/tar": {
+ "version": "7.5.22",
+ "resolved": "https://registry.npmjs.org/tar/-/tar-7.5.22.tgz",
+ "integrity": "sha512-MFO/QzvtAOmJbkhOaCTvbGcFN9L9b+JunIsDwaKljSOdcLMea3NJ1k9Usz/rjdfSXTq4dfzfeS7W4p4YOAAHeA==",
+ "license": "BlueOak-1.0.0",
+ "dependencies": {
+ "@isaacs/fs-minipass": "^4.0.0",
+ "chownr": "^3.0.0",
+ "minipass": "^7.1.2",
+ "minizlib": "^3.1.0",
+ "yallist": "^5.0.0"
+ },
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/tslib": {
+ "version": "2.8.1",
+ "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz",
+ "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==",
+ "license": "0BSD",
+ "optional": true
+ },
+ "node_modules/type-fest": {
+ "version": "0.13.1",
+ "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-0.13.1.tgz",
+ "integrity": "sha512-34R7HTnG0XIJcBSn5XhDd7nNFPRcXYRZrBB2O2jdKqYODldSzBAqzsWoZYYvduky73toYS/ESqxPvkDf/F0XMg==",
+ "license": "(MIT OR CC0-1.0)",
+ "engines": {
+ "node": ">=10"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
+ "node_modules/undici-types": {
+ "version": "8.9.0",
+ "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-8.9.0.tgz",
+ "integrity": "sha512-KTDyRTYX8sWmKXAikPHHSyc63CRPETMctyjKFupcC6OBLXT3xsN0e9aF7m+mIXutFWpUXuedtowG7iLOzp0kQg==",
+ "license": "MIT"
+ },
+ "node_modules/yallist": {
+ "version": "5.0.0",
+ "resolved": "https://registry.npmjs.org/yallist/-/yallist-5.0.0.tgz",
+ "integrity": "sha512-YgvUTfwqyc7UXVMrB+SImsVYSmTS8X/tSrtdNZMImM+n7+QTriRXyXim0mBrTXNeqzVF0KWGgHPeiyViFFrNDw==",
+ "license": "BlueOak-1.0.0",
+ "engines": {
+ "node": ">=18"
+ }
+ }
+ }
+}
diff --git a/orc/lib/explain/package.json b/orc/lib/explain/package.json
new file mode 100644
index 0000000..fb9f908
--- /dev/null
+++ b/orc/lib/explain/package.json
@@ -0,0 +1,9 @@
+{
+ "name": "orc-explain",
+ "private": true,
+ "type": "module",
+ "description": "Local narration for /orc:explain (kokoro-js, no API key). Installed by orc-explain setup into the user cache, never into a repo.",
+ "dependencies": {
+ "kokoro-js": "1.2.1"
+ }
+}
diff --git a/orc/lib/playwright-env.sh b/orc/lib/playwright-env.sh
new file mode 100755
index 0000000..bd3297b
--- /dev/null
+++ b/orc/lib/playwright-env.sh
@@ -0,0 +1,96 @@
+#!/usr/bin/env bash
+# playwright-env.sh — detect/scaffold the committed Playwright project Driver P
+# runs, install it, run Playwright's init-agents, and merge the playwright-test
+# MCP server into .mcp.json without clobbering. Behind bin/orc-playwright.
+# Sourced-library contract: never changes caller shell options.
+
+ORC_PW_DEFAULT_DIR="e2e"
+
+orc_pw__backup() { # → copies to /backups/., prints the path (never the repo root: .mcp.json may hold tokens)
+ local root="$1" f="$2" dir out
+ dir="$root/.orc/backups" # the target repo's own gitignored .orc/, never the caller's state dir
+ mkdir -p "$dir"
+ out="$dir/$(basename "$f").$(date -u +%Y%m%dT%H%M%SZ)"
+ cp "$f" "$out" && printf '%s\n' "$out"
+}
+
+orc_pw__tmpl_dir() { printf '%s/playwright\n' "$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"; }
+
+orc_pw_detect() { # → "\t" | exit 1
+ local root="${1:-.}" c
+ for c in playwright.config.ts playwright.config.mts playwright.config.js playwright.config.mjs; do
+ [ -f "$root/$c" ] && { printf '.\t%s\n' "$c"; return 0; }
+ done
+ for c in playwright.config.ts playwright.config.mts playwright.config.js playwright.config.mjs; do
+ [ -f "$root/$ORC_PW_DEFAULT_DIR/$c" ] && { printf '%s\t%s/%s\n' "$ORC_PW_DEFAULT_DIR" "$ORC_PW_DEFAULT_DIR" "$c"; return 0; }
+ done
+ return 1
+}
+
+orc_pw__put() { # → prints "created " | "kept "
+ local dest="$1" tmpl="$2" rel="$3"
+ if [ -e "$dest" ]; then printf 'kept %s\n' "$rel"; return 0; fi
+ mkdir -p "$(dirname "$dest")"
+ cp "$(orc_pw__tmpl_dir)/$tmpl" "$dest"
+ printf 'created %s\n' "$rel"
+}
+
+orc_pw_scaffold() { # [--dir e2e]
+ local root="${1:-.}" dir="$ORC_PW_DEFAULT_DIR"; shift || true
+ while [ $# -gt 0 ]; do case "$1" in --dir) dir="$2"; shift 2 ;; *) echo "orc-playwright: unknown argument $1" >&2; return 2 ;; esac; done
+ orc_pw__put "$root/$dir/package.json" package.json.tmpl "$dir/package.json"
+ orc_pw__put "$root/$dir/playwright.config.ts" playwright.config.ts.tmpl "$dir/playwright.config.ts"
+ orc_pw__put "$root/$dir/tests/fixtures.ts" fixtures.ts.tmpl "$dir/tests/fixtures.ts"
+ orc_pw__put "$root/$dir/tests/auth.setup.ts" auth.setup.ts.tmpl "$dir/tests/auth.setup.ts"
+ orc_pw__put "$root/$dir/tests/seed.spec.ts" seed.spec.ts.tmpl "$dir/tests/seed.spec.ts"
+ orc_pw__put "$root/$dir/.gitignore" gitignore.tmpl "$dir/.gitignore"
+ orc_pw__put "$root/$dir/specs/README.md" specs-README.md.tmpl "$dir/specs/README.md"
+}
+
+orc_pw_install() { # --dir D (runs ONLY after the caller's setup gate)
+ local root="${1:-.}" dir="$ORC_PW_DEFAULT_DIR"; shift || true
+ while [ $# -gt 0 ]; do case "$1" in --dir) dir="$2"; shift 2 ;; *) return 2 ;; esac; done
+ (cd "$root/$dir" && npm install --no-fund --no-audit && npx playwright install chromium)
+}
+
+orc_pw_init_agents() { # --dir D --config C (runs from repo root so .claude/agents lands there)
+ local root="${1:-.}" dir="$ORC_PW_DEFAULT_DIR" cfg=""; shift || true
+ while [ $# -gt 0 ]; do case "$1" in --dir) dir="$2"; shift 2 ;; --config) cfg="$2"; shift 2 ;; *) return 2 ;; esac; done
+ [ -n "$cfg" ] || { echo "orc-playwright: init-agents needs --config" >&2; return 2; }
+ local bak=""
+ if [ -f "$root/.mcp.json" ]; then bak="$(orc_pw__backup "$root" "$root/.mcp.json")"; fi
+ (cd "$root" && node "$dir/node_modules/playwright/cli.js" init-agents --loop=claude -c "$cfg")
+ # init-agents overwrote .mcp.json unconditionally; restore the user's servers then add ours.
+ if [ -n "$bak" ]; then cp "$bak" "$root/.mcp.json"; fi
+ orc_pw_mcp_merge "$root" --dir "$dir" --config "$cfg"
+}
+
+orc_pw_mcp_merge() { # --dir D --config C
+ local root="${1:-.}" dir="$ORC_PW_DEFAULT_DIR" cfg=""; shift || true
+ while [ $# -gt 0 ]; do case "$1" in --dir) dir="$2"; shift 2 ;; --config) cfg="$2"; shift 2 ;; *) return 2 ;; esac; done
+ [ -n "$cfg" ] || { echo "orc-playwright: mcp-merge needs --config" >&2; return 2; }
+ local f="$root/.mcp.json" cli
+ case "$dir" in .|"") cli="node_modules/playwright/cli.js" ;; *) cli="$dir/node_modules/playwright/cli.js" ;; esac
+ if [ -f "$f" ]; then
+ orc_pw__backup "$root" "$f" >/dev/null
+ else
+ printf '{"mcpServers":{}}\n' > "$f"
+ fi
+ jq --arg cli "$cli" --arg cfg "$cfg" '.mcpServers["playwright-test"] = {command: "node", args: [$cli, "run-test-mcp-server", "-c", $cfg]}' "$f" > "$f.tmp" && mv "$f.tmp" "$f"
+ printf '%s\n' "$f"
+}
+
+# CLI mode — active only when EXECUTED (via bin/orc-playwright), never when sourced.
+if [ "${BASH_SOURCE[0]:-}" = "${0:-}" ]; then
+ set -euo pipefail
+ sub="${1:-}"; shift 2>/dev/null || true
+ case "$sub" in
+ detect) orc_pw_detect "$@" ;;
+ scaffold) orc_pw_scaffold "$@" ;;
+ install) orc_pw_install "$@" ;;
+ init-agents) orc_pw_init_agents "$@" ;;
+ mcp-merge) orc_pw_mcp_merge "$@" ;;
+ --help|-h|help|'') printf 'usage: orc-playwright detect |scaffold [--dir D]|install --dir D|init-agents --dir D --config C|mcp-merge --dir D --config C\n' ;;
+ *) printf 'orc-playwright: unknown subcommand %s\n' "$sub" >&2; exit 2 ;;
+ esac
+fi
diff --git a/orc/lib/playwright/auth.setup.ts.tmpl b/orc/lib/playwright/auth.setup.ts.tmpl
new file mode 100644
index 0000000..49d0950
--- /dev/null
+++ b/orc/lib/playwright/auth.setup.ts.tmpl
@@ -0,0 +1,26 @@
+// Logs in once (video/trace OFF in the setup project — the password is never
+// on camera) and saves storageState for the qa/demo projects. With no users or
+// no login recipe on the target it writes an empty state and exits.
+import { test as setup, expect } from '@playwright/test';
+import * as fs from 'node:fs';
+import * as path from 'node:path';
+
+const target = JSON.parse(process.env.ORC_TARGET_JSON ?? '{"users":{},"login":null}');
+const outDir = process.env.ORC_PW_OUTPUT_DIR ?? path.join(__dirname, '..', 'test-results');
+const file = path.join(outDir, '.auth', 'user.json');
+
+setup('authenticate', async ({ page }) => {
+ fs.mkdirSync(path.dirname(file), { recursive: true });
+ const users = target.users ?? {};
+ const wanted = process.env.ORC_QA_USER;
+ if (wanted && !users[wanted]) throw new Error(`ORC_QA_USER=${wanted} is not a user on target ${target.name ?? '?'}`);
+ const user = wanted ? users[wanted] : Object.values(users)[0];
+ if (!user || !target.login) { await page.context().storageState({ path: file }); return; }
+ const l = target.login;
+ await page.goto(l.path);
+ await page.getByLabel(l.usernameField, { exact: false }).or(page.getByPlaceholder(l.usernameField, { exact: false })).first().fill(user.username);
+ await page.getByLabel(l.passwordField, { exact: false }).or(page.getByPlaceholder(l.passwordField, { exact: false })).first().fill(user.password);
+ await page.getByRole('button', { name: l.submit }).click();
+ await expect(page).toHaveURL(new RegExp(l.successPath.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')));
+ await page.context().storageState({ path: file });
+});
diff --git a/orc/lib/playwright/fixtures.ts.tmpl b/orc/lib/playwright/fixtures.ts.tmpl
new file mode 100644
index 0000000..5db211d
--- /dev/null
+++ b/orc/lib/playwright/fixtures.ts.tmpl
@@ -0,0 +1,15 @@
+// Console + page-error capture for every test; attached as console.log so
+// orc-qa-video can lift it into the evidence packet. Generated tests import
+// `test`/`expect` from here (the seed does, and the generator copies the seed's imports).
+import { test as base, expect } from '@playwright/test';
+
+export const test = base.extend({
+ page: async ({ page }, use, testInfo) => {
+ const lines: string[] = [];
+ page.on('console', (m) => lines.push(`[${m.type()}] ${m.text()}`));
+ page.on('pageerror', (e) => lines.push(`[pageerror] ${e.message}`));
+ await use(page);
+ await testInfo.attach('console.log', { body: lines.join('\n'), contentType: 'text/plain' });
+ },
+});
+export { expect };
diff --git a/orc/lib/playwright/gitignore.tmpl b/orc/lib/playwright/gitignore.tmpl
new file mode 100644
index 0000000..dbd64df
--- /dev/null
+++ b/orc/lib/playwright/gitignore.tmpl
@@ -0,0 +1,3 @@
+node_modules/
+test-results/
+playwright-report/
diff --git a/orc/lib/playwright/package.json.tmpl b/orc/lib/playwright/package.json.tmpl
new file mode 100644
index 0000000..ec7fa3d
--- /dev/null
+++ b/orc/lib/playwright/package.json.tmpl
@@ -0,0 +1,7 @@
+{
+ "name": "e2e",
+ "private": true,
+ "description": "Browser QA project managed by orc (/orc:qa). Specs in specs/, tests in tests/.",
+ "scripts": { "test": "playwright test --project=qa", "demo": "playwright test --project=demo" },
+ "devDependencies": { "@playwright/test": "^1.59.0" }
+}
diff --git a/orc/lib/playwright/playwright.config.ts.tmpl b/orc/lib/playwright/playwright.config.ts.tmpl
new file mode 100644
index 0000000..7a84cb2
--- /dev/null
+++ b/orc/lib/playwright/playwright.config.ts.tmpl
@@ -0,0 +1,55 @@
+// Managed by orc (/orc:qa Driver P). Target + credentials arrive at run time
+// via ORC_TARGET_JSON (orc-targets resolve ) — nothing secret lives here.
+import { defineConfig, devices } from '@playwright/test';
+import * as path from 'node:path';
+
+const target = JSON.parse(process.env.ORC_TARGET_JSON ?? '{"name":"local","baseUrl":"http://localhost:3000","users":{},"login":null}');
+const outDir = process.env.ORC_PW_OUTPUT_DIR ?? path.join(__dirname, 'test-results');
+const storageState = path.join(outDir, '.auth', 'user.json');
+
+export default defineConfig({
+ testDir: './tests',
+ outputDir: outDir,
+ fullyParallel: false,
+ workers: 1,
+ retries: 0,
+ reporter: [['list'], ['json', { outputFile: path.join(outDir, 'results.json') }]],
+ use: { baseURL: target.baseUrl ?? 'http://localhost:3000' },
+ projects: [
+ { name: 'setup', testMatch: /auth\.setup\.ts/, use: { video: 'off', trace: 'off', screenshot: 'off' } },
+ {
+ name: 'qa',
+ testIgnore: /auth\.setup\.ts/,
+ dependencies: ['setup'],
+ use: {
+ ...devices['Desktop Chrome'],
+ storageState,
+ trace: 'on',
+ screenshot: 'on',
+ video: {
+ mode: 'on',
+ size: { width: 1280, height: 720 },
+ show: {
+ actions: { duration: 700, position: 'bottom' },
+ test: { level: 'step', position: 'top-left' },
+ },
+ },
+ },
+ },
+ {
+ name: 'demo',
+ testIgnore: /auth\.setup\.ts/,
+ dependencies: ['setup'],
+ grep: /@golden/,
+ use: {
+ ...devices['Desktop Chrome'],
+ storageState,
+ viewport: { width: 1920, height: 1080 },
+ launchOptions: { slowMo: 300 },
+ trace: 'off',
+ screenshot: 'off',
+ video: { mode: 'on', size: { width: 1920, height: 1080 } },
+ },
+ },
+ ],
+});
diff --git a/orc/lib/playwright/seed.spec.ts.tmpl b/orc/lib/playwright/seed.spec.ts.tmpl
new file mode 100644
index 0000000..7727a93
--- /dev/null
+++ b/orc/lib/playwright/seed.spec.ts.tmpl
@@ -0,0 +1,9 @@
+// Seed test: the planner/generator run this to inherit fixtures + login state.
+import { test, expect } from './fixtures';
+
+test.describe('seed', () => {
+ test('seed', async ({ page }) => {
+ await page.goto('/');
+ await expect(page).toHaveURL(/./);
+ });
+});
diff --git a/orc/lib/playwright/specs-README.md.tmpl b/orc/lib/playwright/specs-README.md.tmpl
new file mode 100644
index 0000000..9e9f61a
--- /dev/null
+++ b/orc/lib/playwright/specs-README.md.tmpl
@@ -0,0 +1,3 @@
+# Specs
+
+Test plans written by the Playwright planner agent, one file per feature (`.md`). Reviewed at the /orc:qa plan gate; the generator turns each into tests//*.spec.ts.
diff --git a/orc/lib/pr-size-budget.sh b/orc/lib/pr-size-budget.sh
index fb71ffd..cb8fdf1 100644
--- a/orc/lib/pr-size-budget.sh
+++ b/orc/lib/pr-size-budget.sh
@@ -9,7 +9,7 @@
#
# Defines:
# ORC_PR_LOC_DEFAULT_BUDGET integer (default 300)
-# ORC_PR_LOC_EXCLUDES array of git pathspecs (lockfiles, generated, snapshots, builds)
+# ORC_PR_LOC_EXCLUDES array of git pathspecs (lockfiles, generated, snapshots, builds, e2e proof)
#
# Sourced-library contract: this file MUST NOT modify the caller's shell options
# (no `set -e`, no `set -u`, no `set -o pipefail`). All functions defend against
@@ -29,16 +29,18 @@ ORC_PR_LOC_DEFAULT_BUDGET=300
ORC_PR_LOC_EXCLUDES=(
':(exclude,glob)**/*.lock'
':(exclude,glob)**/*.lockb'
- ':(exclude)package-lock.json'
- ':(exclude)pnpm-lock.yaml'
- ':(exclude)yarn.lock'
- ':(exclude)Cargo.lock'
- ':(exclude)go.sum'
- ':(exclude)Gemfile.lock'
- ':(exclude)composer.lock'
- ':(exclude)poetry.lock'
- ':(exclude)uv.lock'
+ ':(exclude,glob)**/package-lock.json'
+ ':(exclude,glob)**/pnpm-lock.yaml'
+ ':(exclude,glob)**/yarn.lock'
+ ':(exclude,glob)**/Cargo.lock'
+ ':(exclude,glob)**/go.sum'
+ ':(exclude,glob)**/Gemfile.lock'
+ ':(exclude,glob)**/composer.lock'
+ ':(exclude,glob)**/poetry.lock'
+ ':(exclude,glob)**/uv.lock'
':(exclude,glob)**/__snapshots__/**'
+ ':(exclude,glob)e2e/specs/**'
+ ':(exclude,glob)e2e/tests/**'
':(exclude,glob)**/__generated__/**'
':(exclude,glob)**/generated/**'
':(exclude,glob)**/*.gen.ts'
@@ -206,6 +208,6 @@ orc_pr_excluded_summary() {
local excl_files=$((total_files - included_files))
local excl_loc=$((total_loc - included_loc))
- printf 'Excluded: %d files (%d LOC) — lockfiles, generated, snapshots, builds, migrations.\n' \
+ printf 'Excluded: %d files (%d LOC) — lockfiles, generated, snapshots, builds, migrations, e2e proof.\n' \
"$excl_files" "$excl_loc"
}
diff --git a/orc/lib/qa-video.sh b/orc/lib/qa-video.sh
new file mode 100755
index 0000000..7c52ed8
--- /dev/null
+++ b/orc/lib/qa-video.sh
@@ -0,0 +1,112 @@
+#!/usr/bin/env bash
+# shellcheck disable=SC2015,SC2016
+# qa-video.sh — turns a Playwright run into QA evidence: an ordered scenario
+# plan from results.json, per-test console/trace collection into the packet,
+# and the single stitched qa-.webm (title card + clip per scenario)
+# with chapters.json timestamps. Behind bin/orc-qa-video. Needs ffmpeg/ffprobe
+# only for `stitch` (exit 4 when absent — the caller says so, never installs).
+
+orc_qv__slug() { printf '%s' "$1" | tr '[:upper:]' '[:lower:]' | sed -E 's/[^a-z0-9]+/-/g; s/^-+|-+$//g'; }
+
+orc_qv_plan() { # --results R → JSON array [{id,title,file,outcome,video,console,trace}]
+ local results=""
+ while [ $# -gt 0 ]; do case "$1" in --results) results="$2"; shift 2 ;; *) echo "orc-qa-video: unknown argument $1" >&2; return 2 ;; esac; done
+ [ -f "$results" ] || { echo "orc-qa-video: results.json not found: $results" >&2; return 1; }
+ jq -c '
+ def specs: .specs[]?, (.suites[]? | specs);
+ [ .suites[] | specs
+ | . as $spec
+ | $spec.tests[] | select(.projectName != "setup")
+ | (.results | last) as $r
+ | { title: $spec.title, file: $spec.file, outcome: $r.status,
+ video: ([$r.attachments[]? | select(.name == "video") | .path] | first),
+ console: ([$r.attachments[]? | select(.name == "console.log") | .path] | first),
+ trace: ([$r.attachments[]? | select(.name == "trace") | .path] | first) } ]' "$results" \
+ | jq -c 'reduce .[] as $s ({seen: {}, out: []};
+ ($s.title | ascii_downcase | gsub("[^a-z0-9]+"; "-") | gsub("^-+|-+$"; "")) as $base
+ | (.seen[$base] // 0) as $k | .seen[$base] = $k + 1
+ | .out += [$s + {id: (if $k == 0 then $base else "\($base)-\($k + 1)" end)}]) | .out'
+}
+
+orc_qv_collect() { # --results R --qa-dir D → copies console-.log, trace-.zip
+ local results="" qa=""
+ while [ $# -gt 0 ]; do case "$1" in --results) results="$2"; shift 2 ;; --qa-dir) qa="$2"; shift 2 ;; *) return 2 ;; esac; done
+ [ -d "$qa" ] || mkdir -p "$qa"
+ local id console trace
+ while IFS=$'\t' read -r id console trace; do
+ [ -n "$console" ] && [ "$console" != "null" ] && [ -f "$console" ] && cp "$console" "$qa/console-$id.log"
+ [ -n "$trace" ] && [ "$trace" != "null" ] && [ -f "$trace" ] && cp "$trace" "$qa/trace-$id.zip"
+ printf 'collected %s\n' "$id"
+ done < <(orc_qv_plan --results "$results" | jq -r '.[] | [.id, (.console // "null"), (.trace // "null")] | @tsv')
+}
+
+orc_qv__font() { # first usable font file for drawtext, or empty (fontconfig fallback)
+ local f
+ for f in /System/Library/Fonts/Helvetica.ttc /usr/share/fonts/truetype/dejavu/DejaVuSans.ttf /usr/share/fonts/dejavu/DejaVuSans.ttf; do
+ [ -f "$f" ] && { printf '%s' "$f"; return 0; }
+ done
+}
+
+orc_qv__card() { #
+ local out="$1" title="$2" sub="$3" secs="$4" size="$5" font tf1 tf2 ff=""
+ if ! ffmpeg -hide_banner -filters 2>/dev/null | grep -q ' drawtext '; then
+ # ffmpeg built without libfreetype: blank card (chapters.json still carries titles)
+ ffmpeg -nostdin -v error -y -f lavfi -i "color=c=0x1d1d1b:s=$size:d=$secs" -r 25 -c:v libvpx -b:v 1M -an "$out"
+ return
+ fi
+ font="$(orc_qv__font)"; [ -n "$font" ] && ff="fontfile=$font:"
+ tf1="$(mktemp)"; tf2="$(mktemp)"; printf '%s' "$title" > "$tf1"; printf '%s' "$sub" > "$tf2"
+ ffmpeg -nostdin -v error -y -f lavfi -i "color=c=0x1d1d1b:s=$size:d=$secs" \
+ -vf "drawtext=${ff}textfile=$tf1:fontcolor=white:fontsize=44:x=(w-text_w)/2:y=(h-text_h)/2-30,drawtext=${ff}textfile=$tf2:fontcolor=0xa29f98:fontsize=26:x=(w-text_w)/2:y=(h-text_h)/2+40" \
+ -r 25 -c:v libvpx -b:v 1M -an "$out"
+ rm -f "$tf1" "$tf2"
+}
+
+orc_qv__dur() { ffprobe -v error -show_entries format=duration -of csv=p=0 "$1"; }
+
+orc_qv_stitch() { # --results R --qa-dir D --branch B [--card-seconds 2] [--size 1280x720]
+ local results="" qa="" branch="" card=2 size="1280x720"
+ while [ $# -gt 0 ]; do
+ case "$1" in
+ --results) results="$2"; shift 2 ;; --qa-dir) qa="$2"; shift 2 ;; --branch) branch="$2"; shift 2 ;;
+ --card-seconds) card="$2"; shift 2 ;; --size) size="$2"; shift 2 ;;
+ *) echo "orc-qa-video: unknown argument $1" >&2; return 2 ;;
+ esac
+ done
+ command -v ffmpeg >/dev/null 2>&1 && command -v ffprobe >/dev/null 2>&1 || { echo "orc-qa-video: ffmpeg not installed — motion proof skipped" >&2; return 4; }
+ [ -n "$branch" ] && [ -n "$qa" ] || { echo "orc-qa-video: stitch needs --qa-dir and --branch" >&2; return 2; }
+ mkdir -p "$qa"
+ local work list chapters="[]" t=0 n=0 id title outcome video seg cardf d1 d2
+ work="$(mktemp -d)"; list="$work/list.txt"; : > "$list"
+ while IFS=$'\t' read -r id title outcome video; do
+ [ -n "$video" ] && [ "$video" != "null" ] && [ -f "$video" ] || continue
+ n=$((n+1))
+ cardf="$work/card-$n.webm"
+ orc_qv__card "$cardf" "$title" "$(printf 'scenario %d — %s' "$n" "$outcome")" "$card" "$size"
+ seg="$work/seg-$n.webm"
+ ffmpeg -nostdin -v error -y -i "$video" -vf "scale=${size/x/:}:force_original_aspect_ratio=decrease,pad=${size/x/:}:(ow-iw)/2:(oh-ih)/2,fps=25" -c:v libvpx -b:v 1M -an "$seg"
+ d1="$(orc_qv__dur "$cardf")"; d2="$(orc_qv__dur "$seg")"
+ chapters="$(printf '%s' "$chapters" | jq --arg id "$id" --arg title "$title" --arg outcome "$outcome" --arg f "$(basename "$video")" \
+ --argjson start "$(awk -v t="$t" -v c="$d1" 'BEGIN{printf "%.2f", t+c}')" --argjson end "$(awk -v t="$t" -v c="$d1" -v d="$d2" 'BEGIN{printf "%.2f", t+c+d}')" \
+ '. + [{id: $id, title: $title, outcome: $outcome, source: $f, start: $start, end: $end}]')"
+ t="$(awk -v t="$t" -v c="$d1" -v d="$d2" 'BEGIN{printf "%.2f", t+c+d}')"
+ printf "file '%s'\nfile '%s'\n" "$cardf" "$seg" >> "$list"
+ done < <(orc_qv_plan --results "$results" | jq -r '.[] | [.id, .title, .outcome, (.video // "null")] | @tsv')
+ [ "$n" -gt 0 ] || { echo "orc-qa-video: no scenario videos found in $results" >&2; rm -rf "$work"; return 1; }
+ ffmpeg -nostdin -v error -y -f concat -safe 0 -i "$list" -c copy "$qa/qa-$branch.webm"
+ printf '%s\n' "$chapters" > "$qa/chapters.json"
+ rm -rf "$work"
+ printf '%s\n%s\n' "$qa/qa-$branch.webm" "$qa/chapters.json"
+}
+
+if [ "${BASH_SOURCE[0]:-}" = "${0:-}" ]; then
+ set -euo pipefail
+ sub="${1:-}"; shift 2>/dev/null || true
+ case "$sub" in
+ plan) orc_qv_plan "$@" ;;
+ collect) orc_qv_collect "$@" ;;
+ stitch) orc_qv_stitch "$@" ;;
+ --help|-h|help|'') printf 'usage: orc-qa-video plan --results R | collect --results R --qa-dir D | stitch --results R --qa-dir D --branch B [--card-seconds N] [--size WxH]\n' ;;
+ *) printf 'orc-qa-video: unknown subcommand %s\n' "$sub" >&2; exit 2 ;;
+ esac
+fi
diff --git a/orc/lib/report-template.html b/orc/lib/report-template.html
index 5c64eec..5065916 100644
--- a/orc/lib/report-template.html
+++ b/orc/lib/report-template.html
@@ -24,6 +24,7 @@
.note { color: var(--muted); font-size: 13px; margin-top: 4px; }
.shots { display:grid; grid-template-columns: repeat(auto-fill, minmax(220px, 1fr)); gap: 10px; margin-top: 10px; }
.shots img, .shots video { width: 100%; border: 1px solid var(--line); border-radius: 8px; background: var(--chip); }
+ .chapters { margin: 10px 0 0; padding-left: 18px; font-size: 14px; }
.missing { color: var(--fail); font-size: 13px; }
code { background: var(--chip); padding: 1px 5px; border-radius: 4px; font-size: 13px; }
@@ -43,7 +44,7 @@
var src = file.split('#')[0];
if (missing.has(file)) return el('div', 'missing', 'missing: ' + file);
if (/\.(png|jpe?g|gif|webp)$/i.test(src)) { var i = el('img'); i.src = src; i.alt = file; i.loading = 'lazy'; return i; }
- if (/\.(webm|mp4)$/i.test(src)) { var v = el('video'); v.src = src; v.controls = true; v.preload = 'metadata'; return v; }
+ if (/\.(webm|mp4)$/i.test(src)) { var v = el('video'); v.src = src + (file.indexOf('#t=') > -1 ? '#t=' + file.split('#t=')[1] : ''); v.controls = true; v.preload = 'metadata'; return v; }
var a = el('a', null, file); a.href = src; return a;
}
app.appendChild(el('h1', null, 'QA evidence — ' + (r.branch || 'unknown branch')));
@@ -54,6 +55,21 @@
r.jiraTicket, r.generatedAt && ('generated ' + r.generatedAt)].filter(Boolean).join(' · ')));
app.appendChild(meta);
if (r.summary) app.appendChild(el('p', null, r.summary));
+ if (r.video && r.video.file) {
+ app.appendChild(el('h2', null, 'Recording'));
+ var vwrap = el('div', 'row'); var vid = media(r.video.file); vwrap.appendChild(vid);
+ if ((r.video.chapters || []).length) {
+ var ul = el('ul', 'chapters');
+ r.video.chapters.forEach(function (c) {
+ var li = el('li'); var a = el('a', null, c.title + ' (' + c.start + 's)'); a.href = '#t=' + c.start;
+ a.onclick = function (e) { e.preventDefault(); if (vid.currentTime !== undefined) { vid.currentTime = c.start; vid.play(); } };
+ li.appendChild(a); li.appendChild(el('span', 'meta badge ' + (c.outcome === 'passed' ? 'pass' : 'fail'), ' ' + c.outcome)); ul.appendChild(li);
+ });
+ vwrap.appendChild(ul);
+ }
+ if (r.target) vwrap.appendChild(el('div', 'note', 'target ' + r.target.name + (r.target.baseUrl ? ' · ' + r.target.baseUrl : '') + (r.target.guard ? ' · read-only' : '')));
+ app.appendChild(vwrap);
+ }
if ((r.checks || []).length) {
app.appendChild(el('h2', null, 'Checks'));
diff --git a/orc/lib/report.sh b/orc/lib/report.sh
index 8b865e2..ede1c27 100644
--- a/orc/lib/report.sh
+++ b/orc/lib/report.sh
@@ -39,7 +39,8 @@ orc_report_json() { # [--branch B]
artifacts: [$m.artifacts[]? | {file, role: (.role // ""),
kind: (if (.file | test("\\.(png|jpe?g|gif|webp)(#.*)?$"; "i")) then "image"
elif (.file | test("\\.(webm|mp4)$"; "i")) then "video" else "file" end)}],
- curated: ($m.curated // []), missing: $missing }'
+ curated: ($m.curated // []), target: ($m.target // null), video: ($m.video // null),
+ specs: ($m.specs // []), tests: ($m.tests // []), missing: $missing }'
}
orc_report_html() { # [--branch B]
diff --git a/orc/lib/targets.sh b/orc/lib/targets.sh
new file mode 100755
index 0000000..8c8bdc6
--- /dev/null
+++ b/orc/lib/targets.sh
@@ -0,0 +1,174 @@
+#!/usr/bin/env bash
+# shellcheck disable=SC2015,SC2016 # $vars inside single quotes are jq program variables
+# targets.sh — the named QA-target registry (/targets.json) behind
+# bin/orc-targets. A target = where browser QA runs (local provisioned env or
+# a remote URL), its health probe, a guard flag, named test users, and a login
+# recipe. Credential VALUES are references resolved only at run time:
+# literal | op://vault/item/field (1Password CLI) | env:VAR_NAME
+# Resolved JSON goes to stdout for the caller to export as ORC_TARGET_JSON —
+# never to disk. Sourced after lib/state.sh (orc_state__dir).
+# Sourced-library contract: never changes caller shell options.
+
+ORC_TARGETS_SCHEMA=1
+
+orc_targets__file() { printf '%s/targets.json\n' "$(orc_state__dir)"; }
+
+orc_targets__write() { # $1 = jq program; rest = jq args. Atomic.
+ local f prog="$1"; shift
+ f="$(orc_targets__file)"
+ mkdir -p "$(dirname "$f")"
+ jq "$@" "$prog" "$f" > "$f.tmp" && mv "$f.tmp" "$f"
+}
+
+orc_targets_init() {
+ local f; f="$(orc_targets__file)"
+ [ -f "$f" ] && return 0
+ mkdir -p "$(dirname "$f")"
+ jq -n --argjson s "$ORC_TARGETS_SCHEMA" '{schema: $s, targets: {local: {kind: "local", baseUrl: null, healthPath: "/", guard: false, users: {}, login: null}}}' > "$f"
+}
+
+orc_targets__require() { # $1 = name → exit 1 if absent
+ local f; f="$(orc_targets__file)"
+ [ -f "$f" ] && jq -e --arg n "$1" '.targets[$n] != null' "$f" >/dev/null 2>&1 \
+ || { echo "orc-targets: no target named '$1' (orc-targets list)" >&2; return 1; }
+}
+
+orc_targets_list() {
+ orc_targets_init
+ jq -r '.targets | to_entries[] | [.key, .value.kind, (.value.baseUrl // "-"), (.value.guard | tostring)] | @tsv' "$(orc_targets__file)"
+}
+
+orc_targets_get() { #
+ orc_targets_init; orc_targets__require "$1" || return 1
+ jq --arg n "$1" '.targets[$n] + {name: $n}' "$(orc_targets__file)"
+}
+
+orc_targets_set() { # [--base-url U] [--kind local|remote] [--health-path P] [--guard true|false]
+ local name="${1:-}"; shift || true
+ [ -n "$name" ] || { echo "orc-targets: set needs a name" >&2; return 2; }
+ orc_targets_init
+ local base="" kind="" health="" guard=""
+ while [ $# -gt 0 ]; do
+ case "$1" in
+ --base-url) base="$2"; shift 2 ;;
+ --kind) kind="$2"; shift 2 ;;
+ --health-path) health="$2"; shift 2 ;;
+ --guard) guard="$2"; shift 2 ;;
+ *) echo "orc-targets: unknown argument $1" >&2; return 2 ;;
+ esac
+ done
+ [ -z "$kind" ] && { [ -n "$base" ] && kind="remote" || kind="local"; }
+ case "$kind" in local|remote) ;; *) echo "orc-targets: kind must be local|remote" >&2; return 2 ;; esac
+ case "${guard:-false}" in true|false) ;; *) echo "orc-targets: guard must be true|false" >&2; return 2 ;; esac
+ orc_targets__write '
+ .targets[$n] = ((.targets[$n] // {users: {}, login: null, healthPath: "/", guard: false})
+ + {kind: $k}
+ + (if $b != "" then {baseUrl: $b} else {} end)
+ + (if $h != "" then {healthPath: $h} else {} end)
+ + (if $g != "" then {guard: ($g == "true")} else {} end))' \
+ --arg n "$name" --arg k "$kind" --arg b "$base" --arg h "$health" --arg g "$guard"
+}
+
+orc_targets_user() { # --username V --password V
+ local target="${1:-}" user="${2:-}"; shift 2 || true
+ orc_targets_init; orc_targets__require "$target" || return 1
+ local u="" p=""
+ while [ $# -gt 0 ]; do
+ case "$1" in
+ --username) u="$2"; shift 2 ;;
+ --password) p="$2"; shift 2 ;;
+ *) echo "orc-targets: unknown argument $1" >&2; return 2 ;;
+ esac
+ done
+ [ -n "$u" ] && [ -n "$p" ] || { echo "orc-targets: user needs --username and --password (literal | op://… | env:VAR)" >&2; return 2; }
+ orc_targets__write '.targets[$t].users[$u] = {username: $un, password: $pw}' \
+ --arg t "$target" --arg u "$user" --arg un "$u" --arg pw "$p"
+}
+
+orc_targets_login() { # --path P --username-field F --password-field F --submit TEXT --success-path P
+ local target="${1:-}"; shift || true
+ orc_targets_init; orc_targets__require "$target" || return 1
+ local path="/login" uf="Email" pf="Password" submit="Sign in" ok="/"
+ while [ $# -gt 0 ]; do
+ case "$1" in
+ --path) path="$2"; shift 2 ;;
+ --username-field) uf="$2"; shift 2 ;;
+ --password-field) pf="$2"; shift 2 ;;
+ --submit) submit="$2"; shift 2 ;;
+ --success-path) ok="$2"; shift 2 ;;
+ *) echo "orc-targets: unknown argument $1" >&2; return 2 ;;
+ esac
+ done
+ orc_targets__write '.targets[$t].login = {path: $p, usernameField: $uf, passwordField: $pf, submit: $s, successPath: $ok}' \
+ --arg t "$target" --arg p "$path" --arg uf "$uf" --arg pf "$pf" --arg s "$submit" --arg ok "$ok"
+}
+
+orc_targets__resolve_value() { # $1 = reference → value on stdout; exit 3 when unresolvable
+ local v="$1" op="${ORC_TARGETS_OP_BIN:-op}"
+ case "$v" in
+ op://*)
+ command -v "$op" >/dev/null 2>&1 || { echo "orc-targets: 1Password CLI ($op) not found — needed to read $v" >&2; return 3; }
+ "$op" read "$v" 2>/dev/null || { echo "orc-targets: '$op read $v' failed — run: op signin" >&2; return 3; } ;;
+ env:*)
+ local n="${v#env:}"
+ printf '%s' "$n" | grep -Eq '^[A-Za-z_][A-Za-z0-9_]*$' || { echo "orc-targets: invalid env reference '$v' (expected env:VAR_NAME)" >&2; return 3; }
+ [ -n "${!n:-}" ] || { echo "orc-targets: env var $n is empty — referenced by a target credential" >&2; return 3; }
+ printf '%s' "${!n}" ;;
+ *) printf '%s' "$v" ;;
+ esac
+}
+
+orc_targets_resolve() { # [--base-url U] → resolved JSON on stdout (never written to disk)
+ local name="${1:-}"; shift || true
+ local override=""
+ while [ $# -gt 0 ]; do
+ case "$1" in --base-url) override="$2"; shift 2 ;; *) echo "orc-targets: unknown argument $1" >&2; return 2 ;; esac
+ done
+ local json; json="$(orc_targets_get "$name")" || return 1
+ # An ad-hoc URL that leaves localhost is an UNAUTHENTICATED remote run: the local target's users/login never
+ # travel to an arbitrary origin. Authenticated remote QA needs a named target (orc-targets set/user/login).
+ [ -n "$override" ] && json="$(printf '%s' "$json" | jq --arg b "$override" '
+ ($b | test("^https?://(localhost|127\\.0\\.0\\.1)(:|/|$)")) as $isLocal
+ | .baseUrl = $b
+ | if .kind == "local" and ($isLocal | not) then .kind = "remote" | .users = {} | .login = null | .adhoc = true else . end')"
+ local user un pw run rpw
+ while IFS= read -r user; do
+ [ -n "$user" ] || continue
+ un="$(printf '%s' "$json" | jq -r --arg u "$user" '.users[$u].username')"
+ pw="$(printf '%s' "$json" | jq -r --arg u "$user" '.users[$u].password')"
+ run="$(orc_targets__resolve_value "$un")" || return 3
+ rpw="$(orc_targets__resolve_value "$pw")" || return 3
+ json="$(printf '%s' "$json" | jq --arg u "$user" --arg un "$run" --arg pw "$rpw" '.users[$u] = {username: $un, password: $pw}')"
+ done < <(printf '%s' "$json" | jq -r '.users | keys[]')
+ printf '%s\n' "$json"
+}
+
+orc_targets_probe() { # → exit 0 when GET baseUrl+healthPath is 2xx/3xx
+ local arg="${1:-}" url
+ case "$arg" in
+ http://*|https://*) url="$arg" ;;
+ *) url="$(orc_targets_get "$arg" | jq -r '(.baseUrl // "") + (.healthPath // "/")')" || return 1 ;;
+ esac
+ [ -n "$url" ] && [ "$url" != "/" ] || { echo "orc-targets: target has no baseUrl to probe" >&2; return 1; }
+ curl -sf -o /dev/null --max-time 10 -L "$url"
+}
+
+# CLI mode — active only when EXECUTED (via bin/orc-targets), never when sourced.
+if [ "${BASH_SOURCE[0]:-}" = "${0:-}" ]; then
+ set -euo pipefail
+ # shellcheck disable=SC1091
+ . "$(dirname "${BASH_SOURCE[0]}")/state.sh"
+ sub="${1:-}"; shift 2>/dev/null || true
+ case "$sub" in
+ init) orc_targets_init ;;
+ list) orc_targets_list ;;
+ get) orc_targets_get "$@" ;;
+ set) orc_targets_set "$@" ;;
+ user) orc_targets_user "$@" ;;
+ login) orc_targets_login "$@" ;;
+ resolve) orc_targets_resolve "$@" ;;
+ probe) orc_targets_probe "$@" ;;
+ --help|-h|help|'') printf 'usage: orc-targets init|list|get |set [--base-url U --kind K --health-path P --guard B]|user --username V --password V|login [--path --username-field --password-field --submit --success-path]|resolve [--base-url U]|probe \n' ;;
+ *) printf 'orc-targets: unknown subcommand %s\n' "$sub" >&2; exit 2 ;;
+ esac
+fi
diff --git a/orc/lib/tools.json b/orc/lib/tools.json
index 388672c..8749de0 100644
--- a/orc/lib/tools.json
+++ b/orc/lib/tools.json
@@ -55,7 +55,7 @@
{
"name": "agent-browser",
"tier": "recommended",
- "usedBy": "`/orc:qa` (web mode — required for browser-driven QA evidence)",
+ "usedBy": "`/orc:qa` fallback driver when Node/Playwright is unavailable; `/orc:evidence`",
"hints": {
"macos": "brew install agent-browser && agent-browser install",
"default": "npm install -g agent-browser && agent-browser install"
@@ -153,7 +153,7 @@
{
"name": "ffmpeg",
"tier": "recommended",
- "usedBy": "`/orc:qa`, `/orc:evidence` (motion evidence — agent-browser's `record` wraps ffmpeg; without it a headless QA run captures stills only)",
+ "usedBy": "`/orc:qa` (stitched QA video), `/orc:explain` (explainer render), agent-browser `record` — title cards and captions need an ffmpeg build with libfreetype (`brew install ffmpeg` has it; minimal builds don't)",
"hints": {
"macos": "brew install ffmpeg",
"debian": "sudo apt-get install -y ffmpeg",
diff --git a/orc/skills/browser-qa/SKILL.md b/orc/skills/browser-qa/SKILL.md
index 91a7875..04fdbc7 100644
--- a/orc/skills/browser-qa/SKILL.md
+++ b/orc/skills/browser-qa/SKILL.md
@@ -1,46 +1,57 @@
---
name: browser-qa
-description: The canonical browser-QA protocol for web changes — env attach, the driver gate (agent-browser headless vs Claude-in-Chrome live), the validator dispatch contract, and the chrome-mode evidence packet. Use when running browser QA on a web change; /orc:qa Phase 4 and /orc:flow Phase 6 delegate here.
+description: The canonical browser-QA protocol for web changes — target resolution, env attach, the driver gate (Playwright primary, agent-browser, Claude-in-Chrome), validator dispatch, chrome evidence packet. Use when running browser QA; /orc:qa and /orc:flow Phase 6 delegate here.
---
# Browser QA
The single source of truth for driving browser QA — `/orc:qa` and `/orc:flow` both execute this protocol instead of restating it. Inputs from the caller: the feature description, the state dir, `--driver`/`--web`/`--no-env` flags when given, and (workspace mode) the web-surface repo + siblings.
-## Step 0 — Provision or attach the environment
+## Step 0 — Resolve the target
-Skip when `--web ` or `--no-env`. Check `orc-docker-env is-ready $(orc-docker-env state-path "$ORC_STATE_DIR" )`:
+Invoke `orc:qa-targets`: `--target` / `--web` / settled decision / the Target gate. Result: a target **name** (plus a `--base-url` override when one applies) and `guard`. The resolved JSON is never produced here — the engine resolves it inline in the command that consumes it (`orc:qa-targets` iron rule). Remote target ⇒ probe; unreachable ⇒ `🛑 Escalation — target unreachable`, stop.
+
+## Step 1 — Provision or attach the environment (local target only)
+
+Skip for remote targets and under `--no-env`. Check `orc-docker-env is-ready $(orc-docker-env state-path "$ORC_STATE_DIR" )`:
- `ready` → attach; echo the reuse line (project, appUrl, "reused").
- otherwise → dispatch **`orc-env-provisioner`** via `Task` (repoPath = the worktree; workspace mode adds `repos[]`, `webSurfaceRepo`, plan path). On `fallback`: re-print the agent's ⚠️ callout and continue. On `failed`: re-print the 🛑 callout and `AskUserQuestion` — retry / retry `--fresh` / continue with `--no-env` legacy boot / abort QA.
-The environment **stays up after QA** — the "QA partial → fix → re-run" loop attaches in seconds. Teardown belongs to `/orc:cleanup`. Then init `${ORC_STATE_DIR}//files/qa/`; in workspace mode, cross-repo integration evidence goes there while per-repo QA stays at `/.orc//files/qa/`.
+Record `` as the `--base-url` override for the `local` target (the engine resolves inline). The environment **stays up after QA** — the "QA partial → fix → re-run" loop attaches in seconds. Teardown belongs to `/orc:cleanup`. Init `${ORC_STATE_DIR}//files/qa/`; in workspace mode, cross-repo integration evidence goes there while per-repo QA stays at `/.orc//files/qa/`.
-## Step 1 — Choose the driver
+## Step 1b — Choose the driver
-If `--driver` was passed or the session has a settled `driver` decision, use it silently. Otherwise print the Gate headline, then `AskUserQuestion` (record the answer via `orc-state decision set driver --provenance asked` — re-runs keep the same driver unless the user asks to switch):
+If `--driver` was passed or the session has a settled `driver` decision, use it silently. Otherwise print the Gate headline, then `AskUserQuestion` (re-runs keep the same driver unless the user asks to switch):
```markdown
> **⛔ Gate — browser driver**
>
-> Web QA is ready to run against . Pick how to drive the browser.
+> Web QA is ready to run against (). Pick how to drive the browser.
```
-- **agent-browser CLI (headless)** — richest evidence: annotated screenshots, network HAR, request mocking for failure-state testing, and a WebM recording when `ffmpeg` is installed; runs isolated from your browsing. Best for thorough pre-PR gates and CI-like rigor.
-- **Claude-in-Chrome extension (watch live)** — the test runs in YOUR Chrome; you see every click as it happens, with your real sessions, cookies, and extensions, and the GIF recording needs no external tooling. Best when you want to visually follow the flow or the app needs an already-logged-in state.
+- **Playwright (Recommended)** — plans and generates real Playwright tests committed with your change (`e2e/`), heals flaky locators, and records a stitched video whose on-screen tags name each criterion as it is proven; traces per scenario. Needs Node; first use creates `e2e/` behind a gate.
+- **agent-browser CLI (headless)** — one-off walk with annotated screenshots, HAR, request mocking, WebM when ffmpeg is installed. No tests left behind. Used automatically when Node is unavailable.
+- **Claude-in-Chrome extension (watch live)** — the test runs in YOUR Chrome with your sessions and extensions; GIF recording, no external tooling.
+
+Record: `orc-state decision set driver --provenance asked`. `guided`/`full` ⇒ `playwright` as a policy decision.
-## Step 2 — Load the acceptance criteria (both drivers)
+## Step 2 — Load the acceptance criteria (all drivers)
Before driving anything, read the session's `slices.json` ledger and collect the relevant slices' **`acceptance` lists**. These are the scoring rubric — the walk exists to prove them, not to tour the app. Every criterion ends the run as `pass`, `fail`, or `skipped` **with a stated reason**, each carrying the artifact that proves it.
No ledger (ad-hoc QA, `/orc:evidence` against a ticket) ⇒ distil the criteria from the feature description or ticket body instead; the scoring contract is identical.
+## Driver P — Playwright (delegate)
+
+Invoke `orc:playwright-qa` with the inputs listed in its header (feature description, ``, the step-2 acceptance lists, target name + optional `--base-url` override + `guard` (never the resolved JSON), `isVisual`; workspace mode adds the web-surface `repoPath`). It returns `pass|fail|partial` with `qa-manifest.json` written, or `fallback` (Node missing, setup declined, MCP not connected) — on `fallback` print the reason and run **Driver A** below with the same inputs; never silently.
+
## Driver A — agent-browser (dispatch the validator)
Dispatch the `orc-qa-validator` subagent via `Task`. Pass:
- The feature description.
-- **`appUrl` + `serviceEndpoints` + `envStatePath`** from `docker-env-state.json` (the validator NEVER boots infra when env state exists — it attaches). Only when step 0 was skipped: the `--web` URL, or legacy boot instructions under `--no-env`.
+- **`appUrl` + `serviceEndpoints` + `envStatePath`** from `docker-env-state.json` (the validator NEVER boots infra when env state exists — it attaches). Pass `baseUrl` from the resolved target; under `--no-env`, legacy boot instructions.
- The artifact directory.
- **The acceptance lists from step 2**, each criterion tagged with its slice id and index — the agent captures a dedicated `ac---.png` at the moment each criterion is observable and scores it evidence-cited, instead of narrating vibes.
- **Whether the change is visual** (the diff touches a rendered surface) — when it is, the agent records the walk via `agent-browser record`. That recorder wraps `ffmpeg`: with ffmpeg installed the WebM is a required artifact; without it the run passes on stills and says so in one line. Never a silent omission, never an install.
diff --git a/orc/skills/evidence-publish/SKILL.md b/orc/skills/evidence-publish/SKILL.md
index f0f134c..1674f16 100644
--- a/orc/skills/evidence-publish/SKILL.md
+++ b/orc/skills/evidence-publish/SKILL.md
@@ -30,6 +30,8 @@ Resolve the ticket: explicit `ticketKey`, else the active session's `jiraTicket`
**Report + Artifact target** (independent of the tracker): for a session packet, always render the report — `orc-report html` writes `qaDir/report.html` (criteria, notes, screenshots, recording; missing files flagged) and prints its path. The **Artifact** target is available iff the host exposes the `Artifact` tool.
+**PR target** (independent): `references/github-pr-adapter.md` `detect()` — available iff `gh` authed and the branch has an open PR.
+
Exact commands: `references/jira-adapter.md`. The tracker-agnostic interface (to add GitHub/Linear later): `references/adapter-contract.md`.
### 2. Curate the payload
@@ -41,7 +43,7 @@ No manifest (a hand-built packet) ⇒ fall back to the driver's packet shape:
- **Driver B (Chrome)** → `qa-.gif` + `steps.md`.
- **Driver A (agent-browser)** → the `ac-*.png` criterion shots + `qa-.webm` (or its `.gif`) + any failing-step shots + `steps.md`.
-Attach `.webm`/`.gif`/`.png` alike — the REST upload path in `references/jira-adapter.md` is content-type agnostic. Never attach `console.log` / `network.har` / `network-summary.md` / `snapshot-final.txt` — noise on a ticket; they stay local. When **attach** is unavailable (comment tier only), the payload is comment-only — note it in the preview.
+Attach `.webm`/`.gif`/`.png` alike — the REST upload path in `references/jira-adapter.md` is content-type agnostic. Never attach `console.log` / `console-*.log` / `network.har` / `network-summary.md` / `snapshot-final.txt` / `trace-*.zip` / anything under `pw/` — noise on a ticket, and Playwright traces carry the live session cookie and full response bodies; they stay local. When **attach** is unavailable (comment tier only), the payload is comment-only — note it in the preview.
### 3. Preview gate — always ask
@@ -61,12 +63,16 @@ If a prior `## Evidence delivery` block in `steps.md` already reads "uploaded",
Artifact target available ⇒ the same `AskUserQuestion` call carries a second question (header `Publish`): **Publish the QA report as a private Artifact?** — `Keep local (Recommended)` / `Publish privately` (preview: the report's criteria list + the files it would upload). With no tracker target it is the only question. A prior `Report: artifact ` line in `steps.md` ⇒ offer `Update that artifact` instead of a second URL.
+**PR target available** ⇒ the call gains a further question (header `Post`): **Post QA summary to PR #?** — `Keep local (Recommended)` / `Post comment` (preview: the comment body). Only asked when a PR exists; hard-outward, so `--auto` never answers it. Order in the call: Jira upload → Artifact → PR comment, so the Artifact URL can land in the comment — when the user picks Artifact publish and PR post together, publish first, then build the body with `ARTIFACT_URL`.
+
### 4. Deliver — on Upload
- **Attach** each curated file over REST (acli has no upload verb — `references/jira-adapter.md`). A per-file failure ⇒ surface it and continue; partial delivery beats none.
- **Comment**: post the plain-text summary via `acli jira workitem comment create`. **Plain text only** — Jira stores rich text as ADF, so markdown renders literally; reference attachments by filename, never embed.
-- **Artifact** (on Publish): publish `qaDir/report.html` with the `Artifact` tool (new artifact: `icon: checklist`; update: pass the recorded `url`). Upload every image/video the report references and that exists — `orc-report json | jq -r '[.artifacts[].file, .acceptance[].evidence[]] - .missing | map(sub("#.*"; "")) | unique | .[]'` — as `files`, each published at its bare filename so the report's relative paths resolve. Never include `console.log` / `network.har`. It stays private; linking it from a PR or ticket is a separate, asked step.
+- **Artifact** (on Publish): publish `qaDir/report.html` with the `Artifact` tool (new artifact: `icon: checklist`; update: pass the recorded `url`). Upload only the **image/video** files the report references and that exist — `orc-report json | jq -r '[.artifacts[].file, .acceptance[].evidence[]] - .missing | map(sub("#.*"; "")) | unique | .[] | select(test("\\.(png|jpe?g|gif|webp|webm|mp4)$"; "i"))'` — as `files`, each published at its bare filename so the report's relative paths resolve. Never include `*.log`, `*.har`, `*.zip`, `*.txt`, `*.json`, or anything under `pw/` — a Playwright `trace-*.zip` holds the session cookie and response bodies, and the Artifact link travels with the PR comment. It stays private; linking it from a PR or ticket is a separate, asked step.
+
+- **PR comment** (on Post): run `comment()` from `references/github-pr-adapter.md`. Markdown is fine on GitHub; links only, no media upload.
### 5. Record — provenance + idempotency
@@ -79,6 +85,7 @@ Append to `steps.md`:
- Attached:
- Comment: posted | n/a
- Report: /report.html | artifact
+- PR comment: posted | n/a
```
Echo a one-line `✓` on upload, or a plain note otherwise. Local-only and cancel stay plain — no callout.
diff --git a/orc/skills/evidence-publish/references/adapter-contract.md b/orc/skills/evidence-publish/references/adapter-contract.md
index a179790..5fc415e 100644
--- a/orc/skills/evidence-publish/references/adapter-contract.md
+++ b/orc/skills/evidence-publish/references/adapter-contract.md
@@ -1,6 +1,6 @@
# Tracker adapter contract
-`evidence-publish` is tracker-agnostic. Each adapter implements four operations; the SKILL.md protocol is written against **these**, not against Jira. Shipped adapters: **jira** (`jira-adapter.md`). GitHub Issues / Linear are added by writing a sibling reference file — no protocol change.
+`evidence-publish` is tracker-agnostic. Each adapter implements four operations; the SKILL.md protocol is written against **these**, not against Jira. Shipped adapters: **jira** (`jira-adapter.md`), **github-pr** (`github-pr-adapter.md`; comment-only, links media). GitHub Issues / Linear are added by writing a sibling reference file — no protocol change.
| Op | Signature | Jira implementation |
|----|-----------|---------------------|
diff --git a/orc/skills/evidence-publish/references/github-pr-adapter.md b/orc/skills/evidence-publish/references/github-pr-adapter.md
new file mode 100644
index 0000000..7834eff
--- /dev/null
+++ b/orc/skills/evidence-publish/references/github-pr-adapter.md
@@ -0,0 +1,33 @@
+# GitHub PR adapter — evidence delivery
+
+Posts ONE comment on the branch's open PR: verdict table + links. GitHub has no API to upload a video into a comment, so media is linked (private Artifact URL recorded in `steps.md`, or "kept local").
+
+## detect()
+```bash
+command -v gh >/dev/null 2>&1 || exit 0
+gh auth status >/dev/null 2>&1 || exit 0
+PR_JSON="$(gh pr view --json number,url 2>/dev/null)" || exit 0 # no PR for this branch ⇒ disabled
+PR_NUMBER="$(printf '%s' "$PR_JSON" | jq -r .number)"; PR_URL="$(printf '%s' "$PR_JSON" | jq -r .url)"
+```
+`comment: true` when all three succeed; `attach: false` always (links only).
+
+## comment()
+```bash
+body="$(mktemp)"
+{
+ printf '## QA evidence — %s\n\n' "$(jq -r '.verdict | ascii_upcase' "$QA/qa-manifest.json")"
+ printf '| Criterion | Result | Evidence |\n|---|---|---|\n'
+ jq -r --arg art "${ARTIFACT_URL:-}" '.acceptance[] | "| \(.criterion) | \(.result)\(if .note != "" and .note != null then " — " + .note else "" end) | \(if $art != "" then "[" + (.evidence[0] // "-") + "](" + $art + ")" else (.evidence[0] // "-") end) |"' "$QA/qa-manifest.json"
+ printf '\n'
+ jq -r '"Driver: \(.driver)" + (if .target then " · target `\(.target.name)`" + (if .target.guard then " (read-only)" else "" end) else "" end)' "$QA/qa-manifest.json"
+ [ -n "${ARTIFACT_URL:-}" ] && printf '\nRecording + report: %s\n' "$ARTIFACT_URL"
+ [ -n "${EXPLAINER_URL:-}" ] && printf 'Explainer: %s\n' "$EXPLAINER_URL"
+ jq -r 'if (.specs // []) != [] then "Specs: " + (.specs | map("`" + . + "`") | join(", ")) else empty end' "$QA/qa-manifest.json"
+ printf '\nFull packet: `.orc//files/qa/` (local).\n'
+} > "$body"
+gh pr comment "$PR_NUMBER" --body-file "$body"
+```
+Markdown renders on GitHub (unlike Jira). Never paste console/HAR content. Never include credentials or `ORC_TARGET_JSON`; never echo a token.
+
+## Idempotency
+`steps.md` records `- PR comment: posted `; a second run offers **Update** = post a new comment prefixed `Updated QA evidence` (GitHub comment edit needs the comment id — `gh pr comment --edit-last` works when the last comment is ours; prefer it).
diff --git a/orc/skills/explainer/SKILL.md b/orc/skills/explainer/SKILL.md
new file mode 100644
index 0000000..561a822
--- /dev/null
+++ b/orc/skills/explainer/SKILL.md
@@ -0,0 +1,39 @@
+---
+name: explainer
+description: "Build the narrated what-was-made walkthrough for a QA packet: script from plan + acceptance, demo pass, segments.json, animate graphics, kokoro narration, orc-explain assemble. Use from /orc:explain and the flow explainer gate. Not for QA itself."
+---
+
+# Explainer
+
+**Announce at start:** "I'm using the explainer skill to build the narrated walkthrough."
+
+Inputs: `` with `qa-manifest.json`, the session's `plan.md` (optional), the target **name** + optional `--base-url` override for the demo pass (resolved inline per `orc:qa-targets`; never the resolved JSON), settings `explainer_style` (`CLAUDE_PLUGIN_OPTION_EXPLAINER_STYLE`, default `isometric`) and `explainer_voice` (`CLAUDE_PLUGIN_OPTION_EXPLAINER_VOICE`, default `af_heart`).
+
+## 1. Toolchain
+`orc-explain setup` (first run: npm install kokoro-js into the user cache; model downloads on first narration). `command -v ffmpeg` missing ⇒ stop with one line, no explainer. animate missing ⇒ say `graphic segments will be plain title cards` and continue.
+
+## 2. Demo footage
+If `/demo/` lacks a `.webm` per `@golden` scenario: `( cd / && ORC_TARGET_JSON="$(orc-targets resolve [--base-url ])" ORC_PW_OUTPUT_DIR=/pw-demo npx playwright test --project=demo )` (resolve stays inline — `orc:qa-targets` iron rule), then `orc-qa-video plan --results /pw-demo/results.json` and copy each `video` to `/demo/.webm`.
+
+## 3. Script → `segments.json` (schema: `references/segments-schema.md`)
+Sources, in order: the plan's slice titles + acceptance (what was built), the manifest's acceptance rows (what was proven), `git log --format=%s ..HEAD` (what changed). **Never the raw diff** — the script describes behaviour, not code. Shape: `s01` graphic (feature title + 2–3 lines), one clip segment per golden scenario (caption = the first `AC` title that scenario proves; narration = one sentence of what the viewer sees), `sNN` graphic outcome (criteria passed/total, notable skips). Narration lines ≤ 25 words, present tense, no file names. Total target ≤ 90 seconds. `style` from setting, `voice` from setting.
+
+## 4. Explainer gate (soft-inward, header `Explainer`; `guided`/`full` pre-approve)
+```markdown
+> **⛔ Gate — explainer**
+>
+> segments, ~s, style