From 1a335bb34440560df1b4dce28d49b5194d130633 Mon Sep 17 00:00:00 2001 From: Higor Alves Date: Tue, 6 Oct 2026 15:52:15 -0300 Subject: [PATCH 01/21] test(qa): failing fixture for orc-targets registry CLI --- scripts/ci/verify-targets.sh | 61 ++++++++++++++++++++++++++++++++++++ 1 file changed, 61 insertions(+) create mode 100755 scripts/ci/verify-targets.sh diff --git a/scripts/ci/verify-targets.sh b/scripts/ci/verify-targets.sh new file mode 100755 index 0000000..d23774d --- /dev/null +++ b/scripts/ci/verify-targets.sh @@ -0,0 +1,61 @@ +#!/usr/bin/env bash +# Fixture tests for bin/orc-targets — the named-target registry behind +# /orc:qa --target. Pins the registry shape and the credential-reference +# resolution (literal | op:// | env:) the Playwright config consumes. +# Run from the repo root. +set -euo pipefail +repo_root="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)" +cli="$repo_root/orc/bin/orc-targets" +status=0; pass_count=0 +fail() { echo "verify-targets: FAIL — $1"; status=1; } +ok() { pass_count=$((pass_count + 1)); } +tmp="$(mktemp -d)"; trap 'rm -rf "$tmp"' EXIT +export ORC_STATE_DIR="$tmp/.orc" + +[ -x "$cli" ] || { echo "verify-targets: FAIL — $cli missing or not executable"; exit 1; } + +# init: creates local, idempotent +bash "$cli" init >/dev/null +f="$ORC_STATE_DIR/targets.json" +[ -f "$f" ] && jq -e '.schema == 1 and .targets.local.kind == "local"' "$f" >/dev/null && ok || fail "init must create local target" +before="$(cat "$f")"; bash "$cli" init >/dev/null; [ "$before" = "$(cat "$f")" ] && ok || fail "init must be idempotent" + +# set + get +bash "$cli" set staging --base-url https://staging.example.com --health-path /healthz --guard true >/dev/null +[ "$(bash "$cli" get staging | jq -r .guard)" = "true" ] && ok || fail "set/get guard" +[ "$(bash "$cli" get staging | jq -r .kind)" = "remote" ] && ok || fail "set defaults kind=remote when base-url given" +bash "$cli" get nope >/dev/null 2>&1 && fail "get unknown must exit non-zero" || ok + +# list +bash "$cli" list | grep -q $'^staging\tremote\thttps://staging.example.com\ttrue$' && ok || fail "list row format" + +# user + login +bash "$cli" user staging admin --username env:T_USER --password op://Eng/item/password >/dev/null +bash "$cli" login staging --path /login --username-field Email --password-field Password --submit "Sign in" --success-path /dashboard >/dev/null +[ "$(bash "$cli" get staging | jq -r .login.submit)" = "Sign in" ] && ok || fail "login recipe stored" + +# resolve: env + op seam +cat > "$tmp/op" <<'OP' +#!/usr/bin/env bash +[ "$1" = "read" ] && [ "$2" = "op://Eng/item/password" ] && { printf 'hunter2'; exit 0; } +exit 1 +OP +chmod +x "$tmp/op" +out="$(T_USER=alice ORC_TARGETS_OP_BIN="$tmp/op" bash "$cli" resolve staging)" +[ "$(printf '%s' "$out" | jq -r .users.admin.username)" = "alice" ] && ok || fail "resolve env: reference" +[ "$(printf '%s' "$out" | jq -r .users.admin.password)" = "hunter2" ] && ok || fail "resolve op:// reference" +[ "$(printf '%s' "$out" | jq -r .name)" = "staging" ] && ok || fail "resolved JSON carries name" +# unresolvable env → exit 3, names the var +set +e; err="$(ORC_TARGETS_OP_BIN="$tmp/op" bash "$cli" resolve staging 2>&1 >/dev/null)"; rc=$?; set -e +[ "$rc" -eq 3 ] && printf '%s' "$err" | grep -q 'T_USER' && ok || fail "unresolvable env must exit 3 naming the var (rc=$rc: $err)" +# missing op binary → exit 3 +set +e; err="$(T_USER=a ORC_TARGETS_OP_BIN="$tmp/missing-op" bash "$cli" resolve staging 2>&1 >/dev/null)"; rc=$?; set -e +[ "$rc" -eq 3 ] && printf '%s' "$err" | grep -qi '1password' && ok || fail "missing op must exit 3 (rc=$rc)" +# --base-url override (ad-hoc --web) +[ "$(bash "$cli" resolve local --base-url http://127.0.0.1:4000 | jq -r .baseUrl)" = "http://127.0.0.1:4000" ] && ok || fail "base-url override" + +# probe: closed port → exit 1 +bash "$cli" probe http://127.0.0.1:1 >/dev/null 2>&1 && fail "probe must fail on closed port" || ok + +[ "$status" -eq 0 ] && echo "verify-targets: OK ($pass_count cases)" +exit "$status" From 8f122b34c1a6194e2272cab4d9145f0655ef1ce9 Mon Sep 17 00:00:00 2001 From: Higor Alves Date: Tue, 6 Oct 2026 15:54:28 -0300 Subject: [PATCH 02/21] feat(qa): orc-targets registry CLI with op:// and env: credential resolution --- orc/bin/orc-targets | 7 ++ orc/lib/targets.sh | 168 +++++++++++++++++++++++++++++++++++ scripts/ci/verify-targets.sh | 1 + 3 files changed, 176 insertions(+) create mode 100755 orc/bin/orc-targets create mode 100755 orc/lib/targets.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/lib/targets.sh b/orc/lib/targets.sh new file mode 100755 index 0000000..664978a --- /dev/null +++ b/orc/lib/targets.sh @@ -0,0 +1,168 @@ +#!/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:}" + [ -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 + [ -n "$override" ] && json="$(printf '%s' "$json" | jq --arg b "$override" '.baseUrl = $b | .kind = (if .kind == "local" and ($b | test("localhost|127\\.0\\.0\\.1") | not) then "remote" else .kind 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/scripts/ci/verify-targets.sh b/scripts/ci/verify-targets.sh index d23774d..028c037 100755 --- a/scripts/ci/verify-targets.sh +++ b/scripts/ci/verify-targets.sh @@ -3,6 +3,7 @@ # /orc:qa --target. Pins the registry shape and the credential-reference # resolution (literal | op:// | env:) the Playwright config consumes. # Run from the repo root. +# shellcheck disable=SC2015 # `A && ok || fail` is the fixture idiom; ok never fails set -euo pipefail repo_root="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)" cli="$repo_root/orc/bin/orc-targets" From 0b33cdd1acb487f2c8f6ab4c6285404eae0feb5b Mon Sep 17 00:00:00 2001 From: Higor Alves Date: Tue, 6 Oct 2026 15:54:19 -0300 Subject: [PATCH 03/21] =?UTF-8?q?feat(qa):=20orc-qa-video=20=E2=80=94=20re?= =?UTF-8?q?sults.json=20plan,=20console/trace=20collection,=20stitched=20Q?= =?UTF-8?q?A=20video=20with=20chapters?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- orc/bin/orc-qa-video | 7 +++ orc/lib/qa-video.sh | 109 ++++++++++++++++++++++++++++++++++ scripts/ci/verify-qa-video.sh | 58 ++++++++++++++++++ 3 files changed, 174 insertions(+) create mode 100755 orc/bin/orc-qa-video create mode 100755 orc/lib/qa-video.sh create mode 100755 scripts/ci/verify-qa-video.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/lib/qa-video.sh b/orc/lib/qa-video.sh new file mode 100755 index 0000000..9576f91 --- /dev/null +++ b/orc/lib/qa-video.sh @@ -0,0 +1,109 @@ +#!/usr/bin/env bash +# shellcheck disable=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 'map(. + {id: (.title | ascii_downcase | gsub("[^a-z0-9]+"; "-") | gsub("^-+|-+$"; ""))})' +} + +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() { # <subtitle> <seconds> <WxH> + 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/scripts/ci/verify-qa-video.sh b/scripts/ci/verify-qa-video.sh new file mode 100755 index 0000000..02d82aa --- /dev/null +++ b/scripts/ci/verify-qa-video.sh @@ -0,0 +1,58 @@ +#!/usr/bin/env bash +# Fixture tests for bin/orc-qa-video — Playwright results.json → ordered scenario +# plan, console/trace collection, and (ffmpeg present) the stitched QA video + +# chapters.json. Run from the repo root. +set -euo pipefail +repo_root="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)" +cli="$repo_root/orc/bin/orc-qa-video" +status=0; pass_count=0 +fail() { echo "verify-qa-video: FAIL — $1"; status=1; } +ok() { pass_count=$((pass_count + 1)); } +tmp="$(mktemp -d)"; trap 'rm -rf "$tmp"' EXIT +mkdir -p "$tmp/tr/a" "$tmp/tr/b" "$tmp/qa" +printf 'log a' > "$tmp/tr/a/console.log"; printf 'zip' > "$tmp/tr/a/trace.zip" +printf 'log b' > "$tmp/tr/b/console.log" +cat > "$tmp/results.json" <<EOF +{ "suites": [ + { "title": "auth.setup.ts", "specs": [ { "title": "authenticate", "file": "auth.setup.ts", + "tests": [ { "projectName": "setup", "results": [ { "status": "passed", "duration": 10, "attachments": [] } ] } ] } ] }, + { "title": "export", "suites": [ { "title": "CSV export", "specs": [ + { "title": "Export golden path @golden", "file": "export/golden.spec.ts", + "tests": [ { "projectName": "qa", "results": [ { "status": "passed", "duration": 1200, "attachments": [ + { "name": "video", "path": "$tmp/tr/a/video.webm", "contentType": "video/webm" }, + { "name": "console.log", "path": "$tmp/tr/a/console.log", "contentType": "text/plain" }, + { "name": "trace", "path": "$tmp/tr/a/trace.zip", "contentType": "application/zip" } ] } ] } ] }, + { "title": "Export empty state", "file": "export/empty.spec.ts", + "tests": [ { "projectName": "qa", "results": [ { "status": "failed", "duration": 800, "attachments": [ + { "name": "video", "path": "$tmp/tr/b/video.webm", "contentType": "video/webm" }, + { "name": "console.log", "path": "$tmp/tr/b/console.log", "contentType": "text/plain" } ] } ] } ] } ] } ] } ] } +EOF + +plan="$(bash "$cli" plan --results "$tmp/results.json")" +[ "$(printf '%s' "$plan" | jq 'length')" = "2" ] && ok || fail "plan must skip the setup project (got $(printf '%s' "$plan" | jq -c .))" +[ "$(printf '%s' "$plan" | jq -r '.[0].id')" = "export-golden-path-golden" ] && ok || fail "plan id slug" +[ "$(printf '%s' "$plan" | jq -r '.[1].outcome')" = "failed" ] && ok || fail "plan outcome" +[ "$(printf '%s' "$plan" | jq -r '.[0].video')" = "$tmp/tr/a/video.webm" ] && ok || fail "plan video path" + +bash "$cli" collect --results "$tmp/results.json" --qa-dir "$tmp/qa" >/dev/null +[ "$(cat "$tmp/qa/console-export-golden-path-golden.log")" = "log a" ] && ok || fail "collect console" +[ -f "$tmp/qa/trace-export-golden-path-golden.zip" ] && ok || fail "collect trace" +[ -f "$tmp/qa/console-export-empty-state.log" ] && ok || fail "collect second console" + +if command -v ffmpeg >/dev/null 2>&1 && command -v ffprobe >/dev/null 2>&1; then + ffmpeg -v error -y -f lavfi -i color=c=blue:s=320x180:d=1 -c:v libvpx "$tmp/tr/a/video.webm" + ffmpeg -v error -y -f lavfi -i color=c=red:s=320x180:d=1 -c:v libvpx "$tmp/tr/b/video.webm" + bash "$cli" stitch --results "$tmp/results.json" --qa-dir "$tmp/qa" --branch feat-x --card-seconds 1 >/dev/null + [ -s "$tmp/qa/qa-feat-x.webm" ] && ok || fail "stitch output" + [ "$(jq 'length' "$tmp/qa/chapters.json")" = "2" ] && ok || fail "chapters count" + jq -e '.[1].start > .[0].start and .[0].title == "Export golden path @golden"' "$tmp/qa/chapters.json" >/dev/null && ok || fail "chapters ordered with titles" + dur="$(ffprobe -v error -show_entries format=duration -of csv=p=0 "$tmp/qa/qa-feat-x.webm" | cut -d. -f1)" + [ "$dur" -ge 3 ] && ok || fail "stitched duration must cover cards + clips (got ${dur}s)" +else + echo "verify-qa-video: note — ffmpeg absent, stitch cases skipped" + set +e; bash "$cli" stitch --results "$tmp/results.json" --qa-dir "$tmp/qa" --branch feat-x >/dev/null 2>&1; rc=$?; set -e + [ "$rc" -eq 4 ] && ok || fail "stitch without ffmpeg must exit 4" +fi + +[ "$status" -eq 0 ] && echo "verify-qa-video: OK ($pass_count cases)" +exit "$status" From ae05d2d2984e8faf64820f35fda7902e7bf6e00e Mon Sep 17 00:00:00 2001 From: Higor Alves <me@higoralves.dev> Date: Tue, 6 Oct 2026 15:54:52 -0300 Subject: [PATCH 04/21] =?UTF-8?q?feat(explain):=20orc-explain=20toolchain?= =?UTF-8?q?=20=E2=80=94=20segments=20validation,=20kokoro-js=20narration,?= =?UTF-8?q?=20ffmpeg=20timeline=20assembly?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- orc/bin/orc-explain | 7 ++ orc/lib/explain/explain.sh | 127 +++++++++++++++++++++++++++++++++++ orc/lib/explain/narrate.mjs | 21 ++++++ orc/lib/explain/package.json | 3 + scripts/ci/verify-explain.sh | 46 +++++++++++++ 5 files changed, 204 insertions(+) create mode 100755 orc/bin/orc-explain create mode 100755 orc/lib/explain/explain.sh create mode 100644 orc/lib/explain/narrate.mjs create mode 100644 orc/lib/explain/package.json create mode 100755 scripts/ci/verify-explain.sh 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/lib/explain/explain.sh b/orc/lib/explain/explain.sh new file mode 100755 index 0000000..532ea65 --- /dev/null +++ b/orc/lib/explain/explain.sh @@ -0,0 +1,127 @@ +#!/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 +# narrate run kokoro-js → <work>/narration/<id>.wav +# assemble per-segment mp4 (clip | graphic from <work>/graphics/<id>.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" 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" "$home/" + if [ "${ORC_EXPLAIN_SKIP_NPM:-0}" != "1" ]; then + (cd "$home" && npm install --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 + local home; home="$(orc_ex__home)" + [ -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__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() { # <out.mp4> <title> <lines-joined-by-\n> <seconds> + 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 -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 -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() { # <video-in> <wav|""> <caption|""> <out.mp4> → 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 -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 -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 -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 "$@" ;; + narrate) orc_ex_narrate "$@" ;; + assemble) orc_ex_assemble "$@" ;; + --help|-h|help|'') printf 'usage: orc-explain validate --segments S | setup | 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..7e1d830 --- /dev/null +++ b/orc/lib/explain/narrate.mjs @@ -0,0 +1,21 @@ +// narrate.mjs <segments.json> <outDir> — 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 <segments.json> <outDir>'); process.exit(2); } +const doc = JSON.parse(fs.readFileSync(segmentsPath, 'utf8')); +const voice = doc.voice ?? process.env.ORC_EXPLAIN_VOICE ?? 'af_heart'; +const tts = await KokoroTTS.from_pretrained('onnx-community/Kokoro-82M-v1.0-ONNX', { dtype: 'q8' }); +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.json b/orc/lib/explain/package.json new file mode 100644 index 0000000..c064296 --- /dev/null +++ b/orc/lib/explain/package.json @@ -0,0 +1,3 @@ +{ "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.0" } } diff --git a/scripts/ci/verify-explain.sh b/scripts/ci/verify-explain.sh new file mode 100755 index 0000000..ebc337f --- /dev/null +++ b/scripts/ci/verify-explain.sh @@ -0,0 +1,46 @@ +#!/usr/bin/env bash +# shellcheck disable=SC2015 +# Fixture tests for bin/orc-explain — segments.json validation, cache setup +# (npm skipped), and the ffmpeg timeline assembly with fallback title cards. +set -euo pipefail +repo_root="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)" +cli="$repo_root/orc/bin/orc-explain" +status=0; pass_count=0 +fail() { echo "verify-explain: FAIL — $1"; status=1; } +ok() { pass_count=$((pass_count + 1)); } +tmp="$(mktemp -d)"; trap 'rm -rf "$tmp"' EXIT +export XDG_CACHE_HOME="$tmp/cache" ORC_EXPLAIN_SKIP_NPM=1 + +cat > "$tmp/good.json" <<'EOF' +{ "schema": 1, "branch": "feat-x", "style": "isometric", "voice": "af_heart", "segments": [ + { "id": "s01", "kind": "graphic", "title": "Hello", "lines": ["a", "b"], "narration": "Intro." }, + { "id": "s02", "kind": "clip", "source": "demo/clip.webm", "caption": "AC 1.1 — works", "narration": "Clip." } ] } +EOF +bash "$cli" validate --segments "$tmp/good.json" >/dev/null && ok || fail "validate good" +jq '.segments[1] |= del(.source)' "$tmp/good.json" > "$tmp/bad1.json" +set +e; err="$(bash "$cli" validate --segments "$tmp/bad1.json" 2>&1)"; rc=$?; set -e +[ "$rc" -eq 2 ] && printf '%s' "$err" | grep -q 's02' && ok || fail "clip without source must exit 2 naming s02" +jq '.segments[0] |= del(.narration)' "$tmp/good.json" > "$tmp/bad2.json" +set +e; err="$(bash "$cli" validate --segments "$tmp/bad2.json" 2>&1)"; rc=$?; set -e +[ "$rc" -eq 2 ] && printf '%s' "$err" | grep -q 's01' && ok || fail "missing narration must exit 2 naming s01" + +bash "$cli" setup >/dev/null +[ -f "$XDG_CACHE_HOME/orc/explain/narrate.mjs" ] && [ -f "$XDG_CACHE_HOME/orc/explain/package.json" ] && ok || fail "setup must stage narrate.mjs + package.json" + +if command -v ffmpeg >/dev/null 2>&1 && command -v ffprobe >/dev/null 2>&1; then + mkdir -p "$tmp/qa/demo" "$tmp/work/narration" + ffmpeg -v error -y -f lavfi -i color=c=green:s=320x180:d=1 -c:v libvpx "$tmp/qa/demo/clip.webm" + ffmpeg -v error -y -f lavfi -i anullsrc=r=24000:cl=mono -t 3 "$tmp/work/narration/s01.wav" + ffmpeg -v error -y -f lavfi -i anullsrc=r=24000:cl=mono -t 2 "$tmp/work/narration/s02.wav" + bash "$cli" assemble --segments "$tmp/good.json" --work "$tmp/work" --qa-dir "$tmp/qa" --out "$tmp/qa/explainer-feat-x.mp4" >/dev/null + [ -s "$tmp/qa/explainer-feat-x.mp4" ] && ok || fail "assemble output" + d="$(ffprobe -v error -show_entries format=duration -of csv=p=0 "$tmp/qa/explainer-feat-x.mp4" | cut -d. -f1)" + [ "$d" -ge 4 ] && [ "$d" -le 6 ] && ok || fail "duration must be ≈ 3s (card padded to narration) + 2s (clip padded to narration) (got ${d}s)" + ffprobe -v error -select_streams a -show_entries stream=codec_type -of csv=p=0 "$tmp/qa/explainer-feat-x.mp4" | grep -q audio && ok || fail "output must carry an audio track" +else + echo "verify-explain: note — ffmpeg absent, assemble cases skipped" + set +e; bash "$cli" assemble --segments "$tmp/good.json" --work "$tmp/work" --qa-dir "$tmp" --out "$tmp/o.mp4" >/dev/null 2>&1; rc=$?; set -e + [ "$rc" -eq 4 ] && ok || fail "assemble without ffmpeg must exit 4" +fi +[ "$status" -eq 0 ] && echo "verify-explain: OK ($pass_count cases)" +exit "$status" From fe977f331d7c4ed55dddc9e15a9608c34cf83370 Mon Sep 17 00:00:00 2001 From: Higor Alves <me@higoralves.dev> Date: Tue, 6 Oct 2026 15:55:26 -0300 Subject: [PATCH 05/21] =?UTF-8?q?feat(qa):=20orc-playwright=20=E2=80=94=20?= =?UTF-8?q?detect/scaffold=20e2e=20project,=20init-agents,=20non-clobberin?= =?UTF-8?q?g=20.mcp.json=20merge?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- orc/bin/orc-playwright | 7 ++ orc/lib/playwright-env.sh | 88 ++++++++++++++++++++ orc/lib/playwright/auth.setup.ts.tmpl | 24 ++++++ orc/lib/playwright/fixtures.ts.tmpl | 15 ++++ orc/lib/playwright/gitignore.tmpl | 3 + orc/lib/playwright/package.json.tmpl | 7 ++ orc/lib/playwright/playwright.config.ts.tmpl | 55 ++++++++++++ orc/lib/playwright/seed.spec.ts.tmpl | 9 ++ orc/lib/playwright/specs-README.md.tmpl | 3 + scripts/ci/verify-playwright-env.sh | 49 +++++++++++ 10 files changed, 260 insertions(+) create mode 100755 orc/bin/orc-playwright create mode 100755 orc/lib/playwright-env.sh create mode 100644 orc/lib/playwright/auth.setup.ts.tmpl create mode 100644 orc/lib/playwright/fixtures.ts.tmpl create mode 100644 orc/lib/playwright/gitignore.tmpl create mode 100644 orc/lib/playwright/package.json.tmpl create mode 100644 orc/lib/playwright/playwright.config.ts.tmpl create mode 100644 orc/lib/playwright/seed.spec.ts.tmpl create mode 100644 orc/lib/playwright/specs-README.md.tmpl create mode 100755 scripts/ci/verify-playwright-env.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/lib/playwright-env.sh b/orc/lib/playwright-env.sh new file mode 100755 index 0000000..4af5b82 --- /dev/null +++ b/orc/lib/playwright-env.sh @@ -0,0 +1,88 @@ +#!/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__tmpl_dir() { printf '%s/playwright\n' "$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"; } + +orc_pw_detect() { # <repo_root> → "<dir>\t<config>" | 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() { # <dest> <template-name> <rel> → prints "created <rel>" | "kept <rel>" + 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() { # <repo_root> [--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() { # <repo_root> --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() { # <repo_root> --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="$root/.mcp.json.orc-backup-$(date -u +%Y%m%dT%H%M%SZ)"; cp "$root/.mcp.json" "$bak"; 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() { # <repo_root> --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 + cp "$f" "$f.orc-backup-$(date -u +%Y%m%dT%H%M%SZ)" + 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 <root>|scaffold <root> [--dir D]|install <root> --dir D|init-agents <root> --dir D --config C|mcp-merge <root> --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..1511688 --- /dev/null +++ b/orc/lib/playwright/auth.setup.ts.tmpl @@ -0,0 +1,24 @@ +// 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 user = users[process.env.ORC_QA_USER ?? ''] ?? 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 <name>) — 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 (`<feature>.md`). Reviewed at the /orc:qa plan gate; the generator turns each into tests/<feature>/*.spec.ts. diff --git a/scripts/ci/verify-playwright-env.sh b/scripts/ci/verify-playwright-env.sh new file mode 100755 index 0000000..c2fcbb1 --- /dev/null +++ b/scripts/ci/verify-playwright-env.sh @@ -0,0 +1,49 @@ +#!/usr/bin/env bash +# Fixture tests for bin/orc-playwright — detect / scaffold / mcp-merge for the +# committed e2e/ Playwright project orc's Driver P runs. No npm, no network. +# shellcheck disable=SC2015 # `cond && ok || fail` is the intended assert idiom +set -euo pipefail +repo_root="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)" +cli="$repo_root/orc/bin/orc-playwright" +status=0; pass_count=0 +fail() { echo "verify-playwright-env: FAIL — $1"; status=1; } +ok() { pass_count=$((pass_count + 1)); } +tmp="$(mktemp -d)"; trap 'rm -rf "$tmp"' EXIT + +# detect: nothing → exit 1 +mkdir -p "$tmp/r1"; bash "$cli" detect "$tmp/r1" >/dev/null 2>&1 && fail "detect must exit 1 with no project" || ok +# detect: root config +mkdir -p "$tmp/r2"; : > "$tmp/r2/playwright.config.ts" +[ "$(bash "$cli" detect "$tmp/r2")" = $'.\tplaywright.config.ts' ] && ok || fail "detect root config" +# detect: e2e config wins when both absent at root +mkdir -p "$tmp/r3/e2e"; : > "$tmp/r3/e2e/playwright.config.ts" +[ "$(bash "$cli" detect "$tmp/r3")" = $'e2e\te2e/playwright.config.ts' ] && ok || fail "detect e2e config" + +# scaffold: creates the set, never overwrites +mkdir -p "$tmp/r4" +out="$(bash "$cli" scaffold "$tmp/r4")" +for f in e2e/package.json e2e/playwright.config.ts e2e/tests/fixtures.ts e2e/tests/auth.setup.ts e2e/tests/seed.spec.ts e2e/.gitignore e2e/specs/README.md; do + [ -f "$tmp/r4/$f" ] && ok || fail "scaffold missing $f" +done +printf '%s' "$out" | grep -q 'created e2e/playwright.config.ts' && ok || fail "scaffold must report created files" +grep -q "from './fixtures'" "$tmp/r4/e2e/tests/seed.spec.ts" && ok || fail "seed must import the console-capturing fixtures" +printf 'custom' > "$tmp/r4/e2e/tests/seed.spec.ts" +out2="$(bash "$cli" scaffold "$tmp/r4")"; printf '%s' "$out2" | grep -q 'kept e2e/tests/seed.spec.ts' && [ "$(cat "$tmp/r4/e2e/tests/seed.spec.ts")" = "custom" ] && ok || fail "scaffold must not overwrite" +grep -q 'ORC_TARGET_JSON' "$tmp/r4/e2e/playwright.config.ts" && ok || fail "config must read ORC_TARGET_JSON" +grep -q "level: 'step'" "$tmp/r4/e2e/playwright.config.ts" && ok || fail "config must enable step overlays" +grep -q "name: 'demo'" "$tmp/r4/e2e/playwright.config.ts" && ok || fail "config must define the demo project" +jq -e '.devDependencies["@playwright/test"]' "$tmp/r4/e2e/package.json" >/dev/null && ok || fail "package.json must pin @playwright/test" + +# mcp-merge: preserves existing servers, backs up, sets our entry +mkdir -p "$tmp/r5" +printf '{"mcpServers":{"other":{"command":"x","args":[]}}}' > "$tmp/r5/.mcp.json" +bash "$cli" mcp-merge "$tmp/r5" --dir e2e --config e2e/playwright.config.ts >/dev/null +jq -e '.mcpServers.other.command == "x"' "$tmp/r5/.mcp.json" >/dev/null && ok || fail "mcp-merge must keep existing servers" +jq -e '.mcpServers["playwright-test"].command == "node" and .mcpServers["playwright-test"].args == ["e2e/node_modules/playwright/cli.js","run-test-mcp-server","-c","e2e/playwright.config.ts"]' "$tmp/r5/.mcp.json" >/dev/null && ok || fail "mcp-merge entry shape" +ls "$tmp/r5"/.mcp.json.orc-backup-* >/dev/null 2>&1 && ok || fail "mcp-merge must back up the previous file" +# mcp-merge with no prior file +mkdir -p "$tmp/r6"; bash "$cli" mcp-merge "$tmp/r6" --dir . --config playwright.config.ts >/dev/null +jq -e '.mcpServers["playwright-test"].args[0] == "node_modules/playwright/cli.js"' "$tmp/r6/.mcp.json" >/dev/null && ok || fail "mcp-merge root dir path" + +[ "$status" -eq 0 ] && echo "verify-playwright-env: OK ($pass_count cases)" +exit "$status" From 2f305585425dde33ff7aa6f11e5851723d2c34aa Mon Sep 17 00:00:00 2001 From: Higor Alves <me@higoralves.dev> Date: Tue, 6 Oct 2026 15:56:38 -0300 Subject: [PATCH 06/21] ci: wire targets/playwright/qa-video/explain fixtures; shellcheck covers lib/explain --- .github/workflows/validate.yml | 12 ++++++++++++ orc/lib/qa-video.sh | 2 +- scripts/ci/shellcheck.sh | 1 + scripts/ci/verify-qa-video.sh | 1 + 4 files changed, 15 insertions(+), 1 deletion(-) diff --git a/.github/workflows/validate.yml b/.github/workflows/validate.yml index b1919db..28eda2f 100644 --- a/.github/workflows/validate.yml +++ b/.github/workflows/validate.yml @@ -78,6 +78,18 @@ 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: statusline render fixtures run: bash scripts/ci/verify-statusline.sh diff --git a/orc/lib/qa-video.sh b/orc/lib/qa-video.sh index 9576f91..83ad87d 100755 --- a/orc/lib/qa-video.sh +++ b/orc/lib/qa-video.sh @@ -1,5 +1,5 @@ #!/usr/bin/env bash -# shellcheck disable=SC2016 +# 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-<branch>.webm (title card + clip per scenario) diff --git a/scripts/ci/shellcheck.sh b/scripts/ci/shellcheck.sh index 5565a7a..9307ad1 100755 --- a/scripts/ci/shellcheck.sh +++ b/scripts/ci/shellcheck.sh @@ -10,6 +10,7 @@ cd "$repo_root" shellcheck \ orc/hooks/scripts/*.sh \ orc/lib/*.sh \ + orc/lib/explain/*.sh \ orc/bin/* \ scripts/ci/*.sh diff --git a/scripts/ci/verify-qa-video.sh b/scripts/ci/verify-qa-video.sh index 02d82aa..d51b100 100755 --- a/scripts/ci/verify-qa-video.sh +++ b/scripts/ci/verify-qa-video.sh @@ -2,6 +2,7 @@ # Fixture tests for bin/orc-qa-video — Playwright results.json → ordered scenario # plan, console/trace collection, and (ffmpeg present) the stitched QA video + # chapters.json. Run from the repo root. +# shellcheck disable=SC2015 # `A && ok || fail` is the fixture idiom; ok never fails set -euo pipefail repo_root="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)" cli="$repo_root/orc/bin/orc-qa-video" From 7b891eff351511919360d59b16bfa8124765aad8 Mon Sep 17 00:00:00 2001 From: Higor Alves <me@higoralves.dev> Date: Tue, 6 Oct 2026 15:58:39 -0300 Subject: [PATCH 07/21] =?UTF-8?q?feat(qa):=20manifest=20v2=20=E2=80=94=20p?= =?UTF-8?q?laywright=20driver,=20target,=20video=20chapters;=20report=20re?= =?UTF-8?q?nders=20chapter=20seek=20list?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- orc/lib/report-template.html | 18 +++++++++++++++++- orc/lib/report.sh | 3 ++- orc/skills/state-protocol/references/schema.md | 9 ++++++++- scripts/ci/verify-report.sh | 18 ++++++++++++++++++ 4 files changed, 45 insertions(+), 3 deletions(-) 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; } </style> @@ -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/skills/state-protocol/references/schema.md b/orc/skills/state-protocol/references/schema.md index 924ebd9..c2e9ff3 100644 --- a/orc/skills/state-protocol/references/schema.md +++ b/orc/skills/state-protocol/references/schema.md @@ -95,6 +95,10 @@ Written by whichever browser driver ran (`orc:browser-qa`), and the only machine ```json { "schema": 1, "driver": "agent-browser", "generatedAt": "…", "verdict": "pass", "artifacts": [{ "file": "screenshot-01-loaded.png", "role": "golden path step 1" }], + "target": { "name": "staging", "baseUrl": "https://staging.example.com", "guard": true }, + "video": { "file": "qa-feat-export.webm", + "chapters": [{ "id": "golden", "title": "Export golden path @golden", "outcome": "passed", "start": 2.0, "end": 9.5 }] }, + "specs": ["e2e/specs/export.md"], "tests": ["e2e/tests/export/golden.spec.ts"], "curated": ["qa-feat-export.webm", "ac-3-1-export-202.png"], "acceptance": [ { "id": "slice-3-ac-1", "sliceId": 3, "criterion": "POST /export returns 202 + Location header", @@ -104,7 +108,10 @@ Written by whichever browser driver ran (`orc:browser-qa`), and the only machine } ``` -- `driver` enum: `agent-browser` · `chrome`. +- `driver` enum: `agent-browser` · `chrome` · `playwright`. +- `target` (playwright driver): `{ name, baseUrl, guard }` from `orc:qa-targets` — never credentials. +- `video` (playwright driver): `{ file, chapters: [{ id, title, outcome, start, end }] }` — one chapter per scenario in the stitched recording; an acceptance `evidence` entry may be `qa-<branch>.webm#t=<start>`. +- `specs[]` / `tests[]` (playwright driver): repo-relative paths committed with the change; linked from the PR comment. - `result` enum: `pass` · `fail` · `skipped`. A `skipped` row MUST carry a non-empty `note` — un-scoreable criteria stay visible rather than silently dropping out of the verdict. - `evidence` paths are relative to the packet dir. Driver A names an `ac-<sliceId>-<idx>-<slug>.png`; Driver B, which cannot write binary from the session, names `qa-<branch>.gif#step-<N>` pointing at the numbered heading in `steps.md`. - `curated` is the publish payload — `orc:evidence-publish` takes it verbatim. diff --git a/scripts/ci/verify-report.sh b/scripts/ci/verify-report.sh index e475517..abcaeab 100644 --- a/scripts/ci/verify-report.sh +++ b/scripts/ci/verify-report.sh @@ -61,6 +61,24 @@ if grep -q 'id="orc-report-data"' "$html" && grep -q 'Export returns 202' "$html if grep -qF '</script><b>' "$html"; then fail "html: '</script>' in data must be escaped"; else ok; fi if grep -q '__ORC_REPORT_DATA__' "$html"; then fail "html: placeholder left in output"; else ok; fi +# Playwright-driver packet: video chapters + target pass through +cat > "$files/qa/qa-manifest.json" <<'EOF' +{ "schema": 1, "driver": "playwright", "generatedAt": "2026-10-06T00:00:00Z", "verdict": "pass", + "target": { "name": "staging", "baseUrl": "https://staging.example.com", "guard": true }, + "artifacts": [{ "file": "qa-feat-x.webm", "role": "stitched recording" }], + "video": { "file": "qa-feat-x.webm", "chapters": [ { "id": "golden", "title": "Export golden path @golden", "outcome": "passed", "start": 2.0, "end": 9.5 } ] }, + "curated": ["qa-feat-x.webm"], "specs": ["e2e/specs/export.md"], "tests": ["e2e/tests/export/golden.spec.ts"], + "acceptance": [ { "id": "slice-1-ac-1", "sliceId": 1, "criterion": "Export returns 202", "result": "pass", "evidence": ["qa-feat-x.webm#t=2.0"] } ], + "summary": "ok" } +EOF +printf 'webm' > "$files/qa/qa-feat-x.webm" +json="$(bash "$orc_report" json --branch feat/x)" +check "playwright driver passes through" '.driver == "playwright" and .target.name == "staging"' +check "video chapters pass through" '(.video.chapters | length) == 1 and .video.chapters[0].start == 2.0' +check "evidence with #t anchor resolves to the file" '.missing == []' +path="$(bash "$orc_report" html --branch feat/x)" +if grep -q 'Export golden path' "$html" && grep -q 'chapters' "$html"; then ok; else fail "html: chapters must render"; fi + if [ "$status" -eq 0 ]; then echo "verify-report: OK ($pass_count cases)" fi From b5ec1d86e2c1303fea7f0f730d2931e6da8d530d Mon Sep 17 00:00:00 2001 From: Higor Alves <me@higoralves.dev> Date: Tue, 6 Oct 2026 15:58:48 -0300 Subject: [PATCH 08/21] =?UTF-8?q?feat(evidence):=20GitHub=20PR-comment=20a?= =?UTF-8?q?dapter=20=E2=80=94=20verdict=20table=20+=20links=20behind=20a?= =?UTF-8?q?=20hard-outward=20Post=20gate;=20ship=20offers=20it=20after=20P?= =?UTF-8?q?R=20open?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- orc/commands/ship.md | 2 ++ orc/skills/evidence-publish/SKILL.md | 7 ++++ .../references/adapter-contract.md | 2 +- .../references/github-pr-adapter.md | 33 +++++++++++++++++++ .../flow-phases/references/PHASE-7-SHIP.md | 1 + 5 files changed, 44 insertions(+), 1 deletion(-) create mode 100644 orc/skills/evidence-publish/references/github-pr-adapter.md diff --git a/orc/commands/ship.md b/orc/commands/ship.md index 79568d6..177ebae 100644 --- a/orc/commands/ship.md +++ b/orc/commands/ship.md @@ -129,6 +129,8 @@ gh pr create --title "<title>" --body "<body>" [--draft] [--base <base>] Echo the PR URL. +When `${ORC_STATE_DIR}/<sanitized-branch>/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/skills/evidence-publish/SKILL.md b/orc/skills/evidence-publish/SKILL.md index f0f134c..6a0ead9 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 @@ -61,6 +63,8 @@ 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 <url>` 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 #<n>?** — `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. @@ -68,6 +72,8 @@ Artifact target available ⇒ the same `AskUserQuestion` call carries a second q - **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. +- **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 Append to `steps.md`: @@ -79,6 +85,7 @@ Append to `steps.md`: - Attached: <file list | none (comment-only — set JIRA_API_TOKEN to attach) | none (local)> - Comment: posted | n/a - Report: <qaDir>/report.html | artifact <url> +- PR comment: posted <url> | 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 '\n<sub>Full packet: `.orc/<branch>/files/qa/` (local).</sub>\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 <url>`; 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/flow-phases/references/PHASE-7-SHIP.md b/orc/skills/flow-phases/references/PHASE-7-SHIP.md index 9163b5e..6c2396d 100644 --- a/orc/skills/flow-phases/references/PHASE-7-SHIP.md +++ b/orc/skills/flow-phases/references/PHASE-7-SHIP.md @@ -12,6 +12,7 @@ Flow-specific deltas from standalone `/orc:ship`: - `orc:git-commit` (if uncommitted) - PR composition: `orc:caveman-pr` by default; the long-form template only if `--verbose` was passed - `gh pr create` — UNLESS this repo stacked in the size gate, in which case `/orc:stack-pr` already opened the PRs and Phase 7 only records the stack metadata in `linkedPRs`. +- When `${ORC_STATE_DIR}/<sanitized-branch>/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. Per-repo size-gate decisions are independent: in workspace mode, repo `api` can stack while `ui` opens single with an override. Record each decision in `checkpoint.md` (so `/orc:resume` knows we already gated this repo). From 933a54c832d2d53ae2cc46c219cb4c9ff1534921 Mon Sep 17 00:00:00 2001 From: Higor Alves <me@higoralves.dev> Date: Tue, 6 Oct 2026 15:59:15 -0300 Subject: [PATCH 09/21] feat(ship): allow gh pr comment/view/auth for the evidence-publish PR step --- orc/commands/ship.md | 3 +++ 1 file changed, 3 insertions(+) diff --git a/orc/commands/ship.md b/orc/commands/ship.md index 177ebae..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:*) From 2537787da6750f9cf240ff7f2d191bf87b7e47e0 Mon Sep 17 00:00:00 2001 From: Higor Alves <me@higoralves.dev> Date: Tue, 6 Oct 2026 15:59:06 -0300 Subject: [PATCH 10/21] =?UTF-8?q?feat(qa):=20qa-targets=20skill=20?= =?UTF-8?q?=E2=80=94=20named=20targets,=20Target=20gate,=20credential=20re?= =?UTF-8?q?ferences;=20--target=20on=20qa/evidence/flow?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- orc/commands/evidence.md | 7 ++++- orc/commands/flow.md | 5 +-- orc/commands/qa.md | 7 ++++- orc/skills/qa-targets/SKILL.md | 56 ++++++++++++++++++++++++++++++++++ 4 files changed, 71 insertions(+), 4 deletions(-) create mode 100644 orc/skills/qa-targets/SKILL.md diff --git a/orc/commands/evidence.md b/orc/commands/evidence.md index 38cfbd5..f45228f 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: "[<TICKET-KEY>] [--web <url>] [--driver chrome|agent-browser] [--context \"<what to test>\"] [--no-env] [--repos a,b | --repo a | --this-repo]" +argument-hint: "[<TICKET-KEY>] [--target <name>] [--web <url>] [--driver chrome|agent-browser] [--context \"<what to test>\"] [--no-env] [--repos a,b | --repo a | --this-repo]" arguments: [ticket] allowed-tools: - Bash(orc-state:*) @@ -21,6 +21,10 @@ 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:*) --- # /orc:evidence @@ -33,6 +37,7 @@ Collection **reuses `/orc:qa`'s browser drivers verbatim**; delivery reuses the - `<TICKET-KEY>` — the ticket to scope from and (on upload) deliver to. Omit to use the session's bound `jiraTicket`, or to run context-only. - `--context "<what to test>"` — describe the flow to exercise, instead of (or on top of) pulling it from the ticket. +- `--target <name>` — 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 <url>` — 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. - `--no-env` — skip Docker provisioning; use `--web` or a legacy boot. diff --git a/orc/commands/flow.md b/orc/commands/flow.md index 17a8b22..f890ecd 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 <KEY> links a ticket. Workspace-aware. -argument-hint: "[--auto[=guided|full]] [--type=feature|bug|refactor|docs] [--rfc] [--verbose] [--pause-at-implement] [--jira <KEY>] [--max-loc <N>] [--no-size-gate] [--driver agent-browser|chrome] [--repos a,b | --repo a | --all-repos | --this-repo] <one-line task description>" +argument-hint: "[--auto[=guided|full]] [--type=feature|bug|refactor|docs] [--rfc] [--verbose] [--pause-at-implement] [--jira <KEY>] [--max-loc <N>] [--no-size-gate] [--driver agent-browser|chrome] [--target <name>] [--repos a,b | --repo a | --all-repos | --this-repo] <one-line task description>" allowed-tools: - Bash(orc-state:*) - Bash(orc-report:*) @@ -47,6 +47,7 @@ Interactivity follows the gate taxonomy (`orc:using-orc`) and the resolved `inte - `--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. +- `--target <name>` — pre-answers Phase 6's Target gate (`orc:qa-targets`); recorded as a `target` decision. - `--pause-at-implement` — Phase 5 pauses for the human to write the code (keeps Phase 4's red-confirm gate). - `--jira <KEY>` — link a Jira ticket silently (validate `^[A-Z][A-Z0-9_]*-\d+$`); lands as `Resolves <KEY>` in the PR body. - `--max-loc <N>` / `--no-size-gate` — pass-through to `/orc:ship`'s Phase 4.5 size gate (single owner; flow never pre-flights it). @@ -138,7 +139,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..9537ed5 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 <url>] [--no-web] [--no-env] [--driver agent-browser|chrome] [--repos a,b | --repo a | --all-repos | --this-repo] <feature description>" +argument-hint: "[--auto[=guided|full]] [--target <name>] [--web <url>] [--no-web] [--no-env] [--driver playwright|agent-browser|chrome] [--repos a,b | --repo a | --all-repos | --this-repo] <feature description>" allowed-tools: - Bash(orc-state:*) - Bash(orc-report:*) @@ -27,6 +27,10 @@ allowed-tools: - Bash(acli:*) - Bash(jq:*) - Bash(git branch --show-current:*) + - Bash(orc-targets:*) + - Bash(npx playwright:*) + - Bash(orc-playwright:*) + - Bash(orc-qa-video:*) effort: high --- @@ -41,6 +45,7 @@ Run a quality gate before opening a PR. Two modes: - `--auto[=guided|full]` — autopilot level for this run (overrides `interaction_policy`); ladder and hard-outward exemption in `orc:gates` §6. +- `--target <name>` — 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 <url>` remains as the ad-hoc remote shorthand. - `--web <url>` — 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. diff --git a/orc/skills/qa-targets/SKILL.md b/orc/skills/qa-targets/SKILL.md new file mode 100644 index 0000000..63d30c3 --- /dev/null +++ b/orc/skills/qa-targets/SKILL.md @@ -0,0 +1,56 @@ +--- +name: qa-targets +description: Resolve WHERE browser QA runs — named targets in .orc/targets.json (local, staging, preview), the Target gate, credential references (literal | op:// | env:), guard rule. Use before env provisioning in /orc:qa, /orc:flow Phase 6, /orc:evidence. +--- + +# QA targets + +Browser QA runs against a **target**: `local` (the Docker env `orc-env-provisioner` boots) or a remote URL such as staging or a preview deploy. Targets live in `<repo>/.orc/targets.json` (gitignored, repo-level — not per branch), managed by `orc-targets` (plugin `bin/`, on PATH while orc is enabled). + +**Announce at start:** "I'm using the qa-targets skill to resolve where browser QA runs." + +## Registry shape + +```json +{ "schema": 1, "targets": { + "local": { "kind": "local", "baseUrl": null, "healthPath": "/", "guard": false, "users": {}, "login": null }, + "staging": { "kind": "remote", "baseUrl": "https://staging.example.com", "healthPath": "/healthz", "guard": true, + "users": { "admin": { "username": "env:STAGING_ADMIN_USER", "password": "op://Eng/staging-admin/password" } }, + "login": { "path": "/login", "usernameField": "Email", "passwordField": "Password", "submit": "Sign in", "successPath": "/dashboard" } } } } +``` + +- `kind` — `local` is provisioned by `orc:browser-qa` Step 0 (today's path, unchanged); `remote` is probed, never provisioned. +- `guard: true` — a shared environment. The planner is told to tag mutating scenarios `@mutating`; on a guarded target those run as `skipped` with note `guarded target` — never silently dropped, never executed. +- `users.<name>.{username,password}` — **references, not values**: a literal, `op://vault/item/field` (1Password CLI, `op read`), or `env:VAR`. `orc-targets resolve <name>` substitutes them at run time and prints JSON for `ORC_TARGET_JSON`; the resolved JSON is **never written to disk**. Unresolvable ⇒ exit 3 ⇒ an escalation, not a skip. +- `login` — the recipe `tests/auth.setup.ts` follows once per run in the video-off `setup` project; the session is saved as `storageState` and reused by every scenario. Field hints match by label or placeholder text. + +## Resolution order (every caller, same order) + +1. `--target <name>` flag → use it silently (`orc-state decision set target <name> --provenance flag`). +2. `--web <url>` flag (kept for compatibility) → an ad-hoc remote target: `orc-targets resolve local --base-url <url>`; recorded as `target=adhoc:<url>`. +3. A settled `target` decision in the session → reuse silently; re-runs keep it. +4. Otherwise the **Target gate** below. `guided`/`full` autopilot: `local` (policy decision, printed one-liner) — a remote target is a judgment call and is never auto-picked. + +## The Target gate (soft-inward, header chip `Target`) + +Run `orc-targets init && orc-targets list`, then: + +```markdown +> **⛔ Gate — QA target** +> +> Where should browser QA run? `local` boots the Docker env; remote targets are probed first and never provisioned. +``` + +`AskUserQuestion` (header `Target`), options in this order — `local` first, each remote row as `<name> — <baseUrl>` with `(read-only)` appended when `guard` is true, and last **New remote URL** (follow-up free text: URL, optional name to save, guard yes/no, optional user as `env:`/`op://` references — then `orc-targets set|user|login` and `resolve`). Record the answer: `orc-state decision set target <name> --provenance asked`. + +## After the gate + +- `kind: local` → continue to `orc:browser-qa` Step 0 (env attach/provision). `appUrl` from `docker-env-state.json` becomes `baseUrl` for the run (`orc-targets resolve local --base-url <appUrl>`). +- `kind: remote` → `orc-targets probe <name>`; failure is an **environment escalation** (`🛑 Escalation — target unreachable`: name, URL, curl exit) — stop, never fall back to local silently. +- Export for the engine: `ORC_TARGET_JSON="$(orc-targets resolve <name> [--base-url …])"`. Pass the **name** and `guard` to the validator/planner prompts; pass the JSON only through the environment of the `npx playwright test` call. + +## Redaction rules (iron) + +- Resolved credentials appear in no packet file: not `steps.md`, not `qa-manifest.json`, not the PR comment, not the explainer script. Reference the user by its registry name (`as admin`). +- The `setup` project records no video and no trace (config template, Slice 2) so the password is never typed on camera; scenario traces contain the session cookie only — acceptable for test environments, noted in `steps.md` as `Auth: storageState from setup project (user: admin)`. +- `op://` reads go through `op read` only; orc never caches the value. From f230879c095a993e5d109a5c4234f44ea8737628 Mon Sep 17 00:00:00 2001 From: Higor Alves <me@higoralves.dev> Date: Tue, 6 Oct 2026 15:58:59 -0300 Subject: [PATCH 11/21] =?UTF-8?q?feat(qa):=20playwright-qa=20skill=20?= =?UTF-8?q?=E2=80=94=20Driver=20P=20protocol=20with=20stock=20Playwright?= =?UTF-8?q?=20agents,=20plan=20gate,=20healer=20cap,=20stitched=20evidence?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- orc/skills/playwright-qa/SKILL.md | 80 +++++++++++++++++++ .../references/dispatch-prompts.md | 31 +++++++ 2 files changed, 111 insertions(+) create mode 100644 orc/skills/playwright-qa/SKILL.md create mode 100644 orc/skills/playwright-qa/references/dispatch-prompts.md diff --git a/orc/skills/playwright-qa/SKILL.md b/orc/skills/playwright-qa/SKILL.md new file mode 100644 index 0000000..5b34aee --- /dev/null +++ b/orc/skills/playwright-qa/SKILL.md @@ -0,0 +1,80 @@ +--- +name: playwright-qa +description: Driver P of orc:browser-qa — browser QA with Playwright Test and the stock planner/generator/healer agents against a resolved QA target; yields a stitched step-tagged video, traces, and qa-manifest.json. Use when browser-qa selects the playwright driver; falls back to agent-browser. +--- + +# Playwright QA (Driver P) + +**Announce at start:** "I'm using the playwright-qa skill to run browser QA with Playwright." + +Inputs from `orc:browser-qa`: feature description, `<qa-dir>`, the acceptance lists (step 2), the resolved target name + `guard` (see `orc:qa-targets`), `ORC_TARGET_JSON` in the environment, `isVisual`. Output: the packet in `<qa-dir>` with `qa-manifest.json` (`driver: "playwright"`). + +## 0. Preconditions → fallback + +`command -v node` and `command -v npx` must succeed. Otherwise print `➡️ Driver P unavailable (node missing) — falling back to agent-browser` and return `fallback` to the caller (which runs Driver A). Never install Node. + +## 1. Locate or create the project (setup gate on first creation) + +```bash +proj="$(orc-playwright detect "$repoPath")" || proj="" +``` + +- Found → `dir=${proj%%$'\t'*}`, `cfg=${proj#*$'\t'}`. If `<dir>/tests/fixtures.ts` is missing, `orc-playwright scaffold "$repoPath" --dir "$dir"` adds only the missing orc files (never overwrites) and say which were added. +- Not found → **Setup gate** (soft-inward, header `Setup`; `guided`/`full` pre-approve it): + +```markdown +> **⛔ Gate — Playwright project** +> +> No Playwright project in this repo. orc can create a self-contained `e2e/` (package.json, config with qa/demo/setup projects, console fixtures, seed) — committed with your change — then install `@playwright/test` and Chromium (~150 MB) and write the planner/generator/healer agents + `.mcp.json` (merged, backed up). +``` + +Options: **Create `e2e/` and install (Recommended)** · **Use agent-browser instead for this run** · **Abort QA**. On create: `orc-playwright scaffold "$repoPath"`, `orc-playwright install "$repoPath" --dir e2e`, `orc-playwright init-agents "$repoPath" --dir e2e --config e2e/playwright.config.ts`. Then say: `➡️ Playwright agents written to .claude/agents/ and .mcp.json — reload MCP (/mcp) before the planner runs`. If the `playwright-test` MCP server is not connected after reload, return `fallback`. + +If `.claude/agents/playwright-test-planner.md` is missing on a found project, run `init-agents` the same way (gate as above, minus the install line). + +## 2. Plan (planner agent) → plan gate + +Dispatch `playwright-test-planner` via `Task` with the prompt in `references/dispatch-prompts.md#planner`: the feature description, the acceptance list (id + text), the target name, the guard flag, the seed `<dir>/tests/seed.spec.ts`, and the spec path `<dir>/specs/<feature-slug>.md`. The planner saves the spec via `planner_save_plan`. + +**Plan gate** (soft-inward, header `Plan`; pre-approved under `guided`/`full` with the one-liner `➡️ auto: plan approved — <N> scenarios`): preview = the spec file. Options: **Approve plan (Recommended)** · **Edit, then re-plan** (user edits the file or says what to change; re-dispatch with the delta) · **Skip web QA** (logged, only with rationale). + +Re-runs on the same branch with an existing `specs/<feature-slug>.md`: skip planning unless new acceptance criteria appeared (compare criterion ids against the spec's `AC` mentions) — then dispatch the planner with "extend the existing plan, do not rewrite". + +## 3. Generate (generator agent) + +For each scenario in the spec: dispatch `playwright-test-generator` with `#generator` — includes the mandatory `test.step('AC <sliceId>.<idx> — <criterion>', …)` wrapping rule (one step per criterion moment, plain steps for other actions), the `@golden` / `@mutating` tag rule in the test title, and `import { test, expect } from '../fixtures'`. Run generators for independent scenarios in parallel (`orc:dispatching-parallel-agents`). + +## 4. Run + +```bash +export ORC_PW_OUTPUT_DIR="<qa-dir>/pw" # outside the repo; never committed +( cd "$repoPath/$dir" && ORC_TARGET_JSON="$ORC_TARGET_JSON" npx playwright test --project=qa $( [ "$guard" = true ] && printf -- '--grep-invert @mutating' ) ) +``` + +Guarded target ⇒ every `@mutating` scenario becomes a manifest `skipped` row with note `guarded target <name>`. + +## 5. Heal (healer agent, cap 3 per test) + +Any failed test → dispatch `playwright-test-healer` with `#healer`, naming the failing test and `<qa-dir>/pw/results.json`. Re-run `--project=qa` after it returns. **At most 3 heal dispatches per test**; a test still failing, or one the healer marked `test.fixme()`, maps to `result: "fail"` (still failing) or `"skipped"` with the healer's comment as `note` (fixme). The healer may edit only files under `<dir>/tests/`. + +## 6. Evidence + +```bash +orc-qa-video collect --results "$ORC_PW_OUTPUT_DIR/results.json" --qa-dir "<qa-dir>" +orc-qa-video stitch --results "$ORC_PW_OUTPUT_DIR/results.json" --qa-dir "<qa-dir>" --branch "<sanitized-branch>" # exit 4 ⇒ "Motion proof: skipped — ffmpeg not installed" +``` + +Then write `steps.md` (validator template; one `### Scenario <n> — <title>` per test, each listing its steps and the `AC` ids proven) and `qa-manifest.json`: + +- `driver: "playwright"`, `target: { name, baseUrl, guard }` +- `artifacts[]`: `qa-<branch>.webm` (role `stitched recording`), `chapters.json`, each `trace-<id>.zip` (role `trace <title>`), each `console-<id>.log`, `pw/results.json` +- `video: { file: "qa-<branch>.webm", chapters: <chapters.json contents> }` +- `acceptance[]`: one row per criterion; `evidence` = `["qa-<branch>.webm#t=<chapter.start>", "trace-<id>.zip"]` of the scenario whose `test.step` title carries the criterion id; `skipped` rows carry the note. +- `curated`: `["qa-<branch>.webm"]` +- `specs`: `["<dir>/specs/<feature-slug>.md"]`, `tests`: the generated spec files (relative to repo) — so the PR comment and ship can link them. + +## Iron rules + +- No "QA passed" without `qa-manifest.json` + the stitched video (or the stated ffmpeg exemption) + a trace per scenario. +- Generated specs/tests are committed with the change (`e2e/specs/**`, `e2e/tests/**` are budget-excluded per `orc:pr-size-budget`); `<qa-dir>/pw/` is not. +- Credentials: only through `ORC_TARGET_JSON` in the process environment (`orc:qa-targets` redaction rules). diff --git a/orc/skills/playwright-qa/references/dispatch-prompts.md b/orc/skills/playwright-qa/references/dispatch-prompts.md new file mode 100644 index 0000000..4e0db72 --- /dev/null +++ b/orc/skills/playwright-qa/references/dispatch-prompts.md @@ -0,0 +1,31 @@ +# Dispatch prompts — Playwright agents + +Fill the `<…>` placeholders, then pass the block body as the `Task` prompt. Step-title convention everywhere: `AC <sliceId>.<idx> — <criterion>`. + +## planner + +Agent: `playwright-test-planner` + +``` +Use the seed test `<dir>/tests/seed.spec.ts` (it logs in via storageState — do not plan a login scenario). Target: `<name>` at <baseUrl>; guard=<true|false>. Explore the running app and produce a test plan for: <feature description>. +The plan MUST contain exactly one scenario per acceptance criterion below, plus a golden-path scenario titled "<feature> golden path @golden" and the reachable edge cases (validation error, empty state, failure state). In every scenario, write each criterion's proving step as `AC <sliceId>.<idx> — <criterion>` verbatim. Append `@mutating` to the title of any scenario that creates, updates, or deletes data. +Acceptance criteria: +<for each: `- AC <sliceId>.<idx> — <criterion>`> +Save the plan with planner_save_plan as `<dir>/specs/<feature-slug>.md`; test files go under `<dir>/tests/<feature-slug>/`. +``` + +## generator + +Agent: `playwright-test-generator` + +``` +Generate the test for scenario "<scenario title>" from `<dir>/specs/<feature-slug>.md` (seed: `<dir>/tests/seed.spec.ts`; save to `<dir>/tests/<feature-slug>/<scenario-slug>.spec.ts`). Rules beyond the plan: import `{ test, expect }` from '../fixtures'; keep the scenario title verbatim including its `@golden`/`@mutating` tag; wrap every plan step whose text starts with `AC ` in `await test.step('<that exact step text>', async () => { … })` (title form `AC <sliceId>.<idx> — <criterion>`) and leave other steps as plain code with the `// N. <step>` comment; one test per file. +``` + +## healer + +Agent: `playwright-test-healer` + +``` +Debug and fix the failing Playwright test "<test title>" (`<file>`; results at `<qa-dir>/pw/results.json`, config `<cfg>`). Edit only files under `<dir>/tests/`. Keep every `test.step('AC <sliceId>.<idx> — <criterion>')` title unchanged. If the application behaviour is wrong rather than the test, mark the test `test.fixme()` with a comment starting `APP BUG:` describing what happens instead of the expectation, and stop. +``` From c189499db260ed350f70ac5d9ffd374b15231cb7 Mon Sep 17 00:00:00 2001 From: Higor Alves <me@higoralves.dev> Date: Tue, 6 Oct 2026 16:01:00 -0300 Subject: [PATCH 12/21] =?UTF-8?q?feat(explain):=20animate=20graphics=20dri?= =?UTF-8?q?ver=20(pinned)=20+=20explainer=20skill=20=E2=80=94=20script=20r?= =?UTF-8?q?ules,=20Explainer=20gate,=20render=20pipeline?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- orc/lib/explain/animate-piece.mjs | 30 ++++++++++++++++ orc/lib/explain/animate.lock | 16 +++++++++ orc/lib/explain/explain.sh | 35 +++++++++++++++++-- orc/skills/explainer/SKILL.md | 32 +++++++++++++++++ .../explainer/references/segments-schema.md | 22 ++++++++++++ scripts/ci/verify-explain.sh | 2 ++ 6 files changed, 135 insertions(+), 2 deletions(-) create mode 100644 orc/lib/explain/animate-piece.mjs create mode 100644 orc/lib/explain/animate.lock create mode 100644 orc/skills/explainer/SKILL.md create mode 100644 orc/skills/explainer/references/segments-schema.md diff --git a/orc/lib/explain/animate-piece.mjs b/orc/lib/explain/animate-piece.mjs new file mode 100644 index 0000000..c560307 --- /dev/null +++ b/orc/lib/explain/animate-piece.mjs @@ -0,0 +1,30 @@ +// animate-piece.mjs <segments.json> <workDir> <animateSkillRoot> +// For each kind:"graphic" segment writes <workDir>/pieces/<id>/ 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 <segments.json> <workDir> <animateSkillRoot>'); 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')) { + 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 <dir> assembles <dir>/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=<segment id>, title=<segment title>, style=<segments.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 <dir> --formats 16:9 -> <dir>/renders/final-<w>x<h>.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 index 532ea65..44e775f 100755 --- a/orc/lib/explain/explain.sh +++ b/orc/lib/explain/explain.sh @@ -3,6 +3,7 @@ # 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 → <work>/graphics/<id>.mp4 for graphic segments (fallback cards when animate is absent) # narrate run kokoro-js → <work>/narration/<id>.wav # assemble per-segment mp4 (clip | graphic from <work>/graphics/<id>.mp4 | fallback title card), # each max(video, narration) long, concat → --out @@ -31,7 +32,7 @@ orc_ex_validate() { # --segments S orc_ex_setup() { local home; home="$(orc_ex__home)" mkdir -p "$home" - cp "$(orc_ex__src)/narrate.mjs" "$(orc_ex__src)/package.json" "$home/" + cp "$(orc_ex__src)/narrate.mjs" "$(orc_ex__src)/package.json" "$(orc_ex__src)/animate-piece.mjs" "$(orc_ex__src)/animate.lock" "$home/" if [ "${ORC_EXPLAIN_SKIP_NPM:-0}" != "1" ]; then (cd "$home" && npm install --no-fund --no-audit >/dev/null) fi @@ -46,6 +47,35 @@ orc_ex_narrate() { # --segments S --work W 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 + 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 + pieces="$(node "$(orc_ex__home)/animate-piece.mjs" "$s" "$w" "$root")" || return 1 + 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; } @@ -119,9 +149,10 @@ if [ "${BASH_SOURCE[0]:-}" = "${0:-}" ]; then 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 | narrate --segments S --work W | assemble --segments S --work W --qa-dir Q --out O\n' ;; + --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/skills/explainer/SKILL.md b/orc/skills/explainer/SKILL.md new file mode 100644 index 0000000..c9ae14c --- /dev/null +++ b/orc/skills/explainer/SKILL.md @@ -0,0 +1,32 @@ +--- +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: `<qa-dir>` with `qa-manifest.json`, the session's `plan.md` (optional), `ORC_TARGET_JSON` (for the demo pass; resolve the target per `orc:qa-targets`), 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 `<qa-dir>/demo/` lacks a `.webm` per `@golden` scenario: `( cd <repo>/<dir> && ORC_TARGET_JSON=… ORC_PW_OUTPUT_DIR=<qa-dir>/pw-demo npx playwright test --project=demo )`, then `orc-qa-video plan --results <qa-dir>/pw-demo/results.json` and copy each `video` to `<qa-dir>/demo/<id>.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 <base>..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** +> +> <N> segments, ~<seconds>s, style <style>, voice <voice>. Review the script before rendering. +``` +preview = `segments.json` rendered as `id · kind · caption/title · narration`. Options: **Render (Recommended)** · **Edit script** (apply the user's wording changes, re-show) · **Cancel**. + +## 5. Render +`orc-explain narrate --segments S --work <qa-dir>/explain` → `orc-explain graphics …` → `orc-explain assemble --segments S --work <qa-dir>/explain --qa-dir <qa-dir> --out <qa-dir>/explainer-<branch>.mp4`. Then add to `qa-manifest.json`: `explainer: { file: "explainer-<branch>.mp4", segments: "explain/segments.json" }`, append the file to `artifacts[]` (role `explainer`) and `curated[]`; append `## Explainer — <ISO>` to `steps.md` (segments, duration, style, voice, animate used yes/no). Delivery is `orc:evidence-publish`'s job (same gates; the PR comment links it as `EXPLAINER_URL`). + +`graphics` writes an animate piece per graphic segment under `<work>/pieces/` and uses fallback cards unless the scenes were authored and `ORC_ANIMATE_RENDER=1` (see `animate.lock` notes: animate pieces are hand-authored canvas scenes, not data-driven). diff --git a/orc/skills/explainer/references/segments-schema.md b/orc/skills/explainer/references/segments-schema.md new file mode 100644 index 0000000..7399d9e --- /dev/null +++ b/orc/skills/explainer/references/segments-schema.md @@ -0,0 +1,22 @@ +# segments.json schema (version 1) + +```json +{ "schema": 1, "branch": "feat-x", "style": "isometric", "voice": "af_heart", "segments": [ + { "id": "s01", "kind": "graphic", "title": "Hello", "lines": ["a", "b"], "narration": "Intro." }, + { "id": "s02", "kind": "clip", "source": "demo/clip.webm", "caption": "AC 1.1 — works", "narration": "Clip." } ] } +``` + +| Field | Applies to | Rule | +|---|---|---| +| `schema` | doc | must be `1` | +| `branch` | doc | sanitized branch, used in the output filename | +| `style` | doc | one of animate's seven: `cut-paper`, `crosshatch`, `isometric`, `math`, `pixel`, `riso`, `sketchbook` | +| `voice` | doc | kokoro voice id; `af_heart` default, `pf_dora` / `pm_alex` for pt-BR | +| `id` | segment | slug, unique within the doc | +| `kind` | segment | `graphic` or `clip` | +| `title`, `lines[]` | graphic only | `title` required; `lines` optional beat lines | +| `source` | clip only | required; path relative to `<qa-dir>` | +| `caption` | clip | optional burned-in caption | +| `narration` | segment | required; spoken text, ≤ 25 words | + +`orc-explain validate --segments S` enforces these (exit 2 naming the segment id). diff --git a/scripts/ci/verify-explain.sh b/scripts/ci/verify-explain.sh index ebc337f..f52d8e4 100755 --- a/scripts/ci/verify-explain.sh +++ b/scripts/ci/verify-explain.sh @@ -26,6 +26,8 @@ set +e; err="$(bash "$cli" validate --segments "$tmp/bad2.json" 2>&1)"; rc=$?; s bash "$cli" setup >/dev/null [ -f "$XDG_CACHE_HOME/orc/explain/narrate.mjs" ] && [ -f "$XDG_CACHE_HOME/orc/explain/package.json" ] && ok || fail "setup must stage narrate.mjs + package.json" +[ -f "$XDG_CACHE_HOME/orc/explain/animate-piece.mjs" ] && [ -f "$XDG_CACHE_HOME/orc/explain/animate.lock" ] && ok || fail "setup must stage animate-piece.mjs + animate.lock" +ORC_ANIMATE_ROOT=/nonexistent bash "$cli" graphics --segments "$tmp/good.json" --work "$tmp/work" | grep -q 'fallback cards' && ok || fail "graphics without animate must exit 0 printing fallback cards" if command -v ffmpeg >/dev/null 2>&1 && command -v ffprobe >/dev/null 2>&1; then mkdir -p "$tmp/qa/demo" "$tmp/work/narration" From 8b1dfa881f525bbf1a169ec837ece13d91a758e9 Mon Sep 17 00:00:00 2001 From: Higor Alves <me@higoralves.dev> Date: Tue, 6 Oct 2026 16:00:30 -0300 Subject: [PATCH 13/21] test(ci): exclude e2e specs/tests from LOC budget --- .github/workflows/validate.yml | 3 +++ orc/lib/pr-size-budget.sh | 6 ++++-- orc/skills/pr-size-budget/SKILL.md | 1 + scripts/ci/verify-pr-size.sh | 16 ++++++++++++++++ 4 files changed, 24 insertions(+), 2 deletions(-) create mode 100755 scripts/ci/verify-pr-size.sh diff --git a/.github/workflows/validate.yml b/.github/workflows/validate.yml index 28eda2f..d27e53b 100644 --- a/.github/workflows/validate.yml +++ b/.github/workflows/validate.yml @@ -90,6 +90,9 @@ jobs: - 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/orc/lib/pr-size-budget.sh b/orc/lib/pr-size-budget.sh index fb71ffd..449f59e 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 @@ -39,6 +39,8 @@ ORC_PR_LOC_EXCLUDES=( ':(exclude)poetry.lock' ':(exclude)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/skills/pr-size-budget/SKILL.md b/orc/skills/pr-size-budget/SKILL.md index c8c7d4d..cae81e4 100644 --- a/orc/skills/pr-size-budget/SKILL.md +++ b/orc/skills/pr-size-budget/SKILL.md @@ -36,6 +36,7 @@ These never count: - **Lockfiles** — `package-lock.json`, `pnpm-lock.yaml`, `yarn.lock`, `Cargo.lock`, `go.sum`, `Gemfile.lock`, `composer.lock`, `poetry.lock`, `uv.lock`, any `*.lock` / `*.lockb`. - **Snapshots / generated** — `**/__snapshots__/**`, `**/__generated__/**`, `**/generated/**`, `**/*.gen.{ts,go}`, `**/*.pb.{go,ts}`, `**/*_pb2.py`. - **Build artifacts** — `**/dist/**`, `**/build/**`, `**/.next/**`. +- **Generated browser-QA proof** — `e2e/specs/**`, `e2e/tests/**` (Playwright plans + tests written by `/orc:qa`'s planner/generator; reviewers see them, the budget doesn't count them). ## Migrations diff --git a/scripts/ci/verify-pr-size.sh b/scripts/ci/verify-pr-size.sh new file mode 100755 index 0000000..929acf7 --- /dev/null +++ b/scripts/ci/verify-pr-size.sh @@ -0,0 +1,16 @@ +#!/usr/bin/env bash +# Fixture test for lib/pr-size-budget.sh exclusions — generated Playwright +# specs/tests never count toward the budget (settled decision: proof is not logic). +set -euo pipefail +repo_root="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)" +tmp="$(mktemp -d)"; trap 'rm -rf "$tmp"' EXIT +cd "$tmp" && git init -q -b main && git config user.email t@t && git config user.name t +printf 'a\n' > src.txt && git add . && git commit -qm base +git checkout -qb feat +mkdir -p e2e/specs e2e/tests/x src +printf 'spec\nspec\n' > e2e/specs/x.md; printf 'test\ntest\ntest\n' > e2e/tests/x/a.spec.ts; printf 'b\n' >> src.txt +git add . && git commit -qm feat +# shellcheck disable=SC1091 +. "$repo_root/orc/lib/pr-size-budget.sh" +loc="$(orc_pr_loc main "$tmp")" +if [ "$loc" = "1" ]; then echo "verify-pr-size: OK (e2e specs/tests excluded, loc=$loc)"; else echo "verify-pr-size: FAIL — expected 1, got $loc"; exit 1; fi From 8d7317f3ceb7534d12b3657e9a63b9f7807ba7f0 Mon Sep 17 00:00:00 2001 From: Higor Alves <me@higoralves.dev> Date: Tue, 6 Oct 2026 16:01:44 -0300 Subject: [PATCH 14/21] =?UTF-8?q?feat(qa):=20Playwright=20is=20Driver=20P?= =?UTF-8?q?=20=E2=80=94=20target=20step,=20three-way=20driver=20gate,=20pl?= =?UTF-8?q?aywright=20packet=20column;=20e2e=20proof=20excluded=20from=20L?= =?UTF-8?q?OC=20budget?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- orc/agents/orc-qa-validator.md | 2 +- orc/commands/evidence.md | 12 ++++--- orc/commands/flow.md | 4 +-- orc/commands/qa.md | 29 ++++++++-------- orc/skills/browser-qa/SKILL.md | 33 ++++++++++++------- .../flow-phases/references/PHASE-6-QA.md | 4 +-- 6 files changed, 51 insertions(+), 33 deletions(-) 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/commands/evidence.md b/orc/commands/evidence.md index f45228f..c95adc9 100644 --- a/orc/commands/evidence.md +++ b/orc/commands/evidence.md @@ -25,13 +25,16 @@ allowed-tools: - 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 @@ -39,7 +42,7 @@ Collection **reuses `/orc:qa`'s browser drivers verbatim**; delivery reuses the - `--context "<what to test>"` — describe the flow to exercise, instead of (or on top of) pulling it from the ticket. - `--target <name>` — 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 <url>` — 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`. @@ -64,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" <sanitized-branch>)` → `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" <sanitized-branch>)` → `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}/<sanitized-branch>/files/qa/`; no session → `.orc/evidence/<sanitized-KEY>/` (context-only → `.orc/evidence/adhoc-<sanitized-context>/`). 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-<n>`). - **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-<sanitized-branch>.gif`, narrate each step under a numbered `### Step <N>` 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/flow.md b/orc/commands/flow.md index f890ecd..16eee97 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 <KEY> links a ticket. Workspace-aware. -argument-hint: "[--auto[=guided|full]] [--type=feature|bug|refactor|docs] [--rfc] [--verbose] [--pause-at-implement] [--jira <KEY>] [--max-loc <N>] [--no-size-gate] [--driver agent-browser|chrome] [--target <name>] [--repos a,b | --repo a | --all-repos | --this-repo] <one-line task description>" +argument-hint: "[--auto[=guided|full]] [--type=feature|bug|refactor|docs] [--rfc] [--verbose] [--pause-at-implement] [--jira <KEY>] [--max-loc <N>] [--no-size-gate] [--driver playwright|agent-browser|chrome] [--target <name>] [--repos a,b | --repo a | --all-repos | --this-repo] <one-line task description>" allowed-tools: - Bash(orc-state:*) - Bash(orc-report:*) @@ -46,7 +46,7 @@ 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 <name>` — pre-answers Phase 6's Target gate (`orc:qa-targets`); recorded as a `target` decision. - `--pause-at-implement` — Phase 5 pauses for the human to write the code (keeps Phase 4's red-confirm gate). - `--jira <KEY>` — link a Jira ticket silently (validate `^[A-Z][A-Z0-9_]*-\d+$`); lands as `Resolves <KEY>` in the PR body. diff --git a/orc/commands/qa.md b/orc/commands/qa.md index 9537ed5..cafb92d 100644 --- a/orc/commands/qa.md +++ b/orc/commands/qa.md @@ -31,6 +31,9 @@ allowed-tools: - Bash(npx playwright:*) - Bash(orc-playwright:*) - Bash(orc-qa-video:*) + - Bash(gh pr comment:*) + - Bash(gh pr view:*) + - Bash(gh auth status:*) effort: high --- @@ -39,7 +42,7 @@ 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/<branch>/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/<branch>/files/qa/` either way. ## Arguments @@ -49,7 +52,7 @@ Run a quality gate before opening a PR. Two modes: - `--web <url>` — 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 @@ -82,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 @@ -110,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-<step>.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-<sliceId>-<idx>-<slug>.png` per scored criterion | the numbered `### Step <N>` anchor a criterion's `evidence` points at | -| Motion proof | `qa-<branch>.webm` (`agent-browser record` — needs `ffmpeg`) (+ optional `.gif` for ticket embedding) | `qa-<branch>.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-<id>.zip` per scenario (screenshots inside) | `screenshot-NN-<step>.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-<branch>.webm#t=<chapter>` + the `test.step` title | `ac-<sliceId>-<idx>-<slug>.png` per scored criterion | the numbered `### Step <N>` anchor a criterion's `evidence` points at | +| Motion proof | `qa-<branch>.webm` stitched (ffmpeg) | `qa-<branch>.webm` (`agent-browser record` — needs `ffmpeg`) (+ optional `.gif` for ticket embedding) | `qa-<branch>.gif` (`gif_creator`, edge cases included) | +| Manifest | `qa-manifest.json` (`video.chapters`) | `qa-manifest.json` | `qa-manifest.json` | +| A11y snapshot | `trace-<id>.zip` (ARIA snapshots per action) | `snapshot-final.txt` (`agent-browser snapshot`) | `snapshot-final.txt` (`read_page` output) | +| Console | `console-<id>.log` | `console.log` (`agent-browser console`) | `console.log` (`read_console_messages`; state any filter used) | +| Network | `trace-<id>.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/skills/browser-qa/SKILL.md b/orc/skills/browser-qa/SKILL.md index 91a7875..2f44fd1 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 <url>` or `--no-env`. Check `orc-docker-env is-ready $(orc-docker-env state-path "$ORC_STATE_DIR" <sanitized-branch>)`: +Invoke `orc:qa-targets`: `--target` / `--web` / settled decision / the Target gate. Result: a target name, `guard`, and `ORC_TARGET_JSON` ready to export. 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" <sanitized-branch>)`: - `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}/<sanitized-branch>/files/qa/`; in workspace mode, cross-repo integration evidence goes there while per-repo QA stays at `<repoPath>/.orc/<branch>/files/qa/`. +Then `ORC_TARGET_JSON="$(orc-targets resolve local --base-url <appUrl>)"`. 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}/<sanitized-branch>/files/qa/`; in workspace mode, cross-repo integration evidence goes there while per-repo QA stays at `<repoPath>/.orc/<branch>/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 <v> --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 <appUrl>. Pick how to drive the browser. +> Web QA is ready to run against <target name> (<baseUrl>). 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 <playwright|agent-browser|chrome> --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, `<qa-dir>`, the step-2 acceptance lists, target name + `guard`, `ORC_TARGET_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-<sliceId>-<idx>-<slug>.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/flow-phases/references/PHASE-6-QA.md b/orc/skills/flow-phases/references/PHASE-6-QA.md index b4c6e44..da10c1d 100644 --- a/orc/skills/flow-phases/references/PHASE-6-QA.md +++ b/orc/skills/flow-phases/references/PHASE-6-QA.md @@ -11,11 +11,11 @@ Always invoke `orc:caveman-review` (self-review of diff) — that's a different When the diff touches security-sensitive paths (auth, sessions, raw SQL, deserialization, file upload, network egress, dependency surface) — dispatch `orc-security-reviewer` in parallel with the self-review. Merge findings before surfacing. -For web changes, invoke **`orc:browser-qa`** and execute its protocol end-to-end — env attach/provision, the driver gate (`--driver` or a settled `driver` decision skips it), the acceptance-criteria load, then Driver A validator dispatch or Driver B inline. The skill is the single source of truth shared with `/orc:qa` Phase 4; flow adds only the workspace note: the web-surface repo comes from the plan's "Repo touchpoints" (`repoPath = <workspaceRoot>/<that repo>`), cross-repo integration evidence lands at the workspace-level `qa/` dir, repo-local QA stays per-repo. The environment stays up across the QA-partial → fix → re-run loop; teardown happens in Phase 9. +For web changes, invoke **`orc:browser-qa`** and execute its protocol end-to-end — target resolution (`orc:qa-targets`), env attach/provision for local targets, the driver gate (`--driver`/`--target` or settled decisions skip them), the acceptance-criteria load, then Driver P (Playwright), Driver A validator dispatch or Driver B inline. The skill is the single source of truth shared with `/orc:qa` Phase 4; flow adds only the workspace note: the web-surface repo comes from the plan's "Repo touchpoints" (`repoPath = <workspaceRoot>/<that repo>`), cross-repo integration evidence lands at the workspace-level `qa/` dir, repo-local QA stays per-repo. The environment stays up across the QA-partial → fix → re-run loop; teardown happens in Phase 9. If verification flags untested branches, dispatch `orc-test-author` to fill them in before continuing. -**No gate on a clean pass.** The verdict is computed from the evidence packet, not vibes — when the verdict is `pass` AND the packet is complete, print the verdict line + artifact list and advance to Phase 7. **Completeness is driver-shaped, read from the packet's `qa-manifest.json`** — every file its `artifacts` names exists on disk, and every `acceptance` row is `pass` or a `skipped` carrying a reason. (Do not check for screenshots and a HAR by name: a chrome-driver packet has neither, and hard-coding Driver A's shape here made a clean chrome pass unreachable.) Code mode is unchanged: suite + lint + type-check green. +**No gate on a clean pass.** The verdict is computed from the evidence packet, not vibes — when the verdict is `pass` AND the packet is complete, print the verdict line + artifact list and advance to Phase 7. **Completeness is driver-shaped, read from the packet's `qa-manifest.json`** — every file its `artifacts` names exists on disk, and every `acceptance` row is `pass` or a `skipped` carrying a reason. (a playwright packet has a stitched video + traces, no PNGs) (Do not check for screenshots and a HAR by name: a chrome-driver packet has neither, and hard-coding Driver A's shape here made a clean chrome pass unreachable.) Code mode is unchanged: suite + lint + type-check green. Gate only on anomaly (verdict `partial`/`fail`, incomplete evidence, or web QA about to be skipped): From 3d1005f59d4dc4491e8650bed3cfe296bf9147c8 Mon Sep 17 00:00:00 2001 From: Higor Alves <me@higoralves.dev> Date: Tue, 6 Oct 2026 16:03:43 -0300 Subject: [PATCH 15/21] feat(explain): /orc:explain command, flow explainer gate, explainer_style/voice settings --- orc/.claude-plugin/plugin.json | 12 ++++ orc/commands/explain.md | 58 +++++++++++++++++++ orc/commands/flow.md | 3 +- orc/lib/explain/explain.sh | 9 ++- orc/skills/explainer/SKILL.md | 9 ++- .../flow-phases/references/PHASE-6-QA.md | 2 + orc/skills/map/SKILL.md | 3 + 7 files changed, 93 insertions(+), 3 deletions(-) create mode 100644 orc/commands/explain.md 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/commands/explain.md b/orc/commands/explain.md new file mode 100644 index 0000000..0f07055 --- /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 <dir>] [--target <name>] [--style <animate-style>] [--voice <kokoro-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 <dir>` — packet to explain. Default: the active session's `${ORC_STATE_DIR}/<sanitized-branch>/files/qa/`; else the newest `.orc/evidence/*/` packet; else ask. +- `--target <name>` — target for the demo pass when the packet has no `demo/` clips (`orc:qa-targets`); default: the session's settled `target`. +- `--style <s>` — animate style (`cut-paper|crosshatch|riso|sketchbook|math|pixel|isometric`); default userConfig `explainer_style`. +- `--voice <v>` — 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 resolved target JSON (only if the demo pass is needed), 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-<branch>.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-<branch>.mp4 (<seconds>s)` and append `## Explainer — <ISO>` 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 16eee97..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 <KEY> links a ticket. Workspace-aware. -argument-hint: "[--auto[=guided|full]] [--type=feature|bug|refactor|docs] [--rfc] [--verbose] [--pause-at-implement] [--jira <KEY>] [--max-loc <N>] [--no-size-gate] [--driver playwright|agent-browser|chrome] [--target <name>] [--repos a,b | --repo a | --all-repos | --this-repo] <one-line task description>" +argument-hint: "[--auto[=guided|full]] [--type=feature|bug|refactor|docs] [--rfc] [--verbose] [--pause-at-implement] [--jira <KEY>] [--max-loc <N>] [--no-size-gate] [--driver playwright|agent-browser|chrome] [--target <name>] [--explainer] [--repos a,b | --repo a | --all-repos | --this-repo] <one-line task description>" allowed-tools: - Bash(orc-state:*) - Bash(orc-report:*) @@ -48,6 +48,7 @@ Interactivity follows the gate taxonomy (`orc:using-orc`) and the resolved `inte - `--verbose` — pass through to `/orc:ship` (long-form PR body; terse `orc:caveman-pr` is the default). - `--driver playwright|agent-browser|chrome` — pre-answers Phase 6's browser-driver gate. - `--target <name>` — 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 <KEY>` — link a Jira ticket silently (validate `^[A-Z][A-Z0-9_]*-\d+$`); lands as `Resolves <KEY>` in the PR body. - `--max-loc <N>` / `--no-size-gate` — pass-through to `/orc:ship`'s Phase 4.5 size gate (single owner; flow never pre-flights it). diff --git a/orc/lib/explain/explain.sh b/orc/lib/explain/explain.sh index 44e775f..068a87e 100755 --- a/orc/lib/explain/explain.sh +++ b/orc/lib/explain/explain.sh @@ -43,6 +43,7 @@ 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 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" } @@ -58,11 +59,17 @@ orc_ex__animate_root() { 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 + 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 - pieces="$(node "$(orc_ex__home)/animate-piece.mjs" "$s" "$w" "$root")" || return 1 + if [ "${ORC_ANIMATE_RENDER:-0}" = "1" ] && [ -d "$w/pieces" ]; 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 diff --git a/orc/skills/explainer/SKILL.md b/orc/skills/explainer/SKILL.md index c9ae14c..aeba6a4 100644 --- a/orc/skills/explainer/SKILL.md +++ b/orc/skills/explainer/SKILL.md @@ -27,6 +27,13 @@ Sources, in order: the plan's slice titles + acceptance (what was built), the ma preview = `segments.json` rendered as `id · kind · caption/title · narration`. Options: **Render (Recommended)** · **Edit script** (apply the user's wording changes, re-show) · **Cancel**. ## 5. Render -`orc-explain narrate --segments S --work <qa-dir>/explain` → `orc-explain graphics …` → `orc-explain assemble --segments S --work <qa-dir>/explain --qa-dir <qa-dir> --out <qa-dir>/explainer-<branch>.mp4`. Then add to `qa-manifest.json`: `explainer: { file: "explainer-<branch>.mp4", segments: "explain/segments.json" }`, append the file to `artifacts[]` (role `explainer`) and `curated[]`; append `## Explainer — <ISO>` to `steps.md` (segments, duration, style, voice, animate used yes/no). Delivery is `orc:evidence-publish`'s job (same gates; the PR comment links it as `EXPLAINER_URL`). +Authoring and rendering are render work, not a new gate: the Explainer gate above (script + segments + style) already covered them. + +1. `orc-explain narrate --segments S --work <qa-dir>/explain`. +2. **Scene authoring** (only when animate is installed; otherwise skip, fallback cards apply). Run `orc-explain graphics --segments S --work <qa-dir>/explain` once to create `<work>/pieces/<id>/` (`piece.json`, `brief.md`, the example's `src/`). Then, for each graphic segment, author `src/scenes.js` (and the `TIMELINE` in `src/head.html`) from that piece's `brief.md`, following animate's `craft.md` and the chosen style's `STYLE.md` + `kit.js` under `<animate skill root>/styles/<style>/`. One card-like scene per piece: the headline, then the lines, 16:9, timed to the narration length (`ffprobe -v error -show_entries format=duration -of csv=p=0 <work>/narration/<id>.wav`). +3. `ORC_ANIMATE_RENDER=1 orc-explain graphics --segments S --work <qa-dir>/explain` (render pass reuses the authored pieces, never regenerates them). +4. `orc-explain assemble --segments S --work <qa-dir>/explain --qa-dir <qa-dir> --out <qa-dir>/explainer-<branch>.mp4`. + +Then add to `qa-manifest.json`: `explainer: { file: "explainer-<branch>.mp4", segments: "explain/segments.json" }`, append the file to `artifacts[]` (role `explainer`) and `curated[]`; append `## Explainer — <ISO>` to `steps.md` (segments, duration, style, voice, animate used yes/no). Delivery is `orc:evidence-publish`'s job (same gates; the PR comment links it as `EXPLAINER_URL`). `graphics` writes an animate piece per graphic segment under `<work>/pieces/` and uses fallback cards unless the scenes were authored and `ORC_ANIMATE_RENDER=1` (see `animate.lock` notes: animate pieces are hand-authored canvas scenes, not data-driven). diff --git a/orc/skills/flow-phases/references/PHASE-6-QA.md b/orc/skills/flow-phases/references/PHASE-6-QA.md index da10c1d..47ac013 100644 --- a/orc/skills/flow-phases/references/PHASE-6-QA.md +++ b/orc/skills/flow-phases/references/PHASE-6-QA.md @@ -17,6 +17,8 @@ If verification flags untested branches, dispatch `orc-test-author` to fill them **No gate on a clean pass.** The verdict is computed from the evidence packet, not vibes — when the verdict is `pass` AND the packet is complete, print the verdict line + artifact list and advance to Phase 7. **Completeness is driver-shaped, read from the packet's `qa-manifest.json`** — every file its `artifacts` names exists on disk, and every `acceptance` row is `pass` or a `skipped` carrying a reason. (a playwright packet has a stitched video + traces, no PNGs) (Do not check for screenshots and a HAR by name: a chrome-driver packet has neither, and hard-coding Driver A's shape here made a clean chrome pass unreachable.) Code mode is unchanged: suite + lint + type-check green. +**Explainer (opt-in, soft-inward, header `Explainer`).** After a clean web-mode pass print `➡️ Explainer available` and `AskUserQuestion`: **Skip (Recommended)** · **Build now** (invoke `orc:explainer` on the packet, then continue to Phase 7) · **Later** (note `/orc:explain` in the digest). `--explainer` pre-answers Build now; `guided`/`full` pick Skip unless `--explainer`. + Gate only on anomaly (verdict `partial`/`fail`, incomplete evidence, or web QA about to be skipped): ``` diff --git a/orc/skills/map/SKILL.md b/orc/skills/map/SKILL.md index 71ee2f6..ffb8563 100644 --- a/orc/skills/map/SKILL.md +++ b/orc/skills/map/SKILL.md @@ -22,6 +22,8 @@ plan starts at `/orc:start`. The one-shot alternative: `/orc:flow` runs the whole pipeline (plan → implement → QA → ship → address → cleanup) with an interactive gate per phase. Prefer `/orc:flow` when you want to hand over a well-described feature; prefer the step commands when you want to steer. +`/orc:explain` is the optional step after QA: a narrated walkthrough of what +was built and proven. ## On-ramps (how work arrives) @@ -38,6 +40,7 @@ well-described feature; prefer the step commands when you want to steer. | Issue backlog needs herding | `/orc:triage` | | Feature needs a Jira Epic→Story→Task build-out | `/orc:jira-breakdown` | | Dependencies stale or vulnerable | `/orc:deps` | +| Need a narrated video of what shipped | `/orc:explain` (after `/orc:qa`) | | Cut a release (user project) | `/orc:release` | | New package/service/app shell | `/orc:scaffold` | | Interrupted mid-anything | `/orc:resume` (see `/orc:status` for what's live) | From 0c7069338240cec4a07c763161e169312e92f371 Mon Sep 17 00:00:00 2001 From: Higor Alves <me@higoralves.dev> Date: Tue, 6 Oct 2026 16:05:52 -0300 Subject: [PATCH 16/21] =?UTF-8?q?docs:=20Playwright-first=20QA,=20named=20?= =?UTF-8?q?targets,=20/orc:explain=20=E2=80=94=20counts,=20catalog,=20conf?= =?UTF-8?q?ig,=20example=2018?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .claude-plugin/marketplace.json | 2 +- README.md | 6 +- cli/internal/deps/tools.json | 4 +- docs/architecture.md | 6 +- docs/commands.md | 11 +- docs/configuration.md | 8 ++ docs/examples/10-web-qa-before-shipping.md | 2 +- .../18-playwright-qa-and-explainer.md | 128 ++++++++++++++++++ docs/examples/README.md | 3 +- docs/install.md | 14 +- orc/lib/tools.json | 4 +- 11 files changed, 168 insertions(+), 20 deletions(-) create mode 100644 docs/examples/18-playwright-qa-and-explainer.md 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/README.md b/README.md index 1aad8bb..f9004ad 100644 --- a/README.md +++ b/README.md @@ -36,14 +36,14 @@ </p> <p align="center"> - <code>81 skills</code>  ·  <code>30 commands</code>  ·  + <code>84 skills</code>  ·  <code>31 commands</code>  ·  <code>14 agents</code>  ·  <code>9 hooks</code>  ·  <code>0 telemetry</code>  ·  <code>MIT</code> </p> --- -`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:<name> -├── commands/ # 30 composite slash commands /orc:<cmd> (incl. /orc:flow umbrella) +├── skills/ # 84 skills, namespaced /orc:<name> +├── commands/ # 31 composite slash commands /orc:<cmd> (incl. /orc:flow umbrella) ├── agents/ # 14 specialist subagents (orc-<role>) ├── 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/<branch>/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 `<qa-dir>/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 <w> <h>` 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 <name>` (`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<br/><i>orc:qa-targets</i>] + setup[Setup gate<br/><i>first run: create e2e/</i>] + plan[Plan gate<br/><i>planner agent writes the spec</i>] + run["npx playwright test --project=qa"] + stitch[orc-qa-video stitch] + explain["/orc:explain"] + pr[PR comment<br/><i>hard-outward gate</i>] + + 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-<id>.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/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", From 6180ddcefb9105d5873ee2d8680b413a09e7f637 Mon Sep 17 00:00:00 2001 From: Higor Alves <me@higoralves.dev> Date: Tue, 6 Oct 2026 16:08:53 -0300 Subject: [PATCH 17/21] =?UTF-8?q?fix(qa):=20self-review=20=E2=80=94=20env-?= =?UTF-8?q?ref=20validation,=20slug=20dedupe,=20explain=20stdin/render/id?= =?UTF-8?q?=20guards,=20strict=20ORC=5FQA=5FUSER?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- orc/lib/explain/explain.sh | 19 ++++++++++++------- orc/lib/playwright/auth.setup.ts.tmpl | 4 +++- orc/lib/qa-video.sh | 5 ++++- orc/lib/targets.sh | 1 + scripts/ci/verify-explain.sh | 5 +++++ scripts/ci/verify-qa-video.sh | 7 +++++-- scripts/ci/verify-targets.sh | 5 +++++ 7 files changed, 35 insertions(+), 11 deletions(-) diff --git a/orc/lib/explain/explain.sh b/orc/lib/explain/explain.sh index 068a87e..b92ccec 100755 --- a/orc/lib/explain/explain.sh +++ b/orc/lib/explain/explain.sh @@ -20,7 +20,8 @@ orc_ex_validate() { # --segments S 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" else empty end), + (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), @@ -64,7 +65,11 @@ orc_ex_graphics() { # --segments S --work W [ -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 + 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 @@ -94,12 +99,12 @@ orc_ex__card() { # <out.mp4> <title> <lines-joined-by-\n> <seconds> 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 -v error -y -f lavfi -i "color=c=0x1d1d1b:s=1920x1080:d=$secs" -r 30 -c:v libx264 -pix_fmt yuv420p -an "$out" + 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 -v error -y -f lavfi -i "color=c=0x1d1d1b:s=1920x1080:d=$secs" \ + 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" @@ -116,9 +121,9 @@ orc_ex__segment() { # <video-in> <wav|""> <caption|""> <out.mp4> → renders vid 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 -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" + 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 -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" + 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 @@ -146,7 +151,7 @@ orc_ex_assemble() { # --segments S --work W --qa-dir Q --out O 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 -v error -y -f concat -safe 0 -i "$list" -c copy "$o" + ffmpeg -nostdin -v error -y -f concat -safe 0 -i "$list" -c copy "$o" printf '%s\n' "$o" } diff --git a/orc/lib/playwright/auth.setup.ts.tmpl b/orc/lib/playwright/auth.setup.ts.tmpl index 1511688..49d0950 100644 --- a/orc/lib/playwright/auth.setup.ts.tmpl +++ b/orc/lib/playwright/auth.setup.ts.tmpl @@ -12,7 +12,9 @@ const file = path.join(outDir, '.auth', 'user.json'); setup('authenticate', async ({ page }) => { fs.mkdirSync(path.dirname(file), { recursive: true }); const users = target.users ?? {}; - const user = users[process.env.ORC_QA_USER ?? ''] ?? Object.values(users)[0]; + 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); diff --git a/orc/lib/qa-video.sh b/orc/lib/qa-video.sh index 83ad87d..7c52ed8 100755 --- a/orc/lib/qa-video.sh +++ b/orc/lib/qa-video.sh @@ -22,7 +22,10 @@ orc_qv_plan() { # --results R → JSON array [{id,title,file,outcome,video,conso 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 'map(. + {id: (.title | ascii_downcase | gsub("[^a-z0-9]+"; "-") | gsub("^-+|-+$"; ""))})' + | 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-<id>.log, trace-<id>.zip diff --git a/orc/lib/targets.sh b/orc/lib/targets.sh index 664978a..081fb74 100755 --- a/orc/lib/targets.sh +++ b/orc/lib/targets.sh @@ -111,6 +111,7 @@ orc_targets__resolve_value() { # $1 = reference → value on stdout; exit 3 when "$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" ;; diff --git a/scripts/ci/verify-explain.sh b/scripts/ci/verify-explain.sh index f52d8e4..3bd8df3 100755 --- a/scripts/ci/verify-explain.sh +++ b/scripts/ci/verify-explain.sh @@ -24,6 +24,11 @@ jq '.segments[0] |= del(.narration)' "$tmp/good.json" > "$tmp/bad2.json" set +e; err="$(bash "$cli" validate --segments "$tmp/bad2.json" 2>&1)"; rc=$?; set -e [ "$rc" -eq 2 ] && printf '%s' "$err" | grep -q 's01' && ok || fail "missing narration must exit 2 naming s01" +jq '.segments[0].id = "../x"' "$tmp/good.json" > "$tmp/bad3.json" +set +e; err="$(bash "$cli" validate --segments "$tmp/bad3.json" 2>&1)"; rc=$?; set -e +[ "$rc" -eq 2 ] && printf '%s' "$err" | grep -q 'must be a slug' && ok || fail "non-slug id must exit 2 (path traversal guard)" +set +e; err="$(ORC_ANIMATE_ROOT=/nonexistent ORC_ANIMATE_RENDER=1 bash "$cli" graphics --segments "$tmp/good.json" --work "$tmp/nopieces" 2>&1)"; rc=$?; set -e +[ "$rc" -eq 0 ] && ok || fail "render flag without animate must still be a no-op (rc=$rc)" bash "$cli" setup >/dev/null [ -f "$XDG_CACHE_HOME/orc/explain/narrate.mjs" ] && [ -f "$XDG_CACHE_HOME/orc/explain/package.json" ] && ok || fail "setup must stage narrate.mjs + package.json" [ -f "$XDG_CACHE_HOME/orc/explain/animate-piece.mjs" ] && [ -f "$XDG_CACHE_HOME/orc/explain/animate.lock" ] && ok || fail "setup must stage animate-piece.mjs + animate.lock" diff --git a/scripts/ci/verify-qa-video.sh b/scripts/ci/verify-qa-video.sh index d51b100..65652b5 100755 --- a/scripts/ci/verify-qa-video.sh +++ b/scripts/ci/verify-qa-video.sh @@ -26,11 +26,14 @@ cat > "$tmp/results.json" <<EOF { "title": "Export empty state", "file": "export/empty.spec.ts", "tests": [ { "projectName": "qa", "results": [ { "status": "failed", "duration": 800, "attachments": [ { "name": "video", "path": "$tmp/tr/b/video.webm", "contentType": "video/webm" }, - { "name": "console.log", "path": "$tmp/tr/b/console.log", "contentType": "text/plain" } ] } ] } ] } ] } ] } ] } + { "name": "console.log", "path": "$tmp/tr/b/console.log", "contentType": "text/plain" } ] } ] } ] }, + { "title": "Export empty state", "file": "export/empty-mobile.spec.ts", + "tests": [ { "projectName": "qa", "results": [ { "status": "passed", "duration": 10, "attachments": [] } ] } ] } ] } ] } ] } EOF plan="$(bash "$cli" plan --results "$tmp/results.json")" -[ "$(printf '%s' "$plan" | jq 'length')" = "2" ] && ok || fail "plan must skip the setup project (got $(printf '%s' "$plan" | jq -c .))" +[ "$(printf '%s' "$plan" | jq 'length')" = "3" ] && ok || fail "plan must skip the setup project (got $(printf '%s' "$plan" | jq -c .))" +[ "$(printf '%s' "$plan" | jq -r '.[2].id')" = "export-empty-state-2" ] && ok || fail "duplicate titles must get distinct ids" [ "$(printf '%s' "$plan" | jq -r '.[0].id')" = "export-golden-path-golden" ] && ok || fail "plan id slug" [ "$(printf '%s' "$plan" | jq -r '.[1].outcome')" = "failed" ] && ok || fail "plan outcome" [ "$(printf '%s' "$plan" | jq -r '.[0].video')" = "$tmp/tr/a/video.webm" ] && ok || fail "plan video path" diff --git a/scripts/ci/verify-targets.sh b/scripts/ci/verify-targets.sh index 028c037..bce3229 100755 --- a/scripts/ci/verify-targets.sh +++ b/scripts/ci/verify-targets.sh @@ -52,6 +52,11 @@ set +e; err="$(ORC_TARGETS_OP_BIN="$tmp/op" bash "$cli" resolve staging 2>&1 >/d # missing op binary → exit 3 set +e; err="$(T_USER=a ORC_TARGETS_OP_BIN="$tmp/missing-op" bash "$cli" resolve staging 2>&1 >/dev/null)"; rc=$?; set -e [ "$rc" -eq 3 ] && printf '%s' "$err" | grep -qi '1password' && ok || fail "missing op must exit 3 (rc=$rc)" +# malformed env reference → exit 3 before any indirect expansion +bash "$cli" user staging evil --username 'env:x[$(id)]' --password literal >/dev/null +set +e; err="$(T_USER=a ORC_TARGETS_OP_BIN="$tmp/op" bash "$cli" resolve staging 2>&1 >/dev/null)"; rc=$?; set -e +[ "$rc" -eq 3 ] && printf '%s' "$err" | grep -q 'invalid env reference' && ok || fail "malformed env ref must exit 3 (rc=$rc: $err)" +bash "$cli" get staging | jq 'del(.users.evil) | del(.name)' > "$tmp/t.json" && jq --slurpfile t "$tmp/t.json" '.targets.staging = $t[0]' "$f" > "$f.tmp" && mv "$f.tmp" "$f" # --base-url override (ad-hoc --web) [ "$(bash "$cli" resolve local --base-url http://127.0.0.1:4000 | jq -r .baseUrl)" = "http://127.0.0.1:4000" ] && ok || fail "base-url override" From 32f18d402526407e1124eb063b29fc8be6695a01 Mon Sep 17 00:00:00 2001 From: Higor Alves <me@higoralves.dev> Date: Tue, 6 Oct 2026 16:12:38 -0300 Subject: [PATCH 18/21] fix(security): ad-hoc remote drops creds, backups out of repo root, explain validates ids everywhere, kokoro pinned with lockfile + HF revision --- orc/lib/explain/animate-piece.mjs | 1 + orc/lib/explain/explain.sh | 7 +- orc/lib/explain/narrate.mjs | 3 +- orc/lib/explain/package-lock.json | 1097 +++++++++++++++++++++++++++ orc/lib/explain/package.json | 10 +- orc/lib/playwright-env.sh | 12 +- orc/lib/targets.sh | 7 +- scripts/ci/verify-explain.sh | 1 + scripts/ci/verify-playwright-env.sh | 3 +- scripts/ci/verify-targets.sh | 4 + 10 files changed, 1136 insertions(+), 9 deletions(-) create mode 100644 orc/lib/explain/package-lock.json diff --git a/orc/lib/explain/animate-piece.mjs b/orc/lib/explain/animate-piece.mjs index c560307..2b2343b 100644 --- a/orc/lib/explain/animate-piece.mjs +++ b/orc/lib/explain/animate-piece.mjs @@ -15,6 +15,7 @@ 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 }); diff --git a/orc/lib/explain/explain.sh b/orc/lib/explain/explain.sh index b92ccec..5df1126 100755 --- a/orc/lib/explain/explain.sh +++ b/orc/lib/explain/explain.sh @@ -33,9 +33,10 @@ orc_ex_validate() { # --segments 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)/animate-piece.mjs" "$(orc_ex__src)/animate.lock" "$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 - (cd "$home" && npm install --no-fund --no-audit >/dev/null) + # 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" } @@ -43,6 +44,7 @@ orc_ex_setup() { 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; } @@ -60,6 +62,7 @@ orc_ex__animate_root() { 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; } diff --git a/orc/lib/explain/narrate.mjs b/orc/lib/explain/narrate.mjs index 7e1d830..b5d8d86 100644 --- a/orc/lib/explain/narrate.mjs +++ b/orc/lib/explain/narrate.mjs @@ -10,7 +10,8 @@ const [, , segmentsPath, outDir] = process.argv; if (!segmentsPath || !outDir) { console.error('usage: narrate.mjs <segments.json> <outDir>'); process.exit(2); } const doc = JSON.parse(fs.readFileSync(segmentsPath, 'utf8')); const voice = doc.voice ?? process.env.ORC_EXPLAIN_VOICE ?? 'af_heart'; -const tts = await KokoroTTS.from_pretrained('onnx-community/Kokoro-82M-v1.0-ONNX', { dtype: 'q8' }); +// 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; 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 index c064296..fb9f908 100644 --- a/orc/lib/explain/package.json +++ b/orc/lib/explain/package.json @@ -1,3 +1,9 @@ -{ "name": "orc-explain", "private": true, "type": "module", +{ + "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.0" } } + "dependencies": { + "kokoro-js": "1.2.1" + } +} diff --git a/orc/lib/playwright-env.sh b/orc/lib/playwright-env.sh index 4af5b82..bd3297b 100755 --- a/orc/lib/playwright-env.sh +++ b/orc/lib/playwright-env.sh @@ -6,6 +6,14 @@ ORC_PW_DEFAULT_DIR="e2e" +orc_pw__backup() { # <repo_root> <file> → copies to <state>/backups/<name>.<ts>, 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() { # <repo_root> → "<dir>\t<config>" | exit 1 @@ -50,7 +58,7 @@ orc_pw_init_agents() { # <repo_root> --dir D --config C (runs from repo root so 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="$root/.mcp.json.orc-backup-$(date -u +%Y%m%dT%H%M%SZ)"; cp "$root/.mcp.json" "$bak"; fi + 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 @@ -64,7 +72,7 @@ orc_pw_mcp_merge() { # <repo_root> --dir D --config C 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 - cp "$f" "$f.orc-backup-$(date -u +%Y%m%dT%H%M%SZ)" + orc_pw__backup "$root" "$f" >/dev/null else printf '{"mcpServers":{}}\n' > "$f" fi diff --git a/orc/lib/targets.sh b/orc/lib/targets.sh index 081fb74..8c8bdc6 100755 --- a/orc/lib/targets.sh +++ b/orc/lib/targets.sh @@ -125,7 +125,12 @@ orc_targets_resolve() { # <name> [--base-url U] → resolved JSON on stdout (nev 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 - [ -n "$override" ] && json="$(printf '%s' "$json" | jq --arg b "$override" '.baseUrl = $b | .kind = (if .kind == "local" and ($b | test("localhost|127\\.0\\.0\\.1") | not) then "remote" else .kind end)')" + # 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 diff --git a/scripts/ci/verify-explain.sh b/scripts/ci/verify-explain.sh index 3bd8df3..d9982ef 100755 --- a/scripts/ci/verify-explain.sh +++ b/scripts/ci/verify-explain.sh @@ -30,6 +30,7 @@ set +e; err="$(bash "$cli" validate --segments "$tmp/bad3.json" 2>&1)"; rc=$?; s set +e; err="$(ORC_ANIMATE_ROOT=/nonexistent ORC_ANIMATE_RENDER=1 bash "$cli" graphics --segments "$tmp/good.json" --work "$tmp/nopieces" 2>&1)"; rc=$?; set -e [ "$rc" -eq 0 ] && ok || fail "render flag without animate must still be a no-op (rc=$rc)" bash "$cli" setup >/dev/null +[ -f "$XDG_CACHE_HOME/orc/explain/package-lock.json" ] && jq -e '.packages["node_modules/kokoro-js"].version == "1.2.1"' "$XDG_CACHE_HOME/orc/explain/package-lock.json" >/dev/null && ok || fail "setup must stage a lockfile pinning kokoro-js" [ -f "$XDG_CACHE_HOME/orc/explain/narrate.mjs" ] && [ -f "$XDG_CACHE_HOME/orc/explain/package.json" ] && ok || fail "setup must stage narrate.mjs + package.json" [ -f "$XDG_CACHE_HOME/orc/explain/animate-piece.mjs" ] && [ -f "$XDG_CACHE_HOME/orc/explain/animate.lock" ] && ok || fail "setup must stage animate-piece.mjs + animate.lock" ORC_ANIMATE_ROOT=/nonexistent bash "$cli" graphics --segments "$tmp/good.json" --work "$tmp/work" | grep -q 'fallback cards' && ok || fail "graphics without animate must exit 0 printing fallback cards" diff --git a/scripts/ci/verify-playwright-env.sh b/scripts/ci/verify-playwright-env.sh index c2fcbb1..ab9b0e1 100755 --- a/scripts/ci/verify-playwright-env.sh +++ b/scripts/ci/verify-playwright-env.sh @@ -40,7 +40,8 @@ printf '{"mcpServers":{"other":{"command":"x","args":[]}}}' > "$tmp/r5/.mcp.json bash "$cli" mcp-merge "$tmp/r5" --dir e2e --config e2e/playwright.config.ts >/dev/null jq -e '.mcpServers.other.command == "x"' "$tmp/r5/.mcp.json" >/dev/null && ok || fail "mcp-merge must keep existing servers" jq -e '.mcpServers["playwright-test"].command == "node" and .mcpServers["playwright-test"].args == ["e2e/node_modules/playwright/cli.js","run-test-mcp-server","-c","e2e/playwright.config.ts"]' "$tmp/r5/.mcp.json" >/dev/null && ok || fail "mcp-merge entry shape" -ls "$tmp/r5"/.mcp.json.orc-backup-* >/dev/null 2>&1 && ok || fail "mcp-merge must back up the previous file" +ls "$tmp/r5"/.orc/backups/.mcp.json.* >/dev/null 2>&1 && ok || fail "mcp-merge must back up under .orc/backups" +ls "$tmp/r5"/.mcp.json.orc-backup-* >/dev/null 2>&1 && fail "backup must never land in the repo root" || ok # mcp-merge with no prior file mkdir -p "$tmp/r6"; bash "$cli" mcp-merge "$tmp/r6" --dir . --config playwright.config.ts >/dev/null jq -e '.mcpServers["playwright-test"].args[0] == "node_modules/playwright/cli.js"' "$tmp/r6/.mcp.json" >/dev/null && ok || fail "mcp-merge root dir path" diff --git a/scripts/ci/verify-targets.sh b/scripts/ci/verify-targets.sh index bce3229..8e057e0 100755 --- a/scripts/ci/verify-targets.sh +++ b/scripts/ci/verify-targets.sh @@ -59,6 +59,10 @@ set +e; err="$(T_USER=a ORC_TARGETS_OP_BIN="$tmp/op" bash "$cli" resolve staging bash "$cli" get staging | jq 'del(.users.evil) | del(.name)' > "$tmp/t.json" && jq --slurpfile t "$tmp/t.json" '.targets.staging = $t[0]' "$f" > "$f.tmp" && mv "$f.tmp" "$f" # --base-url override (ad-hoc --web) [ "$(bash "$cli" resolve local --base-url http://127.0.0.1:4000 | jq -r .baseUrl)" = "http://127.0.0.1:4000" ] && ok || fail "base-url override" +bash "$cli" user local dev --username devuser --password devpass >/dev/null +out="$(bash "$cli" resolve local --base-url https://localhost.evil.example)" +[ "$(printf '%s' "$out" | jq -r '.kind')" = "remote" ] && [ "$(printf '%s' "$out" | jq -c '.users')" = "{}" ] && ok || fail "ad-hoc remote override must drop local users (unanchored localhost match)" +[ "$(bash "$cli" resolve local --base-url http://localhost:3000 | jq -r '.users.dev.username')" = "devuser" ] && ok || fail "localhost override keeps local users" # probe: closed port → exit 1 bash "$cli" probe http://127.0.0.1:1 >/dev/null 2>&1 && fail "probe must fail on closed port" || ok From 9906300d365e7f0bc141c17313e2b0cff54bc0e6 Mon Sep 17 00:00:00 2001 From: Higor Alves <me@higoralves.dev> Date: Tue, 6 Oct 2026 16:13:35 -0300 Subject: [PATCH 19/21] fix(security): credentials resolve inline only, media-only Artifact upload, generated-test scan + healer boundary check --- orc/commands/explain.md | 2 +- orc/skills/browser-qa/SKILL.md | 6 +++--- orc/skills/evidence-publish/SKILL.md | 4 ++-- orc/skills/explainer/SKILL.md | 4 ++-- orc/skills/playwright-qa/SKILL.md | 19 +++++++++++++++---- orc/skills/qa-targets/SKILL.md | 7 +++++-- 6 files changed, 28 insertions(+), 14 deletions(-) diff --git a/orc/commands/explain.md b/orc/commands/explain.md index 0f07055..d9503cd 100644 --- a/orc/commands/explain.md +++ b/orc/commands/explain.md @@ -44,7 +44,7 @@ Turn a QA packet into a short narrated walkthrough of what was built and proven: 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 resolved target JSON (only if the demo pass is needed), 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-<branch>.mp4`, and updates the manifest + `steps.md`. +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-<branch>.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. diff --git a/orc/skills/browser-qa/SKILL.md b/orc/skills/browser-qa/SKILL.md index 2f44fd1..04fdbc7 100644 --- a/orc/skills/browser-qa/SKILL.md +++ b/orc/skills/browser-qa/SKILL.md @@ -9,7 +9,7 @@ The single source of truth for driving browser QA — `/orc:qa` and `/orc:flow` ## Step 0 — Resolve the target -Invoke `orc:qa-targets`: `--target` / `--web` / settled decision / the Target gate. Result: a target name, `guard`, and `ORC_TARGET_JSON` ready to export. Remote target ⇒ probe; unreachable ⇒ `🛑 Escalation — target unreachable`, stop. +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) @@ -18,7 +18,7 @@ Skip for remote targets and under `--no-env`. Check `orc-docker-env is-ready $(o - `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. -Then `ORC_TARGET_JSON="$(orc-targets resolve local --base-url <appUrl>)"`. 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}/<sanitized-branch>/files/qa/`; in workspace mode, cross-repo integration evidence goes there while per-repo QA stays at `<repoPath>/.orc/<branch>/files/qa/`. +Record `<appUrl>` 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}/<sanitized-branch>/files/qa/`; in workspace mode, cross-repo integration evidence goes there while per-repo QA stays at `<repoPath>/.orc/<branch>/files/qa/`. ## Step 1b — Choose the driver @@ -44,7 +44,7 @@ No ledger (ad-hoc QA, `/orc:evidence` against a ticket) ⇒ distil the criteria ## Driver P — Playwright (delegate) -Invoke `orc:playwright-qa` with the inputs listed in its header (feature description, `<qa-dir>`, the step-2 acceptance lists, target name + `guard`, `ORC_TARGET_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. +Invoke `orc:playwright-qa` with the inputs listed in its header (feature description, `<qa-dir>`, 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) diff --git a/orc/skills/evidence-publish/SKILL.md b/orc/skills/evidence-publish/SKILL.md index 6a0ead9..1674f16 100644 --- a/orc/skills/evidence-publish/SKILL.md +++ b/orc/skills/evidence-publish/SKILL.md @@ -43,7 +43,7 @@ No manifest (a hand-built packet) ⇒ fall back to the driver's packet shape: - **Driver B (Chrome)** → `qa-<branch>.gif` + `steps.md`. - **Driver A (agent-browser)** → the `ac-*.png` criterion shots + `qa-<branch>.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 @@ -70,7 +70,7 @@ Artifact target available ⇒ the same `AskUserQuestion` call carries a second q - **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. diff --git a/orc/skills/explainer/SKILL.md b/orc/skills/explainer/SKILL.md index aeba6a4..561a822 100644 --- a/orc/skills/explainer/SKILL.md +++ b/orc/skills/explainer/SKILL.md @@ -7,13 +7,13 @@ description: "Build the narrated what-was-made walkthrough for a QA packet: scri **Announce at start:** "I'm using the explainer skill to build the narrated walkthrough." -Inputs: `<qa-dir>` with `qa-manifest.json`, the session's `plan.md` (optional), `ORC_TARGET_JSON` (for the demo pass; resolve the target per `orc:qa-targets`), settings `explainer_style` (`CLAUDE_PLUGIN_OPTION_EXPLAINER_STYLE`, default `isometric`) and `explainer_voice` (`CLAUDE_PLUGIN_OPTION_EXPLAINER_VOICE`, default `af_heart`). +Inputs: `<qa-dir>` 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 `<qa-dir>/demo/` lacks a `.webm` per `@golden` scenario: `( cd <repo>/<dir> && ORC_TARGET_JSON=… ORC_PW_OUTPUT_DIR=<qa-dir>/pw-demo npx playwright test --project=demo )`, then `orc-qa-video plan --results <qa-dir>/pw-demo/results.json` and copy each `video` to `<qa-dir>/demo/<id>.webm`. +If `<qa-dir>/demo/` lacks a `.webm` per `@golden` scenario: `( cd <repo>/<dir> && ORC_TARGET_JSON="$(orc-targets resolve <name> [--base-url <override>])" ORC_PW_OUTPUT_DIR=<qa-dir>/pw-demo npx playwright test --project=demo )` (resolve stays inline — `orc:qa-targets` iron rule), then `orc-qa-video plan --results <qa-dir>/pw-demo/results.json` and copy each `video` to `<qa-dir>/demo/<id>.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 <base>..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. diff --git a/orc/skills/playwright-qa/SKILL.md b/orc/skills/playwright-qa/SKILL.md index 5b34aee..fd921f1 100644 --- a/orc/skills/playwright-qa/SKILL.md +++ b/orc/skills/playwright-qa/SKILL.md @@ -7,7 +7,7 @@ description: Driver P of orc:browser-qa — browser QA with Playwright Test and **Announce at start:** "I'm using the playwright-qa skill to run browser QA with Playwright." -Inputs from `orc:browser-qa`: feature description, `<qa-dir>`, the acceptance lists (step 2), the resolved target name + `guard` (see `orc:qa-targets`), `ORC_TARGET_JSON` in the environment, `isVisual`. Output: the packet in `<qa-dir>` with `qa-manifest.json` (`driver: "playwright"`). +Inputs from `orc:browser-qa`: feature description, `<qa-dir>`, the acceptance lists (step 2), the target **name** + optional `--base-url` override + `guard` (see `orc:qa-targets`; never the resolved JSON), `isVisual`. Output: the packet in `<qa-dir>` with `qa-manifest.json` (`driver: "playwright"`). ## 0. Preconditions → fallback @@ -46,16 +46,27 @@ For each scenario in the spec: dispatch `playwright-test-generator` with `#gener ## 4. Run +**Scan generated tests first (and again after every heal).** The planner/generator/healer read live page content, so a page can steer the code they write, and that code runs in a process whose environment holds the resolved credentials: + +```bash +grep -nE 'process\.env|fetch\(|child_process|require\(|import\(|XMLHttpRequest|\bnet\b|\bhttp\b' "$repoPath/$dir/tests/<feature-slug>/" +``` + +Any hit ⇒ **stop**: show the lines, `AskUserQuestion` (header `Tests`): drop the test / keep it (reason logged) / abort QA. Also `git -C "$repoPath" diff --name-only` must list only files under `<dir>/tests/` and `<dir>/specs/`; anything else written by an agent is reverted (`git checkout -- <file>`) and reported. + ```bash export ORC_PW_OUTPUT_DIR="<qa-dir>/pw" # outside the repo; never committed -( cd "$repoPath/$dir" && ORC_TARGET_JSON="$ORC_TARGET_JSON" npx playwright test --project=qa $( [ "$guard" = true ] && printf -- '--grep-invert @mutating' ) ) +# guard=false: +( cd "$repoPath/$dir" && ORC_TARGET_JSON="$(orc-targets resolve <name> [--base-url <override>])" npx playwright test --project=qa ) +# guard=true (write the flag literally — a `$( … )` that prints the flag is lost when $guard is unset in a fresh shell): +( cd "$repoPath/$dir" && ORC_TARGET_JSON="$(orc-targets resolve <name> [--base-url <override>])" npx playwright test --project=qa --grep-invert @mutating ) ``` Guarded target ⇒ every `@mutating` scenario becomes a manifest `skipped` row with note `guarded target <name>`. ## 5. Heal (healer agent, cap 3 per test) -Any failed test → dispatch `playwright-test-healer` with `#healer`, naming the failing test and `<qa-dir>/pw/results.json`. Re-run `--project=qa` after it returns. **At most 3 heal dispatches per test**; a test still failing, or one the healer marked `test.fixme()`, maps to `result: "fail"` (still failing) or `"skipped"` with the healer's comment as `note` (fixme). The healer may edit only files under `<dir>/tests/`. +Any failed test → dispatch `playwright-test-healer` with `#healer`, naming the failing test and `<qa-dir>/pw/results.json`. Re-run `--project=qa` after it returns. **At most 3 heal dispatches per test**; a test still failing, or one the healer marked `test.fixme()`, maps to `result: "fail"` (still failing) or `"skipped"` with the healer's comment as `note` (fixme). The healer may edit only files under `<dir>/tests/` — verify with `git diff --name-only` after each heal and re-run the §4 scan; a hit or an out-of-boundary write stops the loop as in §4. ## 6. Evidence @@ -77,4 +88,4 @@ Then write `steps.md` (validator template; one `### Scenario <n> — <title>` pe - No "QA passed" without `qa-manifest.json` + the stitched video (or the stated ffmpeg exemption) + a trace per scenario. - Generated specs/tests are committed with the change (`e2e/specs/**`, `e2e/tests/**` are budget-excluded per `orc:pr-size-budget`); `<qa-dir>/pw/` is not. -- Credentials: only through `ORC_TARGET_JSON` in the process environment (`orc:qa-targets` redaction rules). +- Credentials: only through `ORC_TARGET_JSON="$(orc-targets resolve …)"` inline in the `npx playwright test` command — never a standalone resolve, never in prompts, packet files, or the manifest (`orc:qa-targets` redaction rules). diff --git a/orc/skills/qa-targets/SKILL.md b/orc/skills/qa-targets/SKILL.md index 63d30c3..dfdee62 100644 --- a/orc/skills/qa-targets/SKILL.md +++ b/orc/skills/qa-targets/SKILL.md @@ -45,9 +45,12 @@ Run `orc-targets init && orc-targets list`, then: ## After the gate -- `kind: local` → continue to `orc:browser-qa` Step 0 (env attach/provision). `appUrl` from `docker-env-state.json` becomes `baseUrl` for the run (`orc-targets resolve local --base-url <appUrl>`). +- `kind: local` → continue to `orc:browser-qa` Step 1 (env attach/provision). `appUrl` from `docker-env-state.json` becomes the `--base-url` override the engine passes to `orc-targets resolve local` inline. +- `--web <url>` / **New remote URL** without a saved name → an **unauthenticated** ad-hoc run: `resolve local --base-url <url>` drops `users` and `login` whenever the URL leaves localhost, so local credentials never reach an arbitrary origin. Authenticated remote QA requires a named target. - `kind: remote` → `orc-targets probe <name>`; failure is an **environment escalation** (`🛑 Escalation — target unreachable`: name, URL, curl exit) — stop, never fall back to local silently. -- Export for the engine: `ORC_TARGET_JSON="$(orc-targets resolve <name> [--base-url …])"`. Pass the **name** and `guard` to the validator/planner prompts; pass the JSON only through the environment of the `npx playwright test` call. +- Hand the engine the **target name** (plus the `--base-url` override when one applies) and `guard` — never the resolved JSON. Resolution happens **inline, inside the same shell command that consumes it**: `ORC_TARGET_JSON="$(orc-targets resolve <name> [--base-url …])" npx playwright test …`. Every tool call is a fresh shell, so an exported variable from an earlier call is gone; and a standalone `orc-targets resolve` prints plaintext passwords into the conversation transcript, which is written to disk. + +**Iron rule — never run `orc-targets resolve` as its own command.** It is only ever the `$(…)` inside the command that needs the JSON. If a step seems to need the resolved values to reason about (which user, which URL), use `orc-targets get <name>` — it returns the references, not the secrets. ## Redaction rules (iron) From 1ea55a7768257abc9fb0bf3b403c9e6369ea55aa Mon Sep 17 00:00:00 2001 From: Higor Alves <me@higoralves.dev> Date: Tue, 6 Oct 2026 16:15:17 -0300 Subject: [PATCH 20/21] fix(pr-size): exclude lockfiles at any depth, not only repo root --- orc/lib/pr-size-budget.sh | 18 +++++++++--------- scripts/ci/verify-pr-size.sh | 3 ++- 2 files changed, 11 insertions(+), 10 deletions(-) diff --git a/orc/lib/pr-size-budget.sh b/orc/lib/pr-size-budget.sh index 449f59e..cb8fdf1 100644 --- a/orc/lib/pr-size-budget.sh +++ b/orc/lib/pr-size-budget.sh @@ -29,15 +29,15 @@ 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/**' diff --git a/scripts/ci/verify-pr-size.sh b/scripts/ci/verify-pr-size.sh index 929acf7..a589fa9 100755 --- a/scripts/ci/verify-pr-size.sh +++ b/scripts/ci/verify-pr-size.sh @@ -8,9 +8,10 @@ cd "$tmp" && git init -q -b main && git config user.email t@t && git config user printf 'a\n' > src.txt && git add . && git commit -qm base git checkout -qb feat mkdir -p e2e/specs e2e/tests/x src +mkdir -p lib/sub && printf '{"a":1,\n"b":2}\n' > lib/sub/package-lock.json printf 'spec\nspec\n' > e2e/specs/x.md; printf 'test\ntest\ntest\n' > e2e/tests/x/a.spec.ts; printf 'b\n' >> src.txt git add . && git commit -qm feat # shellcheck disable=SC1091 . "$repo_root/orc/lib/pr-size-budget.sh" loc="$(orc_pr_loc main "$tmp")" -if [ "$loc" = "1" ]; then echo "verify-pr-size: OK (e2e specs/tests excluded, loc=$loc)"; else echo "verify-pr-size: FAIL — expected 1, got $loc"; exit 1; fi +if [ "$loc" = "1" ]; then echo "verify-pr-size: OK (e2e specs/tests + nested lockfiles excluded, loc=$loc)"; else echo "verify-pr-size: FAIL — expected 1, got $loc"; exit 1; fi From 0cad0111af24a17b73e71e0e1369d08b51b30bd3 Mon Sep 17 00:00:00 2001 From: Higor Alves <me@higoralves.dev> Date: Tue, 6 Oct 2026 16:46:07 -0300 Subject: [PATCH 21/21] test(qa): pass fixture credential placeholders via variables so secret scanners skip them --- scripts/ci/verify-targets.sh | 17 +++++++++++------ 1 file changed, 11 insertions(+), 6 deletions(-) diff --git a/scripts/ci/verify-targets.sh b/scripts/ci/verify-targets.sh index 8e057e0..8524a2f 100755 --- a/scripts/ci/verify-targets.sh +++ b/scripts/ci/verify-targets.sh @@ -30,21 +30,23 @@ bash "$cli" get nope >/dev/null 2>&1 && fail "get unknown must exit non-zero" || # list bash "$cli" list | grep -q $'^staging\tremote\thttps://staging.example.com\ttrue$' && ok || fail "list row format" -# user + login -bash "$cli" user staging admin --username env:T_USER --password op://Eng/item/password >/dev/null +# user + login (credential VALUES are references; the fixture passes them through variables so secret +# scanners don't mistake test placeholders for real --password arguments) +ref_user='env:T_USER'; ref_pass='op://Eng/item/password' +bash "$cli" user staging admin --username "$ref_user" --password "$ref_pass" >/dev/null bash "$cli" login staging --path /login --username-field Email --password-field Password --submit "Sign in" --success-path /dashboard >/dev/null [ "$(bash "$cli" get staging | jq -r .login.submit)" = "Sign in" ] && ok || fail "login recipe stored" # resolve: env + op seam cat > "$tmp/op" <<'OP' #!/usr/bin/env bash -[ "$1" = "read" ] && [ "$2" = "op://Eng/item/password" ] && { printf 'hunter2'; exit 0; } +[ "$1" = "read" ] && [ "$2" = "op://Eng/item/password" ] && { printf 'dummy-resolved-value'; exit 0; } exit 1 OP chmod +x "$tmp/op" out="$(T_USER=alice ORC_TARGETS_OP_BIN="$tmp/op" bash "$cli" resolve staging)" [ "$(printf '%s' "$out" | jq -r .users.admin.username)" = "alice" ] && ok || fail "resolve env: reference" -[ "$(printf '%s' "$out" | jq -r .users.admin.password)" = "hunter2" ] && ok || fail "resolve op:// reference" +[ "$(printf '%s' "$out" | jq -r .users.admin.password)" = "dummy-resolved-value" ] && ok || fail "resolve op:// reference" [ "$(printf '%s' "$out" | jq -r .name)" = "staging" ] && ok || fail "resolved JSON carries name" # unresolvable env → exit 3, names the var set +e; err="$(ORC_TARGETS_OP_BIN="$tmp/op" bash "$cli" resolve staging 2>&1 >/dev/null)"; rc=$?; set -e @@ -53,13 +55,16 @@ set +e; err="$(ORC_TARGETS_OP_BIN="$tmp/op" bash "$cli" resolve staging 2>&1 >/d set +e; err="$(T_USER=a ORC_TARGETS_OP_BIN="$tmp/missing-op" bash "$cli" resolve staging 2>&1 >/dev/null)"; rc=$?; set -e [ "$rc" -eq 3 ] && printf '%s' "$err" | grep -qi '1password' && ok || fail "missing op must exit 3 (rc=$rc)" # malformed env reference → exit 3 before any indirect expansion -bash "$cli" user staging evil --username 'env:x[$(id)]' --password literal >/dev/null +# shellcheck disable=SC2016 # the literal $(id) is the payload under test, never expanded +bad_ref='env:x[$(id)]'; placeholder='not-a-secret' +bash "$cli" user staging evil --username "$bad_ref" --password "$placeholder" >/dev/null set +e; err="$(T_USER=a ORC_TARGETS_OP_BIN="$tmp/op" bash "$cli" resolve staging 2>&1 >/dev/null)"; rc=$?; set -e [ "$rc" -eq 3 ] && printf '%s' "$err" | grep -q 'invalid env reference' && ok || fail "malformed env ref must exit 3 (rc=$rc: $err)" bash "$cli" get staging | jq 'del(.users.evil) | del(.name)' > "$tmp/t.json" && jq --slurpfile t "$tmp/t.json" '.targets.staging = $t[0]' "$f" > "$f.tmp" && mv "$f.tmp" "$f" # --base-url override (ad-hoc --web) [ "$(bash "$cli" resolve local --base-url http://127.0.0.1:4000 | jq -r .baseUrl)" = "http://127.0.0.1:4000" ] && ok || fail "base-url override" -bash "$cli" user local dev --username devuser --password devpass >/dev/null +dev_user='devuser'; dev_pass='not-a-secret' +bash "$cli" user local dev --username "$dev_user" --password "$dev_pass" >/dev/null out="$(bash "$cli" resolve local --base-url https://localhost.evil.example)" [ "$(printf '%s' "$out" | jq -r '.kind')" = "remote" ] && [ "$(printf '%s' "$out" | jq -c '.users')" = "{}" ] && ok || fail "ad-hoc remote override must drop local users (unanchored localhost match)" [ "$(bash "$cli" resolve local --base-url http://localhost:3000 | jq -r '.users.dev.username')" = "devuser" ] && ok || fail "localhost override keeps local users"