chore(memory): BDR-101 + LRN-170 + LRN-171 + EVAL-031 — feat default-profile-full
This commit is contained in:
@@ -0,0 +1,248 @@
|
||||
# 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.
|
||||
Reference in New Issue
Block a user