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

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