feat(skill-surface): install-time profiles + runtime /gsd:surface (#3408) (#3456)

* feat(skill-deps): add requires: frontmatter to all 51 skills with cross-skill references

Mechanical migration from docs/research/data/2026-05-12-skill-audit.json.
Every skill whose body references another GSD skill now declares those
dependencies in `requires:` YAML frontmatter (flow-style array).

Notable: discuss-phase, plan-phase, and execute-phase all reference `phase`,
which confirms the latent gap in MINIMAL_SKILL_ALLOWLIST — `phase` is pulled
by the core loop but was never in the allowlist. The profile closure model
(ADR-0010 Phase 1) resolves this automatically.

Closes part of #3408.

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

* feat(skill-surface-budget): add PROFILES map, resolveProfile, loadSkillsManifest, staging, marker IO

Implements the Skill Surface Budget Module core (ADR-0010, Phase 1):

- PROFILES Object.freeze map: core (6 skills), standard (~13), full ('*')
- loadSkillsManifest: parses requires: frontmatter from commands/gsd/*.md
  into a Map<stem, string[]> without external YAML dep
- resolveProfile({modes, manifest}): computes transitive closure over the
  requires: graph; composable (modes=['core','audit'] unions closures)
- stageSkillsForProfile / stageAgentsForProfile: filesystem staging with
  same exit-cleanup machinery as the legacy stageSkillsForMode
- readActiveProfile / writeActiveProfile: .gsd-profile marker round-trip
- Back-compat shims preserved: MINIMAL_SKILL_ALLOWLIST, isMinimalMode,
  shouldInstallSkill (overloaded), stageSkillsForMode — all legacy tests pass

The phase latent bug is now resolved by closure: discuss-phase, plan-phase,
and execute-phase all require phase, so any profile including any of them
automatically includes phase via transitive closure.

Tests: 22 manifest+resolve, 9 stage, 10 marker (41 new tests, all green).
Back-compat anchor: 80/80 passing.

Closes part of #3408.

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

* feat(skill-surface-budget): add lint-skill-deps.cjs CI gate and fix 19 missed requires: entries

Two lint checks (scripts/lint-skill-deps.cjs):
  a) Frontmatter-body consistency: skill body references must appear in requires:
  b) Profile closure: every requires: dep of any profile skill must be in closure

Running the lint revealed 19 body references missed by the audit JSON (the
audit used static analysis; some bodies have conditional references). Fixed:
  complete-milestone: +audit-milestone, discuss-phase, plan-phase, execute-phase, new-milestone
  fast: +quick
  health: +thread
  map-codebase: +new-project, plan-phase
  new-milestone, new-project, review, ultraplan-phase: +plan-phase
  ship: +verify-work
  sketch, spike: +new-project
  verify-work: +execute-phase
  workstreams: +new-milestone, resume-work

Wired into package.json as lint:skill-deps and added to pretest.
8 fixture-based tests: all green.

Closes part of #3408.

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

* feat(skill-surface-budget): wire --profile= arg, profile marker write/read in bin/install.js

- Add --profile=<name> / --profile=<n1>,<n2> arg parsing (composable).
  Mutually exclusive with --minimal / --core-only (aliases for --profile=core).
  Default (no flag): full.
- Import readActiveProfile / writeActiveProfile from install-profiles.cjs.
- After writeManifest: persist active profile to .gsd-profile marker.
- gsd update path: if no --profile flag given, read existing .gsd-profile
  marker so non-full profiles are not silently re-expanded to full (ADR-0010).
- Update --help block to document --profile= with per-tier token costs.

New test: install-minimal-backcompat.test.cjs (6 tests):
  - PROFILES.core === MINIMAL_SKILL_ALLOWLIST (contract)
  - --minimal writes .gsd-profile marker "core"
  - --profile=core, --profile=standard write correct markers
  - default install writes marker "full"

Closes part of #3408.

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

* chore(changeset): add feat-3408-skill-profiles changelog fragment

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

* feat(install-profiles): derive agents from skill body refs and wire into resolveProfile

Deviation 1 of ADR-0010 phase 1b: tiered profiles (core, standard) now produce
a non-empty agents Set instead of always returning empty. resolveProfile() scans
each skill body for gsd-* agent name references (via new parseCallsAgents()),
stores them in _calls_agents_<stem> manifest entries, and unions them across the
resolved skill closure. stageAgentsForProfile() already checked resolvedProfile.agents
— it now gets real data so tiered profiles install the correct subset of agents
instead of zero.

Closes #3408 (partial — Deviation 1 only)

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

* feat(install): honor .gsd-profile marker on update, add resolveEffectiveProfile/mostRestrictiveProfile

Deviation 2 of ADR-0010 phase 1b: the marker written during installation is now
actually honored when re-running without explicit flags (e.g. gsd update). The
dead-end logging block is replaced by resolveEffectiveProfile(), which picks the
marker profile over 'full' when no explicit --profile= flag was given. The resolved
profile is piped through to all 13 stageSkillsForMode dispatch sites (now _stageSkills)
so updates install only the previously-chosen skill subset.

--minimal retains its back-compat behavior (strict 6-skill allowlist, no closure)
while writing 'core' to the marker. mostRestrictiveProfile() is exported for callers
that need to reconcile disagreeing markers across runtimes (smallest skill set wins).

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

* feat(surface): add CLUSTERS data + state IO module

Add clusters.cjs with 10 named skill groups covering all 66 skills
(verified by surface-clusters.test.cjs). Add surface.cjs with readSurface/
writeSurface atomic IO, resolveSurface, applySurface, and listSurface.
Tests: 17 passing (11 state IO + 6 cluster integrity).

Closes #3408

* docs(adr): add ADR-0011 Skill Surface Budget Module (Phase 1 accepted, Phase 2 amendment)

Records the install-time profile staging decision (Phase 1, landed) and the
runtime /gsd:surface cluster-toggle decision (Phase 2, in flight) as an
amendment. Updates the ADR README index.

Closes #3408

* docs(install-profiles): update module docblock for Phase 2 and ADR-0011

Corrects the ADR reference from 0010 to 0011, documents the three-profile
model and back-compat aliases, adds resolveEffectiveProfile precedence rule,
and notes the companion surface.cjs Phase 2 engine.

* docs(context): add Skill Surface Budget Module canonical entry

Adds the Domain terms entry for the Skill Surface Budget Module covering
both Phase 1 (install-time profiles, .gsd-profile marker) and Phase 2
(runtime /gsd:surface cluster toggles, clusters.cjs, .gsd-surface.json),
per ADR-0011 Consequences requirement.

* feat(surface): add resolveSurface and applySurface engine + tests

Tests cover: profile → surface equivalence, cluster disable/enable,
explicitAdds transitive closure, applySurface file sync (add missing,
remove superseded, preserve non-gsd files), listSurface token cost.
16 new tests passing.

* docs(readme): document --profile= flag and /gsd:surface command

Brief user-facing mention of install profiles (core/standard/full) and the
/gsd:surface slash command in the Commands table. Points to ADR-0011 for details.

* feat(surface): add /gsd:surface slash command runbook

New skill: gsd:surface — runtime profile/cluster toggle without reinstall.
Sub-commands: list, status, profile <name>, disable/enable <cluster>, reset.
Persists state to .gsd-surface.json (independent of .gsd-profile).
Description 96 chars (≤100 limit). lint:descriptions + lint:skill-deps: 0 violations.

* feat(surface): add changeset fragment for /gsd:surface runtime toggle

* feat(surface): add surface skill stem to utility cluster

surface.md is a new skill; add it to the utility cluster so the
surface-clusters.test.cjs coverage invariant stays satisfied.

* docs(adr): fix ADR references to 0011 and record Phase 2 as shipped

ADR-0010 number was already claimed by the file-operation-engine ADR; this
ADR landed as 0011-skill-surface-budget-module.md. Update inline ADR
references in clusters.cjs, surface.cjs, install-profiles.cjs, and the
Phase 2 changeset to ADR-0011. Update the ADR Status section to record
Phase 2 artifacts as shipped on this branch rather than "in progress".

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

* docs(research): port skill-surface-budget memo and audit data

ADR-0011 references docs/research/2026-05-12-skill-surface-budget.md and
docs/research/data/2026-05-12-skill-audit.json, which only existed in the
research worktree. Port both onto this branch so the ADR's References
section resolves and reviewers can read the cluster taxonomy (§3.2),
dependency topology (§3.1), and option grading (§4) that justify Phase 1
and Phase 2 decisions.

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

* fix(registration): register surface/clusters in INVENTORY, COMMANDS, and help.md

- surface.md: convert allowed-tools from inline YAML array to block style
  (was parsed as a single tool name "[Read, Write, Bash]" by test harness)
- docs/INVENTORY.md: add CLI module rows for clusters.cjs and surface.cjs;
  add Commands row for /gsd-surface; bump CLI Modules count 55→57, Commands 66→67
- docs/INVENTORY-MANIFEST.json: add entries for clusters.cjs, surface.cjs,
  and /gsd-surface (filename-based command key)
- docs/COMMANDS.md: add ### `/gsd-surface` heading in Configuration Commands
- get-shit-done/workflows/help.md: add /gsd:surface entry in Configuration section

Fixes registration failures introduced by Phase 2 of #3408.

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

* fix(surface,docs): scrub .claude leakage and escape hypothetical slash tokens

Two PR regressions introduced earlier on this branch:

1. surface.cjs JSDoc comments contained the canonical paths
   (~/.claude/commands/gsd, ~/.claude/agents) as example values, which the
   cline-install leak regex (~\/\.claude\/(?:get-shit-done|commands|agents
   |hooks)) flagged as install-time path leaks. Reworded the docblocks to
   describe runtime-resolved paths without literal ~/.claude tokens.

2. The ported research memo proposed hypothetical Option C dispatchers
   using slash syntax (/gsd:milestone, /gsd:research). The
   docs-parity-live-registry test enforces that every slash-command token
   in docs/ resolves to a real command. Rewrote the Option C sketch
   without the slash prefix and added a clarifying note that the
   dispatchers are illustrative, not shipped.

Targeted tests now pass: tests/cline-install.test.cjs and
tests/docs-parity-live-registry.test.cjs both green.

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

* test: remove raw output/source grep in lint tests

* fix: close coderabbit profile and requires issues

* test: align surface token-cost assertion wording

* fix(install): align core profile alias and defer profile marker write

---------

Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Tom Boucher
2026-05-13 12:45:16 -04:00
committed by GitHub
parent a60e05c714
commit d0f916728b
83 changed files with 4735 additions and 74 deletions

View File

@@ -0,0 +1,5 @@
---
type: Added
pr: 3408
---
**Named install profiles and dependency manifest for skill surface budget control** — GSD now ships a typed profile model (`core`, `standard`, `full`) replacing the binary `--minimal`/full toggle. Install with `--profile=core` (~87 desc tokens) or `--profile=standard` (~700 tokens) to reduce the GSD share of the Claude Code skill-listing budget. Profiles compute transitive closure over a new `requires:` frontmatter field added to all 66 skills, so dependent skills are automatically included. The active profile is persisted to a `.gsd-profile` marker and respected by `gsd update` — no more silent re-expansion to full on upgrade. A new CI lint gate (`lint:skill-deps`) enforces frontmatter-body consistency and profile closure safety. `--minimal` / `--core-only` remain as aliases for `--profile=core`. (#3408)

View File

@@ -0,0 +1,5 @@
---
type: Added
pr: 3408
---
**Runtime skill surface toggle (`/gsd:surface`)** — new slash command lets users enable/disable skill clusters and switch profiles without reinstalling. Sub-commands: `list` (show enabled/disabled skills + token cost), `status` (list + profile summary), `profile <name>` (apply a named profile), `disable/enable <cluster>` (toggle one of 10 named clusters), `reset` (return to install-time profile). State persists to `~/.claude/skills/.gsd-surface.json` independently of the install-time `.gsd-profile` marker. Backed by `surface.cjs` engine and `clusters.cjs` cluster definitions (ADR-0011 Phase 2, Option B). (#3408)

View File

@@ -67,6 +67,9 @@ Module owning runtime-aware global skills directory policy for SDK query surface
### Installer Migration Authoring Guard Module
Module owning validation for Installer Migration Module records and planned actions. It enforces migration metadata, explicit install scopes, ownership evidence for destructive/config actions, and runtime contract citations for runtime config rewrites before a migration can enter planning or apply.
### Skill Surface Budget Module
Module owning which skills and agents are written to runtime config directories at install time (Phase 1) and at runtime via cluster-level toggles (Phase 2). Phase 1: `get-shit-done/bin/lib/install-profiles.cjs` defines named profiles (`core`, `standard`, `full`), computes transitive closure over `requires:` frontmatter, stages skills/agents to runtime config dirs, and persists the chosen profile in a `.gsd-profile` marker. Profile resolution precedence: explicit `--profile=` flag > `.gsd-profile` marker > `full`. `--minimal`/`--core-only` are back-compat aliases for `--profile=core`. Phase 2: `get-shit-done/bin/lib/surface.cjs` implements the `/gsd:surface` slash command for cluster-level enable/disable without reinstall; cluster definitions live in `get-shit-done/bin/lib/clusters.cjs`; per-runtime state persists in `<runtimeConfigDir>/.gsd-surface.json` independent from the `.gsd-profile` marker. See ADR-0011.
### MVP Mode
Phase-level planning mode that frames work as a vertical slice (UI → API → DB) of one user-visible capability instead of horizontal layers. Resolved at workflow init via the precedence chain: `--mvp` CLI flag → ROADMAP.md `**Mode:** mvp` field → `workflow.mvp_mode` config → false. All-or-nothing per phase (PRD #2826 Q1). Surfaced as `MVP_MODE=true|false` to the planner, executor, verifier, and discovery surfaces (progress, stats, graphify). Canonical parser: `roadmap.cjs` `**Mode:**` field; canonical resolution chain documented in `workflows/plan-phase.md`. Concept index: `references/mvp-concepts.md`.

View File

@@ -142,7 +142,7 @@ claude --dangerously-skip-permissions
GSD is built for frictionless automation. Skip-permissions is how it's intended to run.
See **[docs/USER-GUIDE.md](docs/USER-GUIDE.md)** for the full walkthrough, non-interactive install flags for all 15 runtimes, minimal install (`--minimal`), Docker setup, and permissions configuration.
Install only the skills you need with `--profile=core` (six core-loop skills), `--profile=standard` (core + phase management), or the default full install. Profiles compose: `--profile=core,audit`. `--minimal` is an alias for `--profile=core`. See **[docs/USER-GUIDE.md](docs/USER-GUIDE.md)** for the full walkthrough, non-interactive install flags for all 15 runtimes, and permissions configuration. See [ADR-0011](docs/adr/0011-skill-surface-budget-module.md) for the profile model and runtime surface control.
---
@@ -161,6 +161,7 @@ The main loop:
| `/gsd-progress --next` | Auto-detect and run the next step |
| `/gsd-complete-milestone` | Archive milestone and tag release |
| `/gsd-new-milestone` | Start next version |
| `/gsd:surface` | Enable/disable skill clusters at runtime without reinstall |
For ad-hoc tasks, autonomous mode, codebase analysis, forensics, and the full command surface — see **[docs/COMMANDS.md](docs/COMMANDS.md)**.

File diff suppressed because one or more lines are too long

View File

@@ -15,6 +15,7 @@ argument-instructions: |
Parse the argument as a phase number (integer, decimal, or letter-suffix), plus optional free-text instructions.
Example: /gsd:add-tests 12
Example: /gsd:add-tests 12 focus on edge cases in the pricing module
requires: [phase]
---
<objective>
Generate unit and E2E tests for a completed phase, using its SUMMARY.md, CONTEXT.md, and VERIFICATION.md as specifications.

View File

@@ -13,6 +13,7 @@ allowed-tools:
- WebSearch
- AskUserQuestion
- mcp__context7__*
requires: [phase]
---
<objective>
Create an AI design contract (AI-SPEC.md) for a phase involving AI system development.

View File

@@ -12,6 +12,7 @@ allowed-tools:
- Glob
- Agent
- AskUserQuestion
requires: [audit-uat]
---
<objective>
Run an audit, classify findings as auto-fixable vs manual-only, then autonomously fix

View File

@@ -9,6 +9,7 @@ allowed-tools:
- Bash
- Agent
- Write
requires: [execute-phase]
---
<objective>
Verify milestone achieved its definition of done. Check requirements coverage, cross-phase integration, and end-to-end flows.

View File

@@ -10,6 +10,7 @@ allowed-tools:
- Grep
- AskUserQuestion
- Agent
requires: [cleanup, phase, progress]
---
<objective>
Execute all remaining milestone phases autonomously. For each phase: discuss → plan → execute. Pauses only for user decisions (grey area acceptance, blockers, validation requests).

View File

@@ -6,6 +6,7 @@ allowed-tools:
- Write
- Bash
- AskUserQuestion
requires: [phase]
---
<objective>
Archive phase directories from completed milestones into `.planning/milestones/v{X.Y}-phases/`.

View File

@@ -9,6 +9,7 @@ allowed-tools:
- Grep
- Write
- Agent
requires: [config, import, phase, quick, review]
---
<objective>
Review source files changed during a phase for bugs, security vulnerabilities, and code quality problems.

View File

@@ -7,6 +7,7 @@ allowed-tools:
- Read
- Write
- Bash
requires: [audit-milestone, discuss-phase, execute-phase, new-milestone, phase, plan-phase, stats, update]
---
<objective>

View File

@@ -7,6 +7,7 @@ allowed-tools:
- Write
- Bash
- AskUserQuestion
requires: [code-review, review, settings]
---
<objective>

View File

@@ -12,6 +12,7 @@ allowed-tools:
- Agent
- mcp__context7__resolve-library-id
- mcp__context7__query-docs
requires: [config, phase]
---
<objective>

View File

@@ -11,6 +11,7 @@ allowed-tools:
- Grep
- Agent
- AskUserQuestion
requires: [update]
---
<objective>
Generate and update up to 9 documentation files for the current project. Each doc type is written by a gsd-doc-writer subagent that explores the codebase directly — no hallucinated paths, phantom endpoints, or stale signatures.

View File

@@ -10,6 +10,7 @@ allowed-tools:
- Grep
- Agent
- AskUserQuestion
requires: [phase]
---
<objective>
Conduct a retroactive evaluation coverage audit of a completed AI phase.

View File

@@ -12,6 +12,7 @@ allowed-tools:
- Agent
- TodoWrite
- AskUserQuestion
requires: [phase, verify-work]
---
<objective>
Execute all plans in a phase using wave-based parallel execution.

View File

@@ -10,6 +10,7 @@ allowed-tools:
- Glob
- Agent
type: prompt
requires: [phase]
---
<objective>
Extract structured learnings from completed phase artifacts (PLAN.md, SUMMARY.md, VERIFICATION.md, UAT.md, STATE.md) into a LEARNINGS.md file that captures decisions, lessons learned, patterns discovered, and surprises encountered.

View File

@@ -9,6 +9,7 @@ allowed-tools:
- Bash
- Grep
- Glob
requires: [config, quick]
---
<objective>

View File

@@ -9,6 +9,7 @@ allowed-tools:
- Bash
- Grep
- Glob
requires: [phase, progress, update]
---
<objective>

View File

@@ -5,6 +5,7 @@ argument-hint: "[build|query <term>|status|diff]"
allowed-tools:
- Read
- Bash
requires: [config, fast, phase, update]
---
**STOP -- DO NOT READ THIS FILE. You are already reading it. This prompt was injected into your context by Claude Code's command system. Using the Read tool on this file wastes tokens. Begin executing Step 0 immediately.**

View File

@@ -7,6 +7,7 @@ allowed-tools:
- Bash
- Write
- AskUserQuestion
requires: [thread]
---
<objective>
Validate `.planning/` directory integrity and report actionable issues. Checks for missing files, invalid configurations, inconsistent state, and orphaned plans.

View File

@@ -9,6 +9,7 @@ allowed-tools:
- Grep
- Glob
- AskUserQuestion
requires: [review]
---
<objective>
One-command triage of the project's GitHub inbox. Fetches all open issues and PRs,

View File

@@ -11,6 +11,7 @@ allowed-tools:
- AskUserQuestion
- Skill
- Agent
requires: [phase]
---
<objective>
Single-terminal command center for managing a milestone. Shows a dashboard of all phases with visual status indicators, recommends optimal next actions, and dispatches work — discuss runs inline, plan/execute run as background agents.

View File

@@ -9,6 +9,7 @@ allowed-tools:
- Grep
- Write
- Agent
requires: [config, new-project, plan-phase]
---
<objective>

View File

@@ -10,6 +10,7 @@ allowed-tools:
- Grep
- Agent
- AskUserQuestion
requires: [new-project, phase, plan-phase]
---
<objective>
Guide the user through MVP-mode planning for a phase. The command:

View File

@@ -8,6 +8,7 @@ allowed-tools:
- Bash
- Agent
- AskUserQuestion
requires: [new-project, phase, plan-phase]
---
<objective>
Start a new milestone: questioning → research (optional) → requirements → roadmap.

View File

@@ -8,6 +8,7 @@ allowed-tools:
- Write
- Agent
- AskUserQuestion
requires: [config, phase, plan-phase]
---
<runtime_note>
**Copilot (VS Code):** Use `vscode_askquestions` wherever this workflow calls `AskUserQuestion`. They are equivalent — `vscode_askquestions` is the VS Code Copilot implementation of the same interactive question API.

View File

@@ -5,6 +5,7 @@ argument-hint: ""
allowed-tools:
- Read
- Skill
requires: [map-codebase, graphify, docs-update, extract-learnings]
---
Route to the appropriate codebase-intelligence skill based on the user's intent.

View File

@@ -5,6 +5,7 @@ argument-hint: ""
allowed-tools:
- Read
- Skill
requires: [capture, explore, sketch, spike, spec-phase]
---
Route to the appropriate exploration / capture skill based on the user's intent.

View File

@@ -5,6 +5,7 @@ argument-hint: ""
allowed-tools:
- Read
- Skill
requires: [config, workspace, workstreams, thread, pause-work, resume-work, update, ship, inbox, pr-branch, undo]
---
Route to the appropriate management skill based on the user's intent.

View File

@@ -5,6 +5,7 @@ argument-hint: ""
allowed-tools:
- Read
- Skill
requires: [code-review, audit-uat, secure-phase, eval-review, ui-review, validate-phase, debug, forensics]
---
Route to the appropriate quality / review skill based on the user's intent.

View File

@@ -5,6 +5,7 @@ argument-hint: ""
allowed-tools:
- Read
- Skill
requires: [discuss-phase, spec-phase, plan-phase, execute-phase, verify-work, phase, progress, ultraplan-phase, plan-review-convergence]
---
Route to the appropriate phase-pipeline skill based on the user's intent.

View File

@@ -6,6 +6,7 @@ allowed-tools:
- Read
- Write
- Bash
requires: [phase, progress]
---
<objective>

View File

@@ -12,6 +12,7 @@ allowed-tools:
- AskUserQuestion
- WebFetch
- mcp__context7__*
requires: [discuss-phase, phase, review, update]
---
<objective>
Create executable phase prompts (PLAN.md files) for a roadmap phase with integrated research and verification.

View File

@@ -10,6 +10,7 @@ allowed-tools:
- Grep
- Agent
- AskUserQuestion
requires: [phase, review]
---
<objective>

View File

@@ -6,6 +6,7 @@ allowed-tools:
- Bash
- Read
- AskUserQuestion
requires: [review]
---
<objective>

View File

@@ -9,6 +9,7 @@ allowed-tools:
- Glob
- SlashCommand
- AskUserQuestion
requires: [phase]
---
<objective>
Check project progress, summarize recent work and what's ahead, then intelligently route to the next action.

View File

@@ -11,6 +11,7 @@ allowed-tools:
- Bash
- Agent
- AskUserQuestion
requires: [phase]
---
<objective>
Execute small, ad-hoc tasks with GSD guarantees (atomic commits, STATE.md tracking).

View File

@@ -6,6 +6,7 @@ allowed-tools:
- Write
- Bash
- AskUserQuestion
requires: [phase, review]
---
<objective>

View File

@@ -8,6 +8,7 @@ allowed-tools:
- Bash
- Glob
- Grep
requires: [config, phase, plan-phase]
---
<objective>

View File

@@ -11,6 +11,7 @@ allowed-tools:
- Grep
- Agent
- AskUserQuestion
requires: [phase]
---
<objective>
Verify threat mitigations for a completed phase. Three states:

View File

@@ -6,6 +6,7 @@ allowed-tools:
- Write
- Bash
- AskUserQuestion
requires: [quick]
---
<objective>

View File

@@ -9,6 +9,7 @@ allowed-tools:
- Glob
- Write
- AskUserQuestion
requires: [review, verify-work]
---
<objective>
Bridge local completion → merged PR. After /gsd:verify-work passes, ship the work: push branch, create PR with auto-generated body, optionally trigger review, and track the merge.

View File

@@ -14,6 +14,7 @@ allowed-tools:
- WebFetch
- mcp__context7__resolve-library-id
- mcp__context7__query-docs
requires: [spike]
---
<objective>
Explore design directions through throwaway HTML mockups before committing to implementation.
@@ -25,7 +26,7 @@ Two modes:
- **Idea mode** (default) — describe a design idea to sketch
- **Frontier mode** (no argument or "frontier") — analyzes existing sketch landscape and proposes consistency and frontier sketches
Does not require `/gsd:new-project` — auto-creates `.planning/sketches/` if needed.
Does not require prior new-project setup — auto-creates `.planning/sketches/` if needed.
</objective>
<execution_context>

View File

@@ -9,6 +9,7 @@ allowed-tools:
- Glob
- Grep
- AskUserQuestion
requires: [discuss-phase, execute-phase, phase, plan-phase]
---
<objective>

View File

@@ -14,6 +14,7 @@ allowed-tools:
- WebFetch
- mcp__context7__resolve-library-id
- mcp__context7__query-docs
requires: []
---
<objective>
Spike an idea through experiential exploration — build focused experiments to feel the pieces
@@ -25,7 +26,7 @@ Two modes:
- **Idea mode** (default) — describe an idea to spike
- **Frontier mode** (no argument or "frontier") — analyzes existing spike landscape and proposes integration and frontier spikes
Does not require `/gsd:new-project` — auto-creates `.planning/spikes/` if needed.
Does not require prior new-project setup — auto-creates `.planning/spikes/` if needed.
</objective>
<execution_context>

View File

@@ -4,6 +4,7 @@ description: Display project statistics — phases, plans, requirements, git met
allowed-tools:
- Read
- Bash
requires: [phase, progress]
---
<objective>
Display comprehensive project statistics including phase progress, plan execution metrics, requirements completion, git history stats, and project timeline.

129
commands/gsd/surface.md Normal file
View File

@@ -0,0 +1,129 @@
---
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/skills/.gsd-surface.json`
(sibling to `.gsd-profile`) and re-stages the active commands/gsd directory in place.
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: `applySurface(runtimeConfigDir, commandsDir, agentsDir, 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` → `applySurface`.
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` → `applySurface`.
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
```bash
# Claude Code
RUNTIME_CONFIG_DIR=~/.claude/skills
# Resolve commandsDir and agentsDir
COMMANDS_DIR=~/.claude/commands/gsd
AGENTS_DIR=~/.claude/agents
```
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/skills/.gsd-surface.json`
Install profile marker: `~/.claude/skills/.gsd-profile`
Engine module: `~/.claude/get-shit-done/bin/lib/surface.cjs`
Cluster definitions: `~/.claude/get-shit-done/bin/lib/clusters.cjs`
</execution_context>

View File

@@ -6,6 +6,7 @@ allowed-tools:
- Read
- Write
- Bash
requires: [phase]
---
<objective>

View File

@@ -12,6 +12,7 @@ allowed-tools:
- WebFetch
- AskUserQuestion
- mcp__context7__*
requires: [phase]
---
<objective>
Create a UI design contract (UI-SPEC.md) for a frontend phase.

View File

@@ -10,6 +10,7 @@ allowed-tools:
- Grep
- Agent
- AskUserQuestion
requires: [phase]
---
<objective>
Conduct a retroactive 6-pillar visual audit. Produces UI-REVIEW.md with

View File

@@ -7,6 +7,7 @@ allowed-tools:
- Bash
- Glob
- Grep
requires: [import, phase, plan-phase]
---
<objective>

View File

@@ -8,6 +8,7 @@ allowed-tools:
- Glob
- Grep
- AskUserQuestion
requires: [phase]
---
<objective>

View File

@@ -11,6 +11,7 @@ allowed-tools:
- Grep
- Agent
- AskUserQuestion
requires: [phase]
---
<objective>
Audit Nyquist validation coverage for a completed phase. Three states:

View File

@@ -10,6 +10,7 @@ allowed-tools:
- Edit
- Write
- Agent
requires: [execute-phase, phase]
---
<objective>
Validate built features through conversational testing with persistent state.

View File

@@ -4,6 +4,7 @@ description: Manage parallel workstreams — list, create, switch, status, progr
allowed-tools:
- Read
- Bash
requires: [new-milestone, phase, progress, resume-work]
---
# /gsd:workstreams

View File

@@ -966,6 +966,26 @@ All answers merge via `gsd-sdk query config-set`, preserving unrelated keys. API
See [CONFIGURATION.md](CONFIGURATION.md) for the full schema and defaults.
### `/gsd-surface`
Toggle which skills are surfaced — apply a profile, list, or disable a cluster without reinstall.
| Subcommand | Description |
|------------|-------------|
| `list` | Show enabled and disabled clusters and skills |
| `status` | Alias for `list` plus token cost summary |
| `profile <name>` | Write `baseProfile` and re-stage skills |
| `disable <cluster>` | Add cluster to disabled list and re-stage |
| `enable <cluster>` | Remove cluster from disabled list and re-stage |
| `reset` | Delete surface delta; return to install-time profile |
```bash
/gsd-surface list # Show current surface
/gsd-surface profile standard # Switch to standard profile
/gsd-surface disable utility # Disable the utility cluster
/gsd-surface reset # Restore install-time profile
```
---
## Brownfield Commands

View File

@@ -93,6 +93,7 @@
"/gsd-spec-phase",
"/gsd-spike",
"/gsd-stats",
"/gsd-surface",
"/gsd-thread",
"/gsd-ui-phase",
"/gsd-ui-review",
@@ -262,6 +263,7 @@
"artifacts.cjs",
"audit.cjs",
"cjs-command-router-adapter.cjs",
"clusters.cjs",
"command-aliases.generated.cjs",
"commands.cjs",
"config-schema.cjs",
@@ -303,6 +305,7 @@
"state-command-router.cjs",
"state-document.cjs",
"state.cjs",
"surface.cjs",
"template.cjs",
"uat.cjs",
"validate-command-router.cjs",

View File

@@ -54,7 +54,7 @@ Full roster at `agents/gsd-*.md`. The "Primary doc" column flags whether [`docs/
---
## Commands (66 shipped)
## Commands (67 shipped)
Full roster at `commands/gsd/*.md`. The groupings below mirror `docs/COMMANDS.md` section order; each row carries the command name, a one-line role derived from the command's frontmatter `description:`, and a link to the source file. `tests/command-count-sync.test.cjs` locks the count against the filesystem.
@@ -158,6 +158,7 @@ These six routers are descriptor-only entries that the model picks first; the bo
| `/gsd-settings` | Configure GSD workflow toggles and model profile. | [commands/gsd/settings.md](../commands/gsd/settings.md) |
| `/gsd-config` | Configure GSD settings — workflow toggles (default), advanced knobs (`--advanced`), integrations (`--integrations`), or model profile (`--profile`). | [commands/gsd/config.md](../commands/gsd/config.md) |
| `/gsd-pr-branch` | Create a clean PR branch by filtering out `.planning/` commits. | [commands/gsd/pr-branch.md](../commands/gsd/pr-branch.md) |
| `/gsd-surface` | Toggle which skills are surfaced — apply a profile, list, or disable a cluster without reinstall. | [commands/gsd/surface.md](../commands/gsd/surface.md) |
| `/gsd-update` | Update GSD to latest version; use `--sync` to sync skills across runtimes or `--reapply` to reapply local patches. | [commands/gsd/update.md](../commands/gsd/update.md) |
| `/gsd-help` | Show available GSD commands and usage guide. | [commands/gsd/help.md](../commands/gsd/help.md) |
@@ -359,7 +360,7 @@ The `gsd-planner` agent is decomposed into a core agent plus reference modules t
---
## CLI Modules (55 shipped)
## CLI Modules (57 shipped)
Full listing: `get-shit-done/bin/lib/*.cjs`.
@@ -370,6 +371,7 @@ Full listing: `get-shit-done/bin/lib/*.cjs`.
| `artifacts.cjs` | Canonical artifact registry — known `.planning/` root file names; used by `gsd-health` W019 lint |
| `audit.cjs` | Audit dispatch, audit open sessions, audit storage helpers |
| `cjs-command-router-adapter.cjs` | Shared compatibility adapter for manifest-backed CJS command-family routers |
| `clusters.cjs` | Skill cluster definitions for the runtime surface module (ADR-0011 Phase 2) |
| `command-aliases.generated.cjs` | Generated CJS alias/subcommand metadata for manifest-backed family routers |
| `commands.cjs` | Misc CLI commands (slug, timestamp, todos, scaffolding, stats) |
| `config-schema.cjs` | Single source of truth for `VALID_CONFIG_KEYS` and dynamic key patterns; imported by both the validator and the config-schema-docs parity test |
@@ -411,6 +413,7 @@ Full listing: `get-shit-done/bin/lib/*.cjs`.
| `state-command-router.cjs` | Thin CJS subcommand router adapter for `gsd-tools state` |
| `state.cjs` | STATE.md parsing, updating, progression, metrics |
| `state-document.cjs` | Pure STATE.md field extraction, replacement, status normalization, and progress calculation transforms |
| `surface.cjs` | Runtime surface module — manages the runtime enable/disable surface state independently of the install-time profile marker (ADR-0011 Phase 2) |
| `template.cjs` | Template selection and filling with variable substitution |
| `uat.cjs` | UAT file parsing, verification debt tracking, audit-uat support |
| `validate-command-router.cjs` | Thin CJS subcommand router adapter for `gsd-tools validate` |

View File

@@ -0,0 +1,80 @@
# Skill Surface Budget Module owns install-time profile staging and runtime surface control
- **Status:** Accepted
- **Date:** 2026-05-12
- **Decision date:** 2026-05-12
- **Implementation:** feat/3408-skills-description-dropped-due-to-size, PR <TBD>
Every installed `gsd-*` skill costs eager system-prompt tokens: runtimes (Claude Code, opencode, and others) enumerate all skill descriptions in `<available_skills>` on every turn. With 66 skills and 33 agents, GSD alone consumes roughly 60% of the default 1%-of-context skill-listing budget, causing descriptions to drop when users stack multiple plugins (#3408).
The root problem is an absence of a profile/surface seam: the installer wrote every skill unconditionally, and no runtime-side control existed for enabling or disabling a cohesive group of skills without a full reinstall.
## Decision
- Add a **Skill Surface Budget Module** under `get-shit-done/bin/lib/install-profiles.cjs` as the single owner for which skills and agents are written to runtime config directories.
- Define three named profiles: `core` (six skills covering the main loop), `standard` (core + phase management and workspace skills), and `full` (all skills — the previous default).
- Compute each profile's effective skill set as the transitive closure over the `requires:` dependency graph extracted from skill frontmatter, so partial installs never break cross-skill dependencies.
- Persist the chosen profile in a `.gsd-profile` marker file in each runtime config directory; `gsd update` reads the marker to honor the profile on re-install.
- When multiple runtimes are configured, resolve disagreement to the most-restrictive profile (smallest effective skill set).
- Allow profile composition: `--profile=core,audit` resolves to `union(closure(core), closure(audit))`.
- Preserve back-compat aliases: `--minimal` and `--core-only` map to `--profile=core`; `MINIMAL_SKILL_ALLOWLIST`, `isMinimalMode`, `shouldInstallSkill`, and `stageSkillsForMode` remain exported for existing callers.
- Add a CI gate (`scripts/lint-skill-deps.cjs`, wired into `pretest`) that verifies every skill's `requires:` entries resolve against real skill stems — prevents the profile closure from silently over-installing or breaking.
## Phase 2 — Runtime Surface Module
The Phase 2 decision, previously listed as an open question, is recorded here as an amendment to this ADR.
### Decision
- Add a `/gsd:surface` slash command with the following sub-commands:
- `list` — show all clusters and their enabled/disabled status for the active runtime
- `status` — show the active profile, effective skill count, and any dropped-description warnings
- `profile <name>` — switch the active profile and re-stage skills/agents for the current runtime
- `disable <cluster>` — mark a cluster disabled; re-stage to remove its skills from the runtime config dir
- `enable <cluster>` — mark a cluster enabled; re-stage to add its skills back
- `reset` — clear surface state and re-apply the active profile from `.gsd-profile`
- Implement the runtime surface engine in `get-shit-done/bin/lib/surface.cjs`, consuming `stageSkillsForProfile` and `stageAgentsForProfile` from the Phase 1 module without duplicating staging logic.
- Persist per-runtime surface state in `<runtimeConfigDir>/.gsd-surface.json`, independent from `.gsd-profile`. The profile marker owns install-time identity; the surface JSON owns session-scope cluster toggles.
- Source cluster taxonomy from the research memo §3.2 (2026-05-12-skill-surface-budget.md). Define clusters in `get-shit-done/bin/lib/clusters.cjs` — a separate module so the surface engine and future SDK callers can import cluster definitions without loading the full profile module.
- Cluster taxonomy: `core_loop`, `audit_review`, `milestone`, `research_ideate`, `workspace_state`, `docs`, `ui`, `ai_eval`, `ns_meta`, `utility`. Membership may overlap; every installed skill stem must appear in at least one cluster (enforced by `tests/surface-clusters.test.cjs`).
- Relationship to Anthropic platform asks: Asks D (native per-skill toggle API) and E (budget-fraction negotiation) remain filed separately. The `/gsd:surface` command is a unilateral GSD-side workaround that does not depend on those platform changes.
## Status — Phase 1 shipped
Phase 1 artifacts landed on `feat/3408-skills-description-dropped-due-to-size`:
- `get-shit-done/bin/lib/install-profiles.cjs` — `PROFILES` map, `resolveProfile`, `loadSkillsManifest`, `stageSkillsForProfile`, `stageAgentsForProfile`, `readActiveProfile`, `writeActiveProfile`, `mostRestrictiveProfile`, `resolveEffectiveProfile`
- `requires:` frontmatter added to 64 skills in `commands/gsd/*.md`
- `scripts/lint-skill-deps.cjs` — CI gate for `requires:` integrity, wired into `pretest`
- `bin/install.js` — `--profile=<name>` flag (composable); `--minimal`/`--core-only` as aliases; `.gsd-profile` marker write on install; `gsd update` re-reads marker
- Tests: `tests/install-profiles-manifest.test.cjs`, `tests/install-profiles-marker.test.cjs`, `tests/install-profiles-resolve.test.cjs`, `tests/install-profiles-stage.test.cjs`, `tests/lint-skill-deps.test.cjs`
Phase 2 shipped on the same branch:
- `commands/gsd/surface.md` — `/gsd:surface` slash command runbook (sub-commands: `list`, `status`, `profile <name>`, `disable <cluster>`, `enable <cluster>`, `reset`)
- `get-shit-done/bin/lib/surface.cjs` — runtime engine (`readSurface`, `writeSurface`, `resolveSurface`, `applySurface`, `listSurface`); reuses `stageSkillsForProfile` / `stageAgentsForProfile` from Phase 1
- `get-shit-done/bin/lib/clusters.cjs` — 10-cluster taxonomy covering all installed skill stems
- Tests: `tests/surface-state.test.cjs`, `tests/surface-clusters.test.cjs`, `tests/surface-resolve.test.cjs`, `tests/surface-apply.test.cjs`, `tests/surface-list.test.cjs`
- Persistent surface state: `<runtimeConfigDir>/.gsd-surface.json` (independent from `.gsd-profile`)
## Open questions
- Whether the `requires:` field should also be consumed by `/gsd:help` to annotate dependency chains in help output (follow-up).
- Whether telemetry (per-profile install counts, cluster-disable events) should be added to the surface engine or deferred.
- Whether Anthropic platform asks D and E (native skill toggle API, budget-fraction negotiation) should block any future work in this module.
## Consequences
- Users with constrained context budgets can install `--profile=core` and expand incrementally via `/gsd:surface enable <cluster>` without a full reinstall.
- The `requires:` closure ensures partial installs never silently break skill cross-references.
- Future skills must declare `requires:` dependencies to participate in profile resolution; the lint gate enforces this at CI time.
- `CONTEXT.md` gains a canonical **Skill Surface Budget Module** entry; future architecture reviews should treat out-of-seam skill staging as drift.
- Cluster definitions in `clusters.cjs` are the authoritative taxonomy for runtime surface control; additions must be reflected there and in tests.
## References
- Feature issue: `#3408`
- Research memo: `docs/research/2026-05-12-skill-surface-budget.md` (§3.2 cluster taxonomy)
- See `0008-installer-migration-module.md`
- See `0009-shell-command-projection-module.md`
- See `0010-file-operation-engine-module.md`

View File

@@ -18,6 +18,7 @@ Each ADR documents one architectural decision: what was decided, why, and what c
| [0008-installer-migration-module.md](0008-installer-migration-module.md) | Installer Migration Module owns install-time upgrade safety | Accepted |
| [0009-shell-command-projection-module.md](0009-shell-command-projection-module.md) | Shell Command Projection Module owns runtime-aware OS command rendering | Accepted |
| [0010-file-operation-engine-module.md](0010-file-operation-engine-module.md) | File Operation Engine Module owns safe runtime/config file mutations | Proposed |
| [0011-skill-surface-budget-module.md](0011-skill-surface-budget-module.md) | Skill Surface Budget Module owns install-time profile staging and runtime surface control | Accepted |
## Seam map
@@ -33,3 +34,8 @@ projection of installer-owned command text and projection IR.
ADR 0010 documents the File Operation Engine Module seam for converging
installer/migration/planning file mutation safety policy, and its relationship
to ADR 0009 hook-command ownership policy.
ADR 0011 documents the Skill Surface Budget Module for install-time skill/agent
profile staging (`--profile=<name>`, `.gsd-profile` marker, `requires:` closure)
and the Phase 2 runtime `/gsd:surface` command for cluster-level enable/disable
without reinstall.

View File

@@ -0,0 +1,269 @@
# Skill surface budget — research memo
**Date:** 2026-05-12
**Author:** triage analysis for issue [#3408](https://github.com/gsd-build/get-shit-done/issues/3408)
**Status:** Research input for ADR-0010
**Reading time:** ~15 min
---
## 1. Problem statement
Claude Code and other runtimes that surface skills enumerate every installed skill's `name + description` into the system prompt on every turn, inside an `<available_skills>` block. This block is capped by `skillListingBudgetFraction` — default **1%** of the model's context window, ~**2,000 tokens** at 200k. When the combined descriptions exceed the cap, the harness silently truncates the tail. The reporter of #3408 has 135 installed skills across multiple plugins and observed dropped skills.
GSD's audited footprint:
| Metric | Value |
|---|---|
| Installed skills | 66 |
| Installed sub-agents | 33 (not listed in `<available_skills>` — invoked via `Task`) |
| Total skill description chars | 4,787 |
| Estimated description tokens (÷4) | ~1,196 |
| Mean description length | 72.5 chars |
| Description ceiling (enforced by `scripts/lint-descriptions.cjs`) | 100 chars |
| GSD share of a 200k 1% budget | ~60% |
GSD on its own consumes roughly 60% of the default skill-listing budget. When the user stacks any other plugin of comparable size (the reporter's `/doctor` shows several), the budget is breached and skills get dropped. This is not a GSD-only problem, but **GSD is the single biggest contributor in the typical install**, so we are the natural place for ecosystem-wide remediation to start.
## 2. What's already in place
GSD has done one consolidation pass and shipped one install-time lever:
- **`--minimal` / `--core-only` install flag** (`bin/install.js:123`, `get-shit-done/bin/lib/install-profiles.cjs`). Stages a filtered copy of `commands/gsd/` into a temp dir before each runtime-specific copy step. Reduces ~12k tokens of cold-start overhead to ~700.
- **`MINIMAL_SKILL_ALLOWLIST`** — 6 skills: `new-project`, `discuss-phase`, `plan-phase`, `execute-phase`, `help`, `update`. Zero sub-agents in minimal.
- **Hard 100-char description budget**, enforced in CI by `scripts/lint-descriptions.cjs` and `npm run lint:descriptions`.
- **`gsd update` (without `--minimal`)** as the documented upgrade path from minimal → full.
The 100-char cap means **shrinking descriptions further is not a viable lever** — average is already 72.5 chars and the rare 99-char outliers exist because they earn their length (e.g. `gsd:progress`, `gsd:inbox`). Any future budget relief has to come from **emitting fewer skills**, not shorter ones.
## 3. Audit findings
### 3.1 Dependency topology (66 skills)
Hot nodes (counted by other-skill body references):
| Rank | Skill | Callers | Role |
|---|---|---|---|
| 1 | `phase` | 38 | Dispatcher to all phase-typed workflows |
| 2 | `review` | 11 | Review-output convergence |
| 3 | `config` | 7 | Project config display/edit |
| 4 | `progress` | 5 | Active-phase progress tracker |
| 5 | `update` | 5 | Upgrade path |
| 6 | `discuss-phase` | 2 | Main-loop step 1 |
| 6 | `execute-phase` | 2 | Main-loop step 3 |
| 6 | `new-project` | 2 | Bootstrap |
| 6 | `plan-phase` | 2 | Main-loop step 2 |
`phase` and `review` are the two skills whose absence would silently break dozens of others. **`phase` is referenced by 38 other skills but is not in the current minimal allowlist** — a latent gap worth raising. Confirm with a `--minimal` install + a `/gsd:audit-fix` invocation whether it still works.
### 3.2 Functional clusters
Greedy clustering by name + prose intent:
| Cluster | Skills | Desc chars | Desc tokens |
|---|---|---|---|
| `core_loop` | 6 | 346 | ~87 |
| `phase_variants` (includes `core_loop`) | 10 | 734 | ~184 |
| `audit_review` | 11 | 772 | ~193 |
| `milestone` | 4 | 292 | ~73 |
| `research_ideate` (`sketch`, `spike`, `forensics`, `explore`, `graphify`, `ns-ideate`) | 6 | 482 | ~121 |
| `workspace_state` (`pause`, `resume`, `workspace`, `workstreams`, `thread`, `capture`, `inbox`) | 7 | 496 | ~124 |
| `docs` | 2 | 160 | ~40 |
| `ui` | 2 | 122 | ~31 |
| `ai_eval` | 2 | 179 | ~45 |
| `ns_meta` | 6 | 315 | ~79 |
| `utility` (incl. `health`, `stats`, `settings`, `cleanup`, `pr-branch`, `ship`, `undo`, `fast`, `quick`, …) | 23 | 1,714 | ~429 |
The **utility** bucket is the heaviest single cluster (36% of description tokens) and is also the most heterogeneous — half of these likely run only once per project, not once per session. They're the prime candidates for an "advanced" or "opt-in" tier.
### 3.3 Consolidation ceiling
A second consolidation pass (collapsing all 10 `*-phase` skills into a single dispatcher) would save ~150 tokens at most, at the cost of making the user-facing slash commands less discoverable in the UI and complicating argument parsing. **Pure consolidation has diminishing returns** below the current 66-skill count; the next big saves come from changing *what gets surfaced* rather than *how it's written*.
## 4. Options
Each option is graded against five dimensions:
- **User UX**: how surprising / how much new mental model
- **Implementation cost**: relative dev effort
- **Dependency safety**: risk of breaking cross-skill calls
- **Token savings**: vs. current ~1,196 desc tokens
- **Anthropic dependency**: whether platform changes are required
### Option A — Expand install-time profiles (named feature flags)
Layer named profiles on top of the existing minimal/full binary:
```
gsd install --profile=core # current --minimal (6 skills, 0 agents)
gsd install --profile=standard # +phase, +review, +config, +progress (~14 skills)
gsd install --profile=full # current default (66 skills, 33 agents)
gsd install --profile=core,audit,ui # composable feature tags
```
| Dimension | Assessment |
|---|---|
| User UX | Familiar pattern (Cargo features, Helm `--set`, Ansible tags). Picker prompt on interactive install. |
| Implementation cost | **Low.** `install-profiles.cjs` already does the staging. Extend `MINIMAL_SKILL_ALLOWLIST` into a map of profile → set, add a `--profile` arg parser, add interactive `AskUserQuestion`. |
| Dependency safety | Need a manifest declaring each skill's required-skills set, so a profile can't ship an orphan caller. Add as CI lint. |
| Token savings | High — `standard` cuts ~70% of descriptions; named clusters give users granular control. |
| Anthropic dependency | None. |
**Pros:** ships unilaterally, leverages existing seam, low blast radius.
**Cons:** install-time only — users on a "full" install can't shrink without reinstall.
### Option B — Runtime enable/disable command
A `/gsd:surface` (or `gsd surface` CLI) command that toggles which skills are visible to the runtime without touching installed files:
```
/gsd:surface list # show enabled/disabled
/gsd:surface disable ui audit # hide a cluster
/gsd:surface profile standard # apply a named profile
```
Implementation: write enable/disable state to `~/.claude/skills/<name>/SKILL.md.disabled` (rename) or maintain a `gsd-surface.json` manifest the installer reads on every `update`.
| Dimension | Assessment |
|---|---|
| User UX | Discoverable through `/gsd:help`. Lower commit than reinstall. Mirrors VS Code's enable/disable extension UX. |
| Implementation cost | **Medium.** Need persistent state separate from install files, plus a re-apply loop on `gsd update`. |
| Dependency safety | Same manifest requirement as Option A — disabling `phase` should warn that 38 skills depend on it. |
| Token savings | High — user-driven; can match Option A's savings. |
| Anthropic dependency | None for the rename approach. Cleaner if Anthropic supports a `SKILL.disabled` convention natively. |
**Pros:** in-session adjustable, no reinstall friction.
**Cons:** state lives outside the installer's idempotent model, so `gsd update` migrations get more complex.
### Option C — Further skill consolidation
Collapse semantically related skills into a single dispatcher with sub-modes:
```text
gsd-phase → keeps existing
gsd-milestone {new|complete|summary|audit} # was 4 skills (hypothetical)
gsd-research {sketch|spike|forensics|explore} # was 4 skills (hypothetical)
gsd-workspace {pause|resume|capture|inbox|thread} # was 5 skills (hypothetical)
```
(The hypothetical dispatchers above are written without the slash prefix to signal they are not shipped commands — Option C is a sketch, not a recommendation.)
| Dimension | Assessment |
|---|---|
| User UX | Breaking change for muscle-memorized slash commands; needs aliases for ≥1 release cycle. |
| Implementation cost | **Medium-high.** Argument-parsing inside each dispatcher; migration of cross-references in 30+ skill bodies; aliases; CHANGELOG entries. |
| Dependency safety | High — every cross-reference in existing skill bodies needs rewriting. The audit graph (3.1) is the migration spec. |
| Token savings | Moderate — ~200-300 tokens by collapsing the 13-15 named skills above into 3 dispatchers. |
| Anthropic dependency | None. |
**Pros:** also improves IA — the slash-command surface becomes more discoverable.
**Cons:** breaks user habits, doesn't compose with A/B (you still want profiles after consolidating).
### Option D — Lazy / on-demand descriptions (Anthropic ask)
Skills ship a 1-line *teaser* in the system prompt and the full description loads only when the model expresses interest (analogous to `ToolSearch` for deferred tools). Cuts per-skill listing cost to ~10 chars.
| Dimension | Assessment |
|---|---|
| User UX | Invisible to users. |
| Implementation cost | **Low for GSD** — add a `teaser:` frontmatter field. **High for Anthropic** — harness changes. |
| Dependency safety | N/A (purely about listing). |
| Token savings | ~85% of all skill-listing budget across the ecosystem. |
| Anthropic dependency | **Yes — platform feature.** |
This is the architecturally correct long-term answer. GSD can't ship it alone.
### Option E — Per-plugin budget allocation (Anthropic ask)
Instead of one shared `skillListingBudgetFraction`, give each plugin a dedicated quota (e.g. proportional to declared `skills.count` × ceiling). Eliminates one greedy plugin starving others.
| Dimension | Assessment |
|---|---|
| User UX | Invisible. |
| Implementation cost | **Low for GSD** — declare quota in `package.json` / plugin manifest. **Medium for Anthropic** — quota arithmetic and tie-breaking in the harness. |
| Token savings | Doesn't reduce total, but eliminates the silent-drop failure mode. |
| Anthropic dependency | **Yes.** |
### Option F — Sub-plugins / split distribution
Publish GSD as multiple npm packages: `get-shit-done-cc-core`, `get-shit-done-cc-milestones`, `get-shit-done-cc-research`, etc. Users install only what they need.
| Dimension | Assessment |
|---|---|
| User UX | Reasonable for advanced users; confusing for first-time installers. Needs a meta-package (`get-shit-done-cc`) that depends on the slim ones — analogous to VS Code extension packs. |
| Implementation cost | **High.** Multi-package build pipeline, version sync across packages, changelog routing, install-script forking. |
| Dependency safety | npm semver carries the contract; cross-package refs become real `require()` calls. |
| Token savings | Same as Option A in practice — token savings come from choosing not to install, not from the package boundary. |
| Anthropic dependency | None. |
**Pros:** clean separation, follows npm-ecosystem norms.
**Cons:** very high lift for the same token savings Option A delivers.
## 5. Recommendation
**Adopt Option A (named install profiles) as ADR-0010, with Option B (runtime surface toggle) as a Phase-2 amendment**. File Options D and E to Anthropic as platform asks.
Why this ordering:
1. **A reuses an existing seam.** `install-profiles.cjs` is already the staging point; this is the lowest-risk way to ship meaningful relief in the next release.
2. **A is composable.** Naming clusters as profiles is a forcing function for the dependency manifest, which we want anyway for the lint described in 3.1.
3. **B follows A naturally.** Once profiles exist, the `/gsd:surface` command is "apply a profile to a live install plus persist deltas." Without A, B has no profiles to apply.
4. **C is independent and orthogonal.** It can happen in parallel as IA cleanup; it should not block A.
5. **D and E are platform-level.** GSD ships A regardless; D/E are documented as cooperative asks so Anthropic sees them in context.
## 6. Anthropic platform asks
Drafted for filing at <https://docs.claude.com/feedback> or similar channel; copy unchanged into the issue or feedback form.
### Ask 1 — Native lazy skill descriptions
> Skills currently emit `name + description` into every system prompt. For large plugin ecosystems (135+ skills on power-user installs) this overruns `skillListingBudgetFraction` and silently drops skills. Proposal: add a frontmatter `teaser:` field (≤40 chars) that ships in the listing, with the full `description:` loaded only when the model requests it (analogous to ToolSearch for deferred tools). Backwards-compatible: skills without `teaser:` keep current behavior.
### Ask 2 — Per-plugin budget allocation
> A single `skillListingBudgetFraction` shared across all installed plugins causes silent truncation when one plugin's skill set is large. Proposal: each plugin declares a soft quota in its manifest; the harness arbitrates fairness when total demand exceeds budget (e.g. proportional shrink, with a documented order — most-recently-installed last to be cut). Surface drops in `/doctor` output today; do not drop silently.
### Ask 3 — Dependency-aware skill listing
> Skills can call other skills (in GSD, the `phase` skill is referenced by 38 others). When the harness drops a skill from the listing, it has no way to know whether anything else relies on it. Proposal: optional frontmatter `requires: [other-skill]` so the harness keeps the closure of dependencies in the listing, or warns the user at install time that a dropped skill is reachable from a kept one.
### Ask 4 — Disable/enable without uninstall
> Today the only way to remove a skill from the listing is to delete its `SKILL.md`. Proposal: a `.disabled` suffix (e.g. `SKILL.md.disabled`) or a per-skill `enabled: false` frontmatter is treated as "not surfaced" by the harness. This lets plugins ship surface-toggle UIs (like our proposed `/gsd:surface disable`) without touching install state.
## 7. Implementation sketch (for the ADR)
Phase 1 — profiles (ships with ADR-0010):
1. In `get-shit-done/bin/lib/install-profiles.cjs`, replace the single `MINIMAL_SKILL_ALLOWLIST` constant with a `PROFILES` map. Each profile is the *transitive closure* over a base set, so `standard` includes `core` automatically.
2. Add a `requires:` frontmatter field to every skill that calls another skill in its body. Add a lint check in `scripts/lint-descriptions.cjs` (or a sibling `lint-skill-deps.cjs`) that fails CI if a skill body references another skill that isn't in its `requires` list, and that fails if any profile would ship a skill whose `requires` aren't satisfied.
3. Extend the `bin/install.js` argument parser: `--profile=<name>` (mutually exclusive with `--minimal`), `--profile=core,audit` for composition. Keep `--minimal` as an alias for `--profile=core`.
4. Interactive install: if no `--profile` is given and no runtime/location is forced, present an `AskUserQuestion`-style picker. (Cowork analog already in the install flow.)
5. `gsd update` re-applies the recorded profile from a small marker file (`~/.claude/skills/.gsd-profile`).
Phase 2 — runtime surface command (follow-up ADR or amendment):
1. `/gsd:surface` command writes to the profile marker and re-runs the staging step for the active runtime.
2. Once Anthropic ships Ask 4, switch from file-deletion to `.disabled`-suffix toggling.
## 8. Follow-ups outside this scope
- **Stale comment in `install-profiles.cjs`.** The module-level header cites "86 skills + 33 agents" producing ~12k tokens. The audited count is 66 + 33 — a previous consolidation pass already happened. Update the comment in a parallel cleanup commit when ADR-0010 lands.
- **Audit JSON refresh.** `docs/research/data/2026-05-12-skill-audit.json` is a one-shot snapshot. If we want it to stay current, wire the extraction script into `scripts/` and run it on `lint:skill-deps`. Not a blocker.
## 9. Risks and unknowns
- **The `phase` dispatcher gap in the existing minimal allowlist.** Confirm whether a fresh `--minimal` install + the documented main loop actually works end-to-end. If `discuss-phase`/`plan-phase`/`execute-phase` silently fall back to `/gsd:phase`, the minimal allowlist is currently broken. Track as a separate bug if confirmed.
- **Profile naming bikeshed.** `core` / `standard` / `full` vs. `minimal` / `recommended` / `everything` vs. functional names (`planning`, `audit`, `research`). Settle in the ADR's Open Questions.
- **Discoverability of disabled skills.** If `gsd:audit-fix` isn't surfaced, a user asking "audit my project" won't get it suggested. `/gsd:help` should list installed-but-not-surfaced skills with a one-line upgrade hint.
- **Telemetry blind spot.** GSD doesn't currently know which skills users invoke, so "drop the long tail" is theoretical. Survey or self-reporting may be needed before drawing the `standard` profile line.
## 10. References
- Issue: [#3408](https://github.com/gsd-build/get-shit-done/issues/3408)
- Existing seam: `get-shit-done/bin/lib/install-profiles.cjs`
- Description lint: `scripts/lint-descriptions.cjs`
- Install dispatcher: `bin/install.js:123` (mode parsing), `bin/install.js:8167-8207` (minimal staging)
- Audit data: [`docs/research/data/2026-05-12-skill-audit.json`](data/2026-05-12-skill-audit.json) (per-skill dep graph, description sizes, and cluster mapping — reproducible from `commands/gsd/` and `agents/`)
- Prior ADRs: 0008 (Installer Migration Module) and 0009 (Shell Command Projection Module) — both touch the same install pipeline this proposal extends.
- Ecosystem precedents: Cargo `[features]`, npm `optionalDependencies`, VS Code extension packs, Homebrew taps, Helm chart values, Ansible role tags, Linux kernel `make menuconfig` tristate, systemd target activation.

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,135 @@
'use strict';
/**
* Skill cluster definitions for the runtime surface module (ADR-0011 Phase 2).
*
* Each cluster is a named group of skill stems. Clusters are used by /gsd:surface
* to enable/disable a cohesive group of skills without reinstall.
*
* Cluster membership may overlap (a skill can live in two clusters). The union
* of all clusters should cover every installed skill stem; uncategorized stems
* are flagged by surface-clusters.test.cjs.
*
* Source: docs/research/2026-05-12-skill-surface-budget.md §3.2 (verified
* against commands/gsd/ listing in surface-clusters.test.cjs).
*/
const CLUSTERS = Object.freeze({
core_loop: Object.freeze([
'new-project',
'discuss-phase',
'plan-phase',
'execute-phase',
'help',
'update',
]),
audit_review: Object.freeze([
'code-review',
'review',
'audit-fix',
'audit-milestone',
'audit-uat',
'verify-work',
'validate-phase',
'plan-review-convergence',
'eval-review',
'add-tests',
'secure-phase',
]),
milestone: Object.freeze([
'new-milestone',
'complete-milestone',
'milestone-summary',
'health',
]),
research_ideate: Object.freeze([
'sketch',
'spike',
'forensics',
'explore',
'graphify',
'ns-ideate',
]),
workspace_state: Object.freeze([
'pause-work',
'resume-work',
'workspace',
'workstreams',
'thread',
'capture',
'inbox',
]),
docs: Object.freeze([
'docs-update',
'ingest-docs',
]),
ui: Object.freeze([
'ui-phase',
'ui-review',
]),
ai_eval: Object.freeze([
'ai-integration-phase',
'eval-review',
]),
ns_meta: Object.freeze([
'ns-context',
'ns-ideate',
'ns-manage',
'ns-project',
'ns-review',
'ns-workflow',
]),
utility: Object.freeze([
'health',
'stats',
'settings',
'cleanup',
'pr-branch',
'ship',
'undo',
'fast',
'quick',
'autonomous',
'config',
'progress',
'phase',
'review',
'update',
'help',
'code-review',
'import',
'manager',
'map-codebase',
'profile-user',
'spec-phase',
'ultraplan-phase',
'mvp-phase',
'execute-phase',
'review-backlog',
'debug',
'extract-learnings',
'surface',
]),
});
/**
* Build a Set of all skill stems covered by at least one cluster.
* @returns {Set<string>}
*/
function allClusteredSkills() {
const result = new Set();
for (const skills of Object.values(CLUSTERS)) {
for (const s of skills) result.add(s);
}
return result;
}
module.exports = { CLUSTERS, allClusteredSkills };

View File

@@ -1,46 +1,261 @@
/**
* Install profiles — single source of truth for which skills/agents
* are written to the runtime config dirs.
* Skill Surface Budget Module — single source of truth for which skills/agents
* are written to the runtime config dirs (ADR-0011).
*
* Background: every installed `gsd-*` skill costs eager system-prompt
* tokens because runtimes (Claude Code, opencode, etc.) enumerate
* skill descriptions in `<available_skills>` on every turn. With 86
* skills + 33 agents the floor is ~12k tokens per turn, which is a
* meaningful tax for local LLMs with 32K–128K context. Frontier
* models (Sonnet 4.6 / Opus 4.7 with 200K–1M ctx) don't feel it.
* Background: every installed `gsd-*` skill costs eager system-prompt tokens
* because runtimes (Claude Code, opencode, etc.) enumerate skill descriptions
* in `<available_skills>` on every turn. With 66 skills + 33 agents GSD alone
* consumes ~60% of the default 1%-of-context skill-listing budget, causing
* dropped skills when users stack multiple plugins (#3408).
*
* The `minimal` profile installs the main GSD loop only:
* new-project → discuss-phase → plan-phase → execute-phase
* plus `help` (discoverability) and `update` (upgrade path).
* Profile model: three named profiles replace the old minimal/full binary:
* - core — six skills covering the main project loop
* - standard — core + phase management and workspace skills
* - full — all skills (previous default, '*' sentinel)
* Profiles compose: --profile=core,audit resolves to union(closure(core), closure(audit)).
* Back-compat aliases: --minimal / --core-only both map to --profile=core.
*
* Users opt into minimal via `--minimal` on the install CLI.
* Default install (`full`) is unchanged — back-compat preserved.
* This module owns:
* - PROFILES map: named profile → base skill set (or '*' sentinel for full)
* - loadSkillsManifest: parse requires: frontmatter from commands/gsd/*.md
* - resolveProfile: compute transitive closure of profile base over manifest
* - stageSkillsForProfile / stageAgentsForProfile: filesystem staging
* - readActiveProfile / writeActiveProfile: .gsd-profile marker persistence
* - resolveEffectiveProfile: explicit flag > .gsd-profile marker > full
* - mostRestrictiveProfile: resolve multi-runtime disagreement to smallest set
*
* Companion module (Phase 2): get-shit-done/bin/lib/surface.cjs owns the
* runtime /gsd:surface command. It reuses stageSkillsForProfile and
* stageAgentsForProfile from this module for cluster-level enable/disable
* without reinstall, persisting state in <runtimeConfigDir>/.gsd-surface.json.
*
* Legacy back-compat exports (deprecated, kept for existing callers):
* - MINIMAL_SKILL_ALLOWLIST — derived from PROFILES.core
* - isMinimalMode(mode) — returns true for 'minimal'
* - shouldInstallSkill(name, mode|resolvedProfile) — overloaded
* - stageSkillsForMode(srcDir, mode) — wraps stageSkillsForProfile
*/
'use strict';
const fs = require('fs');
const path = require('path');
const os = require('os');
const MINIMAL_SKILL_ALLOWLIST = Object.freeze([
'new-project',
'discuss-phase',
'plan-phase',
'execute-phase',
'help',
'update',
]);
// ---------------------------------------------------------------------------
// Profile definitions
// ---------------------------------------------------------------------------
const MINIMAL_ALLOWLIST_SET = new Set(MINIMAL_SKILL_ALLOWLIST);
/**
* PROFILES maps profile name → base skill set (array) or '*' sentinel (full).
*
* The effective set for any profile is CLOSURE(base, requires: manifest).
* standard is a superset of core; full is the identity (all skills).
*
* Composition: --profile=core,audit resolves to union(closure(core), closure(audit)).
*/
const PROFILES = Object.freeze({
core: Object.freeze([
'new-project',
'discuss-phase',
'plan-phase',
'execute-phase',
'help',
'update',
]),
standard: Object.freeze([
// Core loop
'new-project',
'discuss-phase',
'plan-phase',
'execute-phase',
'help',
'update',
// Phase management (hot nodes from audit — required by 38+ skills)
'phase',
'review',
'config',
'progress',
// Workspace / state
'resume-work',
'pause-work',
'workspace',
]),
full: '*',
});
function isMinimalMode(mode) {
return mode === 'minimal';
// ---------------------------------------------------------------------------
// Manifest parsing
// ---------------------------------------------------------------------------
/**
* Parse the requires: field from YAML frontmatter.
* Handles: "requires: [a, b, c]" (flow style) and absent field.
* Returns string[] — empty array if no requires: field.
*
* No external YAML parser dependency — hand-parse the single line
* since GSD enforces flow-style arrays for requires:.
*
* @param {string} content full file content
* @returns {string[]}
*/
function parseRequires(content) {
const fmMatch = content.match(/^---\r?\n([\s\S]*?)\r?\n---/m);
if (!fmMatch) return [];
const fm = fmMatch[1];
const line = fm.match(/^requires:\s*(.+)$/m);
if (!line) return [];
const val = line[1].trim();
// Flow-style: [a, b, c]
if (val.startsWith('[') && val.endsWith(']')) {
const inner = val.slice(1, -1).trim();
if (!inner) return [];
return inner.split(',').map((s) => s.trim()).filter(Boolean);
}
// Single bare value (not currently used, but defensive)
return val ? [val] : [];
}
function shouldInstallSkill(skillBaseName, mode) {
if (!isMinimalMode(mode)) return true;
return MINIMAL_ALLOWLIST_SET.has(skillBaseName);
/**
* Parse agent references from a skill file's body text.
* Scans the full content for `gsd-<stem>` patterns that correspond to
* real agent files. Returns all unique `gsd-*` stems found in the body.
*
* The caller is responsible for filtering by which agents actually exist —
* this function returns all syntactically valid `gsd-*` matches.
*
* @param {string} content full file content
* @returns {string[]} deduplicated agent stems like ['gsd-planner', 'gsd-executor']
*/
function parseCallsAgents(content) {
// Match word-boundary gsd-<stem> patterns; stems are lowercase letters and hyphens.
// We use a regex that matches `gsd-` followed by one or more lowercase-alpha-or-hyphen chars.
// This catches `gsd-planner`, `gsd-plan-checker`, etc. in prose and code.
const matches = content.match(/\bgsd-[a-z][a-z-]*/g);
if (!matches) return [];
// Deduplicate
return [...new Set(matches)];
}
/**
* Load the requires: dependency graph from a commands/gsd directory.
* Also derives calls_agents for each skill by scanning the body text for
* `gsd-*` agent name references. Agent stems are stored under the special
* key `_calls_agents_<stem>` so they don't conflict with skill stems.
*
* @param {string} commandsDir absolute path to commands/gsd/
* @returns {Map<string, string[]>} stem → [required stem, ...] plus _calls_agents_<stem> entries
*/
function loadSkillsManifest(commandsDir) {
const manifest = new Map();
if (!fs.existsSync(commandsDir)) return manifest;
const entries = fs.readdirSync(commandsDir, { withFileTypes: true });
for (const entry of entries) {
if (!entry.isFile()) continue;
if (!entry.name.endsWith('.md')) continue;
const stem = entry.name.slice(0, -3);
try {
const content = fs.readFileSync(path.join(commandsDir, entry.name), 'utf8');
manifest.set(stem, parseRequires(content));
// Derive agent references from body text
const agentRefs = parseCallsAgents(content);
manifest.set(`_calls_agents_${stem}`, agentRefs);
} catch {
manifest.set(stem, []);
manifest.set(`_calls_agents_${stem}`, []);
}
}
return manifest;
}
// ---------------------------------------------------------------------------
// Profile resolution (transitive closure)
// ---------------------------------------------------------------------------
/**
* Compute the transitive closure of a set of skill stems over the manifest.
*
* @param {Iterable<string>} base initial set of stems
* @param {Map<string, string[]>} manifest skill → [required stems]
* @returns {Set<string>}
*/
function computeClosure(base, manifest) {
const closed = new Set(base);
const queue = [...closed];
while (queue.length > 0) {
const stem = queue.pop();
const deps = manifest.get(stem) || [];
for (const dep of deps) {
if (!closed.has(dep)) {
closed.add(dep);
queue.push(dep);
}
}
}
return closed;
}
/**
* Resolve a profile (or composed profiles) to a typed result object.
*
* @param {object} opts
* @param {string[]} [opts.modes=['full']] profile names to resolve and union
* @param {Map<string, string[]>} [opts.manifest] parsed requires: graph
* @param {object} [opts._profilesOverride] for testing — override PROFILES
* @returns {{ name: string, skills: Set<string>|'*', agents: Set<string> }}
*/
function resolveProfile({ modes, manifest, _profilesOverride } = {}) {
const profiles = _profilesOverride || PROFILES;
const activeModes = (modes && modes.length > 0) ? modes : ['full'];
const normalizedModes = activeModes
.flatMap((mode) => String(mode).split(','))
.map((mode) => mode.trim())
.filter(Boolean);
const modesToResolve = normalizedModes.length > 0 ? normalizedModes : ['full'];
// If any mode is 'full', the result is the full sentinel
if (modesToResolve.includes('full')) {
return { name: 'full', skills: '*', agents: new Set() };
}
const validModes = modesToResolve.filter((mode) => Object.prototype.hasOwnProperty.call(profiles, mode));
if (validModes.length === 0) {
// Invalid/corrupt marker fallback: avoid empty installs by defaulting to full.
return { name: 'full', skills: '*', agents: new Set() };
}
const man = manifest || new Map();
const unionSkills = new Set();
for (const mode of validModes) {
const base = profiles[mode];
if (base === '*') {
// This profile is full — sentinel short-circuit
return { name: 'full', skills: '*', agents: new Set() };
}
const closure = computeClosure(base, man);
for (const s of closure) unionSkills.add(s);
}
// Derive agents: union of all agent names referenced in the body text of
// every skill in unionSkills. Agent names are stored in the manifest under
// _calls_agents_<stem> keys (populated by loadSkillsManifest).
const unionAgents = new Set();
for (const skillStem of unionSkills) {
const agentRefs = man.get(`_calls_agents_${skillStem}`) || [];
for (const agentStem of agentRefs) {
unionAgents.add(agentStem);
}
}
const name = validModes.length === 1 ? validModes[0] : validModes.join(',');
return { name, skills: unionSkills, agents: unionAgents };
}
// ---------------------------------------------------------------------------
// Staging — skills
// ---------------------------------------------------------------------------
// Stage dirs created during this process — cleaned up on exit.
// 13 runtime dispatch sites in install.js can each call stageSkillsForMode,
// so accumulating them in a single set avoids leaks without forcing each
@@ -82,17 +297,224 @@ function ensureExitCleanup() {
}
/**
* Stage a filtered copy of the source commands/gsd directory when in
* minimal mode. All runtime-specific copy fns recurse a source dir,
* so filtering at the source point lets every copy fn stay unchanged
* (DRY: one filter, not 12).
* Stage a filtered copy of commands/gsd for a resolved profile.
* In full mode (skills === '*') returns srcDir unchanged (no-op).
*
* In full mode this is a no-op — the original srcDir is returned.
* @param {string} srcDir absolute path to commands/gsd
* @param {{ skills: Set<string>|'*' }} resolvedProfile
* @returns {string} path to staged dir (or srcDir for full)
*/
function stageSkillsForProfile(srcDir, resolvedProfile) {
if (resolvedProfile.skills === '*') return srcDir;
if (!fs.existsSync(srcDir)) return srcDir;
const stageDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-profile-skills-'));
try {
const entries = fs.readdirSync(srcDir, { withFileTypes: true });
for (const entry of entries) {
if (!entry.isFile()) continue;
if (!entry.name.endsWith('.md')) continue;
const stem = entry.name.slice(0, -3);
if (!resolvedProfile.skills.has(stem)) continue;
fs.copyFileSync(
path.join(srcDir, entry.name),
path.join(stageDir, entry.name),
);
}
} catch (err) {
try { fs.rmSync(stageDir, { recursive: true, force: true }); } catch {}
throw err;
}
STAGED_DIRS.add(stageDir);
ensureExitCleanup();
return stageDir;
}
/**
* Stage a filtered copy of the agents directory for a resolved profile.
* For 'full', returns srcAgentsDir unchanged.
* For tiered profiles, copies only agents whose full stem (e.g. 'gsd-planner')
* is in resolvedProfile.agents — which is populated by resolveProfile() from
* the _calls_agents_* entries in the manifest.
*
* Cleanup: the staged dir is automatically removed on process exit.
* If the copy loop throws mid-flight, the partially-populated dir is
* removed and the error re-raised, so callers never see an orphan.
* @param {string} srcAgentsDir absolute path to agents/
* @param {{ agents: Set<string>, skills: Set<string>|'*' }} resolvedProfile
* @returns {string} path to staged dir (or srcAgentsDir for full)
*/
function stageAgentsForProfile(srcAgentsDir, resolvedProfile) {
if (resolvedProfile.skills === '*') return srcAgentsDir;
if (!fs.existsSync(srcAgentsDir)) return srcAgentsDir;
const stageDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-profile-agents-'));
try {
if (resolvedProfile.agents instanceof Set && resolvedProfile.agents.size > 0) {
const entries = fs.readdirSync(srcAgentsDir, { withFileTypes: true });
for (const entry of entries) {
if (!entry.isFile()) continue;
if (!entry.name.endsWith('.md')) continue;
// Agent stem is the full filename without extension, e.g. "gsd-planner"
const stem = entry.name.slice(0, -3);
if (!resolvedProfile.agents.has(stem)) continue;
fs.copyFileSync(
path.join(srcAgentsDir, entry.name),
path.join(stageDir, entry.name),
);
}
}
// If agents is empty Set, we produce an empty stageDir (no agents for this profile)
} catch (err) {
try { fs.rmSync(stageDir, { recursive: true, force: true }); } catch {}
throw err;
}
STAGED_DIRS.add(stageDir);
ensureExitCleanup();
return stageDir;
}
// ---------------------------------------------------------------------------
// Profile marker persistence
// ---------------------------------------------------------------------------
const PROFILE_MARKER_NAME = '.gsd-profile';
/**
* Read the active profile from a runtime config directory.
*
* @param {string} runtimeConfigDir absolute path (e.g. ~/.claude/skills)
* @returns {string|null} profile name (e.g. 'core', 'standard', 'core,audit') or null
*/
function readActiveProfile(runtimeConfigDir) {
const markerPath = path.join(runtimeConfigDir, PROFILE_MARKER_NAME);
try {
const raw = fs.readFileSync(markerPath, 'utf8').trim();
if (!raw) return null;
// Validate that it looks like a profile name (alphanumeric + hyphens + commas)
if (!/^[a-z0-9,_-]+$/i.test(raw)) return null;
return raw;
} catch {
return null;
}
}
/**
* Persist the active profile to a runtime config directory.
*
* @param {string} runtimeConfigDir absolute path (e.g. ~/.claude/skills)
* @param {string} profileName e.g. 'core', 'standard', 'full'
*/
function writeActiveProfile(runtimeConfigDir, profileName) {
fs.mkdirSync(runtimeConfigDir, { recursive: true });
fs.writeFileSync(path.join(runtimeConfigDir, PROFILE_MARKER_NAME), profileName + '\n', 'utf8');
}
// ---------------------------------------------------------------------------
// Profile resolution helpers for install / update flows
// ---------------------------------------------------------------------------
/**
* Rank ordering for profiles (lower index = more restrictive / smaller skill set).
* Unknown profiles default to the permissive end (treated as 'full').
*/
const PROFILE_RANK = Object.freeze(['core', 'standard', 'full']);
/**
* Given an array of profile names (one per runtime), return the most-restrictive
* profile — i.e. the one with the smallest effective skill set.
*
* Ordering (most to least restrictive): core < standard < full.
* Composed profiles (e.g. 'core,audit') and unknown profiles are treated as
* 'full' for this comparison.
*
* @param {string[]} profileNames
* @returns {string}
*/
function mostRestrictiveProfile(profileNames) {
if (!profileNames || profileNames.length === 0) return 'full';
// Initialize with the least-restrictive rank (one past the end of PROFILE_RANK)
let bestRank = PROFILE_RANK.length;
let bestName = 'full';
for (const name of profileNames) {
const rank = PROFILE_RANK.indexOf(name);
// Unknown/composed profiles are treated as the permissive 'full' rank.
const effectiveRank = rank === -1 ? PROFILE_RANK.indexOf('full') : rank;
if (effectiveRank < bestRank) {
bestRank = effectiveRank;
bestName = rank === -1 ? 'full' : name;
}
}
return bestName;
}
/**
* Resolve the effective profile name for an install() run.
*
* Priority:
* 1. Explicit flag (requestedProfileName != null) → use it as-is.
* 2. Marker exists in targetDir and is not 'full' → use marker.
* 3. Else → 'full' (back-compat for fresh non-interactive installs).
*
* This is the single source-of-truth for the "which profile should this
* install() invocation use?" question. Extracted so it can be unit-tested
* independently of the bin/install.js megafile.
*
* @param {object} opts
* @param {string|null} opts.requestedProfileName explicit flag value (or null)
* @param {string} opts.targetDir runtime config dir (e.g. ~/.claude)
* @returns {string} profile name, e.g. 'core', 'standard', 'full'
*/
function resolveEffectiveProfile({ requestedProfileName, targetDir }) {
// 1. Explicit flag overrides everything
if (requestedProfileName != null) return requestedProfileName;
// 2. Marker-driven (gsd update path)
const marker = readActiveProfile(targetDir);
if (marker && marker !== 'full') return marker;
// 3. Default
return 'full';
}
// ---------------------------------------------------------------------------
// Back-compat shims (deprecated — use profile-based API instead)
// ---------------------------------------------------------------------------
/**
* @deprecated Use PROFILES.core instead.
* Preserved for callers in install.js and existing tests.
*/
const MINIMAL_SKILL_ALLOWLIST = Object.freeze([...PROFILES.core]);
const MINIMAL_ALLOWLIST_SET = new Set(MINIMAL_SKILL_ALLOWLIST);
/**
* @deprecated Use resolveProfile({ modes: ['core'] }) instead.
*/
function isMinimalMode(mode) {
return mode === 'minimal' || mode === 'core-only';
}
/**
* Overloaded for back-compat.
* - If resolvedProfileOrMode is a string: legacy mode check (full/minimal)
* - If resolvedProfileOrMode is an object with .skills: new profile API
*
* @deprecated String-mode form; use resolvedProfile object form instead.
*/
function shouldInstallSkill(skillBaseName, resolvedProfileOrMode) {
if (typeof resolvedProfileOrMode === 'object' && resolvedProfileOrMode !== null) {
const { skills } = resolvedProfileOrMode;
if (skills === '*') return true;
return skills instanceof Set && skills.has(skillBaseName);
}
// Legacy string mode
const mode = resolvedProfileOrMode;
if (!isMinimalMode(mode)) return true;
return MINIMAL_ALLOWLIST_SET.has(skillBaseName);
}
/**
* Stage a filtered copy of the source commands/gsd directory.
* Back-compat wrapper: maps 'minimal' → core profile, 'full' → full.
*
* @deprecated Use stageSkillsForProfile with a resolved profile instead.
* @param {string} srcDir absolute path to commands/gsd
* @param {string} mode 'full' | 'minimal'
* @returns {string} path to use (original or staged tmp)
@@ -123,10 +545,27 @@ function stageSkillsForMode(srcDir, mode) {
return stageDir;
}
// ---------------------------------------------------------------------------
// Exports
// ---------------------------------------------------------------------------
module.exports = {
// New profile API (ADR-0011)
PROFILES,
PROFILE_RANK,
loadSkillsManifest,
resolveProfile,
resolveEffectiveProfile,
mostRestrictiveProfile,
stageSkillsForProfile,
stageAgentsForProfile,
readActiveProfile,
writeActiveProfile,
// Shared internals
cleanupStagedSkills,
// Back-compat / deprecated
MINIMAL_SKILL_ALLOWLIST,
isMinimalMode,
shouldInstallSkill,
stageSkillsForMode,
cleanupStagedSkills,
};

View File

@@ -0,0 +1,401 @@
'use strict';
/**
* Runtime surface module — ADR-0011 Phase 2 (Option B).
*
* Manages the runtime enable/disable surface state (the `.gsd-surface.json` marker in
* each runtime's skills dir) independently of the install-time profile marker
* (`.gsd-profile`). Runtime config locations are resolved by callers.
*
* Effective skill set = base profile ∪ explicitAdds − disabledClusters − explicitRemoves,
* then transitively closed via the manifest.
*
* Exports:
* readSurface(runtimeConfigDir)
* writeSurface(runtimeConfigDir, surfaceState)
* resolveSurface(runtimeConfigDir, manifest, clusterMap)
* applySurface(runtimeConfigDir, commandsDir, agentsDir, manifest, clusterMap)
* listSurface(runtimeConfigDir, manifest, clusterMap)
*/
const fs = require('fs');
const path = require('path');
const os = require('os');
const {
readActiveProfile,
resolveProfile,
stageSkillsForProfile,
stageAgentsForProfile,
loadSkillsManifest,
PROFILES,
} = require('./install-profiles.cjs');
const { CLUSTERS, allClusteredSkills } = require('./clusters.cjs');
const SURFACE_FILE_NAME = '.gsd-surface.json';
// ---------------------------------------------------------------------------
// State IO
// ---------------------------------------------------------------------------
/**
* @typedef {Object} SurfaceState
* @property {string} baseProfile
* @property {string[]} disabledClusters
* @property {string[]} explicitAdds
* @property {string[]} explicitRemoves
*/
/**
* Read the surface state from a runtime config directory.
*
* @param {string} runtimeConfigDir
* @returns {SurfaceState|null} null if file missing or corrupt
*/
function readSurface(runtimeConfigDir) {
const filePath = path.join(runtimeConfigDir, SURFACE_FILE_NAME);
try {
const raw = fs.readFileSync(filePath, 'utf8');
const parsed = JSON.parse(raw);
// Structural validation — must have these fields with expected types
if (typeof parsed !== 'object' || parsed === null) return null;
if (typeof parsed.baseProfile !== 'string') return null;
if (!Array.isArray(parsed.disabledClusters)) return null;
if (!Array.isArray(parsed.explicitAdds)) return null;
if (!Array.isArray(parsed.explicitRemoves)) return null;
return {
baseProfile: parsed.baseProfile,
disabledClusters: parsed.disabledClusters,
explicitAdds: parsed.explicitAdds,
explicitRemoves: parsed.explicitRemoves,
};
} catch {
return null;
}
}
/**
* Write the surface state atomically (write to tmp then rename).
*
* @param {string} runtimeConfigDir
* @param {SurfaceState} surfaceState
*/
function writeSurface(runtimeConfigDir, surfaceState) {
fs.mkdirSync(runtimeConfigDir, { recursive: true });
const finalPath = path.join(runtimeConfigDir, SURFACE_FILE_NAME);
const tmpPath = finalPath + '.tmp.' + process.pid;
fs.writeFileSync(tmpPath, JSON.stringify(surfaceState, null, 2) + '\n', 'utf8');
fs.renameSync(tmpPath, finalPath);
}
// ---------------------------------------------------------------------------
// Resolution
// ---------------------------------------------------------------------------
/**
* Expand cluster names to skill stems using the provided clusterMap.
*
* @param {string[]} clusterNames
* @param {Object} clusterMap CLUSTERS or override
* @returns {Set<string>}
*/
function clustersToSkills(clusterNames, clusterMap) {
const result = new Set();
for (const name of clusterNames) {
const members = clusterMap[name];
if (members) {
for (const s of members) result.add(s);
}
}
return result;
}
/**
* Resolve the effective surface to a typed profile-like object.
* Shape: { name, skills: Set<string>|'*', agents: Set<string> }
*
* Resolution order:
* 1. Start with base profile resolved via resolveProfile()
* 2. Remove skills in disabled clusters
* 3. Add explicitAdds (and their transitive closure)
* 4. Remove explicitRemoves (only the stem itself, no cascade)
*
* @param {string} runtimeConfigDir
* @param {Map<string, string[]>} manifest
* @param {Object} [clusterMap] defaults to CLUSTERS
* @returns {{ name: string, skills: Set<string>, agents: Set<string> }}
*/
function resolveSurface(runtimeConfigDir, manifest, clusterMap) {
const cm = clusterMap || CLUSTERS;
const surface = readSurface(runtimeConfigDir);
// Determine base profile name: from surface state or from .gsd-profile marker
const baseProfileName = (surface && surface.baseProfile)
? surface.baseProfile
: (readActiveProfile(runtimeConfigDir) || 'full');
// Resolve base profile
const baseResolved = resolveProfile({
modes: baseProfileName.split(',').map(s => s.trim()),
manifest,
});
// If full, we need to enumerate all skills from the manifest
let skills;
if (baseResolved.skills === '*') {
// Materialize all skill stems from manifest
skills = new Set();
for (const [key] of manifest) {
if (!key.startsWith('_calls_agents_')) skills.add(key);
}
} else {
skills = new Set(baseResolved.skills);
}
if (surface) {
// Step 2: remove disabled cluster members
const disabledSkills = clustersToSkills(surface.disabledClusters, cm);
for (const s of disabledSkills) skills.delete(s);
// Step 3: add explicitAdds with transitive closure
if (surface.explicitAdds.length > 0) {
const addSet = new Set(surface.explicitAdds);
// Compute closure of adds
const queue = [...addSet];
const visited = new Set(addSet);
while (queue.length > 0) {
const stem = queue.pop();
const deps = manifest.get(stem) || [];
for (const dep of deps) {
if (!visited.has(dep)) {
visited.add(dep);
queue.push(dep);
}
}
}
for (const s of visited) skills.add(s);
}
// Step 4: remove explicitRemoves (stem only, no cascade)
for (const s of surface.explicitRemoves) {
skills.delete(s);
}
}
// Derive agents from skills
const agents = new Set();
for (const skillStem of skills) {
const agentRefs = manifest.get(`_calls_agents_${skillStem}`) || [];
for (const agentStem of agentRefs) agents.add(agentStem);
}
const name = surface ? `surface:${surface.baseProfile}` : `profile:${baseProfileName}`;
return { name, skills, agents };
}
// ---------------------------------------------------------------------------
// Apply
// ---------------------------------------------------------------------------
/**
* Re-stage the active surface to commandsDir and agentsDir in-place.
* Only touches files matching `gsd-` prefix or `*.md` in commandsDir.
* Never touches non-`gsd-*` files.
*
* Steps:
* 1. Resolve surface → active skill/agent sets
* 2. Stage to temp dirs via stageSkillsForProfile / stageAgentsForProfile
* 3. Find the install source (where skill files live)
* 4. Sync: copy missing, delete superseded (gsd-only)
*
* @param {string} runtimeConfigDir
* @param {string} commandsDir runtime commands/gsd dir (resolved per-runtime by callers)
* @param {string} agentsDir runtime agents dir (resolved per-runtime by callers)
* @param {Map<string, string[]>} manifest
* @param {Object} [clusterMap]
*/
function applySurface(runtimeConfigDir, commandsDir, agentsDir, manifest, clusterMap) {
const resolved = resolveSurface(runtimeConfigDir, manifest, clusterMap);
// Find install source
const srcCommandsDir = _findInstallSource(runtimeConfigDir);
// Stage skills
const stagedSkills = stageSkillsForProfile(srcCommandsDir, resolved);
// Sync commandsDir from stagedSkills
_syncGsdDir(stagedSkills, commandsDir, 'commands');
// Stage and sync agents
if (agentsDir && fs.existsSync(agentsDir)) {
const srcAgentsDir = _findAgentsSource(runtimeConfigDir);
if (srcAgentsDir) {
const stagedAgents = stageAgentsForProfile(srcAgentsDir, resolved);
_syncGsdDir(stagedAgents, agentsDir, 'agents');
}
}
}
/**
* Sync destination directory from staged source.
* Adds files present in staged but missing in dest.
* Removes gsd-prefixed .md files in dest not present in staged.
* Never touches non-gsd files.
*
* @param {string} stagedDir source (staged temp dir or original)
* @param {string} destDir runtime destination
* @param {'commands'|'agents'} context
*/
function _syncGsdDir(stagedDir, destDir, context) {
if (!fs.existsSync(stagedDir)) return;
fs.mkdirSync(destDir, { recursive: true });
const stagedFiles = new Set(
fs.readdirSync(stagedDir).filter(f => f.endsWith('.md'))
);
// Copy missing files from staged to dest
for (const file of stagedFiles) {
const destFile = path.join(destDir, file);
if (!fs.existsSync(destFile)) {
fs.copyFileSync(path.join(stagedDir, file), destFile);
} else {
// Overwrite to ensure content is current
fs.copyFileSync(path.join(stagedDir, file), destFile);
}
}
// Remove gsd-only files from dest that aren't in staged set
// For commands dir: all .md files are gsd skills
// For agents dir: only gsd-* files
const destEntries = fs.readdirSync(destDir).filter(f => f.endsWith('.md'));
for (const file of destEntries) {
if (context === 'agents' && !file.startsWith('gsd-')) continue;
if (!stagedFiles.has(file)) {
try { fs.unlinkSync(path.join(destDir, file)); } catch {}
}
}
}
/**
* Find the install source commands/gsd directory.
* Checks the runtime's `.gsd-source` marker (sibling of the surface state file),
* then walks up from __dirname to find the installed package source.
*
* @param {string} runtimeConfigDir
* @returns {string} path to install source commands/gsd
*/
function _findInstallSource(runtimeConfigDir) {
// Check for .gsd-source marker
const sourceMarker = path.join(runtimeConfigDir, '.gsd-source');
if (fs.existsSync(sourceMarker)) {
try {
const src = fs.readFileSync(sourceMarker, 'utf8').trim();
if (src && fs.existsSync(src)) return src;
} catch {}
}
// Walk up from this module's dir to find commands/gsd
let dir = __dirname;
for (let i = 0; i < 6; i++) {
const candidate = path.join(dir, 'commands', 'gsd');
if (fs.existsSync(candidate)) return candidate;
const parent = path.dirname(dir);
if (parent === dir) break;
dir = parent;
}
// Fallback: the runtimeConfigDir itself
return path.join(runtimeConfigDir, '..', 'commands', 'gsd');
}
/**
* Find the install source agents directory.
*
* @param {string} runtimeConfigDir
* @returns {string|null}
*/
function _findAgentsSource(runtimeConfigDir) {
// Prefer .gsd-source sibling marker (commands/gsd) and derive agents from it.
const sourceMarker = path.join(runtimeConfigDir, '.gsd-source');
if (fs.existsSync(sourceMarker)) {
try {
const commandsSrc = fs.readFileSync(sourceMarker, 'utf8').trim();
if (commandsSrc && fs.existsSync(commandsSrc)) {
const commandsParent = path.dirname(commandsSrc); // .../commands
const candidate = path.resolve(commandsParent, '..', 'agents');
if (fs.existsSync(candidate)) return candidate;
}
} catch {}
}
let dir = __dirname;
for (let i = 0; i < 6; i++) {
const candidate = path.join(dir, 'agents');
if (fs.existsSync(candidate)) return candidate;
const parent = path.dirname(dir);
if (parent === dir) break;
dir = parent;
}
return null;
}
// ---------------------------------------------------------------------------
// List
// ---------------------------------------------------------------------------
/**
* List the currently enabled and disabled skills with token cost.
*
* Token cost = sum of description lengths ÷ 4 (mirrors audit script).
* Descriptions are read from the installed commandsDir skill files.
*
* @param {string} runtimeConfigDir
* @param {Map<string, string[]>} manifest
* @param {Object} [clusterMap]
* @returns {{ enabled: string[], disabled: string[], tokenCost: number }}
*/
function listSurface(runtimeConfigDir, manifest, clusterMap) {
const resolved = resolveSurface(runtimeConfigDir, manifest, clusterMap);
// All known stems from manifest (exclude _calls_agents_ meta keys)
const allStems = [];
for (const [key] of manifest) {
if (!key.startsWith('_calls_agents_')) allStems.push(key);
}
const enabledSet = resolved.skills instanceof Set ? resolved.skills : new Set(allStems);
const enabled = allStems.filter(s => enabledSet.has(s)).sort();
const disabled = allStems.filter(s => !enabledSet.has(s)).sort();
// Compute token cost by reading descriptions from the install source
const srcCommandsDir = _findInstallSource(runtimeConfigDir);
let tokenCost = 0;
for (const stem of enabled) {
const filePath = path.join(srcCommandsDir, `${stem}.md`);
try {
const content = fs.readFileSync(filePath, 'utf8');
const descMatch = content.match(/^description:\s*(.+)$/m);
if (descMatch) {
tokenCost += Math.ceil(descMatch[1].trim().length / 4);
}
} catch {}
}
return { enabled, disabled, tokenCost };
}
// ---------------------------------------------------------------------------
// Exports
// ---------------------------------------------------------------------------
module.exports = {
readSurface,
writeSurface,
resolveSurface,
applySurface,
listSurface,
// Exported for testing
_findInstallSource,
_syncGsdDir,
};

View File

@@ -522,6 +522,19 @@ Configure GSD beyond the basic settings: model profile, advanced tuning, and thi
Usage: `/gsd:config --profile budget`
**`/gsd:surface [list|status|profile <name>|disable <cluster>|enable <cluster>|reset]`**
Toggle which skills are surfaced — apply a profile, list, or disable a cluster without reinstall.
- `list` / `status` — Show enabled and disabled clusters and skills with token cost
- `profile <name>` — Switch to a named base profile (`core`, `standard`, `full`)
- `disable <cluster>` — Remove a cluster from the active surface
- `enable <cluster>` — Add a cluster back to the active surface
- `reset` — Delete the surface delta and return to the install-time profile
Usage: `/gsd:surface list`
Usage: `/gsd:surface profile standard`
Usage: `/gsd:surface disable utility`
### Utility Commands
**`/gsd:cleanup`**

View File

@@ -59,9 +59,10 @@
"build:sdk": "cd sdk && npm ci && npm run build",
"check:alias-drift": "cd sdk && npm run check:alias-drift",
"prepublishOnly": "npm run build:hooks && npm run build:sdk",
"pretest": "npm run build:sdk",
"pretest": "npm run build:sdk && npm run lint:skill-deps",
"pretest:coverage": "npm run build:sdk",
"lint:descriptions": "node scripts/lint-descriptions.cjs",
"lint:skill-deps": "node scripts/lint-skill-deps.cjs",
"lint:tests": "node scripts/lint-no-source-grep.cjs",
"lint:changeset": "node scripts/changeset/lint.cjs",
"changeset": "node scripts/changeset/new.cjs",

180
scripts/lint-skill-deps.cjs Normal file
View File

@@ -0,0 +1,180 @@
#!/usr/bin/env node
/**
* lint-skill-deps.cjs
*
* Two checks:
*
* a) Frontmatter to body consistency:
* For each commands/gsd/*.md, parse requires: and walk the body for
* references to other GSD skills (pattern: /gsd:<stem> or gsd:<stem>).
* Fail if a skill body references a skill not listed in requires:.
*
* b) Profile closure satisfaction:
* Load PROFILES from install-profiles.cjs. For each non-full profile,
* compute closure(profile.base) using the manifest. If any skill in
* the closure references a skill NOT in the closure, fail.
*
* Usage:
* node scripts/lint-skill-deps.cjs # scans commands/gsd/
* node scripts/lint-skill-deps.cjs --dir <path> # scan a custom dir (testing)
*
* Exits 0 if all pass; exits 1 if any violation.
*/
'use strict';
const fs = require('fs');
const path = require('path');
const PROFILES_MODULE = path.join(__dirname, '..', 'get-shit-done', 'bin', 'lib', 'install-profiles.cjs');
const { PROFILES, loadSkillsManifest, resolveProfile } = require(PROFILES_MODULE);
// ---------------------------------------------------------------------------
// Argument parsing
// ---------------------------------------------------------------------------
let commandsDir = path.join(__dirname, '..', 'commands', 'gsd');
const args = process.argv.slice(2);
for (let i = 0; i < args.length; i++) {
if (args[i] === '--dir' && args[i + 1]) {
commandsDir = args[i + 1];
i++;
}
}
// ---------------------------------------------------------------------------
// Reference extraction from skill body
// ---------------------------------------------------------------------------
function extractBodyReferences(body) {
const refs = new Set();
const re = /(?:\/?)gsd:([a-z0-9_-]+)/g;
let m;
while ((m = re.exec(body)) !== null) {
refs.add(m[1]);
}
return refs;
}
function extractBody(content) {
const fmEnd = content.match(/^---\r?\n[\s\S]*?\r?\n---\r?\n/m);
if (!fmEnd) return content;
return content.slice(fmEnd[0].length);
}
// ---------------------------------------------------------------------------
// Check a: frontmatter to body consistency
// ---------------------------------------------------------------------------
function checkFrontmatterBodyConsistency(manifest, allStems) {
const violations = [];
for (const [stem, declared] of manifest) {
if (stem.startsWith('_calls_agents_')) continue;
const filePath = path.join(commandsDir, stem + '.md');
let content;
try {
content = fs.readFileSync(filePath, 'utf8');
} catch {
continue;
}
const body = extractBody(content);
const referenced = extractBodyReferences(body);
const declaredSet = new Set(declared);
for (const ref of referenced) {
if (ref === stem) continue;
if (!allStems.has(ref)) {
violations.push({
stem,
filePath,
undeclared: ref,
message: 'body references unknown skill gsd:' + ref,
});
continue;
}
if (!declaredSet.has(ref)) {
violations.push({
stem,
filePath,
undeclared: ref,
message: 'body references gsd:' + ref + ' but requires: does not list it',
});
}
}
}
return violations;
}
// ---------------------------------------------------------------------------
// Check b: profile closure satisfaction
// ---------------------------------------------------------------------------
function checkProfileClosure(manifest) {
const violations = [];
for (const profileName of Object.keys(PROFILES)) {
const base = PROFILES[profileName];
if (base === '*') continue;
const resolved = resolveProfile({ modes: [profileName], manifest });
if (resolved.skills === '*') continue;
const closure = resolved.skills;
for (const stem of closure) {
const deps = manifest.get(stem) || [];
for (const dep of deps) {
if (!closure.has(dep)) {
violations.push({
profile: profileName,
stem,
missingDep: dep,
message: 'profile "' + profileName + '" includes "' + stem + '" which requires "' + dep + '", but "' + dep + '" is not in the closure',
});
}
}
}
}
return violations;
}
// ---------------------------------------------------------------------------
// Main
// ---------------------------------------------------------------------------
const manifest = loadSkillsManifest(commandsDir);
const allStems = new Set(
[...manifest.keys()].filter((k) => !k.startsWith('_calls_agents_'))
);
const consistencyViolations = checkFrontmatterBodyConsistency(manifest, allStems);
const closureViolations = checkProfileClosure(manifest);
const totalViolations = consistencyViolations.length + closureViolations.length;
if (totalViolations === 0) {
const checked = manifest.size;
process.stdout.write('ok lint-skill-deps: ' + checked + ' skill(s) checked, 0 violations\n');
process.exit(0);
}
process.stderr.write('\nERROR lint-skill-deps: ' + totalViolations + ' violation(s) found\n\n');
if (consistencyViolations.length > 0) {
process.stderr.write('Frontmatter to body consistency violations (' + consistencyViolations.length + '):\n\n');
for (const v of consistencyViolations) {
process.stderr.write(' ' + v.filePath + '\n');
process.stderr.write(' ' + v.message + '\n\n');
}
process.stderr.write('Fix: add missing deps to requires: in the skill frontmatter.\n\n');
}
if (closureViolations.length > 0) {
process.stderr.write('Profile closure violations (' + closureViolations.length + '):\n\n');
for (const v of closureViolations) {
process.stderr.write(' ' + v.message + '\n');
}
process.stderr.write('\nFix: add the missing skills to the profile base set in install-profiles.cjs.\n\n');
}
process.exit(1);

View File

@@ -0,0 +1,87 @@
'use strict';
/**
* Back-compat regression: --minimal still produces the same file set as before
* the profile model was introduced (modulo the phase-inclusion fix).
*
* Also verifies --profile=core is equivalent to --minimal.
*/
const { test, describe } = require('node:test');
const assert = require('node:assert/strict');
const fs = require('fs');
const path = require('path');
const os = require('os');
const { spawnSync } = require('child_process');
const {
MINIMAL_SKILL_ALLOWLIST,
PROFILES,
} = require('../get-shit-done/bin/lib/install-profiles.cjs');
const INSTALL_SCRIPT = path.join(__dirname, '..', 'bin', 'install.js');
const MANIFEST_NAME = 'gsd-file-manifest.json';
describe('install-minimal-backcompat: PROFILES.core matches MINIMAL_SKILL_ALLOWLIST', () => {
test('PROFILES.core contains the same 6 skills as MINIMAL_SKILL_ALLOWLIST', () => {
assert.deepStrictEqual(
[...PROFILES.core].sort(),
[...MINIMAL_SKILL_ALLOWLIST].sort(),
'PROFILES.core must equal MINIMAL_SKILL_ALLOWLIST for back-compat',
);
});
});
describe('install-minimal-backcompat: --minimal and --profile=core produce the same manifest skill count', () => {
function installAndGetManifest(extraArgs) {
const targetDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-backcompat-'));
try {
spawnSync(
process.execPath,
[INSTALL_SCRIPT, '--claude', '--global', '--config-dir', targetDir, ...extraArgs],
{ encoding: 'utf8' },
);
const manifestPath = path.join(targetDir, MANIFEST_NAME);
if (!fs.existsSync(manifestPath)) return { mode: null, skillCount: 0, profileMarker: null };
const m = JSON.parse(fs.readFileSync(manifestPath, 'utf8'));
const skillCount = new Set(
Object.keys(m.files || {})
.filter((k) => k.startsWith('skills/'))
.map((k) => k.split('/')[1]),
).size;
// Read marker
const markerPath = path.join(targetDir, '.gsd-profile');
const profileMarker = fs.existsSync(markerPath)
? fs.readFileSync(markerPath, 'utf8').trim()
: null;
return { mode: m.mode, skillCount, profileMarker };
} finally {
fs.rmSync(targetDir, { recursive: true, force: true });
}
}
test('--minimal produces mode "minimal" with exactly 6 skills', () => {
const r = installAndGetManifest(['--minimal']);
assert.strictEqual(r.mode, 'minimal');
assert.strictEqual(r.skillCount, 6);
});
test('--minimal writes .gsd-profile marker with "core"', () => {
const r = installAndGetManifest(['--minimal']);
assert.strictEqual(r.profileMarker, 'core', '--minimal should write profile marker "core"');
});
test('default (no flags) writes .gsd-profile marker with "full"', () => {
const r = installAndGetManifest([]);
assert.strictEqual(r.profileMarker, 'full', 'default install should write profile marker "full"');
});
test('--profile=core writes .gsd-profile marker with "core"', () => {
const r = installAndGetManifest(['--profile=core']);
assert.strictEqual(r.profileMarker, 'core', '--profile=core should write profile marker "core"');
});
test('--profile=standard writes .gsd-profile marker with "standard"', () => {
const r = installAndGetManifest(['--profile=standard']);
assert.strictEqual(r.profileMarker, 'standard', '--profile=standard should write profile marker "standard"');
});
});

View File

@@ -0,0 +1,121 @@
'use strict';
/**
* Tests for loadSkillsManifest — parses requires: frontmatter from commands/gsd/*.md
* and returns a Map<stem, string[]>.
*/
const { test, describe } = require('node:test');
const assert = require('node:assert/strict');
const fs = require('fs');
const path = require('path');
const os = require('os');
const {
loadSkillsManifest,
} = require('../get-shit-done/bin/lib/install-profiles.cjs');
function createFixtureDir() {
const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-manifest-fixture-'));
return tmp;
}
function writeSkill(dir, stem, frontmatter) {
const content = `---\n${frontmatter}\n---\n\n# body\n`;
fs.writeFileSync(path.join(dir, `${stem}.md`), content);
}
describe('loadSkillsManifest', () => {
test('returns a Map', () => {
const dir = createFixtureDir();
try {
const m = loadSkillsManifest(dir);
assert.ok(m instanceof Map, 'should return a Map');
} finally {
fs.rmSync(dir, { recursive: true, force: true });
}
});
test('skill with no requires: frontmatter maps to empty array', () => {
const dir = createFixtureDir();
try {
writeSkill(dir, 'help', 'name: gsd:help\ndescription: Help text');
const m = loadSkillsManifest(dir);
assert.ok(m.has('help'), 'help should be in manifest');
assert.deepStrictEqual(m.get('help'), []);
} finally {
fs.rmSync(dir, { recursive: true, force: true });
}
});
test('skill with requires: single value maps to array of one', () => {
const dir = createFixtureDir();
try {
writeSkill(dir, 'add-tests', 'name: gsd:add-tests\ndescription: Add tests\nrequires: [phase]');
const m = loadSkillsManifest(dir);
assert.ok(m.has('add-tests'));
assert.deepStrictEqual(m.get('add-tests'), ['phase']);
} finally {
fs.rmSync(dir, { recursive: true, force: true });
}
});
test('skill with requires: multiple values maps to full array', () => {
const dir = createFixtureDir();
try {
writeSkill(dir, 'plan-phase', 'name: gsd:plan-phase\ndescription: Plan\nrequires: [discuss-phase, phase, review, update]');
const m = loadSkillsManifest(dir);
assert.deepStrictEqual(m.get('plan-phase'), ['discuss-phase', 'phase', 'review', 'update']);
} finally {
fs.rmSync(dir, { recursive: true, force: true });
}
});
test('ignores non-.md files in the dir', () => {
const dir = createFixtureDir();
try {
writeSkill(dir, 'help', 'name: gsd:help\ndescription: Help');
fs.writeFileSync(path.join(dir, 'README.txt'), 'not a skill');
fs.writeFileSync(path.join(dir, 'notes.json'), '{}');
const m = loadSkillsManifest(dir);
assert.ok(m.has('help'));
assert.ok(!m.has('README'));
assert.ok(!m.has('notes'));
} finally {
fs.rmSync(dir, { recursive: true, force: true });
}
});
test('empty dir returns empty Map', () => {
const dir = createFixtureDir();
try {
const m = loadSkillsManifest(dir);
assert.strictEqual(m.size, 0);
} finally {
fs.rmSync(dir, { recursive: true, force: true });
}
});
test('skill with requires: empty array maps to empty array', () => {
const dir = createFixtureDir();
try {
writeSkill(dir, 'explore', 'name: gsd:explore\ndescription: Explore\nrequires: []');
const m = loadSkillsManifest(dir);
assert.deepStrictEqual(m.get('explore'), []);
} finally {
fs.rmSync(dir, { recursive: true, force: true });
}
});
test('loads real commands/gsd/ directory without throwing', () => {
const realDir = path.join(__dirname, '..', 'commands', 'gsd');
const m = loadSkillsManifest(realDir);
assert.ok(m.size >= 60, `expected >=60 skills, got ${m.size}`);
// discuss-phase requires [config, phase]
const depsDP = m.get('discuss-phase');
assert.ok(Array.isArray(depsDP), 'discuss-phase should be in manifest');
assert.ok(depsDP.includes('phase'), 'discuss-phase should require phase');
assert.ok(depsDP.includes('config'), 'discuss-phase should require config');
// help has no requires
assert.deepStrictEqual(m.get('help'), []);
});
});

View File

@@ -0,0 +1,118 @@
'use strict';
/**
* Tests for readActiveProfile / writeActiveProfile marker persistence.
*/
const { test, describe } = require('node:test');
const assert = require('node:assert/strict');
const fs = require('fs');
const path = require('path');
const os = require('os');
const {
readActiveProfile,
writeActiveProfile,
} = require('../get-shit-done/bin/lib/install-profiles.cjs');
describe('readActiveProfile / writeActiveProfile', () => {
test('write then read round-trips the profile name', () => {
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-marker-'));
try {
writeActiveProfile(dir, 'standard');
assert.strictEqual(readActiveProfile(dir), 'standard');
} finally {
fs.rmSync(dir, { recursive: true, force: true });
}
});
test('round-trips "core" profile', () => {
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-marker-'));
try {
writeActiveProfile(dir, 'core');
assert.strictEqual(readActiveProfile(dir), 'core');
} finally {
fs.rmSync(dir, { recursive: true, force: true });
}
});
test('round-trips composed profiles "core,audit"', () => {
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-marker-'));
try {
writeActiveProfile(dir, 'core,audit');
assert.strictEqual(readActiveProfile(dir), 'core,audit');
} finally {
fs.rmSync(dir, { recursive: true, force: true });
}
});
test('round-trips "full"', () => {
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-marker-'));
try {
writeActiveProfile(dir, 'full');
assert.strictEqual(readActiveProfile(dir), 'full');
} finally {
fs.rmSync(dir, { recursive: true, force: true });
}
});
test('missing marker file returns null (not throws)', () => {
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-marker-'));
try {
const result = readActiveProfile(dir);
assert.strictEqual(result, null);
} finally {
fs.rmSync(dir, { recursive: true, force: true });
}
});
test('non-existent directory returns null (not throws)', () => {
const ghost = path.join(os.tmpdir(), 'gsd-marker-no-exist-' + Date.now());
const result = readActiveProfile(ghost);
assert.strictEqual(result, null);
});
test('corrupt marker content (invalid chars) returns null', () => {
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-marker-'));
try {
fs.writeFileSync(path.join(dir, '.gsd-profile'), 'profile with spaces and !!!\n');
const result = readActiveProfile(dir);
assert.strictEqual(result, null);
} finally {
fs.rmSync(dir, { recursive: true, force: true });
}
});
test('empty marker file returns null', () => {
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-marker-'));
try {
fs.writeFileSync(path.join(dir, '.gsd-profile'), '');
const result = readActiveProfile(dir);
assert.strictEqual(result, null);
} finally {
fs.rmSync(dir, { recursive: true, force: true });
}
});
test('writeActiveProfile creates the directory if it does not exist', () => {
const base = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-marker-base-'));
const nested = path.join(base, 'skills', '.claude');
try {
writeActiveProfile(nested, 'standard');
assert.ok(fs.existsSync(nested), 'directory should be created');
assert.strictEqual(readActiveProfile(nested), 'standard');
} finally {
fs.rmSync(base, { recursive: true, force: true });
}
});
test('overwrites a previously written profile', () => {
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-marker-'));
try {
writeActiveProfile(dir, 'core');
writeActiveProfile(dir, 'full');
assert.strictEqual(readActiveProfile(dir), 'full');
} finally {
fs.rmSync(dir, { recursive: true, force: true });
}
});
});

View File

@@ -0,0 +1,197 @@
'use strict';
/**
* Tests for resolveProfile — computes transitive closure over the requires: graph.
*/
const { test, describe } = require('node:test');
const assert = require('node:assert/strict');
const path = require('path');
const {
PROFILES,
resolveProfile,
loadSkillsManifest,
} = require('../get-shit-done/bin/lib/install-profiles.cjs');
const REAL_COMMANDS_DIR = path.join(__dirname, '..', 'commands', 'gsd');
describe('PROFILES map', () => {
test('PROFILES is frozen', () => {
assert.ok(Object.isFrozen(PROFILES));
});
test('PROFILES has core, standard, full keys', () => {
assert.ok('core' in PROFILES, 'PROFILES.core missing');
assert.ok('standard' in PROFILES, 'PROFILES.standard missing');
assert.ok('full' in PROFILES, 'PROFILES.full missing');
});
test('PROFILES.core contains the 6 main-loop skills', () => {
const core = PROFILES.core;
assert.ok(Array.isArray(core), 'core should be an array');
const sorted = [...core].sort();
assert.deepStrictEqual(sorted, [
'discuss-phase',
'execute-phase',
'help',
'new-project',
'plan-phase',
'update',
]);
});
test('PROFILES.full is the sentinel "*"', () => {
assert.strictEqual(PROFILES.full, '*');
});
test('PROFILES.standard contains at least the core skills', () => {
const core = new Set(PROFILES.core);
const standard = PROFILES.standard;
assert.ok(Array.isArray(standard), 'standard should be an array');
for (const s of core) {
assert.ok(standard.includes(s), `standard should include core skill: ${s}`);
}
});
test('PROFILES.standard has at least 10 skills', () => {
assert.ok(PROFILES.standard.length >= 10, `standard should have >=10 skills, got ${PROFILES.standard.length}`);
});
});
describe('resolveProfile', () => {
test('defaults to full when called with no args', () => {
const manifest = loadSkillsManifest(REAL_COMMANDS_DIR);
const result = resolveProfile({ manifest });
assert.strictEqual(result.name, 'full');
assert.strictEqual(result.skills, '*');
});
test('resolves core profile — returns 6+ skills (closure adds phase)', () => {
const manifest = loadSkillsManifest(REAL_COMMANDS_DIR);
const result = resolveProfile({ modes: ['core'], manifest });
assert.strictEqual(result.name, 'core');
assert.ok(result.skills instanceof Set, 'skills should be a Set');
// core has 6 base; closure adds phase (referenced by discuss/plan/execute/new-project)
// and config (referenced by discuss-phase, new-project), and more
assert.ok(result.skills.size >= 6, `core closure should have >=6 skills, got ${result.skills.size}`);
// All 6 base skills must be present
for (const s of PROFILES.core) {
assert.ok(result.skills.has(s), `core closure should include ${s}`);
}
// phase must be included via closure (discuss-phase, plan-phase, etc. require it)
assert.ok(result.skills.has('phase'), 'core closure must include phase (required by discuss/plan/execute-phase)');
});
test('resolves standard profile — returns superset of core', () => {
const manifest = loadSkillsManifest(REAL_COMMANDS_DIR);
const coreResult = resolveProfile({ modes: ['core'], manifest });
const stdResult = resolveProfile({ modes: ['standard'], manifest });
assert.strictEqual(stdResult.name, 'standard');
assert.ok(stdResult.skills instanceof Set);
assert.ok(stdResult.skills.size >= coreResult.skills.size, 'standard should have >= skills than core');
// All core closure skills must be in standard
for (const s of coreResult.skills) {
assert.ok(stdResult.skills.has(s), `standard must include core skill: ${s}`);
}
});
test('resolves full profile — returns sentinel', () => {
const manifest = loadSkillsManifest(REAL_COMMANDS_DIR);
const result = resolveProfile({ modes: ['full'], manifest });
assert.strictEqual(result.name, 'full');
assert.strictEqual(result.skills, '*');
});
test('composable profiles — core,standard union is same as standard', () => {
const manifest = loadSkillsManifest(REAL_COMMANDS_DIR);
const stdResult = resolveProfile({ modes: ['standard'], manifest });
const composed = resolveProfile({ modes: ['core', 'standard'], manifest });
// name should reflect composed
assert.ok(composed.name.includes('core') && composed.name.includes('standard'),
`composed name should include both, got: ${composed.name}`);
// skills union should equal standard (since core ⊂ standard)
for (const s of stdResult.skills) {
assert.ok(composed.skills.has(s), `composed should include standard skill: ${s}`);
}
});
test('transitive closure: skill that requires phase pulls in phase', () => {
// Build a minimal manifest: only discuss-phase requiring phase
const manifest = new Map([
['discuss-phase', ['phase']],
['phase', []],
['help', []],
]);
// Profile with only discuss-phase in base
const miniProfiles = { core: ['discuss-phase', 'help'], full: '*', standard: ['discuss-phase', 'help'] };
const result = resolveProfile({ modes: ['core'], manifest, _profilesOverride: miniProfiles });
assert.ok(result.skills.has('phase'), 'phase should be pulled in via closure from discuss-phase');
assert.ok(result.skills.has('discuss-phase'));
assert.ok(result.skills.has('help'));
});
test('deep transitive closure works (A→B→C pulls in C)', () => {
const manifest = new Map([
['a', ['b']],
['b', ['c']],
['c', []],
]);
const miniProfiles = { core: ['a'], full: '*', standard: ['a'] };
const result = resolveProfile({ modes: ['core'], manifest, _profilesOverride: miniProfiles });
assert.ok(result.skills.has('a'));
assert.ok(result.skills.has('b'));
assert.ok(result.skills.has('c'));
});
test('resolveProfile result has agents Set', () => {
const manifest = loadSkillsManifest(REAL_COMMANDS_DIR);
const result = resolveProfile({ modes: ['core'], manifest });
assert.ok(result.agents instanceof Set, 'result should have agents Set');
});
test('resolveProfile standard — agents Set is non-empty (plan-phase pulls gsd-planner etc)', () => {
const manifest = loadSkillsManifest(REAL_COMMANDS_DIR);
const result = resolveProfile({ modes: ['standard'], manifest });
assert.ok(result.agents instanceof Set, 'agents should be a Set');
assert.ok(result.agents.size > 0, `standard profile should have >0 agents, got ${result.agents.size}`);
// plan-phase is in standard and calls gsd-planner, gsd-plan-checker, gsd-phase-researcher
assert.ok(result.agents.has('gsd-planner'), 'standard should include gsd-planner (called by plan-phase)');
assert.ok(result.agents.has('gsd-plan-checker'), 'standard should include gsd-plan-checker (called by plan-phase)');
});
test('resolveProfile full — agents is empty Set (full staging uses srcDir directly)', () => {
const manifest = loadSkillsManifest(REAL_COMMANDS_DIR);
const result = resolveProfile({ modes: ['full'], manifest });
assert.strictEqual(result.skills, '*');
// Full profile: agents Set is empty because stageAgentsForProfile uses srcDir directly
assert.ok(result.agents instanceof Set, 'agents should still be a Set for full');
});
test('agents are derived from skill body text — synthetic manifest', () => {
// Build a synthetic manifest where plan-phase calls gsd-planner
const manifest = new Map([
['plan-phase', []],
['phase', []],
]);
// Override with calls_agents map
manifest.get('plan-phase'); // ensure it exists
// Use the real loadSkillsManifest with REAL dir to verify body parsing works
const realManifest = loadSkillsManifest(REAL_COMMANDS_DIR);
const planPhaseAgents = realManifest.get('_calls_agents_plan-phase') ||
// calls_agents may be stored as a separate key or on the manifest entry itself
[];
// This test validates that the real manifest has agent mappings for plan-phase
// by checking resolveProfile computes agents correctly
const result = resolveProfile({ modes: ['standard'], manifest: realManifest });
assert.ok(result.agents.has('gsd-planner'), 'gsd-planner should be derived from plan-phase body');
});
test('agents transitively closed — skill requiring plan-phase also gets its agents', () => {
const manifest = loadSkillsManifest(REAL_COMMANDS_DIR);
// quick requires plan-phase (via requires: field or direct) and also calls gsd-planner directly
// new-project requires plan-phase so inherits its agents
const result = resolveProfile({ modes: ['standard'], manifest });
// Since plan-phase is in standard, and plan-phase calls gsd-planner, gsd-planner must be present
assert.ok(result.agents.has('gsd-planner'));
});
});

View File

@@ -0,0 +1,181 @@
'use strict';
/**
* Tests for stageSkillsForProfile and stageAgentsForProfile.
*/
const { test, describe } = require('node:test');
const assert = require('node:assert/strict');
const fs = require('fs');
const path = require('path');
const os = require('os');
const {
stageSkillsForProfile,
stageAgentsForProfile,
cleanupStagedSkills,
resolveProfile,
loadSkillsManifest,
} = require('../get-shit-done/bin/lib/install-profiles.cjs');
const REAL_COMMANDS_DIR = path.join(__dirname, '..', 'commands', 'gsd');
const REAL_AGENTS_DIR = path.join(__dirname, '..', 'agents');
function createFixtureSkillsDir() {
const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-stage-profile-'));
for (const name of ['plan-phase', 'execute-phase', 'autonomous', 'progress', 'help', 'phase']) {
fs.writeFileSync(path.join(tmp, `${name}.md`), `# ${name}\n`);
}
return tmp;
}
function createFixtureAgentsDir() {
const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-agents-profile-'));
for (const name of ['gsd-planner', 'gsd-executor', 'gsd-code-reviewer']) {
fs.writeFileSync(path.join(tmp, `${name}.md`), `# ${name}\n`);
}
return tmp;
}
describe('stageSkillsForProfile', () => {
test('full profile (skills === "*") returns srcDir unchanged', () => {
const src = createFixtureSkillsDir();
try {
const result = stageSkillsForProfile(src, { skills: '*', agents: new Set() });
assert.strictEqual(result, src);
} finally {
fs.rmSync(src, { recursive: true, force: true });
}
});
test('profile with Set copies only member files', () => {
const src = createFixtureSkillsDir();
let staged;
try {
const skills = new Set(['plan-phase', 'help', 'phase']);
staged = stageSkillsForProfile(src, { skills, agents: new Set() });
assert.notStrictEqual(staged, src);
const files = fs.readdirSync(staged).sort();
assert.deepStrictEqual(files, ['help.md', 'phase.md', 'plan-phase.md']);
} finally {
fs.rmSync(src, { recursive: true, force: true });
if (staged) cleanupStagedSkills();
}
});
test('preserves file content byte-for-byte', () => {
const src = createFixtureSkillsDir();
const content = '# plan-phase special content\n\nsome body\n';
fs.writeFileSync(path.join(src, 'plan-phase.md'), content);
let staged;
try {
const skills = new Set(['plan-phase']);
staged = stageSkillsForProfile(src, { skills, agents: new Set() });
const copied = fs.readFileSync(path.join(staged, 'plan-phase.md'), 'utf8');
assert.strictEqual(copied, content);
} finally {
fs.rmSync(src, { recursive: true, force: true });
if (staged) cleanupStagedSkills();
}
});
test('non-existent srcDir returns srcDir unchanged', () => {
const ghost = path.join(os.tmpdir(), 'gsd-no-exist-' + Date.now());
const result = stageSkillsForProfile(ghost, { skills: new Set(['help']), agents: new Set() });
assert.strictEqual(result, ghost);
});
test('empty skills Set produces empty staged dir', () => {
const src = createFixtureSkillsDir();
let staged;
try {
staged = stageSkillsForProfile(src, { skills: new Set(), agents: new Set() });
const files = fs.readdirSync(staged);
assert.deepStrictEqual(files, []);
} finally {
fs.rmSync(src, { recursive: true, force: true });
if (staged) cleanupStagedSkills();
}
});
});
describe('stageAgentsForProfile', () => {
test('full profile (skills === "*") returns srcDir unchanged', () => {
const src = createFixtureAgentsDir();
try {
const result = stageAgentsForProfile(src, { skills: '*', agents: new Set() });
assert.strictEqual(result, src);
} finally {
fs.rmSync(src, { recursive: true, force: true });
}
});
test('non-full profile with empty agents Set produces empty staged dir', () => {
const src = createFixtureAgentsDir();
let staged;
try {
staged = stageAgentsForProfile(src, { skills: new Set(['help']), agents: new Set() });
const files = fs.readdirSync(staged);
assert.deepStrictEqual(files, [], 'no agents for non-full profile by default');
} finally {
fs.rmSync(src, { recursive: true, force: true });
if (staged) cleanupStagedSkills();
}
});
test('non-full profile with agents Set copies only member agent files', () => {
const src = createFixtureAgentsDir();
let staged;
try {
const agents = new Set(['gsd-planner']);
staged = stageAgentsForProfile(src, { skills: new Set(['plan-phase']), agents });
const files = fs.readdirSync(staged).sort();
assert.deepStrictEqual(files, ['gsd-planner.md']);
} finally {
fs.rmSync(src, { recursive: true, force: true });
if (staged) cleanupStagedSkills();
}
});
test('non-existent srcAgentsDir returns srcAgentsDir unchanged', () => {
const ghost = path.join(os.tmpdir(), 'gsd-agents-no-exist-' + Date.now());
const result = stageAgentsForProfile(ghost, { skills: new Set(), agents: new Set() });
assert.strictEqual(result, ghost);
});
test('standard profile — stageAgentsForProfile copies exactly the agents in resolvedProfile.agents', () => {
// Uses the real agents dir and commands dir
if (!fs.existsSync(REAL_AGENTS_DIR) || !fs.existsSync(REAL_COMMANDS_DIR)) return;
const manifest = loadSkillsManifest(REAL_COMMANDS_DIR);
const resolved = resolveProfile({ modes: ['standard'], manifest });
assert.ok(resolved.agents instanceof Set && resolved.agents.size > 0,
'standard profile must have >0 agents (plan-phase calls gsd-planner etc)');
let staged;
try {
staged = stageAgentsForProfile(REAL_AGENTS_DIR, resolved);
const stagedFiles = new Set(
fs.readdirSync(staged).filter(f => f.endsWith('.md')).map(f => f.slice(0, -3))
);
// Every file staged must be in resolved.agents
for (const stem of stagedFiles) {
assert.ok(resolved.agents.has(stem), `staged agent ${stem} not in resolved.agents`);
}
// Every agent in resolved.agents that exists in the real dir must be staged
for (const agentStem of resolved.agents) {
const exists = fs.existsSync(path.join(REAL_AGENTS_DIR, `${agentStem}.md`));
if (exists) {
assert.ok(stagedFiles.has(agentStem), `resolved agent ${agentStem} missing from staged dir`);
}
}
} finally {
if (staged) cleanupStagedSkills();
}
});
test('full profile staging returns real agents dir unchanged', () => {
if (!fs.existsSync(REAL_AGENTS_DIR)) return;
const manifest = loadSkillsManifest(REAL_COMMANDS_DIR);
const resolved = resolveProfile({ modes: ['full'], manifest });
const result = stageAgentsForProfile(REAL_AGENTS_DIR, resolved);
assert.strictEqual(result, REAL_AGENTS_DIR);
});
});

View File

@@ -0,0 +1,196 @@
'use strict';
/**
* Tests for marker-driven profile re-application on `gsd update` (Deviation 2).
*
* Verifies:
* 1. resolveEffectiveProfile returns the marker's profile when no explicit flag given.
* 2. Explicit --profile flag overrides the marker.
* 3. Multi-runtime marker disagreement resolves to the most-restrictive profile.
* 4. Missing marker falls back to 'full'.
* 5. stageSkillsForProfile is called with the resolved profile (not 'full') on updates.
*/
const { test, describe } = require('node:test');
const assert = require('node:assert/strict');
const fs = require('fs');
const path = require('path');
const os = require('os');
const {
resolveEffectiveProfile,
mostRestrictiveProfile,
writeActiveProfile,
readActiveProfile,
resolveProfile,
loadSkillsManifest,
stageSkillsForProfile,
cleanupStagedSkills,
} = require('../get-shit-done/bin/lib/install-profiles.cjs');
const REAL_COMMANDS_DIR = path.join(__dirname, '..', 'commands', 'gsd');
describe('resolveEffectiveProfile', () => {
test('no explicit flag and no marker → returns "full"', () => {
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-reu-'));
try {
const result = resolveEffectiveProfile({ requestedProfileName: null, targetDir: dir });
assert.strictEqual(result, 'full');
} finally {
fs.rmSync(dir, { recursive: true, force: true });
}
});
test('no explicit flag but marker exists → returns marker profile', () => {
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-reu-'));
try {
writeActiveProfile(dir, 'standard');
const result = resolveEffectiveProfile({ requestedProfileName: null, targetDir: dir });
assert.strictEqual(result, 'standard');
} finally {
fs.rmSync(dir, { recursive: true, force: true });
}
});
test('no explicit flag but core marker exists → returns "core"', () => {
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-reu-'));
try {
writeActiveProfile(dir, 'core');
const result = resolveEffectiveProfile({ requestedProfileName: null, targetDir: dir });
assert.strictEqual(result, 'core');
} finally {
fs.rmSync(dir, { recursive: true, force: true });
}
});
test('explicit --profile=full overrides a non-full marker', () => {
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-reu-'));
try {
writeActiveProfile(dir, 'core');
const result = resolveEffectiveProfile({ requestedProfileName: 'full', targetDir: dir });
assert.strictEqual(result, 'full');
} finally {
fs.rmSync(dir, { recursive: true, force: true });
}
});
test('explicit --profile=standard overrides a core marker', () => {
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-reu-'));
try {
writeActiveProfile(dir, 'core');
const result = resolveEffectiveProfile({ requestedProfileName: 'standard', targetDir: dir });
assert.strictEqual(result, 'standard');
} finally {
fs.rmSync(dir, { recursive: true, force: true });
}
});
test('full marker falls back to "full" (not recorded as a restriction)', () => {
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-reu-'));
try {
writeActiveProfile(dir, 'full');
const result = resolveEffectiveProfile({ requestedProfileName: null, targetDir: dir });
assert.strictEqual(result, 'full');
} finally {
fs.rmSync(dir, { recursive: true, force: true });
}
});
});
describe('mostRestrictiveProfile', () => {
test('returns "core" over "standard" (core is smaller)', () => {
const result = mostRestrictiveProfile(['standard', 'core']);
assert.strictEqual(result, 'core');
});
test('returns "core" over "full"', () => {
const result = mostRestrictiveProfile(['full', 'core']);
assert.strictEqual(result, 'core');
});
test('returns "standard" over "full"', () => {
const result = mostRestrictiveProfile(['full', 'standard']);
assert.strictEqual(result, 'standard');
});
test('single profile returns that profile', () => {
assert.strictEqual(mostRestrictiveProfile(['standard']), 'standard');
assert.strictEqual(mostRestrictiveProfile(['full']), 'full');
assert.strictEqual(mostRestrictiveProfile(['core']), 'core');
});
test('empty array returns "full" (no restriction)', () => {
assert.strictEqual(mostRestrictiveProfile([]), 'full');
});
test('all same profile returns that profile', () => {
assert.strictEqual(mostRestrictiveProfile(['standard', 'standard', 'standard']), 'standard');
});
});
describe('marker-driven profile resolution end-to-end', () => {
test('fresh install with --profile=standard writes marker, re-read resolves standard', () => {
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-e2e-'));
try {
// Simulate: fresh install with --profile=standard
writeActiveProfile(dir, 'standard');
// Simulate: re-run without flags (e.g. `gsd update`)
const effective = resolveEffectiveProfile({ requestedProfileName: null, targetDir: dir });
assert.strictEqual(effective, 'standard');
// Verify the staged output contains only standard's skills
const manifest = loadSkillsManifest(REAL_COMMANDS_DIR);
const resolved = resolveProfile({ modes: [effective], manifest });
assert.ok(resolved.skills instanceof Set, 'skills should be a Set for standard');
assert.ok(resolved.skills.has('plan-phase'), 'standard should include plan-phase');
// plan-phase should be staged
let staged;
try {
staged = stageSkillsForProfile(REAL_COMMANDS_DIR, resolved);
assert.notStrictEqual(staged, REAL_COMMANDS_DIR, 'should be a staged dir, not srcDir');
const stagedFiles = fs.readdirSync(staged).map(f => f.slice(0, -3)); // strip .md
assert.ok(stagedFiles.includes('plan-phase'), 'plan-phase.md should be staged');
// full-only skills must NOT be staged
const allFiles = fs.readdirSync(REAL_COMMANDS_DIR).map(f => f.slice(0, -3));
const notInStandard = allFiles.filter(s => !resolved.skills.has(s));
for (const stem of notInStandard) {
assert.ok(!stagedFiles.includes(stem),
`${stem} should not be staged in standard profile`);
}
} finally {
if (staged) cleanupStagedSkills();
}
} finally {
fs.rmSync(dir, { recursive: true, force: true });
}
});
test('marker disagreement across runtimes → mostRestrictiveProfile picks smaller set', () => {
const dirA = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-rtA-'));
const dirB = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-rtB-'));
try {
writeActiveProfile(dirA, 'standard');
writeActiveProfile(dirB, 'core');
const profileA = readActiveProfile(dirA) || 'full';
const profileB = readActiveProfile(dirB) || 'full';
const resolved = mostRestrictiveProfile([profileA, profileB]);
assert.strictEqual(resolved, 'core',
'core is smaller than standard — most-restrictive wins');
} finally {
fs.rmSync(dirA, { recursive: true, force: true });
fs.rmSync(dirB, { recursive: true, force: true });
}
});
test('explicit --profile=full overrides restrictive marker (no narrowing)', () => {
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-override-'));
try {
writeActiveProfile(dir, 'core');
const effective = resolveEffectiveProfile({ requestedProfileName: 'full', targetDir: dir });
assert.strictEqual(effective, 'full');
const manifest = loadSkillsManifest(REAL_COMMANDS_DIR);
const resolved = resolveProfile({ modes: [effective], manifest });
assert.strictEqual(resolved.skills, '*', 'full profile should be sentinel');
} finally {
fs.rmSync(dir, { recursive: true, force: true });
}
});
});

View File

@@ -0,0 +1,144 @@
'use strict';
/**
* Fixture-based tests for scripts/lint-skill-deps.cjs.
*
* Uses child_process.spawnSync to exercise the lint script as a black box,
* passing fixture files via argv. This tests the actual exit codes and
* error output the script produces.
*/
const { test, describe } = require('node:test');
const assert = require('node:assert/strict');
const fs = require('fs');
const path = require('path');
const os = require('os');
const { spawnSync } = require('child_process');
const LINT_SCRIPT = path.join(__dirname, '..', 'scripts', 'lint-skill-deps.cjs');
function runLint(args = []) {
return spawnSync(process.execPath, [LINT_SCRIPT, ...args], { encoding: 'utf8' });
}
function createFixtureDir() {
return fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-lint-deps-fixture-'));
}
function writeSkillFile(dir, stem, { description = 'Test skill', requires = null, body = '' }) {
let fm = `name: gsd:${stem}\ndescription: ${description}`;
if (requires !== null) {
fm += `\nrequires: [${requires.join(', ')}]`;
}
const content = `---\n${fm}\n---\n\n${body}\n`;
fs.writeFileSync(path.join(dir, `${stem}.md`), content);
}
describe('lint-skill-deps: frontmatter ↔ body consistency', () => {
test('exits 0 when all skills have consistent requires: and references', () => {
const dir = createFixtureDir();
try {
// phase.md has no references to other skills
writeSkillFile(dir, 'phase', { description: 'Phase skill', body: 'Use this to manage phases.' });
// discuss-phase requires phase and references it in body
writeSkillFile(dir, 'discuss-phase', {
description: 'Discuss skill',
requires: ['phase'],
body: 'Invoke with /gsd:phase to manage phases.',
});
const result = runLint(['--dir', dir]);
assert.strictEqual(result.status, 0, `Expected exit 0, got ${result.status}\nstdout: ${result.stdout}\nstderr: ${result.stderr}`);
} finally {
fs.rmSync(dir, { recursive: true, force: true });
}
});
test('exits non-zero when body references gsd:phase but requires: is absent', () => {
const dir = createFixtureDir();
try {
writeSkillFile(dir, 'phase', { description: 'Phase skill', body: '' });
writeSkillFile(dir, 'discuss-phase', {
description: 'Discuss skill',
// requires: absent — but body references /gsd:phase
requires: null,
body: 'Use /gsd:phase to manage phases.',
});
const result = runLint(['--dir', dir]);
assert.notStrictEqual(result.status, 0, 'Should exit non-zero when requires: is missing but body has reference');
} finally {
fs.rmSync(dir, { recursive: true, force: true });
}
});
test('exits non-zero when body references gsd-phase but requires: does not include it', () => {
const dir = createFixtureDir();
try {
writeSkillFile(dir, 'phase', { description: 'Phase skill', body: '' });
writeSkillFile(dir, 'discuss-phase', {
description: 'Discuss skill',
requires: ['config'], // has requires but missing 'phase'
body: 'Use /gsd:phase to manage phases.',
});
const result = runLint(['--dir', dir]);
assert.notStrictEqual(result.status, 0, 'Should exit non-zero for undeclared reference');
} finally {
fs.rmSync(dir, { recursive: true, force: true });
}
});
test('exits 0 when skill has no body references and no requires:', () => {
const dir = createFixtureDir();
try {
writeSkillFile(dir, 'help', { description: 'Help skill', body: 'Shows help.' });
const result = runLint(['--dir', dir]);
assert.strictEqual(result.status, 0);
} finally {
fs.rmSync(dir, { recursive: true, force: true });
}
});
test('exits non-zero when body references unknown skill stem', () => {
const dir = createFixtureDir();
try {
writeSkillFile(dir, 'discuss-phase', {
description: 'Discuss',
requires: ['phase'],
body: 'Use /gsd:phase.',
});
// Unknown skill reference should fail even if declared in requires.
const result = runLint(['--dir', dir]);
assert.notStrictEqual(result.status, 0, 'Unknown skill references must fail lint');
} finally {
fs.rmSync(dir, { recursive: true, force: true });
}
});
});
describe('lint-skill-deps: profile closure satisfaction', () => {
test('exits 0 when run against real commands/gsd (all profiles closed or full)', () => {
// This is the most important integration test: running lint on the real
// commands/gsd/ directory with the real PROFILES must pass.
const result = runLint();
assert.strictEqual(result.status, 0,
`lint-skill-deps failed on real codebase:\nstdout: ${result.stdout}\nstderr: ${result.stderr}`);
});
});
describe('lint-skill-deps: script basics', () => {
test('script is executable (no syntax errors)', () => {
const result = spawnSync(process.execPath, ['--check', LINT_SCRIPT], { encoding: 'utf8' });
assert.strictEqual(result.status, 0, `Syntax error in lint script: ${result.stderr}`);
});
test('prints ok message on success', () => {
const dir = createFixtureDir();
try {
writeSkillFile(dir, 'help', { description: 'Help skill', body: 'Shows help.' });
const result = runLint(['--dir', dir]);
assert.strictEqual(result.status, 0, `Expected exit 0, got ${result.status}\nstdout: ${result.stdout}\nstderr: ${result.stderr}`);
assert.strictEqual(result.stderr, '', `Expected empty stderr on success, got: ${result.stderr}`);
assert.ok(result.stdout.length > 0, 'Expected non-empty stdout on success');
} finally {
fs.rmSync(dir, { recursive: true, force: true });
}
});
});

View File

@@ -0,0 +1,165 @@
'use strict';
/**
* Tests for applySurface — file sync behavior.
*/
const { test, describe } = require('node:test');
const assert = require('node:assert/strict');
const fs = require('fs');
const path = require('path');
const os = require('os');
const { writeSurface, applySurface } = require('../get-shit-done/bin/lib/surface.cjs');
const { loadSkillsManifest, writeActiveProfile } = require('../get-shit-done/bin/lib/install-profiles.cjs');
const { CLUSTERS } = require('../get-shit-done/bin/lib/clusters.cjs');
const REAL_COMMANDS_DIR = path.join(__dirname, '..', 'commands', 'gsd');
const REAL_AGENTS_DIR = path.join(__dirname, '..', 'agents');
function tmpDir() {
return fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-surface-apply-'));
}
/**
* Create a minimal fixture install dir structure.
* Returns { runtimeConfigDir, commandsDir, agentsDir }.
* runtimeConfigDir has a .gsd-source marker pointing to REAL_COMMANDS_DIR.
*/
function createFixtureRuntime() {
const base = tmpDir();
const runtimeConfigDir = path.join(base, 'config');
const commandsDir = path.join(base, 'commands', 'gsd');
const agentsDir = path.join(base, 'agents');
fs.mkdirSync(runtimeConfigDir, { recursive: true });
fs.mkdirSync(commandsDir, { recursive: true });
fs.mkdirSync(agentsDir, { recursive: true });
// Write source marker so surface.cjs can find the install source
fs.writeFileSync(path.join(runtimeConfigDir, '.gsd-source'), REAL_COMMANDS_DIR, 'utf8');
return { base, runtimeConfigDir, commandsDir, agentsDir };
}
describe('applySurface', () => {
test('core profile: only core skills appear in commandsDir', () => {
const { base, runtimeConfigDir, commandsDir, agentsDir } = createFixtureRuntime();
try {
writeActiveProfile(runtimeConfigDir, 'core');
writeSurface(runtimeConfigDir, {
baseProfile: 'core',
disabledClusters: [],
explicitAdds: [],
explicitRemoves: [],
});
const manifest = loadSkillsManifest(REAL_COMMANDS_DIR);
applySurface(runtimeConfigDir, commandsDir, agentsDir, manifest, CLUSTERS);
const files = fs.readdirSync(commandsDir).filter(f => f.endsWith('.md'));
// Every file should be a real stem we know about
for (const file of files) {
assert.ok(fs.existsSync(path.join(REAL_COMMANDS_DIR, file)), `unexpected file: ${file}`);
}
// At minimum core skills should be present
const coreStems = ['new-project', 'discuss-phase', 'plan-phase', 'execute-phase', 'help', 'update'];
for (const stem of coreStems) {
assert.ok(files.includes(`${stem}.md`), `core skill "${stem}" should be in commandsDir`);
}
} finally {
fs.rmSync(base, { recursive: true, force: true });
}
});
test('removes superseded files when profile shrinks', () => {
const { base, runtimeConfigDir, commandsDir, agentsDir } = createFixtureRuntime();
try {
// Start with standard: put some skill files in commandsDir
writeActiveProfile(runtimeConfigDir, 'standard');
writeSurface(runtimeConfigDir, {
baseProfile: 'standard',
disabledClusters: [],
explicitAdds: [],
explicitRemoves: [],
});
const manifest = loadSkillsManifest(REAL_COMMANDS_DIR);
applySurface(runtimeConfigDir, commandsDir, agentsDir, manifest, CLUSTERS);
const afterStandard = new Set(fs.readdirSync(commandsDir).filter(f => f.endsWith('.md')));
// Now switch to core: skills not in core should be removed
writeSurface(runtimeConfigDir, {
baseProfile: 'core',
disabledClusters: [],
explicitAdds: [],
explicitRemoves: [],
});
applySurface(runtimeConfigDir, commandsDir, agentsDir, manifest, CLUSTERS);
const afterCore = new Set(fs.readdirSync(commandsDir).filter(f => f.endsWith('.md')));
// core should be a subset of standard
assert.ok(afterCore.size <= afterStandard.size, 'core should have fewer or equal files than standard');
// Files removed should not be in core set
const coreStems = new Set(['new-project', 'discuss-phase', 'plan-phase', 'execute-phase', 'help', 'update']);
for (const file of afterCore) {
const stem = file.slice(0, -3);
assert.ok(
fs.existsSync(path.join(REAL_COMMANDS_DIR, file)),
`file in commandsDir not a real skill: ${file}`
);
}
} finally {
fs.rmSync(base, { recursive: true, force: true });
}
});
test('leaves non-gsd .md files alone in agentsDir', () => {
const { base, runtimeConfigDir, commandsDir, agentsDir } = createFixtureRuntime();
try {
// Place a non-gsd agent file in agentsDir
const foreignAgent = path.join(agentsDir, 'my-custom-agent.md');
fs.writeFileSync(foreignAgent, '# custom agent\n', 'utf8');
writeActiveProfile(runtimeConfigDir, 'core');
writeSurface(runtimeConfigDir, {
baseProfile: 'core',
disabledClusters: [],
explicitAdds: [],
explicitRemoves: [],
});
const manifest = loadSkillsManifest(REAL_COMMANDS_DIR);
applySurface(runtimeConfigDir, commandsDir, agentsDir, manifest, CLUSTERS);
// Non-gsd file should still be there
assert.ok(fs.existsSync(foreignAgent), 'non-gsd agent file should not be touched');
} finally {
fs.rmSync(base, { recursive: true, force: true });
}
});
test('adds missing skill files from install source', () => {
const { base, runtimeConfigDir, commandsDir, agentsDir } = createFixtureRuntime();
try {
// commandsDir starts empty
writeActiveProfile(runtimeConfigDir, 'core');
writeSurface(runtimeConfigDir, {
baseProfile: 'core',
disabledClusters: [],
explicitAdds: [],
explicitRemoves: [],
});
const manifest = loadSkillsManifest(REAL_COMMANDS_DIR);
applySurface(runtimeConfigDir, commandsDir, agentsDir, manifest, CLUSTERS);
// Core skills should now be present
assert.ok(
fs.existsSync(path.join(commandsDir, 'help.md')),
'help.md should be copied from install source'
);
assert.ok(
fs.existsSync(path.join(commandsDir, 'new-project.md')),
'new-project.md should be copied from install source'
);
} finally {
fs.rmSync(base, { recursive: true, force: true });
}
});
});

View File

@@ -0,0 +1,94 @@
'use strict';
/**
* Tests for CLUSTERS data structure integrity.
* Verifies every cluster member is a real skill stem and all skills are covered.
*/
const { test, describe } = require('node:test');
const assert = require('node:assert/strict');
const fs = require('fs');
const path = require('path');
const { CLUSTERS, allClusteredSkills } = require('../get-shit-done/bin/lib/clusters.cjs');
const COMMANDS_DIR = path.join(__dirname, '..', 'commands', 'gsd');
function realSkillStems() {
const entries = fs.readdirSync(COMMANDS_DIR, { withFileTypes: true });
return new Set(
entries
.filter(e => e.isFile() && e.name.endsWith('.md'))
.map(e => e.name.slice(0, -3))
);
}
describe('CLUSTERS data structure', () => {
test('no cluster is empty', () => {
for (const [name, members] of Object.entries(CLUSTERS)) {
assert.ok(members.length > 0, `cluster ${name} must not be empty`);
}
});
test('every cluster member is a real skill stem in commands/gsd/', () => {
const realStems = realSkillStems();
const mismatches = [];
for (const [cluster, members] of Object.entries(CLUSTERS)) {
for (const stem of members) {
if (!realStems.has(stem)) {
mismatches.push(`${cluster}: "${stem}" not found in commands/gsd/`);
}
}
}
assert.deepStrictEqual(mismatches, [], `Cluster members missing from disk:\n${mismatches.join('\n')}`);
});
test('union of all clusters covers every skill in commands/gsd/', () => {
const realStems = realSkillStems();
const clustered = allClusteredSkills();
const uncategorized = [];
for (const stem of realStems) {
if (!clustered.has(stem)) uncategorized.push(stem);
}
assert.deepStrictEqual(
uncategorized,
[],
`Uncategorized skills (not in any cluster):\n${uncategorized.sort().join('\n')}`
);
});
test('CLUSTERS is frozen (immutable)', () => {
assert.ok(Object.isFrozen(CLUSTERS), 'CLUSTERS must be frozen');
for (const [name, members] of Object.entries(CLUSTERS)) {
assert.ok(Object.isFrozen(members), `CLUSTERS.${name} must be frozen`);
}
});
test('cluster names match expected set from research memo §3.2', () => {
const expectedClusterNames = new Set([
'core_loop',
'audit_review',
'milestone',
'research_ideate',
'workspace_state',
'docs',
'ui',
'ai_eval',
'ns_meta',
'utility',
]);
const actualClusterNames = new Set(Object.keys(CLUSTERS));
for (const name of expectedClusterNames) {
assert.ok(actualClusterNames.has(name), `expected cluster "${name}" missing from CLUSTERS`);
}
});
test('allClusteredSkills returns a Set containing all cluster members', () => {
const result = allClusteredSkills();
assert.ok(result instanceof Set, 'allClusteredSkills() must return a Set');
for (const members of Object.values(CLUSTERS)) {
for (const stem of members) {
assert.ok(result.has(stem), `allClusteredSkills() missing "${stem}"`);
}
}
});
});

143
tests/surface-list.test.cjs Normal file
View File

@@ -0,0 +1,143 @@
'use strict';
/**
* Tests for listSurface — enabled/disabled/tokenCost output.
*/
const { test, describe } = require('node:test');
const assert = require('node:assert/strict');
const fs = require('fs');
const path = require('path');
const os = require('os');
const { writeSurface, listSurface } = require('../get-shit-done/bin/lib/surface.cjs');
const { loadSkillsManifest, writeActiveProfile } = require('../get-shit-done/bin/lib/install-profiles.cjs');
const { CLUSTERS } = require('../get-shit-done/bin/lib/clusters.cjs');
const REAL_COMMANDS_DIR = path.join(__dirname, '..', 'commands', 'gsd');
function tmpDir() {
return fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-surface-list-'));
}
function readFrontmatterDescription(markdown) {
const lines = markdown.split('\n');
if (lines[0].trim() !== '---') return '';
for (let i = 1; i < lines.length; i++) {
const line = lines[i];
if (line.trim() === '---') break;
const sep = line.indexOf(':');
if (sep === -1) continue;
const key = line.slice(0, sep).trim();
if (key !== 'description') continue;
return line.slice(sep + 1).trim();
}
return '';
}
describe('listSurface', () => {
test('returns { enabled, disabled, tokenCost } structure', () => {
const dir = tmpDir();
try {
// Write source marker so listSurface can find descriptions
fs.writeFileSync(path.join(dir, '.gsd-source'), REAL_COMMANDS_DIR, 'utf8');
writeActiveProfile(dir, 'core');
const manifest = loadSkillsManifest(REAL_COMMANDS_DIR);
const result = listSurface(dir, manifest, CLUSTERS);
assert.ok(Array.isArray(result.enabled), 'enabled must be array');
assert.ok(Array.isArray(result.disabled), 'disabled must be array');
assert.ok(typeof result.tokenCost === 'number', 'tokenCost must be number');
assert.ok(result.tokenCost >= 0, 'tokenCost must be non-negative');
} finally {
fs.rmSync(dir, { recursive: true, force: true });
}
});
test('core profile: enabled has fewer skills than full', () => {
const dir = tmpDir();
try {
fs.writeFileSync(path.join(dir, '.gsd-source'), REAL_COMMANDS_DIR, 'utf8');
writeActiveProfile(dir, 'core');
const manifest = loadSkillsManifest(REAL_COMMANDS_DIR);
const coreList = listSurface(dir, manifest, CLUSTERS);
// Core should have fewer enabled skills than total
const totalStems = [...manifest.keys()].filter(k => !k.startsWith('_calls_agents_')).length;
assert.ok(
coreList.enabled.length < totalStems,
'core should enable fewer skills than total'
);
assert.ok(coreList.disabled.length > 0, 'core should have some disabled skills');
assert.ok(coreList.enabled.length + coreList.disabled.length === totalStems,
'enabled + disabled must equal total stems');
} finally {
fs.rmSync(dir, { recursive: true, force: true });
}
});
test('disabling utility cluster reduces enabled count', () => {
const dir = tmpDir();
try {
fs.writeFileSync(path.join(dir, '.gsd-source'), REAL_COMMANDS_DIR, 'utf8');
writeActiveProfile(dir, 'standard');
const manifest = loadSkillsManifest(REAL_COMMANDS_DIR);
const beforeList = listSurface(dir, manifest, CLUSTERS);
writeSurface(dir, {
baseProfile: 'standard',
disabledClusters: ['utility'],
explicitAdds: [],
explicitRemoves: [],
});
const afterList = listSurface(dir, manifest, CLUSTERS);
assert.ok(afterList.enabled.length <= beforeList.enabled.length,
'disabling utility cluster should not increase enabled count');
assert.ok(afterList.tokenCost <= beforeList.tokenCost,
'disabling a cluster should not increase token cost');
} finally {
fs.rmSync(dir, { recursive: true, force: true });
}
});
test('tokenCost is sum of description char lengths ÷ 4 for enabled skills', () => {
const dir = tmpDir();
try {
fs.writeFileSync(path.join(dir, '.gsd-source'), REAL_COMMANDS_DIR, 'utf8');
writeActiveProfile(dir, 'core');
const manifest = loadSkillsManifest(REAL_COMMANDS_DIR);
const result = listSurface(dir, manifest, CLUSTERS);
// Manually compute expected token cost for enabled skills
let expected = 0;
for (const stem of result.enabled) {
const filePath = path.join(REAL_COMMANDS_DIR, `${stem}.md`);
if (!fs.existsSync(filePath)) continue;
const markdown = fs.readFileSync(filePath, 'utf8');
const description = readFrontmatterDescription(markdown);
if (description) expected += Math.ceil(description.length / 4);
}
assert.strictEqual(result.tokenCost, expected, 'tokenCost must equal sum of description lengths ÷ 4');
} finally {
fs.rmSync(dir, { recursive: true, force: true });
}
});
test('enabled and disabled arrays are sorted', () => {
const dir = tmpDir();
try {
fs.writeFileSync(path.join(dir, '.gsd-source'), REAL_COMMANDS_DIR, 'utf8');
writeActiveProfile(dir, 'standard');
const manifest = loadSkillsManifest(REAL_COMMANDS_DIR);
const result = listSurface(dir, manifest, CLUSTERS);
assert.deepStrictEqual(result.enabled, [...result.enabled].sort());
assert.deepStrictEqual(result.disabled, [...result.disabled].sort());
} finally {
fs.rmSync(dir, { recursive: true, force: true });
}
});
});

View File

@@ -0,0 +1,194 @@
'use strict';
/**
* Tests for resolveSurface — profile + cluster + explicit combinations.
*/
const { test, describe } = require('node:test');
const assert = require('node:assert/strict');
const fs = require('fs');
const path = require('path');
const os = require('os');
const { readSurface, writeSurface, resolveSurface } = require('../get-shit-done/bin/lib/surface.cjs');
const { resolveProfile, loadSkillsManifest, writeActiveProfile } = require('../get-shit-done/bin/lib/install-profiles.cjs');
const { CLUSTERS } = require('../get-shit-done/bin/lib/clusters.cjs');
const REAL_COMMANDS_DIR = path.join(__dirname, '..', 'commands', 'gsd');
function tmpDir() {
return fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-surface-resolve-'));
}
function realManifest() {
return loadSkillsManifest(REAL_COMMANDS_DIR);
}
describe('resolveSurface', () => {
test('no surface state + core base profile → identical to resolveProfile core', () => {
const dir = tmpDir();
try {
writeActiveProfile(dir, 'core');
const manifest = realManifest();
const surfaceResolved = resolveSurface(dir, manifest, CLUSTERS);
const profileResolved = resolveProfile({ modes: ['core'], manifest });
// Both should have same skill sets
assert.ok(surfaceResolved.skills instanceof Set);
assert.ok(profileResolved.skills instanceof Set);
assert.deepStrictEqual(
[...surfaceResolved.skills].sort(),
[...profileResolved.skills].sort(),
'surface with no state should equal profile resolution'
);
} finally {
fs.rmSync(dir, { recursive: true, force: true });
}
});
test('standard base + disabledClusters:["utility"] removes utility skills', () => {
const dir = tmpDir();
try {
writeActiveProfile(dir, 'standard');
writeSurface(dir, {
baseProfile: 'standard',
disabledClusters: ['utility'],
explicitAdds: [],
explicitRemoves: [],
});
const manifest = realManifest();
const resolved = resolveSurface(dir, manifest, CLUSTERS);
assert.ok(resolved.skills instanceof Set);
// Utility cluster members should not be in the result
for (const stem of CLUSTERS.utility) {
// Only check stems that were actually in the standard profile
const standardResolved = resolveProfile({ modes: ['standard'], manifest });
if (standardResolved.skills.has(stem)) {
assert.ok(
!resolved.skills.has(stem),
`"${stem}" should be removed by disabling utility cluster`
);
}
}
} finally {
fs.rmSync(dir, { recursive: true, force: true });
}
});
test('explicitAdds:["sketch"] adds sketch to a core install', () => {
const dir = tmpDir();
try {
writeActiveProfile(dir, 'core');
writeSurface(dir, {
baseProfile: 'core',
disabledClusters: [],
explicitAdds: ['sketch'],
explicitRemoves: [],
});
const manifest = realManifest();
const resolved = resolveSurface(dir, manifest, CLUSTERS);
assert.ok(resolved.skills instanceof Set);
assert.ok(resolved.skills.has('sketch'), 'sketch must be in resolved skills');
// Transitive requires of sketch should also be present
const sketchRequires = manifest.get('sketch') || [];
for (const dep of sketchRequires) {
assert.ok(resolved.skills.has(dep), `transitive dep "${dep}" of sketch must be present`);
}
} finally {
fs.rmSync(dir, { recursive: true, force: true });
}
});
test('explicitRemoves removes individual skill stems', () => {
const dir = tmpDir();
try {
writeSurface(dir, {
baseProfile: 'standard',
disabledClusters: [],
explicitAdds: [],
explicitRemoves: ['progress'],
});
writeActiveProfile(dir, 'standard');
const manifest = realManifest();
const resolved = resolveSurface(dir, manifest, CLUSTERS);
// standard includes 'progress', so removing it should take it out
assert.ok(!resolved.skills.has('progress'), '"progress" must be removed by explicitRemoves');
} finally {
fs.rmSync(dir, { recursive: true, force: true });
}
});
test('result is a Set<string> with name property', () => {
const dir = tmpDir();
try {
writeActiveProfile(dir, 'core');
const manifest = realManifest();
const resolved = resolveSurface(dir, manifest, CLUSTERS);
assert.ok(resolved.skills instanceof Set);
assert.ok(typeof resolved.name === 'string');
assert.ok(resolved.agents instanceof Set);
} finally {
fs.rmSync(dir, { recursive: true, force: true });
}
});
test('surface with baseProfile overrides .gsd-profile marker', () => {
const dir = tmpDir();
try {
writeActiveProfile(dir, 'core');
writeSurface(dir, {
baseProfile: 'standard',
disabledClusters: [],
explicitAdds: [],
explicitRemoves: [],
});
const manifest = realManifest();
const resolved = resolveSurface(dir, manifest, CLUSTERS);
const standardResolved = resolveProfile({ modes: ['standard'], manifest });
// Should use standard profile from surface state, not core from marker
assert.deepStrictEqual(
[...resolved.skills].sort(),
[...standardResolved.skills].sort(),
'surface baseProfile takes precedence over marker'
);
} finally {
fs.rmSync(dir, { recursive: true, force: true });
}
});
test('disabled cluster + explicitAdds can re-add specific skills from disabled cluster', () => {
const dir = tmpDir();
try {
writeSurface(dir, {
baseProfile: 'standard',
disabledClusters: ['workspace_state'],
explicitAdds: ['capture'],
explicitRemoves: [],
});
writeActiveProfile(dir, 'standard');
const manifest = realManifest();
const resolved = resolveSurface(dir, manifest, CLUSTERS);
// workspace_state is disabled, but capture is explicitly re-added
assert.ok(resolved.skills.has('capture'), '"capture" must be present via explicitAdds');
// Other workspace_state members that were in standard should be gone
const standardResolved = resolveProfile({ modes: ['standard'], manifest });
for (const stem of CLUSTERS.workspace_state) {
if (stem === 'capture') continue;
if (standardResolved.skills.has(stem)) {
assert.ok(
!resolved.skills.has(stem),
`"${stem}" should be removed (workspace_state disabled, not explicitly re-added)`
);
}
}
} finally {
fs.rmSync(dir, { recursive: true, force: true });
}
});
});

View File

@@ -0,0 +1,165 @@
'use strict';
/**
* Tests for readSurface / writeSurface — state IO round-trips.
*/
const { test, describe } = require('node:test');
const assert = require('node:assert/strict');
const fs = require('fs');
const path = require('path');
const os = require('os');
const { readSurface, writeSurface } = require('../get-shit-done/bin/lib/surface.cjs');
function tmpDir() {
return fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-surface-state-'));
}
describe('readSurface / writeSurface', () => {
test('round-trips a complete surface state', () => {
const dir = tmpDir();
try {
const state = {
baseProfile: 'standard',
disabledClusters: ['utility'],
explicitAdds: ['sketch'],
explicitRemoves: [],
};
writeSurface(dir, state);
const read = readSurface(dir);
assert.deepStrictEqual(read, state);
} finally {
fs.rmSync(dir, { recursive: true, force: true });
}
});
test('round-trips empty arrays', () => {
const dir = tmpDir();
try {
const state = {
baseProfile: 'core',
disabledClusters: [],
explicitAdds: [],
explicitRemoves: [],
};
writeSurface(dir, state);
assert.deepStrictEqual(readSurface(dir), state);
} finally {
fs.rmSync(dir, { recursive: true, force: true });
}
});
test('round-trips composed base profile', () => {
const dir = tmpDir();
try {
const state = {
baseProfile: 'core,audit',
disabledClusters: [],
explicitAdds: [],
explicitRemoves: ['health'],
};
writeSurface(dir, state);
assert.deepStrictEqual(readSurface(dir), state);
} finally {
fs.rmSync(dir, { recursive: true, force: true });
}
});
test('missing file returns null', () => {
const dir = tmpDir();
try {
const result = readSurface(dir);
assert.strictEqual(result, null);
} finally {
fs.rmSync(dir, { recursive: true, force: true });
}
});
test('non-existent directory returns null', () => {
const ghost = path.join(os.tmpdir(), 'gsd-surface-no-exist-' + Date.now());
const result = readSurface(ghost);
assert.strictEqual(result, null);
});
test('corrupt JSON returns null', () => {
const dir = tmpDir();
try {
fs.writeFileSync(path.join(dir, '.gsd-surface.json'), '{not valid json', 'utf8');
const result = readSurface(dir);
assert.strictEqual(result, null);
} finally {
fs.rmSync(dir, { recursive: true, force: true });
}
});
test('JSON missing baseProfile field returns null', () => {
const dir = tmpDir();
try {
fs.writeFileSync(
path.join(dir, '.gsd-surface.json'),
JSON.stringify({ disabledClusters: [], explicitAdds: [], explicitRemoves: [] }),
'utf8'
);
const result = readSurface(dir);
assert.strictEqual(result, null);
} finally {
fs.rmSync(dir, { recursive: true, force: true });
}
});
test('JSON with non-array disabledClusters returns null', () => {
const dir = tmpDir();
try {
fs.writeFileSync(
path.join(dir, '.gsd-surface.json'),
JSON.stringify({ baseProfile: 'standard', disabledClusters: 'utility', explicitAdds: [], explicitRemoves: [] }),
'utf8'
);
const result = readSurface(dir);
assert.strictEqual(result, null);
} finally {
fs.rmSync(dir, { recursive: true, force: true });
}
});
test('atomic write: result file is never a partial tmp file', () => {
const dir = tmpDir();
try {
const state = { baseProfile: 'full', disabledClusters: [], explicitAdds: [], explicitRemoves: [] };
writeSurface(dir, state);
// No .tmp.* files should remain
const files = fs.readdirSync(dir);
const tmpFiles = files.filter(f => f.includes('.tmp.'));
assert.deepStrictEqual(tmpFiles, [], 'no tmp files should remain after write');
// The canonical file exists
assert.ok(files.includes('.gsd-surface.json'));
} finally {
fs.rmSync(dir, { recursive: true, force: true });
}
});
test('second write overwrites first', () => {
const dir = tmpDir();
try {
writeSurface(dir, { baseProfile: 'core', disabledClusters: [], explicitAdds: [], explicitRemoves: [] });
writeSurface(dir, { baseProfile: 'standard', disabledClusters: ['utility'], explicitAdds: [], explicitRemoves: [] });
const read = readSurface(dir);
assert.strictEqual(read.baseProfile, 'standard');
assert.deepStrictEqual(read.disabledClusters, ['utility']);
} finally {
fs.rmSync(dir, { recursive: true, force: true });
}
});
test('writeSurface creates directory if it does not exist', () => {
const base = tmpDir();
const nested = path.join(base, 'skills', 'subdir');
try {
writeSurface(nested, { baseProfile: 'full', disabledClusters: [], explicitAdds: [], explicitRemoves: [] });
assert.ok(fs.existsSync(nested));
assert.ok(readSurface(nested) !== null);
} finally {
fs.rmSync(base, { recursive: true, force: true });
}
});
});