feat(#2871): resolve triggers and host precedence, not just placement (#3291)

* test(#2871): failing-first suite for trigger-surface resolution

23 tests over the 50-test-matrix rows. RED by construction:
resolveTriggerSurface and DEFAULT_TRIGGER_PRECEDENCE do not exist yet,
and the validator silently ignores triggerPrecedence today.

Written in the per-runtime describe idiom the other four
runtime-artifact-layout suites use, not a table.

The rows that carry the weight: windsurf must NOT report a shadow it
does not have, since its global scope emits only agents and agents are
not trigger-bearing; agents and kimi-agents must be absent from the
output for every runtime; and reordering a runtime's triggerPrecedence
must flip the winner, which is the only assertion that proves the axis
is read rather than decorative.

Stems are injected, never scanned, so the surface is assertable with no
filesystem.

* feat(#2871): resolve triggers and host precedence, not just placement

resolveTriggerSurface(runtime, scopes) returns every /gsd-<name> trigger
a runtime emits, with the scope and kind that produced it, whether the
host registers it directly or only through a router, and which artifact
shadows it. resolveRuntimeArtifactLayout is untouched -- its 7 callers
need placement only and the issue requires them unchanged.

AGENTS ARE NOT TRIGGER-BEARING, and ADR-2866 said they were. The
host-integration matrix models command and dispatch as separate interface
points: an agent is invoked through the Agent tool's subagent_type, not
by typing a slash trigger, and _copyStaged never applies the kind prefix
to an agents entry. So agents and kimi-agents are excluded from the
surface entirely, and this commit amends ADR-2866 with a dated
correction. #2218's conclusion is unchanged -- the collision is strictly
commands-vs-skills, and claude's local /gsd-* trigger surface is still
fully shadowed -- but the ADR implied the local agents surface was lost
too, and it is not.

That correction is what makes windsurf come out right. Its global scope
emits only agents, so it has no global trigger and its local commands
are unshadowed. Model agents as trigger-bearing and windsurf falsely
reports a full shadow.

The triggerPrecedence axis lands on all 19 descriptors as an ordered
kind list, one value with one owner, rather than a numeric rank spread
across N kind entries with nothing keeping them consistent. Validation
uses a required-with-default shape that has no precedent in this
validator -- every existing axis is hard-required -- so a third-party
capability.json omitting the field still validates, which is what
ADR-894's additive-only contract promises.

Winner resolution reads Phase 1's scope rank first, then the kind
ordering. A test reorders the axis and asserts the winner flips, since
an axis that is added, validated and never consulted would pass every
other assertion.

shadowedBy ships unread. Phase 4 (#2873) is its first consumer, per this
issue's out-of-scope note.

Verified via the remote runner.

* fix(#2871): single-source namespacedByDir and close two test gaps

Four findings from the isolated adversarial review.

The namespacedByDir rule had reached three copies -- install-engine,
surface, and the new trigger resolver -- one of which carried a
hand-written keep-in-sync comment and no assertion. That is this repo's
generative-fix-divergence class. Extracted to one exported predicate all
three now call. Verified by diverging one copy deliberately: the existing
#816 parity test failed, and passes again on revert.

The omission test was vacuous. Row 16 asserted that a descriptor without
triggerPrecedence still validates, but built its fixture from claude's
shipped descriptor -- which this PR had just added the axis to. It now
clones and deletes the key, following the shippedDescriptorWithout
pattern, and asserts both that validation passes and that the resolver
still picks the right winner from the default. The second half is what
makes it prove anything.

resolveTriggerSurface silently dropped an unrecognized scope while every
sibling in this epic throws. Two phases of one epic should not disagree
about whether an invalid scope is an error, so it now rejects through the
same shared validator; an empty scope list still returns empty rather
than throwing.

The ADR amendment had been spliced into the middle of the References
list, orphaning its last bullet. Moved to the top, after the header
block, which is where ADR-3660 and ADR-1016 both put dated amendments.
No lint checks markdown structure, so this was green while malformed.

* fix(#2871): single-source the command filename composition too

The earlier fix shared the namespacedByDir boolean but left the
filename composition around it written twice -- once in _copyStaged as
what actually gets written, once in resolveTriggerSurface as what gets
predicted. The predictor could go stale silently.

One exported helper now composes it for both. The entry.name asymmetry
that looked like it would block extraction does not: entry.name is
filtered to end in .md and stem is entry.name minus those three
characters, so the two branches are the same string by construction.

Divergence proven to fail: injecting a marker into the helper broke the
trigger-surface suite; reverting restored 25/25. The four sibling layout
suites hold at 227 unchanged.

* docs(#2871): correct the ADR timing notes that this phase makes stale

The Amended by back-links on ADR-3660 and ADR-1016 were written in
Phase 0, when the widenings they describe had not shipped. Each carried
a forward-looking clause -- "the module changes at Phase 2, not before,
until then this module resolves placement only" -- which becomes false
the moment this PR merges. ADR-2866's own Amends header and its
reciprocal-notes section carried the same tense.

All four now describe what shipped. This is a tense and status
correction on Accepted ADRs, not a change to any decision.

Worth stating because it is the failure mode this epic keeps meeting:
gen-adr-index.cjs tracks only Supersedes and Subsumes, so nothing in CI
would have caught either the missing back-link in Phase 0 or these stale
clauses now. They stay correct only because someone checks.

* chore(#2871): backfill changeset PR number

---------

Co-authored-by: sim <sim@local>
This commit is contained in:
Tom Boucher
2026-08-09 22:25:42 -04:00
committed by GitHub
parent 4a1ed2531f
commit cf6de5e1c0
34 changed files with 1129 additions and 34 deletions

View File

@@ -0,0 +1,5 @@
---
type: Added
pr: 3291
---
resolveTriggerSurface (Runtime Artifact Layout Module) resolves the /gsd-<name> trigger surface — winner, shadowedBy, and nested-router registration — per runtime/scope, and a new runtime.triggerPrecedence descriptor axis (required-with-default) decides same-trigger collisions; agents and kimi-agents are never trigger-bearing.

View File

@@ -223,7 +223,7 @@ Pure, no-I/O seam owning in-file `<!-- gsd:section id="<id>" when="<when>" -->`
Pure, no-I/O `when=` evaluator over `InvocationFacts`, mapping a document-order list of parsed `gsd:section` sections (Workflow Fragments Module) to an included/excluded partition for one concrete invocation (ADR-1671 Decision items 3 & 4 + migration step 6; epic #1671 Phase 5, #2932). **The evaluator is a LOOKUP, not a parser** — `WHEN_PREDICATES` is a total map from each frozen `WHEN_VOCABULARY` entry (imported unchanged from `workflow-fragments.cjs`, never redeclared) to exactly one predicate over `InvocationFacts` — `{flags: ReadonlySet<string>, phaseNumber: string|null, hasPriorPhases: boolean}` plus optional already-resolved booleans (`needsCodebaseMap`, `phaseMvpMode`, `worktreesEnabled`, `chunkedMode`, `uiPhaseActive`, `fallowEnabled`, …), every field a plain value the caller computed before `selectSections` runs; `flags` is a `ReadonlySet` rather than a plain object because `.has()` carries no prototype hazard — and note `parseNamedArgs` NEVER returns `undefined` for an absent flag (booleans come back `false`, value keys `null`), so "present in the options record" is not token presence. It MUST NOT tokenize, split on operators, or interpret `when=` structure — the moment it parses, the ad-hoc language Greenspun's Tenth Rule warns against has begun. `selectSections(sections, facts)` returns `{included, excluded}` id arrays that together contain every input id exactly once, in the same relative document order, never mutating the input. An unrecognized `when=` value fails closed via a `TypeError` carrying `.reason = REASON.UNKNOWN_WHEN` — never silently excluded — matching the discipline Phase 3 already established for the same vocabulary at parse time. Every predicate treats an absent fact key as falsy without throwing, since the caller (the init CLI seam) may not always populate every field. A coordinated-change guard runs at module load: every `WHEN_VOCABULARY` entry must have exactly one predicate here, so a 5th vocabulary entry added without a matching predicate fails loudly at load time rather than silently falling through to `REASON.UNKNOWN_WHEN` only at run time. Selection output is generated ahead of time into the committed `gsd-core/workflows/section-manifest.json` (`scripts/gen-section-manifest.cjs`, reusing `parseWorkflowSections` unchanged — a second marker parser here would be the `DEFECT.GENERATIVE-FIX` divergence class) rather than derived from markers at run time, because markers are stripped at emit and the installed parent carries no `gsd:section` metadata. Source of truth: `gsd-core/bin/lib/section-manifest.cjs` (generated from `src/section-manifest.cts`). Test anchors: `tests/section-manifest.test.cjs`, `tests/section-manifest.property.test.cjs`, `tests/gen-section-manifest.test.cjs`.
### Runtime Artifact Layout Module
Module owning the per-runtime mapping from artifact kind to filesystem placement. ADR-3660 defines the typed `kinds` per runtime (`commands`, `agents`, `skills`) with destination subpath, prefix, and stage adapter (with per-runtime converters in `bin/install.js`: `convertClaudeCommandToClaudeSkill`, `…CodexSkill`, `…CopilotSkill`, `…AntigravitySkill`). Owns the per-runtime `nested` skill-bundle decision (#69): a `skillsKind` flag in `src/runtime-artifact-layout.cts` drives whether a runtime receives the nested router layout (6 `gsd-ns-*` routers + concrete skills under `<router>/skills/<name>/`) or the flat `skills/gsd-<stem>/` layout; the evidence/doc-link matrix is recorded in a comment above `resolveRuntimeArtifactLayout`. Phase 1 applies this seam to the Runtime Surface Module (`surface.cjs:applySurface`); as of #813, `applySurface` applies the same per-runtime skill-body path rewrites as `installRuntimeArtifacts` for `skills` kinds — re-surfacing no longer overwrites installed SKILL.md bodies with converter-default `~/.claude` paths. Per ADR-1508 / #1511 the former `getInstallExports`/`loadInstallExports` relay (a `GSD_TEST_MODE`-guarded `require('bin/install.js')` by which `surface.cjs` reached `computePathPrefix`/`applyRuntimeContentRewritesInPlace`) was DELETED from this module; content rewriting now lives in the Runtime Artifact Conversion Module and `surface.cjs:applySurface` calls its `rewriteStagedSkillBodies` directly. The resolved `scope` is still carried on the `Layout` object so `applySurface` derives the same `pathPrefix` (global `$HOME` form vs. absolute) as a fresh install. Phase 2 is planned to migrate install/uninstall in `bin/install.js` so all lifecycle sites iterate one shared layout table instead of re-encoding runtime layout logic. This design is intended to remove the #3659 class of omissions. Migrations remain under the Installer Migration Module (ADR-0008). The `.gsd-source` marker (#1477) is a two-party provisioning contract that lets source resolution succeed on the Claude global skills layout, which ships `gsd-core/{bin,contexts,references,templates,workflows}` but no `commands/gsd` source tree for `findInstallSourceRoot` to walk up to: the writer is `bin/install.js`, which writes `<configDir>/.gsd-source` (content: the absolute path to its own `commands/gsd`, terminated by a newline) when `runtime === 'claude' && isGlobal`, guarded by `fs.existsSync` so a half-published package never writes a dangling marker; the reader is `findInstallSourceRoot(configDir)`, which prefers the marker over its walk-up but falls through to the walk-up if the marker is absent, dangling, or empty/whitespace-only. See ADR-3660.
Module owning the per-runtime mapping from artifact kind to filesystem placement. ADR-3660 defines the typed `kinds` per runtime (`commands`, `agents`, `skills`) with destination subpath, prefix, and stage adapter (with per-runtime converters in `bin/install.js`: `convertClaudeCommandToClaudeSkill`, `…CodexSkill`, `…CopilotSkill`, `…AntigravitySkill`). Owns the per-runtime `nested` skill-bundle decision (#69): a `skillsKind` flag in `src/runtime-artifact-layout.cts` drives whether a runtime receives the nested router layout (6 `gsd-ns-*` routers + concrete skills under `<router>/skills/<name>/`) or the flat `skills/gsd-<stem>/` layout; the evidence/doc-link matrix is recorded in a comment above `resolveRuntimeArtifactLayout`. Phase 1 applies this seam to the Runtime Surface Module (`surface.cjs:applySurface`); as of #813, `applySurface` applies the same per-runtime skill-body path rewrites as `installRuntimeArtifacts` for `skills` kinds — re-surfacing no longer overwrites installed SKILL.md bodies with converter-default `~/.claude` paths. Per ADR-1508 / #1511 the former `getInstallExports`/`loadInstallExports` relay (a `GSD_TEST_MODE`-guarded `require('bin/install.js')` by which `surface.cjs` reached `computePathPrefix`/`applyRuntimeContentRewritesInPlace`) was DELETED from this module; content rewriting now lives in the Runtime Artifact Conversion Module and `surface.cjs:applySurface` calls its `rewriteStagedSkillBodies` directly. The resolved `scope` is still carried on the `Layout` object so `applySurface` derives the same `pathPrefix` (global `$HOME` form vs. absolute) as a fresh install. Phase 2 is planned to migrate install/uninstall in `bin/install.js` so all lifecycle sites iterate one shared layout table instead of re-encoding runtime layout logic. This design is intended to remove the #3659 class of omissions. Migrations remain under the Installer Migration Module (ADR-0008). The `.gsd-source` marker (#1477) is a two-party provisioning contract that lets source resolution succeed on the Claude global skills layout, which ships `gsd-core/{bin,contexts,references,templates,workflows}` but no `commands/gsd` source tree for `findInstallSourceRoot` to walk up to: the writer is `bin/install.js`, which writes `<configDir>/.gsd-source` (content: the absolute path to its own `commands/gsd`, terminated by a newline) when `runtime === 'claude' && isGlobal`, guarded by `fs.existsSync` so a half-published package never writes a dangling marker; the reader is `findInstallSourceRoot(configDir)`, which prefers the marker over its walk-up but falls through to the walk-up if the marker is absent, dangling, or empty/whitespace-only. #2871 Phase 2 widens the Module from placement to placement **+** trigger resolution: `resolveTriggerSurface(runtime, scopes, { stems, routerStems?, childToRouters?, registry? }) -> TriggerSurface[]` answers "what does a user type" rather than "where does a file land" — a new, pure function alongside `resolveRuntimeArtifactLayout` (untouched, still 7 callers), never a widened signature. Only `commands` and `skills` are trigger-bearing; `agents`/`kimi-agents` are excluded entirely — an agent is invoked through the Agent/Task tool's `subagent_type`, a separate dispatch interface point, never a `/gsd-<name>` a user types (ADR-2866 amendment below). Each `TriggerSurface` names its `trigger`, `kind`, `scope`, `destPath` (computed through the SAME `namespacedByDir` branch `_copyStaged` uses), `registration` (`'direct'` | `'via-router'`, the latter naming the owning router's `routerTrigger` for a nested-router runtime's concrete child skill — #69), and `shadowedBy` (the winning sibling entry, or `null`). The winner across scopes and kinds is decided by scope rank first (Install Scope Module's `scopeRank`, consumed not re-derived — global outranks local) then by the runtime's new `runtime.triggerPrecedence` descriptor axis (ordered kind names, highest priority first; default `['skills', 'commands']`, required-with-default so a pre-#2871 `capability.json` keeps validating). `shadowedBy` ships unread this phase — Phase 4 (#2873) is its first consumer. See ADR-3660.
### Runtime Artifact Conversion Module
Sibling Module to Runtime Artifact Layout Module. Owns projection from canonical Claude-authored command/agent/skill markdown into runtime-specific artifact bodies, including converter selection, frontmatter/body normalization, runtime path rewrites, and staged artifact generation. Runtime Artifact Layout remains responsible for filesystem placement (`kind`, destination subpath, prefix, nesting); Runtime Artifact Conversion owns the content Implementation behind that placement seam so install, uninstall/surface parity, and future plugin/package projections stop reaching back through `bin/install.js` for converter functions or `GSD_TEST_MODE`-guarded installer exports. Chosen direction: sibling Module, not an expanded Layout Module, to preserve ADR-3660's narrow placement responsibility while deepening artifact content locality. First slice: relocate only the layout-reached conversion family (`convertClaudeCommandTo*Skill`, converted command-file emitters, `buildKimiAgentArtifacts`) plus the minimal helper closure they need; do not leave helper dependencies in `bin/install.js` because that would preserve the same shallow seam under a new filename. Installer integration decision: `bin/install.js` imports the conversion Module at top level and re-exports the moved names for compatibility; the conversion Module must not import `bin/install.js` or Runtime Artifact Layout, so the dependency direction becomes installer/layout Adapters -> conversion Module, never conversion -> installer. First-slice Interface decision: export the existing compatibility names only; do not introduce a grouped `convertRuntimeArtifact` Interface until after relocation proves byte-for-byte behavior. SHIPPED (ADR-1508): the converter family relocated in #1510 Phase 1 (`getDirName`→runtime-name-policy, `processAttribution` here); #1511 Phase 2 moved the content-rewrite engine here in full — `_applyRuntimeRewrites` (per-runtime switch, injected attribution), the staged-content walkers `applyRuntimeContentRewritesInPlace`/`applyRuntimeContentRewritesForCommandsInPlace`, `computePathPrefix` (private; `_computePathPrefix` for tests), and the deep public seam `rewriteStagedSkillBodies`/`rewriteStagedCommandBodies({runtime,configDir,scope,homedir?,platform?,resolveAttribution?})`. `bin/install.js` binds these back (single owner, exports preserved); `getCommitAttribution` stays in `bin/install.js` (impure install-time config I/O) and is injected. The `getInstallExports` relay in Runtime Artifact Layout Module was deleted; the dependency direction installer/layout → conversion (never upward) is now enforced. Exception: opencode and kilo path-prefix rewriting is a deliberate `bin/install.js`-owned pre-conversion step (`applyOpencodeFamilyPathPrefix`) per #784, not a violation of the single-owner rule. Source: `gsd-core/bin/lib/runtime-artifact-conversion.cjs` (generated from `src/runtime-artifact-conversion.cts`). Also exports `resolveVersionFrom(libDir)` — a lazy, defensive GSD-version resolver (installed-tree `gsd-core/VERSION` first, then the source/npm `package.json` three dirs up, both validated against the repo's shared semver-prefix shape, degrading to `''` on failure) that replaced a module-load-time `require('../../../package.json')` which crashed on runtimes whose root carries no `package.json` (e.g. Codex) (#1383).

View File

@@ -64,6 +64,7 @@
}
]
},
"triggerPrecedence": ["skills", "commands"],
"commandStyle": "slash-hyphen",
"hooksSurface": "settings-json",
"hookEvents": "gemini",

View File

@@ -73,6 +73,7 @@
}
]
},
"triggerPrecedence": ["skills", "commands"],
"commandStyle": "slash-hyphen",
"hooksSurface": "settings-json",
"hookEvents": "claude",

View File

@@ -49,6 +49,7 @@
}
]
},
"triggerPrecedence": ["skills", "commands"],
"commandStyle": "slash-hyphen",
"hooksSurface": "settings-json",
"hookEvents": "claude",

View File

@@ -32,6 +32,7 @@
],
"local": []
},
"triggerPrecedence": ["skills", "commands"],
"commandStyle": "slash-hyphen",
"hooksSurface": "cline-rules",
"sandboxTier": "none",

View File

@@ -73,6 +73,7 @@
}
]
},
"triggerPrecedence": ["skills", "commands"],
"commandStyle": "slash-hyphen",
"hooksSurface": "settings-json",
"hookEvents": "claude",

View File

@@ -42,6 +42,7 @@
}
]
},
"triggerPrecedence": ["skills", "commands"],
"commandStyle": "shell-var",
"hooksSurface": "codex-hooks-json",
"hookEvents": "claude",

View File

@@ -58,6 +58,7 @@
}
]
},
"triggerPrecedence": ["skills", "commands"],
"commandStyle": "slash-hyphen",
"hooksSurface": "copilot-inline",
"sandboxTier": "none",

View File

@@ -57,6 +57,7 @@
}
]
},
"triggerPrecedence": ["skills", "commands"],
"commandStyle": "slash-hyphen",
"hooksSurface": "cursor-hooks-json",
"hookEvents": "claude",

View File

@@ -41,6 +41,7 @@
}
]
},
"triggerPrecedence": ["skills", "commands"],
"commandStyle": "slash-hyphen",
"hooksSurface": "settings-json",
"hookEvents": "claude",

View File

@@ -64,6 +64,7 @@
}
]
},
"triggerPrecedence": ["skills", "commands"],
"commandStyle": "slash-hyphen",
"hooksSurface": "none",
"extensionEvents": "kilo",

View File

@@ -36,6 +36,7 @@
],
"local": []
},
"triggerPrecedence": ["skills", "commands"],
"commandStyle": "slash-hyphen",
"hooksSurface": "kimi-hooks-toml",
"hookEvents": "claude",

View File

@@ -45,6 +45,7 @@
],
"local": []
},
"triggerPrecedence": ["skills", "commands"],
"commandStyle": "slash-hyphen",
"hooksSurface": "kimi-hooks-toml",
"hookEvents": "claude",

View File

@@ -59,6 +59,7 @@
}
]
},
"triggerPrecedence": ["skills", "commands"],
"commandStyle": "slash-hyphen",
"hooksSurface": "none",
"extensionEvents": "opencode",

View File

@@ -24,6 +24,7 @@
"global": [],
"local": []
},
"triggerPrecedence": ["skills", "commands"],
"commandStyle": "slash-hyphen",
"hooksSurface": "none",
"extensionEvents": "pi",

View File

@@ -57,6 +57,7 @@
}
]
},
"triggerPrecedence": ["skills", "commands"],
"commandStyle": "slash-hyphen",
"hooksSurface": "settings-json",
"hookEvents": "claude",

View File

@@ -57,6 +57,7 @@
}
]
},
"triggerPrecedence": ["skills", "commands"],
"commandStyle": "slash-hyphen",
"hooksSurface": "none",
"sandboxTier": "none",

View File

@@ -21,6 +21,7 @@
"global": [],
"local": []
},
"triggerPrecedence": ["skills", "commands"],
"commandStyle": "slash-hyphen",
"hooksSurface": "none",
"extensionEvents": "none",

View File

@@ -50,6 +50,7 @@
}
]
},
"triggerPrecedence": ["skills", "commands"],
"commandStyle": "slash-hyphen",
"hooksSurface": "windsurf-hooks-json",
"sandboxTier": "none",

View File

@@ -73,6 +73,7 @@
}
]
},
"triggerPrecedence": ["skills", "commands"],
"commandStyle": "slash-hyphen",
"hooksSurface": "none",
"sandboxTier": "none",

View File

@@ -8,7 +8,7 @@
- **Materializes:** [ADR-58](58-runtime-install-policy-module.md) (the typed `InstallPlan` projection)
- **Builds on:** [ADR-3660](3660-runtime-artifact-layout-module.md) (artifact layout), [ADR-894](894-capability-declaration-format.md) (the `role: runtime` body, already validated)
- **Subsumed by:** [ADR-1239](1239-gsd-embeddable-orchestration-engine.md) (GSD as an Embeddable Orchestration Engine) — read it first; see the amendment below
- **Amended by:** [ADR-2866](2866-install-surface-resolution.md) (Install-surface resolution) — **one axis is added to this ADR's closed descriptor vocabulary: host trigger precedence.** ADR-2866 is the review this ADR's closed-vocabulary friction exists to force. The axis is *required-with-default*, so descriptors authored against today's schema keep working and [ADR-894](894-capability-declaration-format.md)'s additive-only contract holds; the registry generator and validator move with it. **Timing:** the decision is recorded and `Accepted`; the descriptor schema itself changes at epic [#2866](https://github.com/open-gsd/gsd-core/issues/2866) Phase 2 ([#2871](https://github.com/open-gsd/gsd-core/issues/2871)), not before. Nothing else in this ADR's vocabulary opens — precedence is a fact about the *host*, which is exactly why it belongs on the descriptor rather than in a per-runtime branch.
- **Amended by:** [ADR-2866](2866-install-surface-resolution.md) (Install-surface resolution) — **one axis is added to this ADR's closed descriptor vocabulary: host trigger precedence.** ADR-2866 is the review this ADR's closed-vocabulary friction exists to force. The axis is *required-with-default*, so descriptors authored against today's schema keep working and [ADR-894](894-capability-declaration-format.md)'s additive-only contract holds; the registry generator and validator move with it. **Timing:** Phase 2 ([#2871](https://github.com/open-gsd/gsd-core/issues/2871)) of epic [#2866](https://github.com/open-gsd/gsd-core/issues/2866) has landed — `triggerPrecedence` now exists on all 19 runtime descriptors, validated required-with-default so a descriptor omitting it still validates. Nothing else in this ADR's vocabulary opens — precedence is a fact about the *host*, which is exactly why it belongs on the descriptor rather than in a per-runtime branch.
- **Amended by:** [ADR-2782](2782-reviewer-lane-capability-surface.md) (Reviewer Lane capability surface) — a `role: "runtime"` capability may now carry a `reviewer` body **alongside** its runtime body. The runtime body itself remains closed and unchanged, and no feature-only field becomes permissible on it. ADR-2782 D6 **upholds** this ADR's closed-vocabulary principle: the lane's `handler` is a closed enum of first-party names (the `ConverterName` construction of Decision 3), never an open escape hatch, so §Alternatives #2 stands unreversed.
- **Amended by:** [#2801](https://github.com/open-gsd/gsd-core/issues/2801) (closes `hostBehaviors`) — the one hole in this ADR's closure is closed; see the amendment below.

View File

@@ -3,9 +3,51 @@
- **Status:** Accepted
- **Date:** 2026-08-09
- **Issue:** [#2866](https://github.com/open-gsd/gsd-core/issues/2866) (epic); Phase 0 tracked by [#2869](https://github.com/open-gsd/gsd-core/issues/2869)
- **Amends:** [ADR-3660](3660-runtime-artifact-layout-module.md) (widens the Runtime Artifact Layout Module from placement-only to placement **+** trigger resolution) and [ADR-1016](1016-runtime-capability-descriptor.md) (adds one axis — host trigger precedence — to its closed descriptor vocabulary). Neither is superseded; both remain `Accepted` and live, and both carry the reciprocal `Amended by` field. The decision is recorded now; the modules change at Phase 2 — see [Reciprocal amendment notes](#reciprocal-amendment-notes).
- **Amends:** [ADR-3660](3660-runtime-artifact-layout-module.md) (widens the Runtime Artifact Layout Module from placement-only to placement **+** trigger resolution) and [ADR-1016](1016-runtime-capability-descriptor.md) (adds one axis — host trigger precedence — to its closed descriptor vocabulary). Neither is superseded; both remain `Accepted` and live, and both carry the reciprocal `Amended by` field. Both widenings shipped in Phase 2 ([#2871](https://github.com/open-gsd/gsd-core/issues/2871)) — see [Reciprocal amendment notes](#reciprocal-amendment-notes).
- **Relationship to prior work:** *completes* [ADR-58](58-runtime-install-policy-module.md) rather than revising it (its rollout's cleanup step never landed); preserves [ADR-1508](1508-runtime-artifact-conversion-module.md)'s dependency direction (installer/layout → conversion, never upward); Phase 3's manifest schema bump is [ADR-0008](0008-installer-migration-module.md) territory; Phase 2's schema change is additive-with-default per [ADR-894](894-capability-declaration-format.md). Like the adapters it touches, this ADR sits **beneath** [ADR-1239](1239-gsd-embeddable-orchestration-engine.md) (EoS) — it widens one negotiated surface of the Host-Integration Interface; it does not re-answer how GSD meets a host.
## Amendment (2026-08-10): `agents` is not a trigger-bearing kind — claude's disjointness is `commands` vs `skills`, not `commands, agents` vs `skills`
**Phase 2 (#2871) found, while implementing `resolveTriggerSurface`, that the Context table above
(row `claude`) and this ADR's own prose both mis-describe claude's local scope.** `local=[commands,
agents]` is correct as a *placement* fact — both kinds are emitted locally — but the row's label,
"the only runtime whose scopes emit **disjoint trigger-bearing kinds**", overstates it: `agents` is
not trigger-bearing at all.
- [`docs/reference/host-integration-capability-matrix.md`](../reference/host-integration-capability-matrix.md)
already models `command` and `dispatch` as two **separate** interface points — `command` is
"slash-command routing and invocation", `dispatch` is "subagent/multi-agent dispatch". Claude's
own row cites `dispatch.namedDispatch: true` with the evidence `agents: { … subagent_type:
block.inp }`: an agent is invoked through the Agent/Task tool's `subagent_type`, not by a user
typing `/gsd-<name>`.
- `_copyStaged` (`install-engine.cts:404-493`) never applies `kind.prefix` to an `agents` kind — the
filename passes through verbatim (L474-483). An agent stem carries `gsd-` because the *source
file* is named `gsd-planner.md`, a filesystem convention, not a trigger registration.
**Consequence for this ADR:** the claude row's shape is `global=[skills]`, `local=[commands,
agents]` unchanged (placement), but the trigger-bearing collision it describes is strictly
**`commands` vs `skills`** — `agents` plays no part in it. `resolveTriggerSurface`
(`runtime-artifact-layout.cts`, Phase 2) returns `commands` and `skills` only; `agents` and
`kimi-agents` are absent from its output entirely.
**#2218 itself is unaffected by this correction.** Claude global emits `skills`; claude local emits
`commands`; both derive from the same `commands/gsd/*.md` stems, so the entire local `/gsd-*`
**trigger** surface is still fully shadowed exactly as this ADR's Context section describes — "the
whole local surface vanishes rather than merely being overridden" remains true as written, because
it is scoped to the `/gsd-*` trigger surface, and that surface never included `agents` in the first
place. What changes is precision, not outcome: the local *agents* surface (subagent dispatch) is a
different interface point, is not shadowed by the global skills install, and this ADR should not
have implied it was.
This correction is also why `windsurf`'s row above reads correctly without amendment: its
`global=[agents]` already correctly describes "no command trigger" (agents were never counted as
one), which is exactly the case Phase 2's test suite locks in as "windsurf must not report a shadow
it does not have."
No decision in this ADR changes as a result — Phase 2's `resolveTriggerSurface` signature, the
`triggerPrecedence` axis, and the phase map above were all designed against the corrected model.
See `.gsd/phase/feat-2871-trigger-resolution/40-design.md` for the full analysis.
## Context
[#2218](https://github.com/open-gsd/gsd-core/issues/2218) is the presenting defect: a user who installs the Claude runtime at **both** scopes — `--claude --global` and `--claude --local` — silently loses 100% of the project-local `/gsd-*` surface. It is "every time — 100% reproducible", the reporter's impact assessment is "major — core feature is broken, no workaround", and its triage stalled at `ready-for-human` because every proposed remediation read as a product decision bolted onto the installer.
@@ -111,7 +153,7 @@ Recorded explicitly, because an ADR that quietly ratifies these would be launder
[ADR-3660](3660-runtime-artifact-layout-module.md) and [ADR-1016](1016-runtime-capability-descriptor.md) each carry an `Amended by: ADR-2866` back-reference, added in this same PR — the corpus's established practice for an amendment relation ([ADR-1016](1016-runtime-capability-descriptor.md) already carries the equivalent field for [ADR-2782](2782-reviewer-lane-capability-surface.md)). A one-way pointer is the failure mode this corpus has actually suffered: a reader landing on the amended file learns nothing about the decision that moved it.
Each back-reference states **when the widening takes effect** — the decision is recorded now (this ADR is `Accepted`); the shipped modules still resolve placement only until Phase 2 ([#2871](https://github.com/open-gsd/gsd-core/issues/2871)) lands. Recording the relation without that timing note would tell a reader the layout module already resolves triggers, which would be false for four phases.
Each back-reference states **when the widening takes effect** — the decision was recorded when this ADR became `Accepted`, while the shipped modules still resolved placement only until Phase 2 ([#2871](https://github.com/open-gsd/gsd-core/issues/2871)) landed; both back-references now describe a widening that has actually shipped. Recording the relation without that timing note would have told a reader the layout module already resolved triggers before it did, which would have been false for four phases.
*Mechanical note for future readers:* `scripts/gen-adr-index.cjs` tracks only `Supersedes`/`Subsumes` and their inverses. **`Amends` is not machine-checked in either direction** — the back-links above are a convention this ADR honors deliberately, not something the gate would have caught had they been omitted.

View File

@@ -5,7 +5,7 @@
- **Issue:** #3660
- **Implementation:** #3663 (Phase 1), feat/3663-runtime-artifact-layout-module-phase-1-m
- **Subsumed by:** [ADR-1239](1239-gsd-embeddable-orchestration-engine.md) (GSD as an Embeddable Orchestration Engine) — read it first; see the amendment below
- **Amended by:** [ADR-2866](2866-install-surface-resolution.md) (Install-surface resolution) — **this module widens from *placement* to *placement + trigger resolution*.** The `Layout` returned here models where a file goes but not the `/gsd-<name>` trigger it occupies, so nothing in the tree can express the cross-scope collision in [#2218](https://github.com/open-gsd/gsd-core/issues/2218). ADR-2866 adds a projection returning the resolved trigger, its kind and scope, its destination, and whether another install shadows it. **This ADR's placement decision is unchanged and still in force** — `resolveRuntimeArtifactLayout` stays for callers that only need placement, the per-runtime table remains the single owner of placement knowledge, and legacy-layout migrations stay in [ADR-0008](0008-installer-migration-module.md). **Timing:** the decision is recorded and `Accepted`; the module changes at epic [#2866](https://github.com/open-gsd/gsd-core/issues/2866) Phase 2 ([#2871](https://github.com/open-gsd/gsd-core/issues/2871)), not before — until then this module resolves placement only.
- **Amended by:** [ADR-2866](2866-install-surface-resolution.md) (Install-surface resolution) — **this module widens from *placement* to *placement + trigger resolution*.** The `Layout` returned here models where a file goes but not the `/gsd-<name>` trigger it occupies, so nothing in the tree can express the cross-scope collision in [#2218](https://github.com/open-gsd/gsd-core/issues/2218). ADR-2866 adds a projection returning the resolved trigger, its kind and scope, its destination, and whether another install shadows it. **This ADR's placement decision is unchanged and still in force** — `resolveRuntimeArtifactLayout` stays for callers that only need placement, the per-runtime table remains the single owner of placement knowledge, and legacy-layout migrations stay in [ADR-0008](0008-installer-migration-module.md). **Timing:** Phase 2 ([#2871](https://github.com/open-gsd/gsd-core/issues/2871)) of epic [#2866](https://github.com/open-gsd/gsd-core/issues/2866) has landed — the module now resolves placement **and** triggers via the new `resolveTriggerSurface` function; `resolveRuntimeArtifactLayout` is unchanged for callers that need placement only.
## Amendment (2026-07-16): subsumed by ADR-1239 (EoS)

View File

@@ -48,6 +48,23 @@ consumed verbatim by `gen:capability-registry` and validated by `capability-vali
| `state` | Filesystem/state I/O capability. |
| `artifact` | Artifact delivery (skills, commands) surface capability. |
### Trigger precedence (#2871 Phase 2 — adjacent to, not part of, `hostIntegration`)
`runtime.triggerPrecedence` (an ordered list of trigger-bearing kind names, highest priority
first) is declared as a sibling of `hostIntegration` in `capability.json`'s `runtime` body, not
inside it — it is not researched per-CLI documentation the way the axes above are, so it carries
no per-host `Source`/`Evidence` row. Only `commands` and `skills` are members of the vocabulary;
`agents`/`kimi-agents` are excluded because they are not trigger-bearing (a `/gsd-<name>` a user
types) — an agent is invoked through named/`subagent_type` dispatch, the separate `dispatch`
interface point above, never through the `command` interface point. Every shipped runtime
descriptor declares the same value, `["skills", "commands"]` (skills wins a same-scope collision),
matching `capability-validator.cjs`'s `DEFAULT_TRIGGER_PRECEDENCE` — the axis is
required-with-default (absence resolves to that default) so a third-party descriptor authored
before this phase keeps validating unchanged. `runtime-artifact-layout.cts`'s
`resolveTriggerSurface` reads it to decide the winner among same-trigger candidates once scope
rank (Install Scope Module) has already been applied. See CONTEXT.md's Runtime Artifact Layout
Module entry and `.gsd/phase/feat-2871-trigger-resolution/40-design.md`.
---
## claude

View File

@@ -158,6 +158,10 @@ const capabilities = {
}
]
},
"triggerPrecedence": [
"skills",
"commands"
],
"commandStyle": "slash-hyphen",
"hooksSurface": "settings-json",
"hookEvents": "gemini",
@@ -393,6 +397,10 @@ const capabilities = {
}
]
},
"triggerPrecedence": [
"skills",
"commands"
],
"commandStyle": "slash-hyphen",
"hooksSurface": "settings-json",
"hookEvents": "claude",
@@ -524,6 +532,10 @@ const capabilities = {
}
]
},
"triggerPrecedence": [
"skills",
"commands"
],
"commandStyle": "slash-hyphen",
"hooksSurface": "settings-json",
"hookEvents": "claude",
@@ -741,6 +753,10 @@ const capabilities = {
],
"local": []
},
"triggerPrecedence": [
"skills",
"commands"
],
"commandStyle": "slash-hyphen",
"hooksSurface": "cline-rules",
"sandboxTier": "none",
@@ -914,6 +930,10 @@ const capabilities = {
}
]
},
"triggerPrecedence": [
"skills",
"commands"
],
"commandStyle": "slash-hyphen",
"hooksSurface": "settings-json",
"hookEvents": "claude",
@@ -1038,6 +1058,10 @@ const capabilities = {
}
]
},
"triggerPrecedence": [
"skills",
"commands"
],
"commandStyle": "shell-var",
"hooksSurface": "codex-hooks-json",
"hookEvents": "claude",
@@ -1190,6 +1214,10 @@ const capabilities = {
}
]
},
"triggerPrecedence": [
"skills",
"commands"
],
"commandStyle": "slash-hyphen",
"hooksSurface": "copilot-inline",
"sandboxTier": "none",
@@ -1284,6 +1312,10 @@ const capabilities = {
}
]
},
"triggerPrecedence": [
"skills",
"commands"
],
"commandStyle": "slash-hyphen",
"hooksSurface": "cursor-hooks-json",
"hookEvents": "claude",
@@ -1709,6 +1741,10 @@ const capabilities = {
}
]
},
"triggerPrecedence": [
"skills",
"commands"
],
"commandStyle": "slash-hyphen",
"hooksSurface": "settings-json",
"hookEvents": "claude",
@@ -1875,6 +1911,10 @@ const capabilities = {
}
]
},
"triggerPrecedence": [
"skills",
"commands"
],
"commandStyle": "slash-hyphen",
"hooksSurface": "none",
"extensionEvents": "kilo",
@@ -1965,6 +2005,10 @@ const capabilities = {
],
"local": []
},
"triggerPrecedence": [
"skills",
"commands"
],
"commandStyle": "slash-hyphen",
"hooksSurface": "kimi-hooks-toml",
"hookEvents": "claude",
@@ -2054,6 +2098,10 @@ const capabilities = {
],
"local": []
},
"triggerPrecedence": [
"skills",
"commands"
],
"commandStyle": "slash-hyphen",
"hooksSurface": "kimi-hooks-toml",
"hookEvents": "claude",
@@ -2607,6 +2655,10 @@ const capabilities = {
}
]
},
"triggerPrecedence": [
"skills",
"commands"
],
"commandStyle": "slash-hyphen",
"hooksSurface": "none",
"extensionEvents": "opencode",
@@ -2782,6 +2834,10 @@ const capabilities = {
"global": [],
"local": []
},
"triggerPrecedence": [
"skills",
"commands"
],
"commandStyle": "slash-hyphen",
"hooksSurface": "none",
"extensionEvents": "pi",
@@ -2957,6 +3013,10 @@ const capabilities = {
}
]
},
"triggerPrecedence": [
"skills",
"commands"
],
"commandStyle": "slash-hyphen",
"hooksSurface": "settings-json",
"hookEvents": "claude",
@@ -3409,6 +3469,10 @@ const capabilities = {
}
]
},
"triggerPrecedence": [
"skills",
"commands"
],
"commandStyle": "slash-hyphen",
"hooksSurface": "none",
"sandboxTier": "none",
@@ -3561,6 +3625,10 @@ const capabilities = {
"global": [],
"local": []
},
"triggerPrecedence": [
"skills",
"commands"
],
"commandStyle": "slash-hyphen",
"hooksSurface": "none",
"extensionEvents": "none",
@@ -3643,6 +3711,10 @@ const capabilities = {
}
]
},
"triggerPrecedence": [
"skills",
"commands"
],
"commandStyle": "slash-hyphen",
"hooksSurface": "windsurf-hooks-json",
"sandboxTier": "none",
@@ -3753,6 +3825,10 @@ const capabilities = {
}
]
},
"triggerPrecedence": [
"skills",
"commands"
],
"commandStyle": "slash-hyphen",
"hooksSurface": "none",
"sandboxTier": "none",
@@ -4926,6 +5002,10 @@ const runtimes = {
}
]
},
"triggerPrecedence": [
"skills",
"commands"
],
"commandStyle": "slash-hyphen",
"hooksSurface": "settings-json",
"hookEvents": "gemini",
@@ -5078,6 +5158,10 @@ const runtimes = {
}
]
},
"triggerPrecedence": [
"skills",
"commands"
],
"commandStyle": "slash-hyphen",
"hooksSurface": "settings-json",
"hookEvents": "claude",
@@ -5163,6 +5247,10 @@ const runtimes = {
}
]
},
"triggerPrecedence": [
"skills",
"commands"
],
"commandStyle": "slash-hyphen",
"hooksSurface": "settings-json",
"hookEvents": "claude",
@@ -5292,6 +5380,10 @@ const runtimes = {
],
"local": []
},
"triggerPrecedence": [
"skills",
"commands"
],
"commandStyle": "slash-hyphen",
"hooksSurface": "cline-rules",
"sandboxTier": "none",
@@ -5404,6 +5496,10 @@ const runtimes = {
}
]
},
"triggerPrecedence": [
"skills",
"commands"
],
"commandStyle": "slash-hyphen",
"hooksSurface": "settings-json",
"hookEvents": "claude",
@@ -5486,6 +5582,10 @@ const runtimes = {
}
]
},
"triggerPrecedence": [
"skills",
"commands"
],
"commandStyle": "shell-var",
"hooksSurface": "codex-hooks-json",
"hookEvents": "claude",
@@ -5638,6 +5738,10 @@ const runtimes = {
}
]
},
"triggerPrecedence": [
"skills",
"commands"
],
"commandStyle": "slash-hyphen",
"hooksSurface": "copilot-inline",
"sandboxTier": "none",
@@ -5732,6 +5836,10 @@ const runtimes = {
}
]
},
"triggerPrecedence": [
"skills",
"commands"
],
"commandStyle": "slash-hyphen",
"hooksSurface": "cursor-hooks-json",
"hookEvents": "claude",
@@ -5864,6 +5972,10 @@ const runtimes = {
}
]
},
"triggerPrecedence": [
"skills",
"commands"
],
"commandStyle": "slash-hyphen",
"hooksSurface": "settings-json",
"hookEvents": "claude",
@@ -5978,6 +6090,10 @@ const runtimes = {
}
]
},
"triggerPrecedence": [
"skills",
"commands"
],
"commandStyle": "slash-hyphen",
"hooksSurface": "none",
"extensionEvents": "kilo",
@@ -6068,6 +6184,10 @@ const runtimes = {
],
"local": []
},
"triggerPrecedence": [
"skills",
"commands"
],
"commandStyle": "slash-hyphen",
"hooksSurface": "kimi-hooks-toml",
"hookEvents": "claude",
@@ -6157,6 +6277,10 @@ const runtimes = {
],
"local": []
},
"triggerPrecedence": [
"skills",
"commands"
],
"commandStyle": "slash-hyphen",
"hooksSurface": "kimi-hooks-toml",
"hookEvents": "claude",
@@ -6312,6 +6436,10 @@ const runtimes = {
}
]
},
"triggerPrecedence": [
"skills",
"commands"
],
"commandStyle": "slash-hyphen",
"hooksSurface": "none",
"extensionEvents": "opencode",
@@ -6433,6 +6561,10 @@ const runtimes = {
"global": [],
"local": []
},
"triggerPrecedence": [
"skills",
"commands"
],
"commandStyle": "slash-hyphen",
"hooksSurface": "none",
"extensionEvents": "pi",
@@ -6531,6 +6663,10 @@ const runtimes = {
}
]
},
"triggerPrecedence": [
"skills",
"commands"
],
"commandStyle": "slash-hyphen",
"hooksSurface": "settings-json",
"hookEvents": "claude",
@@ -6666,6 +6802,10 @@ const runtimes = {
}
]
},
"triggerPrecedence": [
"skills",
"commands"
],
"commandStyle": "slash-hyphen",
"hooksSurface": "none",
"sandboxTier": "none",
@@ -6723,6 +6863,10 @@ const runtimes = {
"global": [],
"local": []
},
"triggerPrecedence": [
"skills",
"commands"
],
"commandStyle": "slash-hyphen",
"hooksSurface": "none",
"extensionEvents": "none",
@@ -6805,6 +6949,10 @@ const runtimes = {
}
]
},
"triggerPrecedence": [
"skills",
"commands"
],
"commandStyle": "slash-hyphen",
"hooksSurface": "windsurf-hooks-json",
"sandboxTier": "none",
@@ -6915,6 +7063,10 @@ const runtimes = {
}
]
},
"triggerPrecedence": [
"skills",
"commands"
],
"commandStyle": "slash-hyphen",
"hooksSurface": "none",
"sandboxTier": "none",

View File

@@ -745,6 +745,23 @@ const VALID_EXTENSION_EVENTS = new Set(['opencode', 'pi', 'hermes', 'kilo', 'non
const VALID_SANDBOX_TIERS = new Set(['none', 'codex-agent-sandbox']);
const VALID_ARTIFACT_KIND_NAMES = new Set(['commands', 'agents', 'skills', 'kimi-agents']);
const VALID_ARTIFACT_NESTINGS = new Set(['flat', 'nested']);
// #2871 Phase 2 — only `commands` and `skills` are trigger-bearing (a `/gsd-<name>`
// the USER types). `agents` and `kimi-agents` are a separate dispatch interface
// point (subagent invocation via `subagent_type`/named dispatch), never a trigger —
// see 40-design.md's "agents are not trigger-bearing" correction. A narrower set
// than VALID_ARTIFACT_KIND_NAMES on purpose: an artifact KIND can be agents; a
// trigger-precedence MEMBER never can.
const VALID_TRIGGER_PRECEDENCE_KINDS = new Set(['commands', 'skills']);
// The default runtime.triggerPrecedence (highest priority first) applied when a
// descriptor omits the axis (see validateRuntimeBody's required-with-default
// handling below). Not invented: `['skills', 'commands']` is the ordering every
// in-tree runtime that emits both kinds (from the same trigger stems) wants —
// claude's local/global collision and the same-scope collision (codebuddy, kilo,
// opencode, zcode) all resolve to skills winning. claude's shipped descriptor
// declares this SAME value explicitly, and
// tests/runtime-artifact-layout-trigger-surface.test.cjs asserts the two agree
// (a parity assertion — two surfaces reading one rule must not silently drift).
const DEFAULT_TRIGGER_PRECEDENCE = Object.freeze(['skills', 'commands']);
const FEATURE_FIELDS_FORBIDDEN_ON_RUNTIME = ['skills', 'agents', 'steps', 'contributions', 'gates', 'hooks', 'activationKey'];
// 'none' added #2103 — Marketplace/VSIX-distributed hosts (e.g. VS Code) that
// are never CLI-installed (no allRuntimes membership, no install flag).
@@ -1557,6 +1574,45 @@ function validateRuntimeBody(cap) {
}
}
// triggerPrecedence — #2871 Phase 2 amendment to ADR-1016's runtime body.
// REQUIRED-WITH-DEFAULT: no other axis in this validator uses this shape —
// every axis above either hard-requires the field (throws when absent) or
// treats absence as fully unconstrained (effortSurface/isolation: "nothing to
// validate" when undefined). This axis does neither: an ABSENT value is
// substituted with DEFAULT_TRIGGER_PRECEDENCE and then validated exactly as
// if it HAD been supplied, so a third-party capability.json authored before
// this phase keeps validating (ADR-894 additive-only / Hyrum's Law, ADR-1244)
// while a PRESENT-but-malformed value still fails loudly instead of silently
// passing through unchecked.
const triggerPrecedenceValue = Object.prototype.hasOwnProperty.call(r, 'triggerPrecedence')
? r.triggerPrecedence
: DEFAULT_TRIGGER_PRECEDENCE;
if (!Array.isArray(triggerPrecedenceValue)) {
errors.push(
'runtime.triggerPrecedence must be an array of trigger-bearing kind names (' +
[...VALID_TRIGGER_PRECEDENCE_KINDS].join(', ') + ') (got: ' + JSON.stringify(triggerPrecedenceValue) + ')',
);
} else if (triggerPrecedenceValue.length === 0) {
errors.push('runtime.triggerPrecedence must not be empty');
} else {
const seenKinds = new Set();
for (let i = 0; i < triggerPrecedenceValue.length; i++) {
const k = triggerPrecedenceValue[i];
if (k === '__proto__' || k === 'constructor' || k === 'prototype') {
errors.push('runtime.triggerPrecedence[' + i + '] "' + k + '" is a reserved name');
} else if (typeof k !== 'string' || !VALID_TRIGGER_PRECEDENCE_KINDS.has(k)) {
errors.push(
'runtime.triggerPrecedence[' + i + '] must be one of: ' + [...VALID_TRIGGER_PRECEDENCE_KINDS].join(', ') +
' (got: ' + JSON.stringify(k) + ')',
);
} else if (seenKinds.has(k)) {
errors.push('runtime.triggerPrecedence contains a duplicate kind: ' + JSON.stringify(k));
} else {
seenKinds.add(k);
}
}
}
return errors;
}
@@ -3438,6 +3494,8 @@ module.exports = {
VALID_SANDBOX_TIERS,
VALID_ARTIFACT_KIND_NAMES,
VALID_ARTIFACT_NESTINGS,
VALID_TRIGGER_PRECEDENCE_KINDS,
DEFAULT_TRIGGER_PRECEDENCE,
FEATURE_FIELDS_FORBIDDEN_ON_RUNTIME,
// ADR-2782 D1/D2/D3/D6/D7/D8 — reviewer lane body
FEATURE_FIELDS_FORBIDDEN_ON_REVIEWER,

View File

@@ -56,9 +56,10 @@
"runtime-artifact-layout-descriptor-drive.test.cjs",
"runtime-artifact-layout-install-profiles.test.cjs",
"runtime-artifact-layout-surface.test.cjs",
"runtime-artifact-layout-trigger-surface.test.cjs",
"runtime-artifact-layout.test.cjs"
],
"issue": "TBD"
"issue": "2871"
},
"security": {
"files": [

View File

@@ -461,9 +461,10 @@ function _copyStaged(stagedDir: string, destDir: string, kind: any, configDir: s
const entries = fs.readdirSync(stagedDir, { withFileTypes: true });
// For commands: apply prefix unless the destSubpath's last segment already
// represents the GSD namespace (e.g. 'commands/gsd' → last segment 'gsd').
const destLast = path.basename(kind.destSubpath);
const prefixStem = kind.prefix ? kind.prefix.replace(/-$/, '') : '';
const namespacedByDir = kind.kind === 'commands' && destLast === prefixStem;
// Single source of truth: runtimeArtifactLayout.isNamespacedByDir (#2871
// Phase 2 review finding — this rule previously drifted independently
// across install-engine.cts / surface.cts / runtime-artifact-layout.cts).
const namespacedByDir = runtimeArtifactLayout.isNamespacedByDir(kind.kind, kind.destSubpath, kind.prefix);
for (const entry of entries) {
if (!entry.isFile()) continue;
@@ -481,12 +482,14 @@ function _copyStaged(stagedDir: string, destDir: string, kind: any, configDir: s
destName = _agentExt
? entry.name.replace(/\.md$/, _agentExt)
: entry.name;
} else if (namespacedByDir) {
// Directory is the namespace; don't double-prefix the filename
destName = entry.name;
} else {
// Flat commands directory (e.g. command/ for opencode/kilo)
destName = `${kind.prefix}${stem}.md`;
// Commands: filename composition (namespacedByDir ? `${stem}.md` :
// `${prefix}${stem}.md`) is single-sourced with resolveTriggerSurface's
// destPath prediction via composeCommandFilename (#2871 Phase 2 review
// finding). Byte-identical to the prior separate namespacedByDir/flat
// branches — see that helper's doc comment for why the namespacedByDir
// case reconstructing `${stem}.md` is always exactly `entry.name`.
destName = runtimeArtifactLayout.composeCommandFilename(namespacedByDir, kind.prefix, stem);
}
fs.copyFileSync(path.join(stagedDir, entry.name), path.join(destDir, destName));

View File

@@ -115,12 +115,15 @@ export interface ResolveScopeInput {
const VALID_SCOPE_IDS: ReadonlySet<string> = new Set(['global', 'local']);
/**
* Single owner of the `'global' | 'local'` membership check. `resolveScope`
* and `isGlobalScope` (below) both call this instead of each carrying its
* own copy of the rule — two surfaces reading one validator, not two
* validators that could silently diverge.
* Single owner of the `'global' | 'local'` membership check. `resolveScope`,
* `isGlobalScope`, `scopeRank`, and `resolveTriggerSurface`
* (`runtime-artifact-layout.cts`, #2871 Phase 2) all call this instead of
* each carrying its own copy of the rule — one validator every scope-typed
* seam reads, not N validators that could silently diverge. Exported so a
* sibling module can reuse it directly rather than re-deriving the same
* membership check a second time.
*/
function validateScopeId(id: unknown, caller: string): InstallScope {
export function validateScopeId(id: unknown, caller: string): InstallScope {
if (typeof id !== 'string' || !VALID_SCOPE_IDS.has(id)) {
throw new TypeError(
`${caller}: id must be one of 'global' | 'local', got ${JSON.stringify(id)}`,
@@ -319,3 +322,19 @@ export function resolveScope(input: ResolveScopeInput): ResolvedScope {
export function isGlobalScope(scope: InstallScope): boolean {
return validateScopeId(scope, 'isGlobalScope') === 'global';
}
/**
* Project a bare `InstallScope` down to its `hostPrecedenceRank` — the SAME
* `HOST_PRECEDENCE_RANK` table `resolveScope`'s `ResolvedScope.hostPrecedenceRank`
* field reads, exposed standalone so a caller that only needs the ranking (not a
* full config-home resolution, which touches the filesystem via
* `resolveConfigHomeFromDescriptor`) never has to re-derive `{global: 2, local:
* 1}` as a second copy of the same fact. First consumer: `resolveTriggerSurface`
* (`runtime-artifact-layout.cts`, #2871 Phase 2), which is documented pure — no
* filesystem — so it cannot call `resolveScope` itself. Same validation/error
* contract as `resolveScope` / `isGlobalScope`: all three share `validateScopeId`,
* so an out-of-union `id` throws the same `TypeError` shape everywhere.
*/
export function scopeRank(id: InstallScope): number {
return HOST_PRECEDENCE_RANK[validateScopeId(id, 'scopeRank')];
}

View File

@@ -34,7 +34,7 @@ import { posixNormalize } from './shell-command-projection.cjs';
// 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 } from './install-scope.cjs';
import { isGlobalScope, scopeRank, validateScopeId, type InstallScope } from './install-scope.cjs';
// In .cts (CommonJS output) files, `require` is available as a global.
const _require: NodeRequire = require;
@@ -610,5 +610,289 @@ function resolveRuntimeArtifactLayoutFromRegistry(
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;
}
const SCOPE_ORDER: readonly InstallScope[] = ['global', 'local'];
/**
* 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 };
export = { resolveRuntimeArtifactLayout, resolveRuntimeArtifactLayoutFromRegistry, findInstallSourceRoot, resolveTriggerSurface, isNamespacedByDir, composeCommandFilename };

View File

@@ -625,13 +625,11 @@ function _syncGsdDir(stagedDir: string, destDir: string, kind: ArtifactKind | st
// from install and orphaned the installed gsd-*.md files, and the unscoped
// prune deleted user-owned command files.
//
// NOTE: the destName rule below intentionally mirrors bin/install.js
// `_copyStaged` (the `namespacedByDir` decision). Keep them in sync.
const destLast = (typeof kind === 'object' && kind !== null && kind.destSubpath)
? path.basename(kind.destSubpath)
: '';
const prefixStem = kindPrefix ? kindPrefix.replace(/-$/, '') : '';
const namespacedByDir = kindName === 'commands' && destLast === prefixStem;
// Single source of truth: runtimeArtifactLayout.isNamespacedByDir (#2871
// Phase 2 review finding — this rule previously drifted independently
// across install-engine.cts / surface.cts / runtime-artifact-layout.cts).
const kindDestSubpath = (typeof kind === 'object' && kind !== null && kind.destSubpath) ? kind.destSubpath : '';
const namespacedByDir = runtimeArtifactLayout.isNamespacedByDir(kindName, kindDestSubpath, kindPrefix);
const stagedFiles = fs.readdirSync(stagedDir).filter(f => f.endsWith('.md'));
const stagedDestNames = new Set<string>();

View File

@@ -267,13 +267,16 @@ describe('B. The six existing runtime capabilities', () => {
* vice versa) — together these catch "the sibling edit corrupted this
* body" without requiring line-for-line duplication.
*/
// #2871 Phase 2 added `triggerPrecedence` (required-with-default) to every
// runtime body — a real, deliberate key-set change, not drift. Reflected
// here in sorted position, same as every other key in these snapshots.
const EXPECTED_RUNTIME_KEYS = {
antigravity: ['artifactLayout', 'commandStyle', 'configFormat', 'configHome', 'extendedHookEvents', 'hookEvents', 'hooksSurface', 'hostBehaviors', 'hostIntegration', 'installSurface', 'localConfigDir', 'permissionWriter', 'sandboxTier', 'supportTier', 'writesSharedSettings'],
claude: ['artifactLayout', 'commandStyle', 'configFormat', 'configHome', 'extendedHookEvents', 'harnessIsolationFlag', 'hookEvents', 'hooksSurface', 'hostBehaviors', 'hostIntegration', 'installSurface', 'localConfigDir', 'permissionWriter', 'sandboxTier', 'supportTier', 'writesSharedSettings'],
codex: ['artifactLayout', 'commandStyle', 'configFormat', 'configHome', 'extendedHookEvents', 'hookEvents', 'hooksSurface', 'hostBehaviors', 'hostIntegration', 'installSurface', 'localConfigDir', 'orchestratorExec', 'permissionWriter', 'sandboxTier', 'supportTier', 'writesSharedSettings'],
cursor: ['artifactLayout', 'commandStyle', 'configFormat', 'configHome', 'extendedHookEvents', 'harnessIsolationFlag', 'hookEvents', 'hooksSurface', 'hostBehaviors', 'hostIntegration', 'installSurface', 'localConfigDir', 'permissionWriter', 'sandboxTier', 'supportTier', 'writesSharedSettings'],
opencode: ['artifactLayout', 'commandStyle', 'configFormat', 'configHome', 'extendedHookEvents', 'extensionEvents', 'hooksSurface', 'hostBehaviors', 'hostIntegration', 'installSurface', 'localConfigDir', 'orchestratorExec', 'permissionWriter', 'sandboxTier', 'supportTier', 'writesSharedSettings'],
qwen: ['artifactLayout', 'commandStyle', 'configFormat', 'configHome', 'extendedHookEvents', 'hookEvents', 'hooksSurface', 'hostBehaviors', 'hostIntegration', 'installSurface', 'localConfigDir', 'permissionWriter', 'sandboxTier', 'supportTier', 'writesSharedSettings'],
antigravity: ['artifactLayout', 'commandStyle', 'configFormat', 'configHome', 'extendedHookEvents', 'hookEvents', 'hooksSurface', 'hostBehaviors', 'hostIntegration', 'installSurface', 'localConfigDir', 'permissionWriter', 'sandboxTier', 'supportTier', 'triggerPrecedence', 'writesSharedSettings'],
claude: ['artifactLayout', 'commandStyle', 'configFormat', 'configHome', 'extendedHookEvents', 'harnessIsolationFlag', 'hookEvents', 'hooksSurface', 'hostBehaviors', 'hostIntegration', 'installSurface', 'localConfigDir', 'permissionWriter', 'sandboxTier', 'supportTier', 'triggerPrecedence', 'writesSharedSettings'],
codex: ['artifactLayout', 'commandStyle', 'configFormat', 'configHome', 'extendedHookEvents', 'hookEvents', 'hooksSurface', 'hostBehaviors', 'hostIntegration', 'installSurface', 'localConfigDir', 'orchestratorExec', 'permissionWriter', 'sandboxTier', 'supportTier', 'triggerPrecedence', 'writesSharedSettings'],
cursor: ['artifactLayout', 'commandStyle', 'configFormat', 'configHome', 'extendedHookEvents', 'harnessIsolationFlag', 'hookEvents', 'hooksSurface', 'hostBehaviors', 'hostIntegration', 'installSurface', 'localConfigDir', 'permissionWriter', 'sandboxTier', 'supportTier', 'triggerPrecedence', 'writesSharedSettings'],
opencode: ['artifactLayout', 'commandStyle', 'configFormat', 'configHome', 'extendedHookEvents', 'extensionEvents', 'hooksSurface', 'hostBehaviors', 'hostIntegration', 'installSurface', 'localConfigDir', 'orchestratorExec', 'permissionWriter', 'sandboxTier', 'supportTier', 'triggerPrecedence', 'writesSharedSettings'],
qwen: ['artifactLayout', 'commandStyle', 'configFormat', 'configHome', 'extendedHookEvents', 'hookEvents', 'hooksSurface', 'hostBehaviors', 'hostIntegration', 'installSurface', 'localConfigDir', 'permissionWriter', 'sandboxTier', 'supportTier', 'triggerPrecedence', 'writesSharedSettings'],
};
test('runtimeBodiesAreUnchangedByLaneDeclaration', () => {

View File

@@ -0,0 +1,494 @@
'use strict';
/**
* Failing-first suite for `resolveTriggerSurface` (#2871 Phase 2).
*
* `resolveTriggerSurface` does NOT exist yet in src/runtime-artifact-layout.cts
* (nor in its built gsd-core/bin/lib/runtime-artifact-layout.cjs counterpart).
* Every test below is expected to FAIL until it lands — see
* .gsd/phase/feat-2871-trigger-resolution/50-test-matrix.md (rows 1-23) and
* .gsd/phase/feat-2871-trigger-resolution/40-design.md for the contract this
* suite locks.
*
* Idiom: per-runtime `describe` + explicit field assertions, matching the
* other four runtime-artifact-layout*.test.cjs files. No table-driven format.
*
* ── Stem-injection shape (design call made by this suite) ──────────────────
* `resolveTriggerSurface` is pure — no filesystem, no `configDir`. Stems are
* injected via `opts`:
*
* opts = {
* stems: string[], // source command/skill stems
* // present for this call, shared
* // across every trigger-bearing
* // kind entry (mirrors ResolvedProfile's
* // flat stem membership at staging
* // time — see install-profiles.cts).
* routerStems?: string[], // subset of `stems` that are
* // namespace routers (nested-router
* // runtimes only — #69).
* childToRouters?: Record<string, string[]>, // concrete stem -> owning
* // router stem(s); mirrors
* // buildNamespaceBundleMap's
* // childToRouters shape.
* registry?: { runtimes: {...} }, // full registry override —
* // the SAME seam
* // resolveRuntimeArtifactLayoutFromRegistry
* // already exposes in this file. Lets a
* // synthetic descriptor (row 12's
* // namespacedByDir fixture, row 22's
* // reordered triggerPrecedence) be
* // exercised without touching the real
* // capability-registry.
* }
*
* destPath (also a design call, since no in-tree fixture exercises row 12
* today): `${destSubpath}/${prefix}${stem}` for skills (the skill directory);
* `${destSubpath}/${prefix}${stem}.md` for a flat commands entry; and, when
* `_copyStaged`'s namespacedByDir branch applies (destSubpath's basename ===
* prefix minus its trailing '-'), `${destSubpath}/${stem}.md` — bare, no
* prefix on the filename, matching _copyStaged's actual write (row 12). The
* `trigger` string itself is ALWAYS `${prefix}${stem}` regardless of branch —
* it is what the user types, not a filesystem detail.
*/
const { test, describe } = require('node:test');
const assert = require('node:assert/strict');
const fs = require('fs');
const path = require('path');
const runtimeArtifactLayout = require('../gsd-core/bin/lib/runtime-artifact-layout.cjs');
const { resolveTriggerSurface } = runtimeArtifactLayout;
const capabilityRegistry = require('../gsd-core/bin/lib/capability-registry.cjs');
const capValidator = require('../gsd-core/bin/lib/capability-validator.cjs');
const REPO_ROOT = path.join(__dirname, '..');
/** A real shipped capability.json, parsed fresh (fixture-provenance rule #2371 —
* see tests/effort-surface-axis.test.cjs's shippedDescriptorWithout for the
* precedent this mirrors: a hand-built descriptor only ever encodes the
* author's mental model, which is how required-axis defects reach the runner). */
function shippedCap(runtimeId) {
return JSON.parse(
fs.readFileSync(path.join(REPO_ROOT, 'capabilities', runtimeId, 'capability.json'), 'utf8'),
);
}
/** A real shipped capability.json with one `runtime` field genuinely deleted —
* mirrors tests/effort-surface-axis.test.cjs's shippedDescriptorWithout idiom
* (fixture-provenance rule #2371), adapted for a top-level `runtime.<field>`
* rather than a nested `runtime.hostIntegration.<axis>`. */
function shippedCapWithout(runtimeId, field) {
const cap = shippedCap(runtimeId);
delete cap.runtime[field];
return cap;
}
const VALID_KINDS = new Set(['commands', 'skills']);
const VALID_SCOPES = new Set(['global', 'local']);
const VALID_REGISTRATIONS = new Set(['direct', 'via-router']);
/** Structural + referential shape-check for a resolveTriggerSurface() result. */
function assertValidSurfaceArray(surfaces, label) {
assert.ok(Array.isArray(surfaces), `${label}: must return an array`);
for (const s of surfaces) {
assert.strictEqual(typeof s.trigger, 'string', `${label}: trigger must be a string`);
assert.ok(s.trigger.length > 0, `${label}: trigger must be non-empty`);
assert.ok(VALID_KINDS.has(s.kind), `${label}: kind must be commands|skills, got ${s.kind}`);
assert.ok(VALID_SCOPES.has(s.scope), `${label}: scope must be global|local, got ${s.scope}`);
assert.strictEqual(typeof s.destPath, 'string', `${label}: destPath must be a string`);
assert.ok(s.destPath.length > 0, `${label}: destPath must be non-empty`);
assert.ok(VALID_REGISTRATIONS.has(s.registration), `${label}: registration must be direct|via-router, got ${s.registration}`);
if (s.registration === 'via-router') {
assert.strictEqual(typeof s.routerTrigger, 'string', `${label}: via-router entries must name routerTrigger`);
} else {
assert.strictEqual(s.routerTrigger, null, `${label}: direct entries must have routerTrigger === null`);
}
if (s.shadowedBy !== null) {
assert.ok(VALID_KINDS.has(s.shadowedBy.kind), `${label}: shadowedBy.kind invalid`);
assert.ok(VALID_SCOPES.has(s.shadowedBy.scope), `${label}: shadowedBy.scope invalid`);
const sibling = surfaces.find(
(o) => o !== s && o.trigger === s.trigger && o.kind === s.shadowedBy.kind && o.scope === s.shadowedBy.scope,
);
assert.ok(sibling, `${label}: shadowedBy must name a real sibling entry for trigger ${s.trigger}`);
assert.strictEqual(sibling.shadowedBy, null, `${label}: the named shadowedBy sibling must itself be a winner`);
}
}
}
// ─── Behavior table (matrix rows 1-15) ──────────────────────────────────────
describe('resolveTriggerSurface — claude', () => {
test('claude reports every local command trigger as shadowed by the global skill', () => {
const stems = ['plan-phase', 'help'];
const result = resolveTriggerSurface('claude', ['global', 'local'], { stems });
assert.strictEqual(result.length, stems.length * 2);
for (const stem of stems) {
const trigger = `gsd-${stem}`;
const winner = result.find((s) => s.trigger === trigger && s.kind === 'skills' && s.scope === 'global');
const loser = result.find((s) => s.trigger === trigger && s.kind === 'commands' && s.scope === 'local');
assert.ok(winner, `missing global skills entry for ${trigger}`);
assert.ok(loser, `missing local commands entry for ${trigger}`);
assert.strictEqual(winner.shadowedBy, null, `${trigger}: global skills entry must be unshadowed`);
assert.deepStrictEqual(loser.shadowedBy, { kind: 'skills', scope: 'global' }, `${trigger}: local commands entry must name the global skill as its shadower`);
assert.strictEqual(winner.destPath, `skills/${trigger}`);
assert.strictEqual(loser.destPath, `commands/${trigger}.md`);
assert.strictEqual(winner.registration, 'direct');
assert.strictEqual(loser.registration, 'direct');
assert.strictEqual(winner.routerTrigger, null);
assert.strictEqual(loser.routerTrigger, null);
}
});
test('claude global alone shadows nothing', () => {
const stems = ['plan-phase', 'help'];
const result = resolveTriggerSurface('claude', ['global'], { stems });
assert.strictEqual(result.length, stems.length);
for (const s of result) {
assert.strictEqual(s.kind, 'skills');
assert.strictEqual(s.scope, 'global');
assert.strictEqual(s.shadowedBy, null);
}
});
test('claude local alone shadows nothing', () => {
// #2218's healthy state: local reachable when nothing shadows it.
const stems = ['plan-phase', 'help'];
const result = resolveTriggerSurface('claude', ['local'], { stems });
assert.strictEqual(result.length, stems.length);
for (const s of result) {
assert.strictEqual(s.kind, 'commands');
assert.strictEqual(s.scope, 'local');
assert.strictEqual(s.shadowedBy, null);
}
});
test('no scopes yields no triggers', () => {
assert.deepStrictEqual(resolveTriggerSurface('claude', [], { stems: ['plan-phase'] }), []);
});
});
describe('resolveTriggerSurface — both-scope runtimes', () => {
test('both-scope runtimes report same-kind shadowing', () => {
// The 12 both-scope trigger-bearing runtimes excluding claude (claude has
// its own dedicated rows above). windsurf is excluded too — its global
// scope emits agents only, so it is NOT a both-scope trigger-bearing
// runtime (see the dedicated windsurf test below).
const BOTH_SCOPE_RUNTIMES = [
'antigravity', 'augment', 'codebuddy', 'codex', 'copilot',
'cursor', 'hermes', 'kilo', 'opencode', 'qwen', 'trae', 'zcode',
];
for (const runtime of BOTH_SCOPE_RUNTIMES) {
const result = resolveTriggerSurface(runtime, ['global', 'local'], { stems: ['plan-phase'] });
const group = result.filter((s) => s.trigger === 'gsd-plan-phase');
assert.ok(group.length >= 2, `${runtime}: expected at least 2 candidate entries, got ${group.length}`);
const winners = group.filter((s) => s.shadowedBy === null);
assert.strictEqual(winners.length, 1, `${runtime}: expected exactly one unshadowed winner, got ${winners.length}`);
assert.strictEqual(winners[0].scope, 'global', `${runtime}: the winner must be the global entry`);
for (const s of group) {
if (s === winners[0]) continue;
assert.deepStrictEqual(
s.shadowedBy, { kind: winners[0].kind, scope: 'global' },
`${runtime}: every non-winner must name the global winner's kind`,
);
}
}
});
});
describe('resolveTriggerSurface — global-only runtimes', () => {
test('global-only runtimes report unshadowed triggers', () => {
// cline/kimi/kimi-code declare local: [] — no special-casing, it falls
// out of an empty local kind set even when 'local' is in `scopes`.
for (const runtime of ['cline', 'kimi', 'kimi-code']) {
const result = resolveTriggerSurface(runtime, ['global', 'local'], { stems: ['plan-phase'] });
assert.ok(result.length > 0, `${runtime}: expected at least one trigger`);
for (const s of result) {
assert.strictEqual(s.scope, 'global', `${runtime}: unexpected non-global entry`);
assert.strictEqual(s.shadowedBy, null, `${runtime}: global-only entry must be unshadowed`);
}
}
});
});
describe('resolveTriggerSurface — no-artifact-surface runtimes', () => {
test('runtimes with no artifact surface yield no triggers', () => {
for (const runtime of ['pi', 'vscode']) {
assert.deepStrictEqual(
resolveTriggerSurface(runtime, ['global', 'local'], { stems: ['plan-phase'] }), [],
`${runtime}: expected no triggers`,
);
}
});
});
describe('resolveTriggerSurface — windsurf', () => {
test('windsurf does not report a shadow it does not have', () => {
// Global emits agents only (excluded) -> no global trigger. Local commands
// (workflows) triggers must therefore be UNSHADOWED, not falsely reported
// as shadowed by a global counterpart that doesn't exist.
const result = resolveTriggerSurface('windsurf', ['global', 'local'], { stems: ['plan-phase'] });
assert.strictEqual(result.length, 1, `expected exactly one trigger, got ${JSON.stringify(result)}`);
const [entry] = result;
assert.strictEqual(entry.trigger, 'gsd-plan-phase');
assert.strictEqual(entry.kind, 'commands');
assert.strictEqual(entry.scope, 'local');
assert.strictEqual(entry.shadowedBy, null, 'windsurf must not report a shadow it does not have');
});
});
describe('resolveTriggerSurface — agents exclusion', () => {
test('agents are excluded from the trigger surface', () => {
// claude local emits both commands and agents from the same source tree.
const result = resolveTriggerSurface('claude', ['local'], { stems: ['plan-phase'] });
assert.ok(!result.some((s) => s.kind === 'agents'), 'agents must never appear in the trigger surface');
assert.ok(result.some((s) => s.kind === 'commands'), 'commands must still be present');
});
test('kimi-agents are excluded from the trigger surface', () => {
const result = resolveTriggerSurface('kimi', ['global'], { stems: ['plan-phase'] });
assert.ok(!result.some((s) => s.kind === 'kimi-agents'), 'kimi-agents must never appear in the trigger surface');
assert.strictEqual(result.length, 1);
assert.strictEqual(result[0].kind, 'skills');
});
});
describe('resolveTriggerSurface — nested-router runtimes (#69)', () => {
test('nested runtimes distinguish direct from router-reachable registration', () => {
const NESTED_ROUTER_RUNTIMES = ['cline', 'qwen', 'hermes', 'augment', 'trae'];
const opts = {
stems: ['ns-plan', 'plan-phase'],
routerStems: ['ns-plan'],
childToRouters: { 'plan-phase': ['ns-plan'] },
};
for (const runtime of NESTED_ROUTER_RUNTIMES) {
const result = resolveTriggerSurface(runtime, ['global'], opts);
const skillsEntries = result.filter((s) => s.kind === 'skills');
const router = skillsEntries.find((s) => s.trigger === 'gsd-ns-plan');
const child = skillsEntries.find((s) => s.trigger === 'gsd-plan-phase');
assert.ok(router, `${runtime}: missing router skills entry`);
assert.ok(child, `${runtime}: missing child skills entry`);
assert.strictEqual(router.registration, 'direct', `${runtime}: the router itself registers direct`);
assert.strictEqual(router.routerTrigger, null, `${runtime}: a router has no routerTrigger of its own`);
assert.strictEqual(child.registration, 'via-router', `${runtime}: the child is only reachable via its router`);
assert.strictEqual(child.routerTrigger, 'gsd-ns-plan', `${runtime}: the child must name its owning router`);
}
});
});
describe('resolveTriggerSurface — namespacedByDir (hostile fixture, #row 12)', () => {
test('trigger honors the namespaced-by-directory layout', () => {
// No in-tree descriptor trips _copyStaged's namespacedByDir branch today
// (destSubpath's basename === prefix minus its trailing '-'). Synthetic
// fixture via the registry-override seam, injected the same way
// resolveRuntimeArtifactLayoutFromRegistry already accepts a registry.
const registry = {
runtimes: {
'fixture-namespaced': {
runtime: {
artifactLayout: {
global: [
{ kind: 'commands', destSubpath: 'commands/gsd', prefix: 'gsd-', nesting: 'flat', recursive: false, converter: null },
],
local: [],
},
},
},
},
};
const result = resolveTriggerSurface('fixture-namespaced', ['global'], { stems: ['plan-phase'], registry });
assert.strictEqual(result.length, 1);
const [entry] = result;
// The trigger (what the user types) is unaffected by the destPath branch.
assert.strictEqual(entry.trigger, 'gsd-plan-phase');
// destPath must go through the namespacedByDir branch: bare filename, no
// double-applied prefix — NOT the naive `${destSubpath}/${prefix}${stem}.md`.
assert.strictEqual(entry.destPath, 'commands/gsd/plan-phase.md');
assert.notStrictEqual(entry.destPath, 'commands/gsd/gsd-plan-phase.md');
});
});
describe('resolveTriggerSurface — unknown runtime', () => {
test('rejects an unknown runtime with the established error contract', () => {
assert.throws(
() => resolveTriggerSurface('zorptron', ['global'], { stems: ['plan-phase'] }),
(err) => {
assert.ok(err instanceof TypeError);
assert.ok(err.message.includes('zorptron'), 'error message must contain the runtime name');
return true;
},
);
});
});
describe('resolveTriggerSurface — unrecognized scope', () => {
test('rejects an unrecognized scope entry with the shared validateScopeId contract', () => {
// Sibling parity (#2871 Phase 2 review finding): scopeRank/resolveScope/
// isGlobalScope (install-scope.cts) already throw TypeError via
// validateScopeId for a bad scope id. resolveTriggerSurface must not
// fail open on the same input.
assert.throws(
() => resolveTriggerSurface('claude', ['bogus-scope'], { stems: ['plan-phase'] }),
(err) => {
assert.ok(err instanceof TypeError);
assert.ok(err.message.includes('bogus-scope'), 'error message must name the offending scope');
return true;
},
);
});
test('an empty scopes array still returns [] rather than throwing', () => {
assert.deepStrictEqual(resolveTriggerSurface('claude', [], { stems: ['plan-phase'] }), []);
});
});
describe('resolveTriggerSurface — purity', () => {
test('is pure and does not mutate its input', () => {
const scopes = ['global', 'local'];
const opts = { stems: ['plan-phase', 'help'] };
const scopesSnapshot = JSON.stringify(scopes);
const optsSnapshot = JSON.stringify(opts);
const first = resolveTriggerSurface('claude', scopes, opts);
assert.strictEqual(JSON.stringify(scopes), scopesSnapshot, 'scopes array must not be mutated');
assert.strictEqual(JSON.stringify(opts), optsSnapshot, 'opts object must not be mutated');
const pristine = JSON.parse(JSON.stringify(first));
// A caller mutating the returned array/objects must not corrupt a later call.
first[0].trigger = 'HACKED';
first.push({ trigger: 'INJECTED' });
const second = resolveTriggerSurface('claude', scopes, opts);
assert.deepStrictEqual(second, pristine, 'a second call must be unaffected by mutation of the first result');
});
});
describe('resolveTriggerSurface — exhaustive sweep', () => {
test('resolves for every runtime at every scope combination', () => {
const runtimes = Object.keys(capabilityRegistry.runtimes);
const scopeSubsets = [[], ['global'], ['local'], ['global', 'local']];
for (const runtime of runtimes) {
for (const scopes of scopeSubsets) {
let result;
assert.doesNotThrow(() => {
result = resolveTriggerSurface(runtime, scopes, { stems: ['plan-phase', 'help'] });
}, `${runtime} @ [${scopes.join(',')}] must not throw`);
assertValidSurfaceArray(result, `${runtime} @ [${scopes.join(',')}]`);
}
}
});
});
// ─── Precedence axis rows (matrix rows 16-23) ───────────────────────────────
describe('triggerPrecedence axis — validator', () => {
test('a descriptor without the axis remains valid', () => {
// claude's shipped descriptor now DECLARES triggerPrecedence (this PR added
// it), so the fixture must genuinely omit the key to exercise the omission
// path rather than merely echoing what is already on disk.
const cap = shippedCapWithout('claude', 'triggerPrecedence');
assert.ok(!('triggerPrecedence' in cap.runtime), 'fixture must not carry triggerPrecedence');
assert.deepStrictEqual(capValidator.validateRuntimeBody(cap), []);
assert.ok(Array.isArray(capValidator.DEFAULT_TRIGGER_PRECEDENCE), 'DEFAULT_TRIGGER_PRECEDENCE must be exported');
assert.deepStrictEqual(capValidator.DEFAULT_TRIGGER_PRECEDENCE, ['skills', 'commands']);
// Non-vacuous half: resolution must still pick the DEFAULT_TRIGGER_PRECEDENCE
// winner (skills over commands) when the axis is absent from the registry
// entry actually consulted by resolveTriggerSurface — same registry-override
// seam the 'reordering the axis changes which artifact wins' test below uses.
const registry = { runtimes: { claude: cap } };
const stems = ['plan-phase'];
const result = resolveTriggerSurface('claude', ['global', 'local'], { stems, registry });
const trigger = 'gsd-plan-phase';
const winner = result.find((s) => s.trigger === trigger && s.shadowedBy === null);
const loser = result.find((s) => s.trigger === trigger && s.shadowedBy !== null);
assert.ok(winner, `missing unshadowed winner for ${trigger}`);
assert.strictEqual(winner.kind, 'skills', 'default precedence (skills > commands) must pick skills');
assert.strictEqual(winner.scope, 'global');
assert.ok(loser, `missing shadowed loser for ${trigger}`);
assert.deepStrictEqual(loser.shadowedBy, { kind: 'skills', scope: 'global' });
});
test('every shipped descriptor validates with the new axis', () => {
assert.ok(Array.isArray(capValidator.DEFAULT_TRIGGER_PRECEDENCE), 'DEFAULT_TRIGGER_PRECEDENCE must be exported');
for (const runtime of Object.keys(capabilityRegistry.runtimes)) {
const cap = shippedCap(runtime);
const errors = capValidator.validateRuntimeBody(cap);
assert.deepStrictEqual(errors, [], `${runtime}: expected clean validation, got ${JSON.stringify(errors)}`);
}
});
test('rejects an empty precedence list', () => {
const cap = shippedCap('claude');
cap.runtime.triggerPrecedence = [];
const errors = capValidator.validateRuntimeBody(cap);
assert.ok(
errors.some((e) => String(e).includes('triggerPrecedence')),
`expected a triggerPrecedence error, got ${JSON.stringify(errors)}`,
);
});
test('rejects an unknown kind in the precedence list', () => {
const cap = shippedCap('claude');
cap.runtime.triggerPrecedence = ['skills', 'bogus'];
const errors = capValidator.validateRuntimeBody(cap);
assert.ok(
errors.some((e) => String(e).includes('triggerPrecedence') && String(e).includes('bogus')),
`expected the offending kind named in the error, got ${JSON.stringify(errors)}`,
);
});
test('rejects a duplicate kind in the precedence list', () => {
const cap = shippedCap('claude');
cap.runtime.triggerPrecedence = ['skills', 'skills'];
const errors = capValidator.validateRuntimeBody(cap);
assert.ok(
errors.some((e) => String(e).includes('triggerPrecedence')),
`expected a triggerPrecedence error, got ${JSON.stringify(errors)}`,
);
});
test('rejects a non-array precedence value', () => {
for (const bad of ['skills-then-commands', {}, null]) {
const cap = shippedCap('claude');
cap.runtime.triggerPrecedence = bad;
const errors = capValidator.validateRuntimeBody(cap);
assert.ok(
errors.some((e) => String(e).includes('triggerPrecedence')),
`bad value ${JSON.stringify(bad)}: expected a triggerPrecedence error, got ${JSON.stringify(errors)}`,
);
}
});
});
describe('triggerPrecedence axis — resolution', () => {
test('reordering the axis changes which artifact wins', () => {
// codebuddy declares BOTH commands and skills at the SAME scope from the
// same source stems — the one runtime shape where kind precedence (not
// scope rank) decides the winner.
const defaultResult = resolveTriggerSurface('codebuddy', ['global'], { stems: ['plan-phase'] });
const defaultGroup = defaultResult.filter((s) => s.trigger === 'gsd-plan-phase');
const defaultWinner = defaultGroup.find((s) => s.shadowedBy === null);
assert.ok(defaultWinner, 'expected a winner under the default precedence');
assert.strictEqual(defaultWinner.kind, 'skills', 'default precedence (skills > commands) must pick skills');
const overriddenCap = JSON.parse(JSON.stringify(capabilityRegistry.runtimes.codebuddy));
overriddenCap.runtime.triggerPrecedence = ['commands', 'skills'];
const overriddenRegistry = { runtimes: { codebuddy: overriddenCap } };
const overriddenResult = resolveTriggerSurface('codebuddy', ['global'], { stems: ['plan-phase'], registry: overriddenRegistry });
const overriddenGroup = overriddenResult.filter((s) => s.trigger === 'gsd-plan-phase');
const overriddenWinner = overriddenGroup.find((s) => s.shadowedBy === null);
assert.ok(overriddenWinner, 'expected a winner under the overridden precedence');
assert.strictEqual(overriddenWinner.kind, 'commands', 'reordered precedence (commands > skills) must flip the winner');
});
test('the default precedence matches what claude declares', () => {
assert.ok(Array.isArray(capValidator.DEFAULT_TRIGGER_PRECEDENCE), 'DEFAULT_TRIGGER_PRECEDENCE must be exported');
assert.deepStrictEqual(capValidator.DEFAULT_TRIGGER_PRECEDENCE, ['skills', 'commands']);
const claudeCap = shippedCap('claude');
assert.deepStrictEqual(
claudeCap.runtime.triggerPrecedence, capValidator.DEFAULT_TRIGGER_PRECEDENCE,
"claude's declared triggerPrecedence must equal the validator's default",
);
});
});