Merge remote-tracking branch 'origin/next' into kimi-runtime-support

# Conflicts:
#	docs/ARCHITECTURE.md
This commit is contained in:
Viktorplus
2026-06-08 21:27:05 +02:00
37 changed files with 1865 additions and 281 deletions

View File

@@ -0,0 +1,5 @@
---
type: Fixed
pr: 879
---
**profile-pipeline temp output now lands under the reaped GSD temp root.** `cmdExtractMessages` and `cmdProfileSample` previously created their output directories directly in `os.tmpdir()` root (`gsd-pipeline-*` / `gsd-profile-*`), which `reapStaleTempFiles` never scans (it only scans `GSD_TEMP_DIR = os.tmpdir()/gsd`). The directories accumulated forever. Both sites now call `ensureGsdTempDir()` and create under `GSD_TEMP_DIR`. Also adds missing `after`/`afterEach` teardown to four test fixtures that leaked `gsd-*` temp dirs on every `npm test` run. (#866)

View File

@@ -0,0 +1,5 @@
---
type: Fixed
pr: 880
---
`getMilestonePhaseFilter` now excludes phase headings inside fenced code blocks (``` ``` ``` or `~~~`) — consistent with the fence-aware behavior of `extractCurrentMilestone`. Previously, a `### Phase N:` line inside a fenced block was wrongly counted as a real phase. (#875)

View File

@@ -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 `<router>/skills/<name>/SKILL.md`, cutting the eager skill-listing overhead to ≈6 entries. Concrete skills stay reachable via the router's `Read skills/<name>/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-<concrete>/` skill dirs are removed on upgrade. Recursive/unconfirmed loaders (Cursor, Codex, Copilot, Windsurf, CodeBuddy, OpenCode, Kilo) keep the flat layout.

1
.gitignore vendored
View File

@@ -130,6 +130,7 @@ build/
/gsd-core/bin/lib/core-utils.cjs
/gsd-core/bin/lib/io.cjs
/gsd-core/bin/lib/phase-id.cjs
/gsd-core/bin/lib/phase-locator.cjs
/gsd-core/bin/lib/roadmap-parser.cjs
/gsd-core/bin/lib/drift.cjs
/gsd-core/bin/lib/cjs-command-router-adapter.cjs

View File

@@ -20,6 +20,9 @@ Module owning the pure phase-id parsing and matching helpers: phase-name normali
### Phase Lifecycle Module
Module owning phase create, rename, complete, remove, list, and plan-index operations, plus phase-dir prefix validation, STATE.md staleness detection, and auto-prune behaviour. Entry point: `gsd-core/bin/lib/phase.cjs` (CJS surface). Typed phase events: `GSDPhaseStartEvent`, `GSDPhaseStepStartEvent`, `GSDPhaseStepCompleteEvent`, `GSDPhaseCompleteEvent`. (The SDK native-query surface, the `types.ts` event definitions, `phase-runner.ts`, and `phase-prompt.ts` were retired with the SDK package per ADR-0174.)
### Phase Locator Module
Module owning phase-directory search and location: active-phase discovery against the `.planning/phases/` tree (`searchPhaseInDir`, `findPhaseInternal`) and archived-phase-dir enumeration (`getArchivedPhaseDirs`), matching phase ids/tokens against the filesystem. Depends only on leaf modules (`phase-id` for token/name matching, `core-utils` for fs-scan/path helpers, `planning-workspace` for `planningDir`) — no `loadConfig`, no other core dependency. Extracted from the Core module per ADR-857 rollout phase 2d (#881); `core.cjs` re-exports `searchPhaseInDir`, `findPhaseInternal`, and `getArchivedPhaseDirs` for back-compat. Source of truth: `gsd-core/bin/lib/phase-locator.cjs` (generated from `src/phase-locator.cts`).
### Dispatch Policy Module
Module owning dispatch error mapping, fallback policy, timeout classification, and CLI exit mapping contract.
@@ -107,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/<stem>/` layout (prefix: ''); other skill-runtimes use flat `skills/gsd-<stem>/` 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 `<router>/skills/<name>/SKILL.md` (hermes prefix='': `skills/gsd/ns-*/…`). The remaining skills-runtimes (cursor, codex, copilot, windsurf, codebuddy, opencode, kilo) use the flat `skills/gsd-<stem>/` 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`).
@@ -128,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 `<runtimeConfigDir>/.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 `<router>/skills/<name>/`) or the flat `skills/gsd-<stem>/` 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.

View File

@@ -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.

View File

@@ -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.

View File

@@ -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.

View File

@@ -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.

View File

@@ -123,7 +123,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 `<router>/skills/<name>/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.
@@ -347,6 +347,7 @@ Node.js CLI utility (`gsd-tools.cjs`) with domain modules split across `gsd-core
| `core.cjs` | Shared utilities; compatibility re-exports for planning, I/O (`io.cjs`), and phase-id helpers |
| `io.cjs` | CLI I/O primitives — output/error emission, JSON-error mode, large-payload temp-file spillover |
| `phase-id.cjs` | Pure phase-id parsing/matching helpers — normalize, token match, regex builders (extracted from `core.cjs`, ADR-857) |
| `phase-locator.cjs` | Phase-directory search and location — active-phase discovery (`searchPhaseInDir`, `findPhaseInternal`) and archived-phase-dir enumeration (`getArchivedPhaseDirs`), matching phase ids/tokens against the filesystem (extracted from `core.cjs`, ADR-857) |
| `roadmap-parser.cjs` | ROADMAP.md parsing — milestone slicing, current-milestone extraction, phase/milestone lookups, milestone-phase filter (extracted from `core.cjs`, ADR-857) |
| `planning-workspace.cjs` | Planning seam (`planningDir`, `planningPaths`, active workstream routing, `.planning/.lock`) |
| `state.cjs` | STATE.md parsing, updating, progression, metrics |
@@ -547,7 +548,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/<name>/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
@@ -802,22 +805,22 @@ 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/<name>/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 |
| Kimi CLI | First-existing generic root: `~/.config/agents` recommended, then `~/.agents` when `~/.agents/skills` exists and `~/.config/agents/skills` does not | Deferred and guarded | `skills/gsd-*/SKILL.md` invoked as `/skill:gsd-*` | `agents/gsd.yaml`, `agents/gsd.md`, and `agents/subagents/gsd-*` YAML/prompt pairs | Explicit `kimi --agent-file <configRoot>/agents/gsd.yaml`; no GSD hooks or 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 |
| Kimi CLI | First-existing generic root: `~/.config/agents` recommended, then `~/.agents` when `~/.agents/skills` exists and `~/.config/agents/skills` does not | Deferred and guarded | `skills/gsd-*/SKILL.md` (flat) invoked as `/skill:gsd-*` | `agents/gsd.yaml`, `agents/gsd.md`, and `agents/subagents/gsd-*` YAML/prompt pairs | Explicit `kimi --agent-file <configRoot>/agents/gsd.yaml`; 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/<name>/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/<name>/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/<name>/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/<name>/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/<name>/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/<name>/SKILL.md` (nested concretes) + `.clinerules` | Rules only | No GSD hooks or statusline |
### Upstream Contract Sources

View File

@@ -313,6 +313,7 @@
"phase-command-router.cjs",
"phase-id.cjs",
"phase-lifecycle.cjs",
"phase-locator.cjs",
"phase.cjs",
"phases-command-router.cjs",
"plan-scan.cjs",

View File

@@ -370,7 +370,7 @@ The `gsd-planner` agent is decomposed into a core agent plus reference modules t
---
## CLI Modules (94 shipped)
## CLI Modules (95 shipped)
Full listing: `gsd-core/bin/lib/*.cjs`.
@@ -424,6 +424,7 @@ Full listing: `gsd-core/bin/lib/*.cjs`.
| `phase-command-router.cjs` | Thin CJS subcommand router adapter for `gsd-tools phase` |
| `phase-id.cjs` | Pure phase-id parsing/matching helpers — normalize, token match, milestone/phase-dir id parsing, phase-markdown regex builders (extracted from `core.cjs`, ADR-857) |
| `phase-lifecycle.cjs` | Pure-computation phase lifecycle helpers extracted from the phase-lifecycle SDK handler |
| `phase-locator.cjs` | Phase-directory search/location — active + archived phase-dir discovery, phase-id matching against the filesystem (extracted from `core.cjs`, ADR-857) |
| `phase.cjs` | Phase directory operations, decimal numbering, plan indexing |
| `phases-command-router.cjs` | Thin CJS subcommand router adapter for `gsd-tools phases` |
| `plan-scan.cjs` | Canonical phase-plan scanner for detecting plan and summary files in flat and nested layouts (k014) |

View File

@@ -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:<namespace>`, 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 `<router>/skills/<name>/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/<name>/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-<name>` 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/<name>/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-<concrete>/` skill directories on upgrade — no manual cleanup is needed.
---

View File

@@ -92,6 +92,7 @@ export default tseslint.config(
'gsd-core/bin/lib/core-utils.cjs',
'gsd-core/bin/lib/io.cjs',
'gsd-core/bin/lib/phase-id.cjs',
'gsd-core/bin/lib/phase-locator.cjs',
'gsd-core/bin/lib/roadmap-parser.cjs',
'gsd-core/bin/lib/drift.cjs',
'gsd-core/bin/lib/cjs-command-router-adapter.cjs',

View File

@@ -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",

View File

@@ -56,8 +56,10 @@ const {
getPhaseFileStats,
readSubdirectories,
timeAgo,
extractCanonicalPlanId,
} = coreUtilsModule;
// eslint-disable-next-line @typescript-eslint/no-require-imports
import phaseLocatorModule = require('./phase-locator.cjs');
const { searchPhaseInDir, findPhaseInternal, getArchivedPhaseDirs } = phaseLocatorModule;
import { findProjectRoot } from './project-root.cjs';
import { getGlobalConfigDir } from './runtime-homes.cjs';
@@ -520,150 +522,14 @@ function pruneOrphanedWorktrees(repoRoot: string): string[] {
// — all imported via `phaseIdModule` above; internal callers use the destructured bindings.
// extractCanonicalPlanId moved to core-utils.cjs (ADR-857 phase 2c / #877).
// The destructured binding above (from coreUtilsModule) makes it available to
// core-internal callers (searchPhaseInDir). It is NOT in core.cjs's public export =
// block (it was never public).
// It is consumed exclusively by phase-locator.cjs, which imports it from
// core-utils.cjs directly. It is NOT destructured in core.cts and is NOT
// in core.cjs's public export = block (it was never public).
interface PhaseSearchResult {
found: boolean;
directory: string;
phase_number: string;
phase_name: string | null;
phase_slug: string | null;
plans: string[];
summaries: string[];
incomplete_plans: string[];
has_research: boolean;
has_context: boolean;
has_verification: boolean;
has_reviews: boolean;
archived?: string;
}
function searchPhaseInDir(baseDir: string, relBase: string, normalized: string): PhaseSearchResult | null {
try {
const dirs = readSubdirectories(baseDir, true);
const match = dirs.find(d => phaseTokenMatches(d, normalized));
if (!match) return null;
const phaseToken = extractPhaseToken(match);
const phaseNumber = phaseToken || normalized;
const afterToken = match.slice(phaseToken ? phaseToken.length : 0).replace(/^-/, '');
const phaseName = afterToken || null;
const phaseDir = path.join(baseDir, match);
const { plans: unsortedPlans, summaries: unsortedSummaries, hasResearch, hasContext, hasVerification, hasReviews } = getPhaseFileStats(phaseDir);
const plans = unsortedPlans.sort();
const summaries = unsortedSummaries.sort();
const completedPlanIds = new Set(
summaries.flatMap(s => {
const exact = s.replace('-SUMMARY.md', '').replace('SUMMARY.md', '');
const canonical = extractCanonicalPlanId(s);
return canonical === exact ? [exact] : [exact, canonical];
})
);
const incompletePlans = plans.filter(p => {
const planId = p.replace('-PLAN.md', '').replace('PLAN.md', '');
const canonical = extractCanonicalPlanId(p);
return !completedPlanIds.has(planId) && !completedPlanIds.has(canonical);
});
return {
found: true,
directory: toPosixPath(path.join(relBase, match)),
phase_number: phaseNumber,
phase_name: phaseName,
phase_slug: phaseName ? phaseName.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-+|-+$/g, '') : null,
plans,
summaries,
incomplete_plans: incompletePlans,
has_research: hasResearch,
has_context: hasContext,
has_verification: hasVerification,
has_reviews: hasReviews,
};
} catch {
return null;
}
}
function findPhaseInternal(cwd: string, phase: unknown): PhaseSearchResult | null {
if (!phase) return null;
const phasesDir = path.join(planningDir(cwd), 'phases');
const normalized = normalizePhaseName(phase);
const relPhasesDir = toPosixPath(path.relative(cwd, phasesDir));
const current = searchPhaseInDir(phasesDir, relPhasesDir, normalized);
if (current) return current;
const milestonesDir = path.join(cwd, '.planning', 'milestones');
if (!fs.existsSync(milestonesDir)) return null;
try {
const milestoneEntries = fs.readdirSync(milestonesDir, { withFileTypes: true });
const archiveDirs = milestoneEntries
.filter(e => e.isDirectory() && /^v[\d.]+-phases$/.test(e.name))
.map(e => e.name)
.sort()
.reverse();
for (const archiveName of archiveDirs) {
const versionMatch = archiveName.match(/^(v[\d.]+)-phases$/);
const version = versionMatch![1];
const archivePath = path.join(milestonesDir, archiveName);
const relBase = '.planning/milestones/' + archiveName;
const result = searchPhaseInDir(archivePath, relBase, normalized);
if (result) {
result.archived = version;
return result;
}
}
} catch { /* intentionally empty */ }
return null;
}
interface ArchivedPhaseDir {
name: string;
milestone: string;
basePath: string;
fullPath: string;
}
function getArchivedPhaseDirs(cwd: string): ArchivedPhaseDir[] {
const milestonesDir = path.join(cwd, '.planning', 'milestones');
const results: ArchivedPhaseDir[] = [];
if (!fs.existsSync(milestonesDir)) return results;
try {
const milestoneEntries = fs.readdirSync(milestonesDir, { withFileTypes: true });
const phaseDirs = milestoneEntries
.filter(e => e.isDirectory() && /^v[\d.]+-phases$/.test(e.name))
.map(e => e.name)
.sort()
.reverse();
for (const archiveName of phaseDirs) {
const versionMatch = archiveName.match(/^(v[\d.]+)-phases$/);
const version = versionMatch![1];
const archivePath = path.join(milestonesDir, archiveName);
const dirs = readSubdirectories(archivePath, true);
for (const dir of dirs) {
results.push({
name: dir,
milestone: version,
basePath: path.join('.planning', 'milestones', archiveName),
fullPath: path.join(archivePath, dir),
});
}
}
} catch { /* intentionally empty */ }
return results;
}
// searchPhaseInDir, findPhaseInternal, getArchivedPhaseDirs moved to phase-locator.cjs
// (ADR-857 phase 2d / #881). The destructured bindings above (from phaseLocatorModule)
// make them available to core-internal callers; core.cjs re-exports findPhaseInternal,
// getArchivedPhaseDirs, and searchPhaseInDir for back-compat.
// ─── Roadmap milestone scoping (re-exported from roadmap-parser.cjs) ──────────
// stripShippedMilestones, extractCurrentMilestone, replaceInCurrentMilestone,

View File

@@ -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<string>;
routerChildren: Map<string, string[]>;
childToRouters: Map<string, string[]>;
}
/**
* 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<string>();
const routerChildren = new Map<string, string[]>();
const childToRouters = new Map<string, string[]>();
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-<stem>` token (optionally with `--flag`
* suffixes) is rewritten to `Read \`skills/<stem>/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/<name>/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);

183
src/phase-locator.cts Normal file
View File

@@ -0,0 +1,183 @@
/**
* Phase Locator — Phase-directory search and location
*
* ADR-857 rollout phase 2d: extracted from core.cts (issue #881).
* Owns active-phase discovery against the `.planning/phases/` tree
* (`searchPhaseInDir`, `findPhaseInternal`) and archived-phase-dir
* enumeration (`getArchivedPhaseDirs`), matching phase ids/tokens against
* the filesystem. Behaviour is preserved byte-for-behaviour from the prior
* location; only the module boundary moved. core.cjs re-exports
* `searchPhaseInDir`, `findPhaseInternal`, and `getArchivedPhaseDirs` for back-compat.
*
* New imports should pull phase-locator helpers from phase-locator.cjs
* directly.
*
* Dependencies (leaf modules only — no core.cjs, no loadConfig):
* - node:fs / node:path (stdlib)
* - ./phase-id.cjs (normalizePhaseName, phaseTokenMatches, extractPhaseToken)
* - ./core-utils.cjs (readSubdirectories, getPhaseFileStats, extractCanonicalPlanId, toPosixPath)
* - ./planning-workspace.cjs (planningDir)
*/
import fs from 'node:fs';
import path from 'node:path';
// eslint-disable-next-line @typescript-eslint/no-require-imports
import phaseIdModule = require('./phase-id.cjs');
const { normalizePhaseName, phaseTokenMatches, extractPhaseToken } = phaseIdModule;
// eslint-disable-next-line @typescript-eslint/no-require-imports
import coreUtilsModule = require('./core-utils.cjs');
const { readSubdirectories, getPhaseFileStats, extractCanonicalPlanId, toPosixPath } = coreUtilsModule;
// eslint-disable-next-line @typescript-eslint/no-require-imports
import planningWorkspace = require('./planning-workspace.cjs');
const { planningDir } = planningWorkspace;
// ─── Phase search types ───────────────────────────────────────────────────────
interface PhaseSearchResult {
found: boolean;
directory: string;
phase_number: string;
phase_name: string | null;
phase_slug: string | null;
plans: string[];
summaries: string[];
incomplete_plans: string[];
has_research: boolean;
has_context: boolean;
has_verification: boolean;
has_reviews: boolean;
archived?: string;
}
interface ArchivedPhaseDir {
name: string;
milestone: string;
basePath: string;
fullPath: string;
}
// ─── Phase search helpers ─────────────────────────────────────────────────────
function searchPhaseInDir(baseDir: string, relBase: string, normalized: string): PhaseSearchResult | null {
try {
const dirs = readSubdirectories(baseDir, true);
const match = dirs.find(d => phaseTokenMatches(d, normalized));
if (!match) return null;
const phaseToken = extractPhaseToken(match);
const phaseNumber = phaseToken || normalized;
const afterToken = match.slice(phaseToken ? phaseToken.length : 0).replace(/^-/, '');
const phaseName = afterToken || null;
const phaseDir = path.join(baseDir, match);
const { plans: unsortedPlans, summaries: unsortedSummaries, hasResearch, hasContext, hasVerification, hasReviews } = getPhaseFileStats(phaseDir);
const plans = unsortedPlans.sort();
const summaries = unsortedSummaries.sort();
const completedPlanIds = new Set(
summaries.flatMap(s => {
const exact = s.replace('-SUMMARY.md', '').replace('SUMMARY.md', '');
const canonical = extractCanonicalPlanId(s);
return canonical === exact ? [exact] : [exact, canonical];
})
);
const incompletePlans = plans.filter(p => {
const planId = p.replace('-PLAN.md', '').replace('PLAN.md', '');
const canonical = extractCanonicalPlanId(p);
return !completedPlanIds.has(planId) && !completedPlanIds.has(canonical);
});
return {
found: true,
directory: toPosixPath(path.join(relBase, match)),
phase_number: phaseNumber,
phase_name: phaseName,
phase_slug: phaseName ? phaseName.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-+|-+$/g, '') : null,
plans,
summaries,
incomplete_plans: incompletePlans,
has_research: hasResearch,
has_context: hasContext,
has_verification: hasVerification,
has_reviews: hasReviews,
};
} catch {
return null;
}
}
function findPhaseInternal(cwd: string, phase: unknown): PhaseSearchResult | null {
if (!phase) return null;
const phasesDir = path.join(planningDir(cwd), 'phases');
const normalized = normalizePhaseName(phase);
const relPhasesDir = toPosixPath(path.relative(cwd, phasesDir));
const current = searchPhaseInDir(phasesDir, relPhasesDir, normalized);
if (current) return current;
const milestonesDir = path.join(cwd, '.planning', 'milestones');
if (!fs.existsSync(milestonesDir)) return null;
try {
const milestoneEntries = fs.readdirSync(milestonesDir, { withFileTypes: true });
const archiveDirs = milestoneEntries
.filter(e => e.isDirectory() && /^v[\d.]+-phases$/.test(e.name))
.map(e => e.name)
.sort()
.reverse();
for (const archiveName of archiveDirs) {
const versionMatch = archiveName.match(/^(v[\d.]+)-phases$/);
const version = versionMatch![1];
const archivePath = path.join(milestonesDir, archiveName);
const relBase = '.planning/milestones/' + archiveName;
const result = searchPhaseInDir(archivePath, relBase, normalized);
if (result) {
result.archived = version;
return result;
}
}
} catch { /* intentionally empty */ }
return null;
}
function getArchivedPhaseDirs(cwd: string): ArchivedPhaseDir[] {
const milestonesDir = path.join(cwd, '.planning', 'milestones');
const results: ArchivedPhaseDir[] = [];
if (!fs.existsSync(milestonesDir)) return results;
try {
const milestoneEntries = fs.readdirSync(milestonesDir, { withFileTypes: true });
const phaseDirs = milestoneEntries
.filter(e => e.isDirectory() && /^v[\d.]+-phases$/.test(e.name))
.map(e => e.name)
.sort()
.reverse();
for (const archiveName of phaseDirs) {
const versionMatch = archiveName.match(/^(v[\d.]+)-phases$/);
const version = versionMatch![1];
const archivePath = path.join(milestonesDir, archiveName);
const dirs = readSubdirectories(archivePath, true);
for (const dir of dirs) {
results.push({
name: dir,
milestone: version,
basePath: path.join('.planning', 'milestones', archiveName),
fullPath: path.join(archivePath, dir),
});
}
}
} catch { /* intentionally empty */ }
return results;
}
export = {
searchPhaseInDir,
findPhaseInternal,
getArchivedPhaseDirs,
};

View File

@@ -18,7 +18,7 @@ import os from 'node:os';
import readline from 'node:readline';
// eslint-disable-next-line @typescript-eslint/no-require-imports
import ioModule = require('./io.cjs');
const { output, error, reapStaleTempFiles } = ioModule;
const { output, error, reapStaleTempFiles, ensureGsdTempDir, GSD_TEMP_DIR } = ioModule;
// ─── Types ────────────────────────────────────────────────────────────────────
@@ -413,7 +413,8 @@ async function cmdExtractMessages(projectArg: string, options: { sessionId?: str
}
reapStaleTempFiles('gsd-pipeline-', { dirsOnly: true });
const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-pipeline-'));
ensureGsdTempDir();
const tmpDir = fs.mkdtempSync(path.join(GSD_TEMP_DIR, 'gsd-pipeline-'));
const outputPath = path.join(tmpDir, 'extracted-messages.jsonl');
let sessionsProcessed = 0;
@@ -601,7 +602,8 @@ async function cmdProfileSample(overridePath: string | null | undefined, options
}
reapStaleTempFiles('gsd-profile-', { dirsOnly: true });
const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-profile-'));
ensureGsdTempDir();
const tmpDir = fs.mkdtempSync(path.join(GSD_TEMP_DIR, 'gsd-profile-'));
const outputPath = path.join(tmpDir, 'profile-sample.jsonl');
for (const msg of allMessages) {
fs.appendFileSync(outputPath, JSON.stringify(msg) + '\n');

View File

@@ -327,6 +327,46 @@ function getMilestoneInfo(cwd: string): MilestoneInfo {
}
}
// ─── Fence-aware text helper ──────────────────────────────────────────────────
/**
* Return a copy of `text` with every line that lies inside a fenced code block
* replaced by an empty string, using the same fence semantics as
* `computeSectionEnd` (backtick/tilde, ≥3 chars, indent ≤3 spaces, toggle;
* an unclosed fence treats remaining content as fenced).
*/
function stripFencedLines(text: string): string {
let fenceChar: string | null = null;
let fenceLen = 0;
const lines = text.split('\n');
const result: string[] = [];
for (const line of lines) {
const fm = line.match(/^\s{0,3}((?:`{3,}|~{3,}))(.*)/);
if (fm) {
const ch = fm[1][0];
const ln = fm[1].length;
const trailing = fm[2] || '';
if (!fenceChar) {
fenceChar = ch;
fenceLen = ln;
// The fence-open line itself is not a content line — blank it.
result.push('');
} else if (ch === fenceChar && ln >= fenceLen && /^\s*$/.test(trailing)) {
fenceChar = null;
fenceLen = 0;
// The fence-close line — blank it.
result.push('');
} else {
// A fence marker that doesn't close the current fence (different char or shorter) — keep treating as fenced content.
result.push(fenceChar ? '' : line);
}
} else {
result.push(fenceChar ? '' : line);
}
}
return result.join('\n');
}
// ─── Milestone phase filter ───────────────────────────────────────────────────
type MilestonePhaseFilter = ((dirName: string) => boolean) & {
@@ -416,8 +456,9 @@ function getMilestonePhaseFilter(cwd: string, versionOverride?: string | null):
}
const phasePattern = /#{2,4}\s*(?:\[[^\]]+\]\s*)?Phase\s+([\w][\w.-]*)\s*:/gi;
const roadmapUnfenced = stripFencedLines(roadmap);
let m: RegExpExecArray | null;
while ((m = phasePattern.exec(roadmap)) !== null) {
while ((m = phasePattern.exec(roadmapUnfenced)) !== null) {
milestonePhaseNums.add(m[1]);
}
} catch { /* intentionally empty */ }

View File

@@ -250,6 +250,7 @@ function kimiAgentsKind(destSubpath: string, prefix: string, configDir: string):
* @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,
@@ -257,6 +258,7 @@ function skillsKind(
converterName: string,
runtime: string,
configDir: string,
nested = false,
): ArtifactKind {
return {
kind: 'skills',
@@ -270,7 +272,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);
},
};
}
@@ -313,6 +315,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 `<router>/skills/<name>/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.
*/
@@ -336,7 +371,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;
@@ -364,7 +399,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':
@@ -374,20 +409,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':
@@ -405,7 +440,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 'kimi':

View File

@@ -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-<router>/skills/<stem>/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', () => {

View File

@@ -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 <skillsRoot>/gsd-<router>/skills/<stem>/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-'): <skillsRoot>/gsd-<router>/skills/<stem>/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

View File

@@ -0,0 +1,140 @@
'use strict';
/**
* Regression test for bug #866: profile-pipeline temp output dirs must be
* created under GSD_TEMP_DIR (path.join(os.tmpdir(), 'gsd')), not directly
* under os.tmpdir() root where reapStaleTempFiles() never scans.
*
* Hardening (adversarial-review follow-up):
* - TMPDIR/TEMP/TMP are redirected to a fixture-scoped directory so the child
* process's os.tmpdir() returns an isolated root. This prevents the test from
* touching the real shared temp root and keeps it out of the production
* reaper's view.
* - Both sides of the startsWith assertion are realpath-normalized to kill the
* macOS /var ↔ /private/var symlink flakiness.
* - An explicit exitCode === 0 assertion is added before JSON.parse so a
* non-zero early-exit produces a clear failure rather than a confusing parse
* error.
*/
const { test, describe, beforeEach, afterEach } = require('node:test');
const assert = require('node:assert/strict');
const fs = require('fs');
const path = require('path');
const { runGsdTools, createTempDir, cleanup } = require('./helpers.cjs');
describe('bug-866: profile-pipeline temp dirs under GSD_TEMP_DIR', () => {
let tmpDir;
let isolatedSysTmp;
beforeEach(() => {
tmpDir = createTempDir('gsd-866-');
// Create an isolated os.tmpdir() root inside the fixture so the child
// process never writes to the real shared temp dir.
isolatedSysTmp = path.join(tmpDir, 'systmp');
fs.mkdirSync(isolatedSysTmp, { recursive: true });
});
afterEach(() => {
cleanup(tmpDir);
});
// Helper: create a minimal synthetic sessions directory structure
function createSessions(root) {
const sessionsDir = path.join(root, 'projects');
const projectDir = path.join(sessionsDir, 'test-project-866');
fs.mkdirSync(projectDir, { recursive: true });
const messages = [
{ type: 'user', userType: 'external', message: { content: 'fix the login bug' }, timestamp: Date.now() },
{ type: 'assistant', message: { content: 'Sure.' }, timestamp: Date.now() },
];
fs.writeFileSync(
path.join(projectDir, 'session-001.jsonl'),
messages.map(m => JSON.stringify(m)).join('\n')
);
return sessionsDir;
}
test('extract-messages output_file is under GSD_TEMP_DIR, not os.tmpdir() root', () => {
const sessionsDir = createSessions(tmpDir);
// Pass the isolated tmp root so the child's os.tmpdir() = isolatedSysTmp.
// Belt-and-suspenders: set all three env vars Node checks (TMPDIR=POSIX,
// TEMP+TMP=Windows).
const result = runGsdTools(
['extract-messages', 'test-project-866', '--path', sessionsDir, '--raw'],
tmpDir,
{ TMPDIR: isolatedSysTmp, TEMP: isolatedSysTmp, TMP: isolatedSysTmp }
);
// Explicit exitCode check first — parse errors are confusing on non-zero exit.
assert.strictEqual(result.exitCode, 0, `extract-messages must exit 0; error: ${result.error}`);
assert.ok(result.success, `extract-messages failed: ${result.error}`);
const out = JSON.parse(result.output);
assert.ok(out.output_file, 'should have output_file in result');
const outputFile = out.output_file;
// The expected GSD_TEMP_DIR from the child's perspective: isolatedSysTmp/gsd
const expectedGsdTempDir = path.join(isolatedSysTmp, 'gsd');
// Normalize both sides via realpath to kill macOS /var↔/private/var symlink
// flakiness. Realpath the existing output_file's parent directory (the file
// itself may have been cleaned up already, but the dir will exist).
const expectedRoot = fs.realpathSync(expectedGsdTempDir);
const outputDir = path.dirname(outputFile);
// The output dir must exist since the tool just wrote there; realpath it.
const actualDir = fs.realpathSync(outputDir);
assert.ok(
actualDir.startsWith(expectedRoot + path.sep) || actualDir === expectedRoot,
`output_file "${outputFile}" must be under GSD_TEMP_DIR "${expectedGsdTempDir}" (realpath: ${expectedRoot}); got dir "${actualDir}"`
);
// Must NOT be directly under the isolated systmp root (i.e., no gsd-pipeline-*
// at depth 1 of isolatedSysTmp).
const rel = path.relative(isolatedSysTmp, outputFile);
const depth1Dir = rel.split(path.sep)[0];
assert.ok(
!depth1Dir.startsWith('gsd-pipeline-'),
`output_file must not be in isolatedSysTmp/gsd-pipeline-* but got depth-1 dir: "${depth1Dir}"`
);
});
test('profile-sample output_file is under GSD_TEMP_DIR, not os.tmpdir() root', () => {
const sessionsDir = createSessions(tmpDir);
const result = runGsdTools(
['profile-sample', '--path', sessionsDir, '--raw'],
tmpDir,
{ TMPDIR: isolatedSysTmp, TEMP: isolatedSysTmp, TMP: isolatedSysTmp }
);
// Explicit exitCode check first.
assert.strictEqual(result.exitCode, 0, `profile-sample must exit 0; error: ${result.error}`);
assert.ok(result.success, `profile-sample failed: ${result.error}`);
const out = JSON.parse(result.output);
assert.ok(out.output_file, 'should have output_file in result');
const outputFile = out.output_file;
const expectedGsdTempDir = path.join(isolatedSysTmp, 'gsd');
const expectedRoot = fs.realpathSync(expectedGsdTempDir);
const outputDir = path.dirname(outputFile);
const actualDir = fs.realpathSync(outputDir);
assert.ok(
actualDir.startsWith(expectedRoot + path.sep) || actualDir === expectedRoot,
`output_file "${outputFile}" must be under GSD_TEMP_DIR "${expectedGsdTempDir}" (realpath: ${expectedRoot}); got dir "${actualDir}"`
);
const rel = path.relative(isolatedSysTmp, outputFile);
const depth1Dir = rel.split(path.sep)[0];
assert.ok(
!depth1Dir.startsWith('gsd-profile-'),
`output_file must not be in isolatedSysTmp/gsd-profile-* but got depth-1 dir: "${depth1Dir}"`
);
});
});

View File

@@ -1,12 +1,13 @@
'use strict';
process.env.GSD_TEST_MODE = '1';
const { test, describe } = require('node:test');
const { test, describe, afterEach } = require('node:test');
const assert = require('node:assert/strict');
const fs = require('node:fs');
const os = require('node:os');
const path = require('node:path');
const cp = require('node:child_process');
const helpers = require('./helpers.cjs');
const ROOT = path.join(__dirname, '..');
const SCRIPT = path.join(ROOT, 'scripts', 'changeset', 'cli.cjs');
@@ -63,8 +64,11 @@ function createTaggedRepo() {
}
describe('changeset github release notes: tag-range renderer (#3382)', () => {
let _repo;
afterEach(() => { helpers.cleanup(_repo); _repo = undefined; });
test('loads changed changeset slugs from a git tag range', () => {
const repo = createTaggedRepo();
const repo = (_repo = createTaggedRepo());
const result = loadFragmentsFromRange({ repo, fromRef: 'v1.0.0', toRef: 'v1.0.1' });
assert.deepEqual(result.failures, []);
@@ -78,7 +82,7 @@ describe('changeset github release notes: tag-range renderer (#3382)', () => {
});
test('builds grouped GitHub release-note IR from parsed fragments', () => {
const repo = createTaggedRepo();
const repo = (_repo = createTaggedRepo());
const { fragments } = loadFragmentsFromRange({ repo, fromRef: 'v1.0.0', toRef: 'v1.0.1' });
const ir = buildGithubReleaseNotesIr({ fragments });
@@ -95,7 +99,7 @@ describe('changeset github release notes: tag-range renderer (#3382)', () => {
});
test('CLI writes a notes file suitable for gh release edit --notes-file', () => {
const repo = createTaggedRepo();
const repo = (_repo = createTaggedRepo());
const output = path.join(repo, 'release-notes.md');
const result = cp.spawnSync(
process.execPath,
@@ -130,7 +134,7 @@ describe('changeset github release notes: tag-range renderer (#3382)', () => {
});
test('rejects unsafe git refs before rendering a range', () => {
const repo = createTaggedRepo();
const repo = (_repo = createTaggedRepo());
assert.throws(
() => loadFragmentsFromRange({ repo, fromRef: '--help', toRef: 'v1.0.1' }),
/Invalid git ref/,

View File

@@ -10,16 +10,21 @@
* content when claude_md_assembly.mode is "link".
*/
const { test } = require('node:test');
const { test, 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 helpers = require('./helpers.cjs');
const { cmdGenerateClaudeMd } = require('../gsd-core/bin/lib/profile-output.cjs');
const _dirsToClean = [];
after(() => { for (const d of _dirsToClean) helpers.cleanup(d); });
function makeTempProject(files = {}) {
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-2415-'));
_dirsToClean.push(dir);
fs.mkdirSync(path.join(dir, '.planning', 'codebase'), { recursive: true });
for (const [rel, content] of Object.entries(files)) {
const abs = path.join(dir, rel);

View File

@@ -8,16 +8,21 @@
* Tests for gsd-health MILESTONES.md drift detection (#2446).
*/
const { test } = require('node:test');
const { test, 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 helpers = require('./helpers.cjs');
const { cmdValidateHealth } = require('../gsd-core/bin/lib/verify.cjs');
const _dirsToClean = [];
after(() => { for (const d of _dirsToClean) helpers.cleanup(d); });
function makeTempProject(files = {}) {
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-2446-'));
_dirsToClean.push(dir);
fs.mkdirSync(path.join(dir, '.planning', 'milestones'), { recursive: true });
for (const [rel, content] of Object.entries(files)) {
const abs = path.join(dir, rel);

View File

@@ -8,17 +8,22 @@
* Tests for canonical artifact registry and gsd-health W019 lint (#2448).
*/
const { test, describe } = require('node:test');
const { test, describe, 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 helpers = require('./helpers.cjs');
const { isCanonicalPlanningFile, CANONICAL_EXACT } = require('../gsd-core/bin/lib/artifacts.cjs');
const { cmdValidateHealth } = require('../gsd-core/bin/lib/verify.cjs');
const _dirsToClean = [];
after(() => { for (const d of _dirsToClean) helpers.cleanup(d); });
function makeTempProject(files = {}) {
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-2448-'));
_dirsToClean.push(dir);
fs.mkdirSync(path.join(dir, '.planning', 'phases'), { recursive: true });
for (const [rel, content] of Object.entries(files)) {
const abs = path.join(dir, rel);

View File

@@ -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/<stem>.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-<stem> 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.

View File

@@ -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-'): <skillsRoot>/gsd-<router>/skills/<stem>/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}`);

View File

@@ -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: '<no manifest>', 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 };

View File

@@ -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 <prefix>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/<stem>/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/<stem>/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)`,
);
}
});
});
}

View File

@@ -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 <skillsRoot>/<prefix><router>/skills/<stem>/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/<router>/skills/<stem>/ 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/<router>/skills/<stem>/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-<router>/skills/<stem>/ 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-<router>/skills/<stem>/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-<router>/skills/<stem>/ 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-<router>/skills/<stem>/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')));
});
});

View File

@@ -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<string>, 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)',
);
});
});

View File

@@ -0,0 +1,430 @@
/**
* Tests for src/phase-locator.cts (compiled to gsd-core/bin/lib/phase-locator.cjs).
*
* Verifies behavioural contracts of the phase-locator helpers extracted from
* core.cjs per ADR-857 rollout phase 2d (#881):
* - searchPhaseInDir
* - findPhaseInternal
* - getArchivedPhaseDirs
* - core.cjs re-export shims resolve to the exact same functions (shim-identity)
*
* Adversarial inputs: decimal/repeated phase ids, path-traversal-like names,
* unicode, missing/empty phases dir, milestone-prefixed dirs.
* Uses helpers.cjs createTempProject/cleanup for filesystem tests.
*
* Phase dir naming convention: zero-padded (e.g. "01-setup", "02-auth").
* normalizePhaseName('1') → '01'; phaseTokenMatches('01-setup', '01') → true.
*/
'use strict';
const { test, describe, afterEach } = require('node:test');
const assert = require('node:assert/strict');
const fs = require('node:fs');
const path = require('node:path');
const phaseLocator = require('../gsd-core/bin/lib/phase-locator.cjs');
const core = require('../gsd-core/bin/lib/core.cjs');
const { createTempProject, cleanup } = require('./helpers.cjs');
// ─── Shim-identity assertions ─────────────────────────────────────────────────
describe('phase-locator: shim-identity — core.cjs re-exports same function objects', () => {
test('core.findPhaseInternal === phaseLocator.findPhaseInternal', () => {
assert.strictEqual(core.findPhaseInternal, phaseLocator.findPhaseInternal);
});
test('core.getArchivedPhaseDirs === phaseLocator.getArchivedPhaseDirs', () => {
assert.strictEqual(core.getArchivedPhaseDirs, phaseLocator.getArchivedPhaseDirs);
});
test('core.searchPhaseInDir === phaseLocator.searchPhaseInDir', () => {
assert.strictEqual(core.searchPhaseInDir, phaseLocator.searchPhaseInDir);
});
});
// ─── findPhaseInternal — basic active-phase lookup ────────────────────────────
describe('findPhaseInternal: active phase lookup', () => {
let tmpDir;
afterEach(() => { if (tmpDir) { cleanup(tmpDir); tmpDir = null; } });
test('returns null for falsy phase argument', () => {
tmpDir = createTempProject('gsd-pl-test-');
assert.strictEqual(phaseLocator.findPhaseInternal(tmpDir, null), null);
assert.strictEqual(phaseLocator.findPhaseInternal(tmpDir, ''), null);
assert.strictEqual(phaseLocator.findPhaseInternal(tmpDir, 0), null);
assert.strictEqual(phaseLocator.findPhaseInternal(tmpDir, undefined), null);
});
test('returns null when phases dir does not exist', () => {
// Use a raw tmpDir (no phases subdir) to simulate missing phases dir
tmpDir = fs.mkdtempSync(path.join(require('node:os').tmpdir(), 'gsd-pl-test-'));
fs.mkdirSync(path.join(tmpDir, '.planning'), { recursive: true });
const result = phaseLocator.findPhaseInternal(tmpDir, '1');
assert.strictEqual(result, null);
});
test('returns null when phases dir is empty', () => {
tmpDir = createTempProject('gsd-pl-test-');
const result = phaseLocator.findPhaseInternal(tmpDir, '1');
assert.strictEqual(result, null);
});
test('finds a simple phase by number (zero-padded dir)', () => {
tmpDir = createTempProject('gsd-pl-test-');
// Phase dirs use zero-padded format: normalizePhaseName('1') = '01'
const phaseDir = path.join(tmpDir, '.planning', 'phases', '01-setup');
fs.mkdirSync(phaseDir, { recursive: true });
const result = phaseLocator.findPhaseInternal(tmpDir, '1');
assert.ok(result !== null, 'expected a result for phase 1');
assert.strictEqual(result.found, true);
assert.strictEqual(result.phase_number, '01');
assert.strictEqual(result.phase_name, 'setup');
assert.strictEqual(result.phase_slug, 'setup');
assert.ok(result.directory.includes('01-setup'));
assert.strictEqual(result.archived, undefined);
});
test('finds phase by full normalized id', () => {
tmpDir = createTempProject('gsd-pl-test-');
const phaseDir = path.join(tmpDir, '.planning', 'phases', '02-auth');
fs.mkdirSync(phaseDir, { recursive: true });
const result = phaseLocator.findPhaseInternal(tmpDir, '02');
assert.ok(result !== null);
assert.strictEqual(result.phase_number, '02');
assert.strictEqual(result.phase_name, 'auth');
});
test('reports plans and summaries from phase directory', () => {
tmpDir = createTempProject('gsd-pl-test-');
const phaseDir = path.join(tmpDir, '.planning', 'phases', '01-impl');
fs.mkdirSync(phaseDir, { recursive: true });
fs.writeFileSync(path.join(phaseDir, 'FEATURE-PLAN.md'), '# Plan');
fs.writeFileSync(path.join(phaseDir, 'FEATURE-SUMMARY.md'), '# Summary');
const result = phaseLocator.findPhaseInternal(tmpDir, '1');
assert.ok(result !== null);
assert.ok(result.plans.includes('FEATURE-PLAN.md'));
assert.ok(result.summaries.includes('FEATURE-SUMMARY.md'));
assert.deepEqual(result.incomplete_plans, []);
});
test('includes incomplete plans (plans without corresponding summaries)', () => {
tmpDir = createTempProject('gsd-pl-test-');
const phaseDir = path.join(tmpDir, '.planning', 'phases', '03-work');
fs.mkdirSync(phaseDir, { recursive: true });
fs.writeFileSync(path.join(phaseDir, 'A-PLAN.md'), '# A Plan');
fs.writeFileSync(path.join(phaseDir, 'B-PLAN.md'), '# B Plan');
fs.writeFileSync(path.join(phaseDir, 'A-SUMMARY.md'), '# A Summary');
const result = phaseLocator.findPhaseInternal(tmpDir, '3');
assert.ok(result !== null);
assert.ok(result.incomplete_plans.includes('B-PLAN.md'));
assert.ok(!result.incomplete_plans.includes('A-PLAN.md'));
});
test('directory is a posix-style relative path from cwd', () => {
tmpDir = createTempProject('gsd-pl-test-');
const phaseDir = path.join(tmpDir, '.planning', 'phases', '01-setup');
fs.mkdirSync(phaseDir, { recursive: true });
const result = phaseLocator.findPhaseInternal(tmpDir, '1');
assert.ok(result !== null);
assert.ok(!result.directory.includes('\\'), 'directory should use forward slashes');
assert.ok(result.directory.startsWith('.planning/phases/'));
});
test('returns null when requested phase is not present', () => {
tmpDir = createTempProject('gsd-pl-test-');
fs.mkdirSync(path.join(tmpDir, '.planning', 'phases', '02-other'), { recursive: true });
const result = phaseLocator.findPhaseInternal(tmpDir, '1');
assert.strictEqual(result, null);
});
});
// ─── findPhaseInternal — decimal/complex phase ids ────────────────────────────
describe('findPhaseInternal: decimal and complex phase ids (adversarial)', () => {
let tmpDir;
afterEach(() => { if (tmpDir) { cleanup(tmpDir); tmpDir = null; } });
test('finds decimal sub-phase (e.g. 01.1)', () => {
tmpDir = createTempProject('gsd-pl-test-');
// normalizePhaseName('1.1') = '01.1'
const phaseDir = path.join(tmpDir, '.planning', 'phases', '01.1-subsection');
fs.mkdirSync(phaseDir, { recursive: true });
const result = phaseLocator.findPhaseInternal(tmpDir, '1.1');
assert.ok(result !== null, 'should find decimal phase 1.1');
assert.strictEqual(result.found, true);
assert.strictEqual(result.phase_number, '01.1');
});
test('decimal sub-phase dir is not matched by integer-only search', () => {
tmpDir = createTempProject('gsd-pl-test-');
// Create only 01.1-sub, NOT 01-something: searching for '1' should return null
const phaseDir = path.join(tmpDir, '.planning', 'phases', '01.1-sub');
fs.mkdirSync(phaseDir, { recursive: true });
const result = phaseLocator.findPhaseInternal(tmpDir, '1');
// '01' does not match '01.1-sub' (they are distinct tokens)
assert.strictEqual(result, null);
});
test('handles phases with multi-segment names', () => {
tmpDir = createTempProject('gsd-pl-test-');
const phaseDir = path.join(tmpDir, '.planning', 'phases', '05-some-long-phase-name');
fs.mkdirSync(phaseDir, { recursive: true });
const result = phaseLocator.findPhaseInternal(tmpDir, '5');
assert.ok(result !== null);
assert.strictEqual(result.phase_name, 'some-long-phase-name');
assert.strictEqual(result.phase_slug, 'some-long-phase-name');
});
test('phase with unicode in name — does not throw', () => {
tmpDir = createTempProject('gsd-pl-test-');
try {
const phaseDir = path.join(tmpDir, '.planning', 'phases', '06-中文');
fs.mkdirSync(phaseDir, { recursive: true });
const result = phaseLocator.findPhaseInternal(tmpDir, '6');
// If the filesystem supports unicode dir names, we get a result; if not, null is acceptable
if (result !== null) {
assert.strictEqual(result.found, true);
assert.ok(typeof result.phase_name === 'string' || result.phase_name === null);
}
} catch (e) {
// Some environments may not support unicode filenames; that's fine
assert.ok(e instanceof Error);
}
});
});
// ─── findPhaseInternal — archived phase search ────────────────────────────────
describe('findPhaseInternal: archived milestone phase lookup', () => {
let tmpDir;
afterEach(() => { if (tmpDir) { cleanup(tmpDir); tmpDir = null; } });
test('finds archived phase when not in active phases', () => {
tmpDir = createTempProject('gsd-pl-test-');
const milestonesDir = path.join(tmpDir, '.planning', 'milestones', 'v1.0.0-phases');
fs.mkdirSync(path.join(milestonesDir, '01-archived'), { recursive: true });
const result = phaseLocator.findPhaseInternal(tmpDir, '1');
assert.ok(result !== null, 'should find archived phase');
assert.strictEqual(result.found, true);
assert.strictEqual(result.archived, 'v1.0.0');
assert.ok(result.directory.startsWith('.planning/milestones/v1.0.0-phases/'));
});
test('prefers active phase over archived phase', () => {
tmpDir = createTempProject('gsd-pl-test-');
// Set up both active and archived phase 01
fs.mkdirSync(path.join(tmpDir, '.planning', 'phases', '01-active'), { recursive: true });
const milestonesDir = path.join(tmpDir, '.planning', 'milestones', 'v1.0.0-phases');
fs.mkdirSync(path.join(milestonesDir, '01-archived'), { recursive: true });
const result = phaseLocator.findPhaseInternal(tmpDir, '1');
assert.ok(result !== null);
// Should return active (no archived property)
assert.strictEqual(result.archived, undefined);
assert.ok(result.directory.startsWith('.planning/phases/'));
});
test('searches most recent milestone first (reverse sort)', () => {
tmpDir = createTempProject('gsd-pl-test-');
// v1.2.0 archive has phase 03, v1.1.0 archive also has phase 03
const v110 = path.join(tmpDir, '.planning', 'milestones', 'v1.1.0-phases');
const v120 = path.join(tmpDir, '.planning', 'milestones', 'v1.2.0-phases');
fs.mkdirSync(path.join(v110, '03-old'), { recursive: true });
fs.mkdirSync(path.join(v120, '03-new'), { recursive: true });
const result = phaseLocator.findPhaseInternal(tmpDir, '3');
assert.ok(result !== null);
// v1.2.0 is more recent; reverse-sort means it's checked first
assert.strictEqual(result.archived, 'v1.2.0');
});
test('returns null when phase exists in neither active nor archive', () => {
tmpDir = createTempProject('gsd-pl-test-');
const milestonesDir = path.join(tmpDir, '.planning', 'milestones', 'v1.0.0-phases');
fs.mkdirSync(path.join(milestonesDir, '02-other'), { recursive: true });
const result = phaseLocator.findPhaseInternal(tmpDir, '99');
assert.strictEqual(result, null);
});
test('returns null when milestones dir does not exist', () => {
tmpDir = createTempProject('gsd-pl-test-');
// No .planning/milestones dir — only .planning/phases (empty)
const result = phaseLocator.findPhaseInternal(tmpDir, '1');
assert.strictEqual(result, null);
});
test('ignores non-matching milestone dir names (not vX.Y.Z-phases)', () => {
tmpDir = createTempProject('gsd-pl-test-');
// Directory that doesn't match /^v[\d.]+-phases$/ should be skipped
const badDir = path.join(tmpDir, '.planning', 'milestones', 'not-a-phases-dir');
fs.mkdirSync(path.join(badDir, '01-phase'), { recursive: true });
const result = phaseLocator.findPhaseInternal(tmpDir, '1');
assert.strictEqual(result, null);
});
});
// ─── getArchivedPhaseDirs ─────────────────────────────────────────────────────
describe('getArchivedPhaseDirs', () => {
let tmpDir;
afterEach(() => { if (tmpDir) { cleanup(tmpDir); tmpDir = null; } });
test('returns empty array when .planning/milestones does not exist', () => {
tmpDir = createTempProject('gsd-pl-test-');
const result = phaseLocator.getArchivedPhaseDirs(tmpDir);
assert.deepEqual(result, []);
});
test('returns empty array when milestones dir has no matching phase-archive dirs', () => {
tmpDir = createTempProject('gsd-pl-test-');
const milestonesDir = path.join(tmpDir, '.planning', 'milestones');
fs.mkdirSync(milestonesDir, { recursive: true });
fs.mkdirSync(path.join(milestonesDir, 'not-phases-dir'));
const result = phaseLocator.getArchivedPhaseDirs(tmpDir);
assert.deepEqual(result, []);
});
test('returns phase entries from a single milestone archive', () => {
tmpDir = createTempProject('gsd-pl-test-');
const archiveDir = path.join(tmpDir, '.planning', 'milestones', 'v1.0.0-phases');
fs.mkdirSync(path.join(archiveDir, '01-feature'), { recursive: true });
fs.mkdirSync(path.join(archiveDir, '02-bugfix'), { recursive: true });
const result = phaseLocator.getArchivedPhaseDirs(tmpDir);
assert.ok(Array.isArray(result));
assert.strictEqual(result.length, 2);
const names = result.map(r => r.name).sort();
assert.deepEqual(names, ['01-feature', '02-bugfix']);
});
test('result entries have correct shape', () => {
tmpDir = createTempProject('gsd-pl-test-');
const archiveDir = path.join(tmpDir, '.planning', 'milestones', 'v2.1.0-phases');
fs.mkdirSync(path.join(archiveDir, '03-auth'), { recursive: true });
const result = phaseLocator.getArchivedPhaseDirs(tmpDir);
assert.strictEqual(result.length, 1);
const entry = result[0];
assert.strictEqual(entry.name, '03-auth');
assert.strictEqual(entry.milestone, 'v2.1.0');
assert.strictEqual(entry.basePath, path.join('.planning', 'milestones', 'v2.1.0-phases'));
assert.strictEqual(entry.fullPath, path.join(archiveDir, '03-auth'));
});
test('aggregates phases from multiple milestone archives (most recent first)', () => {
tmpDir = createTempProject('gsd-pl-test-');
const v1Dir = path.join(tmpDir, '.planning', 'milestones', 'v1.0.0-phases');
const v2Dir = path.join(tmpDir, '.planning', 'milestones', 'v2.0.0-phases');
fs.mkdirSync(path.join(v1Dir, '01-old'), { recursive: true });
fs.mkdirSync(path.join(v2Dir, '01-new'), { recursive: true });
const result = phaseLocator.getArchivedPhaseDirs(tmpDir);
assert.strictEqual(result.length, 2);
// Reverse sort: v2.0.0 comes before v1.0.0
const milestones = result.map(r => r.milestone);
assert.strictEqual(milestones[0], 'v2.0.0');
assert.strictEqual(milestones[1], 'v1.0.0');
});
test('adversarial: milestone-prefixed dir names that do not match pattern are skipped', () => {
tmpDir = createTempProject('gsd-pl-test-');
const milestonesDir = path.join(tmpDir, '.planning', 'milestones');
fs.mkdirSync(milestonesDir, { recursive: true });
// These should all be ignored (do not match /^v[\d.]+-phases$/):
for (const bad of ['v1.0.0', 'phases', 'v1.0.0-phase', 'v-phases', '1.0.0-phases']) {
fs.mkdirSync(path.join(milestonesDir, bad), { recursive: true });
fs.mkdirSync(path.join(milestonesDir, bad, '01-sub'), { recursive: true });
}
const result = phaseLocator.getArchivedPhaseDirs(tmpDir);
assert.deepEqual(result, []);
});
test('returns empty array for empty milestone archive dirs', () => {
tmpDir = createTempProject('gsd-pl-test-');
const archiveDir = path.join(tmpDir, '.planning', 'milestones', 'v1.0.0-phases');
fs.mkdirSync(archiveDir, { recursive: true });
// Archive dir exists but has no phase subdirs
const result = phaseLocator.getArchivedPhaseDirs(tmpDir);
assert.deepEqual(result, []);
});
});
// ─── searchPhaseInDir — direct tests ─────────────────────────────────────────
describe('searchPhaseInDir: direct filesystem search', () => {
let tmpDir;
afterEach(() => { if (tmpDir) { cleanup(tmpDir); tmpDir = null; } });
test('returns null for non-existent baseDir', () => {
const result = phaseLocator.searchPhaseInDir('/nonexistent-dir-xyz-' + Date.now(), 'rel/base', '01');
assert.strictEqual(result, null);
});
test('returns null when no matching subdirectory exists', () => {
tmpDir = createTempProject('gsd-pl-test-');
const phasesDir = path.join(tmpDir, '.planning', 'phases');
fs.mkdirSync(path.join(phasesDir, '02-other'), { recursive: true });
const result = phaseLocator.searchPhaseInDir(phasesDir, '.planning/phases', '01');
assert.strictEqual(result, null);
});
test('finds matching dir and returns correct structure', () => {
tmpDir = createTempProject('gsd-pl-test-');
const phasesDir = path.join(tmpDir, '.planning', 'phases');
fs.mkdirSync(path.join(phasesDir, '01-hello'), { recursive: true });
const result = phaseLocator.searchPhaseInDir(phasesDir, '.planning/phases', '01');
assert.ok(result !== null);
assert.strictEqual(result.found, true);
assert.strictEqual(result.phase_number, '01');
assert.strictEqual(result.phase_name, 'hello');
assert.strictEqual(result.phase_slug, 'hello');
assert.strictEqual(result.directory, '.planning/phases/01-hello');
});
test('relBase is prepended to directory in result', () => {
tmpDir = createTempProject('gsd-pl-test-');
const archiveDir = path.join(tmpDir, '.planning', 'milestones', 'v1.0.0-phases');
fs.mkdirSync(path.join(archiveDir, '03-feat'), { recursive: true });
const result = phaseLocator.searchPhaseInDir(archiveDir, '.planning/milestones/v1.0.0-phases', '03');
assert.ok(result !== null);
assert.strictEqual(result.directory, '.planning/milestones/v1.0.0-phases/03-feat');
});
test('adversarial: dir with normal name does not produce path traversal', () => {
tmpDir = createTempProject('gsd-pl-test-');
const phasesDir = path.join(tmpDir, '.planning', 'phases');
fs.mkdirSync(path.join(phasesDir, '01-normal-phase'), { recursive: true });
const result = phaseLocator.searchPhaseInDir(phasesDir, '.planning/phases', '01');
assert.ok(result !== null);
// The directory value should not escape its base
assert.ok(!result.directory.includes('..'));
});
test('adversarial: phase number with repeated decimal segments (e.g. 1.1.1)', () => {
tmpDir = createTempProject('gsd-pl-test-');
const phasesDir = path.join(tmpDir, '.planning', 'phases');
fs.mkdirSync(path.join(phasesDir, '01.1.1-deep'), { recursive: true });
// Result may be found or null depending on normalization; must not throw
const result = phaseLocator.searchPhaseInDir(phasesDir, '.planning/phases', '01.1.1');
assert.ok(result === null || typeof result.found === 'boolean');
});
test('returns has_research/has_context/has_verification/has_reviews as booleans', () => {
tmpDir = createTempProject('gsd-pl-test-');
const phasesDir = path.join(tmpDir, '.planning', 'phases');
const phaseDir = path.join(phasesDir, '01-test');
fs.mkdirSync(phaseDir, { recursive: true });
const result = phaseLocator.searchPhaseInDir(phasesDir, '.planning/phases', '01');
assert.ok(result !== null);
assert.strictEqual(typeof result.has_research, 'boolean');
assert.strictEqual(typeof result.has_context, 'boolean');
assert.strictEqual(typeof result.has_verification, 'boolean');
assert.strictEqual(typeof result.has_reviews, 'boolean');
assert.strictEqual(result.has_research, false);
assert.strictEqual(result.has_context, false);
});
test('adversarial: empty phases dir (no subdirs) returns null', () => {
tmpDir = createTempProject('gsd-pl-test-');
const phasesDir = path.join(tmpDir, '.planning', 'phases');
const result = phaseLocator.searchPhaseInDir(phasesDir, '.planning/phases', '01');
assert.strictEqual(result, null);
});
});

View File

@@ -467,7 +467,7 @@ describe('roadmap-parser: getMilestonePhaseFilter', () => {
assert.strictEqual(filter.phaseCount, 1, 'deduplication: only 1 unique phase');
});
test('adversarial: characterizes current fence-blind behavior for backtick fence (pending #875)', () => {
test('adversarial: phase heading inside backtick fence is excluded (fix #875)', () => {
writeRoadmap(tmpDir, [
'## v1.0: Real',
'```',
@@ -478,10 +478,10 @@ describe('roadmap-parser: getMilestonePhaseFilter', () => {
].join('\n'));
const filter = getMilestonePhaseFilter(tmpDir);
// KNOWN BUG #875: getMilestonePhaseFilter is fence-blind — phase headings inside
// fenced code blocks are incorrectly parsed as real phases. Flip to false when #875 is fixed.
// Phase headings inside fenced code blocks must NOT be counted as real phases.
// getMilestonePhaseFilter is fence-aware (fix #875).
assert.strictEqual(filter('01-real'), true, 'real phase matches');
assert.strictEqual(filter('999-fake'), true, 'characterization: getMilestonePhaseFilter is currently fence-blind (KNOWN BUG #875) — flip to false when #875 is fixed');
assert.strictEqual(filter('999-fake'), false, 'fenced phase heading is correctly excluded');
});
test('adversarial: unclosed fence block — does not crash', () => {
@@ -501,7 +501,7 @@ describe('roadmap-parser: getMilestonePhaseFilter', () => {
assert.ok(typeof filter === 'function', 'filter is a function');
});
test('adversarial: characterizes current fence-blind behavior for tilde fence (pending #875)', () => {
test('adversarial: phase heading inside tilde fence is excluded (fix #875)', () => {
writeRoadmap(tmpDir, [
'## v1.0: Tilde',
'~~~',
@@ -511,10 +511,35 @@ describe('roadmap-parser: getMilestonePhaseFilter', () => {
].join('\n'));
const filter = getMilestonePhaseFilter(tmpDir);
// KNOWN BUG #875: getMilestonePhaseFilter is fence-blind — phase headings inside
// tilde-fenced code blocks are incorrectly parsed as real phases. Flip to false when #875 is fixed.
// Phase headings inside tilde-fenced code blocks must NOT be counted as real phases.
// getMilestonePhaseFilter is fence-aware (fix #875).
assert.strictEqual(filter('01-real'), true, 'real phase matches despite tilde fence');
assert.strictEqual(filter('999-fake'), true, 'characterization: getMilestonePhaseFilter is currently fence-blind (KNOWN BUG #875) — flip to false when #875 is fixed');
assert.strictEqual(filter('999-fake'), false, 'tilde-fenced phase heading is correctly excluded');
});
test('adversarial: phase heading inside fence is excluded with CRLF endings (fix #875)', () => {
const crlf = '## v1.0: CRLF Fence\r\n```\r\n### Phase 999: Fake\r\n```\r\n### Phase 1: Real\r\n';
writeRoadmap(tmpDir, crlf);
const filter = getMilestonePhaseFilter(tmpDir);
assert.strictEqual(filter('01-real'), true, 'real phase matches in CRLF file');
assert.strictEqual(filter('999-fake'), false, 'fenced phase excluded in CRLF file');
});
test('adversarial: phase headings in back-to-back fences are excluded (fix #875)', () => {
writeRoadmap(tmpDir, [
'## v1.0: Adjacent',
'```',
'### Phase 998: Fake A',
'```',
'```',
'### Phase 999: Fake B',
'```',
'### Phase 1: Real',
].join('\n'));
const filter = getMilestonePhaseFilter(tmpDir);
assert.strictEqual(filter('01-real'), true, 'real phase matches');
assert.strictEqual(filter('998-fake'), false, 'first fenced phase excluded');
assert.strictEqual(filter('999-fake'), false, 'second fenced phase excluded');
});
test('adversarial: CRLF line endings in roadmap', () => {

View File

@@ -440,20 +440,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}`);
});
});