15 KiB
15 KiB
PLAN — default-profile-full (feat) — REVISED after challenge (r3)
- date: 2026-09-25 | contract: contracts/2026-09-25-default-profile-full-1254.md
- branch: feature/default-profile-full
- r3 (confirmation pass): Step 11 re-applies an existing selection with
set "$SEL"(Steps 2 and 10 re-park gstack / re-link externals on every run),gstack onmessages stop claiming "all gstack", seed lists emil, T2b provesgstack offtrims with no cache, two out-of-scope citers ofreset/currentjoin the scope. - r2: closes correctness BLOCKER 1 / MAJOR 2-3 / MINOR 4-7, robustness
BLOCKER 1 / MAJOR 2 / MINOR 3-5, simplicity MINOR 1-3. Line numbers dropped
on purpose: the orchestrator's
chore(21st)commit lands BEFORE dispatch and shifts them — refer to functions and anchors.
Ground truth the plan is built on (verified on the live tree)
- gstack is OFF by default (BDR-030): a real tree has ZERO gstack symlinks in
skills/and ZEROgstack__*parked.set/resetLINK the listed skills from the submodule;disable_skill gstackparks only what is present inskills/. So "parked count == 0" says nothing about which profile is on. The oldcmd_currentfast path keyed on that count — it goes away. - Every
apply/set/resetwrites.active-profile. Therefore: cache absent, empty, or legacynone⇔ no profile was ever selected (or the old reset ran). That is the ONE signal "no profile selected" keys on, everywhere. - The 21st pack: staged into
skills-external/21st-*, symlinked on demand;fulllists the five design skills asexternal; the two publishing skills are never listed.
Approach
DEFAULT_PROFILE="full"declared once inlib/profile.sh, right afterACTIVE_CACHE, comment: the profile in force when none is selected.- Two tiny cache helpers next to
write_active():read_cache()→ first line of$ACTIVE_CACHE, ALL whitespace stripped (tr -d '[:space:]', CRLF-proof); empty string when the file is missing. Must not tripset -euo pipefail(2>/dev/null || true).active_profile()→read_cache, or$DEFAULT_PROFILEwhen that is empty or the literalnone. Every reader of the cache in the lib goes through them.
cmd_reset= go to the default profile:info "Resetting to the default profile: $DEFAULT_PROFILE (exclusive — enables its list, parks any non-listed gstack or managed item currently on)"thencmd_set "$DEFAULT_PROFILE"([gated] Q1).cmd_set→cmd_apply→write_activealready records the label. Nowrite_active "none"anywhere.cmd_currentbecomes LABEL-DRIVEN (this replaces the cross-profile best-guess scan and its parked-count fast path — both keyed on a premise that is false under BDR-030):label="$(active_profile)"; if$PROFILES_DIR/$label.profileis missing →echo "$label (unknown profile — no lib/profiles/$label.profile; run: profile reset)", rc 0.profile_match "$label"→ prints<available> <total>(the existing per-entryskill_statusloop, extracted into a helper:enabled/installedcount as available).pct = available*100/total(0 when total is 0).parked= count ofskills-disabled/gstack__*(existing find).- Output, ONE line, first word = the label:
read_cacheempty ornone→"$label (default — not applied yet, ${pct}% of its items enabled; run: profile reset)"- otherwise →
"$label (${pct}% match, ${parked} gstack skills disabled)"No "all gstack skills enabled" claim anywhere.set Xthencurrentalways names X (the SKILL.md "contradiction" failure family disappears).
cmd_gstack on: messages must not claim "all gstack": 0 parked →info "nothing parked — gstack skills are linked per profile (set/apply/reset)"; elseok "$parked parked gstack skills restored". Behaviour unchanged.cmd_gstack off:active="$(active_profile)"; keep the existing "profile file missing" error (rc 1) for a cache naming an unknown profile; drop thenonespecial-case (unreachable now).hooks/statusline.sh: read the constant once —DEFAULT_PROFILE=$(sed -n 's/^DEFAULT_PROFILE="\([^"]*\)".*/\1/p' "$REPO/lib/profile.sh" 2>/dev/null),[ -n "$DEFAULT_PROFILE" ] || DEFAULT_PROFILE=full(unreadable-lib fallback only).PROFILE=$(head -n1 cache 2>/dev/null | tr -d '[:space:]'); empty ornone→$DEFAULT_PROFILE. No call into profile.sh (speed).install-plugins.sh([gated] Q2):- Step 8.7: DELETE the unconditional "Default-disabled" park block (the
TFD_STATUS…toggle-external.sh disable 21stblock and itselsebranch, keep the surroundingecho ""). Replace by a 3-line comment: the pack's state is governed by profiles — the default profile (Step 11) turns the five design skills on, the two publishing skills stay parked; a re-run never re-parks what a profile or the user enabled. Update the Step 8.7 header comment's "installed but DISABLED by default"- the trailing "Default policy: pack DISABLED at install time…" lines to the same statement (no counts).
- NEW Step 11 AFTER the Step 10
link.shrefresh, BEFORE the# SUMMARYbanner, same banner style as the other steps:# ============================================================ # STEP 11 — DEFAULT PROFILE # ============================================================ # The profile decides which skills / externals / plugins are on. No # selection yet (.active-profile absent, empty or legacy "none" — same # rule as lib/profile.sh active_profile()) → apply the default via # `profile.sh reset`. An existing selection is re-applied (`set`). Plugin legs # are install-immutable (the EXIT guard restores settings.json, BDR-028; # the committed enabledPlugins already match the default profile), so # only the skill / external legs matter here. echo "── Step 11: Default profile ────────────────────────────────" echo "" if [ -f "$REPO/lib/profile.sh" ]; then SEL="$(head -n1 "$REPO/.active-profile" 2>/dev/null | tr -d '[:space:]' || true)" case "$SEL" in ""|none) info "No profile selected — applying the default profile (bash lib/profile.sh reset)..." bash "$REPO/lib/profile.sh" reset \ || warn "default profile not applied — run: bash lib/profile.sh reset" ;; *) # Steps 2 (gstack parked) and 10 (link.sh re-links the design # externals) rewrite skill state on every run: re-apply the # selection so its state comes back, label unchanged. info "Profile kept: $SEL — re-applying it (bash lib/profile.sh set $SEL)..." bash "$REPO/lib/profile.sh" set "$SEL" \ || warn "profile $SEL not re-applied — run: bash lib/profile.sh set $SEL" ;; esac else warn "lib/profile.sh not found — skipping the default profile" fi echo "" - Summary line for the pack, NO counts:
🔄 21st skill pack — 21st.dev CLI skills; design ones follow the profile (full by default), publishing ones on demand (toggle: lib/toggle-external.sh enable 21st). - NEVER run install-plugins.sh / link.sh / make plugin:
bash -n+ shellcheck.
- Step 8.7: DELETE the unconditional "Default-disabled" park block (the
- Docs (functions by name):
lib/profile.shheader usage list:resetline → "go to the default profile (full): enable its list, park non-listed gstack/managed items";currentline → "report the active profile (label + match)".usage(): same for theresetandcurrentlines; EXAMPLESreset # back to the default profile (full); NOTE: keep the managed-lists sentence (the orchestrator's chore commit already removed "magic").skills/profile/SKILL.md: Commands block (resetcomment,currentcomment,gstack oncomment → "restore parked gstack skills on top of the current profile"); one "Default profile" paragraph under Mechanism (fullin force when.active-profileis absent/empty/none;resetapplies it; a freshmake pluginapplies it when nothing is selected); Output policy aftercurrent("label + match %;default — not applied yetmeans runreset"); Failure-modes: theset/applymid-toggle row no longer callsresetan always-safe recovery (it is a full exclusiveset): "runcurrent, then re-runset <name>orreset"; the "currentsaysnoneright after a successfulset" row → replace by "currentnames a profile other than the one just set" (cache written by another tool / hand edit; show raw output, never hand-patch).- Makefile:
profile-reset: ## Go to the default profile (full);profile-current: ## Show the active profile (label + match).
Files
- lib/profile.sh —
DEFAULT_PROFILE,read_cache(),active_profile(),profile_match(),cmd_reset,cmd_current(rewritten),cmd_gstackoff branch, header +usage()text. - hooks/statusline.sh — default from the lib, cache normalisation.
- lib/tests/profile-default.test.sh — NEW. Harness copied from
profile-set-managed.test.sh (
$FX, fakeclaudeshim logging calls,run()with both*_REPO_OVERRIDE,check()+PASS=/FAIL=summary, exit 1 on any FAIL). Fixture profiles:full.profile=gs-a,gs-b,emil-design-eng external;otherish.profile=gs-c. Real-tree seed (explicitmkdir -p):$FX/skills,$FX/skills-disabled,$FX/lib/profiles,$FX/bin,$FX/hooks,$FX/skills-external/emil-design-eng(external source, required for T5'semil linked+100% match),$FX/skills-external/gstack/gs-{a,b,c}each with aSKILL.md. gs-a/gs-b/gs-c exist ONLY underskills-external/gstack/, NOTHING linked inskills/, no cache. Copyhooks/statusline.shto$FX/hooks/so its$REPO=$FX; statusline runs asecho '{}' | bash "$FX/hooks/statusline.sh".- T1 clean seed, no cache →
currentfirst wordfull, containsdefault — not applied yet, does NOT containall gstack. - T2 clean seed, no cache →
gstack offrc 0 (nothing to trim, no error). - T2b
ln -s "$FX/skills-external/gstack/gs-c" "$FX/skills/gs-c", no cache →gstack offrc 0;skills-disabled/gstack__gs-cexists,skills/gs-astill absent (untouched). Thenrmthe parked link to return to the clean seed. - T3 cache
none(written with a trailing\r\n) →gstack offrc 0. - T4 cache
ghost(no profile file) →gstack offrc 1;currentfirst wordghost, containsunknown profile. - T5 clean seed →
reset→ cache readsfull; gs-a and gs-b linked, gs-c NOT linked, emil linked;currentfirst wordfull, contains100% match, does NOT containnot applied. - T6
set otherish→currentfirst wordotherish(label-driven, even though gs-c is the only gstack on) → thenreset→ gs-c parked (skills-disabled/gstack__gs-c), gs-a on, cachefull. - T7
set otherishthengstack on(0 parked, cacheotherish) →currentfirst wordotherish, does NOT containdefault. - T8 statusline, no cache →
profile: full. - T9 statusline, cache
otherish→profile: otherish. - T10 statusline, cache
none \r→profile: full. - T11 statusline reads the constant:
sed -ithe fixture's copiedlib/profile.shtoDEFAULT_PROFILE="otherish", no cache →profile: otherish(proves the sed read, not the literal fallback).
- T1 clean seed, no cache →
- skills/profile/SKILL.md — as above.
- Makefile — two help strings.
- install-plugins.sh — Step 8.7 park block removed + comments, Step 11, summary line.
- agents/plugin-advisor.md — two citers of the old semantics: PHASE 3
OUTPUT
PROFILE:line →[active skill profile — name + match%, or "<name> (default — not applied yet …)"]; the paragraph starting "To restore the full skill set:" (through its end) → "To go back to the default profile:bash $HOME/.claude/lib/profile.sh reset(=set full: enables full's list, parks non-listed gstack/managed items, toggles the managed plugins like anyset)." Nothing else in the file. - lib/toggle-external.sh — header comment only: the
bash lib/profile.sh resetline gains# back to the default profile (full).
Executor guardrails
- Tests use the fixture repo + fake
claudeshim ONLY: never run the realclaude plugin …, never touch this machine'sskills/,skills-disabled/or.active-profile(there is none today — keep it that way). - Never run
install-plugins.sh,link.sh,make plugin,make link. - Scope = the 8 files of the contract; CHANGELOG/README are doc-sync's job.
- The
none/empty/absent normalisation is written in three places by design (lib helper, statusline, installer): the statusline must stay free of any profile.sh call, the installer must not depend on a lib function. Each copy carries a one-line comment namingactive_profile()as the reference and uses the sametr -d '[:space:]'rule.
Edge cases
- Cache with trailing whitespace / CRLF → stripped before compare (T3, T10).
- Cache names a profile with no
.profilefile →gstack offrc 1 as today;currentsaysunknown profileand points atreset. reseton a real tree: links full's 34 gstack skills from the submodule (BDR-030 on-demand), parks nothing unless a non-full gstack/managed item was on (docs say exactly that — no "parks 20 skills" claim).- statusline must stay fast: one
sedon the lib, no subshell into profile.sh;$REPO/lib/profile.shunreadable → literalfull. set -euo pipefailin both scripts: everyheadon a maybe-missing file carries2>/dev/null || true; the installer'sresetcall carries|| warn.profile_matchtotal 0 → pct 0, no division by zero.
Disposition (STEP 0.6)
- honors LRN-020:
fullis the REAL default (reset applies it; install applies it when nothing is selected); no label denotes absence; the parenthetical carries applied-vs-in-force.nonesentinel gone. - honors BDR-017: full stays curated;
resetdocs describe what it enables and parks without a fixed count. - honors BDR-018:
gstack on|offkeeps the label;offreads the default throughactive_profile();currentaftergstack onnames the cached label (T7). - honors BDR-030:
currentno longer infers anything from the parked count; tests seed gstack as OFF like a real tree. - honors BDR-079:
resetreusescmd_set; no new toggle path. - honors BDR-093: the pack stays staged + symlinked on demand; its install-time park is superseded by the profile rule ([gated] Q2), the two publishing skills remain unlisted. Residue scrub is prose only.
- honors BDR-028: the installer's EXIT guard on settings.json is left alone; Step 11 documents that plugin legs are install-immutable.
- honors LRN-023 (
cd -P): untouched. - NON-BINDING: BDR-007/008/024/025/026/057/059, LRN-022/108/110, BLK-005/006, EVAL-002 — context only.