feat(profiles): prune the gstack catalog, add max, honor a removed denylist

Nine gstack skills leave every profile (ship is trunk-based on Gitea,
land-and-deploy auto-merges and deploys, setup-deploy, autoplan reads
paths that do not exist here, context-save has no restore, learn is an
unused parallel store, careful and guard hooks never fired, design-shotgun
needs an absent OpenAI key). lib/gstack-removed.sh is the single denylist;
profile.sh gstack on and toggle-external.sh enable gstack skip it.

full now carries everything every other profile carries (user rule), minus
the parked make-pdf, diagram and 21st-ai/ui-explore/ui-review, which live
in the new max profile together with pr-review-toolkit. The 21st trio also
leaves web, web-full and design (redundant with impeccable + ui-ux-pro-max).

lib/tests/profile-census.test.sh asserts the invariants live and on a
baseline fixture plus one mutant per invariant; gstack-removed.test.sh
covers both restore paths.
This commit is contained in:
bastien
2026-09-28 11:48:44 +02:00
parent d91d8d820a
commit f83f8f755b
18 changed files with 457 additions and 67 deletions
+66
View File
@@ -0,0 +1,66 @@
#!/usr/bin/env bash
# lib/tests/gstack-removed.test.sh — GSTACK_REMOVED denylist honored by
# every "bring gstack back" path: profile.sh `gstack on` and
# toggle-external.sh `enable gstack` both skip a policy-removed name and
# leave it parked, restoring only what policy allows; gstack_is_removed()
# itself, positive + negative. Covers contract criterion 17. Hermetic:
# fixture repo via PROFILE_REPO_OVERRIDE / TOGGLE_EXTERNAL_REPO_OVERRIDE —
# same harness as lib/tests/profile-default.test.sh and
# lib/tests/toggle-external-repo-resolution.test.sh.
set -u
ROOT="$(cd "$(dirname "$0")/../.." && pwd)"
pass=0; fail=0
check() { if [ "$2" = "$3" ]; then pass=$((pass+1)); else fail=$((fail+1));
printf 'FAIL %s: got[%s] want[%s]\n' "$1" "$2" "$3"; fi; }
check_has() { case "$2" in *"$3"*) pass=$((pass+1));; *) fail=$((fail+1));
printf 'FAIL %s: [%s] does not contain [%s]\n' "$1" "$2" "$3";; esac; }
check_not() { case "$2" in *"$3"*) fail=$((fail+1));
printf 'FAIL %s: [%s] unexpectedly contains [%s]\n' "$1" "$2" "$3";; *) pass=$((pass+1));; esac; }
# mk_fixture <dir> — minimal repo: profile.sh + toggle-external.sh +
# gstack-removed.sh under lib/, one removed name (ship) and one kept name
# (browse) parked in skills-disabled/.
mk_fixture() {
local fx="$1"
mkdir -p "$fx/skills" "$fx/skills-disabled/gstack__ship" \
"$fx/skills-disabled/gstack__browse" "$fx/lib"
cp "$ROOT/lib/profile.sh" "$ROOT/lib/toggle-external.sh" \
"$ROOT/lib/gstack-removed.sh" "$fx/lib/"
}
# --- T1-T5: profile.sh gstack on restores browse, skips + parks ship ---
FX1="$(mktemp -d)"; mk_fixture "$FX1"
out="$(PROFILE_REPO_OVERRIDE="$FX1" bash "$FX1/lib/profile.sh" gstack on 2>&1)"
check T1-browse-restored "$([ -e "$FX1/skills/browse" ] && echo on || echo off)" on
check T2-ship-parked "$([ -e "$FX1/skills-disabled/gstack__ship" ] && echo p || echo n)" p
check T3-ship-not-live "$([ -e "$FX1/skills/ship" ] && echo on || echo off)" off
check_has T4-skip-names-ship "$out" "ship"
check_has T5-real-count "$out" "1 parked gstack skills restored"
rm -rf "$FX1"
# --- T6-T9: toggle-external.sh enable gstack — same skip + restore ---
FX2="$(mktemp -d)"; mk_fixture "$FX2"
out="$(TOGGLE_EXTERNAL_REPO_OVERRIDE="$FX2" bash "$FX2/lib/toggle-external.sh" enable gstack 2>&1)"
check T6-browse-restored "$([ -e "$FX2/skills/browse" ] && echo on || echo off)" on
check T7-ship-parked "$([ -e "$FX2/skills-disabled/gstack__ship" ] && echo p || echo n)" p
check T8-ship-not-live "$([ -e "$FX2/skills/ship" ] && echo on || echo off)" off
check_has T9-skip-names-ship "$out" "ship"
rm -rf "$FX2"
# --- T10-T11: toggle-external.sh, only removed names parked — 0 restored,
# the policy message fires instead of the "re-run gstack setup" hint ---
FX3="$(mktemp -d)"
mkdir -p "$FX3/skills" "$FX3/skills-disabled/gstack__ship" "$FX3/lib"
cp "$ROOT/lib/toggle-external.sh" "$ROOT/lib/gstack-removed.sh" "$FX3/lib/"
out="$(TOGGLE_EXTERNAL_REPO_OVERRIDE="$FX3" bash "$FX3/lib/toggle-external.sh" enable gstack 2>&1)"
check_has T10-policy-msg "$out" "policy-removed skills remain parked"
check_not T11-no-setup-hint "$out" "re-run gstack setup"
rm -rf "$FX3"
# --- T12-T13: gstack_is_removed() itself, positive + negative ---
# shellcheck source=lib/gstack-removed.sh disable=SC1091
source "$ROOT/lib/gstack-removed.sh"
gstack_is_removed ship; check T12-removed-positive "$?" 0
gstack_is_removed browse; check T13-removed-negative "$?" 1
printf 'PASS=%s FAIL=%s\n' "$pass" "$fail"; [ "$fail" -eq 0 ]
+192
View File
@@ -0,0 +1,192 @@
#!/usr/bin/env bash
# lib/tests/profile-census.test.sh — profile catalog invariants (skill-
# catalog prune, 2026-09-28): no GSTACK_REMOVED name listed in any
# profile, exactly one profile carries the `# SUPERSET-OF: full` marker
# and it is a superset of full + the parked tools + every name any
# profile carries, and `full` carries every name any other (non-max)
# profile carries save one named exception (pr-review-toolkit).
#
# Hermetic: a passing baseline fixture, then one single-change mutant per
# invariant (each mutant is a positive control — it MUST be detected).
# Live part runs against this repo's real lib/profiles/ (a violation
# there fails the suite for real, same style as
# lib/tests/skill-routing-census.test.sh).
set -u
ROOT="$(cd "$(dirname "$0")/../.." && pwd)"
# shellcheck source=lib/gstack-removed.sh disable=SC1091
source "$ROOT/lib/gstack-removed.sh"
# Parked = kept installed, out of `full`, listed only in the superset
# profile (`max`). Names come from the single denylist above for REMOVED;
# PARKED has no such shared source (it is a positive allowlist, not a
# denylist), so it is spelled out here.
PARKED=(make-pdf diagram 21st-ai 21st-ui-explore 21st-ui-review)
# full carries every name any other (non-max) profile carries, with one
# named exception: pr-review-toolkit is deliberately out of full (audit
# 2026-07-02 #12, heaviest single plugin, ~2.2k tokens/session, PR-only
# use) — measured 2026-09-28: it is the only name any specialized
# profile carries that full lacks (audit.profile).
FULL_EXCEPTIONS=(pr-review-toolkit)
pass=0; fail=0
check() { if [ "$2" = "$3" ]; then pass=$((pass+1)); else fail=$((fail+1));
printf 'FAIL %s: got[%s] want[%s]\n' "$1" "$2" "$3"; fi; }
# census_check <profiles_dir> [parked_csv] [exceptions_csv] — one
# violation code per line on stdout, rc 0 iff none. REMOVED always comes
# from the sourced denylist (single source, never overridden);
# PARKED/FULL_EXCEPTIONS default to the real production sets and can be
# overridden per call (fixture runs use a small standalone catalog).
census_check() {
local dir="$1"
# Unset (arg omitted, live call) falls back to the real production
# set; an explicitly EMPTY string (fixture calls) means "none" and
# must stay empty — hence "-" defaults, never ":-" (which would treat
# empty the same as unset and silently pull the real set back in).
local parked_csv="${2-$(IFS=,; echo "${PARKED[*]}")}"
local exc_csv="${3-$(IFS=,; echo "${FULL_EXCEPTIONS[*]}")}"
local removed_csv out
removed_csv="$(IFS=,; echo "${GSTACK_REMOVED[*]}")"
out=$(python3 - "$dir" "$removed_csv" "$parked_csv" "$exc_csv" <<'PY'
import glob, os, re, sys
def entries(path):
"""Entry = first whitespace token of a non-blank, non-comment line."""
names = set()
for line in open(path, encoding="utf-8"):
s = line.strip()
if s and not s.startswith("#"):
names.add(s.split()[0])
return names
def has_marker(path):
text = open(path, encoding="utf-8").read()
return re.search(r'^# SUPERSET-OF: full\s*$', text, re.M) is not None
def check_removed(by_name, removed):
return [f"REMOVED_LISTED:{n}:{e}" for n, ents in by_name.items()
for e in sorted(ents & removed)]
def find_superset(files):
hits = [p for p in files if has_marker(p)]
if not hits:
return None, ["NO_SUPERSET"]
if len(hits) > 1:
return None, ["MANY_SUPERSETS"]
return os.path.basename(hits[0])[:-len(".profile")], []
def check_superset_gap(by_name, sname, parked, exceptions):
full = by_name.get("full", set())
target = full | parked | exceptions
return [f"SUPERSET_GAP:{n}" for n in sorted(target - by_name[sname])]
def check_max_gap(by_name, sname, removed):
union = set().union(*by_name.values())
gap = (union - removed) - by_name[sname]
return [f"MAX_GAP:{n}" for n in sorted(gap)]
def check_full_gap(by_name, sname, removed, exceptions):
trio = {"21st-ai", "21st-ui-explore", "21st-ui-review"}
src = set().union(*(e for n, e in by_name.items()
if n not in ("full", sname)))
full = by_name.get("full", set())
gap = src - full - removed - trio - exceptions
return [f"FULL_GAP:{n}" for n in sorted(gap)]
def main():
d, removed_csv, parked_csv, exc_csv = sys.argv[1:5]
removed = {x for x in removed_csv.split(",") if x}
parked = {x for x in parked_csv.split(",") if x}
exceptions = {x for x in exc_csv.split(",") if x}
files = sorted(glob.glob(os.path.join(d, "*.profile")))
by_name = {os.path.basename(p)[:-len(".profile")]: entries(p)
for p in files}
violations = check_removed(by_name, removed)
sname, sup_violations = find_superset(files)
violations += sup_violations
if sname:
violations += check_superset_gap(by_name, sname, parked, exceptions)
violations += check_max_gap(by_name, sname, removed)
violations += check_full_gap(by_name, sname, removed, exceptions)
# Reason codes only, one per line; no exit here — the bash caller
# derives pass/fail from whether this captured output is empty.
print("\n".join(violations))
main()
PY
)
if [ -n "$out" ]; then
printf '%s\n' "$out"
return 1
fi
return 0
}
# run_mutant <label> <dir> <code> <flag> — census_check on <dir> (fixture
# PARKED override, empty FULL_EXCEPTIONS), asserts a non-zero rc AND the
# presence of <code>; echoes <flag> when both hold (the positive-control
# marker the contract CHECK greps for).
run_mutant() {
local label="$1" dir="$2" code="$3" flag="$4"
local out rc hit nonzero
out=$(census_check "$dir" "parked-x" ""); rc=$?
[ -n "$out" ] && printf '%s\n' "$out"
hit=$(printf '%s\n' "$out" | grep -qxF "$code" && echo yes || echo no)
nonzero=$([ "$rc" -ne 0 ] && echo yes || echo no)
check "$label-code" "$hit" yes
check "$label-nonzero" "$nonzero" yes
[ "$hit" = yes ] && [ "$nonzero" = yes ] && echo "$flag"
}
# ── live: this repo's real profiles dir (a violation here → suite RED) ──
live_out=$(census_check "$ROOT/lib/profiles"); live_rc=$?
[ -n "$live_out" ] && printf '%s\n' "$live_out"
check T1-live-clean "$live_rc" 0
# ── fixtures: baseline + one single-change mutant per invariant ────────
WORK="$(mktemp -d)"; trap 'rm -rf "$WORK"' EXIT
BASE="$WORK/baseline"
mkdir -p "$BASE"
cat > "$BASE/full.profile" <<'EOF'
alpha
beta
EOF
cat > "$BASE/qa.profile" <<'EOF'
alpha
EOF
cat > "$BASE/max.profile" <<'EOF'
# SUPERSET-OF: full
alpha
beta
parked-x
EOF
base_out=$(census_check "$BASE" "parked-x" ""); base_rc=$?
[ -n "$base_out" ] && printf '%s\n' "$base_out"
check T2-fixture-baseline-clean "$base_rc" 0
[ "$base_rc" -eq 0 ] && echo FIXTURE_BASELINE_OK
# Mutant 1: a REMOVED name (ship) added to a profile other than full.
M1="$WORK/mutant-removed"; cp -r "$BASE" "$M1"
printf 'ship\n' >> "$M1/qa.profile"
run_mutant T3-mutant-removed "$M1" 'REMOVED_LISTED:qa:ship' \
FIXTURE_REMOVED_DETECTED
# Mutant 2: the superset profile drops a name full carries.
M2="$WORK/mutant-superset"; cp -r "$BASE" "$M2"
sed -i '/^beta$/d' "$M2/max.profile"
run_mutant T4-mutant-superset "$M2" 'SUPERSET_GAP:beta' \
FIXTURE_SUPERSET_DETECTED
# Mutant 3: a non-full, non-superset profile carries a name full lacks.
M3="$WORK/mutant-fullgap"; cp -r "$BASE" "$M3"
printf 'gamma\n' >> "$M3/qa.profile"
run_mutant T5-mutant-fullgap "$M3" 'FULL_GAP:gamma' \
FIXTURE_FULLGAP_DETECTED
echo "PASS=$pass FAIL=$fail"
[ "$fail" -eq 0 ]
+2 -1
View File
@@ -24,7 +24,8 @@ for g in gs-a gs-b gs-c; do
mkdir -p "$FX/skills-external/gstack/$g"
touch "$FX/skills-external/gstack/$g/SKILL.md"
done
cp "$ROOT/lib/profile.sh" "$ROOT/lib/toggle-external.sh" "$FX/lib/"
cp "$ROOT/lib/profile.sh" "$ROOT/lib/toggle-external.sh" \
"$ROOT/lib/gstack-removed.sh" "$FX/lib/"
cp "$ROOT/hooks/statusline.sh" "$FX/hooks/"
cat > "$FX/lib/profiles/full.profile" <<'EOF'
+2 -1
View File
@@ -19,7 +19,8 @@ for g in gs-a gs-b gs-c; do
mkdir -p "$FX/skills-external/gstack/$g"
touch "$FX/skills-external/gstack/$g/SKILL.md"
done
cp "$ROOT/lib/profile.sh" "$ROOT/lib/toggle-external.sh" "$FX/lib/"
cp "$ROOT/lib/profile.sh" "$ROOT/lib/toggle-external.sh" \
"$ROOT/lib/gstack-removed.sh" "$FX/lib/"
# Non-managed external, enabled from the start — must never be touched.
ln -s "$FX/skills-external/other-ext" "$FX/skills/other-ext"
@@ -17,6 +17,7 @@ mkdir -p "$SANDBOX/repo/lib" "$SANDBOX/repo/skills-external/emil-design-eng" \
"$SANDBOX/repo/skills-external/observability-and-instrumentation" \
"$SANDBOX/repo/skills" "$SANDBOX/home/.claude"
cp "$HELPER_SRC" "$SANDBOX/repo/lib/toggle-external.sh"
cp "$(dirname "$HELPER_SRC")/gstack-removed.sh" "$SANDBOX/repo/lib/"
# mark emil-design-eng ENABLED in the real (physical) repo tree
ln -s "$SANDBOX/repo/skills-external/emil-design-eng" "$SANDBOX/repo/skills/emil-design-eng"
# replicate the real ~/.claude/lib -> <repo>/lib symlink