Files
msd-core/commands/gsd/surface.md
Tom Boucher 99b52a302a fix(3659): applySurface prunes skill dirs on cluster disable (#3766)
* test(3659): add regression tests for applySurface skill-dir pruning on cluster disable

Tests that applySurface with claude global scope correctly prunes
~/.claude/skills/gsd-STEM/ dirs for disabled clusters, preserves
gsd-STEM dirs in enabled clusters, leaves non-gsd user dirs untouched,
and is idempotent across two consecutive calls.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* fix(3659): applySurface now prunes ~/.claude/skills/gsd-STEM/ on cluster disable

Root cause: surface.md directed the AI to use RUNTIME_CONFIG_DIR=~/.claude/skills
(the skills sub-directory) instead of the base Claude config dir (~/.claude).
When runtimeConfigDir=~/.claude/skills and scope=global, the layout computes
dest=~/.claude/skills/skills — the wrong target — so pruning never reached
the actual gsd-STEM dirs in ~/.claude/skills/.

Fix:
- surface.md: correct RUNTIME_CONFIG_DIR to use the base config dir (~/.claude),
  add explicit SCOPE=global, and update all path references in execution_context.
  Surface state file moves from ~/.claude/skills/.gsd-surface.json to
  ~/.claude/.gsd-surface.json, matching install/uninstall conventions.
- surface.cjs: extract pruneSkillDirs() as a shared helper (single point of truth
  for gsd-STEM dir removal). _syncGsdDir now delegates to it instead of having
  the ownership/prune logic inline. Export pruneSkillDirs for callers that need
  stand-alone pruning without a full applySurface pass.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* chore(3659): update changeset to reference PR #3766

* fix(3659): manifest-membership gate on pruneSkillDirs prevents user gsd-* dir data loss

Finding 1 (CRITICAL): the prefixed branch previously deleted any on-disk dir that
matched the 'gsd-' prefix and was not in retainedNames. A user-created gsd-mything/
would be silently destroyed. Fix: deletion now requires BOTH prefix match AND
manifest membership (stem present in manifest). Dirs that match the prefix but are
not manifest-known are preserved with a process.stderr warning so the user knows the
dir was kept.

Finding 2 (type guard): the Hermes (empty-prefix) branch passed manifest directly to
new Set([...manifest.keys()]) without verifying it is actually a Map. A truthy
non-Map would throw. Fix: safeManifest = (manifest instanceof Map) ? manifest : null,
used in both branches. Non-Map manifest triggers the same conservative
no-deletions path already used when manifest is absent.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* test(3659): counter-test for all-clusters-disabled + user gsd-* dir preservation

Finding 3: add test (e) that disables every cluster (Object.keys(CLUSTERS)) and
asserts three things:
  1. All GSD-owned skill dirs (gsd-explore/, gsd-help/) are removed.
  2. Non-gsd user dir (my-custom-skill/) is preserved.
  3. User-created gsd-mything/ (prefix match, not in manifest) is preserved —
     this is the critical regression guard for the Finding 1 data-loss fix.

Also update the existing _syncGsdDir skills-kind test in surface-apply.test.cjs to
pass a manifest that declares old-skill as GSD-owned. Without a manifest the new
conservative path correctly preserves all unknown gsd-* dirs, which broke the
pre-existing no-manifest assertion; supplying the manifest restores the expected
pruning behavior and documents the required calling contract.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* fix(3659): address pr-review-toolkit + codex review findings

- Collapse redundant if/else in _syncGsdDir
- Collapse duplicate canonicalStems branches in pruneSkillDirs
- Update stale module-header comment (config-dir root)
- Clarify dead isGsdOwned guard comment
- Log rmSync failures to stderr
- Add pruneSkillDirs to module-header Exports JSDoc
- Remove unused imports in bug-3659 test file

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-20 23:12:44 -04:00

156 lines
5.0 KiB
Markdown

---
name: gsd:surface
description: Toggle which skills are surfaced — apply a profile, list, or disable a cluster without reinstall
argument-hint: "[list|status|profile <name>|disable <cluster>|enable <cluster>|reset]"
allowed-tools:
- Read
- Write
- Bash
requires: [config, update]
---
<objective>
Manage the runtime skill surface without reinstall. Reads/writes `~/.claude/.gsd-surface.json`
(sibling to `~/.claude/.gsd-profile`) and re-stages the active skills directory in place.
Skill dirs live at `~/.claude/skills/gsd-*/`.
Sub-commands: list · status · profile · disable · enable · reset
</objective>
## Sub-command routing
Parse the first token of $ARGUMENTS:
| Token | Action |
|---|---|
| `list` | Show enabled + disabled clusters and skills |
| `status` | Alias for `list` plus token cost summary |
| `profile <name>` | Write `baseProfile` and re-stage |
| `profile <n1>,<n2>` | Composed profiles (comma-separated, no spaces) |
| `disable <cluster>` | Add cluster to `disabledClusters`, re-stage |
| `enable <cluster>` | Remove cluster from `disabledClusters`, re-stage |
| `reset` | Delete `.gsd-surface.json`, return to install-time profile |
| *(none)* | Treat as `list` |
---
## list / status
Call `listSurface(runtimeConfigDir, manifest, CLUSTERS)` from
`get-shit-done/bin/lib/surface.cjs`. Display:
```
Enabled (N skills, ~T tokens):
core_loop: new-project discuss-phase plan-phase execute-phase help update
audit_review: …
…
Disabled:
utility: health stats settings …
Token cost: ~T (budget cap ~500 tokens for 200k context @ 1%)
```
For `status` also append:
```
Base profile: standard (from .gsd-surface.json)
Install profile: standard (from .gsd-profile)
```
---
## profile \<name\>
1. Read current surface: `readSurface(runtimeConfigDir)` → if null, seed from `readActiveProfile(runtimeConfigDir)`.
2. Set `surfaceState.baseProfile = name`.
3. `writeSurface(runtimeConfigDir, surfaceState)`.
4. Resolve and re-apply:
```js
const layout = resolveRuntimeArtifactLayout(runtime, runtimeConfigDir, scope);
applySurface(runtimeConfigDir, layout, manifest, CLUSTERS);
```
5. Confirm: "Surface updated to profile `<name>`. N skills enabled."
---
## disable \<cluster\>
Valid cluster names: `core_loop`, `audit_review`, `milestone`, `research_ideate`,
`workspace_state`, `docs`, `ui`, `ai_eval`, `ns_meta`, `utility`.
1. Validate cluster name against `Object.keys(CLUSTERS)`.
2. Read or initialize surface state.
3. Add cluster to `surfaceState.disabledClusters` (deduplicate).
4. `writeSurface` → resolve layout → `applySurface`:
```js
const layout = resolveRuntimeArtifactLayout(runtime, runtimeConfigDir, scope);
applySurface(runtimeConfigDir, layout, manifest, CLUSTERS);
```
5. Confirm: "Disabled cluster `<cluster>`. N skills removed from surface."
---
## enable \<cluster\>
1. Read surface state; if null, nothing to enable — print "No surface delta active."
2. Remove cluster from `surfaceState.disabledClusters`.
3. `writeSurface` → resolve layout → `applySurface`:
```js
const layout = resolveRuntimeArtifactLayout(runtime, runtimeConfigDir, scope);
applySurface(runtimeConfigDir, layout, manifest, CLUSTERS);
```
4. Confirm: "Enabled cluster `<cluster>`. N skills added back to surface."
---
## reset
1. Check if `.gsd-surface.json` exists.
2. Delete it.
3. Re-apply using only `readActiveProfile(runtimeConfigDir)` (install-time profile).
4. Confirm: "Surface reset to install-time profile `<name>`."
---
## runtimeConfigDir resolution
The `runtimeConfigDir` for `applySurface` is the **base Claude config directory**
(`~/.claude`), NOT the skills sub-directory (`~/.claude/skills`).
This matches `installRuntimeArtifacts` and `uninstallRuntimeArtifacts`, which also
receive `~/.claude` as `configDir`. The skill dirs themselves live at
`~/.claude/skills/gsd-*/` because the `claude global` layout has `destSubpath =
'skills'` — they are derived from `configDir`, not the root for it.
```bash
# Claude Code — global install
RUNTIME_CONFIG_DIR="${CLAUDE_CONFIG_DIR:-$HOME/.claude}"
SCOPE="global"
# Artifact destinations are derived from runtime layout
# via resolveRuntimeArtifactLayout(runtime, RUNTIME_CONFIG_DIR, SCOPE)
# then applySurface(RUNTIME_CONFIG_DIR, layout, manifest, CLUSTERS)
```
Surface state is stored at `${RUNTIME_CONFIG_DIR}/.gsd-surface.json`
(i.e. `~/.claude/.gsd-surface.json`).
All paths can be overridden by reading the `CLAUDE_CONFIG_DIR` env var if set.
---
## Error handling
- Unknown cluster name → list valid cluster names, exit without writing.
- Unknown profile name → list known profiles (`core`, `standard`, `full`), exit.
- Missing `surface.cjs` → prompt: "Run `npm i -g get-shit-done` to reinstall GSD."
<execution_context>
Surface state file: `~/.claude/.gsd-surface.json`
Install profile marker: `~/.claude/.gsd-profile`
Skill dirs: `~/.claude/skills/gsd-*/`
Engine module: `~/.claude/get-shit-done/bin/lib/surface.cjs`
Cluster definitions: `~/.claude/get-shit-done/bin/lib/clusters.cjs`
</execution_context>