release: v3.5.1 — contributed V-memory embedding sub-batches (#9, @kh… #134
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: Every run dir has a committed state.json (audit-trail gate) | |
| run: | | |
| set -e | |
| # /v:orchestrate writes state.json (step 6) and commits it (step 8) -- both | |
| # are PROSE instructions in a markdown file, so an agent can satisfy neither | |
| # and nothing notices. Four runs between 2026-07-13 and 2026-07-25 shipped a | |
| # manifest with no state: /v:status reports "NO STATE" for them, and the | |
| # SessionStart resume banner (which reads state.json to tell a just-compacted | |
| # agent where it was) is blind to them. Their state is genuinely lost, so they | |
| # are allowlisted by id -- never back-filled, because a reconstructed audit | |
| # trail is the fabricated-evidence pattern, not a repair. | |
| # 2026-07-26-v2.18-autonomy joined them when that branch merged into v3.0: | |
| # this gate caught it on the merge, which is the gate working as designed. | |
| # Its state was never written either, so it is listed, not reconstructed. | |
| ALLOW="2026-07-13-usage-and-advisor 2026-07-14-v2.14-blockers-and-headless 2026-07-15-v2.16-decision-preferences 2026-07-25-v2.17-cochange 2026-07-26-v2.18-autonomy" | |
| rc=0 | |
| for m in $(git ls-files 'docs/superpowers/execution/*/manifest.yaml'); do | |
| d=$(dirname "$m"); id=$(basename "$d") | |
| case " $ALLOW " in *" $id "*) continue ;; esac | |
| git ls-files --error-unmatch "$d/state.json" >/dev/null 2>&1 || { | |
| echo "❌ $d has a committed manifest.yaml but no committed state.json"; rc=1; } | |
| done | |
| [ "$rc" = 0 ] || { echo "A run with no committed state.json is invisible to /v:status and to the resume banner. Commit it (/v:orchestrate step 8)."; exit 1; } | |
| echo "✅ every non-allowlisted run dir carries a committed state.json" | |
| - 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 | |
| # preflight/ joined this list in 3.0. The domain audit's own MUST NOT | |
| # rules name the very pattern this gate detects ("any claimed savings | |
| # ... MUST NOT be printed as a measurement"), so the document that | |
| # FORBIDS the anti-pattern was failing the guard against it. Same | |
| # category as specs/ and plans/: prose that discusses the anti-pattern | |
| # by name, never code that emits one. | |
| *docs/superpowers/specs/*|*docs/superpowers/plans/*|*docs/superpowers/preflight/*) 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 scripts/compound-v-*.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") | |
| # A link QUOTED inside a fenced block or an inline code span is text, not | |
| # navigation: review files and audits quote `[x](path)` examples from nested | |
| # directories, and every one of those read as dead from that directory (the | |
| # 2026-09-03 epic's integration review found nine, and hit the trap itself while | |
| # quoting one). Drop fenced blocks and strip inline code spans before extracting. | |
| awk '/^[[:space:]]*(```|~~~)/ { fence = !fence; next } !fence { print }' "$file" \ | |
| | sed -E 's/`[^`]*`//g' \ | |
| | grep -oE '\]\([^)]+\.(md|py|sh|json|ya?ml)[^)]*\)' 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/*" -not -path "./docs/superpowers/execution/*/jobs/*" -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 | |
| # The two steps above run the epic-critical pair 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 | |
| # pair 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" | |
| # The selftest sweep above globs scripts/*.py ONLY. Everything under tests/ is | |
| # executed by the separate `tests` job below, which always runs. | |
| # --------------------------------------------------------------------------- | |
| # The full-suite backstop (v3.0, Feature B5). | |
| # | |
| # This is a JOB, not a step guarded by `if:` — and the workflow carries no `paths:` | |
| # filter. GitHub reports a conditionally-skipped job as `Success`, so a path filter | |
| # or an `if:` on a REQUIRED check turns it green without executing anything. The job | |
| # therefore always runs and dispatches internally: it decides what to execute, the | |
| # workflow never decides whether to execute it. | |
| # | |
| # It also fixes a second, older hole. Until 3.0 the only sweep was `scripts/*.py` | |
| # plus ONE hardcoded `bash tests/test-epic-goal-stop.sh`, so three of the four files | |
| # under tests/ had never run in CI at all — including tests/v2.9-e2e/*.py, which a | |
| # flat `tests/*.sh` glob would still miss. The discovery below is RECURSIVE and | |
| # covers both extensions, and it fails when it discovers nothing: a guard that | |
| # silently matches zero files is the v2.14.1 false-green all over again. | |
| # --------------------------------------------------------------------------- | |
| tests: | |
| name: Full test suite (tests/, always runs) | |
| runs-on: ubuntu-latest | |
| steps: | |
| - uses: actions/checkout@v4 | |
| - name: Install jq | |
| run: sudo apt-get install -y jq | |
| # Python 3.9 is the documented floor for the scripts these tests drive. | |
| - name: Set up Python 3.9 (test floor) | |
| uses: actions/setup-python@v5 | |
| with: | |
| python-version: '3.9' | |
| - name: Install test dependencies | |
| run: python3 -m pip install --quiet pyyaml jsonschema | |
| - name: Run every test under tests/ (recursive, .sh and .py) | |
| run: | | |
| # No `set -e`: every test runs and every failure is reported, rather than | |
| # stopping at the first. PYTHONDONTWRITEBYTECODE keeps a local run of this | |
| # same sweep from leaving scripts/__pycache__/*.pyc behind — untracked files | |
| # the scope gate unions into a job's changed set, which would BLOCK the job | |
| # that ran its own tests. | |
| set -uo pipefail | |
| rc=0 | |
| found=0 | |
| while IFS= read -r t; do | |
| found=$((found + 1)) | |
| case "$t" in | |
| *.sh) echo "── bash $t"; LANG=C bash "$t" || { echo "❌ TEST FAILED: $t"; rc=1; } ;; | |
| *.py) echo "── python3 $t"; LANG=C PYTHONDONTWRITEBYTECODE=1 python3 "$t" || { echo "❌ TEST FAILED: $t"; rc=1; } ;; | |
| esac | |
| done < <(find tests -type f \( -name '*.sh' -o -name '*.py' \) -print0 \ | |
| | LC_ALL=C sort -z | tr '\0' '\n') | |
| if [ "$found" = "0" ]; then | |
| echo "❌ no test files discovered under tests/ — the discovery glob is dead, which is exactly how 25 of 29 selftests silently stopped running in v2.14" | |
| exit 1 | |
| fi | |
| [ "$rc" = "0" ] || { echo "❌ $found test file(s) swept; one or more failed"; exit 1; } | |
| echo "✅ all $found test file(s) under tests/ pass" |