diff --git a/.claude/memory/decisions.md b/.claude/memory/decisions.md index 1bb5deb..8ed599c 100644 --- a/.claude/memory/decisions.md +++ b/.claude/memory/decisions.md @@ -1065,3 +1065,6 @@ Supersedes BDR-076 scope + amends BDR-066. Doctrine: session model (Fable) = mai ### BDR-078 — ctx7 coverage: central fast-libs list + once-per-session reminder hook; every code path covered [accepted] (2026-07-20) Refines BDR-053 (single surface). Audit 2026-07-20: coverage PARTIAL — find-docs fired on user doc-questions only; ship-feature 0c / init-project 5c pre-fetched; /feat //bugfix executors + ad-hoc coding NEVER consulted ctx7; fast-libs list hardcoded 3× (drift risk). 4 closures shipped: (a) find-docs description += BEFORE-writing-code trigger (fast-moving lib, even without doc question, unless fresh cache) + cache-first rule in body (tee fetched docs to .ctx7-cache/); (b) feater+bugfixer briefs += fast-lib docs rule — read fresh `.ctx7-cache/*.md`, else `npx ctx7@latest` fetch max 2 topics, else `ctx7 cache miss: ` in NOTES + proceed (executors lack Skill tool → Bash path); (c) hooks/ctx7-reminder.sh UserPromptSubmit — ONE fire/session (sentinel on session_id), only when project manifest carries fast-libs; reports cache state; skips turns; always exit 0; (d) lib/fast-libs.sh = SINGLE SOURCE (detect / cache-status verbs, JS package.json anchored full-key match + Python requirements/pyproject, 7-day freshness, LC_ALL=C sort locale-independent) consumed by hook + 3 pipeline skills + 2 briefs. 2nd session surface DELIBERATE, not a BDR-053 reversal: 053 killed a 490-tok ALWAYS-ON rule duplicate; hook costs ~0 quiet, 1 line once when fast-libs present. Alternatives rejected: PreToolUse Edit/Write gate (fires per-edit = noise); description-only fix (probabilistic, executors unreachable). Tests: lib/tests/fast-libs.test.sh 11 checks (anchored/near-miss/py/none, cache fresh/stale/missing, hook fire/sentinel/quiet×2); shellcheck + full make test green. Branch feature/ctx7-coverage, unmerged (human gate). Amendment (same session): skills/find-docs = machine-owned dist (gitignored, ctx7 regenerates on fresh clone) → durable copy of closure (a) lives in install-plugins.sh STEP ctx7 (idempotent grep-guarded python patch, fixture-verified); live SKILL.md carries the same edit uncommitted by design. + +### BDR-079 — profile `set` symmetric on managed externals + MCPs [accepted] (2026-07-20) +Audit (user ask "profile toggles externals both ways?"): ASYMMETRIC. Enable side OK — gstack on-demand from submodule when pack off (shared `skills-disabled/gstack__*` convention with toggle-external.sh, interoperable), externals restored from parked, magic delegated to toggle-external. Disable side MISSING: `cmd_set` trimmed only gstack + MANAGED_PLUGINS → `set backend` left emil/frontend-design/design-motion/impeccable active + magic registered; SKILL.md claimed both-ways toggle (true only at enable). Shipped: (1) `MANAGED_EXTERNALS` (emil-design-eng, frontend-design, design-motion-principles, impeccable = exact union of profile `external` usage; darwin-skill excluded — not task-type-driven) + `MANAGED_MCPS` (magic) allowlists, same doctrine as MANAGED_PLUGINS; (2) cmd_set refactored to 4 trim helpers (`disable_{gstack,plugins,externals,mcps}_not_in`) — symmetric, nothing outside allowlists ever auto-touched; (3) enable_skill external += from-source fallback (`ln -sf skills-external/`, mirrors toggle-external) — closes the "missing symlink" warn; (4) stale usage() NOTE ("NOT toggled automatically") + SKILL.md fixed. Hermetic test profile-set-managed.test.sh 16 checks: fixture repo (both *_REPO_OVERRIDE), fake `claude` shim on PATH logging calls + flat-file MCP registry — gstack on-demand, external from-source, park/restore round-trip, magic add/remove calls, non-managed untouched. shellcheck + make test green. Branch feature/profile-managed-externals, unmerged (human gate). diff --git a/.claude/memory/journal.md b/.claude/memory/journal.md index 1975d9f..1227885 100644 --- a/.claude/memory/journal.md +++ b/.claude/memory/journal.md @@ -417,3 +417,4 @@ rules: ## 2026-07-20 - ctx7 coverage audit (user ask "ctx7 appelé à chaque techno ?") → verdict PARTIAL. 4 gaps: find-docs question-only, /feat //bugfix executors blind, ad-hoc coding uncovered, fast-libs hardcoded 3×. All 4 closed → BDR-078 (fast-libs.sh single source + ctx7-reminder hook + description trigger + executor-brief rule). fast-libs test 11/0, make test + review-guards green. feature/ctx7-coverage, UNMERGED. - v1.2.0 cut + pushed (release-candidate flow: prep/finish via release-executor, tag on main 51b6572). CHANGELOG backfilled at prep: 10 entries added to Unreleased (plan-challenge, seo-data verbs, model-tiering v2, integrity pass, safe_fetch/url-guard) — was ctx7-only. /doc full post-release: README model-routing table v1→v2 reframe + ctx7 two-surface wording, chore/doc-sync-v1.2.0 merged. All pushed on explicit go. +- profile↔toggle-external audit (user) → enable side already symmetric (gstack on-demand LIVE), disable side missing → BDR-079: MANAGED_EXTERNALS+MANAGED_MCPS trim at set, external from-source fallback, 16-check hermetic test (claude shim). feature/profile-managed-externals, UNMERGED. diff --git a/.claude/tasks/TODO.md b/.claude/tasks/TODO.md index a41d64b..df65784 100644 --- a/.claude/tasks/TODO.md +++ b/.claude/tasks/TODO.md @@ -1,5 +1,23 @@ # TODO +## 2026-07-20 — profile ↔ toggle-external symmetry (feature/profile-managed-externals, BDR-079) +Audit verdict: gstack on-demand + design enable already work; DISABLE side +missing — `set backend` leaves emil/frontend-design/design-motion/impeccable +active + magic registered. Doc claims auto-toggle both ways (only enable true). +- [x] profile.sh: `MANAGED_EXTERNALS` (emil-design-eng, frontend-design, + design-motion-principles, impeccable — union of profile usage) + + `MANAGED_MCPS` (magic) allowlists; cmd_set refactored to 4 trim + helpers (disable_{gstack,plugins,externals,mcps}_not_in). +- [x] profile.sh enable_skill external: from-source fallback + (`ln -sf skills-external/`) mirroring toggle-external. +- [x] Texts: cmd_set info line, usage() NOTE (stale "NOT toggled + automatically"), header; skills/profile/SKILL.md Mechanism+tradeoffs. +- [x] Hermetic test lib/tests/profile-set-managed.test.sh — 16/0: gstack + on-demand, external from-source, park/restore round-trip, magic + add/remove via claude shim, non-managed untouched. +- [x] Gate: shellcheck OK + make test exit 0 (review-guards 5/0). BDR-079 + + journal + CHANGELOG done. Committed on branch, NO merge (human gate). + ## 2026-07-20 — ctx7 coverage extension (feature/ctx7-coverage, BDR-078) Close the 4 gaps from the ctx7 coverage audit: /feat //bugfix + ad-hoc coding never consult ctx7; fast-libs list hardcoded 3×; zero deterministic backstop. diff --git a/CHANGELOG.md b/CHANGELOG.md index 2ed4240..a528426 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,9 @@ Format follows [Keep a Changelog](https://keepachangelog.com/). ## [Unreleased] +### Added +- **Profile switches now toggle external packs and MCPs both ways (BDR-079)** — `profile.sh set` was asymmetric: it enabled what a profile listed (including gstack skills on demand when the whole pack is off, and the `magic` MCP) but never disabled the managed leftovers, so `set backend` after design work kept emil-design-eng / frontend-design / design-motion-principles / impeccable active and magic registered. `set` now trims managed externals (`MANAGED_EXTERNALS`) and managed MCPs (`MANAGED_MCPS`, delegated to `toggle-external.sh`) not listed in the profile — same allowlist doctrine as `MANAGED_PLUGINS`, nothing outside the allowlists is ever auto-touched (darwin-skill stays manual). Also: an `external` entry whose symlink never existed is now created from `skills-external/` (mirroring toggle-external's from-source path), and the stale "NOT toggled automatically" note in `profile.sh` usage was corrected. Covered by a hermetic 16-check test (`lib/tests/profile-set-managed.test.sh`) with a fake `claude` shim. + ## [1.2.1] — 2026-07-20 ### Fixed diff --git a/lib/profile.sh b/lib/profile.sh index 3f20971..57d2e33 100755 --- a/lib/profile.sh +++ b/lib/profile.sh @@ -14,6 +14,9 @@ # - MCPs: delegated to lib/toggle-external.sh for known servers (magic), # advisory otherwise # - CLIs: advisory only (rtk, gsd, ctx7, graphify — installed externally) +# - `set` is SYMMETRIC on managed items (BDR-079): plugins, external packs +# and MCPs in the MANAGED_* allowlists are disabled when the profile +# does not list them — nothing outside those lists is ever auto-toggled. # # Always-on plugins (never toggled by `set`): security-guidance, # superpowers + rtk hook + .claude internal. The script refuses to disable @@ -61,6 +64,23 @@ MANAGED_PLUGINS=( "pr-review-toolkit@claude-code-plugins" ) +# External skill packs that are toggle-managed by `set` — same allowlist +# doctrine as MANAGED_PLUGINS: listed here only when the enabled state is +# task-type-driven. `set` disables these when the profile does not list +# them; anything else external (e.g. darwin-skill) is never auto-touched. +MANAGED_EXTERNALS=( + emil-design-eng + frontend-design + design-motion-principles + impeccable +) + +# MCP servers that are toggle-managed by `set`, both ways (enable AND +# disable), delegated to lib/toggle-external.sh. Same allowlist doctrine. +MANAGED_MCPS=( + magic +) + # Plugins that MUST stay enabled — `set` will refuse to disable these even if # they're not in the profile. (Defensive: belt-and-suspenders alongside # MANAGED_PLUGINS allowlist.) @@ -271,6 +291,11 @@ enable_skill() { ok "enabled: $skill ($type)" elif [ -e "$SKILLS_DIR/$skill" ]; then : + elif [ "$type" = external ] && [ -d "$REPO/skills-external/$skill" ]; then + # Symlink never created (or hand-removed): recreate it from the + # vendored pack — mirrors toggle-external.sh's from-source path. + ln -sf "$REPO/skills-external/$skill" "$SKILLS_DIR/$skill" + ok "enabled: $skill (external, symlink created)" else warn "missing: $skill ($type)" fi @@ -422,6 +447,48 @@ parked_gstack_count() { find "$DISABLED_DIR" -maxdepth 1 -name 'gstack__*' 2>/dev/null | wc -l | tr -d ' ' } +# ── `set` trim helpers — one per managed category ───────────── +# Each disables the managed items NOT listed in the given profile. Allowlist +# doctrine: only MANAGED_* entries are ever auto-disabled. + +disable_plugins_not_in() { + local prof="$1" keep_file p plugin_name marketplace + keep_file="$(mktemp)" + read_profile "$prof" \ + | awk -F'\t' '$2 ~ /^plugin@/ { sub(/^plugin@/, "", $2); print $1"@"$2 }' \ + | sort -u > "$keep_file" + for p in "${MANAGED_PLUGINS[@]}"; do + if ! grep -qx "$p" "$keep_file"; then + plugin_name="${p%@*}" + marketplace="${p#*@}" + disable_skill "$plugin_name" "plugin@${marketplace}" + fi + done + rm -f "$keep_file" +} + +disable_externals_not_in() { + local prof="$1" keep_file x + keep_file="$(mktemp)" + read_profile "$prof" | awk -F'\t' '$2 == "external" { print $1 }' \ + | sort -u > "$keep_file" + for x in "${MANAGED_EXTERNALS[@]}"; do + grep -qx "$x" "$keep_file" || disable_skill "$x" external + done + rm -f "$keep_file" +} + +disable_mcps_not_in() { + local prof="$1" keep_file s + keep_file="$(mktemp)" + read_profile "$prof" | awk -F'\t' '$2 == "mcp" { print $1 }' \ + | sort -u > "$keep_file" + for s in "${MANAGED_MCPS[@]}"; do + grep -qx "$s" "$keep_file" || disable_skill "$s" mcp + done + rm -f "$keep_file" +} + # ── Commands ────────────────────────────────────────────── cmd_list() { @@ -506,24 +573,20 @@ cmd_apply() { cmd_set() { local prof="$1" - info "Setting profile: $prof (exclusive — disables non-listed gstack skills + managed plugins)" + info "Setting profile: $prof (exclusive — disables non-listed gstack skills + managed plugins/externals/MCPs)" # Disable gstack-origin skills not in profile. disable_gstack_not_in "$prof" # Disable managed plugins not in profile (PROTECTED_PLUGINS are excluded # by disable_skill itself — belt and suspenders). - local plugin_keep_file p plugin_name marketplace - plugin_keep_file="$(mktemp)" - read_profile "$prof" | awk -F'\t' '$2 ~ /^plugin@/ { sub(/^plugin@/, "", $2); print $1"@"$2 }' | sort -u > "$plugin_keep_file" - for p in "${MANAGED_PLUGINS[@]}"; do - if ! grep -qx "$p" "$plugin_keep_file"; then - plugin_name="${p%@*}" - marketplace="${p#*@}" - disable_skill "$plugin_name" "plugin@${marketplace}" - fi - done - rm -f "$plugin_keep_file" + disable_plugins_not_in "$prof" + + # Symmetry (BDR-079): a profile switch also parks the managed external + # packs and unregisters the managed MCPs the new profile does not need — + # design leftovers (emil, magic…) no longer survive a `set backend`. + disable_externals_not_in "$prof" + disable_mcps_not_in "$prof" # Enable everything listed in the profile. cmd_apply "$prof" @@ -679,9 +742,11 @@ EXAMPLES: bash lib/profile.sh reset # restore everything NOTE: - Plugin and MCP entries print advisory commands — they are NOT toggled - automatically. Run "claude plugin enable|disable" or "claude mcp add|remove" - yourself for those. + "set" toggles the MANAGED items automatically, both ways: plugins + (ui-ux-pro-max, plugin-dev, pr-review-toolkit), external packs + (emil-design-eng, frontend-design, design-motion-principles, impeccable) + and the magic MCP. Anything outside those allowlists stays advisory — + run "claude plugin enable|disable" or "claude mcp add|remove" yourself. EOF } diff --git a/lib/tests/profile-set-managed.test.sh b/lib/tests/profile-set-managed.test.sh new file mode 100644 index 0000000..e0a3eea --- /dev/null +++ b/lib/tests/profile-set-managed.test.sh @@ -0,0 +1,76 @@ +#!/usr/bin/env bash +# lib/tests/profile-set-managed.test.sh — `set` symmetry on managed +# externals + MCPs, gstack on-demand, external from-source (BDR-079). +# Hermetic: fixture repo via *_REPO_OVERRIDE + fake `claude` on PATH. +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; } + +FX="$(mktemp -d)"; trap 'rm -rf "$FX"' EXIT +mkdir -p "$FX/skills" "$FX/skills-disabled" "$FX/lib/profiles" "$FX/bin" \ + "$FX/skills-external/emil-design-eng" "$FX/skills-external/other-ext" +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/" +printf 'MAGIC_API_KEY=test-secret-000\n' > "$FX/.env" + +# Non-managed external, enabled from the start — must never be touched. +ln -s "$FX/skills-external/other-ext" "$FX/skills/other-ext" + +cat > "$FX/lib/profiles/designish.profile" <<'EOF' +gs-a +gs-b +emil-design-eng external +magic mcp +EOF +cat > "$FX/lib/profiles/backendish.profile" <<'EOF' +gs-c +EOF + +# Fake claude: logs every call; keeps MCP registry state in a flat file. +cat > "$FX/bin/claude" <> "\$FX/claude-calls.log" +case "\$1 \${2:-}" in + "mcp list") cat "\$FX/mcp-state" 2>/dev/null ;; + "mcp add") echo "magic: stub" > "\$FX/mcp-state" ;; + "mcp remove") : > "\$FX/mcp-state" ;; +esac +exit 0 +EOF +chmod +x "$FX/bin/claude" + +run() { PATH="$FX/bin:$PATH" PROFILE_REPO_OVERRIDE="$FX" \ + TOGGLE_EXTERNAL_REPO_OVERRIDE="$FX" bash "$FX/lib/profile.sh" "$@"; } + +# --- set designish: gstack on-demand + external from-source + magic on --- +run set designish >/dev/null 2>&1 +check T1-gsa-on "$([ -e "$FX/skills/gs-a" ] && echo on || echo off)" on +check T2-gsb-on "$([ -e "$FX/skills/gs-b" ] && echo on || echo off)" on +check T3-gsc-off "$([ -e "$FX/skills/gs-c" ] && echo on || echo off)" off +check T4-emil-src "$([ -L "$FX/skills/emil-design-eng" ] && echo on || echo off)" on +check T5-magic-on "$(grep -c '^magic:' "$FX/mcp-state" 2>/dev/null)" 1 +check T6-add-call "$(grep -c '^mcp add magic' "$FX/claude-calls.log")" 1 + +# --- set backendish: managed leftovers parked/unregistered --- +run set backendish >/dev/null 2>&1 +check T7-gsc-on "$([ -e "$FX/skills/gs-c" ] && echo on || echo off)" on +check T8-gsa-park "$([ -e "$FX/skills-disabled/gstack__gs-a" ] && echo p || echo n)" p +check T9-emil-off "$([ -e "$FX/skills/emil-design-eng" ] && echo on || echo off)" off +check T10-emil-park "$([ -e "$FX/skills-disabled/emil-design-eng" ] && echo p || echo n)" p +check T11-magic-off "$(grep -c '^magic:' "$FX/mcp-state" 2>/dev/null || true)" 0 +check T12-rm-call "$(grep -c '^mcp remove magic' "$FX/claude-calls.log")" 1 +check T13-other-untouched "$([ -e "$FX/skills/other-ext" ] && echo on || echo off)" on + +# --- back to designish: parked external restored (not re-sourced) --- +run set designish >/dev/null 2>&1 +check T14-emil-back "$([ -e "$FX/skills/emil-design-eng" ] && echo on || echo off)" on +check T15-park-gone "$([ -e "$FX/skills-disabled/emil-design-eng" ] && echo p || echo n)" n +check T16-magic-back "$(grep -c '^magic:' "$FX/mcp-state" 2>/dev/null)" 1 + +printf 'PASS=%s FAIL=%s\n' "$pass" "$fail"; [ "$fail" -eq 0 ] diff --git a/skills/profile/SKILL.md b/skills/profile/SKILL.md index e986809..2f9edc8 100644 --- a/skills/profile/SKILL.md +++ b/skills/profile/SKILL.md @@ -61,6 +61,15 @@ protected — `set` will refuse to disable them even if the profile omits them. **Managed plugins** that `set` may disable when not in profile: `ui-ux-pro-max@ui-ux-pro-max-skill`, `plugin-dev@claude-code-plugins`, `pr-review-toolkit@claude-code-plugins`. Other plugins are never auto-toggled. +**Managed externals** (`emil-design-eng`, `frontend-design`, +`design-motion-principles`, `impeccable`) and **managed MCPs** (`magic`) +follow the same symmetry (BDR-079): `set` enables them when the profile +lists them (from parked state, or from `skills-external/` if the symlink +never existed) and parks/unregisters them when it does not — e.g. `set +backend` after design work turns emil and magic off. `darwin-skill` and any +other unlisted external are never auto-touched. gstack works the same +all the way down: a profile listing gstack skills while the whole pack is +off (via `toggle-external.sh`) re-enables JUST those skills on demand. ## Commands @@ -117,8 +126,11 @@ bash "$HOME/.claude/lib/profile.sh" $ARGUMENTS update-check, learnings — script doesn't touch that infra. Disabled skills are just hidden from Claude Code's scanner; the gstack repo stays installed. - Profile changes DO toggle the managed Claude Code plugins (ui-ux-pro-max, - plugin-dev, pr-review-toolkit) and the `magic` MCP — see the Mechanism table - above (BDR-008). Anything outside that managed set stays manual: - `claude plugin enable|disable`, `claude mcp add|remove`. + plugin-dev, pr-review-toolkit), the managed external packs (emil-design-eng, + frontend-design, design-motion-principles, impeccable) and the `magic` MCP — + in BOTH directions: `set` enables what the profile lists and disables the + managed leftovers it doesn't (BDR-008, BDR-079). Anything outside those + allowlists stays manual: `claude plugin enable|disable`, `claude mcp + add|remove`. - `set` is destructive in the sense that it disables non-listed gstack skills. Use `apply` if the user wants additive behavior.