Files
claude/.claude/tasks/plans/2026-09-25-default-profile-full-1254.md
T

249 lines
15 KiB
Markdown

# 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 on` messages stop claiming "all gstack", seed lists emil,
T2b proves `gstack off` trims with no cache, two out-of-scope citers of
`reset`/`current` join 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 ZERO `gstack__*` parked. `set`/`reset` LINK the listed skills
from the submodule; `disable_skill gstack` parks only what is present in
`skills/`. So "parked count == 0" says nothing about which profile is on.
The old `cmd_current` fast path keyed on that count — it goes away.
- Every `apply` / `set` / `reset` writes `.active-profile`. Therefore: cache
absent, empty, or legacy `none` ⇔ 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;
`full` lists the five design skills as `external`; the two publishing
skills are never listed.
## Approach
- `DEFAULT_PROFILE="full"` declared once in `lib/profile.sh`, right after
`ACTIVE_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 trip `set -euo pipefail` (`2>/dev/null || true`).
- `active_profile()` → `read_cache`, or `$DEFAULT_PROFILE` when that is
empty or the literal `none`.
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)"` then `cmd_set "$DEFAULT_PROFILE"`
([gated] Q1). `cmd_set` → `cmd_apply` → `write_active` already records the
label. No `write_active "none"` anywhere.
- `cmd_current` becomes 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):
1. `label="$(active_profile)"`; if `$PROFILES_DIR/$label.profile` is
missing → `echo "$label (unknown profile — no lib/profiles/$label.profile; run: profile reset)"`, rc 0.
2. `profile_match "$label"` → prints `<available> <total>` (the existing
per-entry `skill_status` loop, extracted into a helper: `enabled` /
`installed` count as available). `pct = available*100/total` (0 when
total is 0).
3. `parked` = count of `skills-disabled/gstack__*` (existing find).
4. Output, ONE line, first word = the label:
- `read_cache` empty or `none` →
`"$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 X` then `current`
always 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)"`;
else `ok "$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 the
`none` special-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 or `none` → `$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 21st` block and its `else`
branch, keep the surrounding `echo ""`). 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.sh` refresh, BEFORE the `# SUMMARY`
banner, 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.
- Docs (functions by name):
- `lib/profile.sh` header usage list: `reset` line → "go to the default
profile (full): enable its list, park non-listed gstack/managed items";
`current` line → "report the active profile (label + match)".
- `usage()`: same for the `reset` and `current` lines; EXAMPLES
`reset # 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 (`reset` comment, `current`
comment, `gstack on` comment → "restore parked gstack skills on top of
the current profile"); one "Default profile" paragraph under Mechanism (`full` in
force when `.active-profile` is absent/empty/`none`; `reset` applies it;
a fresh `make plugin` applies it when nothing is selected); Output policy
after `current` ("label + match %; `default — not applied yet` means run
`reset`"); Failure-modes: the `set`/`apply` mid-toggle row no longer
calls `reset` an always-safe recovery (it is a full exclusive `set`):
"run `current`, then re-run `set <name>` or `reset`"; the
"`current` says `none` right after a successful `set`" row → replace by
"`current` names 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_gstack`
off 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`, fake `claude` shim 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
(explicit `mkdir -p`): `$FX/skills`, `$FX/skills-disabled`,
`$FX/lib/profiles`, `$FX/bin`, `$FX/hooks`,
`$FX/skills-external/emil-design-eng` (external source, required for T5's
`emil linked` + `100% match`), `$FX/skills-external/gstack/gs-{a,b,c}`
each with a `SKILL.md`. gs-a/gs-b/gs-c exist ONLY under
`skills-external/gstack/`, NOTHING linked in `skills/`, no cache. Copy `hooks/statusline.sh` to `$FX/hooks/` so its
`$REPO` = `$FX`; statusline runs as `echo '{}' | bash "$FX/hooks/statusline.sh"`.
- T1 clean seed, no cache → `current` first word `full`, contains
`default — not applied yet`, does NOT contain `all gstack`.
- T2 clean seed, no cache → `gstack off` rc 0 (nothing to trim, no error).
- T2b `ln -s "$FX/skills-external/gstack/gs-c" "$FX/skills/gs-c"`, no cache
→ `gstack off` rc 0; `skills-disabled/gstack__gs-c` exists, `skills/gs-a`
still absent (untouched). Then `rm` the parked link to return to the
clean seed.
- T3 cache `none` (written with a trailing `\r\n`) → `gstack off` rc 0.
- T4 cache `ghost` (no profile file) → `gstack off` rc 1;
`current` first word `ghost`, contains `unknown profile`.
- T5 clean seed → `reset` → cache reads `full`; gs-a and gs-b linked,
gs-c NOT linked, emil linked; `current` first word `full`, contains
`100% match`, does NOT contain `not applied`.
- T6 `set otherish` → `current` first word `otherish` (label-driven, even
though gs-c is the only gstack on) → then `reset` → gs-c parked
(`skills-disabled/gstack__gs-c`), gs-a on, cache `full`.
- T7 `set otherish` then `gstack on` (0 parked, cache `otherish`) →
`current` first word `otherish`, does NOT contain `default`.
- 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 -i` the fixture's copied
`lib/profile.sh` to `DEFAULT_PROFILE="otherish"`, no cache →
`profile: otherish` (proves the sed read, not the literal fallback).
- 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 any `set`)." Nothing else in the file.
- lib/toggle-external.sh — header comment only: the `bash lib/profile.sh
reset` line gains `# back to the default profile (full)`.
## Executor guardrails
- Tests use the fixture repo + fake `claude` shim ONLY: never run the real
`claude plugin …`, never touch this machine's `skills/`, `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 naming `active_profile()`
as the reference and uses the same `tr -d '[:space:]'` rule.
## Edge cases
- Cache with trailing whitespace / CRLF → stripped before compare (T3, T10).
- Cache names a profile with no `.profile` file → `gstack off` rc 1 as
today; `current` says `unknown profile` and points at `reset`.
- `reset` on 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 `sed` on the lib, no subshell into
profile.sh; `$REPO/lib/profile.sh` unreadable → literal `full`.
- `set -euo pipefail` in both scripts: every `head` on a maybe-missing file
carries `2>/dev/null || true`; the installer's `reset` call carries
`|| warn`.
- `profile_match` total 0 → pct 0, no division by zero.
## Disposition (STEP 0.6)
- honors LRN-020: `full` is the REAL default (reset applies it; install
applies it when nothing is selected); no label denotes absence; the
parenthetical carries applied-vs-in-force. `none` sentinel gone.
- honors BDR-017: full stays curated; `reset` docs describe what it enables
and parks without a fixed count.
- honors BDR-018: `gstack on|off` keeps the label; `off` reads the default
through `active_profile()`; `current` after `gstack on` names the cached
label (T7).
- honors BDR-030: `current` no longer infers anything from the parked
count; tests seed gstack as OFF like a real tree.
- honors BDR-079: `reset` reuses `cmd_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.