feat(#1138): make runtime descriptors authoritative (#1157)

This commit is contained in:
Tom Boucher
2026-06-13 01:49:25 -04:00
committed by GitHub
parent bd349aa88e
commit aec3374bc2
40 changed files with 696 additions and 154 deletions

View File

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

View File

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

View File

@@ -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/<project> (never .planning/projects/<project>); 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/<project> (never .planning/projects/<project>); 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`

View File

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

View File

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

View File

@@ -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": {},

View File

@@ -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": [],

View File

@@ -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": {

View File

@@ -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": {

View File

@@ -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": [],

View File

@@ -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": [],

View File

@@ -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": [],

View File

@@ -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": [],

View File

@@ -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": [],

View File

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

View File

@@ -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/<runtime>/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:

View File

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

View File

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

View File

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

View File

@@ -112,7 +112,9 @@ function parseCallsAgents(content: string): string[] {
* `gsd-*` agent name references. Agent stems are stored under the special
* key `_calls_agents_<stem>` so they don't conflict with skill stems.
*/
function loadSkillsManifest(commandsDir: string): Map<string, string[]> {
const DEFAULT_COMMANDS_DIR = path.resolve(__dirname, '..', '..', '..', 'commands', 'gsd');
function loadSkillsManifest(commandsDir: string = DEFAULT_COMMANDS_DIR): Map<string, string[]> {
const manifest = new Map<string, string[]>();
if (!fs.existsSync(commandsDir)) return manifest;
const entries = fs.readdirSync(commandsDir, { withFileTypes: true });

View File

@@ -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<string, { runtime?: { artifactLayout?: ArtifactLayoutDescriptor } }> } {
interface RegistryLike {
runtimes: Record<string, { runtime?: { artifactLayout?: ArtifactLayoutDescriptor } }>;
}
function getRegistry(): RegistryLike {
return _require('./capability-registry.cjs') as {
runtimes: Record<string, { runtime?: { artifactLayout?: ArtifactLayoutDescriptor } }>;
};
@@ -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 };

View File

@@ -82,6 +82,8 @@ interface InstallPlan extends RuntimeConfigIntent {
// Exports
// ---------------------------------------------------------------------------
type RuntimeDescriptorMap = Record<string, { runtime: Record<string, unknown> | undefined }>;
/** The complete set of 16 supported runtimes for config-adapter dispatch. */
const ALLOWED_CONFIG_RUNTIMES: ReadonlySet<string> = 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 };

View File

@@ -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<string, { runtime?: RuntimeDescriptor }> } {
// eslint-disable-next-line @typescript-eslint/no-require-imports
return require('./capability-registry.cjs') as {
runtimes: Record<string, { runtime?: RuntimeDescriptor }>;
};
}
/**
* 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<string, { runtime?: { configHome: ConfigHomeDescriptor } }>;
};
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: <configDir>/skills
* Hermes: <configDir>/skills/gsd (nested category layout — #2841)
* Cline ≥ v3.48.0: <configDir>/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');
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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);

View File

@@ -38,6 +38,7 @@ function run(args, cwd) {
cwd,
timeout: 15000,
encoding: 'utf-8',
stdio: ['pipe', 'pipe', 'pipe'],
}),
ok: true,
};

View File

@@ -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: [],

View File

@@ -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);

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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', () => {

View File

@@ -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', () => {