feat(v-onboard): add Operations/Deployment coverage dimension to /v:onboard #55
Workflow file for this run
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| name: Validate Plugin | |
| on: | |
| push: | |
| branches: [main] | |
| pull_request: | |
| branches: [main] | |
| jobs: | |
| validate: | |
| name: Validate plugin structure | |
| runs-on: ubuntu-latest | |
| steps: | |
| - uses: actions/checkout@v4 | |
| - name: Install jq | |
| run: sudo apt-get install -y jq | |
| - name: Validate plugin.json is valid JSON | |
| run: jq empty .claude-plugin/plugin.json | |
| - name: Validate marketplace.json is valid JSON | |
| run: jq empty .claude-plugin/marketplace.json | |
| - name: Validate hooks.json is valid JSON | |
| run: jq empty hooks/hooks.json | |
| - name: Validate job_result.schema.json is valid JSON | |
| run: jq empty schemas/job_result.schema.json | |
| - name: Verify required plugin.json fields | |
| run: | | |
| set -e | |
| for field in name description version author license; do | |
| value=$(jq -r ".$field // \"\"" .claude-plugin/plugin.json) | |
| if [ -z "$value" ] || [ "$value" = "null" ]; then | |
| echo "❌ plugin.json missing required field: $field" | |
| exit 1 | |
| fi | |
| echo "✅ plugin.json.$field = $value" | |
| done | |
| - name: Verify plugin.json and marketplace.json versions match | |
| run: | | |
| set -e | |
| pv=$(jq -r '.version' .claude-plugin/plugin.json) | |
| mv=$(jq -r '.plugins[] | select(.name=="superpowers-v") | .version' .claude-plugin/marketplace.json) | |
| if [ "$pv" != "$mv" ]; then | |
| echo "❌ version mismatch: plugin.json=$pv marketplace.json=$mv" | |
| exit 1 | |
| fi | |
| echo "✅ versions in lockstep: $pv" | |
| - name: Verify CHANGELOG top version matches plugin.json (lockstep guard) | |
| run: | | |
| PLUGIN_VERSION=$(jq -r .version .claude-plugin/plugin.json) | |
| # First release heading OUTSIDE fenced code blocks; [Unreleased] (non-numeric) is skipped. | |
| # Fences per CommonMark: ``` or ~~~ (≤3 leading spaces); closer = same char, run ≥ opener's. | |
| CHANGELOG_VERSION=$(awk ' | |
| NR==1 { sub(/^\xef\xbb\xbf/, "") } | |
| /^ {0,3}(```|~~~)/ { | |
| match($0, /(`+|~+)/); c = substr($0, RSTART, 1); len = RLENGTH | |
| rest = substr($0, RSTART + RLENGTH) | |
| if (!fence) { | |
| # a backtick opener whose info string contains a backtick is NOT a fence (CommonMark) | |
| if (!(c == "`" && rest ~ /`/)) { fence = 1; fc = c; flen = len } | |
| } else if (c == fc && len >= flen && rest ~ /^[ \t]*$/) { fence = 0 } | |
| next | |
| } | |
| !fence && /^ {0,3}##[ \t]+\[[0-9]+\.[0-9]+\.[0-9]+\]/ { | |
| match($0, /\[[0-9]+\.[0-9]+\.[0-9]+\]/); print substr($0, RSTART+1, RLENGTH-2); exit | |
| } | |
| ' CHANGELOG.md) | |
| if [ -z "$CHANGELOG_VERSION" ]; then | |
| echo "::error::No release heading '## [x.y.z]' found in CHANGELOG.md (outside code fences)." | |
| exit 1 | |
| fi | |
| if [ "$PLUGIN_VERSION" != "$CHANGELOG_VERSION" ]; then | |
| echo "::error::CHANGELOG top entry ($CHANGELOG_VERSION) != plugin.json version ($PLUGIN_VERSION). Bump plugin.json, marketplace.json AND the CHANGELOG heading together." | |
| exit 1 | |
| fi | |
| - name: Verify agent files have required frontmatter | |
| run: | | |
| set -e | |
| for agent in agents/*.md; do | |
| echo "Checking $agent..." | |
| # Frontmatter must have name and description | |
| head -20 "$agent" | grep -q "^name:" || { echo "❌ $agent missing 'name' in frontmatter"; exit 1; } | |
| head -20 "$agent" | grep -q "^description:" || { echo "❌ $agent missing 'description' in frontmatter"; exit 1; } | |
| # Per project policy: no Haiku | |
| if head -20 "$agent" | grep -qi "^model:.*haiku"; then | |
| echo "❌ $agent specifies Haiku — project policy forbids Haiku" | |
| exit 1 | |
| fi | |
| echo "✅ $agent OK" | |
| done | |
| - name: Verify SKILL.md frontmatter | |
| run: | | |
| set -e | |
| for skill in skills/*/SKILL.md; do | |
| echo "Checking $skill..." | |
| head -20 "$skill" | grep -q "^name:" || { echo "❌ $skill missing 'name'"; exit 1; } | |
| head -20 "$skill" | grep -q "^description:" || { echo "❌ $skill missing 'description'"; exit 1; } | |
| echo "✅ $skill OK" | |
| done | |
| - name: Set up Python for linters and validators | |
| uses: actions/setup-python@v5 | |
| with: | |
| python-version: '3.12' | |
| - name: Install PyYAML | |
| run: pip install pyyaml | |
| - name: Run frontmatter linter (description length, missing fields, no-Haiku policy) | |
| run: python3 scripts/lint-frontmatter.py . | |
| - name: Validate every manifest against the invariant gate | |
| run: | | |
| set -e | |
| # The deterministic manifest validator is the partition gate. A missing | |
| # validator is a HARD failure (a rename must never silently drop the gate). | |
| test -f scripts/compound-v-validate-manifest.py || { echo "❌ scripts/compound-v-validate-manifest.py is missing — the partition gate would silently vanish"; exit 1; } | |
| python3 scripts/compound-v-validate-manifest.py examples/manifest.example.yaml | |
| echo "✅ example manifest passes the invariant gate" | |
| # Also validate every tracked run-manifest — exercise the gate on real data, | |
| # not just one curated fixture. | |
| for m in docs/superpowers/execution/*/manifest.yaml; do | |
| [ -f "$m" ] || continue | |
| python3 scripts/compound-v-validate-manifest.py "$m" | |
| echo "✅ $m passes the invariant gate" | |
| done | |
| - name: Validate example job_result against the schema | |
| run: | | |
| set -e | |
| # Prefer the project's own collector validator when present; otherwise fall | |
| # back to a stdlib jsonschema-style check via a tiny inline Python guard. | |
| python3 - <<'PY' | |
| import json, sys | |
| schema = json.load(open("schemas/job_result.schema.json")) | |
| inst = json.load(open("examples/job_result.example.json")) | |
| req = schema.get("required", []) | |
| props = schema.get("properties", {}) | |
| missing = [k for k in req if k not in inst] | |
| if missing: | |
| print("❌ example job_result missing required keys:", missing); sys.exit(1) | |
| if schema.get("additionalProperties") is False: | |
| extra = [k for k in inst if k not in props] | |
| if extra: | |
| print("❌ example job_result has keys not in schema:", extra); sys.exit(1) | |
| enum = props.get("status", {}).get("enum") | |
| if enum and inst.get("status") not in enum: | |
| print("❌ status not in enum:", inst.get("status")); sys.exit(1) | |
| print("✅ example job_result conforms to job_result.schema.json") | |
| PY | |
| - name: No fabricated cost/token metrics (anti-ruflo gate) | |
| run: | | |
| set -e | |
| # The anti-ruflo charter forbids printing token-cost numbers we cannot measure. | |
| # Guard scripts/ and docs/ for fabricated metric output. We match phrases that | |
| # would only appear if code/docs CLAIMED measured savings (e.g. "tokens saved", | |
| # "cost savings: N", a hardcoded "baseline = 1000"). Pure mentions of the | |
| # anti-pattern (the word "anti-ruflo", "fabricated", "do not print") are allowed. | |
| fail=0 | |
| patterns='tokens? saved|token-cost (saved|savings)|cost savings:|saved [0-9]+ tokens|baseline ?= ?1000|\$[0-9]+\.[0-9]+ saved' | |
| while IFS= read -r -d '' f; do | |
| # Skip this workflow file itself (it names the patterns) and the PRD/plan, | |
| # which discuss the anti-pattern by name. | |
| case "$f" in | |
| *docs/superpowers/specs/*|*docs/superpowers/plans/*) continue ;; | |
| esac | |
| if grep -inE "$patterns" "$f" >/dev/null 2>&1; then | |
| echo "❌ possible fabricated cost/token metric in $f:" | |
| grep -inE "$patterns" "$f" | |
| fail=1 | |
| fi | |
| done < <(find scripts docs -type f \( -name "*.sh" -o -name "*.py" -o -name "*.md" \) -print0 2>/dev/null) | |
| [ "$fail" = "0" ] || exit 1 | |
| echo "✅ no fabricated cost/token metrics in scripts/ or docs/" | |
| - name: Verify hook scripts are executable | |
| run: | | |
| set -e | |
| for hook in hooks/*.sh; do | |
| if [ ! -x "$hook" ]; then | |
| echo "❌ $hook is not executable (run: chmod +x $hook)" | |
| exit 1 | |
| fi | |
| echo "✅ $hook is executable" | |
| done | |
| - name: Lint hook scripts with shellcheck | |
| run: | | |
| sudo apt-get install -y shellcheck | |
| shellcheck hooks/*.sh | |
| # FINAL step — run the repo-wide dead-link scan only after every file exists, | |
| # so cross-refs to files authored by later batches resolve at integration time. | |
| - name: Check for dead intra-plugin cross-refs | |
| run: | | |
| set -e | |
| # Verify every markdown link to an intra-repo file (.md/.py/.sh/.json/.yml/.yaml) | |
| # resolves. Dead links are accumulated into a real FILE, not a shell variable: | |
| # the inner `grep | … | while` runs in a SUBSHELL, so a `fail=1` assignment there | |
| # never propagates to the outer scope (the historical bug that made this guard a | |
| # silent no-op). Appending to a temp file survives the subshell. | |
| deadfile="$(mktemp)" | |
| while IFS= read -r -d '' file; do | |
| dir=$(dirname "$file") | |
| grep -oE '\]\([^)]+\.(md|py|sh|json|ya?ml)[^)]*\)' "$file" 2>/dev/null | sed 's/^](//;s/)$//' | while IFS= read -r link; do | |
| # Strip a #anchor and a :LINE / :LINE-LINE suffix (file:line refs are clickable | |
| # links, not filenames — the target is the bare file). | |
| path="${link%%#*}" | |
| path="$(printf '%s' "$path" | sed -E 's/:[0-9]+(-[0-9]+)?$//')" | |
| # Skip URLs and absolute /docs paths | |
| case "$path" in | |
| http*|/docs/*|""|"#"*) continue ;; | |
| esac | |
| # Resolve relative to the source file's dir | |
| target="$dir/$path" | |
| if [ ! -f "$target" ]; then | |
| echo "❌ Dead link in $file → $path (resolved: $target)" | |
| echo x >> "$deadfile" | |
| fi | |
| done | |
| done < <(find . -name "*.md" -not -path "./node_modules/*" -not -path "./.git/*" -print0) | |
| if [ -s "$deadfile" ]; then | |
| n=$(wc -l < "$deadfile" | tr -d ' '); rm -f "$deadfile" | |
| echo "❌ $n dead intra-plugin link(s) — see above"; exit 1 | |
| fi | |
| rm -f "$deadfile" | |
| echo "✅ All intra-plugin links resolve" | |
| # Marathon-mode (v2.10) selftests run under Python 3.9 — the documented floor for | |
| # scripts/compound-v-epic-state.py and scripts/compound-v-epic-arbiter.py (both pure | |
| # stdlib, no PyYAML dependency). Installed LAST in the job so re-pointing PATH at a | |
| # 3.9 interpreter can never affect the earlier PyYAML-dependent steps above. | |
| - name: Set up Python 3.9 (marathon selftest floor) | |
| uses: actions/setup-python@v5 | |
| with: | |
| python-version: '3.9' | |
| - name: Run epic-state.py selftest (Python 3.9 floor) | |
| run: python3 scripts/compound-v-epic-state.py --selftest | |
| - name: Run epic-arbiter.py selftest (Python 3.9 floor) | |
| run: python3 scripts/compound-v-epic-arbiter.py --selftest | |
| - name: Run epic-watch.py selftest (Python 3.9 floor) | |
| run: python3 scripts/compound-v-epic-watch.py --selftest | |
| - name: Run headless-shim.py selftest (Python 3.9 floor) | |
| run: python3 scripts/compound-v-headless-shim.py --selftest | |
| # The four steps above run the resurrection-critical quartet under a CLEAN 3.9 (no | |
| # third-party deps) to prove they stay pure-stdlib. This step then covers EVERY other | |
| # script that ships a --selftest, so a regression anywhere in the routing / scope-gate / | |
| # model-resolution / usage / memory / collector surface is caught (previously only the | |
| # quartet was defended). Discovery is dynamic — a new script with a --selftest is picked | |
| # up automatically. pyyaml + jsonschema are the only third-party deps any selftest needs | |
| # (the memory dense-lane degrades to FTS5-only without its out-of-repo venv). | |
| - name: Run ALL script selftests (Python 3.9 floor) | |
| run: | | |
| set -e | |
| python3 -m pip install --quiet pyyaml jsonschema | |
| rc=0 | |
| for s in scripts/*.py; do | |
| if grep -q -- '--selftest' "$s"; then | |
| echo "── $s --selftest" | |
| if ! LANG=C python3 "$s" --selftest; then | |
| echo "❌ SELFTEST FAILED: $s"; rc=1 | |
| fi | |
| fi | |
| done | |
| [ "$rc" = "0" ] || { echo "❌ one or more selftests failed"; exit 1; } | |
| echo "✅ all script selftests pass under Python 3.9" |