From aec3374bc259db2e24601afefa0b6f48c18861c1 Mon Sep 17 00:00:00 2001 From: Tom Boucher Date: Sat, 13 Jun 2026 01:49:25 -0400 Subject: [PATCH] feat(#1138): make runtime descriptors authoritative (#1157) --- .changeset/kind-quails-purr.md | 5 + AGENTS.md | 55 ---------- CONTEXT.md | 6 +- bin/install.js | 14 +++ capabilities/ai-integration/capability.json | 1 + capabilities/audit/capability.json | 1 + capabilities/code-review/capability.json | 1 + capabilities/graphify/capability.json | 1 + capabilities/intel/capability.json | 1 + capabilities/nyquist/capability.json | 1 + capabilities/pattern-mapper/capability.json | 1 + capabilities/research/capability.json | 1 + capabilities/security/capability.json | 1 + capabilities/ui/capability.json | 1 + docs/contributing/bootstrap.md | 4 +- docs/how-to/develop-a-capability.md | 40 +++++++ gsd-core/bin/lib/capability-registry.cjs | 60 +++++++++++ scripts/gen-capability-registry.cjs | 100 ++++++++++++++++++ scripts/release-tarball-smoke.cjs | 8 +- src/install-profiles.cts | 4 +- src/runtime-artifact-layout.cts | 39 ++++--- src/runtime-config-adapter-registry.cts | 47 ++++---- src/runtime-homes.cts | 76 +++++++++---- .../4-phase-complete-cjs-regression.test.cjs | 3 + tests/backwards-compat-phase-id.test.cjs | 7 +- ...lease-tarball-smoke-explicit-home.test.cjs | 15 +++ ...bug-2248-local-install-statusline.test.cjs | 23 ++-- tests/bug-2554-decimal-phase-filter.test.cjs | 2 + ...ug-3037-gemini-duplicate-commands.test.cjs | 28 +++-- ...added-id-against-unpadded-roadmap.test.cjs | 1 + tests/capability-registry.test.cjs | 70 ++++++++++++ tests/core.test.cjs | 8 +- tests/enh-191-retire-sdk-package.test.cjs | 23 ++++ tests/helpers.cjs | 6 ++ tests/install-nested-layout.test.cjs | 29 +++++ tests/phase.test.cjs | 1 + ...-artifact-layout-descriptor-drive.test.cjs | 54 +++++++++- .../runtime-config-adapter-registry.test.cjs | 54 ++++++++++ tests/runtime-homes-descriptor-drive.test.cjs | 44 ++++++++ tests/state.test.cjs | 14 ++- 40 files changed, 696 insertions(+), 154 deletions(-) create mode 100644 .changeset/kind-quails-purr.md delete mode 100644 AGENTS.md diff --git a/.changeset/kind-quails-purr.md b/.changeset/kind-quails-purr.md new file mode 100644 index 000000000..eb3da1c90 --- /dev/null +++ b/.changeset/kind-quails-purr.md @@ -0,0 +1,5 @@ +--- +type: Changed +pr: 1157 +--- +Capability manifests now declare runtime compatibility through a validated runtimeCompat contract, and runtime descriptor interpreters now read artifact layout, skills-home, and hook-surface facts directly from runtime Capability descriptors instead of parallel runtime-name allowlists or fallbacks. This preserves existing supported runtime behavior while making future descriptor-backed runtimes additive. diff --git a/AGENTS.md b/AGENTS.md deleted file mode 100644 index 56ea2a9b2..000000000 --- a/AGENTS.md +++ /dev/null @@ -1,55 +0,0 @@ -# Repository Guidelines - -## Active Discussions - -For current work on **Grok Build compatibility** and multi-runtime synchronization across Grok Build, Claude Code, Gemini CLI, and Codex, see: - -- `docs/discussions/grok-build-support-2026-05.md` - -## Project Structure & Module Organization - -This repository ships GSD as a Node.js CLI and SDK. Root package entry points live in `bin/`, scripts in `scripts/`, runtime hooks in `hooks/`, command definitions in `commands/gsd/`, and workflow/template content in `gsd-core/`. Agent role files are in `agents/`; docs are in `docs/`; logos and terminal images are in `assets/`. Root tests are in `tests/*.test.cjs`. The TypeScript SDK is isolated under `sdk/`, with source and Vitest tests in `sdk/src/`. - -## Build, Test, and Development Commands - -Use Node.js `>=22`. - -- `npm install`: install root dependencies. -- `npm test`: builds the SDK first, then runs root `node:test` suites via `scripts/run-tests.cjs`. -- `npm run test:coverage`: runs root tests with `c8` and enforces 70% line coverage for included CommonJS library files. -- `npm run build:hooks`: rebuilds generated hook artifacts. -- `npm run build:sdk`: installs SDK dependencies and builds TypeScript. -- `cd sdk && npm test`: runs SDK Vitest unit and integration projects. -- `cd sdk && npm run build`: type-checks and emits `sdk/dist/`. - -## Coding Style & Naming Conventions - -Match the existing style in the edited area. Root JavaScript is CommonJS, generally strict-mode, two-space indentation, semicolons, `const`/`let`, and `node:` imports for built-ins. SDK code is strict TypeScript using ESM/`NodeNext`. Keep command, workflow, and test filenames kebab-case, for example `commands/gsd/plan-phase.md` and `tests/bug-2396-makefile-test-priority.test.cjs`. Agent files use `gsd-*.md`. Avoid unrelated formatting and unnecessary dependencies. - -## Testing Guidelines - -Root tests use Node’s built-in `node:test` and `node:assert/strict`; do not add Jest, Mocha, or Chai. Prefer helpers from `tests/helpers.cjs` for temporary projects, cleanup, and CLI execution. Name root tests `*.test.cjs`; run one with `node --test tests/name.test.cjs`. SDK tests use Vitest with `*.test.ts` for unit tests and `*.integration.test.ts` for integration tests. - -## Commit & Pull Request Guidelines - -Recent history follows Conventional Commit prefixes such as `fix:`, `feat:`, and `ci:`, often with issue references: `fix(#2623): resolve parent .planning root...`. Keep commits scoped and descriptive. - -Every PR must link an approved or confirmed issue with `Closes #123`, `Fixes #123`, or `Resolves #123`. Use the matching template in `.github/PULL_REQUEST_TEMPLATE/`. Include behavior changes, root cause when relevant, test evidence, affected platforms/runtimes, and update `CHANGELOG.md` or docs for user-facing changes. - -## Security & Configuration Tips - -Do not commit secrets, local config, or generated worktree artifacts. Before release-facing changes, run the relevant scan scripts in `scripts/`, especially `secret-scan.sh`, `base64-scan.sh`, and `prompt-injection-scan.sh`. - -## Agent skills - -### Issue tracker - -Issues live in GitHub Issues at `open-gsd/gsd-core` (via the `gh` CLI, always with `--repo open-gsd/gsd-core`). See `docs/agents/issue-tracker.md`. - -### Triage labels - -Five canonical triage roles mapped to this repo's labels — `needs-info`→`needs-reproduction`, `ready-for-agent`→`confirmed`, `ready-for-human`→`approved-enhancement`/`approved-feature`, others default. See `docs/agents/triage-labels.md`. - -### Domain docs - -Single-context — `CONTEXT.md` (domain glossary + recurring PR rules) and `docs/adr/` at the repo root. See `docs/agents/domain.md`. diff --git a/CONTEXT.md b/CONTEXT.md index a7496af9e..ac09572b5 100644 --- a/CONTEXT.md +++ b/CONTEXT.md @@ -92,7 +92,7 @@ Module owning workstream directory discovery, per-workstream state projection, p Module owning project-root resolution from any starting directory. Walks the ancestor chain (bounded by `FIND_PROJECT_ROOT_MAX_DEPTH = 10`) applying four heuristics in order: (0) own `.planning/` guard (#1362), (1) parent `.planning/config.json` `sub_repos` traversal, (2) legacy `multiRepo: true` boolean + ancestor `.git`, (3) `.git` heuristic with parent `.planning/`. Returns `startDir` when no ancestor qualifies. Sync `node:fs` I/O. Source of truth: `gsd-core/bin/lib/project-root.cjs`; consumed via a thin re-export at `gsd-core/bin/lib/core.cjs`. ### Planning Path Projection Module -SDK query Module owning projection from project/workstream context to concrete `.planning` paths. Policy precedence is `explicit workstream > env workstream > env project > root`. Invalid workspace context is a validation error at this seam rather than a silent fallback. +Module owning projection from project/workstream context to concrete `.planning` paths. Policy precedence is `explicit workstream > env workstream > env project > root`. Invalid workspace context is a validation error at this seam rather than a silent fallback. ### Worktree Safety Policy Module CJS Module owning worktree lifecycle safety policy for the GSD orchestration layer. Interface: `resolveWorktreeContext(cwd, deps) → WorktreeContext` (linked-worktree root mapping), `parseWorktreePorcelain(output) → WorktreeEntry[]` (porcelain parser, skips detached HEAD), `planWorktreePrune(repoRoot, opts, deps) → PrunePlan` (metadata-prune plan, never destructive by default), `executeWorktreePrunePlan(plan, deps) → PruneResult` (executes prune; degrades gracefully on git timeout), `listLinkedWorktreePaths(repoRoot, deps) → LinkedPathsResult`, `inspectWorktreeHealth(repoRoot, opts, deps) → HealthResult` (orphan + stale detection), `snapshotWorktreeInventory(repoRoot, opts, deps) → InventoryResult`, `planWorktreeWaveCleanup(repoRoot, manifest) → CleanupPlan` (manifest-scoped, fail-closed), `executeWorktreeWaveCleanupPlan(plan, deps) → CleanupResult`. Source of truth: `gsd-core/bin/lib/worktree-safety.cjs`. Timeout path: all git subprocess calls are bounded; callers receive `ok:false, reason:'git_timed_out'` rather than a thrown exception. Test anchor: `tests/worktree-safety.test.cjs`. @@ -352,8 +352,8 @@ The canonical lint infrastructure adopted in ADR 452 (`docs/adr/452-eslint-lint- `WORKTREE.SEAM.caller-rule=verify.cjs must consume inspectWorktreeHealth for W017 classification; no ad-hoc porcelain parsing in callers` `WORKTREE.SEAM.test-anchor-w017=tests/orphan-worktree-detection.test.cjs + tests/worktree-safety-policy.test.cjs` `WORKTREE.SEAM.inventory-snapshot=snapshotWorktreeInventory(repoRoot,{staleAfterMs,nowMs}) is canonical linked-worktree health snapshot for callers` -`PLANNING.PATH.PARITY.sdk-project-scope=.planning/ (never .planning/projects/); mirror planning-workspace.cjs planningDir()` -`PLANNING.PATH.SEAM.sdk=helpers.planningPaths delegates to workspacePlanningPaths + resolveWorkspaceContext; precedence explicit-ws > env-ws > env-project > root` +`PLANNING.PATH.PARITY.project-scope=.planning/ (never .planning/projects/); mirror planning-workspace.cjs planningDir()` +`PLANNING.PATH.SEAM.helpers=helpers.planningPaths delegates to workspacePlanningPaths + resolveWorkspaceContext; precedence explicit-ws > env-ws > env-project > root` `PLANNING.PATH.SEAM.init-handlers=[initExecutePhase, initPlanPhase, initPhaseOp, initMilestoneOp] consume helpers.planningPaths().planning (no direct relPlanningPath join)` `WORKSTREAM.NAME.POLICY.cjs-module=gsd-core/bin/lib/workstream-name-policy.cjs owns toWorkstreamSlug + active-name/path-segment validation` `WORKSTREAM.POINTER.SEAM.cjs-module=gsd-core/bin/lib/active-workstream-store.cjs owns read/write self-heal for .planning/active-workstream` diff --git a/bin/install.js b/bin/install.js index 463c47f34..5e14d7634 100755 --- a/bin/install.js +++ b/bin/install.js @@ -6926,7 +6926,13 @@ function _applyRuntimeRewrites(content, runtime, pathPrefix, isGlobal = false) { content = content.replace(/~\/\.claude\//g, pathPrefix); content = content.replace(/\$HOME\/\.claude\//g, pathPrefix); content = content.replace(/\.\/\.claude\//g, `./${dirName}/`); + content = content.replace(/~\/\.claude(?![\w-])/g, normalizedPathPrefix); + content = content.replace(/\$HOME\/\.claude(?![\w-])/g, normalizedPathPrefix); + content = content.replace(/\.\/\.claude(?![\w-])/g, `./${dirName}`); content = content.replace(/~\/\.augment\//g, pathPrefix); + content = content.replace(/\$HOME\/\.augment\//g, pathPrefix); + content = content.replace(/~\/\.augment(?![\w-])/g, normalizedPathPrefix); + content = content.replace(/\$HOME\/\.augment(?![\w-])/g, normalizedPathPrefix); content = processAttribution(content, getCommitAttribution(runtime)); break; @@ -6986,6 +6992,10 @@ function _applyRuntimeRewrites(content, runtime, pathPrefix, isGlobal = false) { content = content.replace(/\$HOME\/\.claude\//g, pathPrefix); content = content.replace(/~\/\.qwen\//g, pathPrefix); content = content.replace(/\$HOME\/\.qwen\//g, pathPrefix); + content = content.replace(/~\/\.claude(?![\w-])/g, normalizedPathPrefix); + content = content.replace(/\$HOME\/\.claude(?![\w-])/g, normalizedPathPrefix); + content = content.replace(/~\/\.qwen(?![\w-])/g, normalizedPathPrefix); + content = content.replace(/\$HOME\/\.qwen(?![\w-])/g, normalizedPathPrefix); // Bare relative .claude/ → .qwen/ (residual refs not matched above) content = content.replace(/\.claude\//g, '.qwen/'); content = content.replace(/\.\/\.claude\//g, `./${dirName}/`); @@ -7002,6 +7012,10 @@ function _applyRuntimeRewrites(content, runtime, pathPrefix, isGlobal = false) { content = content.replace(/\$HOME\/\.claude\//g, pathPrefix); content = content.replace(/~\/\.hermes\//g, pathPrefix); content = content.replace(/\$HOME\/\.hermes\//g, pathPrefix); + content = content.replace(/~\/\.claude(?![\w-])/g, normalizedPathPrefix); + content = content.replace(/\$HOME\/\.claude(?![\w-])/g, normalizedPathPrefix); + content = content.replace(/~\/\.hermes(?![\w-])/g, normalizedPathPrefix); + content = content.replace(/\$HOME\/\.hermes(?![\w-])/g, normalizedPathPrefix); // Bare relative .claude/ → .hermes/ (residual refs) content = content.replace(/\.claude\//g, '.hermes/'); content = content.replace(/\.\/\.claude\//g, `./${dirName}/`); diff --git a/capabilities/ai-integration/capability.json b/capabilities/ai-integration/capability.json index 5805dcd87..9649ec585 100644 --- a/capabilities/ai-integration/capability.json +++ b/capabilities/ai-integration/capability.json @@ -5,6 +5,7 @@ "description": "AI-SPEC design contract workflow for phases that build AI systems; owns the AI integration command, agents, and workflow.ai_integration_phase activation key.", "tier": "full", "requires": [], + "runtimeCompat": { "supported": ["*"], "unsupported": [] }, "skills": ["ai-integration-phase"], "agents": [ "gsd-framework-selector", diff --git a/capabilities/audit/capability.json b/capabilities/audit/capability.json index 4543cc45e..508174ccb 100644 --- a/capabilities/audit/capability.json +++ b/capabilities/audit/capability.json @@ -5,6 +5,7 @@ "description": "Open-artifact audit and UAT-gap audit for milestone close gates; exposes `gsd-tools audit-uat` (cross-phase UAT outstanding items) and `gsd-tools audit-open` (structured open-artifact scan across debug, tasks, threads, todos, seeds, UAT, verification, context-questions).", "tier": "full", "requires": [], + "runtimeCompat": { "supported": ["*"], "unsupported": [] }, "skills": [], "agents": [], "config": {}, diff --git a/capabilities/code-review/capability.json b/capabilities/code-review/capability.json index 1404aae06..1713628f5 100644 --- a/capabilities/code-review/capability.json +++ b/capabilities/code-review/capability.json @@ -5,6 +5,7 @@ "description": "Source-file code review and review-fix workflow support for completed execution work.", "tier": "full", "requires": [], + "runtimeCompat": { "supported": ["*"], "unsupported": [] }, "skills": ["code-review"], "agents": ["gsd-code-reviewer", "gsd-code-fixer"], "hooks": [], diff --git a/capabilities/graphify/capability.json b/capabilities/graphify/capability.json index 3545e5be1..4c7e0bfc4 100644 --- a/capabilities/graphify/capability.json +++ b/capabilities/graphify/capability.json @@ -5,6 +5,7 @@ "description": "Build, query, and inspect the project knowledge graph in `.planning/graphs/`; exposes graphify CLI subcommands (build, query, status, diff) and the /gsd-graphify skill.", "tier": "full", "requires": [], + "runtimeCompat": { "supported": ["*"], "unsupported": [] }, "skills": ["graphify"], "agents": [], "config": { diff --git a/capabilities/intel/capability.json b/capabilities/intel/capability.json index 7ad17ee5f..8d7ecba06 100644 --- a/capabilities/intel/capability.json +++ b/capabilities/intel/capability.json @@ -5,6 +5,7 @@ "description": "Code-intelligence store for codebase querying, diff, snapshot, and API-surface extraction; exposes `gsd-tools intel` subcommands (query, status, update, diff, snapshot, patch-meta, validate, extract-exports, api-surface) and backs `/gsd-map-codebase` and `gsd-intel-updater`.", "tier": "full", "requires": [], + "runtimeCompat": { "supported": ["*"], "unsupported": [] }, "skills": [], "agents": [], "config": { diff --git a/capabilities/nyquist/capability.json b/capabilities/nyquist/capability.json index aea60cf2d..cad899c21 100644 --- a/capabilities/nyquist/capability.json +++ b/capabilities/nyquist/capability.json @@ -5,6 +5,7 @@ "description": "Validation coverage audit that maps executed work back to tests and manual-only evidence.", "tier": "full", "requires": [], + "runtimeCompat": { "supported": ["*"], "unsupported": [] }, "skills": ["validate-phase"], "agents": ["gsd-nyquist-auditor"], "hooks": [], diff --git a/capabilities/pattern-mapper/capability.json b/capabilities/pattern-mapper/capability.json index f79087fca..cadfa9e62 100644 --- a/capabilities/pattern-mapper/capability.json +++ b/capabilities/pattern-mapper/capability.json @@ -5,6 +5,7 @@ "description": "Optional codebase-pattern mapping before planning; owns the pattern mapper agent and workflow.pattern_mapper activation key.", "tier": "full", "requires": ["research"], + "runtimeCompat": { "supported": ["*"], "unsupported": [] }, "skills": [], "agents": ["gsd-pattern-mapper"], "hooks": [], diff --git a/capabilities/research/capability.json b/capabilities/research/capability.json index dbe9663f1..51138366e 100644 --- a/capabilities/research/capability.json +++ b/capabilities/research/capability.json @@ -5,6 +5,7 @@ "description": "Optional phase research before planning; owns the phase researcher agent and workflow.research activation key.", "tier": "standard", "requires": [], + "runtimeCompat": { "supported": ["*"], "unsupported": [] }, "skills": [], "agents": ["gsd-phase-researcher"], "hooks": [], diff --git a/capabilities/security/capability.json b/capabilities/security/capability.json index 25cb2af6c..d4d6c6a0c 100644 --- a/capabilities/security/capability.json +++ b/capabilities/security/capability.json @@ -5,6 +5,7 @@ "description": "Threat mitigation verification and ship-time security blocking for phases with security enforcement enabled.", "tier": "full", "requires": [], + "runtimeCompat": { "supported": ["*"], "unsupported": [] }, "skills": ["secure-phase"], "agents": ["gsd-security-auditor"], "hooks": [], diff --git a/capabilities/ui/capability.json b/capabilities/ui/capability.json index bc013952e..16c8ea9f8 100644 --- a/capabilities/ui/capability.json +++ b/capabilities/ui/capability.json @@ -2,6 +2,7 @@ "id": "ui", "role": "feature", "title": "UI design contracts", "description": "UI-SPEC design contract + retrospective UI audit for frontend phases.", "tier": "full", "requires": [], + "runtimeCompat": { "supported": ["*"], "unsupported": [] }, "skills": ["ui-phase", "ui-review"], "agents": ["gsd-ui-checker", "gsd-ui-auditor"], "hooks": [], diff --git a/docs/contributing/bootstrap.md b/docs/contributing/bootstrap.md index f9d189225..b28252cb7 100644 --- a/docs/contributing/bootstrap.md +++ b/docs/contributing/bootstrap.md @@ -65,7 +65,7 @@ See https://docs.npmjs.com/cli/v10/commands/npm-ci | `npm test` | Run the full test suite (unit + integration + security) | | `npm run test:unit` | Unit tests only (fastest) | | `npm run test:integration` | Integration tests | -| `npm run build:sdk` | Rebuild the SDK dist (required before first test run) | +| `npm run build:lib` | Type-check root TypeScript and emit CommonJS build output | > `npm run check:integrity` — available once [#114](https://github.com/open-gsd/gsd-core/issues/114) merges. @@ -183,7 +183,7 @@ nvm use # re-activate from .nvmrc **Fix:** ```bash npm ci # clean install from lockfile -npm run build:sdk +npm run build:lib ``` --- diff --git a/docs/how-to/develop-a-capability.md b/docs/how-to/develop-a-capability.md index 580ef224e..89507d196 100644 --- a/docs/how-to/develop-a-capability.md +++ b/docs/how-to/develop-a-capability.md @@ -57,6 +57,7 @@ At minimum, a feature Capability declares: "description": "Adds an example planning step.", "tier": "standard", "requires": [], + "runtimeCompat": { "supported": ["*"], "unsupported": [] }, "skills": [], "agents": ["gsd-example-agent"], "hooks": [], @@ -78,6 +79,33 @@ At minimum, a feature Capability declares: The registry generator validates the shape, ownership, and cross-capability contracts. `ref.skill` must name a skill declared by the same Capability. `ref.agent` must name an agent declared by the same Capability. +## Declare runtime compatibility + +Every feature Capability must declare `runtimeCompat`. This is part of the GSD 1.5+ developer contract: a Capability says which runtime descriptors it can surface through, and the generator validates that declaration before the central registry is written. + +Use the wildcard when the Capability is runtime-agnostic: + +```json +"runtimeCompat": { + "supported": ["*"], + "unsupported": [] +} +``` + +Use explicit runtime ids when the Capability is intentionally narrower: + +```json +"runtimeCompat": { + "supported": ["claude", "codex"], + "unsupported": ["kilo"], + "notes": { + "kilo": "Requires a hook surface Kilo does not expose yet." + } +} +``` + +`supported` is required and must be non-empty. `"*"` means every descriptor-backed runtime, including future first-party runtime descriptors, is compatible unless it is listed in `unsupported`. Explicit runtime ids and `notes` keys must match runtime Capability ids such as `claude`, `codex`, `opencode`, or `kilo`; typos fail `node scripts/gen-capability-registry.cjs --check`. + ## Add hooks Loop Extension Points are the stable sites where Capabilities attach to the host loop. Phase 6 planning-time features use `plan:pre` so the core planner can ask the registry for active planning hooks instead of reading feature config directly. @@ -139,6 +167,18 @@ node gsd-core/bin/gsd-tools.cjs capability state --config-dir ~/.claude --raw `capability state` is the diagnostic view for the same state that workflow dispatch consumes. For each Capability, `enabled` is true only when the Capability is both installed by the active profile and surfaced by the runtime surface. A hook's `configured` field reflects the `when` config key; `active` is true only when the Capability is enabled and the hook is configured on. +## Add or change a runtime descriptor + +Runtime-specific facts belong in the runtime Capability declaration, not in a parallel allowlist or runtime-name branch. When adding a first-party runtime under `capabilities//capability.json`, declare these fields in the `runtime` object: + +- `configHome`: the global config root resolver, including env overrides and probes. +- `configHome.skillsHome`: optional separate base home for runtimes whose global skills root differs from the config root. +- `artifactLayout.global` and `artifactLayout.local`: command, agent, skill, and Kimi-agent destinations. +- `hooksSurface`, `hookEvents`, and `extendedHookEvents`: hook registration surface and event dialect. +- `installSurface`, `writesSharedSettings`, and `permissionWriter`: install-time config mutation behavior. + +The runtime homes, artifact layout, and install-plan resolvers read those descriptor fields directly. Adding a descriptor-backed runtime should not require editing a second list in `runtime-artifact-layout` or a fallback branch in `runtime-config-adapter-registry`. + ## Own config in the Capability Declare feature config keys in the Capability manifest: diff --git a/gsd-core/bin/lib/capability-registry.cjs b/gsd-core/bin/lib/capability-registry.cjs index 396679ee4..bd1bf9758 100644 --- a/gsd-core/bin/lib/capability-registry.cjs +++ b/gsd-core/bin/lib/capability-registry.cjs @@ -14,6 +14,12 @@ const capabilities = { "description": "AI-SPEC design contract workflow for phases that build AI systems; owns the AI integration command, agents, and workflow.ai_integration_phase activation key.", "tier": "full", "requires": [], + "runtimeCompat": { + "supported": [ + "*" + ], + "unsupported": [] + }, "skills": [ "ai-integration-phase" ], @@ -112,6 +118,12 @@ const capabilities = { "description": "Open-artifact audit and UAT-gap audit for milestone close gates; exposes `gsd-tools audit-uat` (cross-phase UAT outstanding items) and `gsd-tools audit-open` (structured open-artifact scan across debug, tasks, threads, todos, seeds, UAT, verification, context-questions).", "tier": "full", "requires": [], + "runtimeCompat": { + "supported": [ + "*" + ], + "unsupported": [] + }, "skills": [], "agents": [], "config": {}, @@ -305,6 +317,12 @@ const capabilities = { "description": "Source-file code review and review-fix workflow support for completed execution work.", "tier": "full", "requires": [], + "runtimeCompat": { + "supported": [ + "*" + ], + "unsupported": [] + }, "skills": [ "code-review" ], @@ -637,6 +655,12 @@ const capabilities = { "description": "Build, query, and inspect the project knowledge graph in `.planning/graphs/`; exposes graphify CLI subcommands (build, query, status, diff) and the /gsd-graphify skill.", "tier": "full", "requires": [], + "runtimeCompat": { + "supported": [ + "*" + ], + "unsupported": [] + }, "skills": [ "graphify" ], @@ -716,6 +740,12 @@ const capabilities = { "description": "Code-intelligence store for codebase querying, diff, snapshot, and API-surface extraction; exposes `gsd-tools intel` subcommands (query, status, update, diff, snapshot, patch-meta, validate, extract-exports, api-surface) and backs `/gsd-map-codebase` and `gsd-intel-updater`.", "tier": "full", "requires": [], + "runtimeCompat": { + "supported": [ + "*" + ], + "unsupported": [] + }, "skills": [], "agents": [], "config": { @@ -867,6 +897,12 @@ const capabilities = { "description": "Validation coverage audit that maps executed work back to tests and manual-only evidence.", "tier": "full", "requires": [], + "runtimeCompat": { + "supported": [ + "*" + ], + "unsupported": [] + }, "skills": [ "validate-phase" ], @@ -975,6 +1011,12 @@ const capabilities = { "requires": [ "research" ], + "runtimeCompat": { + "supported": [ + "*" + ], + "unsupported": [] + }, "skills": [], "agents": [ "gsd-pattern-mapper" @@ -1070,6 +1112,12 @@ const capabilities = { "description": "Optional phase research before planning; owns the phase researcher agent and workflow.research activation key.", "tier": "standard", "requires": [], + "runtimeCompat": { + "supported": [ + "*" + ], + "unsupported": [] + }, "skills": [], "agents": [ "gsd-phase-researcher" @@ -1112,6 +1160,12 @@ const capabilities = { "description": "Threat mitigation verification and ship-time security blocking for phases with security enforcement enabled.", "tier": "full", "requires": [], + "runtimeCompat": { + "supported": [ + "*" + ], + "unsupported": [] + }, "skills": [ "secure-phase" ], @@ -1245,6 +1299,12 @@ const capabilities = { "description": "UI-SPEC design contract + retrospective UI audit for frontend phases.", "tier": "full", "requires": [], + "runtimeCompat": { + "supported": [ + "*" + ], + "unsupported": [] + }, "skills": [ "ui-phase", "ui-review" diff --git a/scripts/gen-capability-registry.cjs b/scripts/gen-capability-registry.cjs index 1795dfd2a..6370920d0 100644 --- a/scripts/gen-capability-registry.cjs +++ b/scripts/gen-capability-registry.cjs @@ -231,6 +231,7 @@ const KEBAB_RE = /^[a-z][a-z0-9-]*$/; const VALID_ROLES = new Set(['feature', 'runtime']); const VALID_TIERS = new Set(['core', 'standard', 'full']); const VALID_ON_ERROR = new Set(['skip', 'halt']); +const RUNTIME_COMPAT_WILDCARD = '*'; /** * Validate a single capability declaration. @@ -363,9 +364,74 @@ function validateCommandEntry(capId, entry, prefix) { return errors; } +function validateRuntimeCompat(capId, runtimeCompat) { + const errors = []; + const ctx = 'capability "' + capId + '" runtimeCompat'; + + if (typeof runtimeCompat !== 'object' || runtimeCompat === null || Array.isArray(runtimeCompat)) { + errors.push(ctx + ' must be an object with supported and unsupported arrays'); + return errors; + } + + const validateRuntimeArray = (field, { allowWildcard }) => { + const value = runtimeCompat[field]; + if (!Array.isArray(value)) { + errors.push(ctx + '.' + field + ' must be an array of runtime ids' + (allowWildcard ? ' or ["*"]' : '')); + return; + } + if (field === 'supported' && value.length === 0) { + errors.push(ctx + '.supported must be a non-empty array'); + } + let hasWildcard = false; + for (let i = 0; i < value.length; i++) { + const entry = value[i]; + if (typeof entry !== 'string' || entry.length === 0) { + errors.push(ctx + '.' + field + '[' + i + '] must be a non-empty string'); + continue; + } + if (entry === '__proto__' || entry === 'constructor' || entry === 'prototype') { + errors.push(ctx + '.' + field + '[' + i + '] "' + entry + '" is a reserved name'); + } + if (entry === RUNTIME_COMPAT_WILDCARD) { + if (!allowWildcard) { + errors.push(ctx + '.' + field + ' must not include wildcard "*"'); + } + hasWildcard = true; + } else if (!KEBAB_RE.test(entry)) { + errors.push(ctx + '.' + field + '[' + i + '] must be a kebab-case runtime id or "*"'); + } + } + if (hasWildcard && value.length > 1) { + errors.push(ctx + '.' + field + ' wildcard "*" cannot be mixed with runtime ids'); + } + }; + + validateRuntimeArray('supported', { allowWildcard: true }); + validateRuntimeArray('unsupported', { allowWildcard: false }); + + if (runtimeCompat.notes !== undefined) { + if (typeof runtimeCompat.notes !== 'object' || runtimeCompat.notes === null || Array.isArray(runtimeCompat.notes)) { + errors.push(ctx + '.notes must be an object of runtime id to string if present'); + } else { + for (const [key, value] of Object.entries(runtimeCompat.notes)) { + if (key !== RUNTIME_COMPAT_WILDCARD && !KEBAB_RE.test(key)) { + errors.push(ctx + '.notes key "' + key + '" must be a kebab-case runtime id or "*"'); + } + if (typeof value !== 'string' || value.length === 0) { + errors.push(ctx + '.notes["' + key + '"] must be a non-empty string'); + } + } + } + } + + return errors; +} + function validateFeatureBody(cap) { const errors = []; + errors.push(...validateRuntimeCompat(cap.id || '(unknown)', cap.runtimeCompat)); + if (!Array.isArray(cap.skills)) { errors.push('skills must be an array of strings'); } else { @@ -1430,6 +1496,39 @@ function validateCrossCapability(capMap, centralKeys) { } } + // runtimeCompat: explicit runtime ids must reference runtime capabilities. + // The wildcard "*" means descriptor-backed runtimes are supported by default. + const runtimeIds = new Set(); + for (const [id, cap] of capMap) { + if (cap.role === 'runtime') runtimeIds.add(id); + } + for (const [capId, cap] of capMap) { + if (cap.role !== 'feature' || typeof cap.runtimeCompat !== 'object' || cap.runtimeCompat === null) continue; + for (const field of ['supported', 'unsupported']) { + const entries = Array.isArray(cap.runtimeCompat[field]) ? cap.runtimeCompat[field] : []; + for (const runtimeId of entries) { + if (runtimeId === RUNTIME_COMPAT_WILDCARD) continue; + if (typeof runtimeId !== 'string' || runtimeId.length === 0) continue; + if (!runtimeIds.has(runtimeId)) { + errors.push( + 'capability "' + capId + '" runtimeCompat.' + field + + ' references unknown runtime "' + runtimeId + '"', + ); + } + } + } + if (cap.runtimeCompat.notes && typeof cap.runtimeCompat.notes === 'object') { + for (const runtimeId of Object.keys(cap.runtimeCompat.notes)) { + if (runtimeId === RUNTIME_COMPAT_WILDCARD) continue; + if (!runtimeIds.has(runtimeId)) { + errors.push( + 'capability "' + capId + '" runtimeCompat.notes references unknown runtime "' + runtimeId + '"', + ); + } + } + } + } + // requires: acyclic const cycleErrors = detectRequiresCycles(capMap); errors.push(...cycleErrors); @@ -2401,6 +2500,7 @@ module.exports = { runConsistencyGate, // ADR-959: command entry validation validateCommandEntry, + validateRuntimeCompat, // ADR-1016 phase 5a: runtime body validators + closed-vocab sets validateConfigHome, validateArtifactLayout, diff --git a/scripts/release-tarball-smoke.cjs b/scripts/release-tarball-smoke.cjs index 6f20d21e3..5f6218933 100644 --- a/scripts/release-tarball-smoke.cjs +++ b/scripts/release-tarball-smoke.cjs @@ -57,6 +57,11 @@ const { ExitError, runMain } = require('./lib/cli-exit.cjs'); // https://docs.github.com/en/actions/using-github-hosted-runners/about-github-hosted-runners/about-github-hosted-runners#standard-github-hosted-runners-for-public-repositories // ). Raise to 600 s (the same ceiling the before() helper uses for pack+install). const CHILD_TIMEOUT_MS = process.platform === 'win32' ? 600_000 : 120_000; +const QUIET_NPM_ENV = Object.freeze({ + npm_config_loglevel: 'error', + npm_config_update_notifier: 'false', + NO_UPDATE_NOTIFIER: '1', +}); // --------------------------------------------------------------------------- // Frozen result-code enum @@ -304,7 +309,7 @@ function runSmoke({ // Use the caller-supplied npmEnv if provided (allows HOME isolation on Docker // hosts where HOME may be unwritable — same pattern as runNpm() in helpers.cjs). // Falls back to process.env to preserve existing CLI / programmatic behaviour. (#131) - const effectiveNpmEnv = npmEnv !== undefined ? npmEnv : process.env; + const effectiveNpmEnv = { ...(npmEnv !== undefined ? npmEnv : process.env), ...QUIET_NPM_ENV }; const installResult = spawnSync( npmCmd, ['install', '-g', '--prefix', installPrefix, tarballPath], @@ -570,6 +575,7 @@ function cliMain() { encoding: 'utf-8', shell: process.platform === 'win32', timeout: CHILD_TIMEOUT_MS, + env: { ...process.env, ...QUIET_NPM_ENV }, }, ).trim(); // npm pack outputs the filename on stdout (last line when verbose) diff --git a/src/install-profiles.cts b/src/install-profiles.cts index f847c88ab..72f2bc9f8 100644 --- a/src/install-profiles.cts +++ b/src/install-profiles.cts @@ -112,7 +112,9 @@ function parseCallsAgents(content: string): string[] { * `gsd-*` agent name references. Agent stems are stored under the special * key `_calls_agents_` so they don't conflict with skill stems. */ -function loadSkillsManifest(commandsDir: string): Map { +const DEFAULT_COMMANDS_DIR = path.resolve(__dirname, '..', '..', '..', 'commands', 'gsd'); + +function loadSkillsManifest(commandsDir: string = DEFAULT_COMMANDS_DIR): Map { const manifest = new Map(); if (!fs.existsSync(commandsDir)) return manifest; const entries = fs.readdirSync(commandsDir, { withFileTypes: true }); diff --git a/src/runtime-artifact-layout.cts b/src/runtime-artifact-layout.cts index 64df12e9c..6e082fbda 100644 --- a/src/runtime-artifact-layout.cts +++ b/src/runtime-artifact-layout.cts @@ -4,9 +4,9 @@ * Runtime artifact layout module — resolves the artifact directory shapes * (commands, agents, skills) for each supported runtime. * - * grok is intentionally absent: it is in runtime-homes.cjs but not wired - * here. The TypeError on unknown runtime is the loud-fail signal that a - * runtime was added to the homes list without a layout entry. + * grok is intentionally absent: it is in runtime-homes.cjs but has no runtime + * capability descriptor. The TypeError on unknown runtime is the loud-fail + * signal that a runtime was added without an artifact layout descriptor. * * ADR-457 build-at-publish: the hand-written bin/lib/runtime-artifact-layout.cjs * collapsed to a TypeScript source of truth. Behaviour is preserved byte-for-behaviour @@ -166,16 +166,6 @@ function findAgentsSourceRoot(runtimeConfigDir?: string): string { throw new Error(`findAgentsSourceRoot: could not locate agents/ from ${__dirname}`); } -// --------------------------------------------------------------------------- -// Allowlisted runtimes -// --------------------------------------------------------------------------- - -const ALLOWED_RUNTIMES = new Set([ - 'claude', 'cursor', 'gemini', 'codex', 'copilot', 'antigravity', - 'windsurf', 'augment', 'trae', 'qwen', 'hermes', 'codebuddy', - 'cline', 'kimi', 'opencode', 'kilo', -]); - // --------------------------------------------------------------------------- // Layout table builders // --------------------------------------------------------------------------- @@ -382,7 +372,11 @@ interface ArtifactLayoutDescriptor { } /** Lazy registry accessor — mirrors pattern from 5b/5c (runtime-homes.cts). */ -function getRegistry(): { runtimes: Record } { +interface RegistryLike { + runtimes: Record; +} + +function getRegistry(): RegistryLike { return _require('./capability-registry.cjs') as { runtimes: Record; }; @@ -431,19 +425,24 @@ function dispatchKindEntry(entry: ArtifactKindDescriptor, runtime: string, confi * instead of a hardcoded switch statement. */ function resolveRuntimeArtifactLayout(runtime: string, configDir: string, scope: 'local' | 'global' = 'global'): Layout { + return resolveRuntimeArtifactLayoutFromRegistry(getRegistry(), runtime, configDir, scope); +} + +function resolveRuntimeArtifactLayoutFromRegistry( + registry: RegistryLike, + runtime: string, + configDir: string, + scope: 'local' | 'global' = 'global', +): Layout { if (typeof configDir !== 'string' || configDir === '') { throw new TypeError('configDir must be a non-empty string'); } if (scope !== 'local' && scope !== 'global') { throw new TypeError('scope must be "local" or "global"'); } - if (!ALLOWED_RUNTIMES.has(runtime)) { - throw new TypeError(`Unknown runtime: '${runtime}' — add to runtime-artifact-layout.cjs table`); - } - const desc = getRegistry().runtimes[runtime]?.runtime?.artifactLayout; + const desc = registry.runtimes[runtime]?.runtime?.artifactLayout; if (!desc) { - // Runtime is in ALLOWED_RUNTIMES but has no descriptor — reproduce old default: throw. throw new TypeError(`Unknown runtime: '${runtime}' — add to runtime-artifact-layout.cjs table`); } @@ -453,4 +452,4 @@ function resolveRuntimeArtifactLayout(runtime: string, configDir: string, scope: return { runtime, configDir, scope, kinds }; } -export = { resolveRuntimeArtifactLayout, findInstallSourceRoot, getInstallExports }; +export = { resolveRuntimeArtifactLayout, resolveRuntimeArtifactLayoutFromRegistry, findInstallSourceRoot, getInstallExports }; diff --git a/src/runtime-config-adapter-registry.cts b/src/runtime-config-adapter-registry.cts index d19715921..9762abe9f 100644 --- a/src/runtime-config-adapter-registry.cts +++ b/src/runtime-config-adapter-registry.cts @@ -82,6 +82,8 @@ interface InstallPlan extends RuntimeConfigIntent { // Exports // --------------------------------------------------------------------------- +type RuntimeDescriptorMap = Record | undefined }>; + /** The complete set of 16 supported runtimes for config-adapter dispatch. */ const ALLOWED_CONFIG_RUNTIMES: ReadonlySet = new Set( Object.entries(runtimes) @@ -119,6 +121,29 @@ function resolveRuntimeConfigIntent(runtime: string): RuntimeConfigIntent { }; } +function resolveInstallPlanFromRuntimes(runtimeDescriptors: RuntimeDescriptorMap, runtime: string): InstallPlan { + const desc = runtimeDescriptors[runtime]?.runtime; + if (!desc) throw new TypeError(`Unknown runtime for install plan: ${runtime}`); + if (desc['hooksSurface'] == null) { + throw new TypeError(`runtime.hooksSurface is required for install plan: ${runtime}`); + } + const sandboxTier = desc['sandboxTier']; + if (typeof sandboxTier !== 'string' || !VALID_SANDBOX_TIERS.has(sandboxTier)) { + throw new TypeError(`Runtime '${runtime}' has a missing or invalid sandboxTier descriptor axis: ${JSON.stringify(sandboxTier)}`); + } + const permissionWriter = desc['permissionWriter']; + return { + runtime, + installSurface: desc['installSurface'] as ConfigInstallSurface, + writesSharedSettings: desc['writesSharedSettings'] as boolean, + finishPermissionWriter: permissionWriter == null ? null : permissionWriter as FinishPermissionWriter, + hookEvents: desc['hookEvents'] as string | undefined, + extendedHookEvents: Array.isArray(desc['extendedHookEvents']) ? [...desc['extendedHookEvents'] as string[]] : [], + hooksSurface: desc['hooksSurface'] as HooksSurface, + sandboxTier, + }; +} + /** * Resolve the complete install plan for a given runtime. * @@ -132,25 +157,7 @@ function resolveRuntimeConfigIntent(runtime: string): RuntimeConfigIntent { * @throws {TypeError} if runtime is not a known supported runtime. */ function resolveInstallPlan(runtime: string): InstallPlan { - const desc = runtimes[runtime]?.runtime; - if (!desc) throw new TypeError(`Unknown runtime for install plan: ${runtime}`); - const configIntent = resolveRuntimeConfigIntent(runtime); - const sandboxTier = desc['sandboxTier']; - if (typeof sandboxTier !== 'string' || !VALID_SANDBOX_TIERS.has(sandboxTier)) { - throw new TypeError(`Runtime '${runtime}' has a missing or invalid sandboxTier descriptor axis: ${JSON.stringify(sandboxTier)}`); - } - return { - runtime, - installSurface: configIntent.installSurface, - writesSharedSettings: configIntent.writesSharedSettings, - finishPermissionWriter: configIntent.finishPermissionWriter, - hookEvents: desc['hookEvents'] as string | undefined, - extendedHookEvents: Array.isArray(desc['extendedHookEvents']) ? desc['extendedHookEvents'] as string[] : [], - hooksSurface: desc['hooksSurface'] != null - ? desc['hooksSurface'] as HooksSurface - : ((runtime === 'opencode' || runtime === 'kilo') ? 'none' : 'settings-json'), - sandboxTier, - }; + return resolveInstallPlanFromRuntimes(runtimes, runtime); } -export = { resolveRuntimeConfigIntent, resolveInstallPlan, ALLOWED_CONFIG_RUNTIMES, INSTALL_SURFACES }; +export = { resolveRuntimeConfigIntent, resolveInstallPlan, resolveInstallPlanFromRuntimes, ALLOWED_CONFIG_RUNTIMES, INSTALL_SURFACES }; diff --git a/src/runtime-homes.cts b/src/runtime-homes.cts index 7185813b8..1d122e6a4 100644 --- a/src/runtime-homes.cts +++ b/src/runtime-homes.cts @@ -67,6 +67,7 @@ interface DotHomeDescriptor { kind: 'dot-home'; name: string; env: string[]; + skillsHome?: ConfigHomeDescriptor; } interface DotHomeNestedDescriptor { @@ -75,13 +76,14 @@ interface DotHomeNestedDescriptor { parent: string; env: string[]; probe?: string[]; + skillsHome?: ConfigHomeDescriptor; } interface XdgDescriptor { kind: 'xdg'; name: string; env: string[]; - skillsHome?: unknown; + skillsHome?: ConfigHomeDescriptor; } interface GenericAgentsRootDescriptor { @@ -90,6 +92,7 @@ interface GenericAgentsRootDescriptor { env: string[]; probe: string[]; probeExists: string; + skillsHome?: ConfigHomeDescriptor; } type ConfigHomeDescriptor = @@ -98,6 +101,33 @@ type ConfigHomeDescriptor = | XdgDescriptor | GenericAgentsRootDescriptor; +interface RuntimeArtifactKindDescriptor { + kind: string; + destSubpath: string; +} + +interface RuntimeDescriptor { + configHome: ConfigHomeDescriptor; + artifactLayout?: { + global?: RuntimeArtifactKindDescriptor[]; + }; +} + +function resolveDescriptorWithOptions(configHome: ConfigHomeDescriptor): string { + return resolveConfigHomeFromDescriptor(configHome, { + env: process.env, + home: os.homedir(), + existsSync: fs.existsSync, + }); +} + +function getRegistry(): { runtimes: Record } { + // eslint-disable-next-line @typescript-eslint/no-require-imports + return require('./capability-registry.cjs') as { + runtimes: Record; + }; +} + /** * Resolve a configHome descriptor to an absolute directory path. * @@ -267,18 +297,11 @@ export function getGlobalConfigDir(runtime: string, explicitDir?: string | null) } // ── Descriptor-driven: look up in capability-registry ──────────────────── - // eslint-disable-next-line @typescript-eslint/no-require-imports - const { runtimes } = require('./capability-registry.cjs') as { - runtimes: Record; - }; + const { runtimes } = getRegistry(); const runtimeEntry = runtimes[runtime]; if (runtimeEntry?.runtime?.configHome) { - return resolveConfigHomeFromDescriptor(runtimeEntry.runtime.configHome, { - env: process.env, - home: os.homedir(), - existsSync: fs.existsSync, - }); + return resolveDescriptorWithOptions(runtimeEntry.runtime.configHome); } // ── Default (unknown runtime → Claude fallback) ─────────────────────────── @@ -288,21 +311,30 @@ export function getGlobalConfigDir(runtime: string, explicitDir?: string | null) /** * Return the global skills base directory for the given runtime. - * Most runtimes: /skills - * Hermes: /skills/gsd (nested category layout — #2841) - * Cline ≥ v3.48.0: /skills (SKILL.md-based global skills — #782) + * Descriptor-backed runtimes derive the base home from configHome.skillsHome + * when present, then append the first global skills artifact destSubpath. */ +export function resolveSkillsBaseFromDescriptor( + configHome: ConfigHomeDescriptor, + opts: ResolveConfigHomeOpts = {}, + skillsDestSubpath = 'skills', +): string { + const baseDescriptor = configHome.skillsHome ?? configHome; + const base = resolveConfigHomeFromDescriptor(baseDescriptor, opts); + return path.join(base, skillsDestSubpath); +} + export function getGlobalSkillsBase(runtime: string): string | null { - if (runtime === 'hermes') { - const configDir = getGlobalConfigDir(runtime); - return path.join(configDir, 'skills', 'gsd'); + const runtimeEntry = getRegistry().runtimes[runtime]; + const descriptor = runtimeEntry?.runtime; + const globalSkillsKind = descriptor?.artifactLayout?.global?.find((entry) => entry.kind === 'skills'); + if (descriptor?.configHome && globalSkillsKind?.destSubpath) { + return resolveSkillsBaseFromDescriptor( + descriptor.configHome, + { env: process.env, home: os.homedir(), existsSync: fs.existsSync }, + globalSkillsKind.destSubpath, + ); } - // Kilo Code discovers global skills from ~/.kilo/skills/ (HOME-relative), - // independent of the XDG-based config dir (~/.config/kilo) used for commands. - // See: https://kilo.ai/docs/customize/skills - // "Global skills are located in the `.kilo` directory within your Home - // directory: ~/.kilo/skills/" - if (runtime === 'kilo') return path.join(os.homedir(), '.kilo', 'skills'); const configDir = getGlobalConfigDir(runtime); return path.join(configDir, 'skills'); } diff --git a/tests/4-phase-complete-cjs-regression.test.cjs b/tests/4-phase-complete-cjs-regression.test.cjs index 29f50f480..dc9771dda 100644 --- a/tests/4-phase-complete-cjs-regression.test.cjs +++ b/tests/4-phase-complete-cjs-regression.test.cjs @@ -197,11 +197,14 @@ function capturePhaseComplete(cwd, phaseNum) { // directly and redirect output capture. const chunks = []; const origWrite = process.stdout.write.bind(process.stdout); + const origErrWrite = process.stderr.write.bind(process.stderr); process.stdout.write = (chunk) => { chunks.push(chunk); return true; }; + process.stderr.write = () => true; try { cmdPhaseComplete(cwd, phaseNum, false); } finally { process.stdout.write = origWrite; + process.stderr.write = origErrWrite; } return chunks.join(''); } diff --git a/tests/backwards-compat-phase-id.test.cjs b/tests/backwards-compat-phase-id.test.cjs index 60426b931..20536b978 100644 --- a/tests/backwards-compat-phase-id.test.cjs +++ b/tests/backwards-compat-phase-id.test.cjs @@ -109,8 +109,11 @@ describe('backwards-compat: legacy Phase N roadmap entries', () => { const roadmapPath = path.join(tmpDir, '.planning', 'ROADMAP.md'); const before = fs.readFileSync(roadmapPath, 'utf-8'); - // Trigger a load — must not silently migrate the file. - getMilestonePhaseFilter(tmpDir); + // Trigger a load — must not silently migrate the file. The deprecation + // warning is covered by the previous test, so keep this fixture quiet. + captureConsole(() => { + getMilestonePhaseFilter(tmpDir); + }); const after = fs.readFileSync(roadmapPath, 'utf-8'); assert.equal(after, before, 'ROADMAP.md must not be rewritten during load'); diff --git a/tests/bug-131-release-tarball-smoke-explicit-home.test.cjs b/tests/bug-131-release-tarball-smoke-explicit-home.test.cjs index d84d79e18..f0109c31f 100644 --- a/tests/bug-131-release-tarball-smoke-explicit-home.test.cjs +++ b/tests/bug-131-release-tarball-smoke-explicit-home.test.cjs @@ -225,5 +225,20 @@ describe('bug-131: runNpm isolates HOME from the caller environment', () => { typeof env.npm_config_userconfig === 'string' && env.npm_config_userconfig.startsWith(env.HOME), `npm_config_userconfig ${env.npm_config_userconfig} should be under isolated HOME ${env.HOME}`, ); + assert.equal( + env.npm_config_loglevel, + 'error', + 'isolatedNpmEnv() should suppress npm notice/warn chatter in test gates', + ); + assert.equal( + env.npm_config_update_notifier, + 'false', + 'isolatedNpmEnv() should disable npm update-notifier notices in test gates', + ); + assert.equal( + env.NO_UPDATE_NOTIFIER, + '1', + 'isolatedNpmEnv() should disable npm update-notifier notices for npm versions that honor NO_UPDATE_NOTIFIER', + ); }); }); diff --git a/tests/bug-2248-local-install-statusline.test.cjs b/tests/bug-2248-local-install-statusline.test.cjs index 3f044428d..4e49392ce 100644 --- a/tests/bug-2248-local-install-statusline.test.cjs +++ b/tests/bug-2248-local-install-statusline.test.cjs @@ -28,7 +28,7 @@ const { execFileSync } = require('child_process'); const INSTALL_SRC = path.join(__dirname, '..', 'bin', 'install.js'); const BUILD_SCRIPT = path.join(__dirname, '..', 'scripts', 'build-hooks.js'); const { install, finishInstall } = require(INSTALL_SRC); -const { cleanup } = require('./helpers.cjs'); +const { cleanup, captureConsole } = require('./helpers.cjs'); // ─── Ensure hooks/dist/ is populated before install tests ──────────────────── before(() => { @@ -63,13 +63,20 @@ describe('#2248: local Claude install does not clobber profile-level statusLine' // Phase 2: configure settings.local.json (mirrors installAllRuntimes → finalize) // #338: local Claude installs now write to settings.local.json, not settings.json. // shouldInstallStatusline=true mirrors what handleStatusline picks for a fresh install - finishInstall( - result.settingsPath, - result.settings, - result.statuslineCommand, - true, // shouldInstallStatusline - 'claude', - false // isGlobal=false → local install + const { stdout } = captureConsole(() => { + finishInstall( + result.settingsPath, + result.settings, + result.statuslineCommand, + true, // shouldInstallStatusline + 'claude', + false // isGlobal=false -> local install + ); + }); + assert.match( + stdout, + /Skipping statusLine for local install/, + 'Local install must explain that it skipped statusLine unless --force-statusline is passed' ); // #338: local installs write to settings.local.json, not settings.json diff --git a/tests/bug-2554-decimal-phase-filter.test.cjs b/tests/bug-2554-decimal-phase-filter.test.cjs index 26a5f5acc..26e417e41 100644 --- a/tests/bug-2554-decimal-phase-filter.test.cjs +++ b/tests/bug-2554-decimal-phase-filter.test.cjs @@ -63,6 +63,8 @@ describe('bug #2554 — getMilestonePhaseFilter decimal phase dirs', () => { fs.writeFileSync( path.join(tmpDir, '.planning', 'ROADMAP.md'), [ + '## Roadmap v1.0: Current', + '', '### Phase 01: One', '**Goal:** g', '', diff --git a/tests/bug-3037-gemini-duplicate-commands.test.cjs b/tests/bug-3037-gemini-duplicate-commands.test.cjs index 313c5be30..8c1ce7c91 100644 --- a/tests/bug-3037-gemini-duplicate-commands.test.cjs +++ b/tests/bug-3037-gemini-duplicate-commands.test.cjs @@ -15,9 +15,8 @@ * directory already exists with managed-shape content, skip the local copy * and emit a clear warning explaining the conflict avoidance. * - * Tests are structural: they assert on the post-install filesystem shape - * (existence and overlap count of typed paths), not on warning-message - * substrings. + * Tests assert on the post-install filesystem shape and capture the skip + * warning so the full test log remains warning-clean. */ 'use strict'; @@ -28,7 +27,7 @@ const { test, describe, beforeEach, afterEach } = require('node:test'); const assert = require('node:assert/strict'); const fs = require('node:fs'); const path = require('node:path'); -const { createTempDir, cleanup } = require('./helpers.cjs'); +const { createTempDir, cleanup, captureConsole } = require('./helpers.cjs'); const { install } = require('../bin/install.js'); @@ -78,8 +77,12 @@ describe('bug #3037: Gemini global+local install must not create duplicate comma return out.sort(); } + function runInstall(...args) { + return captureConsole(() => install(...args)); + } + test('global install populates HOME/.gemini/commands/gsd', () => { - install(true, 'gemini'); + runInstall(true, 'gemini'); const globalCmds = path.join(tmpHome, '.gemini', 'commands', 'gsd'); const files = listCommandFiles(globalCmds); assert.ok( @@ -90,14 +93,19 @@ describe('bug #3037: Gemini global+local install must not create duplicate comma test('local install after global does NOT populate PROJECT/.gemini/commands/gsd (avoids /gsd:* namespace conflict)', () => { // Step 1: global install - install(true, 'gemini'); + runInstall(true, 'gemini'); const globalCmds = path.join(tmpHome, '.gemini', 'commands', 'gsd'); const globalFiles = listCommandFiles(globalCmds); assert.ok(globalFiles.length > 0, 'precondition: global install must succeed'); // Step 2: local install in a temp project process.chdir(tmpProject); - install(false, 'gemini'); + const { stdout } = runInstall(false, 'gemini'); + assert.match( + stdout, + /Skipping commands\/gsd\/ for local install/, + 'local install must explain why it skips duplicate Gemini commands' + ); // Assertion: the local commands/gsd/ directory must NOT exist (or must // be empty) so Gemini's conflict detection has nothing to rename. The @@ -117,7 +125,7 @@ describe('bug #3037: Gemini global+local install must not create duplicate comma // No global install first — local should proceed normally so users who // only ever run --local still get GSD commands in their project. process.chdir(tmpProject); - install(false, 'gemini'); + runInstall(false, 'gemini'); const localCmds = path.join(tmpProject, '.gemini', 'commands', 'gsd'); const localFiles = listCommandFiles(localCmds); @@ -145,7 +153,7 @@ describe('bug #3037: Gemini global+local install must not create duplicate comma ); process.chdir(tmpProject); - install(false, 'gemini'); + runInstall(false, 'gemini'); const localCmds = path.join(tmpProject, '.gemini', 'commands', 'gsd'); const localFiles = listCommandFiles(localCmds); @@ -171,7 +179,7 @@ describe('bug #3037: Gemini global+local install must not create duplicate comma ); process.chdir(tmpProject); - install(false, 'gemini'); + runInstall(false, 'gemini'); const localCmds = path.join(tmpProject, '.gemini', 'commands', 'gsd'); const localFiles = listCommandFiles(localCmds); diff --git a/tests/bug-3537-padded-id-against-unpadded-roadmap.test.cjs b/tests/bug-3537-padded-id-against-unpadded-roadmap.test.cjs index ed4fefe22..e4ebe2200 100644 --- a/tests/bug-3537-padded-id-against-unpadded-roadmap.test.cjs +++ b/tests/bug-3537-padded-id-against-unpadded-roadmap.test.cjs @@ -38,6 +38,7 @@ function run(args, cwd) { cwd, timeout: 15000, encoding: 'utf-8', + stdio: ['pipe', 'pipe', 'pipe'], }), ok: true, }; diff --git a/tests/capability-registry.test.cjs b/tests/capability-registry.test.cjs index 29a4b6895..2bad00060 100644 --- a/tests/capability-registry.test.cjs +++ b/tests/capability-registry.test.cjs @@ -51,6 +51,7 @@ const { VALID_INSTALL_SURFACES, VALID_EXTENDED_HOOK_EVENTS, VALID_PERMISSION_WRITERS, + validateRuntimeCompat, validateRuntimeBody, loadCentralConfigKeys, } = require('../scripts/gen-capability-registry.cjs'); @@ -98,6 +99,11 @@ describe('UI pilot capability', () => { // capabilities.ui exists assert.ok(registry.capabilities.ui, 'registry.capabilities.ui should exist'); assert.strictEqual(registry.version, SCHEMA_VERSION); + assert.deepStrictEqual( + registry.capabilities.ui.runtimeCompat, + { supported: ['*'], unsupported: [] }, + 'feature runtime compatibility contract should be preserved in the registry', + ); // bySkill maps ui-phase and ui-review to 'ui' assert.strictEqual(registry.bySkill['ui-phase'], 'ui'); @@ -200,6 +206,32 @@ describe('validateCapability adversarial cases', () => { assert.ok(errors.some((e) => e.includes('role'))); }); + test('feature capability without runtimeCompat is rejected', () => { + const cap = { ...UI_CAP }; + delete cap.runtimeCompat; + const errors = validateCapability(cap, 'ui'); + assert.ok( + errors.some((e) => e.includes('runtimeCompat')), + 'Expected missing runtimeCompat validation error, got: ' + JSON.stringify(errors), + ); + }); + + test('runtimeCompat.supported must be a non-empty array', () => { + const errors = validateRuntimeCompat('ui', { supported: [], unsupported: [] }); + assert.ok( + errors.some((e) => e.includes('runtimeCompat.supported')), + 'Expected supported-array validation error, got: ' + JSON.stringify(errors), + ); + }); + + test('runtimeCompat.supported wildcard cannot be mixed with runtime ids', () => { + const errors = validateRuntimeCompat('ui', { supported: ['*', 'claude'], unsupported: [] }); + assert.ok( + errors.some((e) => e.includes('wildcard')), + 'Expected wildcard validation error, got: ' + JSON.stringify(errors), + ); + }); + test('bad tier enum rejected', () => { const cap = { ...UI_CAP, tier: 'premium' }; const errors = validateCapability(cap, 'ui'); @@ -284,6 +316,40 @@ describe('validateCrossCapability adversarial cases', () => { assert.ok(errors.some((e) => e.includes('nonexistent-cap'))); }); + test('runtimeCompat explicit runtime ids must exist', () => { + const cap = { + ...UI_CAP, + runtimeCompat: { supported: ['claude', 'future-runtime'], unsupported: [] }, + }; + const runtime = { + id: 'claude', + role: 'runtime', + title: 'Claude', + description: 'Runtime fixture.', + tier: 'core', + requires: [], + runtime: { + configHome: { kind: 'dot-home', name: '.claude', env: ['CLAUDE_CONFIG_DIR'] }, + configFormat: 'settings-json', + artifactLayout: { global: [], local: [] }, + commandStyle: 'slash-hyphen', + hooksSurface: 'settings-json', + sandboxTier: 'none', + supportTier: 1, + installSurface: 'settings-json', + writesSharedSettings: true, + permissionWriter: null, + extendedHookEvents: [], + }, + }; + const capMap = new Map([['ui', cap], ['claude', runtime]]); + const errors = validateCrossCapability(capMap, new Set()); + assert.ok( + errors.some((e) => e.includes('runtimeCompat.supported') && e.includes('future-runtime')), + 'Expected unknown runtimeCompat runtime error, got: ' + JSON.stringify(errors), + ); + }); + test('requires cycle rejected', () => { const capA = { ...UI_CAP, id: 'cap-a', tier: 'standard', requires: ['cap-b'] }; const capB = { @@ -382,6 +448,7 @@ describe('topological step ordering', () => { title: 'Consumer', tier: 'full', requires: [], + runtimeCompat: { supported: ['*'], unsupported: [] }, skills: [], agents: [], hooks: [], @@ -1260,6 +1327,7 @@ describe('S1: fragment.path traversal guard', () => { description: 'Synthetic fixture for fragment path materialization.', tier: 'full', requires: [], + runtimeCompat: { supported: ['*'], unsupported: [] }, skills: [], agents: [], hooks: [], @@ -1304,6 +1372,7 @@ describe('S1: fragment.path traversal guard', () => { description: 'Synthetic fixture for step fragment materialization.', tier: 'standard', requires: [], + runtimeCompat: { supported: ['*'], unsupported: [] }, skills: [], agents: ['gsd-phase-researcher'], hooks: [], @@ -2674,6 +2743,7 @@ function makeCommandCap(id, commands) { description: 'Synthetic capability for ADR-959 command tests.', tier: 'full', requires: [], + runtimeCompat: { supported: ['*'], unsupported: [] }, skills: [], agents: [], hooks: [], diff --git a/tests/core.test.cjs b/tests/core.test.cjs index 750625ddd..259f9aa00 100644 --- a/tests/core.test.cjs +++ b/tests/core.test.cjs @@ -1137,7 +1137,7 @@ describe('getMilestonePhaseFilter', () => { test('handles letter-suffix phases (e.g. 3A)', () => { fs.writeFileSync( path.join(tmpDir, '.planning', 'ROADMAP.md'), - '### Phase 3A: Sub-feature\n**Goal:** Sub work\n' + '## Roadmap v1.0: Current\n\n### Phase 3A: Sub-feature\n**Goal:** Sub work\n' ); const filter = getMilestonePhaseFilter(tmpDir); @@ -1150,7 +1150,7 @@ describe('getMilestonePhaseFilter', () => { test('handles decimal phases (e.g. 5.1)', () => { fs.writeFileSync( path.join(tmpDir, '.planning', 'ROADMAP.md'), - '### Phase 5: Main\n**Goal:** Main work\n\n### Phase 5.1: Patch\n**Goal:** Patch work\n' + '## Roadmap v1.0: Current\n\n### Phase 5: Main\n**Goal:** Main work\n\n### Phase 5.1: Patch\n**Goal:** Patch work\n' ); const filter = getMilestonePhaseFilter(tmpDir); @@ -1163,7 +1163,7 @@ describe('getMilestonePhaseFilter', () => { test('returns false for non-phase directory names', () => { fs.writeFileSync( path.join(tmpDir, '.planning', 'ROADMAP.md'), - '### Phase 1: Init\n**Goal:** Start\n' + '## Roadmap v1.0: Current\n\n### Phase 1: Init\n**Goal:** Start\n' ); const filter = getMilestonePhaseFilter(tmpDir); @@ -1175,7 +1175,7 @@ describe('getMilestonePhaseFilter', () => { test('phaseCount reflects ROADMAP phase count', () => { fs.writeFileSync( path.join(tmpDir, '.planning', 'ROADMAP.md'), - '### Phase 5: Auth\n### Phase 6: Dashboard\n### Phase 7: Polish\n' + '## Roadmap v1.0: Current\n\n### Phase 5: Auth\n### Phase 6: Dashboard\n### Phase 7: Polish\n' ); const filter = getMilestonePhaseFilter(tmpDir); diff --git a/tests/enh-191-retire-sdk-package.test.cjs b/tests/enh-191-retire-sdk-package.test.cjs index 7865f1f21..103a088f2 100644 --- a/tests/enh-191-retire-sdk-package.test.cjs +++ b/tests/enh-191-retire-sdk-package.test.cjs @@ -8,6 +8,9 @@ const path = require('path'); const ROOT = path.resolve(__dirname, '..'); const PKG_PATH = path.join(ROOT, 'package.json'); const INSTALL_PATH = path.join(ROOT, 'bin', 'install.js'); +const ACTIVE_GUIDANCE_PATHS = [ + 'docs/contributing/bootstrap.md', +]; function readPackageJson() { return JSON.parse(fs.readFileSync(PKG_PATH, 'utf8')); @@ -45,3 +48,23 @@ test('enhancement #191: installer does not maintain gsd-sdk shim compatibility p assert.equal(/installSdkIfNeeded\(\{/.test(installJs), false, 'bin/install.js must not run installSdkIfNeeded during installation'); }); + +test('enhancement #191: root AGENTS.md is not an active source of truth', () => { + assert.equal( + fs.existsSync(path.join(ROOT, 'AGENTS.md')), + false, + 'root AGENTS.md must not exist; use CONTEXT.md and docs/adr/ as the repository source of truth', + ); +}); + +test('enhancement #191: active contributor guidance does not reference retired SDK build steps', () => { + for (const relPath of ACTIVE_GUIDANCE_PATHS) { + const body = fs.readFileSync(path.join(ROOT, relPath), 'utf8'); + + assert.equal( + /\bbuild:sdk\b|\bcd sdk\b|\bsdk\/dist\b|\bsdk\/src\b/.test(body), + false, + `${relPath} must not direct contributors or agents to use the retired SDK package workflow`, + ); + } +}); diff --git a/tests/helpers.cjs b/tests/helpers.cjs index f6b4d5b9c..3e8c3d562 100644 --- a/tests/helpers.cjs +++ b/tests/helpers.cjs @@ -296,6 +296,9 @@ function runNpm(args, options = {}) { HOME: _npmIsolatedHome, npm_config_cache: path.join(_npmIsolatedHome, '.npm'), npm_config_userconfig: path.join(_npmIsolatedHome, '.npmrc'), + npm_config_loglevel: 'error', + npm_config_update_notifier: 'false', + NO_UPDATE_NOTIFIER: '1', }; const defaults = { encoding: 'utf-8', @@ -326,6 +329,9 @@ function isolatedNpmEnv() { HOME: _npmIsolatedHome, npm_config_cache: path.join(_npmIsolatedHome, '.npm'), npm_config_userconfig: path.join(_npmIsolatedHome, '.npmrc'), + npm_config_loglevel: 'error', + npm_config_update_notifier: 'false', + NO_UPDATE_NOTIFIER: '1', }; } diff --git a/tests/install-nested-layout.test.cjs b/tests/install-nested-layout.test.cjs index f3e198b8c..f3f3a53f0 100644 --- a/tests/install-nested-layout.test.cjs +++ b/tests/install-nested-layout.test.cjs @@ -199,6 +199,35 @@ for (const { runtime, scope, skillsSub, prefix } of NEST) { } } }); + + test(`${runtime}: installed nested skill bodies contain no leaked Claude home paths`, () => { + const skillsDir = path.join(tmpDir, skillsSub); + const leakedPathRegex = /(?:~|\$HOME)\/\.claude\b/g; + const leaks = []; + + const scan = (dir) => { + for (const entry of fs.readdirSync(dir, { withFileTypes: true })) { + const fullPath = path.join(dir, entry.name); + if (entry.isDirectory()) { + scan(fullPath); + continue; + } + if (!entry.name.endsWith('.md')) continue; + const relPath = path.relative(skillsDir, fullPath); + const content = fs.readFileSync(fullPath, 'utf8'); + const matches = content.match(leakedPathRegex); + if (matches) leaks.push(`${relPath} (${matches.length})`); + } + }; + + scan(skillsDir); + + assert.deepStrictEqual( + leaks, + [], + `${runtime} nested skills must not leak Claude home paths: ${leaks.join(', ')}`, + ); + }); }); } diff --git a/tests/phase.test.cjs b/tests/phase.test.cjs index 5820a9998..2ec784b05 100644 --- a/tests/phase.test.cjs +++ b/tests/phase.test.cjs @@ -3395,6 +3395,7 @@ function runPhaseComplete(tmpDir, { phase = '1', tolerateExit = false } = {}) { cwd: tmpDir, timeout: 60000, encoding: 'utf-8', + stdio: ['pipe', 'pipe', 'pipe'], }); } catch (err) { // A signal/timeout kill terminated the process before it finished writing — diff --git a/tests/runtime-artifact-layout-descriptor-drive.test.cjs b/tests/runtime-artifact-layout-descriptor-drive.test.cjs index 5c779a950..572718805 100644 --- a/tests/runtime-artifact-layout-descriptor-drive.test.cjs +++ b/tests/runtime-artifact-layout-descriptor-drive.test.cjs @@ -23,8 +23,7 @@ * the STEP-0 golden matches the descriptor exactly and is left unchanged. * * Unknown runtime case: - * ALLOWED_RUNTIMES guard throws TypeError BEFORE the descriptor lookup, - * so unknown runtimes still throw: + * Missing runtime descriptors throw the same TypeError as the old table: * TypeError: Unknown runtime: 'grok' — add to runtime-artifact-layout.cjs table */ @@ -33,7 +32,10 @@ const assert = require('node:assert/strict'); const path = require('node:path'); const ROOT = path.join(__dirname, '..'); -const { resolveRuntimeArtifactLayout } = require( +const { + resolveRuntimeArtifactLayout, + resolveRuntimeArtifactLayoutFromRegistry, +} = require( path.join(ROOT, 'gsd-core', 'bin', 'lib', 'runtime-artifact-layout.cjs'), ); @@ -262,10 +264,10 @@ for (const runtime of RUNTIMES) { } // ── Unknown runtime ─────────────────────────────────────────────────────────── -// ALLOWED_RUNTIMES guard fires BEFORE descriptor lookup — reproduces old behaviour. +// Missing descriptors reproduce the old loud-fail behaviour. describe('resolveRuntimeArtifactLayout — unknown runtime (descriptor-driven)', () => { - test('throws TypeError for grok (not in ALLOWED_RUNTIMES)', () => { + test('throws TypeError for grok (no artifact layout descriptor)', () => { assert.throws( () => resolveRuntimeArtifactLayout('grok', FAKE_DIR, 'global'), (err) => { @@ -291,6 +293,48 @@ describe('resolveRuntimeArtifactLayout — unknown runtime (descriptor-driven)', }); }); +describe('resolveRuntimeArtifactLayout — descriptor-only future runtime', () => { + test('accepts a synthetic descriptor-backed runtime without a parallel allowlist update', () => { + const registry = { + runtimes: { + futurecli: { + runtime: { + artifactLayout: { + global: [ + { + kind: 'commands', + destSubpath: 'commands', + prefix: 'gsd-', + nesting: 'flat', + recursive: false, + converter: null, + }, + ], + local: [], + }, + }, + }, + }, + }; + + const layout = resolveRuntimeArtifactLayoutFromRegistry( + registry, + 'futurecli', + FAKE_DIR, + 'global', + ); + + assert.strictEqual(layout.runtime, 'futurecli'); + assert.strictEqual(layout.configDir, FAKE_DIR); + assert.strictEqual(layout.scope, 'global'); + assert.strictEqual(layout.kinds.length, 1); + assert.strictEqual(layout.kinds[0].kind, 'commands'); + assert.strictEqual(layout.kinds[0].destSubpath, 'commands'); + assert.strictEqual(layout.kinds[0].prefix, 'gsd-'); + assert.strictEqual(typeof layout.kinds[0].stage, 'function'); + }); +}); + // ── Scope default ───────────────────────────────────────────────────────────── // resolveRuntimeArtifactLayout(runtime, configDir) with no scope arg → 'global'. diff --git a/tests/runtime-config-adapter-registry.test.cjs b/tests/runtime-config-adapter-registry.test.cjs index 3fb29f3cc..0c1b233e8 100644 --- a/tests/runtime-config-adapter-registry.test.cjs +++ b/tests/runtime-config-adapter-registry.test.cjs @@ -10,6 +10,8 @@ const path = require('node:path'); const ROOT = path.join(__dirname, '..'); const { resolveRuntimeConfigIntent, + resolveInstallPlan, + resolveInstallPlanFromRuntimes, ALLOWED_CONFIG_RUNTIMES, INSTALL_SURFACES, } = require(path.join(ROOT, 'gsd-core', 'bin', 'lib', 'runtime-config-adapter-registry.cjs')); @@ -250,3 +252,55 @@ describe('INSTALL_SURFACES export', () => { assert.deepStrictEqual(new Set(INSTALL_SURFACES), EXPECTED_SURFACES); }); }); + +describe('resolveInstallPlan — hooksSurface is descriptor-owned', () => { + test('real descriptor-owned none surface is preserved for opencode and kilo', () => { + assert.strictEqual(resolveInstallPlan('opencode').hooksSurface, 'none'); + assert.strictEqual(resolveInstallPlan('kilo').hooksSurface, 'none'); + }); + + test('synthetic descriptor resolves hooksSurface without runtime-name fallback', () => { + const runtimes = { + futurecli: { + runtime: { + installSurface: 'settings-json', + writesSharedSettings: true, + permissionWriter: null, + hookEvents: 'claude', + extendedHookEvents: ['Stop'], + hooksSurface: 'settings-json', + sandboxTier: 'none', + }, + }, + }; + + assert.deepStrictEqual(resolveInstallPlanFromRuntimes(runtimes, 'futurecli'), { + runtime: 'futurecli', + installSurface: 'settings-json', + writesSharedSettings: true, + finishPermissionWriter: null, + hookEvents: 'claude', + extendedHookEvents: ['Stop'], + hooksSurface: 'settings-json', + sandboxTier: 'none', + }); + }); + + test('missing hooksSurface fails loudly instead of falling back from runtime name', () => { + const runtimes = { + opencode: { + runtime: { + installSurface: 'settings-json', + writesSharedSettings: true, + permissionWriter: 'opencode', + extendedHookEvents: [], + }, + }, + }; + + assert.throws( + () => resolveInstallPlanFromRuntimes(runtimes, 'opencode'), + /runtime\.hooksSurface/, + ); + }); +}); diff --git a/tests/runtime-homes-descriptor-drive.test.cjs b/tests/runtime-homes-descriptor-drive.test.cjs index 6f36d31da..651ea0463 100644 --- a/tests/runtime-homes-descriptor-drive.test.cjs +++ b/tests/runtime-homes-descriptor-drive.test.cjs @@ -24,9 +24,11 @@ const { cleanup } = require('./helpers.cjs'); const ROOT = path.join(__dirname, '..'); const { getGlobalConfigDir, + getGlobalSkillsBase, resolveAntigravityGlobalDir, resolveKimiGlobalDir, resolveConfigHomeFromDescriptor, + resolveSkillsBaseFromDescriptor, } = require(path.join(ROOT, 'gsd-core', 'bin', 'lib', 'runtime-homes.cjs')); const HOME = os.homedir(); @@ -632,6 +634,48 @@ describe('descriptor-driven equivalence: unknown runtime fallback', () => { }); }); +describe('descriptor-driven global skills base', () => { + test('hermes skills base is derived from descriptor artifact layout', () => { + const saved = clearAllEnvKeys(); + try { + assert.strictEqual(getGlobalSkillsBase('hermes'), path.join(HOME, '.hermes', 'skills', 'gsd')); + } finally { + restoreEnvKeys(saved); + } + }); + + test('kilo skills base is derived from configHome.skillsHome descriptor', () => { + const saved = clearAllEnvKeys(); + try { + assert.strictEqual(getGlobalSkillsBase('kilo'), path.join(HOME, '.kilo', 'skills')); + } finally { + restoreEnvKeys(saved); + } + }); + + test('synthetic runtime skillsHome descriptor resolves without a runtime-name branch', () => { + const base = resolveSkillsBaseFromDescriptor( + { + kind: 'xdg', + name: 'futurecli', + env: ['FUTURE_CONFIG_DIR', 'FUTURE_CONFIG', 'XDG_CONFIG_HOME'], + skillsHome: { + kind: 'dot-home', + name: '.futurecli', + env: ['FUTURE_SKILLS_HOME'], + }, + }, + { + env: { FUTURE_SKILLS_HOME: '/custom/future-skills' }, + home: '/home/u', + existsSync: () => false, + }, + ); + + assert.strictEqual(base, path.join('/custom/future-skills', 'skills')); + }); +}); + // ── GOLDEN PARITY: getGlobalConfigDir via process.env for all 16 registry runtimes ── describe('descriptor-driven parity: 14 non-probe registry runtimes × no-env-vars = golden defaults', () => { diff --git a/tests/state.test.cjs b/tests/state.test.cjs index 6c556941d..38eb29d9a 100644 --- a/tests/state.test.cjs +++ b/tests/state.test.cjs @@ -732,8 +732,20 @@ describe('stateReplaceFieldWithFallback', () => { test('returns content unchanged when neither field matches', () => { const content = '# State\n\n**Phase:** 3\n'; - const result = stateReplaceFieldWithFallback(content, 'Status', 'state', 'New'); + let warning = ''; + const origErrWrite = process.stderr.write.bind(process.stderr); + process.stderr.write = (chunk) => { + warning += String(chunk); + return true; + }; + let result; + try { + result = stateReplaceFieldWithFallback(content, 'Status', 'state', 'New'); + } finally { + process.stderr.write = origErrWrite; + } assert.strictEqual(result, content, 'content should be unchanged'); + assert.match(warning, /STATE\.md field "Status"/, 'missing field warning should be emitted'); }); test('prefers primary over fallback when both exist', () => {