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

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