* feat(skill-deps): add requires: frontmatter to all 51 skills with cross-skill references Mechanical migration from docs/research/data/2026-05-12-skill-audit.json. Every skill whose body references another GSD skill now declares those dependencies in `requires:` YAML frontmatter (flow-style array). Notable: discuss-phase, plan-phase, and execute-phase all reference `phase`, which confirms the latent gap in MINIMAL_SKILL_ALLOWLIST — `phase` is pulled by the core loop but was never in the allowlist. The profile closure model (ADR-0010 Phase 1) resolves this automatically. Closes part of #3408. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * feat(skill-surface-budget): add PROFILES map, resolveProfile, loadSkillsManifest, staging, marker IO Implements the Skill Surface Budget Module core (ADR-0010, Phase 1): - PROFILES Object.freeze map: core (6 skills), standard (~13), full ('*') - loadSkillsManifest: parses requires: frontmatter from commands/gsd/*.md into a Map<stem, string[]> without external YAML dep - resolveProfile({modes, manifest}): computes transitive closure over the requires: graph; composable (modes=['core','audit'] unions closures) - stageSkillsForProfile / stageAgentsForProfile: filesystem staging with same exit-cleanup machinery as the legacy stageSkillsForMode - readActiveProfile / writeActiveProfile: .gsd-profile marker round-trip - Back-compat shims preserved: MINIMAL_SKILL_ALLOWLIST, isMinimalMode, shouldInstallSkill (overloaded), stageSkillsForMode — all legacy tests pass The phase latent bug is now resolved by closure: discuss-phase, plan-phase, and execute-phase all require phase, so any profile including any of them automatically includes phase via transitive closure. Tests: 22 manifest+resolve, 9 stage, 10 marker (41 new tests, all green). Back-compat anchor: 80/80 passing. Closes part of #3408. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * feat(skill-surface-budget): add lint-skill-deps.cjs CI gate and fix 19 missed requires: entries Two lint checks (scripts/lint-skill-deps.cjs): a) Frontmatter-body consistency: skill body references must appear in requires: b) Profile closure: every requires: dep of any profile skill must be in closure Running the lint revealed 19 body references missed by the audit JSON (the audit used static analysis; some bodies have conditional references). Fixed: complete-milestone: +audit-milestone, discuss-phase, plan-phase, execute-phase, new-milestone fast: +quick health: +thread map-codebase: +new-project, plan-phase new-milestone, new-project, review, ultraplan-phase: +plan-phase ship: +verify-work sketch, spike: +new-project verify-work: +execute-phase workstreams: +new-milestone, resume-work Wired into package.json as lint:skill-deps and added to pretest. 8 fixture-based tests: all green. Closes part of #3408. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * feat(skill-surface-budget): wire --profile= arg, profile marker write/read in bin/install.js - Add --profile=<name> / --profile=<n1>,<n2> arg parsing (composable). Mutually exclusive with --minimal / --core-only (aliases for --profile=core). Default (no flag): full. - Import readActiveProfile / writeActiveProfile from install-profiles.cjs. - After writeManifest: persist active profile to .gsd-profile marker. - gsd update path: if no --profile flag given, read existing .gsd-profile marker so non-full profiles are not silently re-expanded to full (ADR-0010). - Update --help block to document --profile= with per-tier token costs. New test: install-minimal-backcompat.test.cjs (6 tests): - PROFILES.core === MINIMAL_SKILL_ALLOWLIST (contract) - --minimal writes .gsd-profile marker "core" - --profile=core, --profile=standard write correct markers - default install writes marker "full" Closes part of #3408. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * chore(changeset): add feat-3408-skill-profiles changelog fragment Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * feat(install-profiles): derive agents from skill body refs and wire into resolveProfile Deviation 1 of ADR-0010 phase 1b: tiered profiles (core, standard) now produce a non-empty agents Set instead of always returning empty. resolveProfile() scans each skill body for gsd-* agent name references (via new parseCallsAgents()), stores them in _calls_agents_<stem> manifest entries, and unions them across the resolved skill closure. stageAgentsForProfile() already checked resolvedProfile.agents — it now gets real data so tiered profiles install the correct subset of agents instead of zero. Closes #3408 (partial — Deviation 1 only) Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * feat(install): honor .gsd-profile marker on update, add resolveEffectiveProfile/mostRestrictiveProfile Deviation 2 of ADR-0010 phase 1b: the marker written during installation is now actually honored when re-running without explicit flags (e.g. gsd update). The dead-end logging block is replaced by resolveEffectiveProfile(), which picks the marker profile over 'full' when no explicit --profile= flag was given. The resolved profile is piped through to all 13 stageSkillsForMode dispatch sites (now _stageSkills) so updates install only the previously-chosen skill subset. --minimal retains its back-compat behavior (strict 6-skill allowlist, no closure) while writing 'core' to the marker. mostRestrictiveProfile() is exported for callers that need to reconcile disagreeing markers across runtimes (smallest skill set wins). Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * feat(surface): add CLUSTERS data + state IO module Add clusters.cjs with 10 named skill groups covering all 66 skills (verified by surface-clusters.test.cjs). Add surface.cjs with readSurface/ writeSurface atomic IO, resolveSurface, applySurface, and listSurface. Tests: 17 passing (11 state IO + 6 cluster integrity). Closes #3408 * docs(adr): add ADR-0011 Skill Surface Budget Module (Phase 1 accepted, Phase 2 amendment) Records the install-time profile staging decision (Phase 1, landed) and the runtime /gsd:surface cluster-toggle decision (Phase 2, in flight) as an amendment. Updates the ADR README index. Closes #3408 * docs(install-profiles): update module docblock for Phase 2 and ADR-0011 Corrects the ADR reference from 0010 to 0011, documents the three-profile model and back-compat aliases, adds resolveEffectiveProfile precedence rule, and notes the companion surface.cjs Phase 2 engine. * docs(context): add Skill Surface Budget Module canonical entry Adds the Domain terms entry for the Skill Surface Budget Module covering both Phase 1 (install-time profiles, .gsd-profile marker) and Phase 2 (runtime /gsd:surface cluster toggles, clusters.cjs, .gsd-surface.json), per ADR-0011 Consequences requirement. * feat(surface): add resolveSurface and applySurface engine + tests Tests cover: profile → surface equivalence, cluster disable/enable, explicitAdds transitive closure, applySurface file sync (add missing, remove superseded, preserve non-gsd files), listSurface token cost. 16 new tests passing. * docs(readme): document --profile= flag and /gsd:surface command Brief user-facing mention of install profiles (core/standard/full) and the /gsd:surface slash command in the Commands table. Points to ADR-0011 for details. * feat(surface): add /gsd:surface slash command runbook New skill: gsd:surface — runtime profile/cluster toggle without reinstall. Sub-commands: list, status, profile <name>, disable/enable <cluster>, reset. Persists state to .gsd-surface.json (independent of .gsd-profile). Description 96 chars (≤100 limit). lint:descriptions + lint:skill-deps: 0 violations. * feat(surface): add changeset fragment for /gsd:surface runtime toggle * feat(surface): add surface skill stem to utility cluster surface.md is a new skill; add it to the utility cluster so the surface-clusters.test.cjs coverage invariant stays satisfied. * docs(adr): fix ADR references to 0011 and record Phase 2 as shipped ADR-0010 number was already claimed by the file-operation-engine ADR; this ADR landed as 0011-skill-surface-budget-module.md. Update inline ADR references in clusters.cjs, surface.cjs, install-profiles.cjs, and the Phase 2 changeset to ADR-0011. Update the ADR Status section to record Phase 2 artifacts as shipped on this branch rather than "in progress". Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * docs(research): port skill-surface-budget memo and audit data ADR-0011 references docs/research/2026-05-12-skill-surface-budget.md and docs/research/data/2026-05-12-skill-audit.json, which only existed in the research worktree. Port both onto this branch so the ADR's References section resolves and reviewers can read the cluster taxonomy (§3.2), dependency topology (§3.1), and option grading (§4) that justify Phase 1 and Phase 2 decisions. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * fix(registration): register surface/clusters in INVENTORY, COMMANDS, and help.md - surface.md: convert allowed-tools from inline YAML array to block style (was parsed as a single tool name "[Read, Write, Bash]" by test harness) - docs/INVENTORY.md: add CLI module rows for clusters.cjs and surface.cjs; add Commands row for /gsd-surface; bump CLI Modules count 55→57, Commands 66→67 - docs/INVENTORY-MANIFEST.json: add entries for clusters.cjs, surface.cjs, and /gsd-surface (filename-based command key) - docs/COMMANDS.md: add ### `/gsd-surface` heading in Configuration Commands - get-shit-done/workflows/help.md: add /gsd:surface entry in Configuration section Fixes registration failures introduced by Phase 2 of #3408. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * fix(surface,docs): scrub .claude leakage and escape hypothetical slash tokens Two PR regressions introduced earlier on this branch: 1. surface.cjs JSDoc comments contained the canonical paths (~/.claude/commands/gsd, ~/.claude/agents) as example values, which the cline-install leak regex (~\/\.claude\/(?:get-shit-done|commands|agents |hooks)) flagged as install-time path leaks. Reworded the docblocks to describe runtime-resolved paths without literal ~/.claude tokens. 2. The ported research memo proposed hypothetical Option C dispatchers using slash syntax (/gsd:milestone, /gsd:research). The docs-parity-live-registry test enforces that every slash-command token in docs/ resolves to a real command. Rewrote the Option C sketch without the slash prefix and added a clarifying note that the dispatchers are illustrative, not shipped. Targeted tests now pass: tests/cline-install.test.cjs and tests/docs-parity-live-registry.test.cjs both green. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * test: remove raw output/source grep in lint tests * fix: close coderabbit profile and requires issues * test: align surface token-cost assertion wording * fix(install): align core profile alias and defer profile marker write --------- Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
135
get-shit-done/bin/lib/clusters.cjs
Normal file
135
get-shit-done/bin/lib/clusters.cjs
Normal file
@@ -0,0 +1,135 @@
|
||||
'use strict';
|
||||
/**
|
||||
* Skill cluster definitions for the runtime surface module (ADR-0011 Phase 2).
|
||||
*
|
||||
* Each cluster is a named group of skill stems. Clusters are used by /gsd:surface
|
||||
* to enable/disable a cohesive group of skills without reinstall.
|
||||
*
|
||||
* Cluster membership may overlap (a skill can live in two clusters). The union
|
||||
* of all clusters should cover every installed skill stem; uncategorized stems
|
||||
* are flagged by surface-clusters.test.cjs.
|
||||
*
|
||||
* Source: docs/research/2026-05-12-skill-surface-budget.md §3.2 (verified
|
||||
* against commands/gsd/ listing in surface-clusters.test.cjs).
|
||||
*/
|
||||
|
||||
const CLUSTERS = Object.freeze({
|
||||
core_loop: Object.freeze([
|
||||
'new-project',
|
||||
'discuss-phase',
|
||||
'plan-phase',
|
||||
'execute-phase',
|
||||
'help',
|
||||
'update',
|
||||
]),
|
||||
|
||||
audit_review: Object.freeze([
|
||||
'code-review',
|
||||
'review',
|
||||
'audit-fix',
|
||||
'audit-milestone',
|
||||
'audit-uat',
|
||||
'verify-work',
|
||||
'validate-phase',
|
||||
'plan-review-convergence',
|
||||
'eval-review',
|
||||
'add-tests',
|
||||
'secure-phase',
|
||||
]),
|
||||
|
||||
milestone: Object.freeze([
|
||||
'new-milestone',
|
||||
'complete-milestone',
|
||||
'milestone-summary',
|
||||
'health',
|
||||
]),
|
||||
|
||||
research_ideate: Object.freeze([
|
||||
'sketch',
|
||||
'spike',
|
||||
'forensics',
|
||||
'explore',
|
||||
'graphify',
|
||||
'ns-ideate',
|
||||
]),
|
||||
|
||||
workspace_state: Object.freeze([
|
||||
'pause-work',
|
||||
'resume-work',
|
||||
'workspace',
|
||||
'workstreams',
|
||||
'thread',
|
||||
'capture',
|
||||
'inbox',
|
||||
]),
|
||||
|
||||
docs: Object.freeze([
|
||||
'docs-update',
|
||||
'ingest-docs',
|
||||
]),
|
||||
|
||||
ui: Object.freeze([
|
||||
'ui-phase',
|
||||
'ui-review',
|
||||
]),
|
||||
|
||||
ai_eval: Object.freeze([
|
||||
'ai-integration-phase',
|
||||
'eval-review',
|
||||
]),
|
||||
|
||||
ns_meta: Object.freeze([
|
||||
'ns-context',
|
||||
'ns-ideate',
|
||||
'ns-manage',
|
||||
'ns-project',
|
||||
'ns-review',
|
||||
'ns-workflow',
|
||||
]),
|
||||
|
||||
utility: Object.freeze([
|
||||
'health',
|
||||
'stats',
|
||||
'settings',
|
||||
'cleanup',
|
||||
'pr-branch',
|
||||
'ship',
|
||||
'undo',
|
||||
'fast',
|
||||
'quick',
|
||||
'autonomous',
|
||||
'config',
|
||||
'progress',
|
||||
'phase',
|
||||
'review',
|
||||
'update',
|
||||
'help',
|
||||
'code-review',
|
||||
'import',
|
||||
'manager',
|
||||
'map-codebase',
|
||||
'profile-user',
|
||||
'spec-phase',
|
||||
'ultraplan-phase',
|
||||
'mvp-phase',
|
||||
'execute-phase',
|
||||
'review-backlog',
|
||||
'debug',
|
||||
'extract-learnings',
|
||||
'surface',
|
||||
]),
|
||||
});
|
||||
|
||||
/**
|
||||
* Build a Set of all skill stems covered by at least one cluster.
|
||||
* @returns {Set<string>}
|
||||
*/
|
||||
function allClusteredSkills() {
|
||||
const result = new Set();
|
||||
for (const skills of Object.values(CLUSTERS)) {
|
||||
for (const s of skills) result.add(s);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
module.exports = { CLUSTERS, allClusteredSkills };
|
||||
@@ -1,46 +1,261 @@
|
||||
/**
|
||||
* Install profiles — single source of truth for which skills/agents
|
||||
* are written to the runtime config dirs.
|
||||
* Skill Surface Budget Module — single source of truth for which skills/agents
|
||||
* are written to the runtime config dirs (ADR-0011).
|
||||
*
|
||||
* Background: every installed `gsd-*` skill costs eager system-prompt
|
||||
* tokens because runtimes (Claude Code, opencode, etc.) enumerate
|
||||
* skill descriptions in `<available_skills>` on every turn. With 86
|
||||
* skills + 33 agents the floor is ~12k tokens per turn, which is a
|
||||
* meaningful tax for local LLMs with 32K–128K context. Frontier
|
||||
* models (Sonnet 4.6 / Opus 4.7 with 200K–1M ctx) don't feel it.
|
||||
* Background: every installed `gsd-*` skill costs eager system-prompt tokens
|
||||
* because runtimes (Claude Code, opencode, etc.) enumerate skill descriptions
|
||||
* in `<available_skills>` on every turn. With 66 skills + 33 agents GSD alone
|
||||
* consumes ~60% of the default 1%-of-context skill-listing budget, causing
|
||||
* dropped skills when users stack multiple plugins (#3408).
|
||||
*
|
||||
* The `minimal` profile installs the main GSD loop only:
|
||||
* new-project → discuss-phase → plan-phase → execute-phase
|
||||
* plus `help` (discoverability) and `update` (upgrade path).
|
||||
* Profile model: three named profiles replace the old minimal/full binary:
|
||||
* - core — six skills covering the main project loop
|
||||
* - standard — core + phase management and workspace skills
|
||||
* - full — all skills (previous default, '*' sentinel)
|
||||
* Profiles compose: --profile=core,audit resolves to union(closure(core), closure(audit)).
|
||||
* Back-compat aliases: --minimal / --core-only both map to --profile=core.
|
||||
*
|
||||
* Users opt into minimal via `--minimal` on the install CLI.
|
||||
* Default install (`full`) is unchanged — back-compat preserved.
|
||||
* This module owns:
|
||||
* - PROFILES map: named profile → base skill set (or '*' sentinel for full)
|
||||
* - loadSkillsManifest: parse requires: frontmatter from commands/gsd/*.md
|
||||
* - resolveProfile: compute transitive closure of profile base over manifest
|
||||
* - stageSkillsForProfile / stageAgentsForProfile: filesystem staging
|
||||
* - readActiveProfile / writeActiveProfile: .gsd-profile marker persistence
|
||||
* - resolveEffectiveProfile: explicit flag > .gsd-profile marker > full
|
||||
* - mostRestrictiveProfile: resolve multi-runtime disagreement to smallest set
|
||||
*
|
||||
* Companion module (Phase 2): get-shit-done/bin/lib/surface.cjs owns the
|
||||
* runtime /gsd:surface command. It reuses stageSkillsForProfile and
|
||||
* stageAgentsForProfile from this module for cluster-level enable/disable
|
||||
* without reinstall, persisting state in <runtimeConfigDir>/.gsd-surface.json.
|
||||
*
|
||||
* Legacy back-compat exports (deprecated, kept for existing callers):
|
||||
* - MINIMAL_SKILL_ALLOWLIST — derived from PROFILES.core
|
||||
* - isMinimalMode(mode) — returns true for 'minimal'
|
||||
* - shouldInstallSkill(name, mode|resolvedProfile) — overloaded
|
||||
* - stageSkillsForMode(srcDir, mode) — wraps stageSkillsForProfile
|
||||
*/
|
||||
|
||||
'use strict';
|
||||
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
const os = require('os');
|
||||
|
||||
const MINIMAL_SKILL_ALLOWLIST = Object.freeze([
|
||||
'new-project',
|
||||
'discuss-phase',
|
||||
'plan-phase',
|
||||
'execute-phase',
|
||||
'help',
|
||||
'update',
|
||||
]);
|
||||
// ---------------------------------------------------------------------------
|
||||
// Profile definitions
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const MINIMAL_ALLOWLIST_SET = new Set(MINIMAL_SKILL_ALLOWLIST);
|
||||
/**
|
||||
* PROFILES maps profile name → base skill set (array) or '*' sentinel (full).
|
||||
*
|
||||
* The effective set for any profile is CLOSURE(base, requires: manifest).
|
||||
* standard is a superset of core; full is the identity (all skills).
|
||||
*
|
||||
* Composition: --profile=core,audit resolves to union(closure(core), closure(audit)).
|
||||
*/
|
||||
const PROFILES = Object.freeze({
|
||||
core: Object.freeze([
|
||||
'new-project',
|
||||
'discuss-phase',
|
||||
'plan-phase',
|
||||
'execute-phase',
|
||||
'help',
|
||||
'update',
|
||||
]),
|
||||
standard: Object.freeze([
|
||||
// Core loop
|
||||
'new-project',
|
||||
'discuss-phase',
|
||||
'plan-phase',
|
||||
'execute-phase',
|
||||
'help',
|
||||
'update',
|
||||
// Phase management (hot nodes from audit — required by 38+ skills)
|
||||
'phase',
|
||||
'review',
|
||||
'config',
|
||||
'progress',
|
||||
// Workspace / state
|
||||
'resume-work',
|
||||
'pause-work',
|
||||
'workspace',
|
||||
]),
|
||||
full: '*',
|
||||
});
|
||||
|
||||
function isMinimalMode(mode) {
|
||||
return mode === 'minimal';
|
||||
// ---------------------------------------------------------------------------
|
||||
// Manifest parsing
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Parse the requires: field from YAML frontmatter.
|
||||
* Handles: "requires: [a, b, c]" (flow style) and absent field.
|
||||
* Returns string[] — empty array if no requires: field.
|
||||
*
|
||||
* No external YAML parser dependency — hand-parse the single line
|
||||
* since GSD enforces flow-style arrays for requires:.
|
||||
*
|
||||
* @param {string} content full file content
|
||||
* @returns {string[]}
|
||||
*/
|
||||
function parseRequires(content) {
|
||||
const fmMatch = content.match(/^---\r?\n([\s\S]*?)\r?\n---/m);
|
||||
if (!fmMatch) return [];
|
||||
const fm = fmMatch[1];
|
||||
const line = fm.match(/^requires:\s*(.+)$/m);
|
||||
if (!line) return [];
|
||||
const val = line[1].trim();
|
||||
// Flow-style: [a, b, c]
|
||||
if (val.startsWith('[') && val.endsWith(']')) {
|
||||
const inner = val.slice(1, -1).trim();
|
||||
if (!inner) return [];
|
||||
return inner.split(',').map((s) => s.trim()).filter(Boolean);
|
||||
}
|
||||
// Single bare value (not currently used, but defensive)
|
||||
return val ? [val] : [];
|
||||
}
|
||||
|
||||
function shouldInstallSkill(skillBaseName, mode) {
|
||||
if (!isMinimalMode(mode)) return true;
|
||||
return MINIMAL_ALLOWLIST_SET.has(skillBaseName);
|
||||
/**
|
||||
* Parse agent references from a skill file's body text.
|
||||
* Scans the full content for `gsd-<stem>` patterns that correspond to
|
||||
* real agent files. Returns all unique `gsd-*` stems found in the body.
|
||||
*
|
||||
* The caller is responsible for filtering by which agents actually exist —
|
||||
* this function returns all syntactically valid `gsd-*` matches.
|
||||
*
|
||||
* @param {string} content full file content
|
||||
* @returns {string[]} deduplicated agent stems like ['gsd-planner', 'gsd-executor']
|
||||
*/
|
||||
function parseCallsAgents(content) {
|
||||
// Match word-boundary gsd-<stem> patterns; stems are lowercase letters and hyphens.
|
||||
// We use a regex that matches `gsd-` followed by one or more lowercase-alpha-or-hyphen chars.
|
||||
// This catches `gsd-planner`, `gsd-plan-checker`, etc. in prose and code.
|
||||
const matches = content.match(/\bgsd-[a-z][a-z-]*/g);
|
||||
if (!matches) return [];
|
||||
// Deduplicate
|
||||
return [...new Set(matches)];
|
||||
}
|
||||
|
||||
/**
|
||||
* Load the requires: dependency graph from a commands/gsd directory.
|
||||
* Also derives calls_agents for each skill by scanning the body text for
|
||||
* `gsd-*` agent name references. Agent stems are stored under the special
|
||||
* key `_calls_agents_<stem>` so they don't conflict with skill stems.
|
||||
*
|
||||
* @param {string} commandsDir absolute path to commands/gsd/
|
||||
* @returns {Map<string, string[]>} stem → [required stem, ...] plus _calls_agents_<stem> entries
|
||||
*/
|
||||
function loadSkillsManifest(commandsDir) {
|
||||
const manifest = new Map();
|
||||
if (!fs.existsSync(commandsDir)) return manifest;
|
||||
const entries = fs.readdirSync(commandsDir, { withFileTypes: true });
|
||||
for (const entry of entries) {
|
||||
if (!entry.isFile()) continue;
|
||||
if (!entry.name.endsWith('.md')) continue;
|
||||
const stem = entry.name.slice(0, -3);
|
||||
try {
|
||||
const content = fs.readFileSync(path.join(commandsDir, entry.name), 'utf8');
|
||||
manifest.set(stem, parseRequires(content));
|
||||
// Derive agent references from body text
|
||||
const agentRefs = parseCallsAgents(content);
|
||||
manifest.set(`_calls_agents_${stem}`, agentRefs);
|
||||
} catch {
|
||||
manifest.set(stem, []);
|
||||
manifest.set(`_calls_agents_${stem}`, []);
|
||||
}
|
||||
}
|
||||
return manifest;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Profile resolution (transitive closure)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Compute the transitive closure of a set of skill stems over the manifest.
|
||||
*
|
||||
* @param {Iterable<string>} base initial set of stems
|
||||
* @param {Map<string, string[]>} manifest skill → [required stems]
|
||||
* @returns {Set<string>}
|
||||
*/
|
||||
function computeClosure(base, manifest) {
|
||||
const closed = new Set(base);
|
||||
const queue = [...closed];
|
||||
while (queue.length > 0) {
|
||||
const stem = queue.pop();
|
||||
const deps = manifest.get(stem) || [];
|
||||
for (const dep of deps) {
|
||||
if (!closed.has(dep)) {
|
||||
closed.add(dep);
|
||||
queue.push(dep);
|
||||
}
|
||||
}
|
||||
}
|
||||
return closed;
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve a profile (or composed profiles) to a typed result object.
|
||||
*
|
||||
* @param {object} opts
|
||||
* @param {string[]} [opts.modes=['full']] profile names to resolve and union
|
||||
* @param {Map<string, string[]>} [opts.manifest] parsed requires: graph
|
||||
* @param {object} [opts._profilesOverride] for testing — override PROFILES
|
||||
* @returns {{ name: string, skills: Set<string>|'*', agents: Set<string> }}
|
||||
*/
|
||||
function resolveProfile({ modes, manifest, _profilesOverride } = {}) {
|
||||
const profiles = _profilesOverride || PROFILES;
|
||||
const activeModes = (modes && modes.length > 0) ? modes : ['full'];
|
||||
const normalizedModes = activeModes
|
||||
.flatMap((mode) => String(mode).split(','))
|
||||
.map((mode) => mode.trim())
|
||||
.filter(Boolean);
|
||||
const modesToResolve = normalizedModes.length > 0 ? normalizedModes : ['full'];
|
||||
|
||||
// If any mode is 'full', the result is the full sentinel
|
||||
if (modesToResolve.includes('full')) {
|
||||
return { name: 'full', skills: '*', agents: new Set() };
|
||||
}
|
||||
|
||||
const validModes = modesToResolve.filter((mode) => Object.prototype.hasOwnProperty.call(profiles, mode));
|
||||
if (validModes.length === 0) {
|
||||
// Invalid/corrupt marker fallback: avoid empty installs by defaulting to full.
|
||||
return { name: 'full', skills: '*', agents: new Set() };
|
||||
}
|
||||
|
||||
const man = manifest || new Map();
|
||||
const unionSkills = new Set();
|
||||
|
||||
for (const mode of validModes) {
|
||||
const base = profiles[mode];
|
||||
if (base === '*') {
|
||||
// This profile is full — sentinel short-circuit
|
||||
return { name: 'full', skills: '*', agents: new Set() };
|
||||
}
|
||||
const closure = computeClosure(base, man);
|
||||
for (const s of closure) unionSkills.add(s);
|
||||
}
|
||||
|
||||
// Derive agents: union of all agent names referenced in the body text of
|
||||
// every skill in unionSkills. Agent names are stored in the manifest under
|
||||
// _calls_agents_<stem> keys (populated by loadSkillsManifest).
|
||||
const unionAgents = new Set();
|
||||
for (const skillStem of unionSkills) {
|
||||
const agentRefs = man.get(`_calls_agents_${skillStem}`) || [];
|
||||
for (const agentStem of agentRefs) {
|
||||
unionAgents.add(agentStem);
|
||||
}
|
||||
}
|
||||
|
||||
const name = validModes.length === 1 ? validModes[0] : validModes.join(',');
|
||||
return { name, skills: unionSkills, agents: unionAgents };
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Staging — skills
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
// Stage dirs created during this process — cleaned up on exit.
|
||||
// 13 runtime dispatch sites in install.js can each call stageSkillsForMode,
|
||||
// so accumulating them in a single set avoids leaks without forcing each
|
||||
@@ -82,17 +297,224 @@ function ensureExitCleanup() {
|
||||
}
|
||||
|
||||
/**
|
||||
* Stage a filtered copy of the source commands/gsd directory when in
|
||||
* minimal mode. All runtime-specific copy fns recurse a source dir,
|
||||
* so filtering at the source point lets every copy fn stay unchanged
|
||||
* (DRY: one filter, not 12).
|
||||
* Stage a filtered copy of commands/gsd for a resolved profile.
|
||||
* In full mode (skills === '*') returns srcDir unchanged (no-op).
|
||||
*
|
||||
* In full mode this is a no-op — the original srcDir is returned.
|
||||
* @param {string} srcDir absolute path to commands/gsd
|
||||
* @param {{ skills: Set<string>|'*' }} resolvedProfile
|
||||
* @returns {string} path to staged dir (or srcDir for full)
|
||||
*/
|
||||
function stageSkillsForProfile(srcDir, resolvedProfile) {
|
||||
if (resolvedProfile.skills === '*') return srcDir;
|
||||
if (!fs.existsSync(srcDir)) return srcDir;
|
||||
|
||||
const stageDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-profile-skills-'));
|
||||
try {
|
||||
const entries = fs.readdirSync(srcDir, { withFileTypes: true });
|
||||
for (const entry of entries) {
|
||||
if (!entry.isFile()) continue;
|
||||
if (!entry.name.endsWith('.md')) continue;
|
||||
const stem = entry.name.slice(0, -3);
|
||||
if (!resolvedProfile.skills.has(stem)) continue;
|
||||
fs.copyFileSync(
|
||||
path.join(srcDir, entry.name),
|
||||
path.join(stageDir, entry.name),
|
||||
);
|
||||
}
|
||||
} catch (err) {
|
||||
try { fs.rmSync(stageDir, { recursive: true, force: true }); } catch {}
|
||||
throw err;
|
||||
}
|
||||
STAGED_DIRS.add(stageDir);
|
||||
ensureExitCleanup();
|
||||
return stageDir;
|
||||
}
|
||||
|
||||
/**
|
||||
* Stage a filtered copy of the agents directory for a resolved profile.
|
||||
* For 'full', returns srcAgentsDir unchanged.
|
||||
* For tiered profiles, copies only agents whose full stem (e.g. 'gsd-planner')
|
||||
* is in resolvedProfile.agents — which is populated by resolveProfile() from
|
||||
* the _calls_agents_* entries in the manifest.
|
||||
*
|
||||
* Cleanup: the staged dir is automatically removed on process exit.
|
||||
* If the copy loop throws mid-flight, the partially-populated dir is
|
||||
* removed and the error re-raised, so callers never see an orphan.
|
||||
* @param {string} srcAgentsDir absolute path to agents/
|
||||
* @param {{ agents: Set<string>, skills: Set<string>|'*' }} resolvedProfile
|
||||
* @returns {string} path to staged dir (or srcAgentsDir for full)
|
||||
*/
|
||||
function stageAgentsForProfile(srcAgentsDir, resolvedProfile) {
|
||||
if (resolvedProfile.skills === '*') return srcAgentsDir;
|
||||
if (!fs.existsSync(srcAgentsDir)) return srcAgentsDir;
|
||||
|
||||
const stageDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-profile-agents-'));
|
||||
try {
|
||||
if (resolvedProfile.agents instanceof Set && resolvedProfile.agents.size > 0) {
|
||||
const entries = fs.readdirSync(srcAgentsDir, { withFileTypes: true });
|
||||
for (const entry of entries) {
|
||||
if (!entry.isFile()) continue;
|
||||
if (!entry.name.endsWith('.md')) continue;
|
||||
// Agent stem is the full filename without extension, e.g. "gsd-planner"
|
||||
const stem = entry.name.slice(0, -3);
|
||||
if (!resolvedProfile.agents.has(stem)) continue;
|
||||
fs.copyFileSync(
|
||||
path.join(srcAgentsDir, entry.name),
|
||||
path.join(stageDir, entry.name),
|
||||
);
|
||||
}
|
||||
}
|
||||
// If agents is empty Set, we produce an empty stageDir (no agents for this profile)
|
||||
} catch (err) {
|
||||
try { fs.rmSync(stageDir, { recursive: true, force: true }); } catch {}
|
||||
throw err;
|
||||
}
|
||||
STAGED_DIRS.add(stageDir);
|
||||
ensureExitCleanup();
|
||||
return stageDir;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Profile marker persistence
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const PROFILE_MARKER_NAME = '.gsd-profile';
|
||||
|
||||
/**
|
||||
* Read the active profile from a runtime config directory.
|
||||
*
|
||||
* @param {string} runtimeConfigDir absolute path (e.g. ~/.claude/skills)
|
||||
* @returns {string|null} profile name (e.g. 'core', 'standard', 'core,audit') or null
|
||||
*/
|
||||
function readActiveProfile(runtimeConfigDir) {
|
||||
const markerPath = path.join(runtimeConfigDir, PROFILE_MARKER_NAME);
|
||||
try {
|
||||
const raw = fs.readFileSync(markerPath, 'utf8').trim();
|
||||
if (!raw) return null;
|
||||
// Validate that it looks like a profile name (alphanumeric + hyphens + commas)
|
||||
if (!/^[a-z0-9,_-]+$/i.test(raw)) return null;
|
||||
return raw;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Persist the active profile to a runtime config directory.
|
||||
*
|
||||
* @param {string} runtimeConfigDir absolute path (e.g. ~/.claude/skills)
|
||||
* @param {string} profileName e.g. 'core', 'standard', 'full'
|
||||
*/
|
||||
function writeActiveProfile(runtimeConfigDir, profileName) {
|
||||
fs.mkdirSync(runtimeConfigDir, { recursive: true });
|
||||
fs.writeFileSync(path.join(runtimeConfigDir, PROFILE_MARKER_NAME), profileName + '\n', 'utf8');
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Profile resolution helpers for install / update flows
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Rank ordering for profiles (lower index = more restrictive / smaller skill set).
|
||||
* Unknown profiles default to the permissive end (treated as 'full').
|
||||
*/
|
||||
const PROFILE_RANK = Object.freeze(['core', 'standard', 'full']);
|
||||
|
||||
/**
|
||||
* Given an array of profile names (one per runtime), return the most-restrictive
|
||||
* profile — i.e. the one with the smallest effective skill set.
|
||||
*
|
||||
* Ordering (most to least restrictive): core < standard < full.
|
||||
* Composed profiles (e.g. 'core,audit') and unknown profiles are treated as
|
||||
* 'full' for this comparison.
|
||||
*
|
||||
* @param {string[]} profileNames
|
||||
* @returns {string}
|
||||
*/
|
||||
function mostRestrictiveProfile(profileNames) {
|
||||
if (!profileNames || profileNames.length === 0) return 'full';
|
||||
// Initialize with the least-restrictive rank (one past the end of PROFILE_RANK)
|
||||
let bestRank = PROFILE_RANK.length;
|
||||
let bestName = 'full';
|
||||
for (const name of profileNames) {
|
||||
const rank = PROFILE_RANK.indexOf(name);
|
||||
// Unknown/composed profiles are treated as the permissive 'full' rank.
|
||||
const effectiveRank = rank === -1 ? PROFILE_RANK.indexOf('full') : rank;
|
||||
if (effectiveRank < bestRank) {
|
||||
bestRank = effectiveRank;
|
||||
bestName = rank === -1 ? 'full' : name;
|
||||
}
|
||||
}
|
||||
return bestName;
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve the effective profile name for an install() run.
|
||||
*
|
||||
* Priority:
|
||||
* 1. Explicit flag (requestedProfileName != null) → use it as-is.
|
||||
* 2. Marker exists in targetDir and is not 'full' → use marker.
|
||||
* 3. Else → 'full' (back-compat for fresh non-interactive installs).
|
||||
*
|
||||
* This is the single source-of-truth for the "which profile should this
|
||||
* install() invocation use?" question. Extracted so it can be unit-tested
|
||||
* independently of the bin/install.js megafile.
|
||||
*
|
||||
* @param {object} opts
|
||||
* @param {string|null} opts.requestedProfileName explicit flag value (or null)
|
||||
* @param {string} opts.targetDir runtime config dir (e.g. ~/.claude)
|
||||
* @returns {string} profile name, e.g. 'core', 'standard', 'full'
|
||||
*/
|
||||
function resolveEffectiveProfile({ requestedProfileName, targetDir }) {
|
||||
// 1. Explicit flag overrides everything
|
||||
if (requestedProfileName != null) return requestedProfileName;
|
||||
// 2. Marker-driven (gsd update path)
|
||||
const marker = readActiveProfile(targetDir);
|
||||
if (marker && marker !== 'full') return marker;
|
||||
// 3. Default
|
||||
return 'full';
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Back-compat shims (deprecated — use profile-based API instead)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* @deprecated Use PROFILES.core instead.
|
||||
* Preserved for callers in install.js and existing tests.
|
||||
*/
|
||||
const MINIMAL_SKILL_ALLOWLIST = Object.freeze([...PROFILES.core]);
|
||||
|
||||
const MINIMAL_ALLOWLIST_SET = new Set(MINIMAL_SKILL_ALLOWLIST);
|
||||
|
||||
/**
|
||||
* @deprecated Use resolveProfile({ modes: ['core'] }) instead.
|
||||
*/
|
||||
function isMinimalMode(mode) {
|
||||
return mode === 'minimal' || mode === 'core-only';
|
||||
}
|
||||
|
||||
/**
|
||||
* Overloaded for back-compat.
|
||||
* - If resolvedProfileOrMode is a string: legacy mode check (full/minimal)
|
||||
* - If resolvedProfileOrMode is an object with .skills: new profile API
|
||||
*
|
||||
* @deprecated String-mode form; use resolvedProfile object form instead.
|
||||
*/
|
||||
function shouldInstallSkill(skillBaseName, resolvedProfileOrMode) {
|
||||
if (typeof resolvedProfileOrMode === 'object' && resolvedProfileOrMode !== null) {
|
||||
const { skills } = resolvedProfileOrMode;
|
||||
if (skills === '*') return true;
|
||||
return skills instanceof Set && skills.has(skillBaseName);
|
||||
}
|
||||
// Legacy string mode
|
||||
const mode = resolvedProfileOrMode;
|
||||
if (!isMinimalMode(mode)) return true;
|
||||
return MINIMAL_ALLOWLIST_SET.has(skillBaseName);
|
||||
}
|
||||
|
||||
/**
|
||||
* Stage a filtered copy of the source commands/gsd directory.
|
||||
* Back-compat wrapper: maps 'minimal' → core profile, 'full' → full.
|
||||
*
|
||||
* @deprecated Use stageSkillsForProfile with a resolved profile instead.
|
||||
* @param {string} srcDir absolute path to commands/gsd
|
||||
* @param {string} mode 'full' | 'minimal'
|
||||
* @returns {string} path to use (original or staged tmp)
|
||||
@@ -123,10 +545,27 @@ function stageSkillsForMode(srcDir, mode) {
|
||||
return stageDir;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Exports
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
module.exports = {
|
||||
// New profile API (ADR-0011)
|
||||
PROFILES,
|
||||
PROFILE_RANK,
|
||||
loadSkillsManifest,
|
||||
resolveProfile,
|
||||
resolveEffectiveProfile,
|
||||
mostRestrictiveProfile,
|
||||
stageSkillsForProfile,
|
||||
stageAgentsForProfile,
|
||||
readActiveProfile,
|
||||
writeActiveProfile,
|
||||
// Shared internals
|
||||
cleanupStagedSkills,
|
||||
// Back-compat / deprecated
|
||||
MINIMAL_SKILL_ALLOWLIST,
|
||||
isMinimalMode,
|
||||
shouldInstallSkill,
|
||||
stageSkillsForMode,
|
||||
cleanupStagedSkills,
|
||||
};
|
||||
|
||||
401
get-shit-done/bin/lib/surface.cjs
Normal file
401
get-shit-done/bin/lib/surface.cjs
Normal file
@@ -0,0 +1,401 @@
|
||||
'use strict';
|
||||
/**
|
||||
* Runtime surface module — ADR-0011 Phase 2 (Option B).
|
||||
*
|
||||
* Manages the runtime enable/disable surface state (the `.gsd-surface.json` marker in
|
||||
* each runtime's skills dir) independently of the install-time profile marker
|
||||
* (`.gsd-profile`). Runtime config locations are resolved by callers.
|
||||
*
|
||||
* Effective skill set = base profile ∪ explicitAdds − disabledClusters − explicitRemoves,
|
||||
* then transitively closed via the manifest.
|
||||
*
|
||||
* Exports:
|
||||
* readSurface(runtimeConfigDir)
|
||||
* writeSurface(runtimeConfigDir, surfaceState)
|
||||
* resolveSurface(runtimeConfigDir, manifest, clusterMap)
|
||||
* applySurface(runtimeConfigDir, commandsDir, agentsDir, manifest, clusterMap)
|
||||
* listSurface(runtimeConfigDir, manifest, clusterMap)
|
||||
*/
|
||||
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
const os = require('os');
|
||||
|
||||
const {
|
||||
readActiveProfile,
|
||||
resolveProfile,
|
||||
stageSkillsForProfile,
|
||||
stageAgentsForProfile,
|
||||
loadSkillsManifest,
|
||||
PROFILES,
|
||||
} = require('./install-profiles.cjs');
|
||||
const { CLUSTERS, allClusteredSkills } = require('./clusters.cjs');
|
||||
|
||||
const SURFACE_FILE_NAME = '.gsd-surface.json';
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// State IO
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* @typedef {Object} SurfaceState
|
||||
* @property {string} baseProfile
|
||||
* @property {string[]} disabledClusters
|
||||
* @property {string[]} explicitAdds
|
||||
* @property {string[]} explicitRemoves
|
||||
*/
|
||||
|
||||
/**
|
||||
* Read the surface state from a runtime config directory.
|
||||
*
|
||||
* @param {string} runtimeConfigDir
|
||||
* @returns {SurfaceState|null} null if file missing or corrupt
|
||||
*/
|
||||
function readSurface(runtimeConfigDir) {
|
||||
const filePath = path.join(runtimeConfigDir, SURFACE_FILE_NAME);
|
||||
try {
|
||||
const raw = fs.readFileSync(filePath, 'utf8');
|
||||
const parsed = JSON.parse(raw);
|
||||
// Structural validation — must have these fields with expected types
|
||||
if (typeof parsed !== 'object' || parsed === null) return null;
|
||||
if (typeof parsed.baseProfile !== 'string') return null;
|
||||
if (!Array.isArray(parsed.disabledClusters)) return null;
|
||||
if (!Array.isArray(parsed.explicitAdds)) return null;
|
||||
if (!Array.isArray(parsed.explicitRemoves)) return null;
|
||||
return {
|
||||
baseProfile: parsed.baseProfile,
|
||||
disabledClusters: parsed.disabledClusters,
|
||||
explicitAdds: parsed.explicitAdds,
|
||||
explicitRemoves: parsed.explicitRemoves,
|
||||
};
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Write the surface state atomically (write to tmp then rename).
|
||||
*
|
||||
* @param {string} runtimeConfigDir
|
||||
* @param {SurfaceState} surfaceState
|
||||
*/
|
||||
function writeSurface(runtimeConfigDir, surfaceState) {
|
||||
fs.mkdirSync(runtimeConfigDir, { recursive: true });
|
||||
const finalPath = path.join(runtimeConfigDir, SURFACE_FILE_NAME);
|
||||
const tmpPath = finalPath + '.tmp.' + process.pid;
|
||||
fs.writeFileSync(tmpPath, JSON.stringify(surfaceState, null, 2) + '\n', 'utf8');
|
||||
fs.renameSync(tmpPath, finalPath);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Resolution
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Expand cluster names to skill stems using the provided clusterMap.
|
||||
*
|
||||
* @param {string[]} clusterNames
|
||||
* @param {Object} clusterMap CLUSTERS or override
|
||||
* @returns {Set<string>}
|
||||
*/
|
||||
function clustersToSkills(clusterNames, clusterMap) {
|
||||
const result = new Set();
|
||||
for (const name of clusterNames) {
|
||||
const members = clusterMap[name];
|
||||
if (members) {
|
||||
for (const s of members) result.add(s);
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve the effective surface to a typed profile-like object.
|
||||
* Shape: { name, skills: Set<string>|'*', agents: Set<string> }
|
||||
*
|
||||
* Resolution order:
|
||||
* 1. Start with base profile resolved via resolveProfile()
|
||||
* 2. Remove skills in disabled clusters
|
||||
* 3. Add explicitAdds (and their transitive closure)
|
||||
* 4. Remove explicitRemoves (only the stem itself, no cascade)
|
||||
*
|
||||
* @param {string} runtimeConfigDir
|
||||
* @param {Map<string, string[]>} manifest
|
||||
* @param {Object} [clusterMap] defaults to CLUSTERS
|
||||
* @returns {{ name: string, skills: Set<string>, agents: Set<string> }}
|
||||
*/
|
||||
function resolveSurface(runtimeConfigDir, manifest, clusterMap) {
|
||||
const cm = clusterMap || CLUSTERS;
|
||||
const surface = readSurface(runtimeConfigDir);
|
||||
|
||||
// Determine base profile name: from surface state or from .gsd-profile marker
|
||||
const baseProfileName = (surface && surface.baseProfile)
|
||||
? surface.baseProfile
|
||||
: (readActiveProfile(runtimeConfigDir) || 'full');
|
||||
|
||||
// Resolve base profile
|
||||
const baseResolved = resolveProfile({
|
||||
modes: baseProfileName.split(',').map(s => s.trim()),
|
||||
manifest,
|
||||
});
|
||||
|
||||
// If full, we need to enumerate all skills from the manifest
|
||||
let skills;
|
||||
if (baseResolved.skills === '*') {
|
||||
// Materialize all skill stems from manifest
|
||||
skills = new Set();
|
||||
for (const [key] of manifest) {
|
||||
if (!key.startsWith('_calls_agents_')) skills.add(key);
|
||||
}
|
||||
} else {
|
||||
skills = new Set(baseResolved.skills);
|
||||
}
|
||||
|
||||
if (surface) {
|
||||
// Step 2: remove disabled cluster members
|
||||
const disabledSkills = clustersToSkills(surface.disabledClusters, cm);
|
||||
for (const s of disabledSkills) skills.delete(s);
|
||||
|
||||
// Step 3: add explicitAdds with transitive closure
|
||||
if (surface.explicitAdds.length > 0) {
|
||||
const addSet = new Set(surface.explicitAdds);
|
||||
// Compute closure of adds
|
||||
const queue = [...addSet];
|
||||
const visited = new Set(addSet);
|
||||
while (queue.length > 0) {
|
||||
const stem = queue.pop();
|
||||
const deps = manifest.get(stem) || [];
|
||||
for (const dep of deps) {
|
||||
if (!visited.has(dep)) {
|
||||
visited.add(dep);
|
||||
queue.push(dep);
|
||||
}
|
||||
}
|
||||
}
|
||||
for (const s of visited) skills.add(s);
|
||||
}
|
||||
|
||||
// Step 4: remove explicitRemoves (stem only, no cascade)
|
||||
for (const s of surface.explicitRemoves) {
|
||||
skills.delete(s);
|
||||
}
|
||||
}
|
||||
|
||||
// Derive agents from skills
|
||||
const agents = new Set();
|
||||
for (const skillStem of skills) {
|
||||
const agentRefs = manifest.get(`_calls_agents_${skillStem}`) || [];
|
||||
for (const agentStem of agentRefs) agents.add(agentStem);
|
||||
}
|
||||
|
||||
const name = surface ? `surface:${surface.baseProfile}` : `profile:${baseProfileName}`;
|
||||
return { name, skills, agents };
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Apply
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Re-stage the active surface to commandsDir and agentsDir in-place.
|
||||
* Only touches files matching `gsd-` prefix or `*.md` in commandsDir.
|
||||
* Never touches non-`gsd-*` files.
|
||||
*
|
||||
* Steps:
|
||||
* 1. Resolve surface → active skill/agent sets
|
||||
* 2. Stage to temp dirs via stageSkillsForProfile / stageAgentsForProfile
|
||||
* 3. Find the install source (where skill files live)
|
||||
* 4. Sync: copy missing, delete superseded (gsd-only)
|
||||
*
|
||||
* @param {string} runtimeConfigDir
|
||||
* @param {string} commandsDir runtime commands/gsd dir (resolved per-runtime by callers)
|
||||
* @param {string} agentsDir runtime agents dir (resolved per-runtime by callers)
|
||||
* @param {Map<string, string[]>} manifest
|
||||
* @param {Object} [clusterMap]
|
||||
*/
|
||||
function applySurface(runtimeConfigDir, commandsDir, agentsDir, manifest, clusterMap) {
|
||||
const resolved = resolveSurface(runtimeConfigDir, manifest, clusterMap);
|
||||
|
||||
// Find install source
|
||||
const srcCommandsDir = _findInstallSource(runtimeConfigDir);
|
||||
|
||||
// Stage skills
|
||||
const stagedSkills = stageSkillsForProfile(srcCommandsDir, resolved);
|
||||
|
||||
// Sync commandsDir from stagedSkills
|
||||
_syncGsdDir(stagedSkills, commandsDir, 'commands');
|
||||
|
||||
// Stage and sync agents
|
||||
if (agentsDir && fs.existsSync(agentsDir)) {
|
||||
const srcAgentsDir = _findAgentsSource(runtimeConfigDir);
|
||||
if (srcAgentsDir) {
|
||||
const stagedAgents = stageAgentsForProfile(srcAgentsDir, resolved);
|
||||
_syncGsdDir(stagedAgents, agentsDir, 'agents');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Sync destination directory from staged source.
|
||||
* Adds files present in staged but missing in dest.
|
||||
* Removes gsd-prefixed .md files in dest not present in staged.
|
||||
* Never touches non-gsd files.
|
||||
*
|
||||
* @param {string} stagedDir source (staged temp dir or original)
|
||||
* @param {string} destDir runtime destination
|
||||
* @param {'commands'|'agents'} context
|
||||
*/
|
||||
function _syncGsdDir(stagedDir, destDir, context) {
|
||||
if (!fs.existsSync(stagedDir)) return;
|
||||
fs.mkdirSync(destDir, { recursive: true });
|
||||
|
||||
const stagedFiles = new Set(
|
||||
fs.readdirSync(stagedDir).filter(f => f.endsWith('.md'))
|
||||
);
|
||||
|
||||
// Copy missing files from staged to dest
|
||||
for (const file of stagedFiles) {
|
||||
const destFile = path.join(destDir, file);
|
||||
if (!fs.existsSync(destFile)) {
|
||||
fs.copyFileSync(path.join(stagedDir, file), destFile);
|
||||
} else {
|
||||
// Overwrite to ensure content is current
|
||||
fs.copyFileSync(path.join(stagedDir, file), destFile);
|
||||
}
|
||||
}
|
||||
|
||||
// Remove gsd-only files from dest that aren't in staged set
|
||||
// For commands dir: all .md files are gsd skills
|
||||
// For agents dir: only gsd-* files
|
||||
const destEntries = fs.readdirSync(destDir).filter(f => f.endsWith('.md'));
|
||||
for (const file of destEntries) {
|
||||
if (context === 'agents' && !file.startsWith('gsd-')) continue;
|
||||
if (!stagedFiles.has(file)) {
|
||||
try { fs.unlinkSync(path.join(destDir, file)); } catch {}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Find the install source commands/gsd directory.
|
||||
* Checks the runtime's `.gsd-source` marker (sibling of the surface state file),
|
||||
* then walks up from __dirname to find the installed package source.
|
||||
*
|
||||
* @param {string} runtimeConfigDir
|
||||
* @returns {string} path to install source commands/gsd
|
||||
*/
|
||||
function _findInstallSource(runtimeConfigDir) {
|
||||
// Check for .gsd-source marker
|
||||
const sourceMarker = path.join(runtimeConfigDir, '.gsd-source');
|
||||
if (fs.existsSync(sourceMarker)) {
|
||||
try {
|
||||
const src = fs.readFileSync(sourceMarker, 'utf8').trim();
|
||||
if (src && fs.existsSync(src)) return src;
|
||||
} catch {}
|
||||
}
|
||||
|
||||
// Walk up from this module's dir to find commands/gsd
|
||||
let dir = __dirname;
|
||||
for (let i = 0; i < 6; i++) {
|
||||
const candidate = path.join(dir, 'commands', 'gsd');
|
||||
if (fs.existsSync(candidate)) return candidate;
|
||||
const parent = path.dirname(dir);
|
||||
if (parent === dir) break;
|
||||
dir = parent;
|
||||
}
|
||||
|
||||
// Fallback: the runtimeConfigDir itself
|
||||
return path.join(runtimeConfigDir, '..', 'commands', 'gsd');
|
||||
}
|
||||
|
||||
/**
|
||||
* Find the install source agents directory.
|
||||
*
|
||||
* @param {string} runtimeConfigDir
|
||||
* @returns {string|null}
|
||||
*/
|
||||
function _findAgentsSource(runtimeConfigDir) {
|
||||
// Prefer .gsd-source sibling marker (commands/gsd) and derive agents from it.
|
||||
const sourceMarker = path.join(runtimeConfigDir, '.gsd-source');
|
||||
if (fs.existsSync(sourceMarker)) {
|
||||
try {
|
||||
const commandsSrc = fs.readFileSync(sourceMarker, 'utf8').trim();
|
||||
if (commandsSrc && fs.existsSync(commandsSrc)) {
|
||||
const commandsParent = path.dirname(commandsSrc); // .../commands
|
||||
const candidate = path.resolve(commandsParent, '..', 'agents');
|
||||
if (fs.existsSync(candidate)) return candidate;
|
||||
}
|
||||
} catch {}
|
||||
}
|
||||
|
||||
let dir = __dirname;
|
||||
for (let i = 0; i < 6; i++) {
|
||||
const candidate = path.join(dir, 'agents');
|
||||
if (fs.existsSync(candidate)) return candidate;
|
||||
const parent = path.dirname(dir);
|
||||
if (parent === dir) break;
|
||||
dir = parent;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// List
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* List the currently enabled and disabled skills with token cost.
|
||||
*
|
||||
* Token cost = sum of description lengths ÷ 4 (mirrors audit script).
|
||||
* Descriptions are read from the installed commandsDir skill files.
|
||||
*
|
||||
* @param {string} runtimeConfigDir
|
||||
* @param {Map<string, string[]>} manifest
|
||||
* @param {Object} [clusterMap]
|
||||
* @returns {{ enabled: string[], disabled: string[], tokenCost: number }}
|
||||
*/
|
||||
function listSurface(runtimeConfigDir, manifest, clusterMap) {
|
||||
const resolved = resolveSurface(runtimeConfigDir, manifest, clusterMap);
|
||||
|
||||
// All known stems from manifest (exclude _calls_agents_ meta keys)
|
||||
const allStems = [];
|
||||
for (const [key] of manifest) {
|
||||
if (!key.startsWith('_calls_agents_')) allStems.push(key);
|
||||
}
|
||||
|
||||
const enabledSet = resolved.skills instanceof Set ? resolved.skills : new Set(allStems);
|
||||
|
||||
const enabled = allStems.filter(s => enabledSet.has(s)).sort();
|
||||
const disabled = allStems.filter(s => !enabledSet.has(s)).sort();
|
||||
|
||||
// Compute token cost by reading descriptions from the install source
|
||||
const srcCommandsDir = _findInstallSource(runtimeConfigDir);
|
||||
let tokenCost = 0;
|
||||
for (const stem of enabled) {
|
||||
const filePath = path.join(srcCommandsDir, `${stem}.md`);
|
||||
try {
|
||||
const content = fs.readFileSync(filePath, 'utf8');
|
||||
const descMatch = content.match(/^description:\s*(.+)$/m);
|
||||
if (descMatch) {
|
||||
tokenCost += Math.ceil(descMatch[1].trim().length / 4);
|
||||
}
|
||||
} catch {}
|
||||
}
|
||||
|
||||
return { enabled, disabled, tokenCost };
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Exports
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
module.exports = {
|
||||
readSurface,
|
||||
writeSurface,
|
||||
resolveSurface,
|
||||
applySurface,
|
||||
listSurface,
|
||||
// Exported for testing
|
||||
_findInstallSource,
|
||||
_syncGsdDir,
|
||||
};
|
||||
@@ -522,6 +522,19 @@ Configure GSD beyond the basic settings: model profile, advanced tuning, and thi
|
||||
|
||||
Usage: `/gsd:config --profile budget`
|
||||
|
||||
**`/gsd:surface [list|status|profile <name>|disable <cluster>|enable <cluster>|reset]`**
|
||||
Toggle which skills are surfaced — apply a profile, list, or disable a cluster without reinstall.
|
||||
|
||||
- `list` / `status` — Show enabled and disabled clusters and skills with token cost
|
||||
- `profile <name>` — Switch to a named base profile (`core`, `standard`, `full`)
|
||||
- `disable <cluster>` — Remove a cluster from the active surface
|
||||
- `enable <cluster>` — Add a cluster back to the active surface
|
||||
- `reset` — Delete the surface delta and return to the install-time profile
|
||||
|
||||
Usage: `/gsd:surface list`
|
||||
Usage: `/gsd:surface profile standard`
|
||||
Usage: `/gsd:surface disable utility`
|
||||
|
||||
### Utility Commands
|
||||
|
||||
**`/gsd:cleanup`**
|
||||
|
||||
Reference in New Issue
Block a user