Skip to content

release: v3.5.1 — contributed V-memory embedding sub-batches (#9, @kh… #134

release: v3.5.1 — contributed V-memory embedding sub-batches (#9, @kh…

release: v3.5.1 — contributed V-memory embedding sub-batches (#9, @kh… #134

Workflow file for this run

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"