diff --git a/.changeset/feat-3408-skill-profiles.md b/.changeset/feat-3408-skill-profiles.md new file mode 100644 index 000000000..cedae6956 --- /dev/null +++ b/.changeset/feat-3408-skill-profiles.md @@ -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) diff --git a/.changeset/feat-3408-skill-surface.md b/.changeset/feat-3408-skill-surface.md new file mode 100644 index 000000000..606daadec --- /dev/null +++ b/.changeset/feat-3408-skill-surface.md @@ -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 ` (apply a named profile), `disable/enable ` (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) diff --git a/CONTEXT.md b/CONTEXT.md index e5b04443a..3acb3b32a 100644 --- a/CONTEXT.md +++ b/CONTEXT.md @@ -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 `/.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`. diff --git a/README.md b/README.md index 1bd3cb447..954893ff0 100644 --- a/README.md +++ b/README.md @@ -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)**. diff --git a/bin/install.js b/bin/install.js index 1eb7d2853..349af5b17 100755 --- a/bin/install.js +++ b/bin/install.js @@ -89,6 +89,14 @@ const { MINIMAL_SKILL_ALLOWLIST, isMinimalMode, stageSkillsForMode, + readActiveProfile, + writeActiveProfile, + resolveEffectiveProfile, + mostRestrictiveProfile, + resolveProfile, + loadSkillsManifest, + stageSkillsForProfile, + stageAgentsForProfile, } = require(path.join(_gsdLibDir, 'install-profiles.cjs')); const { discoverInstallerMigrations, @@ -124,10 +132,29 @@ const hasUninstall = args.includes('--uninstall') || args.includes('-u'); const hasSkillsRoot = args.includes('--skills-root'); const hasPortableHooks = args.includes('--portable-hooks') || process.env.GSD_PORTABLE_HOOKS === '1'; const hasMinimal = args.includes('--minimal') || args.includes('--core-only'); -const installMode = hasMinimal ? 'minimal' : 'full'; +// --profile= or --profile=, (composable); mutually exclusive with --minimal +const _profileArgRaw = (() => { + for (const arg of args) { + if (arg.startsWith('--profile=')) return arg.slice('--profile='.length); + } + return null; +})(); +// Resolve active profile name: +// 1. --minimal / --core-only → 'core' (back-compat alias) +// 2. --profile= → named profile +// 3. neither → 'full' (default, back-compat) +// Note: when re-running as `gsd update` the marker is read later (after +// configDir is resolved) and may override 'full' — see writeActiveProfile call below. +const _profileIsCore = _profileArgRaw === 'core'; +const _requestedProfileName = (hasMinimal || _profileIsCore) ? 'core' : (_profileArgRaw || null); const hasSdk = args.includes('--sdk'); const hasNoSdk = args.includes('--no-sdk'); +if (hasMinimal && _profileArgRaw) { + console.error(` ${yellow}Cannot specify both --minimal/--core-only and --profile${reset}`); + process.exit(1); +} + if (hasSdk && hasNoSdk) { console.error(` ${yellow}Cannot specify both --sdk and --no-sdk${reset}`); process.exit(1); @@ -508,7 +535,7 @@ if (hasUninstall) { // Show help if requested if (hasHelp) { - console.log(` ${yellow}Usage:${reset} npx get-shit-done-cc [options]\n\n ${yellow}Options:${reset}\n ${cyan}-g, --global${reset} Install globally (to config directory)\n ${cyan}-l, --local${reset} Install locally (to current directory)\n ${cyan}--claude${reset} Install for Claude Code only\n ${cyan}--opencode${reset} Install for OpenCode only\n ${cyan}--gemini${reset} Install for Gemini only\n ${cyan}--kilo${reset} Install for Kilo only\n ${cyan}--codex${reset} Install for Codex only\n ${cyan}--copilot${reset} Install for Copilot only\n ${cyan}--antigravity${reset} Install for Antigravity only\n ${cyan}--cursor${reset} Install for Cursor only\n ${cyan}--windsurf${reset} Install for Windsurf only\n ${cyan}--augment${reset} Install for Augment only\n ${cyan}--trae${reset} Install for Trae only\n ${cyan}--qwen${reset} Install for Qwen Code only\n ${cyan}--hermes${reset} Install for Hermes Agent only\n ${cyan}--cline${reset} Install for Cline only\n ${cyan}--codebuddy${reset} Install for CodeBuddy only\n ${cyan}--all${reset} Install for all runtimes\n ${cyan}-u, --uninstall${reset} Uninstall GSD (remove all GSD files)\n ${cyan}-c, --config-dir ${reset} Specify custom config directory\n ${cyan}-h, --help${reset} Show this help message\n ${cyan}--force-statusline${reset} Replace existing statusline config\n ${cyan}--portable-hooks${reset} Emit \$HOME-relative hook paths in settings.json\n (for WSL/Docker bind-mount setups; also GSD_PORTABLE_HOOKS=1)\n ${cyan}--minimal${reset} Install only the main-loop skills (new-project,\n discuss-phase, plan-phase, execute-phase, help, update)\n and zero gsd-* subagents. Cuts cold-start system-prompt\n overhead from ~12k tokens to ~700 — useful for local LLMs\n with 32K–128K context. Re-run \`gsd update\` (without --minimal)\n to expand to the full surface. Alias: --core-only.\n\n ${yellow}Examples:${reset}\n ${dim}# Interactive install (prompts for runtime and location)${reset}\n npx get-shit-done-cc\n\n ${dim}# Install for Claude Code globally${reset}\n npx get-shit-done-cc --claude --global\n\n ${dim}# Install for Gemini globally${reset}\n npx get-shit-done-cc --gemini --global\n\n ${dim}# Install for Kilo globally${reset}\n npx get-shit-done-cc --kilo --global\n\n ${dim}# Install for Codex globally${reset}\n npx get-shit-done-cc --codex --global\n\n ${dim}# Install for Copilot globally${reset}\n npx get-shit-done-cc --copilot --global\n\n ${dim}# Install for Copilot locally${reset}\n npx get-shit-done-cc --copilot --local\n\n ${dim}# Install for Antigravity globally${reset}\n npx get-shit-done-cc --antigravity --global\n\n ${dim}# Install for Antigravity locally${reset}\n npx get-shit-done-cc --antigravity --local\n\n ${dim}# Install for Cursor globally${reset}\n npx get-shit-done-cc --cursor --global\n\n ${dim}# Install for Cursor locally${reset}\n npx get-shit-done-cc --cursor --local\n\n ${dim}# Install for Windsurf globally${reset}\n npx get-shit-done-cc --windsurf --global\n\n ${dim}# Install for Windsurf locally${reset}\n npx get-shit-done-cc --windsurf --local\n\n ${dim}# Install for Augment globally${reset}\n npx get-shit-done-cc --augment --global\n\n ${dim}# Install for Augment locally${reset}\n npx get-shit-done-cc --augment --local\n\n ${dim}# Install for Trae globally${reset}\n npx get-shit-done-cc --trae --global\n\n ${dim}# Install for Trae locally${reset}\n npx get-shit-done-cc --trae --local\n\n ${dim}# Install for Hermes Agent globally${reset}\n npx get-shit-done-cc --hermes --global\n\n ${dim}# Install for Hermes Agent locally${reset}\n npx get-shit-done-cc --hermes --local\n\n ${dim}# Install for Cline locally${reset}\n npx get-shit-done-cc --cline --local\n\n ${dim}# Install for CodeBuddy globally${reset}\n npx get-shit-done-cc --codebuddy --global\n\n ${dim}# Install for CodeBuddy locally${reset}\n npx get-shit-done-cc --codebuddy --local\n\n ${dim}# Install for all runtimes globally${reset}\n npx get-shit-done-cc --all --global\n\n ${dim}# Install to custom config directory${reset}\n npx get-shit-done-cc --kilo --global --config-dir ~/.kilo-work\n\n ${dim}# Install to current project only${reset}\n npx get-shit-done-cc --claude --local\n\n ${dim}# Uninstall GSD from Cursor globally${reset}\n npx get-shit-done-cc --cursor --global --uninstall\n\n ${yellow}Notes:${reset}\n The --config-dir option is useful when you have multiple configurations.\n It takes priority over CLAUDE_CONFIG_DIR / OPENCODE_CONFIG_DIR / GEMINI_CONFIG_DIR / KILO_CONFIG_DIR / CODEX_HOME / COPILOT_CONFIG_DIR / ANTIGRAVITY_CONFIG_DIR / CURSOR_CONFIG_DIR / WINDSURF_CONFIG_DIR / AUGMENT_CONFIG_DIR / TRAE_CONFIG_DIR / QWEN_CONFIG_DIR / HERMES_HOME / CLINE_CONFIG_DIR / CODEBUDDY_CONFIG_DIR environment variables.\n`); + console.log(` ${yellow}Usage:${reset} npx get-shit-done-cc [options]\n\n ${yellow}Options:${reset}\n ${cyan}-g, --global${reset} Install globally (to config directory)\n ${cyan}-l, --local${reset} Install locally (to current directory)\n ${cyan}--claude${reset} Install for Claude Code only\n ${cyan}--opencode${reset} Install for OpenCode only\n ${cyan}--gemini${reset} Install for Gemini only\n ${cyan}--kilo${reset} Install for Kilo only\n ${cyan}--codex${reset} Install for Codex only\n ${cyan}--copilot${reset} Install for Copilot only\n ${cyan}--antigravity${reset} Install for Antigravity only\n ${cyan}--cursor${reset} Install for Cursor only\n ${cyan}--windsurf${reset} Install for Windsurf only\n ${cyan}--augment${reset} Install for Augment only\n ${cyan}--trae${reset} Install for Trae only\n ${cyan}--qwen${reset} Install for Qwen Code only\n ${cyan}--hermes${reset} Install for Hermes Agent only\n ${cyan}--cline${reset} Install for Cline only\n ${cyan}--codebuddy${reset} Install for CodeBuddy only\n ${cyan}--all${reset} Install for all runtimes\n ${cyan}-u, --uninstall${reset} Uninstall GSD (remove all GSD files)\n ${cyan}-c, --config-dir ${reset} Specify custom config directory\n ${cyan}-h, --help${reset} Show this help message\n ${cyan}--force-statusline${reset} Replace existing statusline config\n ${cyan}--portable-hooks${reset} Emit \$HOME-relative hook paths in settings.json\n (for WSL/Docker bind-mount setups; also GSD_PORTABLE_HOOKS=1)\n ${cyan}--profile=${reset} Install a named skill profile. Profiles:\n core — 6 main-loop skills only (~87 desc tokens)\n standard — ~13 skills incl. phase, review, config (~700)\n full — all 66 skills (default)\n Composable: --profile=core,audit installs union of closures.\n Profile is persisted and respected by \`gsd update\`.\n ${cyan}--minimal${reset} Alias for --profile=core (back-compat).\n Cuts cold-start overhead from ~12k tokens to ~700.\n Alias: --core-only.\n\n ${yellow}Examples:${reset}\n ${dim}# Interactive install (prompts for runtime and location)${reset}\n npx get-shit-done-cc\n\n ${dim}# Install for Claude Code globally${reset}\n npx get-shit-done-cc --claude --global\n\n ${dim}# Install for Gemini globally${reset}\n npx get-shit-done-cc --gemini --global\n\n ${dim}# Install for Kilo globally${reset}\n npx get-shit-done-cc --kilo --global\n\n ${dim}# Install for Codex globally${reset}\n npx get-shit-done-cc --codex --global\n\n ${dim}# Install for Copilot globally${reset}\n npx get-shit-done-cc --copilot --global\n\n ${dim}# Install for Copilot locally${reset}\n npx get-shit-done-cc --copilot --local\n\n ${dim}# Install for Antigravity globally${reset}\n npx get-shit-done-cc --antigravity --global\n\n ${dim}# Install for Antigravity locally${reset}\n npx get-shit-done-cc --antigravity --local\n\n ${dim}# Install for Cursor globally${reset}\n npx get-shit-done-cc --cursor --global\n\n ${dim}# Install for Cursor locally${reset}\n npx get-shit-done-cc --cursor --local\n\n ${dim}# Install for Windsurf globally${reset}\n npx get-shit-done-cc --windsurf --global\n\n ${dim}# Install for Windsurf locally${reset}\n npx get-shit-done-cc --windsurf --local\n\n ${dim}# Install for Augment globally${reset}\n npx get-shit-done-cc --augment --global\n\n ${dim}# Install for Augment locally${reset}\n npx get-shit-done-cc --augment --local\n\n ${dim}# Install for Trae globally${reset}\n npx get-shit-done-cc --trae --global\n\n ${dim}# Install for Trae locally${reset}\n npx get-shit-done-cc --trae --local\n\n ${dim}# Install for Hermes Agent globally${reset}\n npx get-shit-done-cc --hermes --global\n\n ${dim}# Install for Hermes Agent locally${reset}\n npx get-shit-done-cc --hermes --local\n\n ${dim}# Install for Cline locally${reset}\n npx get-shit-done-cc --cline --local\n\n ${dim}# Install for CodeBuddy globally${reset}\n npx get-shit-done-cc --codebuddy --global\n\n ${dim}# Install for CodeBuddy locally${reset}\n npx get-shit-done-cc --codebuddy --local\n\n ${dim}# Install for all runtimes globally${reset}\n npx get-shit-done-cc --all --global\n\n ${dim}# Install to custom config directory${reset}\n npx get-shit-done-cc --kilo --global --config-dir ~/.kilo-work\n\n ${dim}# Install to current project only${reset}\n npx get-shit-done-cc --claude --local\n\n ${dim}# Uninstall GSD from Cursor globally${reset}\n npx get-shit-done-cc --cursor --global --uninstall\n\n ${yellow}Notes:${reset}\n The --config-dir option is useful when you have multiple configurations.\n It takes priority over CLAUDE_CONFIG_DIR / OPENCODE_CONFIG_DIR / GEMINI_CONFIG_DIR / KILO_CONFIG_DIR / CODEX_HOME / COPILOT_CONFIG_DIR / ANTIGRAVITY_CONFIG_DIR / CURSOR_CONFIG_DIR / WINDSURF_CONFIG_DIR / AUGMENT_CONFIG_DIR / TRAE_CONFIG_DIR / QWEN_CONFIG_DIR / HERMES_HOME / CLINE_CONFIG_DIR / CODEBUDDY_CONFIG_DIR environment variables.\n`); process.exit(0); } @@ -6336,6 +6363,12 @@ function uninstall(isGlobal, runtime = 'claude') { let removedCount = 0; + // Remove profile marker so a clean reinstall defaults to full surface. + try { + fs.unlinkSync(path.join(targetDir, '.gsd-profile')); + removedCount++; + } catch {} + // 1. Remove GSD commands/skills if (isOpencode || isKilo) { // OpenCode/Kilo: remove command/gsd-*.md files @@ -7578,6 +7611,55 @@ function install(isGlobal, runtime = 'claude', options = {}) { // `@$HOME/...` is treated as a literal path relative to the config dir, producing // `command/$HOME/...` (file not found). Use the absolute path for OpenCode so // @-references resolve correctly (#2376 Windows, #2831 macOS/Linux). + // gsd update marker re-application (ADR-0010 Deviation 2): + // Resolve which profile to use for this runtime's install: + // 1. --minimal / --core-only → back-compat path (stageSkillsForMode keeps strict 6-skill list) + // 2. Explicit --profile= → use it (overrides any marker) + // 3. Marker exists in targetDir → honor it (prevents silent expansion on update) + // 4. Else → 'full' (back-compat for fresh non-interactive installs) + // + // Multi-runtime disagreement: if installing across runtimes and their markers + // differ, the caller may use mostRestrictiveProfile() across the per-runtime + // results — here we resolve each runtime independently. + // + // Note: --minimal uses stageSkillsForMode (back-compat: strictly 6 skills, no closure). + // Named profiles (--profile=X or marker-driven) use resolveProfile() for transitive closure. + const _activeProfileName = hasMinimal + ? 'core' // --minimal is a back-compat alias for the core profile; marker records 'core' + : resolveEffectiveProfile({ + requestedProfileName: _requestedProfileName, + targetDir, + }); + const _isCoreProfileAlias = _activeProfileName === 'core'; + const _effectiveInstallMode = _isCoreProfileAlias ? 'minimal' : 'full'; + // Load the manifest and compute resolved profile for named profiles. + // --minimal keeps its own staging path via _stageSkillsFn (see below). + const _commandsDir = path.join(src, 'commands', 'gsd'); + const _skillsManifest = _isCoreProfileAlias ? new Map() : loadSkillsManifest(_commandsDir); + const _resolvedProfile = _isCoreProfileAlias + ? null // --minimal uses stageSkillsForMode at dispatch sites + : resolveProfile({ + modes: [_activeProfileName], + manifest: _skillsManifest, + }); + // Unified staging function: for --minimal uses stageSkillsForMode (back-compat); + // for named profiles uses stageSkillsForProfile (new API with transitive closure). + function _stageSkills(commandsGsdDir) { + if (_isCoreProfileAlias) return stageSkillsForMode(commandsGsdDir, _effectiveInstallMode); + return stageSkillsForProfile(commandsGsdDir, _resolvedProfile); + } + function _stageAgents(agentsDir) { + if (_isCoreProfileAlias) return agentsDir; + return stageAgentsForProfile(agentsDir, _resolvedProfile); + } + const persistActiveProfileMarker = () => { + try { + writeActiveProfile(targetDir, _activeProfileName); + } catch { + // Non-fatal: marker persistence failure doesn't break the install. + } + }; + const resolvedTarget = path.resolve(targetDir).replace(/\\/g, '/'); const homeDir = os.homedir().replace(/\\/g, '/'); const isWindowsHost = process.platform === 'win32'; @@ -7661,7 +7743,7 @@ function install(isGlobal, runtime = 'claude', options = {}) { // Map — content snapshot of each pre-existing gsd-* agent file. const codexPreInstallAgentContents = new Map(); let codexPreInstallVersionBytes = null; - if (isCodex && !isMinimalMode(installMode)) { + if (isCodex && !isMinimalMode(_effectiveInstallMode)) { const _preSkillsDir = path.join(targetDir, 'skills'); if (fs.existsSync(_preSkillsDir)) { for (const entry of fs.readdirSync(_preSkillsDir, { withFileTypes: true })) { @@ -7715,7 +7797,7 @@ function install(isGlobal, runtime = 'claude', options = {}) { // atomic-write temp files. It is safe to call before any writes have happened. // The full restoreCodexSnapshot() (defined inside the config block) additionally // handles config.toml, which is not yet touched at this point in the pipeline. - const _codexPreConfigRollback = !isCodex || isMinimalMode(installMode) ? null : () => { + const _codexPreConfigRollback = !isCodex || isMinimalMode(_effectiveInstallMode) ? null : () => { rollbackInstallerMigrations(); // skills/gsd-* — pass 1: restore snapshot entries (may be absent if deleted mid-install). const _earlySkillsDir = path.join(targetDir, 'skills'); @@ -7826,7 +7908,7 @@ function install(isGlobal, runtime = 'claude', options = {}) { fs.mkdirSync(commandDir, { recursive: true }); // Copy commands/gsd/*.md as command/gsd-*.md (flatten structure) - const gsdSrc = stageSkillsForMode(path.join(src, 'commands', 'gsd'), installMode); + const gsdSrc = _stageSkills(_commandsDir); copyFlattenedCommands(gsdSrc, commandDir, 'gsd', pathPrefix, runtime); if (verifyInstalled(commandDir, 'command/gsd-*')) { const count = fs.readdirSync(commandDir).filter(f => f.startsWith('gsd-')).length; @@ -7836,7 +7918,7 @@ function install(isGlobal, runtime = 'claude', options = {}) { } } else if (isCodex) { const skillsDir = path.join(targetDir, 'skills'); - const gsdSrc = stageSkillsForMode(path.join(src, 'commands', 'gsd'), installMode); + const gsdSrc = _stageSkills(_commandsDir); copyCommandsAsCodexSkills(gsdSrc, skillsDir, 'gsd', pathPrefix, runtime); const installedSkillNames = listCodexSkillNames(skillsDir); if (installedSkillNames.length > 0) { @@ -7846,7 +7928,7 @@ function install(isGlobal, runtime = 'claude', options = {}) { } } else if (isCopilot) { const skillsDir = path.join(targetDir, 'skills'); - const gsdSrc = stageSkillsForMode(path.join(src, 'commands', 'gsd'), installMode); + const gsdSrc = _stageSkills(_commandsDir); copyCommandsAsCopilotSkills(gsdSrc, skillsDir, 'gsd', isGlobal); if (fs.existsSync(skillsDir)) { const count = fs.readdirSync(skillsDir, { withFileTypes: true }) @@ -7861,7 +7943,7 @@ function install(isGlobal, runtime = 'claude', options = {}) { } } else if (isAntigravity) { const skillsDir = path.join(targetDir, 'skills'); - const gsdSrc = stageSkillsForMode(path.join(src, 'commands', 'gsd'), installMode); + const gsdSrc = _stageSkills(_commandsDir); copyCommandsAsAntigravitySkills(gsdSrc, skillsDir, 'gsd', isGlobal); if (fs.existsSync(skillsDir)) { const count = fs.readdirSync(skillsDir, { withFileTypes: true }) @@ -7876,7 +7958,7 @@ function install(isGlobal, runtime = 'claude', options = {}) { } } else if (isCursor) { const skillsDir = path.join(targetDir, 'skills'); - const gsdSrc = stageSkillsForMode(path.join(src, 'commands', 'gsd'), installMode); + const gsdSrc = _stageSkills(_commandsDir); copyCommandsAsCursorSkills(gsdSrc, skillsDir, 'gsd', pathPrefix, runtime); const installedSkillNames = listCodexSkillNames(skillsDir); // reuse — same dir structure if (installedSkillNames.length > 0) { @@ -7886,7 +7968,7 @@ function install(isGlobal, runtime = 'claude', options = {}) { } } else if (isWindsurf) { const skillsDir = path.join(targetDir, 'skills'); - const gsdSrc = stageSkillsForMode(path.join(src, 'commands', 'gsd'), installMode); + const gsdSrc = _stageSkills(_commandsDir); copyCommandsAsWindsurfSkills(gsdSrc, skillsDir, 'gsd', pathPrefix, runtime); const installedSkillNames = listCodexSkillNames(skillsDir); // reuse — same dir structure if (installedSkillNames.length > 0) { @@ -7896,7 +7978,7 @@ function install(isGlobal, runtime = 'claude', options = {}) { } } else if (isAugment) { const skillsDir = path.join(targetDir, 'skills'); - const gsdSrc = stageSkillsForMode(path.join(src, 'commands', 'gsd'), installMode); + const gsdSrc = _stageSkills(_commandsDir); copyCommandsAsAugmentSkills(gsdSrc, skillsDir, 'gsd', pathPrefix, runtime); const installedSkillNames = listCodexSkillNames(skillsDir); if (installedSkillNames.length > 0) { @@ -7906,7 +7988,7 @@ function install(isGlobal, runtime = 'claude', options = {}) { } } else if (isTrae) { const skillsDir = path.join(targetDir, 'skills'); - const gsdSrc = stageSkillsForMode(path.join(src, 'commands', 'gsd'), installMode); + const gsdSrc = _stageSkills(_commandsDir); copyCommandsAsTraeSkills(gsdSrc, skillsDir, 'gsd', pathPrefix, runtime); const installedSkillNames = listCodexSkillNames(skillsDir); if (installedSkillNames.length > 0) { @@ -7916,7 +7998,7 @@ function install(isGlobal, runtime = 'claude', options = {}) { } } else if (isQwen) { const skillsDir = path.join(targetDir, 'skills'); - const gsdSrc = stageSkillsForMode(path.join(src, 'commands', 'gsd'), installMode); + const gsdSrc = _stageSkills(_commandsDir); copyCommandsAsClaudeSkills(gsdSrc, skillsDir, 'gsd', pathPrefix, runtime, isGlobal); if (fs.existsSync(skillsDir)) { const count = fs.readdirSync(skillsDir, { withFileTypes: true }) @@ -7952,7 +8034,7 @@ function install(isGlobal, runtime = 'claude', options = {}) { // gsd/ category dir, alongside a DESCRIPTION.md that Hermes uses as the // category summary. const hermesSkillsDir = path.join(targetDir, 'skills', 'gsd'); - const gsdSrc = stageSkillsForMode(path.join(src, 'commands', 'gsd'), installMode); + const gsdSrc = _stageSkills(_commandsDir); copyCommandsAsClaudeSkills(gsdSrc, hermesSkillsDir, 'gsd', pathPrefix, runtime, isGlobal); writeHermesCategoryDescription(hermesSkillsDir); if (fs.existsSync(hermesSkillsDir)) { @@ -7995,7 +8077,7 @@ function install(isGlobal, runtime = 'claude', options = {}) { } } else if (isCodebuddy) { const skillsDir = path.join(targetDir, 'skills'); - const gsdSrc = stageSkillsForMode(path.join(src, 'commands', 'gsd'), installMode); + const gsdSrc = _stageSkills(_commandsDir); copyCommandsAsCodebuddySkills(gsdSrc, skillsDir, 'gsd', pathPrefix, runtime); const installedSkillNames = listCodexSkillNames(skillsDir); if (installedSkillNames.length > 0) { @@ -8049,7 +8131,7 @@ function install(isGlobal, runtime = 'claude', options = {}) { } else { const commandsDir = path.join(targetDir, 'commands'); fs.mkdirSync(commandsDir, { recursive: true }); - const gsdSrc = stageSkillsForMode(path.join(src, 'commands', 'gsd'), installMode); + const gsdSrc = _stageSkills(_commandsDir); const gsdDest = path.join(commandsDir, 'gsd'); copyWithPathReplacement(gsdSrc, gsdDest, pathPrefix, runtime, true, isGlobal); if (verifyInstalled(gsdDest, 'commands/gsd')) { @@ -8061,7 +8143,7 @@ function install(isGlobal, runtime = 'claude', options = {}) { } else if (isGlobal) { // Claude Code global: skills/ format (2.1.88+ compatibility) const skillsDir = path.join(targetDir, 'skills'); - const gsdSrc = stageSkillsForMode(path.join(src, 'commands', 'gsd'), installMode); + const gsdSrc = _stageSkills(_commandsDir); copyCommandsAsClaudeSkills(gsdSrc, skillsDir, 'gsd', pathPrefix, runtime, isGlobal); if (fs.existsSync(skillsDir)) { const count = fs.readdirSync(skillsDir, { withFileTypes: true }) @@ -8096,7 +8178,7 @@ function install(isGlobal, runtime = 'claude', options = {}) { // commands from .claude/commands/gsd/, not .claude/skills/ const commandsDir = path.join(targetDir, 'commands'); fs.mkdirSync(commandsDir, { recursive: true }); - const gsdSrc = stageSkillsForMode(path.join(src, 'commands', 'gsd'), installMode); + const gsdSrc = _stageSkills(_commandsDir); const gsdDest = path.join(commandsDir, 'gsd'); copyWithPathReplacement(gsdSrc, gsdDest, pathPrefix, runtime, true, isGlobal); if (verifyInstalled(gsdDest, 'commands/gsd')) { @@ -8161,7 +8243,7 @@ function install(isGlobal, runtime = 'claude', options = {}) { // when no GSD workflow is active. See gsd-build/get-shit-done#2762. // Note: agentsSrc is declared as let before the enclosing try block so it // is accessible by installCodexConfig() in the Codex config section below. - agentsSrc = path.join(src, 'agents'); + agentsSrc = _stageAgents(path.join(src, 'agents')); const agentsDest = path.join(targetDir, 'agents'); // Always remove stale gsd-* agents first so re-installing with @@ -8179,7 +8261,7 @@ function install(isGlobal, runtime = 'claude', options = {}) { } } - if (isMinimalMode(installMode)) { + if (isMinimalMode(_effectiveInstallMode)) { // Codex registers agents in `config.toml` via `[agents.gsd-*]` sections. // Without stripping them here, a full → minimal reinstall would leave the // runtime advertising the old full agent surface even though the agent @@ -8380,7 +8462,7 @@ function install(isGlobal, runtime = 'claude', options = {}) { } // Write file manifest for future modification detection - writeManifest(targetDir, runtime, { mode: installMode }); + writeManifest(targetDir, runtime, { mode: _effectiveInstallMode }); console.log(` ${green}✓${reset} Wrote file manifest (${MANIFEST_NAME})`); // Report any backed-up local patches @@ -8452,7 +8534,7 @@ function install(isGlobal, runtime = 'claude', options = {}) { throw _earlyInstallErr; } - if (isCodex && !isMinimalMode(installMode)) { + if (isCodex && !isMinimalMode(_effectiveInstallMode)) { // Capture pre-install snapshot of config.toml before ANY GSD mutation // (#2760 fix 3). On post-write schema-validation failure OR any throw // during the mutation sequence (write failure, merge throw, etc.) we @@ -8591,17 +8673,20 @@ function install(isGlobal, runtime = 'claude', options = {}) { _cleanTmpFiles(targetDir); }; - let agentCount; - try { - // Generate Codex config.toml and per-agent .toml files. - // Skipped under --minimal — same rationale as filesystem agents above. - agentCount = installCodexConfig(targetDir, agentsSrc); - } catch (e) { - restoreCodexSnapshot(); - throw e; + let agentCount = 0; + if (!isMinimalMode(_effectiveInstallMode)) { + try { + // Generate Codex config.toml and per-agent .toml files. + agentCount = installCodexConfig(targetDir, agentsSrc); + } catch (e) { + restoreCodexSnapshot(); + throw e; + } + console.log(` ${green}✓${reset} Generated config.toml with ${agentCount} agent roles`); + console.log(` ${green}✓${reset} Generated ${agentCount} agent .toml config files`); + } else { + console.log(` ${dim}↳${reset} Skipping Codex agent config generation (minimal install)`); } - console.log(` ${green}✓${reset} Generated config.toml with ${agentCount} agent roles`); - console.log(` ${green}✓${reset} Generated ${agentCount} agent .toml config files`); // Copy hook files that are referenced in config.toml (#2153) // The main hook-copy block is gated to non-Codex runtimes, but Codex registers @@ -8768,6 +8853,7 @@ function install(isGlobal, runtime = 'claude', options = {}) { throw wrapped; } + persistActiveProfileMarker(); return { settingsPath: null, settings: null, statuslineCommand: null, updateBannerCommand: null, runtime, configDir: targetDir }; } @@ -8781,21 +8867,25 @@ function install(isGlobal, runtime = 'claude', options = {}) { console.log(` ${green}✓${reset} Generated copilot-instructions.md`); } // Copilot: no settings.json, no hooks, no statusline (like Codex) + persistActiveProfileMarker(); return { settingsPath: null, settings: null, statuslineCommand: null, updateBannerCommand: null, runtime, configDir: targetDir }; } if (isCursor) { // Cursor uses skills — no config.toml, no settings.json hooks needed + persistActiveProfileMarker(); return { settingsPath: null, settings: null, statuslineCommand: null, updateBannerCommand: null, runtime, configDir: targetDir }; } if (isWindsurf) { // Windsurf uses skills — no config.toml, no settings.json hooks needed + persistActiveProfileMarker(); return { settingsPath: null, settings: null, statuslineCommand: null, updateBannerCommand: null, runtime, configDir: targetDir }; } if (isTrae) { // Trae uses skills — no settings.json hooks needed + persistActiveProfileMarker(); return { settingsPath: null, settings: null, statuslineCommand: null, updateBannerCommand: null, runtime, configDir: targetDir }; } @@ -8816,6 +8906,7 @@ function install(isGlobal, runtime = 'claude', options = {}) { ].join('\n') + '\n'; fs.writeFileSync(clinerulesDest, clinerules); console.log(` ${green}✓${reset} Wrote .clinerules`); + persistActiveProfileMarker(); return { settingsPath: null, settings: null, statuslineCommand: null, updateBannerCommand: null, runtime, configDir: targetDir }; } @@ -8826,6 +8917,7 @@ function install(isGlobal, runtime = 'claude', options = {}) { const rawSettings = readSettings(settingsPath); if (rawSettings === null) { console.log(' ' + yellow + 'i' + reset + ' Skipping settings.json configuration — file could not be parsed (comments or malformed JSON). Your existing settings are preserved.'); + persistActiveProfileMarker(); return; } const settings = validateHookFields(cleanupOrphanedHooks(rawSettings)); @@ -9189,6 +9281,7 @@ function install(isGlobal, runtime = 'claude', options = {}) { ? buildHookCommand(targetDir, 'gsd-update-banner.js', hookOpts) : localCmd('gsd-update-banner.js')); + persistActiveProfileMarker(); return { settingsPath, settings, diff --git a/commands/gsd/add-tests.md b/commands/gsd/add-tests.md index 5a06c1b20..eb1233deb 100644 --- a/commands/gsd/add-tests.md +++ b/commands/gsd/add-tests.md @@ -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] --- Generate unit and E2E tests for a completed phase, using its SUMMARY.md, CONTEXT.md, and VERIFICATION.md as specifications. diff --git a/commands/gsd/ai-integration-phase.md b/commands/gsd/ai-integration-phase.md index f35932bfc..54f74eb40 100644 --- a/commands/gsd/ai-integration-phase.md +++ b/commands/gsd/ai-integration-phase.md @@ -13,6 +13,7 @@ allowed-tools: - WebSearch - AskUserQuestion - mcp__context7__* +requires: [phase] --- Create an AI design contract (AI-SPEC.md) for a phase involving AI system development. diff --git a/commands/gsd/audit-fix.md b/commands/gsd/audit-fix.md index c7e5bd6a5..5ed681f12 100644 --- a/commands/gsd/audit-fix.md +++ b/commands/gsd/audit-fix.md @@ -12,6 +12,7 @@ allowed-tools: - Glob - Agent - AskUserQuestion +requires: [audit-uat] --- Run an audit, classify findings as auto-fixable vs manual-only, then autonomously fix diff --git a/commands/gsd/audit-milestone.md b/commands/gsd/audit-milestone.md index 3f83605ea..3b573833e 100644 --- a/commands/gsd/audit-milestone.md +++ b/commands/gsd/audit-milestone.md @@ -9,6 +9,7 @@ allowed-tools: - Bash - Agent - Write +requires: [execute-phase] --- Verify milestone achieved its definition of done. Check requirements coverage, cross-phase integration, and end-to-end flows. diff --git a/commands/gsd/autonomous.md b/commands/gsd/autonomous.md index ae2b76dc3..a0ec677b8 100644 --- a/commands/gsd/autonomous.md +++ b/commands/gsd/autonomous.md @@ -10,6 +10,7 @@ allowed-tools: - Grep - AskUserQuestion - Agent +requires: [cleanup, phase, progress] --- Execute all remaining milestone phases autonomously. For each phase: discuss → plan → execute. Pauses only for user decisions (grey area acceptance, blockers, validation requests). diff --git a/commands/gsd/cleanup.md b/commands/gsd/cleanup.md index 71e7eb373..38a70f6b9 100644 --- a/commands/gsd/cleanup.md +++ b/commands/gsd/cleanup.md @@ -6,6 +6,7 @@ allowed-tools: - Write - Bash - AskUserQuestion +requires: [phase] --- Archive phase directories from completed milestones into `.planning/milestones/v{X.Y}-phases/`. diff --git a/commands/gsd/code-review.md b/commands/gsd/code-review.md index 7d1484776..3dc3509ab 100644 --- a/commands/gsd/code-review.md +++ b/commands/gsd/code-review.md @@ -9,6 +9,7 @@ allowed-tools: - Grep - Write - Agent +requires: [config, import, phase, quick, review] --- Review source files changed during a phase for bugs, security vulnerabilities, and code quality problems. diff --git a/commands/gsd/complete-milestone.md b/commands/gsd/complete-milestone.md index 63b31ff42..fcd4e71bc 100644 --- a/commands/gsd/complete-milestone.md +++ b/commands/gsd/complete-milestone.md @@ -7,6 +7,7 @@ allowed-tools: - Read - Write - Bash +requires: [audit-milestone, discuss-phase, execute-phase, new-milestone, phase, plan-phase, stats, update] --- diff --git a/commands/gsd/config.md b/commands/gsd/config.md index e7ab022c9..274d68cbb 100644 --- a/commands/gsd/config.md +++ b/commands/gsd/config.md @@ -7,6 +7,7 @@ allowed-tools: - Write - Bash - AskUserQuestion +requires: [code-review, review, settings] --- diff --git a/commands/gsd/discuss-phase.md b/commands/gsd/discuss-phase.md index d254e1452..757c7c543 100644 --- a/commands/gsd/discuss-phase.md +++ b/commands/gsd/discuss-phase.md @@ -12,6 +12,7 @@ allowed-tools: - Agent - mcp__context7__resolve-library-id - mcp__context7__query-docs +requires: [config, phase] --- diff --git a/commands/gsd/docs-update.md b/commands/gsd/docs-update.md index 238a7d210..b16630b94 100644 --- a/commands/gsd/docs-update.md +++ b/commands/gsd/docs-update.md @@ -11,6 +11,7 @@ allowed-tools: - Grep - Agent - AskUserQuestion +requires: [update] --- 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. diff --git a/commands/gsd/eval-review.md b/commands/gsd/eval-review.md index b8c524548..d72274307 100644 --- a/commands/gsd/eval-review.md +++ b/commands/gsd/eval-review.md @@ -10,6 +10,7 @@ allowed-tools: - Grep - Agent - AskUserQuestion +requires: [phase] --- Conduct a retroactive evaluation coverage audit of a completed AI phase. diff --git a/commands/gsd/execute-phase.md b/commands/gsd/execute-phase.md index a2fcfa5d7..a58bffc5f 100644 --- a/commands/gsd/execute-phase.md +++ b/commands/gsd/execute-phase.md @@ -12,6 +12,7 @@ allowed-tools: - Agent - TodoWrite - AskUserQuestion +requires: [phase, verify-work] --- Execute all plans in a phase using wave-based parallel execution. diff --git a/commands/gsd/extract-learnings.md b/commands/gsd/extract-learnings.md index 5e27e72ab..c6c4b8b80 100644 --- a/commands/gsd/extract-learnings.md +++ b/commands/gsd/extract-learnings.md @@ -10,6 +10,7 @@ allowed-tools: - Glob - Agent type: prompt +requires: [phase] --- 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. diff --git a/commands/gsd/fast.md b/commands/gsd/fast.md index a465b9d49..04f6cd98a 100644 --- a/commands/gsd/fast.md +++ b/commands/gsd/fast.md @@ -9,6 +9,7 @@ allowed-tools: - Bash - Grep - Glob +requires: [config, quick] --- diff --git a/commands/gsd/forensics.md b/commands/gsd/forensics.md index 1a4cc0997..dbd72a6ab 100644 --- a/commands/gsd/forensics.md +++ b/commands/gsd/forensics.md @@ -9,6 +9,7 @@ allowed-tools: - Bash - Grep - Glob +requires: [phase, progress, update] --- diff --git a/commands/gsd/graphify.md b/commands/gsd/graphify.md index d353e9f26..d17a9e5af 100644 --- a/commands/gsd/graphify.md +++ b/commands/gsd/graphify.md @@ -5,6 +5,7 @@ argument-hint: "[build|query |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.** diff --git a/commands/gsd/health.md b/commands/gsd/health.md index 72072801b..8ea92e590 100644 --- a/commands/gsd/health.md +++ b/commands/gsd/health.md @@ -7,6 +7,7 @@ allowed-tools: - Bash - Write - AskUserQuestion +requires: [thread] --- Validate `.planning/` directory integrity and report actionable issues. Checks for missing files, invalid configurations, inconsistent state, and orphaned plans. diff --git a/commands/gsd/inbox.md b/commands/gsd/inbox.md index a756fbbbb..bfd483457 100644 --- a/commands/gsd/inbox.md +++ b/commands/gsd/inbox.md @@ -9,6 +9,7 @@ allowed-tools: - Grep - Glob - AskUserQuestion +requires: [review] --- One-command triage of the project's GitHub inbox. Fetches all open issues and PRs, diff --git a/commands/gsd/manager.md b/commands/gsd/manager.md index 24bf9923a..93515bcd5 100644 --- a/commands/gsd/manager.md +++ b/commands/gsd/manager.md @@ -11,6 +11,7 @@ allowed-tools: - AskUserQuestion - Skill - Agent +requires: [phase] --- 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. diff --git a/commands/gsd/map-codebase.md b/commands/gsd/map-codebase.md index d0c554fd7..3291594a1 100644 --- a/commands/gsd/map-codebase.md +++ b/commands/gsd/map-codebase.md @@ -9,6 +9,7 @@ allowed-tools: - Grep - Write - Agent +requires: [config, new-project, plan-phase] --- diff --git a/commands/gsd/mvp-phase.md b/commands/gsd/mvp-phase.md index 0ab22ee0d..ff0e2972c 100644 --- a/commands/gsd/mvp-phase.md +++ b/commands/gsd/mvp-phase.md @@ -10,6 +10,7 @@ allowed-tools: - Grep - Agent - AskUserQuestion +requires: [new-project, phase, plan-phase] --- Guide the user through MVP-mode planning for a phase. The command: diff --git a/commands/gsd/new-milestone.md b/commands/gsd/new-milestone.md index 5c2b0a9de..f9ff013fe 100644 --- a/commands/gsd/new-milestone.md +++ b/commands/gsd/new-milestone.md @@ -8,6 +8,7 @@ allowed-tools: - Bash - Agent - AskUserQuestion +requires: [new-project, phase, plan-phase] --- Start a new milestone: questioning → research (optional) → requirements → roadmap. diff --git a/commands/gsd/new-project.md b/commands/gsd/new-project.md index f40209120..34209f835 100644 --- a/commands/gsd/new-project.md +++ b/commands/gsd/new-project.md @@ -8,6 +8,7 @@ allowed-tools: - Write - Agent - AskUserQuestion +requires: [config, phase, plan-phase] --- **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. diff --git a/commands/gsd/ns-context.md b/commands/gsd/ns-context.md index 3122949d3..feb952a22 100644 --- a/commands/gsd/ns-context.md +++ b/commands/gsd/ns-context.md @@ -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. diff --git a/commands/gsd/ns-ideate.md b/commands/gsd/ns-ideate.md index 988a5c266..429202218 100644 --- a/commands/gsd/ns-ideate.md +++ b/commands/gsd/ns-ideate.md @@ -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. diff --git a/commands/gsd/ns-manage.md b/commands/gsd/ns-manage.md index ffceb32c7..f7cdc288c 100644 --- a/commands/gsd/ns-manage.md +++ b/commands/gsd/ns-manage.md @@ -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. diff --git a/commands/gsd/ns-review.md b/commands/gsd/ns-review.md index 97e30a4d2..cbcc19390 100644 --- a/commands/gsd/ns-review.md +++ b/commands/gsd/ns-review.md @@ -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. diff --git a/commands/gsd/ns-workflow.md b/commands/gsd/ns-workflow.md index 680ef0457..231990326 100644 --- a/commands/gsd/ns-workflow.md +++ b/commands/gsd/ns-workflow.md @@ -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. diff --git a/commands/gsd/pause-work.md b/commands/gsd/pause-work.md index 945ddafa9..abd5cf06d 100644 --- a/commands/gsd/pause-work.md +++ b/commands/gsd/pause-work.md @@ -6,6 +6,7 @@ allowed-tools: - Read - Write - Bash +requires: [phase, progress] --- diff --git a/commands/gsd/plan-phase.md b/commands/gsd/plan-phase.md index e62e84590..9da6c78f6 100644 --- a/commands/gsd/plan-phase.md +++ b/commands/gsd/plan-phase.md @@ -12,6 +12,7 @@ allowed-tools: - AskUserQuestion - WebFetch - mcp__context7__* +requires: [discuss-phase, phase, review, update] --- Create executable phase prompts (PLAN.md files) for a roadmap phase with integrated research and verification. diff --git a/commands/gsd/plan-review-convergence.md b/commands/gsd/plan-review-convergence.md index 462e56fa9..e3a8f516f 100644 --- a/commands/gsd/plan-review-convergence.md +++ b/commands/gsd/plan-review-convergence.md @@ -10,6 +10,7 @@ allowed-tools: - Grep - Agent - AskUserQuestion +requires: [phase, review] --- diff --git a/commands/gsd/pr-branch.md b/commands/gsd/pr-branch.md index 8b405c125..e46cdada9 100644 --- a/commands/gsd/pr-branch.md +++ b/commands/gsd/pr-branch.md @@ -6,6 +6,7 @@ allowed-tools: - Bash - Read - AskUserQuestion +requires: [review] --- diff --git a/commands/gsd/progress.md b/commands/gsd/progress.md index 1c76d2769..e85cc4517 100644 --- a/commands/gsd/progress.md +++ b/commands/gsd/progress.md @@ -9,6 +9,7 @@ allowed-tools: - Glob - SlashCommand - AskUserQuestion +requires: [phase] --- Check project progress, summarize recent work and what's ahead, then intelligently route to the next action. diff --git a/commands/gsd/quick.md b/commands/gsd/quick.md index 16f96b24c..1382da66e 100644 --- a/commands/gsd/quick.md +++ b/commands/gsd/quick.md @@ -11,6 +11,7 @@ allowed-tools: - Bash - Agent - AskUserQuestion +requires: [phase] --- Execute small, ad-hoc tasks with GSD guarantees (atomic commits, STATE.md tracking). diff --git a/commands/gsd/review-backlog.md b/commands/gsd/review-backlog.md index a8188caaa..a691b2ca3 100644 --- a/commands/gsd/review-backlog.md +++ b/commands/gsd/review-backlog.md @@ -6,6 +6,7 @@ allowed-tools: - Write - Bash - AskUserQuestion +requires: [phase, review] --- diff --git a/commands/gsd/review.md b/commands/gsd/review.md index 1d08e4f6e..5dccb5716 100644 --- a/commands/gsd/review.md +++ b/commands/gsd/review.md @@ -8,6 +8,7 @@ allowed-tools: - Bash - Glob - Grep +requires: [config, phase, plan-phase] --- diff --git a/commands/gsd/secure-phase.md b/commands/gsd/secure-phase.md index 7ab7d2c46..c4139558c 100644 --- a/commands/gsd/secure-phase.md +++ b/commands/gsd/secure-phase.md @@ -11,6 +11,7 @@ allowed-tools: - Grep - Agent - AskUserQuestion +requires: [phase] --- Verify threat mitigations for a completed phase. Three states: diff --git a/commands/gsd/settings.md b/commands/gsd/settings.md index ab5ec17d1..4bd0b11b5 100644 --- a/commands/gsd/settings.md +++ b/commands/gsd/settings.md @@ -6,6 +6,7 @@ allowed-tools: - Write - Bash - AskUserQuestion +requires: [quick] --- diff --git a/commands/gsd/ship.md b/commands/gsd/ship.md index 124695553..a62459d84 100644 --- a/commands/gsd/ship.md +++ b/commands/gsd/ship.md @@ -9,6 +9,7 @@ allowed-tools: - Glob - Write - AskUserQuestion +requires: [review, verify-work] --- 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. diff --git a/commands/gsd/sketch.md b/commands/gsd/sketch.md index 31ad668fa..79eb2fb93 100644 --- a/commands/gsd/sketch.md +++ b/commands/gsd/sketch.md @@ -14,6 +14,7 @@ allowed-tools: - WebFetch - mcp__context7__resolve-library-id - mcp__context7__query-docs +requires: [spike] --- 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. diff --git a/commands/gsd/spec-phase.md b/commands/gsd/spec-phase.md index c81bec050..238f1cbf4 100644 --- a/commands/gsd/spec-phase.md +++ b/commands/gsd/spec-phase.md @@ -9,6 +9,7 @@ allowed-tools: - Glob - Grep - AskUserQuestion +requires: [discuss-phase, execute-phase, phase, plan-phase] --- diff --git a/commands/gsd/spike.md b/commands/gsd/spike.md index 04a00fc74..2d124da33 100644 --- a/commands/gsd/spike.md +++ b/commands/gsd/spike.md @@ -14,6 +14,7 @@ allowed-tools: - WebFetch - mcp__context7__resolve-library-id - mcp__context7__query-docs +requires: [] --- 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. diff --git a/commands/gsd/stats.md b/commands/gsd/stats.md index 2135b17ab..2c3758119 100644 --- a/commands/gsd/stats.md +++ b/commands/gsd/stats.md @@ -4,6 +4,7 @@ description: Display project statistics — phases, plans, requirements, git met allowed-tools: - Read - Bash +requires: [phase, progress] --- Display comprehensive project statistics including phase progress, plan execution metrics, requirements completion, git history stats, and project timeline. diff --git a/commands/gsd/surface.md b/commands/gsd/surface.md new file mode 100644 index 000000000..1da042762 --- /dev/null +++ b/commands/gsd/surface.md @@ -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 |disable |enable |reset]" +allowed-tools: + - Read + - Write + - Bash +requires: [config, update] +--- + + +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 + + +## 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 ` | Write `baseProfile` and re-stage | +| `profile ,` | Composed profiles (comma-separated, no spaces) | +| `disable ` | Add cluster to `disabledClusters`, re-stage | +| `enable ` | 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 \ + +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 ``. N skills enabled." + +--- + +## disable \ + +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 ``. N skills removed from surface." + +--- + +## enable \ + +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 ``. 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 ``." + +--- + +## 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." + + +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` + diff --git a/commands/gsd/thread.md b/commands/gsd/thread.md index 12d583541..ee49ca72f 100644 --- a/commands/gsd/thread.md +++ b/commands/gsd/thread.md @@ -6,6 +6,7 @@ allowed-tools: - Read - Write - Bash +requires: [phase] --- diff --git a/commands/gsd/ui-phase.md b/commands/gsd/ui-phase.md index 83335c33e..9e8c70806 100644 --- a/commands/gsd/ui-phase.md +++ b/commands/gsd/ui-phase.md @@ -12,6 +12,7 @@ allowed-tools: - WebFetch - AskUserQuestion - mcp__context7__* +requires: [phase] --- Create a UI design contract (UI-SPEC.md) for a frontend phase. diff --git a/commands/gsd/ui-review.md b/commands/gsd/ui-review.md index c2b3c942b..92e103b3c 100644 --- a/commands/gsd/ui-review.md +++ b/commands/gsd/ui-review.md @@ -10,6 +10,7 @@ allowed-tools: - Grep - Agent - AskUserQuestion +requires: [phase] --- Conduct a retroactive 6-pillar visual audit. Produces UI-REVIEW.md with diff --git a/commands/gsd/ultraplan-phase.md b/commands/gsd/ultraplan-phase.md index 9d5a1b7dd..7fd103fdd 100644 --- a/commands/gsd/ultraplan-phase.md +++ b/commands/gsd/ultraplan-phase.md @@ -7,6 +7,7 @@ allowed-tools: - Bash - Glob - Grep +requires: [import, phase, plan-phase] --- diff --git a/commands/gsd/undo.md b/commands/gsd/undo.md index d8f4fb310..45fdc5d79 100644 --- a/commands/gsd/undo.md +++ b/commands/gsd/undo.md @@ -8,6 +8,7 @@ allowed-tools: - Glob - Grep - AskUserQuestion +requires: [phase] --- diff --git a/commands/gsd/validate-phase.md b/commands/gsd/validate-phase.md index 7029e4c9e..985bde486 100644 --- a/commands/gsd/validate-phase.md +++ b/commands/gsd/validate-phase.md @@ -11,6 +11,7 @@ allowed-tools: - Grep - Agent - AskUserQuestion +requires: [phase] --- Audit Nyquist validation coverage for a completed phase. Three states: diff --git a/commands/gsd/verify-work.md b/commands/gsd/verify-work.md index 88cb6653e..0744c565d 100644 --- a/commands/gsd/verify-work.md +++ b/commands/gsd/verify-work.md @@ -10,6 +10,7 @@ allowed-tools: - Edit - Write - Agent +requires: [execute-phase, phase] --- Validate built features through conversational testing with persistent state. diff --git a/commands/gsd/workstreams.md b/commands/gsd/workstreams.md index 6a8346477..0b3a451b3 100644 --- a/commands/gsd/workstreams.md +++ b/commands/gsd/workstreams.md @@ -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 diff --git a/docs/COMMANDS.md b/docs/COMMANDS.md index 43ede783d..222e42f64 100644 --- a/docs/COMMANDS.md +++ b/docs/COMMANDS.md @@ -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 ` | Write `baseProfile` and re-stage skills | +| `disable ` | Add cluster to disabled list and re-stage | +| `enable ` | 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 diff --git a/docs/INVENTORY-MANIFEST.json b/docs/INVENTORY-MANIFEST.json index e23998782..284e65db6 100644 --- a/docs/INVENTORY-MANIFEST.json +++ b/docs/INVENTORY-MANIFEST.json @@ -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", diff --git a/docs/INVENTORY.md b/docs/INVENTORY.md index bd2e8d453..4d7f6d006 100644 --- a/docs/INVENTORY.md +++ b/docs/INVENTORY.md @@ -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` | diff --git a/docs/adr/0011-skill-surface-budget-module.md b/docs/adr/0011-skill-surface-budget-module.md new file mode 100644 index 000000000..ec2ed85df --- /dev/null +++ b/docs/adr/0011-skill-surface-budget-module.md @@ -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 + +Every installed `gsd-*` skill costs eager system-prompt tokens: runtimes (Claude Code, opencode, and others) enumerate all skill descriptions in `` 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 ` — switch the active profile and re-stage skills/agents for the current runtime + - `disable ` — mark a cluster disabled; re-stage to remove its skills from the runtime config dir + - `enable ` — 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 `/.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=` 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 `, `disable `, `enable `, `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: `/.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 ` 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` diff --git a/docs/adr/README.md b/docs/adr/README.md index b1e0eab86..54289bcf5 100644 --- a/docs/adr/README.md +++ b/docs/adr/README.md @@ -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=`, `.gsd-profile` marker, `requires:` closure) +and the Phase 2 runtime `/gsd:surface` command for cluster-level enable/disable +without reinstall. diff --git a/docs/research/2026-05-12-skill-surface-budget.md b/docs/research/2026-05-12-skill-surface-budget.md new file mode 100644 index 000000000..77be3d35c --- /dev/null +++ b/docs/research/2026-05-12-skill-surface-budget.md @@ -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 `` 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 `` — 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//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 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=` (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. diff --git a/docs/research/data/2026-05-12-skill-audit.json b/docs/research/data/2026-05-12-skill-audit.json new file mode 100644 index 000000000..c0c8d21fe --- /dev/null +++ b/docs/research/data/2026-05-12-skill-audit.json @@ -0,0 +1,1018 @@ +{ + "summary": { + "skill_count": 66, + "agent_count": 33, + "total_description_chars": 4787, + "estimated_description_tokens": 1196 + }, + "skills": [ + { + "stem": "add-tests", + "name": "gsd:add-tests", + "desc": "Generate tests for a completed phase based on UAT criteria and implementation", + "desc_chars": 77, + "calls_skills": [ + "phase" + ], + "calls_agents": [], + "called_by": [] + }, + { + "stem": "ai-integration-phase", + "name": "gsd:ai-integration-phase", + "desc": "Generate an AI-SPEC.md design contract for phases that involve building AI systems.", + "desc_chars": 83, + "calls_skills": [ + "phase" + ], + "calls_agents": [ + "gsd-ai-researcher", + "gsd-domain-researcher", + "gsd-eval-planner", + "gsd-framework-selector" + ], + "called_by": [] + }, + { + "stem": "audit-fix", + "name": "gsd:audit-fix", + "desc": "Autonomous audit-to-fix pipeline \u2014 find issues, classify, fix, test, commit", + "desc_chars": 75, + "calls_skills": [ + "audit-uat" + ], + "calls_agents": [], + "called_by": [] + }, + { + "stem": "audit-milestone", + "name": "gsd:audit-milestone", + "desc": "Audit milestone completion against original intent before archiving", + "desc_chars": 67, + "calls_skills": [ + "execute-phase" + ], + "calls_agents": [], + "called_by": [] + }, + { + "stem": "audit-uat", + "name": "gsd:audit-uat", + "desc": "Cross-phase audit of all outstanding UAT and verification items", + "desc_chars": 63, + "calls_skills": [], + "calls_agents": [], + "called_by": [ + "audit-fix" + ] + }, + { + "stem": "autonomous", + "name": "gsd:autonomous", + "desc": "Run all remaining phases autonomously \u2014 discuss\u2192plan\u2192execute per phase", + "desc_chars": 70, + "calls_skills": [ + "cleanup", + "phase", + "progress" + ], + "calls_agents": [], + "called_by": [] + }, + { + "stem": "capture", + "name": "gsd:capture", + "desc": "Capture ideas, tasks, notes, and seeds to their destination", + "desc_chars": 59, + "calls_skills": [], + "calls_agents": [], + "called_by": [ + "ns-ideate" + ] + }, + { + "stem": "cleanup", + "name": "gsd:cleanup", + "desc": "Archive accumulated phase directories from completed milestones", + "desc_chars": 63, + "calls_skills": [ + "phase" + ], + "calls_agents": [], + "called_by": [ + "autonomous" + ] + }, + { + "stem": "code-review", + "name": "gsd:code-review", + "desc": "Review source files changed during a phase for bugs, security issues, and code quality problems", + "desc_chars": 95, + "calls_skills": [ + "config", + "import", + "phase", + "quick", + "review" + ], + "calls_agents": [ + "gsd-code-fixer", + "gsd-code-reviewer" + ], + "called_by": [ + "config" + ] + }, + { + "stem": "complete-milestone", + "name": "gsd:complete-milestone", + "desc": "Archive completed milestone and prepare for next version", + "desc_chars": 56, + "calls_skills": [ + "phase", + "stats", + "update" + ], + "calls_agents": [], + "called_by": [] + }, + { + "stem": "config", + "name": "gsd:config", + "desc": "Configure GSD settings \u2014 workflow toggles, advanced knobs, integrations, and model profile", + "desc_chars": 90, + "calls_skills": [ + "code-review", + "review", + "settings" + ], + "calls_agents": [], + "called_by": [ + "code-review", + "discuss-phase", + "fast", + "graphify", + "map-codebase", + "new-project", + "review" + ] + }, + { + "stem": "debug", + "name": "gsd:debug", + "desc": "Systematic debugging with persistent state across context resets", + "desc_chars": 64, + "calls_skills": [], + "calls_agents": [ + "gsd-debug-session-manager", + "gsd-debugger" + ], + "called_by": [] + }, + { + "stem": "discuss-phase", + "name": "gsd:discuss-phase", + "desc": "Gather phase context through adaptive questioning before planning.", + "desc_chars": 66, + "calls_skills": [ + "config", + "phase" + ], + "calls_agents": [], + "called_by": [ + "plan-phase", + "spec-phase" + ] + }, + { + "stem": "docs-update", + "name": "gsd:docs-update", + "desc": "Generate or update project documentation verified against the codebase", + "desc_chars": 70, + "calls_skills": [ + "update" + ], + "calls_agents": [ + "gsd-doc-verifier", + "gsd-doc-writer" + ], + "called_by": [] + }, + { + "stem": "eval-review", + "name": "gsd:eval-review", + "desc": "Audit an executed AI phase's evaluation coverage and produce an EVAL-REVIEW.md remediation plan.", + "desc_chars": 96, + "calls_skills": [ + "phase" + ], + "calls_agents": [], + "called_by": [] + }, + { + "stem": "execute-phase", + "name": "gsd:execute-phase", + "desc": "Execute all plans in a phase with wave-based parallelization", + "desc_chars": 60, + "calls_skills": [ + "phase", + "verify-work" + ], + "calls_agents": [], + "called_by": [ + "audit-milestone", + "spec-phase" + ] + }, + { + "stem": "explore", + "name": "gsd:explore", + "desc": "Socratic ideation and idea routing \u2014 think through ideas before committing to plans", + "desc_chars": 83, + "calls_skills": [], + "calls_agents": [], + "called_by": [] + }, + { + "stem": "extract-learnings", + "name": "gsd:extract-learnings", + "desc": "Extract decisions, lessons, patterns, and surprises from completed phase artifacts", + "desc_chars": 82, + "calls_skills": [ + "phase" + ], + "calls_agents": [], + "called_by": [] + }, + { + "stem": "fast", + "name": "gsd:fast", + "desc": "Execute a trivial task inline \u2014 no subagents, no planning overhead", + "desc_chars": 66, + "calls_skills": [ + "config" + ], + "calls_agents": [], + "called_by": [ + "graphify" + ] + }, + { + "stem": "forensics", + "name": "gsd:forensics", + "desc": "Post-mortem investigation for failed GSD workflows \u2014 diagnoses what went wrong.", + "desc_chars": 79, + "calls_skills": [ + "phase", + "progress", + "update" + ], + "calls_agents": [], + "called_by": [] + }, + { + "stem": "graphify", + "name": "gsd:graphify", + "desc": "Build, query, and inspect the project knowledge graph in .planning/graphs/", + "desc_chars": 74, + "calls_skills": [ + "config", + "fast", + "phase", + "update" + ], + "calls_agents": [], + "called_by": [] + }, + { + "stem": "health", + "name": "gsd:health", + "desc": "Diagnose planning directory health and optionally repair issues", + "desc_chars": 63, + "calls_skills": [], + "calls_agents": [], + "called_by": [] + }, + { + "stem": "help", + "name": "gsd:help", + "desc": "Show available GSD commands and usage guide", + "desc_chars": 43, + "calls_skills": [], + "calls_agents": [], + "called_by": [] + }, + { + "stem": "import", + "name": "gsd:import", + "desc": "Ingest external plans with conflict detection against project decisions before writing anything.", + "desc_chars": 96, + "calls_skills": [], + "calls_agents": [ + "gsd-plan-checker" + ], + "called_by": [ + "code-review", + "ultraplan-phase" + ] + }, + { + "stem": "inbox", + "name": "gsd:inbox", + "desc": "Triage and review open GitHub issues and PRs against project templates and contribution guidelines.", + "desc_chars": 99, + "calls_skills": [ + "review" + ], + "calls_agents": [], + "called_by": [ + "ns-manage" + ] + }, + { + "stem": "ingest-docs", + "name": "gsd:ingest-docs", + "desc": "Bootstrap or merge a .planning/ setup from existing ADRs, PRDs, SPECs, and docs in a repo.", + "desc_chars": 90, + "calls_skills": [], + "calls_agents": [ + "gsd-roadmapper" + ], + "called_by": [] + }, + { + "stem": "manager", + "name": "gsd:manager", + "desc": "Interactive command center for managing multiple phases from one terminal", + "desc_chars": 73, + "calls_skills": [ + "phase" + ], + "calls_agents": [], + "called_by": [] + }, + { + "stem": "map-codebase", + "name": "gsd:map-codebase", + "desc": "Analyze codebase with parallel mapper agents to produce .planning/codebase/ documents", + "desc_chars": 85, + "calls_skills": [ + "config" + ], + "calls_agents": [ + "gsd-codebase-mapper" + ], + "called_by": [] + }, + { + "stem": "milestone-summary", + "name": "gsd:milestone-summary", + "desc": "Generate a comprehensive project summary from milestone artifacts for team onboarding and review", + "desc_chars": 96, + "calls_skills": [ + "review" + ], + "calls_agents": [], + "called_by": [] + }, + { + "stem": "mvp-phase", + "name": "gsd:mvp-phase", + "desc": "Plan a phase as a vertical MVP slice \u2014 user story, SPIDR splitting, then plan-phase", + "desc_chars": 83, + "calls_skills": [ + "new-project", + "phase", + "plan-phase" + ], + "calls_agents": [], + "called_by": [] + }, + { + "stem": "new-milestone", + "name": "gsd:new-milestone", + "desc": "Start a new milestone cycle \u2014 update PROJECT.md and route to requirements", + "desc_chars": 73, + "calls_skills": [ + "new-project", + "phase" + ], + "calls_agents": [], + "called_by": [] + }, + { + "stem": "new-project", + "name": "gsd:new-project", + "desc": "Initialize a new project with deep context gathering and PROJECT.md", + "desc_chars": 67, + "calls_skills": [ + "config", + "phase" + ], + "calls_agents": [], + "called_by": [ + "mvp-phase", + "new-milestone" + ] + }, + { + "stem": "ns-context", + "name": "gsd-context", + "desc": "codebase intelligence | map graphify docs learnings", + "desc_chars": 51, + "calls_skills": [ + "phase" + ], + "calls_agents": [], + "called_by": [] + }, + { + "stem": "ns-ideate", + "name": "gsd-ideate", + "desc": "exploration capture | explore sketch spike spec capture", + "desc_chars": 55, + "calls_skills": [ + "capture", + "phase", + "spike" + ], + "calls_agents": [], + "called_by": [] + }, + { + "stem": "ns-manage", + "name": "gsd-manage", + "desc": "config workspace | workstreams thread update ship inbox", + "desc_chars": 55, + "calls_skills": [ + "inbox", + "settings", + "thread", + "workstreams" + ], + "calls_agents": [], + "called_by": [] + }, + { + "stem": "ns-project", + "name": "gsd-project", + "desc": "project lifecycle | milestones audits summary", + "desc_chars": 45, + "calls_skills": [], + "calls_agents": [], + "called_by": [] + }, + { + "stem": "ns-review", + "name": "gsd-quality", + "desc": "quality gates | code review debug audit security eval ui", + "desc_chars": 56, + "calls_skills": [ + "phase", + "review" + ], + "calls_agents": [], + "called_by": [] + }, + { + "stem": "ns-workflow", + "name": "gsd-workflow", + "desc": "workflow | discuss plan execute verify phase progress", + "desc_chars": 53, + "calls_skills": [ + "phase", + "review" + ], + "calls_agents": [], + "called_by": [] + }, + { + "stem": "pause-work", + "name": "gsd:pause-work", + "desc": "Create context handoff when pausing work mid-phase", + "desc_chars": 50, + "calls_skills": [ + "phase", + "progress" + ], + "calls_agents": [], + "called_by": [] + }, + { + "stem": "phase", + "name": "gsd:phase", + "desc": "CRUD for phases in ROADMAP.md \u2014 add, insert, remove, or edit phases", + "desc_chars": 67, + "calls_skills": [], + "calls_agents": [], + "called_by": [ + "add-tests", + "ai-integration-phase", + "autonomous", + "cleanup", + "code-review", + "complete-milestone", + "discuss-phase", + "eval-review", + "execute-phase", + "extract-learnings", + "forensics", + "graphify", + "manager", + "mvp-phase", + "new-milestone", + "new-project", + "ns-context", + "ns-ideate", + "ns-review", + "ns-workflow", + "pause-work", + "plan-phase", + "plan-review-convergence", + "progress", + "quick", + "review", + "review-backlog", + "secure-phase", + "spec-phase", + "stats", + "thread", + "ui-phase", + "ui-review", + "ultraplan-phase", + "undo", + "validate-phase", + "verify-work", + "workstreams" + ] + }, + { + "stem": "plan-phase", + "name": "gsd:plan-phase", + "desc": "Create detailed phase plan (PLAN.md) with verification loop", + "desc_chars": 59, + "calls_skills": [ + "discuss-phase", + "phase", + "review", + "update" + ], + "calls_agents": [ + "gsd-phase-researcher", + "gsd-plan-checker", + "gsd-planner" + ], + "called_by": [ + "mvp-phase", + "spec-phase" + ] + }, + { + "stem": "plan-review-convergence", + "name": "gsd:plan-review-convergence", + "desc": "Cross-AI plan convergence loop \u2014 replan with review feedback until no HIGH concerns remain.", + "desc_chars": 91, + "calls_skills": [ + "phase", + "review" + ], + "calls_agents": [ + "gsd-plan-checker", + "gsd-planner" + ], + "called_by": [] + }, + { + "stem": "pr-branch", + "name": "gsd:pr-branch", + "desc": "Create a clean PR branch by filtering out .planning/ commits \u2014 ready for code review", + "desc_chars": 84, + "calls_skills": [ + "review" + ], + "calls_agents": [], + "called_by": [] + }, + { + "stem": "profile-user", + "name": "gsd:profile-user", + "desc": "Generate developer behavioral profile and create Claude-discoverable artifacts", + "desc_chars": 78, + "calls_skills": [], + "calls_agents": [], + "called_by": [] + }, + { + "stem": "progress", + "name": "gsd:progress", + "desc": "Check progress, advance workflow, or dispatch freeform intent \u2014 the unified GSD situational command", + "desc_chars": 99, + "calls_skills": [ + "phase" + ], + "calls_agents": [], + "called_by": [ + "autonomous", + "forensics", + "pause-work", + "stats", + "workstreams" + ] + }, + { + "stem": "quick", + "name": "gsd:quick", + "desc": "Execute a quick task with GSD guarantees (atomic commits, state tracking) but skip optional agents", + "desc_chars": 98, + "calls_skills": [ + "phase" + ], + "calls_agents": [ + "gsd-executor", + "gsd-planner" + ], + "called_by": [ + "code-review", + "settings" + ] + }, + { + "stem": "resume-work", + "name": "gsd:resume-work", + "desc": "Resume work from previous session with full context restoration", + "desc_chars": 63, + "calls_skills": [], + "calls_agents": [], + "called_by": [] + }, + { + "stem": "review-backlog", + "name": "gsd:review-backlog", + "desc": "Review and promote backlog items to active milestone", + "desc_chars": 52, + "calls_skills": [ + "phase", + "review" + ], + "calls_agents": [], + "called_by": [] + }, + { + "stem": "review", + "name": "gsd:review", + "desc": "Request cross-AI peer review of phase plans from external AI CLIs", + "desc_chars": 65, + "calls_skills": [ + "config", + "phase" + ], + "calls_agents": [], + "called_by": [ + "code-review", + "config", + "inbox", + "milestone-summary", + "ns-review", + "ns-workflow", + "plan-phase", + "plan-review-convergence", + "pr-branch", + "review-backlog", + "ship" + ] + }, + { + "stem": "secure-phase", + "name": "gsd:secure-phase", + "desc": "Retroactively verify threat mitigations for a completed phase", + "desc_chars": 61, + "calls_skills": [ + "phase" + ], + "calls_agents": [], + "called_by": [] + }, + { + "stem": "settings", + "name": "gsd:settings", + "desc": "Configure GSD workflow toggles and model profile", + "desc_chars": 48, + "calls_skills": [ + "quick" + ], + "calls_agents": [], + "called_by": [ + "config", + "ns-manage" + ] + }, + { + "stem": "ship", + "name": "gsd:ship", + "desc": "Create PR, run review, and prepare for merge after verification passes", + "desc_chars": 70, + "calls_skills": [ + "review" + ], + "calls_agents": [], + "called_by": [] + }, + { + "stem": "sketch", + "name": "gsd:sketch", + "desc": "Sketch UI/design ideas with throwaway HTML mockups, or propose what to sketch next (frontier mode)", + "desc_chars": 98, + "calls_skills": [ + "spike" + ], + "calls_agents": [], + "called_by": [] + }, + { + "stem": "spec-phase", + "name": "gsd:spec-phase", + "desc": "Clarify WHAT a phase delivers with ambiguity scoring; produces a SPEC.md before discuss-phase.", + "desc_chars": 94, + "calls_skills": [ + "discuss-phase", + "execute-phase", + "phase", + "plan-phase" + ], + "calls_agents": [], + "called_by": [] + }, + { + "stem": "spike", + "name": "gsd:spike", + "desc": "Spike an idea through experiential exploration, or propose what to spike next (frontier mode)", + "desc_chars": 93, + "calls_skills": [], + "calls_agents": [], + "called_by": [ + "ns-ideate", + "sketch" + ] + }, + { + "stem": "stats", + "name": "gsd:stats", + "desc": "Display project statistics \u2014 phases, plans, requirements, git metrics, and timeline", + "desc_chars": 83, + "calls_skills": [ + "phase", + "progress" + ], + "calls_agents": [], + "called_by": [ + "complete-milestone" + ] + }, + { + "stem": "thread", + "name": "gsd:thread", + "desc": "Manage persistent context threads for cross-session work", + "desc_chars": 56, + "calls_skills": [ + "phase" + ], + "calls_agents": [], + "called_by": [ + "ns-manage" + ] + }, + { + "stem": "ui-phase", + "name": "gsd:ui-phase", + "desc": "Generate UI design contract (UI-SPEC.md) for frontend phases", + "desc_chars": 60, + "calls_skills": [ + "phase" + ], + "calls_agents": [ + "gsd-ui-checker", + "gsd-ui-researcher" + ], + "called_by": [] + }, + { + "stem": "ui-review", + "name": "gsd:ui-review", + "desc": "Retroactive 6-pillar visual audit of implemented frontend code", + "desc_chars": 62, + "calls_skills": [ + "phase" + ], + "calls_agents": [], + "called_by": [] + }, + { + "stem": "ultraplan-phase", + "name": "gsd:ultraplan-phase", + "desc": "[BETA] Offload plan phase to Claude Code's ultraplan cloud; review in browser and import back.", + "desc_chars": 94, + "calls_skills": [ + "import", + "phase" + ], + "calls_agents": [], + "called_by": [] + }, + { + "stem": "undo", + "name": "gsd:undo", + "desc": "Safe git revert. Roll back phase or plan commits using the phase manifest with dependency checks.", + "desc_chars": 97, + "calls_skills": [ + "phase" + ], + "calls_agents": [], + "called_by": [] + }, + { + "stem": "update", + "name": "gsd:update", + "desc": "Update GSD to latest version with changelog display", + "desc_chars": 51, + "calls_skills": [], + "calls_agents": [], + "called_by": [ + "complete-milestone", + "docs-update", + "forensics", + "graphify", + "plan-phase" + ] + }, + { + "stem": "validate-phase", + "name": "gsd:validate-phase", + "desc": "Retroactively audit and fill Nyquist validation gaps for a completed phase", + "desc_chars": 74, + "calls_skills": [ + "phase" + ], + "calls_agents": [], + "called_by": [] + }, + { + "stem": "verify-work", + "name": "gsd:verify-work", + "desc": "Validate built features through conversational UAT", + "desc_chars": 50, + "calls_skills": [ + "phase" + ], + "calls_agents": [], + "called_by": [ + "execute-phase" + ] + }, + { + "stem": "workspace", + "name": "gsd:workspace", + "desc": "Manage GSD workspaces \u2014 create, list, or remove isolated workspace environments", + "desc_chars": 79, + "calls_skills": [], + "calls_agents": [], + "called_by": [] + }, + { + "stem": "workstreams", + "name": "gsd:workstreams", + "desc": "Manage parallel workstreams \u2014 list, create, switch, status, progress, complete, and resume", + "desc_chars": 90, + "calls_skills": [ + "phase", + "progress" + ], + "calls_agents": [], + "called_by": [ + "ns-manage" + ] + } + ], + "clusters": { + "core_loop": [ + "discuss-phase", + "execute-phase", + "help", + "new-project", + "plan-phase", + "update" + ], + "phase_variants": [ + "ai-integration-phase", + "discuss-phase", + "execute-phase", + "mvp-phase", + "plan-phase", + "secure-phase", + "spec-phase", + "ui-phase", + "ultraplan-phase", + "validate-phase" + ], + "audit_review": [ + "audit-fix", + "audit-milestone", + "audit-uat", + "code-review", + "eval-review", + "ns-review", + "plan-review-convergence", + "review", + "review-backlog", + "ui-review", + "verify-work" + ], + "milestone": [ + "audit-milestone", + "complete-milestone", + "milestone-summary", + "new-milestone" + ], + "research_ideate": [ + "explore", + "forensics", + "graphify", + "ns-ideate", + "sketch", + "spike" + ], + "workspace_state": [ + "capture", + "inbox", + "pause-work", + "resume-work", + "thread", + "workspace", + "workstreams" + ], + "docs": [ + "docs-update", + "ingest-docs" + ], + "ui": [ + "ui-phase", + "ui-review" + ], + "ai_eval": [ + "ai-integration-phase", + "eval-review" + ], + "ns_meta": [ + "ns-context", + "ns-ideate", + "ns-manage", + "ns-project", + "ns-review", + "ns-workflow" + ], + "utility": [ + "add-tests", + "cleanup", + "config", + "debug", + "extract-learnings", + "fast", + "health", + "help", + "import", + "manager", + "map-codebase", + "plan-review-convergence", + "pr-branch", + "profile-user", + "progress", + "quick", + "review-backlog", + "secure-phase", + "settings", + "ship", + "stats", + "undo", + "update" + ] + }, + "agent_usage": { + "gsd-ai-researcher": 1, + "gsd-domain-researcher": 1, + "gsd-eval-planner": 1, + "gsd-framework-selector": 1, + "gsd-code-fixer": 1, + "gsd-code-reviewer": 1, + "gsd-debug-session-manager": 1, + "gsd-debugger": 1, + "gsd-doc-verifier": 1, + "gsd-doc-writer": 1, + "gsd-plan-checker": 3, + "gsd-roadmapper": 1, + "gsd-codebase-mapper": 1, + "gsd-phase-researcher": 1, + "gsd-planner": 3, + "gsd-executor": 1, + "gsd-ui-checker": 1, + "gsd-ui-researcher": 1 + } +} \ No newline at end of file diff --git a/get-shit-done/bin/lib/clusters.cjs b/get-shit-done/bin/lib/clusters.cjs new file mode 100644 index 000000000..90b10e234 --- /dev/null +++ b/get-shit-done/bin/lib/clusters.cjs @@ -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} + */ +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 }; diff --git a/get-shit-done/bin/lib/install-profiles.cjs b/get-shit-done/bin/lib/install-profiles.cjs index 68e2b63f0..688e867f4 100644 --- a/get-shit-done/bin/lib/install-profiles.cjs +++ b/get-shit-done/bin/lib/install-profiles.cjs @@ -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 `` 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 `` 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 /.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-` 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- 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_` so they don't conflict with skill stems. + * + * @param {string} commandsDir absolute path to commands/gsd/ + * @returns {Map} stem → [required stem, ...] plus _calls_agents_ 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} base initial set of stems + * @param {Map} manifest skill → [required stems] + * @returns {Set} + */ +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} [opts.manifest] parsed requires: graph + * @param {object} [opts._profilesOverride] for testing — override PROFILES + * @returns {{ name: string, skills: Set|'*', agents: Set }} + */ +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_ 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|'*' }} 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, skills: Set|'*' }} 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, }; diff --git a/get-shit-done/bin/lib/surface.cjs b/get-shit-done/bin/lib/surface.cjs new file mode 100644 index 000000000..60ae71890 --- /dev/null +++ b/get-shit-done/bin/lib/surface.cjs @@ -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} + */ +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|'*', agents: Set } + * + * 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} manifest + * @param {Object} [clusterMap] defaults to CLUSTERS + * @returns {{ name: string, skills: Set, agents: Set }} + */ +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} 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} 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, +}; diff --git a/get-shit-done/workflows/help.md b/get-shit-done/workflows/help.md index 4e7292152..847353d29 100644 --- a/get-shit-done/workflows/help.md +++ b/get-shit-done/workflows/help.md @@ -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 |disable |enable |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 ` — Switch to a named base profile (`core`, `standard`, `full`) +- `disable ` — Remove a cluster from the active surface +- `enable ` — 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`** diff --git a/package.json b/package.json index 59c5d68d8..3d9c74c74 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/scripts/lint-skill-deps.cjs b/scripts/lint-skill-deps.cjs new file mode 100644 index 000000000..56111908b --- /dev/null +++ b/scripts/lint-skill-deps.cjs @@ -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: or gsd:). + * 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 # 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); diff --git a/tests/install-minimal-backcompat.test.cjs b/tests/install-minimal-backcompat.test.cjs new file mode 100644 index 000000000..655608893 --- /dev/null +++ b/tests/install-minimal-backcompat.test.cjs @@ -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"'); + }); +}); diff --git a/tests/install-profiles-manifest.test.cjs b/tests/install-profiles-manifest.test.cjs new file mode 100644 index 000000000..b1b8bfd76 --- /dev/null +++ b/tests/install-profiles-manifest.test.cjs @@ -0,0 +1,121 @@ +'use strict'; +/** + * Tests for loadSkillsManifest — parses requires: frontmatter from commands/gsd/*.md + * and returns a Map. + */ + +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'), []); + }); +}); diff --git a/tests/install-profiles-marker.test.cjs b/tests/install-profiles-marker.test.cjs new file mode 100644 index 000000000..5abfd9d6c --- /dev/null +++ b/tests/install-profiles-marker.test.cjs @@ -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 }); + } + }); +}); diff --git a/tests/install-profiles-resolve.test.cjs b/tests/install-profiles-resolve.test.cjs new file mode 100644 index 000000000..85bd715d1 --- /dev/null +++ b/tests/install-profiles-resolve.test.cjs @@ -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')); + }); +}); diff --git a/tests/install-profiles-stage.test.cjs b/tests/install-profiles-stage.test.cjs new file mode 100644 index 000000000..861cced6d --- /dev/null +++ b/tests/install-profiles-stage.test.cjs @@ -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); + }); +}); diff --git a/tests/install-update-marker.test.cjs b/tests/install-update-marker.test.cjs new file mode 100644 index 000000000..6116b356f --- /dev/null +++ b/tests/install-update-marker.test.cjs @@ -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 }); + } + }); +}); diff --git a/tests/lint-skill-deps.test.cjs b/tests/lint-skill-deps.test.cjs new file mode 100644 index 000000000..a13556a06 --- /dev/null +++ b/tests/lint-skill-deps.test.cjs @@ -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 }); + } + }); +}); diff --git a/tests/surface-apply.test.cjs b/tests/surface-apply.test.cjs new file mode 100644 index 000000000..05152c6c7 --- /dev/null +++ b/tests/surface-apply.test.cjs @@ -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 }); + } + }); +}); diff --git a/tests/surface-clusters.test.cjs b/tests/surface-clusters.test.cjs new file mode 100644 index 000000000..6f928b70f --- /dev/null +++ b/tests/surface-clusters.test.cjs @@ -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}"`); + } + } + }); +}); diff --git a/tests/surface-list.test.cjs b/tests/surface-list.test.cjs new file mode 100644 index 000000000..eb3bb11bb --- /dev/null +++ b/tests/surface-list.test.cjs @@ -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 }); + } + }); +}); diff --git a/tests/surface-resolve.test.cjs b/tests/surface-resolve.test.cjs new file mode 100644 index 000000000..e8dc7aefd --- /dev/null +++ b/tests/surface-resolve.test.cjs @@ -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 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 }); + } + }); +}); diff --git a/tests/surface-state.test.cjs b/tests/surface-state.test.cjs new file mode 100644 index 000000000..09dcd7e1b --- /dev/null +++ b/tests/surface-state.test.cjs @@ -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 }); + } + }); +});