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
This commit is contained in:
sim
2026-08-14 22:47:17 -04:00
parent 52f4ea17cc
commit 2641e6cb67
14 changed files with 1765 additions and 176 deletions

View File

@@ -0,0 +1,110 @@
/**
* Install Surface Shadowing rule (#2873, epic #2866 Phase 4a — governed by
* `.gsd/phase/feat-2873-cross-scope-shadowing/40-design.md`).
*
* One code, W028, surfacing `install-shadow-report.cts`'s `ShadowReport` as
* a `/gsd-health` diagnostic — the design doc's row #4 ("one diagnostic,
* severity WARNING, same projection" as the install-time report). WARNING
* severity, ADVISE remedy with `risk: NONE`: shadowing is never auto-fixable
* (there is no single correct scope to remove) so this is advisory only,
* mirroring `agent-install.cts`'s W010 shape.
*
* Reaches OUTSIDE the planning snapshot the same way `agent-install.cts`
* does for W010: `resolveRuntime(cwd)` (`runtime-slash.cjs`) resolves the
* runtime id from `process.env.GSD_RUNTIME` / `config.runtime` / the
* `'claude'` default (never throws), using `snapshot.cwd`
* (`planning-snapshot.cts`'s `buildPlanningSnapshot` — `path.resolve(cwd)`)
* as the project directory. `buildShadowReport` is then called with that
* `cwd` so the local scope resolves against the project actually being
* health-checked, while `home` is left un-injected so the resolver defaults
* to `os.homedir()` — the real machine, same production call shape the
* installer uses.
*
* `check(snapshot)` degrades to `[]` (never throws) whenever there is
* nothing installable to report: `buildShadowReport` itself already
* degrades an unresolvable runtime (`configHome.kind === 'none'`, e.g.
* vscode — design row #7) to `reason: RESOLVER_UNAVAILABLE`, which
* `renderShadowReport` renders as `[]`; the try/catch around both calls
* below additionally absorbs any other unexpected throw (design row D5),
* since this rule is advisory and must never make `/gsd-health` itself
* fail.
*
* Design: .gsd/phase/feat-2873-cross-scope-shadowing/40-design.md
*
* ADR-457 build-at-publish: source in
* src/health-diagnostic-rules/install-surface-shadowing.cts, compiled to
* gsd-core/bin/lib/health-diagnostic-rules/install-surface-shadowing.cjs
* (gitignored).
*/
// eslint-disable-next-line @typescript-eslint/no-require-imports -- type-only; erased at compile time, no runtime require emitted
import type planningSnapshotMod = require('../planning-snapshot.cjs');
type PlanningSnapshot = ReturnType<typeof planningSnapshotMod.buildPlanningSnapshot>;
// eslint-disable-next-line @typescript-eslint/no-require-imports
import healthDiagnosticMod = require('../health-diagnostic-types.cjs');
const { SEVERITY, adviseRemedy } = healthDiagnosticMod;
type Rule = healthDiagnosticMod.Rule;
type Diagnostic = healthDiagnosticMod.Diagnostic;
// eslint-disable-next-line @typescript-eslint/no-require-imports
import installShadowReportMod = require('../install-shadow-report.cjs');
const { buildShadowReport, renderShadowReport } = installShadowReportMod;
// eslint-disable-next-line @typescript-eslint/no-require-imports
import runtimeSlashMod = require('../runtime-slash.cjs');
const { resolveRuntime } = runtimeSlashMod;
import { PACKAGE_NAME } from '../package-identity.cjs';
/**
* `check(snapshot)` for W028 — see module header for the degrade-to-`[]`
* cases and the runtime-resolution mechanism reused from `agent-install.cts`.
*/
function checkInstallSurfaceShadowing(snapshot: PlanningSnapshot): Diagnostic[] {
let lines: string[];
let runtime: string;
try {
runtime = resolveRuntime(snapshot.cwd);
const report = buildShadowReport(runtime, { cwd: snapshot.cwd });
// `renderShadowReport` returns `[]` for every reason other than
// SCOPE_SHADOWED (design rows #1, #2, #6, #7, #8, #11), and sanitizes
// every `declaredRuntime` it interpolates via `sanitizeForRender`
// internally (`install-shadow-report.cts`) — this rule's `message`
// reuses that render path rather than re-sanitizing, so the
// sanitize-at-the-render-seam guarantee (design row #13) holds here too.
// Trigger names are already SAFE_STEM-gated upstream
// (`installed-surface-resolver.cts`'s `deriveStemsForKindEntry`) before
// they ever reach a rendered line — no re-gating needed here either.
lines = renderShadowReport(report);
} catch {
// Advisory rule: an unresolvable runtime, a `configHome.kind === 'none'`
// runtime, or any other unexpected failure degrades to "no finding",
// never a thrown exception that would break `/gsd-health` itself
// (design row D5).
return [];
}
if (lines.length === 0) return [];
return [
{
code: 'W028',
severity: SEVERITY.WARNING,
message: lines.join(' '),
remedy: adviseRemedy(`Review install scopes for ${runtime}: npx ${PACKAGE_NAME}@latest`),
},
];
}
const RULES: Rule[] = [
{
code: 'W028',
severity: SEVERITY.WARNING,
description: 'A GSD-owned install scope shadows another on this machine',
repairable: false,
check: checkInstallSurfaceShadowing,
},
];
export = { RULES };

View File

@@ -83,6 +83,8 @@ import worktreeHealthMod = require('./health-diagnostic-rules/worktree-health.cj
import milestoneArchiveHygieneMod = require('./health-diagnostic-rules/milestone-archive-hygiene.cjs');
// eslint-disable-next-line @typescript-eslint/no-require-imports
import consistencyMod = require('./health-diagnostic-rules/consistency.cjs');
// eslint-disable-next-line @typescript-eslint/no-require-imports
import installSurfaceShadowingMod = require('./health-diagnostic-rules/install-surface-shadowing.cjs');
const RULES: Rule[] = [
...rootExistenceMod.RULES,
@@ -93,6 +95,7 @@ const RULES: Rule[] = [
...roadmapDiskConsistencyMod.RULES,
...worktreeHealthMod.RULES,
...milestoneArchiveHygieneMod.RULES,
...installSurfaceShadowingMod.RULES,
];
/**

View File

@@ -43,6 +43,55 @@
* (`installed-surface-resolver.cts`'s `deriveStemsForKindEntry`) before they
* ever reach a `TriggerSurface` — this module does not re-gate them.
*
* ── Per-scope truth filter (why this lives HERE, not in the resolver) ──────
* `resolveOneRuntime` (`installed-surface-resolver.cts`) builds ONE union of
* every installed scope's `stems` and hands that single list to
* `resolveTriggerSurface`, which then synthesizes a candidate trigger for
* EVERY stem at EVERY installed scope's trigger-bearing kind entry —
* regardless of whether that specific scope's own manifest actually shipped
* that stem. Concretely: a global `full`-profile install (stems a, b, c)
* alongside a local `core`-profile install (stem a only) unions to
* `{a, b, c}`, and `resolveTriggerSurface` then reports `commands@local`
* candidates for b and c too — trigger names for artifacts that do not exist
* on disk at that scope. Left unfiltered, this module would tell the user
* `/gsd-b` and `/gsd-c` are shadowed local commands when there is no local
* artifact for either at all — over-reporting that is not cosmetic, since
* the whole point of this report is to make a real failure legible.
*
* `resolveTriggerSurface`'s API takes ONE stem list shared by every scope it
* is asked about, so per-scope truth cannot be expressed through it without
* either widening a shipped Phase-2 contract other callers may depend on, or
* calling it once per scope and re-implementing its winner computation
* (`isHigherPriority`) here as a second, driftable copy. `resolveOneRuntime`
* / `resolveInstalledSurfaces` (Phase 3, #2872) is likewise a shipped module
* this task deliberately leaves untouched. This module already receives the
* full `InstalledRuntimeSurface`, including each scope's own REAL `stems`
* list (`installed-surface-resolver.cts`'s `deriveStemsFromManifest`) — so
* the correction belongs here, as a filter over `resolveTriggerSurface`'s
* already-computed `shadowedBy` groups: a trigger is reported as shadowed
* only when its underlying stem is present in BOTH the winner's scope's own
* `stems` AND the shadowed side's scope's own `stems` — i.e. an artifact
* genuinely exists at both scopes, not merely "some stem exists somewhere in
* the union".
*
* `TriggerSurface` does not carry the originating stem OR the composing
* prefix on its output — only the already-composed `trigger` string
* (`${prefix}${stem}`) — so the stem cannot be read off it directly. Rather
* than hand-roll a fixed-offset `trigger.slice(4)` (which would silently
* assume every runtime's prefix is exactly `gsd-` — true today, but not a
* contract this module owns), the prefix is recovered the honest way: by
* re-resolving that scope's `ArtifactKind` layout (`resolveRuntimeArtifactLayout`
* / `resolveRuntimeArtifactLayoutFromRegistry`, the SAME layout descriptor
* `resolveTriggerSurface` itself reads its `entry.prefix` from) for the
* winner's and shadowed side's own `(scope, kind)`, and reading `.prefix`
* off the matching kind entry. This is metadata-only (constructing an
* `ArtifactKind` never touches the filesystem — see
* `runtime-artifact-layout.cts`'s kind-builder functions), so it costs
* nothing beyond a small per-`(scope,kind)` memo. If a prefix cannot be
* resolved at all (a `TypeError` from an unexpected registry shape), the
* trigger is conservatively DROPPED rather than kept — the same
* report-nothing-you-cannot-prove posture as the rest of this filter.
*
* ── Pure with respect to caller-visible state ───────────────────────────────
* `buildShadowReport` builds a fresh `ShadowReport` (fresh arrays, fresh
* objects) on every call, exactly as the resolver documents for itself
@@ -55,8 +104,24 @@ import {
resolveInstalledSurfaces,
type ResolveInstalledSurfacesOptions,
type InstalledRuntimeSurface,
type InstalledScopeRecord,
} from './installed-surface-resolver.cjs';
// eslint-disable-next-line @typescript-eslint/no-require-imports
import runtimeArtifactLayoutMod = require('./runtime-artifact-layout.cjs');
const { resolveRuntimeArtifactLayout, resolveRuntimeArtifactLayoutFromRegistry } = runtimeArtifactLayoutMod;
/** The registry shape `resolveRuntimeArtifactLayoutFromRegistry` accepts as
* its first argument — reused (not re-typed) so `opts.registry` can be
* forwarded to it, mirroring `installed-surface-resolver.cts`'s own
* `LayoutRegistryLike`. */
type LayoutRegistryLike = Parameters<typeof resolveRuntimeArtifactLayoutFromRegistry>[0];
/** `installed-surface-resolver.cts` does not export `TriggerSurface` by
* name (only via `InstalledRuntimeSurface.triggers`'s element type) —
* derived here rather than re-declared as a second, driftable shape. */
type TriggerSurface = InstalledRuntimeSurface['triggers'][number];
// ── Reason enum ─────────────────────────────────────────────────────────
export const SHADOW_REASON = Object.freeze({
@@ -133,6 +198,76 @@ export function sanitizeForRender(value: string | null): string | null {
return stripped.replace(/\s+/g, ' ').trim();
}
// ── Per-scope truth filter helpers ─────────────────────────────────────
/**
* Build a `(scope, kind) -> prefix | null` lookup for one runtime, memoized
* per call to `buildShadowReport` (never shared across calls — matches this
* module's "fresh objects on every call" contract). `null` means "could not
* be resolved" (unknown scope record, or a `TypeError` from the layout
* resolver) — the caller treats that as "cannot honestly attribute this
* trigger to a real stem here", not as "assume it is fine".
*/
function buildPrefixLookup(
runtime: string,
scopeRecords: Map<InstallScope, InstalledScopeRecord>,
opts: ResolveInstalledSurfacesOptions,
): (scope: InstallScope, kind: string) => string | null {
const cache = new Map<string, string | null>();
return (scope: InstallScope, kind: string): string | null => {
const key = `${scope}:${kind}`;
if (cache.has(key)) return cache.get(key) as string | null;
const record = scopeRecords.get(scope);
let prefix: string | null = null;
if (record) {
try {
const layout = opts.registry !== undefined
? resolveRuntimeArtifactLayoutFromRegistry(opts.registry as LayoutRegistryLike, runtime, record.configHome, record.scope)
: resolveRuntimeArtifactLayout(runtime, record.configHome, record.scope);
const kindEntry = (layout.kinds as Array<{ kind: string; prefix: string }>).find((k) => k.kind === kind);
prefix = kindEntry ? kindEntry.prefix : null;
} catch {
// Unknown runtime / malformed registry — degrade to "cannot resolve",
// never throw out of a report builder (matches this module's own
// RESOLVER_UNAVAILABLE degrade-not-propagate posture above).
prefix = null;
}
}
cache.set(key, prefix);
return prefix;
};
}
/** `trigger` minus `prefix`, or `null` when `prefix` is unknown, does not
* actually prefix `trigger`, or the remainder would be empty (a `prefix`
* covering the whole trigger string is not a real stem). */
function stemFromTrigger(trigger: string, prefix: string | null): string | null {
if (prefix === null || !trigger.startsWith(prefix)) return null;
const stem = trigger.slice(prefix.length);
return stem === '' ? null : stem;
}
/**
* True when `t` (a `resolveTriggerSurface`-reported shadowed trigger) is a
* REAL cross-scope shadow: its stem is present in the winner's OWN scope
* `stems` and, independently, in the shadowed side's OWN scope `stems`. See
* the module-level "Per-scope truth filter" comment for why this check
* exists and why it lives here rather than in the resolver.
*/
function isGenuinelyShadowed(
t: TriggerSurface,
scopeRecords: Map<InstallScope, InstalledScopeRecord>,
prefixFor: (scope: InstallScope, kind: string) => string | null,
): boolean {
if (t.shadowedBy === null) return false;
const winnerRecord = scopeRecords.get(t.shadowedBy.scope);
const shadowedRecord = scopeRecords.get(t.scope);
const winnerStem = stemFromTrigger(t.trigger, prefixFor(t.shadowedBy.scope, t.shadowedBy.kind));
const shadowedStem = stemFromTrigger(t.trigger, prefixFor(t.scope, t.kind));
if (winnerStem === null || shadowedStem === null) return false;
return (winnerRecord?.stems ?? []).includes(winnerStem) && (shadowedRecord?.stems ?? []).includes(shadowedStem);
}
// ── Report builder ──────────────────────────────────────────────────────
/**
@@ -171,7 +306,15 @@ export function buildShadowReport(runtime: string, opts: ResolveInstalledSurface
// always returns exactly one element (see its own doc comment).
const surface = surfaces[0];
const shadowedSurfaces = surface.triggers.filter((t) => t.shadowedBy !== null);
// Per-scope truth filter (see module comment): `surface.triggers` may
// contain candidates synthesized from the CROSS-SCOPE stem union
// (`installed-surface-resolver.cts`'s `stemUnion`) that do not correspond
// to a real artifact at one or both scopes. Only a trigger whose stem is
// provably present in BOTH the winner's own `stems` and the shadowed
// side's own `stems` is reported.
const scopeRecords = new Map<InstallScope, InstalledScopeRecord>(surface.scopes.map((r) => [r.scope, r] as const));
const prefixFor = buildPrefixLookup(runtime, scopeRecords, opts);
const shadowedSurfaces = surface.triggers.filter((t) => isGenuinelyShadowed(t, scopeRecords, prefixFor));
const triggers: ShadowedTrigger[] = shadowedSurfaces
.map((t) => ({
trigger: t.trigger,

View File

@@ -246,7 +246,14 @@ function normalizeManifestVersion(raw: unknown): number {
function readInstallManifest(configDir: string): InstallManifest {
const manifest = readJsonIfPresent(path.join(configDir, MANIFEST_NAME), null);
if (!manifest || typeof manifest !== 'object') {
// `typeof [] === 'object'` in JS, so a bare `typeof !== 'object'` guard lets
// a top-level JSON array (valid JSON, but not the manifest's documented
// object shape) fall through to the field reads below — `m.manifestVersion`
// reads `undefined` off an array, which `normalizeManifestVersion` then
// reports as `1` (a v1 manifest), misclassifying "not an object" as
// "installed". `Array.isArray` closes that gap explicitly rather than
// relying on the object-shape checks below to catch it incidentally.
if (!manifest || typeof manifest !== 'object' || Array.isArray(manifest)) {
return {
version: null,
timestamp: null,

View File

@@ -504,6 +504,88 @@ function convertClaudeCommandToClaudeSkill(content, skillName, runtime = null, c
return `${fm}\n${normalizedBody}`;
}
// #2873 (4b) — spec-root reachability. Matches ONLY a line that is a real
// `@~/.claude/gsd-core/workflows/<stem>.md` include: line-start `@`, exact
// spec-root shape, nothing else on the line. This is deliberately narrower
// than "any line mentioning gsd-core/workflows" so prose mentions and
// `references/`/`templates/`/`@.planning/...` includes are never touched
// (rows 24/25). CRLF-safe: an optional trailing `\r` is captured and
// preserved rather than dropped.
const WORKFLOW_SPEC_ROOT_INCLUDE_RE = /^@~\/\.claude\/gsd-core\/workflows\/([A-Za-z0-9._-]+)\.md[ \t]*(\r?)$/gm;
// Matches a fenced code-block delimiter line (``` or ~~~, any info string)
// so occurrences of the include shape used as *documentation* inside a fence
// are left untouched — Claude Code documents backticks as the way to
// *prevent* an `@`-import, so rewriting a fenced example would corrupt
// documentation-of-the-syntax.
const FENCE_DELIMITER_RE = /^(```|~~~)[^\r\n]*$/gm;
/**
* Rewrite a static global-scope Claude skill `@`-include of the command's own
* workflow spec into an imperative two-step resolution the agent performs at
* runtime: prefer the project-local spec (cwd-relative), fall back to the
* global spec, and treat "neither exists" as a visible failure rather than a
* silent no-spec proceed.
*
* WHY this can't stay a static `@`-include (even a relative one): Claude Code
* documents relative `@`-paths as resolving against the file *containing* the
* import, which for a global skill is `~/.claude/skills/gsd-<stem>/` — not
* the project's working directory. `@./.claude/...` would therefore always
* resolve inside the skill's own install directory, never the project, so
* there is no static include syntax that can express "prefer local, fall
* back to global". This function exists precisely so that resolution can be
* performed by the agent, not the host's pre-expansion.
*
* Scope-free by design: this function does not know or care whether it is
* being applied to a global or local artifact, or which runtime — that
* judgment belongs to the caller (`skillsKind` in
* `runtime-artifact-layout.cts`, the one site that knows install scope).
* Applying it to a body with no workflow include is a no-op (row 26); a body
* with two independent workflow includes has each rewritten independently
* (row 27); an include inside a fenced code block or wrapped in inline
* backticks is left untouched (the backtick case is already excluded by the
* line-start anchor, since a backtick-wrapped line does not begin with `@`).
* Idempotent: the replacement text never begins with `@` and never matches
* `WORKFLOW_SPEC_ROOT_INCLUDE_RE`, so re-applying this function to its own
* output is a no-op.
*/
function resolveSpecRootReference(body) {
if (typeof body !== 'string' || body.length === 0) return body;
if (!body.includes('@~/.claude/gsd-core/workflows/')) return body;
// Collect [start, end) offset ranges covered by fenced code blocks so
// matches inside them are skipped. An unterminated trailing fence covers
// to the end of the string (still "inside a fence").
const fenceRanges = [];
{
let m;
let openStart = null;
FENCE_DELIMITER_RE.lastIndex = 0;
while ((m = FENCE_DELIMITER_RE.exec(body)) !== null) {
if (openStart === null) {
openStart = m.index;
} else {
fenceRanges.push([openStart, m.index + m[0].length]);
openStart = null;
}
}
if (openStart !== null) fenceRanges.push([openStart, body.length]);
}
const isInsideFence = (offset) => fenceRanges.some(([start, end]) => offset >= start && offset < end);
return body.replace(WORKFLOW_SPEC_ROOT_INCLUDE_RE, (match, stem, cr, offset) => {
if (isInsideFence(offset)) return match;
return (
`To load this command's workflow spec: check for ` +
`\`.claude/gsd-core/workflows/${stem}.md\` relative to the current working ` +
`directory first (project-local); if it is not there, fall back to ` +
`\`~/.claude/gsd-core/workflows/${stem}.md\` (the global install). If ` +
`neither file exists, stop — a workflow spec is required and none was found.` +
cr
);
});
}
function normalizeKimiSkillName(skillName) {
let text = String(skillName || '').trim().toLowerCase();
if (text.startsWith('/')) text = text.slice(1);
@@ -2995,6 +3077,38 @@ function applyRuntimeContentRewritesForCommandsInPlace(stagedDir, runtime, pathP
return tempDir;
}
/**
* #2873 (4b) — second pass over a staged skills directory, run strictly AFTER
* `applyRuntimeContentRewritesInPlace`. That pass's `case 'claude':` branch
* unconditionally rewrites any bare (non-`@`-prefixed) `~/.claude/` substring
* in the body to the computed pathPrefix (`$HOME/.claude/` for a global
* install) and restores ONLY the `@`-prefixed form back to `~`
* (`@$HOME/.claude/` → `@~/.claude/`). `resolveSpecRootReference`'s
* replacement text is deliberately imperative prose containing a literal,
* non-`@`-prefixed `~/.claude/gsd-core/workflows/<stem>.md` — running it
* BEFORE the pass above would let that literal tilde text get silently
* mangled into the undocumented `$HOME/` form the design explicitly rejects.
* Running it here, after, means it only ever sees the FINAL
* `@~/.claude/gsd-core/workflows/<stem>.md` include line (which survives the
* pass above intact via its own `@`-guarded restore).
*/
function applySpecRootReferenceToStagedSkills(stagedDir) {
if (!fs.existsSync(stagedDir)) return;
const walk = (dir) => {
for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
const fullPath = path.join(dir, entry.name);
if (entry.isDirectory()) {
walk(fullPath);
} else if (entry.name === 'SKILL.md') {
const content = fs.readFileSync(fullPath, 'utf8');
const rewritten = resolveSpecRootReference(content);
if (rewritten !== content) fs.writeFileSync(fullPath, rewritten);
}
}
};
walk(stagedDir);
}
/**
* HIGH-LEVEL: In-place fs walk: rewrite all .md files under stagedDir for the given runtime.
*
@@ -3032,6 +3146,17 @@ function rewriteStagedSkillBodies(stagedDir, opts) {
const attribution = resolveAttribution ? resolveAttribution(runtime) : undefined;
applyRuntimeContentRewritesInPlace(stagedDir, runtime, pathPrefix, isGlobal, attribution);
// #2873 (4b): claude, global scope only — see
// applySpecRootReferenceToStagedSkills's doc comment for why this MUST run
// after the rewrite pass above, not before. `rewriteStagedSkillBodies` is
// the skills-kind seam (`kind.kind === 'skills'`), so this never touches a
// 'commands' or 'agents' kind body (rows 24/25 unaffected), and claude has
// no skills-kind entry at local scope, so this is already structurally
// scoped to global (row 23) — the explicit isGlobal check is defense-in-depth
// against that descriptor wiring ever changing.
if (runtime === 'claude' && isGlobal) {
applySpecRootReferenceToStagedSkills(stagedDir);
}
}
/**
@@ -3176,6 +3301,11 @@ export = {
convertClaudeToAntigravityContent,
convertClaudeCommandToAntigravitySkill,
convertClaudeCommandToClaudeSkill,
// #2873 (4b): pure, scope-free transform — applied by the one call site
// that knows install scope (skillsKind's stage() in
// runtime-artifact-layout.cts), never inside convertClaudeCommandToClaudeSkill
// itself.
resolveSpecRootReference,
convertClaudeCommandToKimiSkill,
convertClaudeCommandToKimiCodeSkill,
buildKimiAgentArtifacts,

View File

@@ -395,6 +395,16 @@ function skillsKind(
// 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);