Files
msd-core/src/runtime-artifact-layout.cts
sim 2641e6cb67 feat(#2873): detect cross-scope shadowing and reach the local spec tree
4a - the detection floor. A shadowed install now reports which triggers are
shadowed and which scope wins, at install time and through a new W028
/gsd-health diagnostic. Exit codes are untouched: a shadowed install is a
warning, not a failure. Only triggers whose stem exists at BOTH scopes are
reported, so a global full profile beside a local core profile no longer
names local artifacts the user does not have.

4b - spec-root reachability, claude runtime and global scope only. The
winning global skill stops carrying a static workflow @-include and instead
resolves its spec at runtime: prefer the project-local copy, fall back to
the global one, stop if neither exists. Every other @-include stays static,
and the local emission is byte-identical. It runs after the staged-skills
rewrite pass, whose claude branch would otherwise mangle the literal tilde
path into an undocumented $HOME form.

Also fixed inline: readInstallManifest classified a top-level JSON array as
an installed v1 manifest, because typeof [] is object.

Refs #2873
2026-08-14 23:48:39 -04:00

907 lines
41 KiB
TypeScript

'use strict';
/**
* 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 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
* from the prior hand-written .cjs; only types are added.
*/
import path from 'node:path';
import fs from 'node:fs';
import os from 'node:os';
// eslint-disable-next-line @typescript-eslint/no-require-imports
import installProfiles = require('./install-profiles.cjs');
const {
stageSkillsForProfile,
stageAgentsForRuntimeWithConverter,
stageSkillsForRuntimeAsSkills,
stageCommandsForRuntimeFlat,
} = installProfiles;
// eslint-disable-next-line @typescript-eslint/no-require-imports
import runtimeArtifactConversion = require('./runtime-artifact-conversion.cjs');
const conversionExports = runtimeArtifactConversion as Record<string, unknown> & {
readGsdCommandNames?: () => string[];
};
import { posixNormalize } from './shell-command-projection.cjs';
// #2870: `isGlobalScope` centralizes the `scope === 'global'` boolean
// projection both kind-builder closures below need at the converters'
// positional `isGlobal` boundary (see its doc comment in install-scope.cts
// for why the projection is centralized rather than eliminated).
import { isGlobalScope, scopeRank, validateScopeId, SCOPE_ORDER, type InstallScope } from './install-scope.cjs';
// In .cts (CommonJS output) files, `require` is available as a global.
const _require: NodeRequire = require;
// loadInstallExports / getInstallExports / InstallExports removed in ADR-1508
// / #1511 Phase 2 — removed this module's upward dependency on bin/install.js
// (the getInstallExports relay). surface.cts now calls
// runtimeArtifactConversion.rewriteStagedSkillBodies directly.
// ---------------------------------------------------------------------------
// Types
// ---------------------------------------------------------------------------
type ArtifactKindName = 'commands' | 'agents' | 'skills';
type KimiArtifactKindName = ArtifactKindName | 'kimi-agents';
// Mirrors the (unexported) ResolvedProfile in install-profiles.cts.
// Must stay in sync if that shape changes.
interface ResolvedProfile {
name: string;
skills: Set<string> | '*';
agents: Set<string>;
}
/**
* #2322: mirrors the (unexported) CapabilityRegistry shape in install-profiles.cts.
* Threaded through resolveRuntimeArtifactLayout -> skillsKind so the skills-kind
* stage() closure can bind a third-party capability skill stem to its DECLARING
* capability (capabilityClusters) at staging time — never by scanning the
* installed capabilities root and guessing. Optional: a caller with no registry
* in scope gets a layout whose skills kind stages NOTHING third-party (fail
* closed), matching install-profiles.cts's own registry-optional contract.
*/
interface CapabilityRegistryForSkills {
capabilityClusters?: Record<string, string[]>;
profileMembership?: Record<string, { tier: string; profiles: string[] }>;
}
/**
* Cross-cutting context for descriptor-driven agent staging (ADR-1235 §1).
* Passed as the optional second arg to ArtifactKind.stage() for agents kind
* entries so that stageAgentsForRuntimeWithConverter can apply the exact
* inline-loop transform order: pathRewrites → attribution → converter → normalize.
*/
interface AgentCtx {
runtime: string;
pathPrefix: string;
attribution: string | null | undefined;
}
interface ArtifactKind {
kind: KimiArtifactKindName;
destSubpath: string;
prefix: string;
/** For agents kind with a converter, accepts an optional AgentCtx as the second
* arg so cross-cutting can be applied pre-converter (ADR-1235 §1). */
stage: (resolvedProfile: ResolvedProfile, agentCtx?: AgentCtx) => string;
/** Resolved absolute alternate install root for this kind, if the descriptor
* specifies one (e.g. codex skills → $HOME/.agents). Undefined means the
* kind installs under the runtime's normal configDir. */
home?: string;
/** Name of the converter function in Runtime Artifact Conversion exports, as
* declared on the descriptor's `converter` field. Only populated for the
* `skills` kind today — lets bespoke callers (e.g. the OpenCode-family
* combined installer, ADR-1239 / #2093) look up the descriptor-declared
* converter by name instead of re-deriving it from a runtime === check. */
converter?: string;
}
interface Layout {
runtime: string;
configDir: string;
scope?: 'local' | 'global';
kinds: ArtifactKind[];
}
// ---------------------------------------------------------------------------
// Source root finders
// ---------------------------------------------------------------------------
/**
* Locate the GSD commands/gsd source directory.
*
* Resolution order:
* 1. If runtimeConfigDir provided, check <runtimeConfigDir>/.gsd-source marker.
* 2. Walk up from __dirname using path.dirname (no literal .. segments).
* 3. Throw a descriptive error if neither succeeds.
*/
function findInstallSourceRoot(runtimeConfigDir?: string): string {
// Step 1: marker check
if (runtimeConfigDir) {
const markerPath = path.join(runtimeConfigDir, '.gsd-source');
if (fs.existsSync(markerPath)) {
try {
const src = fs.readFileSync(markerPath, 'utf8').trim();
if (src && fs.existsSync(src)) return src;
} catch { /* fall through */ }
}
}
// Step 2: walk up from __dirname
let dir = __dirname;
for (let i = 0; i < 6; i++) {
const candidate = path.join(dir, 'commands', 'gsd');
if (fs.existsSync(candidate)) return candidate;
const parent = path.dirname(dir);
if (parent === dir) break;
dir = parent;
}
throw new Error(`findInstallSourceRoot: could not locate commands/gsd from ${__dirname}`);
}
/**
* Locate the GSD agents source directory.
*
* Resolution order:
* 1. If runtimeConfigDir provided, check <runtimeConfigDir>/.gsd-source marker.
* 2. Walk up from __dirname using path.dirname (no literal .. segments).
* 3. Throw a descriptive error if neither succeeds.
*/
function findAgentsSourceRoot(runtimeConfigDir?: string): string {
// Step 1: marker check
if (runtimeConfigDir) {
const markerPath = path.join(runtimeConfigDir, '.gsd-source');
if (fs.existsSync(markerPath)) {
try {
const src = fs.readFileSync(markerPath, 'utf8').trim();
if (src && fs.existsSync(src)) {
// Marker points to commands/gsd; agents/ is a sibling of commands/
const agentsCandidate = path.resolve(path.dirname(src), '..', 'agents');
if (fs.existsSync(agentsCandidate)) return agentsCandidate;
}
} catch { /* fall through */ }
}
}
// Step 2: walk up from __dirname
let dir = __dirname;
for (let i = 0; i < 6; i++) {
const candidate = path.join(dir, 'agents');
if (fs.existsSync(candidate)) return candidate;
const parent = path.dirname(dir);
if (parent === dir) break;
dir = parent;
}
throw new Error(`findAgentsSourceRoot: could not locate agents/ from ${__dirname}`);
}
// ---------------------------------------------------------------------------
// Layout table builders
// ---------------------------------------------------------------------------
function commandsKind(destSubpath: string, prefix: string, configDir: string): ArtifactKind {
return {
kind: 'commands',
destSubpath,
prefix,
stage: (resolved) => stageSkillsForProfile(findInstallSourceRoot(configDir), resolved),
};
}
function agentsKind(destSubpath: string, prefix: string, configDir: string): ArtifactKind {
return {
kind: 'agents',
destSubpath,
prefix,
// #2995: a `converter: null` agents entry (claude local, zcode) previously
// staged via stageAgentsForProfile — a RAW byte copy that never reads content
// into JS, so gsd:section markers shipped verbatim. Route through the
// composing stager with an identity converter instead: same output as the raw
// copy for an unmarked agent, markers stripped for a marked one. Routing both
// agent kinds through the stager collapses what were five independent agent
// read points down to three compose call sites: this stager, bin/install.js's
// inline agent loop, and installCodexConfig's per-agent .toml writer. The
// exhaustive per-runtime sweep in tests/agent-fragments-emission.install.test.cjs
// is what keeps a fourth from appearing uncomposed.
stage: (resolved) => stageAgentsForRuntimeWithConverter(
findAgentsSourceRoot(configDir),
resolved,
(content: string) => content,
),
};
}
/**
* Build a converted-agents kind descriptor for runtimes whose agent `.md` files
* need runtime-specific frontmatter/body conversion (e.g. Copilot, Cursor, Codex).
*
* Unlike `agentsKind` (which raw-copies source files), this kind applies
* `converterName` from Runtime Artifact Conversion exports to each agent file
* during staging, writing flat `${name}.md` files to the staged directory.
*
* Agent filenames are preserved verbatim (the prefix is already embedded in the
* agent stem — e.g. `gsd-planner.md`).
*
* #1173 SCOPE — plumbing only (real install still elsewhere): this provides
* the converter dispatch + `isGlobal` scope threading for the descriptor's
* `agents` kind. As of #2092, 8 non-Claude runtimes DO declare a converted
* `agents` kind in their `capability.json` — qwen (`convertClaudeAgentToQwenAgent`)
* plus the 7 that already declared one before it (antigravity, augment,
* codebuddy, copilot, cursor, trae, windsurf) — so the descriptor-level
* declaration is no longer deferred. What IS still deferred is wiring
* `resolveRuntimeArtifactLayout`'s `agents` kind into the REAL install:
* `bin/install.js`'s agent-staging loop does not consume this module's
* `convertedAgentsKind` resolution at all — it dispatches the very same
* converter functions directly via `_hostBehaviors(runtime)` checks
* (`frontmatterDialect`, `brandingRewrites`, `isCopilot`/`isAntigravity`/…),
* duplicating the mapping declared here. That duplication is deliberate until
* the second `layout.kinds` consumer — `applySurface` / `/gsd:surface` /
* `--materialize` (`src/surface.cts`) — mirrors the legacy agent pipeline
* (Copilot's `.agent.md` filename rename, the cross-cutting path-prefix
* rewrite + attribution, stale-file cleanup, config-reading steps); declaring
* `bin/install.js` itself against this resolver before then would risk
* regressing the surface path. Until that follow-up lands, `bin/install.js`
* remains authoritative for the real install, and this `convertedAgentsKind`
* is exercised only by `/gsd:surface` and synthetic-descriptor seam tests.
*
* Mirrors the `convertedCommandsKind` pattern (#785).
*
* @param destSubpath destination subpath within configDir (e.g. 'agents')
* @param prefix filename prefix (informational; not applied here)
* @param converterName name of converter function in Runtime Artifact Conversion exports
* @param configDir runtime config dir (for .gsd-source marker resolution)
*/
function convertedAgentsKind(
destSubpath: string,
prefix: string,
converterName: string,
configDir: string,
scope: 'local' | 'global' = 'global',
): ArtifactKind {
return {
kind: 'agents',
destSubpath,
prefix,
stage: (resolved, agentCtx) => {
// isGlobal is threaded so scope-aware agent converters (copilot, antigravity)
// choose global-home vs workspace-relative paths; converters that only take
// (content) ignore the extra positional arg. Mirrors skillsKind's scope
// threading (#1173).
// #2870: `scope` is this function's own parameter (default `'global'`,
// so it is never undefined here), sourced upstream from the Install
// Scope Module's resolved id. `isGlobalScope` projects it to the
// boolean `stageAgentsForRuntimeWithConverter`'s positional API
// requires — see its doc comment in install-scope.cts.
const converter = conversionExports[converterName] as (content: string, isGlobal?: boolean) => string;
// ADR-1235 §1: when agentCtx is provided (by createRuntimeArtifactInstallPlan
// for descriptor-driven runtimes), thread it through so stageAgentsForRuntimeWithConverter
// can apply the full pre-converter + post-converter sequence in the correct order.
return stageAgentsForRuntimeWithConverter(
findAgentsSourceRoot(configDir),
resolved,
converter,
isGlobalScope(scope),
agentCtx,
);
},
};
}
function kimiAgentsKind(destSubpath: string, prefix: string, configDir: string): ArtifactKind {
return {
kind: 'kimi-agents',
destSubpath,
prefix,
stage: (resolved) => {
const buildKimiAgentArtifacts = conversionExports['buildKimiAgentArtifacts'] as (opts: {
rootAgent?: string;
subagents?: Array<{ path: string; content: string }>;
}) => {
root: { yaml: string; prompt: string };
subagents: Array<{ name: string; yaml: string; prompt: string }>;
};
// #2995: compose at staging (identity converter) so the readFileSync below
// sees marker-free content — same single composing stager as agentsKind.
const stagedAgents = stageAgentsForRuntimeWithConverter(
findAgentsSourceRoot(configDir),
resolved,
(content: string) => content,
);
const subagents: Array<{ path: string; content: string }> = [];
if (fs.existsSync(stagedAgents)) {
for (const entry of fs.readdirSync(stagedAgents, { withFileTypes: true })) {
if (!entry.isFile() || !entry.name.endsWith('.md')) continue;
const agentPath = path.join(stagedAgents, entry.name);
subagents.push({
path: posixNormalize(path.join('agents', entry.name)),
content: fs.readFileSync(agentPath, 'utf8'),
});
}
}
const rootAgent = `---\nname: gsd\ndescription: Run GSD workflows in Kimi CLI.\ntools: Agent\n---\n\n# GSD for Kimi CLI\n\nCoordinate installed /skill:gsd-* workflows and route work to generated GSD subagents when a workflow requires an agent handoff.\n`;
const artifacts = buildKimiAgentArtifacts({ rootAgent, subagents });
const stageDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-kimi-agents-'));
installProfiles.STAGED_DIRS.add(stageDir);
fs.writeFileSync(path.join(stageDir, 'gsd.yaml'), artifacts.root.yaml);
fs.writeFileSync(path.join(stageDir, 'gsd.md'), artifacts.root.prompt);
const subagentsDir = path.join(stageDir, 'subagents');
fs.mkdirSync(subagentsDir, { recursive: true });
for (const artifact of artifacts.subagents) {
fs.writeFileSync(path.join(subagentsDir, `${artifact.name}.yaml`), artifact.yaml);
fs.writeFileSync(path.join(subagentsDir, `${artifact.name}.md`), artifact.prompt);
}
return stageDir;
},
};
}
/**
* Build a skills kind descriptor.
*
* @param destSubpath
* @param prefix
* @param converterName name of converter function in Runtime Artifact Conversion exports
* @param runtime canonical runtime ID (gates Hermes/Qwen branding in converter)
* @param configDir runtime config dir (for .gsd-source marker resolution)
* @param nested if true, nest concrete skills under their ns-* routers (#69)
* @param scope install scope; converted to isGlobal and passed as 5th positional
* arg so scope-aware converters (antigravity, copilot) can choose
* between global home paths and workspace-relative paths without
* colliding with the `runtime` string at position 3.
* @param capabilityRegistry #2322: optional capability registry — captured in the
* stage() closure so third-party capability skills are bound to
* their declaring capId at staging time. Absent -> stage() stages
* nothing third-party (fail closed).
*/
function skillsKind(
destSubpath: string,
prefix: string,
converterName: string,
runtime: string,
configDir: string,
nested = false,
scope: 'local' | 'global' = 'global',
capabilityRegistry?: CapabilityRegistryForSkills,
): ArtifactKind {
return {
kind: 'skills',
destSubpath,
prefix,
converter: converterName,
stage: (resolved) => {
const realConverter = conversionExports[converterName] as (content: string, skillName: string, runtime: string, cmdNames: string[], isGlobal: boolean) => string;
// Compute cmdNames once per stage call for performance (#3583).
// Extra trailing args are ignored by converters that don't need them. The
// isGlobal flag is the 5th positional (NOT the 3rd): the 3rd positional is
// `runtime` for the claude/kimi/cline converters, so the scope-aware
// converters (antigravity, copilot) read isGlobal from position 5 to avoid
// colliding with `runtime` and always taking the global branch.
const cmdNames = conversionExports.readGsdCommandNames
? conversionExports.readGsdCommandNames()
: [];
// #2870: same judgment as convertedAgentsKind above — `scope` is this
// function's own parameter (default `'global'`, so it is never
// undefined here); `isGlobalScope` projects it to the boolean
// `realConverter`'s positional `isGlobal` arg requires.
const isGlobal = isGlobalScope(scope);
// #2873 (4b): spec-root reachability is applied LATER in the pipeline —
// see `rewriteStagedSkillBodies` in runtime-artifact-conversion.cts, not
// here. This stage() closure runs BEFORE the staged directory's generic
// path-prefix rewrite pass (`applyRuntimeContentRewritesInPlace`'s
// `case 'claude'`), which unconditionally rewrites any bare (non-`@`)
// `~/.claude/` substring to the undocumented `$HOME/.claude/` form and
// only restores the `@`-prefixed form. Emitting the imperative
// tilde-path prose here would get silently mangled by that later pass;
// it must run AFTER it instead, once the `@`-include is in its final
// rewritten shape.
const wrappedConverter = (content: string, skillName: string): string =>
realConverter(content, skillName, runtime, cmdNames, isGlobal);
return stageSkillsForRuntimeAsSkills(findInstallSourceRoot(configDir), resolved, wrappedConverter, prefix, nested, capabilityRegistry);
},
};
}
/**
* Build a converted-commands kind descriptor for runtimes that use a flat
* commands directory with per-file conversion (e.g. Cursor 1.6 slash commands).
*
* Unlike `commandsKind` (which passes raw source files through), this kind
* applies `converterName` from Runtime Artifact Conversion exports to each file during
* staging, writing flat `${prefix}${stem}.md` files to the staged directory.
*
* The staged files are then written by `_copyStaged` (commands branch) which
* handles prefix logic via the existing layout machinery.
*
* @param destSubpath destination subpath within configDir (e.g. 'commands')
* @param prefix filename prefix, e.g. 'gsd-'
* @param converterName name of converter function in Runtime Artifact Conversion exports
* @param configDir runtime config dir (for .gsd-source marker resolution)
*/
function convertedCommandsKind(
destSubpath: string,
prefix: string,
converterName: string,
configDir: string,
): ArtifactKind {
return {
kind: 'commands',
destSubpath,
prefix,
stage: (resolved) => {
const converter = conversionExports[converterName] as (content: string, commandName: string) => string;
return stageCommandsForRuntimeFlat(findInstallSourceRoot(configDir), resolved, converter, prefix);
},
};
}
// ---------------------------------------------------------------------------
// Public API
// ---------------------------------------------------------------------------
// ---------------------------------------------------------------------------
// Nested skill-bundle support matrix (#69)
// ---------------------------------------------------------------------------
//
// When a runtime's skill loader scans only one level deep (non-recursive), a
// concrete skill nested at `<router>/skills/<name>/SKILL.md` drops out of the
// eager top-level listing yet stays readable by file path — which is exactly
// what namespace routing needs. Recursive loaders surface every nested SKILL.md
// as a peer (zero token saving), so they stay flat. Unconfirmed loaders stay
// flat conservatively. Verified June 2026:
//
// NEST (confirmed non-recursive / one-level scan):
// cline — cline/cline skills.ts scanSkillsDirectory uses flat fs.readdir
// qwen — QwenLM/qwen-code skill-load.ts flat readdir ("depth 2 enough")
// hermes — hermes-agent.nousresearch.com/docs/user-guide/features/skills
// (single-level subdir probe of the tap path)
// augment — https://docs.augmentcode.com/cli/skills (flat single-level)
// trae — docs.trae.ai/ide/skills + Trae-AI/TRAE#2253 (flat; nesting errors)
// Trae IDE (trae.ai), not trae-agent — see runtime-homes.cts header note
// FLAT (recursive loader → nesting gives no saving):
// cursor — https://cursor.com/docs/skills (walks skills root recursively)
// opencode — sst/opencode skill/index.ts glob "skills/**/SKILL.md"
// kilo — Kilo-Org/kilocode (opencode fork, same ** glob)
//
// FLAT (one-level scan, but concrete skills must be directly discoverable):
// antigravity— https://antigravity.google/docs/skills + /docs/cli-plugins
// (skills live at <skills-dir>/<skill-folder>/SKILL.md; AGY does not
// register router-nested concrete skills as slash commands)
//
// FLAT (reverted from nested — nested skills not discoverable by Skill tool, #924):
// claude — https://code.claude.com/docs/en/skills + anthropics/claude-code#28266
// (one-level scan under ~/.claude/skills — but Skill-tool errors on unknown
// names rather than re-routing via the router; concrete skills must be
// at the top level so Skill(skill="gsd-plan-phase") succeeds)
//
// FLAT (nested-scan behaviour unconfirmed → conservative):
// codex — developers.openai.com/codex/skills/
// copilot — docs.github.com/en/copilot/concepts/agents/about-agent-skills
// windsurf — docs.devin.ai/desktop/cascade/skills
// codebuddy — codebuddy.ai/docs/cli/skills
// ---------------------------------------------------------------------------
// Descriptor-driven dispatch helpers (ADR-857 phase 5d)
// ---------------------------------------------------------------------------
interface ArtifactKindDescriptor {
kind: string;
destSubpath: string;
prefix: string;
nesting: 'flat' | 'nested';
recursive: boolean;
converter: string | null;
/** Optional alternate install home, relative to the user's home directory
* (e.g. ".agents" for codex skills → $HOME/.agents/skills). When absent,
* the kind installs under the runtime's normal configDir. */
home?: string;
}
interface ArtifactLayoutDescriptor {
global: ArtifactKindDescriptor[];
local: ArtifactKindDescriptor[];
}
/** Lazy registry accessor — mirrors pattern from 5b/5c (runtime-homes.cts). */
interface RegistryLike {
runtimes: Record<string, { runtime?: { artifactLayout?: ArtifactLayoutDescriptor } }>;
}
function getRegistry(): RegistryLike {
return _require('./capability-registry.cjs') as {
runtimes: Record<string, { runtime?: { artifactLayout?: ArtifactLayoutDescriptor } }>;
};
}
/**
* Map a single ArtifactKindDescriptor entry to an ArtifactKind using the
* matching builder function. Mirrors the hand-built calls in the old switch.
*/
function dispatchKindEntry(entry: ArtifactKindDescriptor, runtime: string, configDir: string, scope: 'local' | 'global', capabilityRegistry?: CapabilityRegistryForSkills): ArtifactKind {
const { kind, destSubpath, prefix, nesting, converter } = entry;
const nested = nesting === 'nested';
let result: ArtifactKind;
switch (kind) {
case 'commands':
result = converter == null
? commandsKind(destSubpath, prefix, configDir)
: convertedCommandsKind(destSubpath, prefix, converter, configDir);
break;
case 'agents':
result = converter == null
? agentsKind(destSubpath, prefix, configDir)
: convertedAgentsKind(destSubpath, prefix, converter, configDir, scope);
break;
case 'skills':
if (converter == null) {
throw new TypeError(
`resolveRuntimeArtifactLayout: skills entry for '${runtime}' has converter=null (converter is required for skills)`,
);
}
result = skillsKind(destSubpath, prefix, converter, runtime, configDir, nested, scope, capabilityRegistry);
break;
case 'kimi-agents':
result = kimiAgentsKind(destSubpath, prefix, configDir);
break;
default:
throw new TypeError(
`resolveRuntimeArtifactLayout: unknown kind '${kind}' in descriptor for runtime '${runtime}'`,
);
}
// scope is guaranteed 'local' | 'global' here: resolveRuntimeArtifactLayoutFromRegistry
// (the only caller of dispatchKindEntry) throws TypeError before this point if scope is
// anything else (see the `scope !== 'local' && scope !== 'global'` guard above its
// dispatchKindEntry call), so isGlobalScope's throw-on-invalid-input never fires here.
if (isGlobalScope(scope) && typeof entry.home === 'string' && entry.home !== '') {
result.home = path.join(os.homedir(), entry.home);
}
return result;
}
/**
* Resolve the artifact layout for a given runtime and config directory.
*
* ADR-857 phase 5d: driven by the capability-registry artifactLayout descriptor
* instead of a hardcoded switch statement.
*
* @param capabilityRegistry #2322: optional — when the caller has a composed
* capability registry in scope (e.g. capability-writer.cts's `capability set`
* path, or a fresh install's registry-aware profile resolution), pass it here
* so the skills kind's stage() closure can materialize installed third-party
* capability skills bound to their declaring capId. Both call paths (surface
* apply AND the installer) must pass their registry here — resolveProfile's
* own `'*'` (full profile) short-circuit never carries a registry, so if it
* is not threaded in at layout-build time a `full`-profile install stages no
* third-party capability skills regardless of registration (#2322 blocker 2).
*/
function resolveRuntimeArtifactLayout(runtime: string, configDir: string, scope: 'local' | 'global' = 'global', capabilityRegistry?: CapabilityRegistryForSkills): Layout {
return resolveRuntimeArtifactLayoutFromRegistry(getRegistry(), runtime, configDir, scope, capabilityRegistry);
}
function resolveRuntimeArtifactLayoutFromRegistry(
registry: RegistryLike,
runtime: string,
configDir: string,
scope: 'local' | 'global' = 'global',
capabilityRegistry?: CapabilityRegistryForSkills,
): 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"');
}
const desc = registry.runtimes[runtime]?.runtime?.artifactLayout;
if (!desc) {
throw new TypeError(`Unknown runtime: '${runtime}' — add to runtime-artifact-layout.cjs table`);
}
const entries: ArtifactKindDescriptor[] = desc[scope] ?? [];
const kinds: ArtifactKind[] = entries.map((entry) => dispatchKindEntry(entry, runtime, configDir, scope, capabilityRegistry));
return { runtime, configDir, scope, kinds };
}
// ---------------------------------------------------------------------------
// resolveTriggerSurface (#2871 Phase 2)
// ---------------------------------------------------------------------------
//
// Widens this module from PLACEMENT (resolveRuntimeArtifactLayout, above —
// untouched, still 7 callers) to TRIGGER resolution: "what does a user type"
// rather than "where does a file land". A new function, not a widened
// signature — see .gsd/phase/feat-2871-trigger-resolution/40-design.md.
//
// Only `commands` and `skills` are trigger-bearing. `agents` / `kimi-agents`
// are a SEPARATE dispatch interface point (subagent invocation via
// `subagent_type` / named dispatch, never a `/gsd-<name>` a user types) — see
// 40-design.md's "agents are not trigger-bearing" correction to ADR-2866.
// Excluding them here is deliberate, not an oversight: including `agents`
// would misreport windsurf (whose global scope emits agents only) as fully
// shadowing its local `/gsd-*` surface, when in fact nothing shadows it.
/** The trigger-bearing subset of ArtifactKindName — mirrors
* VALID_TRIGGER_PRECEDENCE_KINDS in capability-validator.cjs (kept as two
* literal-typed surfaces rather than importing a runtime Set into a type
* position; tests assert the two vocabularies parity-match via
* DEFAULT_TRIGGER_PRECEDENCE). */
type TriggerKindName = 'commands' | 'skills';
/** 'direct': the host itself registers this trigger. 'via-router': only the
* owning router is registered by the host; this trigger is reachable
* because the router's body was rewritten to `Read` it (#69 nested-skill
* bundles — install-profiles.cts:714-723). See 40-design.md's "Nested-router
* children" section for why a boolean cannot carry this distinction.
*
* Not `export`ed: matches this file's existing house style (`Layout`,
* `ArtifactKind`, etc. are internal types too) — `export =` at the bottom
* of this module is its sole export surface, and mixing it with named type
* exports is unnecessary since the only external consumer of these shapes
* is a plain-JS test file. */
type TriggerRegistration = 'direct' | 'via-router';
interface TriggerShadower {
kind: TriggerKindName;
scope: InstallScope;
}
interface TriggerSurface {
/** What the user types, e.g. `gsd-plan-phase`. Always `${prefix}${stem}` —
* unaffected by the destPath branch below (see `destPath`). */
trigger: string;
kind: TriggerKindName;
scope: InstallScope;
/** Where the artifact is staged, mirroring `_copyStaged`'s actual write
* (`install-engine.cts:404-493`) INCLUDING its `namespacedByDir` branch
* (~L464-466): a `commands` kind whose `destSubpath` basename equals
* `prefix` minus its trailing hyphen is written bare (no prefix on the
* filename) because the directory itself is the namespace. */
destPath: string;
registration: TriggerRegistration;
/** The owning router's trigger string, only when `registration ===
* 'via-router'`; `null` otherwise (including for the router's own entry —
* a router has no router of its own). */
routerTrigger: string | null;
/** The winning sibling entry for this SAME trigger, or `null` when this
* entry is itself unshadowed (including when it is the only candidate).
* Reported as a fact, never a defect — see 40-design.md's "Not-corruption"
* section: same-kind shadowing across scopes is the healthy, expected
* state for every both-scope runtime. */
shadowedBy: TriggerShadower | null;
}
interface TriggerSurfaceOpts {
/** Source command/skill stems present for this call, shared across every
* trigger-bearing kind entry — mirrors ResolvedProfile's flat stem
* membership at staging time (install-profiles.cts). */
stems: string[];
/** Subset of `stems` that are namespace routers (nested-router runtimes
* only, #69). Absent or empty ⇒ no nested-router distinction is made —
* every stem resolves `registration: 'direct'`, matching the caller's own
* choice not to supply router membership. */
routerStems?: string[];
/** Concrete stem -> owning router stem(s); mirrors
* buildNamespaceBundleMap's childToRouters shape. Only consulted for a
* stem that is NOT itself in `routerStems`, on a `nesting: 'nested'` kind
* entry. The first named router is used. */
childToRouters?: Record<string, string[]>;
/** Registry override — the SAME seam resolveRuntimeArtifactLayoutFromRegistry
* already exposes. Lets a synthetic descriptor be exercised without
* touching the real capability-registry. */
registry?: TriggerRegistryLike;
}
interface RuntimeDescriptorForTriggers {
artifactLayout?: ArtifactLayoutDescriptor;
/** Ordered kind precedence, highest priority first (#2871 Phase 2). Absent
* ⇒ capability-validator.cjs's DEFAULT_TRIGGER_PRECEDENCE applies — see
* `getDefaultTriggerPrecedence` below. */
triggerPrecedence?: string[];
}
interface TriggerRegistryLike {
runtimes: Record<string, { runtime?: RuntimeDescriptorForTriggers }>;
}
function getTriggerRegistry(): TriggerRegistryLike {
return _require('./capability-registry.cjs') as TriggerRegistryLike;
}
/**
* capability-validator.cjs is a COMMITTED plain .cjs (not built from a .cts
* source — see its own header comment), so it is required the same way
* capability-registry.cjs is above: a lazy `_require` rather than a static
* ES import. DEFAULT_TRIGGER_PRECEDENCE is the single source of truth for
* "what applies when a descriptor omits triggerPrecedence"; this module
* reads it rather than re-declaring `['skills', 'commands']` as a second
* literal that could silently drift from the validator's own default.
*/
function getDefaultTriggerPrecedence(): string[] {
const capValidator = _require('./capability-validator.cjs') as { DEFAULT_TRIGGER_PRECEDENCE: string[] };
return capValidator.DEFAULT_TRIGGER_PRECEDENCE;
}
/**
* True when a `commands` kind entry is namespaced by its destination
* directory rather than by a filename prefix — i.e. `destSubpath`'s basename
* equals `prefix` with its trailing hyphen stripped (e.g. `commands/gsd` +
* `gsd-`). When true, `_copyStaged` (`install-engine.cts`) and `surface.cts`
* both write the bare stem filename (no prefix) because the directory itself
* already carries the namespace; `resolveTriggerSurface` mirrors that in its
* own `destPath` computation. Single source of truth for the three sites
* that used to compute this independently (#2871 Phase 2 review finding) —
* a new caller MUST reuse this rather than re-deriving the rule.
*/
function isNamespacedByDir(kind: string, destSubpath: string, prefix: string): boolean {
const destLast = path.posix.basename(posixNormalize(destSubpath));
const prefixStem = prefix ? prefix.replace(/-$/, '') : '';
return kind === 'commands' && destLast === prefixStem;
}
/**
* Compose the destination filename `_copyStaged` (install-engine.cts) writes
* for a `commands` kind entry, given `isNamespacedByDir`'s result, the
* kind's prefix, and the file's `.md`-stripped stem. Single source of truth
* alongside `isNamespacedByDir` for the FILENAME COMPOSITION itself (#2871
* Phase 2 review finding — the boolean was single-sourced first, but the
* `${stem}.md` / `${prefix}${stem}.md` string-building around it stayed
* duplicated between `_copyStaged` and `resolveTriggerSurface`'s `destPath`
* prediction below, so a divergence in the write convention would not have
* failed anything).
*
* Byte-identical to `_copyStaged`'s prior separate branches: when
* `namespacedByDir` is true this returns `${stem}.md`. `_copyStaged` always
* derives `stem` as `entry.name.slice(0, -3)` for an `entry.name` that has
* already been filtered to end in `.md`, so `${stem}.md` is always exactly
* `entry.name` again — `_copyStaged` can pass this helper's result in place
* of the `entry.name` it used to write directly, with no behavior change.
*/
function composeCommandFilename(namespacedByDir: boolean, prefix: string, stem: string): string {
return namespacedByDir ? `${stem}.md` : `${prefix}${stem}.md`;
}
/**
* True when candidate `a` should win over the current best `b` for the same
* trigger. Scope rank first (Phase 1's `install-scope.cts#scopeRank` —
* global outranks local; NOT re-derived here), then the runtime's
* `triggerPrecedence` kind ordering (lower index = higher priority). A kind
* absent from `precedenceRank` (should not happen — every entry's kind is
* validated against the same closed vocabulary the precedence list draws
* from) sorts last rather than throwing, so a malformed precedence value
* degrades to "leaves the incumbent standing" instead of corrupting the
* whole resolution.
*/
function isHigherPriority(a: TriggerSurface, b: TriggerSurface, precedenceRank: Map<string, number>): boolean {
const rankA = scopeRank(a.scope);
const rankB = scopeRank(b.scope);
if (rankA !== rankB) return rankA > rankB;
const pa = precedenceRank.get(a.kind) ?? Number.POSITIVE_INFINITY;
const pb = precedenceRank.get(b.kind) ?? Number.POSITIVE_INFINITY;
return pa < pb;
}
/**
* Resolve the `/gsd-<name>`-style trigger surface for a runtime: what the
* user types, at which scope, whether it wins or is shadowed, and (for
* nested-router runtimes) whether the host registers it directly or only
* reaches it through a router. Pure — no filesystem, no mutation of `scopes`
* or `opts`, and safe against a caller mutating the returned array/objects
* (a fresh array/objects are built on every call; nothing is cached or
* shared across calls beyond the read-only registry module).
*
* Only `commands` and `skills` kind entries are considered — see the
* module-level comment above. `resolveRuntimeArtifactLayout` is untouched by
* this function; they are independent readers of the same descriptor.
*
* @throws {TypeError} for an unknown runtime — same contract (and message
* shape) as `resolveRuntimeArtifactLayoutFromRegistry`.
* @throws {TypeError} for an unrecognized entry in `scopes` — reuses
* `install-scope.cts`'s shared `validateScopeId`, the same validator
* `scopeRank`/`resolveScope`/`isGlobalScope` already throw through, so this
* sibling of theirs cannot silently fail open on a bad scope (#2871 Phase 2
* review finding). `scopes: []` is untouched — an empty array has no
* entries to validate and still resolves to `[]`.
*/
function resolveTriggerSurface(runtime: string, scopes: InstallScope[], opts: TriggerSurfaceOpts): TriggerSurface[] {
const registry = opts.registry ?? getTriggerRegistry();
const runtimeDescriptor = registry.runtimes[runtime]?.runtime;
const layout = runtimeDescriptor?.artifactLayout;
if (!layout) {
throw new TypeError(`Unknown runtime: '${runtime}' — add to runtime-artifact-layout.cjs table`);
}
for (const scope of scopes) {
validateScopeId(scope, 'resolveTriggerSurface');
}
const scopeSet = new Set(scopes);
const stems = opts.stems ?? [];
const routerStemSet = new Set(opts.routerStems ?? []);
const childToRouters = opts.childToRouters ?? {};
const precedence = runtimeDescriptor?.triggerPrecedence ?? getDefaultTriggerPrecedence();
const precedenceRank = new Map(precedence.map((kind, index) => [kind, index]));
const surfaces: TriggerSurface[] = [];
for (const scope of SCOPE_ORDER) {
if (!scopeSet.has(scope)) continue;
const entries = layout[scope] ?? [];
for (const entry of entries) {
if (entry.kind !== 'commands' && entry.kind !== 'skills') continue; // excludes agents/kimi-agents
const kind = entry.kind;
const destSubpath = posixNormalize(entry.destSubpath);
const namespacedByDir = isNamespacedByDir(kind, entry.destSubpath, entry.prefix);
const nested = entry.nesting === 'nested';
for (const stem of stems) {
const trigger = `${entry.prefix}${stem}`;
let destPath: string;
if (kind === 'skills') {
destPath = `${destSubpath}/${entry.prefix}${stem}`;
} else {
destPath = `${destSubpath}/${composeCommandFilename(namespacedByDir, entry.prefix, stem)}`;
}
let registration: TriggerRegistration = 'direct';
let routerTrigger: string | null = null;
if (nested && routerStemSet.size > 0 && !routerStemSet.has(stem)) {
const owningRouters = childToRouters[stem];
const routerStem = owningRouters && owningRouters.length > 0 ? owningRouters[0] : undefined;
if (routerStem !== undefined && routerStemSet.has(routerStem)) {
registration = 'via-router';
routerTrigger = `${entry.prefix}${routerStem}`;
}
}
surfaces.push({ trigger, kind, scope, destPath, registration, routerTrigger, shadowedBy: null });
}
}
}
// Winner computation, per trigger string, across every scope/kind candidate.
const groups = new Map<string, TriggerSurface[]>();
for (const surface of surfaces) {
const group = groups.get(surface.trigger);
if (group) {
group.push(surface);
} else {
groups.set(surface.trigger, [surface]);
}
}
for (const group of groups.values()) {
if (group.length <= 1) continue; // sole candidate: unshadowed by construction
let winner = group[0];
for (let i = 1; i < group.length; i++) {
const candidate = group[i];
if (isHigherPriority(candidate, winner, precedenceRank)) winner = candidate;
}
for (const surface of group) {
if (surface !== winner) {
surface.shadowedBy = { kind: winner.kind, scope: winner.scope };
}
}
}
return surfaces;
}
// getInstallExports removed in ADR-1508 / #1511 Phase 2 (last upward .cts→install.js dep).
export = { resolveRuntimeArtifactLayout, resolveRuntimeArtifactLayoutFromRegistry, findInstallSourceRoot, resolveTriggerSurface, isNamespacedByDir, composeCommandFilename };