From 0a11d361cab21764cfb05fbae00dad33647e98a0 Mon Sep 17 00:00:00 2001 From: Tom Boucher Date: Mon, 8 Jun 2026 15:09:34 -0400 Subject: [PATCH] feat(#69): nest concrete skills under namespace routers at install (#883) Emit the 6 gsd-ns-* routers as the only top-level skill bundles and nest the ~61 concrete skills under /skills//SKILL.md on runtimes with confirmed non-recursive skill loaders (claude global, cline, qwen, hermes, augment, trae, antigravity). Router bodies rewrite their routing tables from Skill-tool dispatch to a Read skills//SKILL.md pattern. Recursive/unconfirmed loaders (cursor, codex, copilot, windsurf, codebuddy, opencode, kilo) keep the flat layout. Completes the v1.40 namespace architecture (#2792) so the eager skill listing drops to ~6 entries. Co-authored-by: Claude Opus 4.8 --- .changeset/nested-namespace-skill-layout.md | 5 + CONTEXT.md | 4 +- commands/gsd/ns-manage.md | 9 +- commands/gsd/ns-project.md | 5 + commands/gsd/ns-review.md | 5 +- commands/gsd/ns-workflow.md | 8 +- docs/ARCHITECTURE.md | 30 +- docs/USER-GUIDE.md | 41 ++- scripts/lint-test-file-count.allowlist.json | 1 + src/install-profiles.cts | 124 ++++++++ src/runtime-artifact-layout.cts | 51 ++- tests/bug-2808-skill-hyphen-name.test.cjs | 79 ++--- tests/bug-782-cline-skills-emission.test.cjs | 63 +++- tests/enh-2792-namespace-skills.test.cjs | 89 ++++++ ...h-769-context-fork-effort.install.test.cjs | 62 +++- tests/install-minimal-hooks.test.cjs | 14 +- tests/install-nested-layout.test.cjs | 296 ++++++++++++++++++ tests/install.test.cjs | 90 +++++- tests/issue-69-surface-keeps-nested.test.cjs | 80 +++++ tests/runtime-artifact-layout.test.cjs | 34 +- 20 files changed, 975 insertions(+), 115 deletions(-) create mode 100644 .changeset/nested-namespace-skill-layout.md create mode 100644 tests/install-nested-layout.test.cjs create mode 100644 tests/issue-69-surface-keeps-nested.test.cjs diff --git a/.changeset/nested-namespace-skill-layout.md b/.changeset/nested-namespace-skill-layout.md new file mode 100644 index 000000000..6c42da6d2 --- /dev/null +++ b/.changeset/nested-namespace-skill-layout.md @@ -0,0 +1,5 @@ +--- +type: Changed +pr: 883 +--- +**Namespace router skills now nest their concrete sub-skills at install time (#69).** On runtimes with non-recursive skill loaders (Claude global, Cline, Qwen, Hermes, Augment, Trae, Antigravity) the installer emits the 6 `gsd-ns-*` routers as the only top-level skill bundles and nests the ~61 concrete skills under `/skills//SKILL.md`, cutting the eager skill-listing overhead to ≈6 entries. Concrete skills stay reachable via the router's `Read skills//SKILL.md` routing table. **Breaking:** on those runtimes the concrete skills are no longer invocable by bare name through the Skill tool / top-level listing — route via the namespace router (or the unchanged `/gsd-*` slash command where a commands surface exists). Legacy top-level `gsd-/` skill dirs are removed on upgrade. Recursive/unconfirmed loaders (Cursor, Codex, Copilot, Windsurf, CodeBuddy, OpenCode, Kilo) keep the flat layout. diff --git a/CONTEXT.md b/CONTEXT.md index 47af56fd8..4c794299e 100644 --- a/CONTEXT.md +++ b/CONTEXT.md @@ -110,7 +110,7 @@ Module owning runtime identity normalization at runtime-selection seams. Canonic 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. ### Installer Module -Primary installer for all runtimes. Single production file: `bin/install.js` (generated). Exports: `install(isGlobal, runtime[, configDir])` → typed result `{ runtime, configDir, settingsPath, settings, statuslineCommand, updateBannerCommand }`; `uninstall(isGlobal, runtime[, configDir])`; `installRuntimeArtifacts(runtime, configDir, scope, resolvedProfile)`; `uninstallRuntimeArtifacts(runtime, configDir, scope)`; `writeManifest(configDir, runtime)`. Runtime enum: `allRuntimes` (15 values: claude, antigravity, augment, cline, codebuddy, codex, copilot, cursor, gemini, hermes, kilo, opencode, qwen, trae, windsurf). Directory helpers: `getDirName(runtime)` → local dir name; `getConfigDirFromHome(runtime, isGlobal)` → shell-quoted path fragment. Per-runtime global config-dir resolution is delegated to `gsd-core/bin/lib/runtime-homes.cjs:getGlobalConfigDir(runtime[, explicitDir])` — the canonical, env-var–aware projection (`explicitDir` override + opencode/kilo `*_CONFIG` file-path precedence); the legacy in-installer `getGlobalDir`/`getOpencodeGlobalDir`/`getKiloGlobalDir` were retired into it (#56). Runtime-specific helpers: `resolveKiloConfigPath(configDir)`, `configureKiloPermissions(isGlobal[, explicitDir])`. Claude-specific permission helpers: `mergeClaudePermissions(settings)` — non-destructively appends GSD-owned allow/deny entries (see `GSD_CLAUDE_ALLOW_PERMISSIONS`, `GSD_CLAUDE_DENY_PERMISSIONS` constants) to a Claude Code settings object; called from `finishInstall` for `runtime === 'claude'` only; uninstall removes exactly these entries (#768). Layout-driven artifact copy/removal delegates to `gsd-core/bin/lib/runtime-artifact-layout.cjs:resolveRuntimeArtifactLayout` (throws `TypeError` for unknown runtimes). Hermes uses nested `skills/gsd//` layout (prefix: ''); other skill-runtimes use flat `skills/gsd-/` layout. See Skill Surface Budget Module and Runtime Artifact Layout Module. +Primary installer for all runtimes. Single production file: `bin/install.js` (generated). Exports: `install(isGlobal, runtime[, configDir])` → typed result `{ runtime, configDir, settingsPath, settings, statuslineCommand, updateBannerCommand }`; `uninstall(isGlobal, runtime[, configDir])`; `installRuntimeArtifacts(runtime, configDir, scope, resolvedProfile)`; `uninstallRuntimeArtifacts(runtime, configDir, scope)`; `writeManifest(configDir, runtime)`. Runtime enum: `allRuntimes` (15 values: claude, antigravity, augment, cline, codebuddy, codex, copilot, cursor, gemini, hermes, kilo, opencode, qwen, trae, windsurf). Directory helpers: `getDirName(runtime)` → local dir name; `getConfigDirFromHome(runtime, isGlobal)` → shell-quoted path fragment. Per-runtime global config-dir resolution is delegated to `gsd-core/bin/lib/runtime-homes.cjs:getGlobalConfigDir(runtime[, explicitDir])` — the canonical, env-var–aware projection (`explicitDir` override + opencode/kilo `*_CONFIG` file-path precedence); the legacy in-installer `getGlobalDir`/`getOpencodeGlobalDir`/`getKiloGlobalDir` were retired into it (#56). Runtime-specific helpers: `resolveKiloConfigPath(configDir)`, `configureKiloPermissions(isGlobal[, explicitDir])`. Claude-specific permission helpers: `mergeClaudePermissions(settings)` — non-destructively appends GSD-owned allow/deny entries (see `GSD_CLAUDE_ALLOW_PERMISSIONS`, `GSD_CLAUDE_DENY_PERMISSIONS` constants) to a Claude Code settings object; called from `finishInstall` for `runtime === 'claude'` only; uninstall removes exactly these entries (#768). Layout-driven artifact copy/removal delegates to `gsd-core/bin/lib/runtime-artifact-layout.cjs:resolveRuntimeArtifactLayout` (throws `TypeError` for unknown runtimes). Seven runtimes with non-recursive skill loaders (claude global, cline, qwen, hermes, augment, trae, antigravity) use a nested router layout: 6 `gsd-ns-*` router bundles emitted as top-level skills, with concrete skills nested at `/skills//SKILL.md` (hermes prefix='': `skills/gsd/ns-*/…`). The remaining skills-runtimes (cursor, codex, copilot, windsurf, codebuddy, opencode, kilo) use the flat `skills/gsd-/` layout unchanged. See Skill Surface Budget Module and Runtime Artifact Layout Module. ### I/O Module Module owning the tool's CLI I/O primitives: `output()` result emission (with large-payload temp-file spillover via `GSD_TEMP_DIR`/`ensureGsdTempDir`/`reapStaleTempFiles`), `error()` stderr emission with exit-code mapping, and the JSON-error-mode toggle (`setJsonErrorMode`/`getJsonErrorMode`, `ERROR_REASON`). Extracted from the Core module per ADR-857 rollout phase 1 (#859) so feature modules (`graphify`, `intel`, `audit`, `profile-pipeline`) depend on a small I/O seam instead of the core god-module; `core.cjs` re-exports the primitives for back-compat. Source of truth: `gsd-core/bin/lib/io.cjs` (generated from `src/io.cts`). @@ -131,7 +131,7 @@ Module owning install detection for `/gsd:update`. `resolveUpdateContext({ home, 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: `gsd-core/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: `gsd-core/bin/lib/surface.cjs` implements the `/gsd:surface` slash command for cluster-level enable/disable without reinstall; cluster definitions live in `gsd-core/bin/lib/clusters.cjs`; per-runtime state persists in `/.gsd-surface.json` independent from the `.gsd-profile` marker. See ADR-0011. ### Runtime Artifact Layout Module -Module owning the per-runtime mapping from artifact kind to filesystem placement. ADR-3660 defines the typed `kinds` per runtime (`commands`, `agents`, `skills`) with destination subpath, prefix, and stage adapter (with per-runtime converters in `bin/install.js`: `convertClaudeCommandToClaudeSkill`, `…CodexSkill`, `…CopilotSkill`, `…AntigravitySkill`). Phase 1 applies this seam to the Runtime Surface Module (`surface.cjs:applySurface`); as of #813, `applySurface` applies the same per-runtime skill-body path rewrites as `installRuntimeArtifacts` for `skills` kinds — re-surfacing no longer overwrites installed SKILL.md bodies with converter-default `~/.claude` paths. The shared accessor `getInstallExports` (exported from `runtime-artifact-layout.cjs`) is the single-source seam through which `surface.cjs` reaches `computePathPrefix` and `applyRuntimeContentRewritesInPlace`; the resolved `scope` (`'local'`|`'global'`) is now carried on the `Layout` object returned by `resolveRuntimeArtifactLayout` so `applySurface` derives the same `pathPrefix` (global `$HOME` form vs. absolute) as a fresh install. Phase 2 is planned to migrate install/uninstall in `bin/install.js` so all lifecycle sites iterate one shared layout table instead of re-encoding runtime layout logic. This design is intended to remove the #3659 class of omissions. Migrations remain under the Installer Migration Module (ADR-0008). See ADR-3660. +Module owning the per-runtime mapping from artifact kind to filesystem placement. ADR-3660 defines the typed `kinds` per runtime (`commands`, `agents`, `skills`) with destination subpath, prefix, and stage adapter (with per-runtime converters in `bin/install.js`: `convertClaudeCommandToClaudeSkill`, `…CodexSkill`, `…CopilotSkill`, `…AntigravitySkill`). Owns the per-runtime `nested` skill-bundle decision (#69): a `skillsKind` flag in `src/runtime-artifact-layout.cts` drives whether a runtime receives the nested router layout (6 `gsd-ns-*` routers + concrete skills under `/skills//`) or the flat `skills/gsd-/` layout; the evidence/doc-link matrix is recorded in a comment above `resolveRuntimeArtifactLayout`. Phase 1 applies this seam to the Runtime Surface Module (`surface.cjs:applySurface`); as of #813, `applySurface` applies the same per-runtime skill-body path rewrites as `installRuntimeArtifacts` for `skills` kinds — re-surfacing no longer overwrites installed SKILL.md bodies with converter-default `~/.claude` paths. The shared accessor `getInstallExports` (exported from `runtime-artifact-layout.cjs`) is the single-source seam through which `surface.cjs` reaches `computePathPrefix` and `applyRuntimeContentRewritesInPlace`; the resolved `scope` (`'local'`|`'global'`) is now carried on the `Layout` object returned by `resolveRuntimeArtifactLayout` so `applySurface` derives the same `pathPrefix` (global `$HOME` form vs. absolute) as a fresh install. Phase 2 is planned to migrate install/uninstall in `bin/install.js` so all lifecycle sites iterate one shared layout table instead of re-encoding runtime layout logic. This design is intended to remove the #3659 class of omissions. Migrations remain under the Installer Migration Module (ADR-0008). See ADR-3660. ### Runtime Install Policy Module Projects a pure, typed install plan for a given runtime by composing artifact placements (Runtime Artifact Layout Module), command text (Shell Command Projection Module), and per-runtime config intentions — with no filesystem IO or format-specific serialization. Runtime-specific adapters consume the plan and execute concrete file mutations and config rendering. See ADR-58. diff --git a/commands/gsd/ns-manage.md b/commands/gsd/ns-manage.md index f7cdc288c..ca0fb4dab 100644 --- a/commands/gsd/ns-manage.md +++ b/commands/gsd/ns-manage.md @@ -5,7 +5,7 @@ argument-hint: "" allowed-tools: - Read - Skill -requires: [config, workspace, workstreams, thread, pause-work, resume-work, update, ship, inbox, pr-branch, undo] +requires: [config, workspace, workstreams, thread, pause-work, resume-work, update, ship, inbox, pr-branch, undo, cleanup, health, manager, settings, stats, surface, help] --- Route to the appropriate management skill based on the user's intent. @@ -25,5 +25,12 @@ Route to the appropriate management skill based on the user's intent. | Process inbox items | gsd-inbox | | Create a clean PR branch | gsd-pr-branch | | Undo the last GSD action | gsd-undo | +| Archive accumulated phase directories | gsd-cleanup | +| Diagnose planning directory health | gsd-health | +| Open the interactive command center | gsd-manager | +| Configure workflow toggles and model profile | gsd-settings | +| Show project statistics | gsd-stats | +| Toggle which skills are surfaced | gsd-surface | +| Show the GSD command guide | gsd-help | Invoke the matched skill directly using the Skill tool. diff --git a/commands/gsd/ns-project.md b/commands/gsd/ns-project.md index 68addd737..3deb4943f 100644 --- a/commands/gsd/ns-project.md +++ b/commands/gsd/ns-project.md @@ -5,6 +5,7 @@ argument-hint: "" allowed-tools: - Read - Skill +requires: [new-project, new-milestone, complete-milestone, audit-milestone, milestone-summary, import, ingest-docs, profile-user, review-backlog] --- Route to the appropriate project / milestone skill based on the user's intent. @@ -18,5 +19,9 @@ inline as part of `gsd-audit-milestone`'s output. | Complete the current milestone | gsd-complete-milestone | | Audit a milestone for issues | gsd-audit-milestone | | Summarize milestone status | gsd-milestone-summary | +| Import an external plan | gsd-import | +| Bootstrap planning from existing docs | gsd-ingest-docs | +| Generate a developer profile | gsd-profile-user | +| Review and promote backlog items | gsd-review-backlog | Invoke the matched skill directly using the Skill tool. diff --git a/commands/gsd/ns-review.md b/commands/gsd/ns-review.md index cbcc19390..bc51ef42a 100644 --- a/commands/gsd/ns-review.md +++ b/commands/gsd/ns-review.md @@ -5,7 +5,7 @@ argument-hint: "" allowed-tools: - Read - Skill -requires: [code-review, audit-uat, secure-phase, eval-review, ui-review, validate-phase, debug, forensics] +requires: [code-review, audit-uat, secure-phase, eval-review, ui-review, validate-phase, debug, forensics, audit-fix, review, ui-phase] --- Route to the appropriate quality / review skill based on the user's intent. @@ -22,5 +22,8 @@ Route to the appropriate quality / review skill based on the user's intent. | Validate phase outputs | gsd-validate-phase | | Debug a failing feature or error | gsd-debug | | Forensic investigation of a broken system | gsd-forensics | +| Autonomous audit-to-fix pipeline | gsd-audit-fix | +| Cross-AI peer review of plans | gsd-review | +| Generate a UI design contract | gsd-ui-phase | Invoke the matched skill directly using the Skill tool. diff --git a/commands/gsd/ns-workflow.md b/commands/gsd/ns-workflow.md index 231990326..a5cca73fe 100644 --- a/commands/gsd/ns-workflow.md +++ b/commands/gsd/ns-workflow.md @@ -5,7 +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] +requires: [discuss-phase, spec-phase, plan-phase, execute-phase, verify-work, phase, progress, ultraplan-phase, plan-review-convergence, add-tests, ai-integration-phase, autonomous, fast, mvp-phase, quick] --- Route to the appropriate phase-pipeline skill based on the user's intent. @@ -24,5 +24,11 @@ absorbs the former next/do commands. | Advance to the next logical step | gsd-progress | | Offload planning to the ultraplan cloud | gsd-ultraplan-phase | | Cross-AI plan review convergence loop | gsd-plan-review-convergence | +| Generate tests for a completed phase | gsd-add-tests | +| Design an AI-integration phase | gsd-ai-integration-phase | +| Run all remaining phases autonomously | gsd-autonomous | +| Execute a trivial task inline | gsd-fast | +| Plan a phase as a vertical MVP slice | gsd-mvp-phase | +| Execute a quick task with GSD guarantees | gsd-quick | Invoke the matched skill directly using the Skill tool. diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index d8272ca4f..ac66e3833 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -122,7 +122,7 @@ User-facing entry points. Each file contains YAML frontmatter (name, description #### Two-stage hierarchical routing (v1.40, [#2792](https://github.com/open-gsd/gsd-core/issues/2792)) -To keep the eager skill-listing token cost low, v1.40 introduces six namespace **meta-skills** (`gsd-workflow`, `gsd-project`, `gsd-quality`, `gsd-context`, `gsd-manage`, `gsd-ideate` — sourced from `commands/gsd/ns-*.md`, but the invocable `name:` is the bare form shown here) layered above the concrete sub-skills. The model sees 6 namespace routers (~120 tokens) instead of a flat 86-skill listing (~2,150 tokens), selects a namespace, then routes to the concrete sub-skill via a routing table embedded in the namespace router's body. Namespace skills are **additive** — every concrete command is still directly invocable. +To keep the eager skill-listing token cost low, v1.40 introduces six namespace **meta-skills** (`gsd-workflow`, `gsd-project`, `gsd-quality`, `gsd-context`, `gsd-manage`, `gsd-ideate` — sourced from `commands/gsd/ns-*.md`, but the invocable `name:` is the bare form shown here) layered above the concrete sub-skills. On runtimes with non-recursive skill loaders (claude global, cline, qwen, hermes, augment, trae, antigravity) the installer now realizes this fully: it emits only the 6 namespace router bundles as top-level skills and nests the ~61 concrete skills under `/skills//SKILL.md`, so the eager listing is ≈6 entries instead of ≈67. The model selects a namespace router, which instructs it to read the nested concrete skill file via a routing table embedded in the router body. On these runtimes concrete skills are **not** directly invocable by bare name via the Skill tool; they are reachable through the router. Slash commands (`/gsd-*`, via the separate commands surface) are unaffected where the runtime has one. On runtimes with recursive or unconfirmed skill loaders (cursor, codex, copilot, windsurf, codebuddy, opencode, kilo) the layout remains flat — all skills emitted at the top level as before. The router descriptions use pipe-separated keyword tags (≤ 60 chars) per the Tool Attention research showing keyword-dense tags outperform prose for routing at ~40 % the token cost. @@ -547,7 +547,9 @@ UI-SPEC.md (per phase) ─────────────────── ``` ~/.claude/ # Claude Code (global install) -├── skills/gsd-*/SKILL.md # Global skills (authoritative roster: docs/INVENTORY.md) +├── skills/gsd-ns-*/SKILL.md # Global skills — nesting runtimes: 6 namespace routers (authoritative roster: docs/INVENTORY.md) +│ └── skills//SKILL.md # concrete skills nested under each router +│ (flat runtimes: skills/gsd-*/SKILL.md — all ~67 skills at top level) ├── commands/gsd/*.md # Local Claude installs use slash commands instead of global skills ├── gsd-core/ │ ├── bin/gsd-tools.cjs # CLI utility @@ -800,21 +802,21 @@ The migration-specific ownership and source snapshots live in | Runtime | Global root | Local root | Invocation surface | Agent surface | Config and hooks | | --- | --- | --- | --- | --- | --- | -| Claude Code | `~/.claude` | `./.claude` | Global `skills/gsd-*/SKILL.md`; local `commands/gsd/*.md` | `agents/gsd-*.md` | `settings.json` hook and statusLine entries | +| Claude Code | `~/.claude` | `./.claude` | Global `skills/gsd-ns-*/SKILL.md` (6 routers) + `skills/gsd-ns-*/skills//SKILL.md` (nested concretes); local `commands/gsd/*.md` | `agents/gsd-*.md` | `settings.json` hook and statusLine entries | | OpenCode | `~/.config/opencode` | `./.opencode` | `command/gsd-*.md` | `agents/gsd-*.md` | `opencode.json` or `opencode.jsonc`; no GSD hooks | | Kilo | `~/.config/kilo` | `./.kilo` | `command/gsd-*.md` | `agents/gsd-*.md` | `kilo.json` or `kilo.jsonc`; no GSD hooks | | Gemini CLI | `~/.gemini` | `./.gemini` | `commands/gsd/*.toml` | `agents/gsd-*.md` | `settings.json` feature flag, hooks, and statusline | -| Codex | `~/.codex` | `./.codex` | `skills/gsd-*/SKILL.md` | `agents/` source markdown plus per-agent TOML | `config.toml` `[agents.gsd-*]`, `[features].hooks` (canonical; legacy alias `codex_hooks` is recognized and migrated forward on reinstall, #3566), and hook tables | -| GitHub Copilot | `~/.copilot` | `./.github` | `skills/gsd-*/SKILL.md`, `copilot-instructions.md`, and `AGENTS.md` (repo root, local) | `.agent.md` files | Self-contained `sessionStart` hook (`hooks/gsd-session.json`, inline `command` type); no statusline | -| Antigravity | auto-detected: `~/.gemini/antigravity`, `~/.gemini/antigravity-ide`, or `~/.gemini/antigravity-cli` | `./.agent` | `skills/gsd-*/SKILL.md` | `agents/gsd-*.md` | Gemini-style `settings.json` hook entries when installed by GSD | -| Cursor | `~/.cursor` | `./.cursor` | `skills/gsd-*/SKILL.md` | `agents/gsd-*.md` | Rule references under `rules/`; `hooks.json` with sessionStart context injection and postToolUse STATE.md monitor (#777) | -| Windsurf | `~/.codeium/windsurf` | `./.windsurf` | `skills/gsd-*/SKILL.md` | `agents/gsd-*.md` | Rule references under `rules/`; no GSD hooks | -| Augment Code | `~/.augment` | `./.augment` | `skills/gsd-*/SKILL.md` | `agents/gsd-*.md` | No GSD hooks or statusline | -| Trae | `~/.trae` | `./.trae` | `skills/gsd-*/SKILL.md` | `agents/gsd-*.md` | Rule references under `rules/`; no GSD hooks | -| Qwen Code | `~/.qwen` | `./.qwen` | `skills/gsd-*/SKILL.md` | `agents/gsd-*.md` | Common GSD settings and hook entries where supported | -| Hermes Agent | `~/.hermes` | `./.hermes` | `skills/gsd/DESCRIPTION.md` plus `skills/gsd/gsd-*/SKILL.md` | `agents/gsd-*.md` | Common GSD settings and hook entries where supported | -| CodeBuddy | `~/.codebuddy` | `./.codebuddy` | `skills/gsd-*/SKILL.md` (`user-invocable: false`) | `agents/gsd-*.md` | `/gsd-*` slash commands under `commands/`; common GSD settings and hook entries where supported | -| Cline | `~/.cline` | project root | `.clinerules` | Rules only | No GSD hooks or statusline | +| Codex | `~/.codex` | `./.codex` | `skills/gsd-*/SKILL.md` (flat) | `agents/` source markdown plus per-agent TOML | `config.toml` `[agents.gsd-*]`, `[features].hooks` (canonical; legacy alias `codex_hooks` is recognized and migrated forward on reinstall, #3566), and hook tables | +| GitHub Copilot | `~/.copilot` | `./.github` | `skills/gsd-*/SKILL.md` (flat), `copilot-instructions.md`, and `AGENTS.md` (repo root, local) | `.agent.md` files | Self-contained `sessionStart` hook (`hooks/gsd-session.json`, inline `command` type); no statusline | +| Antigravity | auto-detected: `~/.gemini/antigravity`, `~/.gemini/antigravity-ide`, or `~/.gemini/antigravity-cli` | `./.agent` | `skills/gsd-ns-*/SKILL.md` (6 routers) + `skills/gsd-ns-*/skills//SKILL.md` (nested concretes) | `agents/gsd-*.md` | Gemini-style `settings.json` hook entries when installed by GSD | +| Cursor | `~/.cursor` | `./.cursor` | `skills/gsd-*/SKILL.md` (flat) | `agents/gsd-*.md` | Rule references under `rules/`; `hooks.json` with sessionStart context injection and postToolUse STATE.md monitor (#777) | +| Windsurf | `~/.codeium/windsurf` | `./.windsurf` | `skills/gsd-*/SKILL.md` (flat) | `agents/gsd-*.md` | Rule references under `rules/`; no GSD hooks | +| Augment Code | `~/.augment` | `./.augment` | `skills/gsd-ns-*/SKILL.md` (6 routers) + `skills/gsd-ns-*/skills//SKILL.md` (nested concretes) | `agents/gsd-*.md` | No GSD hooks or statusline | +| Trae | `~/.trae` | `./.trae` | `skills/gsd-ns-*/SKILL.md` (6 routers) + `skills/gsd-ns-*/skills//SKILL.md` (nested concretes) | `agents/gsd-*.md` | Rule references under `rules/`; no GSD hooks | +| Qwen Code | `~/.qwen` | `./.qwen` | `skills/gsd-ns-*/SKILL.md` (6 routers) + `skills/gsd-ns-*/skills//SKILL.md` (nested concretes) | `agents/gsd-*.md` | Common GSD settings and hook entries where supported | +| Hermes Agent | `~/.hermes` | `./.hermes` | `skills/gsd/ns-*/SKILL.md` (6 routers, prefix='') + `skills/gsd/ns-*/skills//SKILL.md` (nested concretes) | `agents/gsd-*.md` | Common GSD settings and hook entries where supported | +| CodeBuddy | `~/.codebuddy` | `./.codebuddy` | `skills/gsd-*/SKILL.md` (flat, `user-invocable: false`) | `agents/gsd-*.md` | `/gsd-*` slash commands under `commands/`; common GSD settings and hook entries where supported | +| Cline | `~/.cline` | project root | `skills/gsd-ns-*/SKILL.md` (6 routers) + `skills/gsd-ns-*/skills//SKILL.md` (nested concretes) + `.clinerules` | Rules only | No GSD hooks or statusline | ### Upstream Contract Sources diff --git a/docs/USER-GUIDE.md b/docs/USER-GUIDE.md index 7f5d9d373..e83cfae8f 100644 --- a/docs/USER-GUIDE.md +++ b/docs/USER-GUIDE.md @@ -10,7 +10,7 @@ A narrative companion guide to GSD Core — orient yourself here, then follow th ## Table of Contents - [Slash-command forms](#slash-command-forms-hyphen-vs-colon) -- [Namespace routing primer](#namespace-routing-primer-gsdnamespace-v140) +- [Namespace routing primer](#namespace-routing-primer-gsd-ns--v140) - [Project lifecycle overview](#project-lifecycle-overview) - [Workflow Diagrams](#workflow-diagrams) - [UI Design Contract](#ui-design-contract) @@ -40,20 +40,37 @@ GSD ships **the same set of skills** to every supported runtime, but two slash-f You don't need to choose — the installer writes the correct form into the command directory of each runtime you target. When following a walkthrough on a Gemini terminal, replace the hyphen after `gsd` with a colon as you read each slash command. -## Namespace routing primer (`gsd:`, v1.40) +## Namespace routing primer (`gsd-ns-*`, v1.40+) -v1.40 ships six **namespace meta-skills** as the first-stage entry points for hierarchical routing — they keep the eager skill-listing token cost low (~120 tokens for 6 routers vs ~2,150 for a flat 86-skill listing) while every concrete sub-skill remains directly invocable. Each namespace router's body contains a routing table that maps your intent to the correct concrete sub-skill. +### Architecture -| Namespace | Router | Routes to | -|-----------|--------|-----------| -| Phase pipeline | `/gsd-workflow` | discuss / plan / execute / verify / phase / progress | -| Project lifecycle | `/gsd-project` | milestones, audits, summary | -| Quality gates | `/gsd-quality` | code review, debug, audit, security, eval, ui | -| Codebase intelligence | `/gsd-context` | map, graphify, docs, learnings | -| Management | `/gsd-manage` | config, workspace, workstreams, thread, update, ship, inbox | -| Exploration & capture | `/gsd-ideate` | explore, sketch, spike, spec, capture | +GSD ships six **namespace router bundles** (`gsd-ns-workflow`, `gsd-ns-project`, `gsd-ns-review`, `gsd-ns-context`, `gsd-ns-ideate`, `gsd-ns-manage`). On runtimes with non-recursive skill loaders, the installer emits these 6 routers as the **only top-level skill entries**; the ~61 concrete skills are nested under each router at `/skills//SKILL.md`. This reduces the eager skill-listing overhead to ≈6 entries instead of ≈67. -You almost never need to type a namespace router yourself. Their value is in the routing layer the model uses to discover the right sub-skill — they exist so the system prompt can list 6 entries instead of 86. If you already know the concrete command (e.g. `/gsd-plan-phase`), call it directly. +Each router's body contains a routing table. When the model receives a request, it reads the router, identifies the relevant sub-skill by name, then opens `skills//SKILL.md` via a file-path `Read`. The concrete skill is fully available — it is not invocable by bare name through the Skill tool's top-level listing, but is reachable through the router. + +The nested layout applies only to runtimes with confirmed non-recursive skill loaders: **Claude (global), Cline, Qwen, Hermes, Augment, Trae, Antigravity**. Recursive or unconfirmed loaders (Cursor, Codex, Copilot, Windsurf, CodeBuddy, OpenCode, Kilo) retain the flat layout unchanged. + +| Namespace | Router bundle | Routes to | +|-----------|--------------|-----------| +| Phase pipeline | `gsd-ns-workflow` | discuss / plan / execute / verify / phase / progress | +| Project lifecycle | `gsd-ns-project` | milestones, audits, summary | +| Quality gates | `gsd-ns-review` | code review, debug, audit, security, eval, ui | +| Codebase intelligence | `gsd-ns-context` | map, graphify, docs, learnings | +| Exploration & capture | `gsd-ns-ideate` | explore, sketch, spike, spec, capture | +| Management | `gsd-ns-manage` | config, workspace, workstreams, thread, update, ship, inbox | + +### Slash commands are unaffected + +On runtimes that install a commands surface (`commands/gsd`), slash commands such as `/gsd-plan-phase` continue to work directly — the nesting applies only to the Skill tool's top-level listing, not to the commands directory. + +### Migration note (breaking change on nesting runtimes) + +On the seven nesting runtimes listed above, upgrading to v1.40 changes skill invocation behaviour: + +- **Before:** each of the ~67 concrete `gsd-` skills appeared at the top level and was invocable by bare name through the Skill tool. +- **After:** only the 6 `gsd-ns-*` router bundles appear at the top level. Concrete skills are reachable via the router's routing table and a `Read skills//SKILL.md` call. Direct bare-name invocation of concrete skills through the Skill tool's listing no longer works. +- **Slash commands unchanged:** `/gsd-plan-phase`, `/gsd-discuss-phase`, etc. still work directly where a commands surface is installed. +- **Upgrade prune:** the installer's existing prune step removes the legacy top-level `gsd-/` skill directories on upgrade — no manual cleanup is needed. --- diff --git a/scripts/lint-test-file-count.allowlist.json b/scripts/lint-test-file-count.allowlist.json index d5859805f..47aed538e 100644 --- a/scripts/lint-test-file-count.allowlist.json +++ b/scripts/lint-test-file-count.allowlist.json @@ -123,6 +123,7 @@ "bug-410-install-defaults-test-mode-guard.test.cjs", "enh-776-install-gemini-hook-events.test.cjs", "install-minimal-hooks.test.cjs", + "install-nested-layout.test.cjs", "install-path-detection.test.cjs", "install-regressions.test.cjs", "install-runtime-artifacts.test.cjs", diff --git a/src/install-profiles.cts b/src/install-profiles.cts index 8a838d3ac..44e1cd8f9 100644 --- a/src/install-profiles.cts +++ b/src/install-profiles.cts @@ -331,14 +331,114 @@ function stageAgentsForProfile(srcAgentsDir: string, resolvedProfile: ResolvedPr return stageDir; } +/** + * Namespace-router → concrete sub-skill mapping for nested install layouts (#69). + */ +interface NamespaceBundleMap { + routerStems: Set; + routerChildren: Map; + childToRouters: Map; +} + +/** + * Build the namespace router → concrete sub-skill mapping (#69). The + * authoritative source is each `ns-*.md` router file's `requires:` frontmatter + * list. A concrete skill may be routed by more than one router (e.g. spec-phase + * is shared by ns-workflow and ns-ideate); it is nested — and physically + * duplicated — under every owning router. + */ +function buildNamespaceBundleMap(srcCommandsDir: string): NamespaceBundleMap { + const routerStems = new Set(); + const routerChildren = new Map(); + const childToRouters = new Map(); + if (!fs.existsSync(srcCommandsDir)) { + return { routerStems, routerChildren, childToRouters }; + } + for (const entry of fs.readdirSync(srcCommandsDir, { withFileTypes: true })) { + if (!entry.isFile() || !entry.name.endsWith('.md')) continue; + if (!entry.name.startsWith('ns-')) continue; + const stem = entry.name.slice(0, -3); + let children: string[] = []; + try { + children = parseRequires(fs.readFileSync(path.join(srcCommandsDir, entry.name), 'utf8')); + } catch { children = []; } + routerStems.add(stem); + routerChildren.set(stem, children); + for (const child of children) { + const owners = childToRouters.get(child) || []; + owners.push(stem); + childToRouters.set(child, owners); + } + } + return { routerStems, routerChildren, childToRouters }; +} + +/** + * Rewrite a converted namespace-router SKILL.md so its routing table points at + * nested sub-skill files instead of bare Skill-tool names (#69). Each table row + * whose final cell carries a `gsd-` token (optionally with `--flag` + * suffixes) is rewritten to `Read \`skills//SKILL.md\`` (flags preserved + * as a note), the `Invoke` column header becomes `Read`, and the + * "Invoke … using the Skill tool" trailer becomes a file-read instruction. + * Only lines beginning with a table pipe are touched, so the `|` inside the + * `description:` frontmatter field is never matched. + */ +function transformRouterBodyToNested(converted: string): string { + const lines = converted.split('\n'); + const out = lines.map((line) => { + if (/Invoke the matched skill directly using the Skill tool\./.test(line)) { + return line.replace( + /Invoke the matched skill directly using the Skill tool\./, + "Read the matched sub-skill's SKILL.md and follow its instructions. The `skills//SKILL.md` paths in the right column are relative to this skill's own directory.", + ); + } + if (!/^\s*\|/.test(line)) return line; + if (/^\s*\|[\s:|-]+\|\s*$/.test(line)) return line; + if (/\|\s*Invoke\s*\|/.test(line)) { + return line.replace(/\|\s*Invoke\s*\|/, '| Read |'); + } + const cells = line.split('|'); + const lastIdx = cells.length - 2; + if (lastIdx < 1) return line; + const cell = cells[lastIdx]; + const m = cell.match(/gsd-([a-z0-9-]+)((?:\s+--[a-z0-9-]+)*)/i); + if (!m) return line; + const stem = m[1]; + const flags = m[2].trim(); + cells[lastIdx] = flags + ? ` Read \`skills/${stem}/SKILL.md\` (${flags}) ` + : ` Read \`skills/${stem}/SKILL.md\` `; + return cells.join('|'); + }); + return out.join('\n'); +} + function stageSkillsForRuntimeAsSkills( srcCommandsDir: string, resolvedProfile: ResolvedProfile, converter: (content: string, skillName: string) => string, prefix: string, + nested = false, ): string { if (!fs.existsSync(srcCommandsDir)) return srcCommandsDir; + // Nesting applies to the `full` install AND to any surface whose skill set + // still contains every namespace router (a full/reset surface). It must NOT + // depend on the `'*'` sentinel alone: applySurface() materializes `full` into + // a concrete Set, so a sentinel-only gate would re-flatten the layout on every + // surface apply/reset (#69 adversarial-review finding). A partial surface that + // drops a whole router cluster falls back to flat automatically. + const bundles = nested ? buildNamespaceBundleMap(srcCommandsDir) : null; + let doNest = false; + if (nested && bundles && bundles.routerStems.size > 0) { + if (resolvedProfile.skills === '*') { + doNest = true; + } else { + const present = resolvedProfile.skills; + doNest = [...bundles.routerStems].every((r) => present.has(r)); + } + } + const stageDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-profile-runtime-skills-')); try { const entries = fs.readdirSync(srcCommandsDir, { withFileTypes: true }); @@ -350,6 +450,30 @@ function stageSkillsForRuntimeAsSkills( const content = fs.readFileSync(path.join(srcCommandsDir, entry.name), 'utf8'); const skillName = `${prefix}${stem}`; const converted = converter(content, skillName); + + if (doNest && bundles!.routerStems.has(stem)) { + // Router skill: rewrite its routing table to the nested Read pattern and + // emit it as the single top-level bundle entry. + const destDir = path.join(stageDir, skillName); + fs.mkdirSync(destDir, { recursive: true }); + fs.writeFileSync(path.join(destDir, 'SKILL.md'), transformRouterBodyToNested(converted)); + continue; + } + + if (doNest && bundles!.childToRouters.has(stem)) { + // Concrete skill routed by one or more namespace routers: nest a copy + // under each owning router's skills/ subdir so it drops out of the + // top-level eager listing while staying readable by file path (#69). + for (const routerStem of bundles!.childToRouters.get(stem)!) { + const destDir = path.join(stageDir, `${prefix}${routerStem}`, 'skills', stem); + fs.mkdirSync(destDir, { recursive: true }); + fs.writeFileSync(path.join(destDir, 'SKILL.md'), converted); + } + continue; + } + + // Flat top-level skill (default behaviour; also the unrouted fallback when + // nesting is active). const destDir = path.join(stageDir, skillName); fs.mkdirSync(destDir, { recursive: true }); fs.writeFileSync(path.join(destDir, 'SKILL.md'), converted); diff --git a/src/runtime-artifact-layout.cts b/src/runtime-artifact-layout.cts index 41c7eae40..18d37eab0 100644 --- a/src/runtime-artifact-layout.cts +++ b/src/runtime-artifact-layout.cts @@ -204,6 +204,7 @@ function agentsKind(destSubpath: string, prefix: string, configDir: string): Art * @param converterName name of converter function in bin/install.js exports * @param runtime canonical runtime ID (gates Hermes/Qwen branding in converter) * @param configDir runtime config dir (for .gsd-source marker resolution) + * @param nested if true, nest concrete skills under their ns-* routers (#69) */ function skillsKind( destSubpath: string, @@ -211,6 +212,7 @@ function skillsKind( converterName: string, runtime: string, configDir: string, + nested = false, ): ArtifactKind { return { kind: 'skills', @@ -224,7 +226,7 @@ function skillsKind( const cmdNames = installExports.readGsdCommandNames(); const wrappedConverter = (content: string, skillName: string): string => realConverter(content, skillName, runtime, cmdNames); - return stageSkillsForRuntimeAsSkills(findInstallSourceRoot(configDir), resolved, wrappedConverter, prefix); + return stageSkillsForRuntimeAsSkills(findInstallSourceRoot(configDir), resolved, wrappedConverter, prefix, nested); }, }; } @@ -267,6 +269,39 @@ function convertedCommandsKind( // Public API // --------------------------------------------------------------------------- +// --------------------------------------------------------------------------- +// Nested skill-bundle support matrix (#69) +// --------------------------------------------------------------------------- +// +// When a runtime's skill loader scans only one level deep (non-recursive), a +// concrete skill nested at `/skills//SKILL.md` drops out of the +// eager top-level listing yet stays readable by file path — which is exactly +// what namespace routing needs. Recursive loaders surface every nested SKILL.md +// as a peer (zero token saving), so they stay flat. Unconfirmed loaders stay +// flat conservatively. Verified June 2026: +// +// NEST (confirmed non-recursive / one-level scan): +// claude — https://code.claude.com/docs/en/skills + anthropics/claude-code#28266 +// (scans one level under ~/.claude/skills; nested skills not auto-listed) +// cline — cline/cline skills.ts scanSkillsDirectory uses flat fs.readdir +// qwen — QwenLM/qwen-code skill-load.ts flat readdir ("depth 2 enough") +// hermes — hermes-agent.nousresearch.com/docs/user-guide/features/skills +// (single-level subdir probe of the tap path) +// augment — https://docs.augmentcode.com/cli/skills (flat single-level) +// trae — docs.trae.ai/ide/skills + Trae-AI/TRAE#2253 (flat; nesting errors) +// antigravity— discuss.ai.google.dev/t/more-antigravity-issues/145875 ("will not recursive scan") +// +// FLAT (recursive loader → nesting gives no saving): +// cursor — https://cursor.com/docs/skills (walks skills root recursively) +// opencode — sst/opencode skill/index.ts glob "skills/**/SKILL.md" +// kilo — Kilo-Org/kilocode (opencode fork, same ** glob) +// +// FLAT (nested-scan behaviour unconfirmed → conservative): +// codex — developers.openai.com/codex/skills/ +// copilot — docs.github.com/en/copilot/concepts/agents/about-agent-skills +// windsurf — docs.devin.ai/desktop/cascade/skills +// codebuddy — codebuddy.ai/docs/cli/skills + /** * Resolve the artifact layout for a given runtime and config directory. */ @@ -290,7 +325,7 @@ function resolveRuntimeArtifactLayout(runtime: string, configDir: string, scope: agentsKind('agents', 'gsd-', configDir), ]; } else { - kinds = [skillsKind('skills', 'gsd-', 'convertClaudeCommandToClaudeSkill', 'claude', configDir)]; + kinds = [skillsKind('skills', 'gsd-', 'convertClaudeCommandToClaudeSkill', 'claude', configDir, true /* #69 nested: non-recursive scan, see matrix above */)]; } break; @@ -318,7 +353,7 @@ function resolveRuntimeArtifactLayout(runtime: string, configDir: string, scope: break; case 'antigravity': - kinds = [skillsKind('skills', 'gsd-', 'convertClaudeCommandToAntigravitySkill', 'antigravity', configDir)]; + kinds = [skillsKind('skills', 'gsd-', 'convertClaudeCommandToAntigravitySkill', 'antigravity', configDir, true /* #69 nested */)]; break; case 'windsurf': @@ -328,20 +363,20 @@ function resolveRuntimeArtifactLayout(runtime: string, configDir: string, scope: case 'augment': kinds = [ commandsKind('commands', 'gsd-', configDir), - skillsKind('skills', 'gsd-', 'convertClaudeCommandToAugmentSkill', 'augment', configDir), + skillsKind('skills', 'gsd-', 'convertClaudeCommandToAugmentSkill', 'augment', configDir, true /* #69 nested */), ]; break; case 'trae': - kinds = [skillsKind('skills', 'gsd-', 'convertClaudeCommandToTraeSkill', 'trae', configDir)]; + kinds = [skillsKind('skills', 'gsd-', 'convertClaudeCommandToTraeSkill', 'trae', configDir, true /* #69 nested */)]; break; case 'qwen': - kinds = [skillsKind('skills', 'gsd-', 'convertClaudeCommandToClaudeSkill', 'qwen', configDir)]; + kinds = [skillsKind('skills', 'gsd-', 'convertClaudeCommandToClaudeSkill', 'qwen', configDir, true /* #69 nested */)]; break; case 'hermes': - kinds = [skillsKind('skills/gsd', '', 'convertClaudeCommandToClaudeSkill', 'hermes', configDir)]; + kinds = [skillsKind('skills/gsd', '', 'convertClaudeCommandToClaudeSkill', 'hermes', configDir, true /* #69 nested */)]; break; case 'codebuddy': @@ -359,7 +394,7 @@ function resolveRuntimeArtifactLayout(runtime: string, configDir: string, scope: break; case 'cline': - kinds = scope === 'global' ? [skillsKind('skills', 'gsd-', 'convertClaudeCommandToClineSkill', 'cline', configDir)] : []; + kinds = scope === 'global' ? [skillsKind('skills', 'gsd-', 'convertClaudeCommandToClineSkill', 'cline', configDir, true /* #69 nested */)] : []; break; case 'opencode': diff --git a/tests/bug-2808-skill-hyphen-name.test.cjs b/tests/bug-2808-skill-hyphen-name.test.cjs index efdc0a8ce..88b1a01be 100644 --- a/tests/bug-2808-skill-hyphen-name.test.cjs +++ b/tests/bug-2808-skill-hyphen-name.test.cjs @@ -174,56 +174,65 @@ describe('bug-2808: SKILL.md name: uses hyphen form', () => { // Use the real COMMANDS_DIR as the source via .gsd-source marker. // installRuntimeArtifacts('claude', configDir, 'global') writes to - // configDir/skills/gsd-*/SKILL.md using the same converter as the shim did. + // configDir/skills/ using the same converter as the shim did. + // With the full profile, skills are nested: gsd-ns-/skills//SKILL.md const configDir = path.join(tmp, 'config'); fs.mkdirSync(configDir, { recursive: true }); fs.writeFileSync(path.join(configDir, '.gsd-source'), COMMANDS_DIR + '\n'); installRuntimeArtifacts('claude', configDir, 'global', resolvedProfileFull); const skillsDir = path.join(configDir, 'skills'); - // Don't filter the directory listing by `startsWith('gsd-')` — that - // would silently hide exactly the kind of drift this test exists to - // catch (a `gsd:extract-learnings` colon variant or a bare - // `extract-learnings` without the namespace prefix would never be - // collected, and the loop below would never see them). Capture every - // generated directory and assert the namespace invariants explicitly. - const skillDirs = fs.readdirSync(skillsDir, { withFileTypes: true }) - .filter((entry) => entry.isDirectory()) - .map((entry) => entry.name) - .sort(); - - assert.ok(skillDirs.length > 0, 'expected generated skill directories under skillsDir'); - for (const dir of skillDirs) { - assert.ok( - dir.startsWith('gsd-'), - `${dir}: generated skill directory must start with the canonical 'gsd-' namespace`, - ); - assert.ok( - !dir.includes(':'), - `${dir}: generated skill directory must not contain the retired colon namespace separator`, - ); - assert.ok( - !dir.includes('_'), - `${dir}: generated skill directory must use hyphens, not underscores`, - ); + // Recursively collect all SKILL.md files under skills/ (handles both flat and + // nested layouts). Don't filter any paths — that would silently hide exactly + // the kind of drift this test exists to catch (a `gsd:extract-learnings` + // colon variant or a bare `extract-learnings` without the namespace prefix + // would never be collected, and the loop below would never see them). + function collectSkillMds(dir) { + const results = []; + for (const entry of fs.readdirSync(dir, { withFileTypes: true })) { + const full = path.join(dir, entry.name); + if (entry.isDirectory()) { + results.push(...collectSkillMds(full)); + } else if (entry.name === 'SKILL.md') { + results.push(full); + } + } + return results; } - assert.ok(skillDirs.includes('gsd-extract-learnings'), 'autocomplete surface must include gsd-extract-learnings'); - assert.ok(!skillDirs.includes('gsd-extract_learnings'), 'autocomplete surface must not include gsd-extract_learnings'); + const allSkillMdPaths = collectSkillMds(skillsDir); + assert.ok(allSkillMdPaths.length > 0, 'expected generated SKILL.md files under skillsDir'); - for (const skillDir of skillDirs) { - const skillContent = fs.readFileSync(path.join(skillsDir, skillDir, 'SKILL.md'), 'utf-8'); + // Validate every SKILL.md's name: field (the consumer-facing name used in + // autocomplete). We also check that the containing dir name doesn't use + // banned characters at any level of nesting. + const allNames = []; + for (const skillMdPath of allSkillMdPaths) { + const relPath = path.relative(skillsDir, skillMdPath); + const skillContent = fs.readFileSync(skillMdPath, 'utf-8'); // Scope the name: lookup to the YAML frontmatter block so a stray // `name:` line in the body cannot satisfy the assertion. const fmMatch = skillContent.match(/^---\n([\s\S]*?)\n---/); - assert.ok(fmMatch, `${skillDir}: generated SKILL.md must include frontmatter`); + assert.ok(fmMatch, `${relPath}: generated SKILL.md must include frontmatter`); const nameLine = fmMatch[1].split('\n').find((l) => /^name:\s*/.test(l)); - assert.ok(nameLine, `${skillDir}: generated SKILL.md is missing name: frontmatter`); + assert.ok(nameLine, `${relPath}: generated SKILL.md is missing name: frontmatter`); const name = nameLine.replace(/^name:\s*/, '').trim(); - assert.ok(name.startsWith('gsd-'), `${skillDir}: autocomplete name must start with gsd-, got ${name}`); - assert.ok(!name.includes(':'), `${skillDir}: autocomplete name must not contain colon, got ${name}`); - assert.ok(!name.includes('_'), `${skillDir}: autocomplete name must not contain underscore, got ${name}`); + assert.ok(name.startsWith('gsd-'), `${relPath}: autocomplete name must start with gsd-, got ${name}`); + assert.ok(!name.includes(':'), `${relPath}: autocomplete name must not contain colon, got ${name}`); + assert.ok(!name.includes('_'), `${relPath}: autocomplete name must not contain underscore, got ${name}`); + allNames.push(name); + + // Also validate each path segment (dir name) in the relative path doesn't + // contain the banned characters — catches mislabeled directory names. + const segments = relPath.split(path.sep).slice(0, -1); // exclude 'SKILL.md' filename + for (const seg of segments) { + assert.ok(!seg.includes(':'), `${relPath}: dir segment "${seg}" must not contain colon`); + assert.ok(!seg.includes('_'), `${relPath}: dir segment "${seg}" must use hyphens, not underscores`); + } } + + assert.ok(allNames.includes('gsd-extract-learnings'), 'autocomplete surface must include gsd-extract-learnings'); + assert.ok(!allNames.includes('gsd-extract_learnings'), 'autocomplete surface must not include gsd-extract_learnings'); }); test('transformContentToHyphen (from fix-slash-commands.cjs) rewrites colon to hyphen for known commands', () => { diff --git a/tests/bug-782-cline-skills-emission.test.cjs b/tests/bug-782-cline-skills-emission.test.cjs index d60e0731d..cf3d5789e 100644 --- a/tests/bug-782-cline-skills-emission.test.cjs +++ b/tests/bug-782-cline-skills-emission.test.cjs @@ -41,6 +41,53 @@ const REAL_COMMANDS_DIR = path.join(__dirname, '..', 'commands', 'gsd'); const MANIFEST = loadSkillsManifest(REAL_COMMANDS_DIR); const RESOLVED_CORE = resolveProfile({ modes: ['core'], manifest: MANIFEST }); +/** + * Map from concrete skill stem → ns-* router stem. + * Used for nesting runtimes (claude, cline, qwen, etc.) when the full profile + * is installed: concrete skills live at /gsd-/skills//SKILL.md. + */ +const CHILD_ROUTER = { + // ns-workflow + 'discuss-phase': 'ns-workflow', 'spec-phase': 'ns-workflow', 'plan-phase': 'ns-workflow', + 'execute-phase': 'ns-workflow', 'verify-work': 'ns-workflow', 'phase': 'ns-workflow', + 'progress': 'ns-workflow', 'ultraplan-phase': 'ns-workflow', + 'plan-review-convergence': 'ns-workflow', 'add-tests': 'ns-workflow', + 'ai-integration-phase': 'ns-workflow', 'autonomous': 'ns-workflow', + 'fast': 'ns-workflow', 'mvp-phase': 'ns-workflow', 'quick': 'ns-workflow', + // ns-project + 'new-project': 'ns-project', 'new-milestone': 'ns-project', 'complete-milestone': 'ns-project', + 'audit-milestone': 'ns-project', 'milestone-summary': 'ns-project', 'import': 'ns-project', + 'ingest-docs': 'ns-project', 'profile-user': 'ns-project', 'review-backlog': 'ns-project', + // ns-review + 'code-review': 'ns-review', 'audit-uat': 'ns-review', 'secure-phase': 'ns-review', + 'eval-review': 'ns-review', 'ui-review': 'ns-review', 'validate-phase': 'ns-review', + 'debug': 'ns-review', 'forensics': 'ns-review', 'audit-fix': 'ns-review', + 'review': 'ns-review', 'ui-phase': 'ns-review', + // ns-context + 'map-codebase': 'ns-context', 'graphify': 'ns-context', 'docs-update': 'ns-context', + 'extract-learnings': 'ns-context', + // ns-ideate + 'capture': 'ns-ideate', 'explore': 'ns-ideate', 'sketch': 'ns-ideate', + 'spike': 'ns-ideate', + // ns-manage + 'config': 'ns-manage', 'workspace': 'ns-manage', 'workstreams': 'ns-manage', + 'thread': 'ns-manage', 'pause-work': 'ns-manage', 'resume-work': 'ns-manage', + 'update': 'ns-manage', 'ship': 'ns-manage', 'inbox': 'ns-manage', + 'pr-branch': 'ns-manage', 'undo': 'ns-manage', 'cleanup': 'ns-manage', + 'health': 'ns-manage', 'manager': 'ns-manage', 'settings': 'ns-manage', + 'stats': 'ns-manage', 'surface': 'ns-manage', 'help': 'ns-manage', +}; + +/** + * Returns the nested SKILL.md path for a concrete skill stem on cline + * (prefix='gsd-'): /gsd-/skills//SKILL.md + */ +function nestedClineSkillPath(skillsRoot, stem) { + const router = CHILD_ROUTER[stem]; + if (!router) throw new Error(`No router mapping for stem: ${stem}`); + return path.join(skillsRoot, 'gsd-' + router, 'skills', stem, 'SKILL.md'); +} + // ─── (a) Converter unit test ───────────────────────────────────────────────── const SAMPLE_COMMAND = `--- @@ -347,11 +394,11 @@ describe('install() global cline — coexistence: skills AND .clinerules', () => `skills/ directory must exist under ${tmpGlobalDir} after global cline install` ); - // gsd-help is present in every profile (core, standard, full) - const helpSkillFile = path.join(skillsDir, 'gsd-help', 'SKILL.md'); + // full profile: gsd-help is nested under gsd-ns-manage/skills/help/SKILL.md + const helpSkillFile = nestedClineSkillPath(skillsDir, 'help'); assert.ok( fs.existsSync(helpSkillFile), - `skills/gsd-help/SKILL.md must exist under ${tmpGlobalDir} — skills emission broken for global cline` + `${path.relative(tmpGlobalDir, helpSkillFile)} must exist under ${tmpGlobalDir} — skills emission broken for global cline` ); }); @@ -438,8 +485,9 @@ describe('convertClaudeToCliineMarkdown — bare ~/.claude and CLAUDE_CONFIG_DIR installRuntimeArtifacts('cline', configDir, 'global', RESOLVED_FULL); - const surfaceSkill = path.join(configDir, 'skills', 'gsd-surface', 'SKILL.md'); - assert.ok(fs.existsSync(surfaceSkill), 'gsd-surface/SKILL.md must exist for full profile'); + // full profile: surface is nested under gsd-ns-manage/skills/surface/SKILL.md + const surfaceSkill = nestedClineSkillPath(path.join(configDir, 'skills'), 'surface'); + assert.ok(fs.existsSync(surfaceSkill), `${path.relative(configDir, surfaceSkill)} must exist for full profile`); const content = fs.readFileSync(surfaceSkill, 'utf8'); assert.ok( @@ -497,8 +545,9 @@ describe('_applyRuntimeRewrites — cline custom-dir embedded path (Fix 1)', () // gsd-surface SKILL.md references config paths; with a custom configDir // (not under $HOME), pathPrefix will be the absolute custom path. - const surfaceSkill = path.join(configDir, 'skills', 'gsd-surface', 'SKILL.md'); - assert.ok(fs.existsSync(surfaceSkill), 'gsd-surface/SKILL.md must exist'); + // full profile: surface is nested under gsd-ns-manage/skills/surface/SKILL.md + const surfaceSkill = nestedClineSkillPath(path.join(configDir, 'skills'), 'surface'); + assert.ok(fs.existsSync(surfaceSkill), `${path.relative(configDir, surfaceSkill)} must exist`); const content = fs.readFileSync(surfaceSkill, 'utf8'); // With a custom dir (path under /tmp, not ~/.cline), the output must NOT diff --git a/tests/enh-2792-namespace-skills.test.cjs b/tests/enh-2792-namespace-skills.test.cjs index a46d49b8e..acb194622 100644 --- a/tests/enh-2792-namespace-skills.test.cjs +++ b/tests/enh-2792-namespace-skills.test.cjs @@ -208,6 +208,95 @@ describe('gsd-health --context flag is wired into command + workflow', () => { }); }); +// ── Namespace nesting completeness (#69) ────────────────────────────── +// Guards that the install-layout nesting invariant (<=6 top-level entries) +// is always satisfiable: every router's requires list points at real files, +// every concrete skill is covered by at least one router, and each router's +// body table stays in sync with its requires list. + +const NS_FILES = NAMESPACE_SKILLS.map((ns) => ns.file); + +/** + * Parse the `requires:` flow-style array from a router file's raw content. + * Matches `requires: [a, b, c]` anywhere (frontmatter or body — always in fm). + */ +function parseRouterRequires(content) { + const m = content.match(/^requires:\s*\[([^\]]*)\]/m); + if (!m) return []; + return m[1].split(',').map((s) => s.trim()).filter(Boolean); +} + +describe('namespace nesting completeness (#69)', () => { + // Build the concrete-skill set once (all *.md minus ns-*.md) + const allFiles = fs.readdirSync(COMMANDS_DIR).filter((f) => f.endsWith('.md')); + const concreteStemSet = new Set( + allFiles + .filter((f) => !f.startsWith('ns-')) + .map((f) => f.replace(/\.md$/, '')), + ); + + // Build per-router requires and the union over all routers + const routerRequires = new Map(); // stem -> string[] + for (const f of NS_FILES) { + const stem = f.replace(/\.md$/, ''); + const content = fs.readFileSync(path.join(COMMANDS_DIR, f), 'utf-8'); + routerRequires.set(stem, parseRouterRequires(content)); + } + const allRoutedStems = new Set([...routerRequires.values()].flat()); + + test('every router requires entry resolves to a real concrete skill file', () => { + const bad = []; + for (const [routerStem, children] of routerRequires) { + for (const child of children) { + if (!fs.existsSync(path.join(COMMANDS_DIR, `${child}.md`))) { + bad.push(`${routerStem} → ${child}`); + } + } + } + assert.deepStrictEqual( + bad, + [], + `Router requires entries with no matching commands/gsd/.md: ${bad.join(', ')}`, + ); + }); + + test('every concrete skill is routed by at least one namespace router', () => { + const unrouted = [...concreteStemSet].filter((stem) => !allRoutedStems.has(stem)); + assert.deepStrictEqual( + unrouted, + [], + `Concrete skills not routed by any ns-*.md (add to a router's requires:): ${unrouted.join(', ')}`, + ); + }); + + test("each router's routing-table rows reference only its own required sub-skills (plus flag variants)", () => { + const bad = []; + for (const [routerStem, children] of routerRequires) { + const childSet = new Set(children); + const content = fs.readFileSync(path.join(COMMANDS_DIR, `${routerStem}.md`), 'utf-8'); + const fm = parseFrontmatter(content); + // Extract gsd- tokens from table data rows (last cell), strip flags + for (const line of fm._body.split('\n')) { + if (!line.startsWith('|') || /^\|[\s\-:|]+\|?\s*$/.test(line)) continue; + const cells = line.split('|').map((c) => c.trim()).filter(Boolean); + if (cells.length < 2) continue; + const lastCell = cells[cells.length - 1]; + for (const match of lastCell.matchAll(/\bgsd-([a-z][a-z0-9-]*)/g)) { + const stem = match[1]; + if (!childSet.has(stem)) { + bad.push(`${routerStem}: body table references gsd-${stem} but it's not in requires`); + } + } + } + } + assert.deepStrictEqual( + bad, + [], + `Routing table / requires mismatch:\n${bad.join('\n')}`, + ); + }); +}); + // ── Cross-reference: every routed sub-skill must exist ───────────────── // This is the regression guard the original PR lacked. Without it, // post-#2790 consolidations can quietly invalidate router targets again. diff --git a/tests/enh-769-context-fork-effort.install.test.cjs b/tests/enh-769-context-fork-effort.install.test.cjs index 125730723..c192914e2 100644 --- a/tests/enh-769-context-fork-effort.install.test.cjs +++ b/tests/enh-769-context-fork-effort.install.test.cjs @@ -43,6 +43,52 @@ function makeTmpDir(prefix) { return fs.mkdtempSync(path.join(os.tmpdir(), prefix)); } +/** + * Map from concrete skill stem → ns-* router stem for nesting runtimes. + * Derived from the authoritative ns-*.md `requires:` lists. + */ +const CHILD_ROUTER = { + // ns-workflow + 'discuss-phase': 'ns-workflow', 'spec-phase': 'ns-workflow', 'plan-phase': 'ns-workflow', + 'execute-phase': 'ns-workflow', 'verify-work': 'ns-workflow', 'phase': 'ns-workflow', + 'progress': 'ns-workflow', 'ultraplan-phase': 'ns-workflow', + 'plan-review-convergence': 'ns-workflow', 'add-tests': 'ns-workflow', + 'ai-integration-phase': 'ns-workflow', 'autonomous': 'ns-workflow', + 'fast': 'ns-workflow', 'mvp-phase': 'ns-workflow', 'quick': 'ns-workflow', + // ns-project + 'new-project': 'ns-project', 'new-milestone': 'ns-project', 'complete-milestone': 'ns-project', + 'audit-milestone': 'ns-project', 'milestone-summary': 'ns-project', 'import': 'ns-project', + 'ingest-docs': 'ns-project', 'profile-user': 'ns-project', 'review-backlog': 'ns-project', + // ns-review + 'code-review': 'ns-review', 'audit-uat': 'ns-review', 'secure-phase': 'ns-review', + 'eval-review': 'ns-review', 'ui-review': 'ns-review', 'validate-phase': 'ns-review', + 'debug': 'ns-review', 'forensics': 'ns-review', 'audit-fix': 'ns-review', + 'review': 'ns-review', 'ui-phase': 'ns-review', + // ns-context + 'map-codebase': 'ns-context', 'graphify': 'ns-context', 'docs-update': 'ns-context', + 'extract-learnings': 'ns-context', + // ns-ideate + 'capture': 'ns-ideate', 'explore': 'ns-ideate', 'sketch': 'ns-ideate', + 'spike': 'ns-ideate', + // ns-manage + 'config': 'ns-manage', 'workspace': 'ns-manage', 'workstreams': 'ns-manage', + 'thread': 'ns-manage', 'pause-work': 'ns-manage', 'resume-work': 'ns-manage', + 'update': 'ns-manage', 'ship': 'ns-manage', 'inbox': 'ns-manage', + 'pr-branch': 'ns-manage', 'undo': 'ns-manage', 'cleanup': 'ns-manage', + 'health': 'ns-manage', 'manager': 'ns-manage', 'settings': 'ns-manage', + 'stats': 'ns-manage', 'surface': 'ns-manage', 'help': 'ns-manage', +}; + +/** + * Returns the nested SKILL.md path for a concrete skill stem on Claude + * (prefix='gsd-'): /gsd-/skills//SKILL.md + */ +function nestedClaudeSkillPath(skillsRoot, stem) { + const router = CHILD_ROUTER[stem]; + if (!router) throw new Error(`No router mapping for stem: ${stem}`); + return path.join(skillsRoot, 'gsd-' + router, 'skills', stem, 'SKILL.md'); +} + function readFrontmatter(mdPath) { const content = fs.readFileSync(mdPath, 'utf8'); if (!content.startsWith('---')) return ''; @@ -253,7 +299,7 @@ describe('#769 Claude global install: SKILL.md files preserve context: fork and test('gsd-autonomous SKILL.md has context: fork after global install', () => { runClaudeGlobalInstall(claudeHome); - const skillPath = path.join(claudeHome, 'skills', 'gsd-autonomous', 'SKILL.md'); + const skillPath = nestedClaudeSkillPath(path.join(claudeHome, 'skills'), 'autonomous'); const fm = readFrontmatter(skillPath); assert.match(fm, /^context:[ \t]*fork$/m, `gsd-autonomous SKILL.md must have context: fork\nActual:\n${fm}`); @@ -261,7 +307,7 @@ describe('#769 Claude global install: SKILL.md files preserve context: fork and test('gsd-autonomous SKILL.md has effort: xhigh after global install', () => { runClaudeGlobalInstall(claudeHome); - const skillPath = path.join(claudeHome, 'skills', 'gsd-autonomous', 'SKILL.md'); + const skillPath = nestedClaudeSkillPath(path.join(claudeHome, 'skills'), 'autonomous'); const fm = readFrontmatter(skillPath); assert.match(fm, /^effort:[ \t]*xhigh$/m, `gsd-autonomous SKILL.md must have effort: xhigh\nActual:\n${fm}`); @@ -269,7 +315,7 @@ describe('#769 Claude global install: SKILL.md files preserve context: fork and test('gsd-execute-phase SKILL.md has context: fork after global install', () => { runClaudeGlobalInstall(claudeHome); - const skillPath = path.join(claudeHome, 'skills', 'gsd-execute-phase', 'SKILL.md'); + const skillPath = nestedClaudeSkillPath(path.join(claudeHome, 'skills'), 'execute-phase'); const fm = readFrontmatter(skillPath); assert.match(fm, /^context:[ \t]*fork$/m, `gsd-execute-phase SKILL.md must have context: fork\nActual:\n${fm}`); @@ -277,7 +323,7 @@ describe('#769 Claude global install: SKILL.md files preserve context: fork and test('gsd-execute-phase SKILL.md has effort: xhigh after global install', () => { runClaudeGlobalInstall(claudeHome); - const skillPath = path.join(claudeHome, 'skills', 'gsd-execute-phase', 'SKILL.md'); + const skillPath = nestedClaudeSkillPath(path.join(claudeHome, 'skills'), 'execute-phase'); const fm = readFrontmatter(skillPath); assert.match(fm, /^effort:[ \t]*xhigh$/m, `gsd-execute-phase SKILL.md must have effort: xhigh\nActual:\n${fm}`); @@ -285,7 +331,7 @@ describe('#769 Claude global install: SKILL.md files preserve context: fork and test('gsd-plan-phase SKILL.md has context: fork after global install', () => { runClaudeGlobalInstall(claudeHome); - const skillPath = path.join(claudeHome, 'skills', 'gsd-plan-phase', 'SKILL.md'); + const skillPath = nestedClaudeSkillPath(path.join(claudeHome, 'skills'), 'plan-phase'); const fm = readFrontmatter(skillPath); assert.match(fm, /^context:[ \t]*fork$/m, `gsd-plan-phase SKILL.md must have context: fork\nActual:\n${fm}`); @@ -293,7 +339,7 @@ describe('#769 Claude global install: SKILL.md files preserve context: fork and test('gsd-plan-phase SKILL.md has effort: xhigh after global install', () => { runClaudeGlobalInstall(claudeHome); - const skillPath = path.join(claudeHome, 'skills', 'gsd-plan-phase', 'SKILL.md'); + const skillPath = nestedClaudeSkillPath(path.join(claudeHome, 'skills'), 'plan-phase'); const fm = readFrontmatter(skillPath); assert.match(fm, /^effort:[ \t]*xhigh$/m, `gsd-plan-phase SKILL.md must have effort: xhigh\nActual:\n${fm}`); @@ -301,7 +347,7 @@ describe('#769 Claude global install: SKILL.md files preserve context: fork and test('gsd-progress SKILL.md has effort: low after global install', () => { runClaudeGlobalInstall(claudeHome); - const skillPath = path.join(claudeHome, 'skills', 'gsd-progress', 'SKILL.md'); + const skillPath = nestedClaudeSkillPath(path.join(claudeHome, 'skills'), 'progress'); const fm = readFrontmatter(skillPath); assert.match(fm, /^effort:[ \t]*low$/m, `gsd-progress SKILL.md must have effort: low\nActual:\n${fm}`); @@ -309,7 +355,7 @@ describe('#769 Claude global install: SKILL.md files preserve context: fork and test('gsd-stats SKILL.md has effort: low after global install', () => { runClaudeGlobalInstall(claudeHome); - const skillPath = path.join(claudeHome, 'skills', 'gsd-stats', 'SKILL.md'); + const skillPath = nestedClaudeSkillPath(path.join(claudeHome, 'skills'), 'stats'); const fm = readFrontmatter(skillPath); assert.match(fm, /^effort:[ \t]*low$/m, `gsd-stats SKILL.md must have effort: low\nActual:\n${fm}`); diff --git a/tests/install-minimal-hooks.test.cjs b/tests/install-minimal-hooks.test.cjs index 13ee1fbdd..2da12e539 100644 --- a/tests/install-minimal-hooks.test.cjs +++ b/tests/install-minimal-hooks.test.cjs @@ -421,9 +421,10 @@ describe('install: manifest records mode for both profiles', () => { const manifestPath = path.join(targetDir, MANIFEST_NAME); if (!fs.existsSync(manifestPath)) return { mode: '', skillCount: 0, agentCount: 0 }; 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; + // Count SKILL.md files under skills/ (works for both flat and ns-nested layouts). + const skillCount = Object.keys(m.files || {}).filter( + k => k.startsWith('skills/') && k.endsWith('/SKILL.md'), + ).length; const agentCount = Object.keys(m.files || {}).filter(k => k.startsWith('agents/')).length; return { mode: m.mode, skillCount, agentCount }; } finally { @@ -474,9 +475,10 @@ describe('install-minimal-backcompat: --minimal and --profile=core produce same 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; + // Count SKILL.md files under skills/ (works for both flat and ns-nested layouts). + const skillCount = Object.keys(m.files || {}).filter( + k => k.startsWith('skills/') && k.endsWith('/SKILL.md'), + ).length; const markerPath = path.join(targetDir, '.gsd-profile'); const profileMarker = fs.existsSync(markerPath) ? fs.readFileSync(markerPath, 'utf8').trim() : null; return { mode: m.mode, skillCount, profileMarker }; diff --git a/tests/install-nested-layout.test.cjs b/tests/install-nested-layout.test.cjs new file mode 100644 index 000000000..e59b96e7d --- /dev/null +++ b/tests/install-nested-layout.test.cjs @@ -0,0 +1,296 @@ +// #69: namespace nested-skill install layout — multi-runtime parity + +// allow-test-rule: source-text-is-the-product +// Reads installed .md files (product artefacts) from a real install run — +// testing their on-disk layout tests the deployed contract. + +'use strict'; + +process.env.GSD_TEST_MODE = '1'; + +const { describe, test, before, after } = require('node:test'); +const assert = require('node:assert/strict'); +const fs = require('node:fs'); +const path = require('node:path'); +const os = require('node:os'); + +const ROOT = path.join(__dirname, '..'); +const COMMANDS_GSD = path.join(ROOT, 'commands', 'gsd'); + +const { + installRuntimeArtifacts, +} = require('../bin/install.js'); + +const { cleanup } = require('./helpers.cjs'); + +const { + loadSkillsManifest, + resolveProfile, +} = require('../gsd-core/bin/lib/install-profiles.cjs'); + +// --------------------------------------------------------------------------- +// Runtime parity decision matrix (#69) +// --------------------------------------------------------------------------- + +const NEST = [ + { runtime: 'claude', scope: 'global', skillsSub: 'skills', prefix: 'gsd-' }, + { runtime: 'cline', scope: 'global', skillsSub: 'skills', prefix: 'gsd-' }, + { runtime: 'qwen', scope: 'global', skillsSub: 'skills', prefix: 'gsd-' }, + { runtime: 'hermes', scope: 'global', skillsSub: 'skills/gsd', prefix: '' }, + { runtime: 'augment', scope: 'global', skillsSub: 'skills', prefix: 'gsd-' }, + { runtime: 'trae', scope: 'global', skillsSub: 'skills', prefix: 'gsd-' }, + { runtime: 'antigravity', scope: 'global', skillsSub: 'skills', prefix: 'gsd-' }, +]; + +const FLAT = [ + { runtime: 'cursor', scope: 'global', skillsSub: 'skills' }, + { runtime: 'codex', scope: 'global', skillsSub: 'skills' }, + { runtime: 'copilot', scope: 'global', skillsSub: 'skills' }, + { runtime: 'windsurf', scope: 'global', skillsSub: 'skills' }, + { runtime: 'codebuddy', scope: 'global', skillsSub: 'skills' }, + { runtime: 'opencode', scope: 'global', skillsSub: 'skills' }, + { runtime: 'kilo', scope: 'global', skillsSub: 'skills' }, +]; + +const ROUTER_STEMS = ['ns-context', 'ns-ideate', 'ns-manage', 'ns-project', 'ns-review', 'ns-workflow']; + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + +/** + * Parse the `requires:` flow-style array from a router file's raw content. + * Matches `requires: [a, b, c]` (inline array form). + */ +function parseRouterRequires(content) { + const m = content.match(/^requires:\s*\[([^\]]*)\]/m); + if (!m) return []; + return m[1].split(',').map((s) => s.trim()).filter(Boolean); +} + +/** + * Read the requires list for a router stem from the source commands/gsd dir. + */ +function routerChildren(routerStem) { + const srcFile = path.join(COMMANDS_GSD, `${routerStem}.md`); + const content = fs.readFileSync(srcFile, 'utf-8'); + return parseRouterRequires(content); +} + +/** + * Create a fresh temp dir, run installRuntimeArtifacts into it, and return + * the tmpDir path. Caller must cleanup in finally. + */ +function runInstall(runtime, scope, resolved) { + const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), `gsd-nest-test-${runtime}-`)); + installRuntimeArtifacts(runtime, tmpDir, scope, resolved); + return tmpDir; +} + +// Resolve the full profile once (shared by all installs) +const MANIFEST = loadSkillsManifest(COMMANDS_GSD); +const RESOLVED_FULL = resolveProfile({ modes: ['full'], manifest: MANIFEST }); + +// --------------------------------------------------------------------------- +// NEST runtimes: should produce exactly 6 top-level router bundles +// --------------------------------------------------------------------------- + +for (const { runtime, scope, skillsSub, prefix } of NEST) { + describe(`${runtime} (nested layout)`, () => { + let tmpDir; + + before(() => { + tmpDir = runInstall(runtime, scope, RESOLVED_FULL); + }); + + after(() => { + if (tmpDir) { + try { cleanup(tmpDir); } catch { /* best-effort */ } + } + }); + + test(`${runtime}: exactly 6 top-level router bundles, no concrete skill at top level`, () => { + const skillsDir = path.join(tmpDir, skillsSub); + assert.ok(fs.existsSync(skillsDir), `skillsDir must exist: ${skillsDir}`); + + // Find router bundle dirs (top-level, named ns-*) + const topLevel = fs.readdirSync(skillsDir); + const routerDirs = topLevel.filter((n) => n.startsWith(`${prefix}ns-`)); + assert.strictEqual( + routerDirs.length, + 6, + `Expected exactly 6 router dirs under ${skillsDir}, got ${routerDirs.length}: [${routerDirs.join(', ')}]`, + ); + + // Each router dir must be a real directory with a SKILL.md + for (const rd of routerDirs) { + const routerPath = path.join(skillsDir, rd); + assert.ok( + fs.statSync(routerPath).isDirectory(), + `${rd} must be a directory`, + ); + assert.ok( + fs.existsSync(path.join(routerPath, 'SKILL.md')), + `${rd}/SKILL.md must exist`, + ); + } + + // Sample concrete skills must NOT appear at top level + for (const concreteSample of ['plan-phase', 'code-review']) { + const concreteName = `${prefix}${concreteSample}`; + assert.ok( + !topLevel.includes(concreteName), + `Concrete skill ${concreteName} must NOT be at top level for ${runtime}`, + ); + } + + // Total GSD-owned top-level entries must be EXACTLY 6 (only the routers). + // For prefix='gsd-' runtimes: count dirs starting with 'gsd-'. + // For hermes (prefix=''): count ALL dirs under skills/gsd (everything is GSD-owned). + const gsdTopLevelCount = prefix !== '' + ? topLevel.filter((n) => n.startsWith(prefix)).length + : topLevel.filter((n) => fs.statSync(path.join(skillsDir, n)).isDirectory()).length; + assert.strictEqual( + gsdTopLevelCount, + 6, + `Expected exactly 6 total GSD-owned top-level skill dirs for ${runtime} (only routers), got ${gsdTopLevelCount}: [${topLevel.join(', ')}]`, + ); + }); + + test(`${runtime}: every router has a skills/ subdir with its required children as nested SKILL.md`, () => { + const skillsDir = path.join(tmpDir, skillsSub); + + for (const routerStem of ROUTER_STEMS) { + const routerDirName = `${prefix}${routerStem}`; + const routerDir = path.join(skillsDir, routerDirName); + assert.ok( + fs.existsSync(routerDir), + `Router dir must exist: ${routerDir}`, + ); + + const childrenSubdir = path.join(routerDir, 'skills'); + assert.ok( + fs.statSync(childrenSubdir).isDirectory(), + `${routerDirName}/skills must be a directory`, + ); + + const children = routerChildren(routerStem); + assert.ok(children.length > 0, `Router ${routerStem} must have at least one child`); + + for (const child of children) { + const childSkillMd = path.join(childrenSubdir, child, 'SKILL.md'); + assert.ok( + fs.existsSync(childSkillMd), + `${routerDirName}/skills/${child}/SKILL.md must exist (child of ${routerStem})`, + ); + assert.ok( + fs.statSync(childSkillMd).isFile(), + `${routerDirName}/skills/${child}/SKILL.md must be a file`, + ); + } + } + }); + + test(`${runtime}: every router body Read-reference resolves to a nested file`, () => { + const skillsDir = path.join(tmpDir, skillsSub); + + for (const routerStem of ROUTER_STEMS) { + const routerDirName = `${prefix}${routerStem}`; + const routerDir = path.join(skillsDir, routerDirName); + const routerSkillMd = path.join(routerDir, 'SKILL.md'); + assert.ok(fs.existsSync(routerSkillMd), `${routerDirName}/SKILL.md must exist`); + + const body = fs.readFileSync(routerSkillMd, 'utf-8'); + // Extract skills//SKILL.md paths from Read-reference lines in the table + const refs = [...body.matchAll(/skills\/([a-z0-9-]+)\/SKILL\.md/g)].map((m) => m[1]); + + // There should be at least one reference in every nested router body + assert.ok( + refs.length > 0, + `${routerDirName}/SKILL.md must contain at least one skills//SKILL.md reference`, + ); + + for (const stem of refs) { + assert.ok( + fs.existsSync(path.join(routerDir, 'skills', stem, 'SKILL.md')), + `${routerDirName}/SKILL.md references skills/${stem}/SKILL.md but it does not exist on disk`, + ); + } + } + }); + }); +} + +// --------------------------------------------------------------------------- +// claude extra: total top-level gsd- count must equal exactly 6 +// --------------------------------------------------------------------------- + +describe('claude: total top-level gsd- entries == 6', () => { + let tmpDir; + + before(() => { + tmpDir = runInstall('claude', 'global', RESOLVED_FULL); + }); + + after(() => { + if (tmpDir) { + try { cleanup(tmpDir); } catch { /* best-effort */ } + } + }); + + test('claude: total top-level gsd- skill entries == 6', () => { + const skillsDir = path.join(tmpDir, 'skills'); + assert.ok(fs.existsSync(skillsDir), 'skills/ dir must exist'); + + const topLevel = fs.readdirSync(skillsDir).filter((n) => n.startsWith('gsd-')); + assert.strictEqual( + topLevel.length, + 6, + `Expected exactly 6 gsd-* top-level entries under claude/skills, got ${topLevel.length}: [${topLevel.join(', ')}]`, + ); + }); +}); + +// --------------------------------------------------------------------------- +// FLAT runtimes: concrete skills stay top-level, no nesting +// --------------------------------------------------------------------------- + +for (const { runtime, scope, skillsSub } of FLAT) { + describe(`${runtime} (flat layout)`, () => { + let tmpDir; + + before(() => { + tmpDir = runInstall(runtime, scope, RESOLVED_FULL); + }); + + after(() => { + if (tmpDir) { + try { cleanup(tmpDir); } catch { /* best-effort */ } + } + }); + + test(`${runtime}: stays flat — concrete skills remain top-level, no nesting`, () => { + const skillsDir = path.join(tmpDir, skillsSub); + assert.ok(fs.existsSync(skillsDir), `skillsDir must exist: ${skillsDir}`); + + const topLevel = fs.readdirSync(skillsDir); + const gsdEntries = topLevel.filter((n) => n.startsWith('gsd-')); + + // For flat runtimes, there should be many more than 6 top-level gsd- entries + assert.ok( + gsdEntries.length >= 60, + `Flat runtime ${runtime} must have >= 60 gsd-* top-level entries (concrete skills), got ${gsdEntries.length}`, + ); + + // No router dir should contain a skills/ subdirectory (nesting must not have been applied) + const routerDirsPresent = topLevel.filter((n) => n.startsWith('gsd-ns-')); + for (const rd of routerDirsPresent) { + const nestedSkillsDir = path.join(skillsDir, rd, 'skills'); + assert.ok( + !fs.existsSync(nestedSkillsDir), + `Flat runtime ${runtime}: router dir ${rd} must NOT have a skills/ subdirectory (nesting must not apply)`, + ); + } + }); + }); +} diff --git a/tests/install.test.cjs b/tests/install.test.cjs index 42949a22e..ce52a4e61 100644 --- a/tests/install.test.cjs +++ b/tests/install.test.cjs @@ -53,6 +53,43 @@ const { walk, } = require('./helpers/install-shared.cjs'); +/** + * Map from concrete skill stem → ns-* router stem for nesting runtimes. + * These runtimes nest concrete skills at //skills//SKILL.md + * (claude/cline/qwen/trae/augment/antigravity: prefix='gsd-'; hermes: prefix=''). + */ +const CHILD_ROUTER = { + // ns-workflow + 'discuss-phase': 'ns-workflow', 'spec-phase': 'ns-workflow', 'plan-phase': 'ns-workflow', + 'execute-phase': 'ns-workflow', 'verify-work': 'ns-workflow', 'phase': 'ns-workflow', + 'progress': 'ns-workflow', 'ultraplan-phase': 'ns-workflow', + 'plan-review-convergence': 'ns-workflow', 'add-tests': 'ns-workflow', + 'ai-integration-phase': 'ns-workflow', 'autonomous': 'ns-workflow', + 'fast': 'ns-workflow', 'mvp-phase': 'ns-workflow', 'quick': 'ns-workflow', + // ns-project + 'new-project': 'ns-project', 'new-milestone': 'ns-project', 'complete-milestone': 'ns-project', + 'audit-milestone': 'ns-project', 'milestone-summary': 'ns-project', 'import': 'ns-project', + 'ingest-docs': 'ns-project', 'profile-user': 'ns-project', 'review-backlog': 'ns-project', + // ns-review + 'code-review': 'ns-review', 'audit-uat': 'ns-review', 'secure-phase': 'ns-review', + 'eval-review': 'ns-review', 'ui-review': 'ns-review', 'validate-phase': 'ns-review', + 'debug': 'ns-review', 'forensics': 'ns-review', 'audit-fix': 'ns-review', + 'review': 'ns-review', 'ui-phase': 'ns-review', + // ns-context + 'map-codebase': 'ns-context', 'graphify': 'ns-context', 'docs-update': 'ns-context', + 'extract-learnings': 'ns-context', + // ns-ideate + 'capture': 'ns-ideate', 'explore': 'ns-ideate', 'sketch': 'ns-ideate', + 'spike': 'ns-ideate', + // ns-manage + 'config': 'ns-manage', 'workspace': 'ns-manage', 'workstreams': 'ns-manage', + 'thread': 'ns-manage', 'pause-work': 'ns-manage', 'resume-work': 'ns-manage', + 'update': 'ns-manage', 'ship': 'ns-manage', 'inbox': 'ns-manage', + 'pr-branch': 'ns-manage', 'undo': 'ns-manage', 'cleanup': 'ns-manage', + 'health': 'ns-manage', 'manager': 'ns-manage', 'settings': 'ns-manage', + 'stats': 'ns-manage', 'surface': 'ns-manage', 'help': 'ns-manage', +}; + // ─── Section 1: getDirName / getGlobalConfigDir / getConfigDirFromHome ────────── describe('getDirName — all runtimes', () => { @@ -286,7 +323,7 @@ describe('getConfigDirFromHome — spot-checks', () => { // Full E2E for runtimes that have distinct install paths (hermes nested layout, // qwen flat layout, trae flat layout). Others are covered by layout-loop tests. -describe('install/uninstall — hermes (nested skills/gsd/ layout)', () => { +describe('install/uninstall — hermes (nested skills/gsd//skills// layout)', () => { let tmpDir; let previousCwd; @@ -308,19 +345,28 @@ describe('install/uninstall — hermes (nested skills/gsd/ layout)', () => { assert.strictEqual(result.runtime, 'hermes'); assert.strictEqual(result.configDir, fs.realpathSync(targetDir)); - assert.ok(fs.existsSync(path.join(targetDir, 'skills', 'gsd', 'help', 'SKILL.md'))); + // hermes nests: skills/gsd//skills//SKILL.md + const hermesHelpPath = path.join( + targetDir, 'skills', 'gsd', CHILD_ROUTER['help'], 'skills', 'help', 'SKILL.md' + ); + assert.ok(fs.existsSync(hermesHelpPath), + `help SKILL.md must exist at nested path: ${path.relative(targetDir, hermesHelpPath)}`); assert.ok(fs.existsSync(path.join(targetDir, 'skills', 'gsd', 'DESCRIPTION.md')), 'DESCRIPTION.md at category root'); assert.ok(fs.existsSync(path.join(targetDir, 'gsd-core', 'VERSION'))); assert.ok(fs.existsSync(path.join(targetDir, 'agents'))); const manifest = writeManifest(targetDir, 'hermes'); - assert.ok(Object.keys(manifest.files).some(f => f.startsWith('skills/gsd/help/')), - JSON.stringify(manifest.files)); + assert.ok( + Object.keys(manifest.files).some(f => + f.startsWith('skills/gsd/' + CHILD_ROUTER['help'] + '/skills/help/') + ), + JSON.stringify(manifest.files) + ); uninstall(false, 'hermes'); - assert.ok(!fs.existsSync(path.join(targetDir, 'skills', 'gsd', 'help'))); + assert.ok(!fs.existsSync(hermesHelpPath)); assert.ok(!fs.existsSync(path.join(targetDir, 'skills', 'gsd'))); assert.ok(!fs.existsSync(path.join(targetDir, 'gsd-core'))); }); @@ -377,7 +423,7 @@ describe('install/uninstall — hermes (nested skills/gsd/ layout)', () => { }); }); -describe('install/uninstall — qwen (flat skills/gsd-* layout)', () => { +describe('install/uninstall — qwen (nested skills/gsd-/skills// layout)', () => { let tmpDir; let previousCwd; @@ -399,20 +445,29 @@ describe('install/uninstall — qwen (flat skills/gsd-* layout)', () => { assert.strictEqual(result.runtime, 'qwen'); assert.strictEqual(result.configDir, fs.realpathSync(targetDir)); - assert.ok(fs.existsSync(path.join(targetDir, 'skills', 'gsd-help', 'SKILL.md'))); + // qwen nests: skills/gsd-/skills//SKILL.md + const qwenHelpPath = path.join( + targetDir, 'skills', 'gsd-' + CHILD_ROUTER['help'], 'skills', 'help', 'SKILL.md' + ); + assert.ok(fs.existsSync(qwenHelpPath), + `help SKILL.md must exist at nested path: ${path.relative(targetDir, qwenHelpPath)}`); assert.ok(fs.existsSync(path.join(targetDir, 'gsd-core', 'VERSION'))); assert.ok(fs.existsSync(path.join(targetDir, 'agents'))); const manifest = writeManifest(targetDir, 'qwen'); - assert.ok(Object.keys(manifest.files).some(f => f.startsWith('skills/gsd-help/'))); + assert.ok( + Object.keys(manifest.files).some(f => + f.startsWith('skills/gsd-' + CHILD_ROUTER['help'] + '/skills/help/') + ) + ); uninstall(false, 'qwen'); - assert.ok(!fs.existsSync(path.join(targetDir, 'skills', 'gsd-help'))); + assert.ok(!fs.existsSync(qwenHelpPath)); assert.ok(!fs.existsSync(path.join(targetDir, 'gsd-core'))); }); }); -describe('install/uninstall — trae (flat skills/gsd-* layout)', () => { +describe('install/uninstall — trae (nested skills/gsd-/skills// layout)', () => { let tmpDir; let previousCwd; @@ -440,15 +495,24 @@ describe('install/uninstall — trae (flat skills/gsd-* layout)', () => { configDir: fs.realpathSync(targetDir), }); - assert.ok(fs.existsSync(path.join(targetDir, 'skills', 'gsd-help', 'SKILL.md'))); + // trae nests: skills/gsd-/skills//SKILL.md + const traeHelpPath = path.join( + targetDir, 'skills', 'gsd-' + CHILD_ROUTER['help'], 'skills', 'help', 'SKILL.md' + ); + assert.ok(fs.existsSync(traeHelpPath), + `help SKILL.md must exist at nested path: ${path.relative(targetDir, traeHelpPath)}`); assert.ok(fs.existsSync(path.join(targetDir, 'gsd-core', 'VERSION'))); assert.ok(fs.existsSync(path.join(targetDir, 'agents'))); const manifest = writeManifest(targetDir, 'trae'); - assert.ok(Object.keys(manifest.files).some(f => f.startsWith('skills/gsd-help/'))); + assert.ok( + Object.keys(manifest.files).some(f => + f.startsWith('skills/gsd-' + CHILD_ROUTER['help'] + '/skills/help/') + ) + ); uninstall(false, 'trae'); - assert.ok(!fs.existsSync(path.join(targetDir, 'skills', 'gsd-help'))); + assert.ok(!fs.existsSync(traeHelpPath)); assert.ok(!fs.existsSync(path.join(targetDir, 'gsd-core'))); }); }); diff --git a/tests/issue-69-surface-keeps-nested.test.cjs b/tests/issue-69-surface-keeps-nested.test.cjs new file mode 100644 index 000000000..3fa38d32b --- /dev/null +++ b/tests/issue-69-surface-keeps-nested.test.cjs @@ -0,0 +1,80 @@ +// #69 regression: applySurface must NOT re-flatten the nested skill layout +// +// Bug: stageSkillsForRuntimeAsSkills gated nesting on `resolvedProfile.skills === '*'` +// (the sentinel). applySurface → resolveSurface materializes the full profile into a +// concrete Set, so the sentinel check was never true on the surface path. +// Result: applySurface called kind.stage(resolved) → stageSkillsForRuntimeAsSkills with +// a concrete Set → doNest = false → flat layout, overwriting the nested install. +// +// Fix (install-profiles.cts): gate nesting on full OR full-equivalent (all routerStems +// present in the concrete Set) so that the surface path preserves nesting. + +'use strict'; + +process.env.GSD_TEST_MODE = '1'; + +const { describe, test } = require('node:test'); +const assert = require('node:assert/strict'); +const fs = require('node:fs'); +const path = require('node:path'); +const os = require('node:os'); + +const ROOT = path.join(__dirname, '..'); +const COMMANDS_GSD = path.join(ROOT, 'commands', 'gsd'); + +const { installRuntimeArtifacts } = require('../bin/install.js'); +const { applySurface } = require('../gsd-core/bin/lib/surface.cjs'); +const { loadSkillsManifest, resolveProfile } = require('../gsd-core/bin/lib/install-profiles.cjs'); +const { resolveRuntimeArtifactLayout } = require('../gsd-core/bin/lib/runtime-artifact-layout.cjs'); +const { cleanup } = require('./helpers.cjs'); + +describe('issue-69: applySurface preserves nested skill layout (no re-flatten)', () => { + test('claude global full: applySurface keeps 6 router dirs and nested gsd-ns-workflow/skills/plan-phase/SKILL.md', (t) => { + const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-69-surface-')); + t.after(() => { try { cleanup(tmpDir); } catch { /* best-effort */ } }); + + // Step 1: full install + const manifest = loadSkillsManifest(COMMANDS_GSD); + const resolved = resolveProfile({ modes: ['full'], manifest }); + installRuntimeArtifacts('claude', tmpDir, 'global', resolved); + + const skillsDir = path.join(tmpDir, 'skills'); + + // Sanity: install must produce nested layout + const topLevelAfterInstall = fs.readdirSync(skillsDir).filter((n) => n.startsWith('gsd-')); + assert.strictEqual( + topLevelAfterInstall.length, + 6, + `Install must produce exactly 6 gsd-* top-level dirs (routers). Got ${topLevelAfterInstall.length}: [${topLevelAfterInstall.join(', ')}]`, + ); + assert.ok( + fs.existsSync(path.join(skillsDir, 'gsd-ns-workflow', 'skills', 'plan-phase', 'SKILL.md')), + 'After install: gsd-ns-workflow/skills/plan-phase/SKILL.md must exist', + ); + + // Step 2: applySurface (full surface, no surface state file → resolves to full) + const layout = resolveRuntimeArtifactLayout('claude', tmpDir, 'global'); + applySurface(tmpDir, layout, manifest); + + // Step 3: assert nested layout is preserved after applySurface + const topLevelAfterSurface = fs.readdirSync(skillsDir).filter((n) => n.startsWith('gsd-')); + assert.strictEqual( + topLevelAfterSurface.length, + 6, + `After applySurface: expected exactly 6 gsd-* top-level dirs (routers only). Got ${topLevelAfterSurface.length}: [${topLevelAfterSurface.join(', ')}]. ` + + 'Re-flattening detected: applySurface must preserve nested layout (#69 regression).', + ); + + // The nested SKILL.md must still exist (not re-flattened to top-level concrete dir) + assert.ok( + fs.existsSync(path.join(skillsDir, 'gsd-ns-workflow', 'skills', 'plan-phase', 'SKILL.md')), + 'After applySurface: gsd-ns-workflow/skills/plan-phase/SKILL.md must still exist (nested layout preserved)', + ); + + // The concrete skill must NOT have been promoted to a top-level flat dir + assert.ok( + !fs.existsSync(path.join(skillsDir, 'gsd-plan-phase', 'SKILL.md')), + 'After applySurface: gsd-plan-phase/ must NOT exist at top level (#69 re-flatten regression guard)', + ); + }); +}); diff --git a/tests/runtime-artifact-layout.test.cjs b/tests/runtime-artifact-layout.test.cjs index 2bfa835b9..7e45474e8 100644 --- a/tests/runtime-artifact-layout.test.cjs +++ b/tests/runtime-artifact-layout.test.cjs @@ -417,20 +417,40 @@ describe('stage — skills kind (claude global)', () => { assert.ok(entries.length >= 1, 'at least one skill dir should be staged'); }); - test('stage with skills="*" stages all commands/gsd/*.md as skills', () => { + test('stage with skills="*" nests all commands/gsd/*.md under 6 routers (claude)', () => { const layout = resolveRuntimeArtifactLayout('claude', FAKE_STAGE_DIR, 'global'); const skillsKind = layout.kinds.find(k => k.kind === 'skills'); assert.ok(skillsKind, 'should have a skills kind'); const stagedDir = skillsKind.stage(PROFILE_FULL); assert.ok(fs.existsSync(stagedDir), 'stagedDir must exist'); - const entries = fs.readdirSync(stagedDir); - assert.ok(entries.length > 10, `full profile should have many skills, got ${entries.length}`); - for (const entry of entries) { - assert.ok(entry.startsWith('gsd-'), `entry should start with gsd-: ${entry}`); - const skillMd = path.join(stagedDir, entry, 'SKILL.md'); - assert.ok(fs.existsSync(skillMd), `SKILL.md must exist in ${entry}`); + + // Claude is a NESTING runtime: full profile produces exactly 6 gsd-ns-* router dirs. + const topEntries = fs.readdirSync(stagedDir); + assert.strictEqual(topEntries.length, 6, `full profile should have exactly 6 router dirs, got ${topEntries.length}`); + for (const entry of topEntries) { + assert.ok(entry.startsWith('gsd-ns-'), `top-level entry should be a gsd-ns-* router: ${entry}`); + // Each router has its own SKILL.md. + const routerSkillMd = path.join(stagedDir, entry, 'SKILL.md'); + assert.ok(fs.existsSync(routerSkillMd), `router SKILL.md must exist in ${entry}`); + // Each router has a skills/ subdirectory with nested children. + const skillsSubdir = path.join(stagedDir, entry, 'skills'); + assert.ok(fs.existsSync(skillsSubdir), `skills/ subdir must exist in ${entry}`); + assert.ok(fs.statSync(skillsSubdir).isDirectory(), `${entry}/skills must be a directory`); } + + // Total SKILL.md files across all routers + nested children must be large (proves no skill was dropped). + function countSkillMdFiles(dir) { + let count = 0; + for (const entry of fs.readdirSync(dir, { withFileTypes: true })) { + const fullPath = path.join(dir, entry.name); + if (entry.isDirectory()) count += countSkillMdFiles(fullPath); + else if (entry.name === 'SKILL.md') count++; + } + return count; + } + const totalSkillMd = countSkillMdFiles(stagedDir); + assert.ok(totalSkillMd >= 60, `full profile should have >= 60 total SKILL.md files (routers + children), got ${totalSkillMd}`); }); });