* fix(#2875): stage user artifacts durably across install wipes (#1874-F19) preserveUserArtifacts held user files only in an in-memory Map across the wipe, so any process death between preserve and restore lost them outright. Seven call sites, not the four the issue records. Three of them never called the helper at all - they open-coded the same read/wipe/write - so searching for callers under-counted by construction; the extra sites were found by sweeping for the pattern instead. The worst is the mainline install path, where the crash window spans the entire gsd-core tree copy rather than a single rmSync. Adds src/user-artifact-staging.cts: durable on-disk staging with a record written after the copies land as the commit point, plus recovery of orphaned batches on the next run - without recovery the staged bytes survive but the user's file is still gone, which would pass its own test while delivering nothing. Routes copyPreservingSymlink through installFs() so staging cannot bypass the install fs seam, and reunites its symlink-safety docblock with the function it documents. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * docs(#2875): amend ADR-3574 with four claims disproved by implementation Implementing Phase 6 disproved four statements the ADR rests on. The central decision - no single materializer - is unaffected and stands. Corrected: decision 3 was already satisfied, so nothing was extracted; the agents-bypass runtime set omitted claude, kilo and opencode, and closing it needed three new pieces of descriptor contract rather than proceeding on its own terms; three of the four blockers the layout comment names were already stale; and F19 is seven call sites, not four. Records the generalizable lesson: the defect is the pattern of holding user data in memory across a wipe, not the helper, so searching for callers of the helper under-counts by construction. Also resolves the ADR's open question on USER_OWNED_ARTIFACTS membership, and notes that copyPreservingSymlink needed routing through the install fs seam before it could be reused. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * fix(#2875): close dangling-symlink blind spot and harden staging recovery An adversarial review found the F19 staging work shipped red and unsafe. Root cause, shared by two arbitrary-write findings: hasExistingSymlinkBetween missed dangling symlinks in both its root check and its per-segment walk, because it probed with existsSync, which is false for a link whose target does not exist. Fixing only the new module would have reused a guard that was itself blind. This guard protects the whole install tree. Recovery no longer throws: it degrades per entry and per file, so one bad batch cannot block the others. Previously an unrecoverable entry propagated out of the first statement of install and uninstall, before the cleanup that would have removed it - wedging the installer permanently. Partial fs adapters now throw on any omitted method instead of silently reaching the real filesystem, closing the trap that let a test poison list pass while real IO happened. Staged names must be flat, recovery refuses a dangling destination symlink, and a batch whose recovery genuinely failed is no longer swept - it was discarding the only durable copy of the file it had just failed to restore. Replaces three tests that could not fail, including the one labelled negative proof. Known limitation, documented not closed: concurrent installs sharing a staging key can still lose a batch. A real fix needs a cross-process lock. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * enh(#2875): make the descriptor authoritative for the agents kind Deletes the inline agent-staging loop in bin/install.js and the _DESCRIPTOR_AGENTS_RUNTIMES set, so every runtime materializes agents from its capability descriptor instead of an inline hostBehaviors dispatch. Closing it needed three pieces of contract the descriptor pipeline never had, all reducible to one missing input - per-agent resolution context: a frontmatter-extensions step for claude's effort and disallowedTools, per-agent model-override resolution for kilo and opencode, and a named branding converter for hermes, whose rewrite data was already declared. Seven runtimes were on the loop, not the six the design recorded - kimi-code was found by a golden fixture, not by analysis. claude-local and kimi-code both silently lost their agents mid-change; the fixtures caught both and the cause was fixed rather than the fixtures regenerated. A parity harness gates the migration: both pipelines over identical inputs, byte-identical output including filenames, per runtime. It is demonstrated red before being trusted. Surface and install paths converge for all seven, which also fixes surface previously writing no agents for these runtimes. Codex's config.toml strip stays put - it mutates host config, which no descriptor kind models. Also routes install-model-override-resolver and install-effort-resolver through the install fs seam. Both leaked real filesystem IO from the install call tree; the stricter adapter is what exposed them. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * docs(#2875): record the agents-descriptor migration and correct the ADR count The _DESCRIPTOR_AGENTS_RUNTIMES allow-list no longer exists, so the host integration guide told readers to join a set that is gone. Replaces that with what is now true - declare an agents entry and it installs, on the surface path as well as install - and points anyone needing a per-agent transform at the three extension points rather than at a new inline branch. Corrects the ADR amendment: seven runtimes were on the inline loop, not six. kimi-code was found by a golden fixture going red, not by reading. That is the third short count this phase, all from enumerating by symbol or set membership when the thing that matters is a behavior. Adds the Changed changeset for the surface-path convergence. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * docs(#2875): amend ADR-2866 - claude global always wrote agents on disk The claude row's global=[skills] described what capability.json declared, not what the installer wrote. bin/install.js's inline agent-staging loop was never scope-gated and never consulted the descriptor, so a claude --global install has always written agents/gsd-*.md. Phase 6 closes the gap by deleting that loop and declaring agents on claude's descriptor at global scope. On-disk bytes are unchanged - the golden fixtures did not move, which is the evidence that the descriptor, not the installer, was incomplete. #2218 is unaffected: agents are not trigger-bearing, so the wider row does not introduce a new shadowing case. Records the warning that an incomplete descriptor is invisible while a second code path silently does its work, and only surfaces when the two are forced into agreement. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * fix(#2875): close review findings across staging, agents and the parity harness Two independent reviews of this branch found defects the local gates missed. Security: a dangling symlink at a migration destination allowed writing outside configDir - the same class this change claimed to close, missed at the terminal write of the flow being added. The staging-root resolver threw as the first statement of install and uninstall, so a hostile symlink bricked both, and symlinked-configDir users lost uninstall as well as install; it now degrades instead of aborting. Recovery gained a source-side symlink check and now refuses a relative destDir, which resolved against cwd. Converter dispatch gained a runtime allowlist - lint-time validation stopped mattering once this branch promoted that dispatch from the surface path to real installs. Correctness: claude --local --minimal exited 1 because the minimal profile legitimately yields zero agents and the new path treated that as a failure. cline --local silently lost its agents - its descriptor declared none while the deleted loop wrote them unconditionally. The agents prune was widened to any gsd-* entry and destroyed user files it never owned. The parity harness, on which the migration's safety argument rested, drove a synthetic registry and never byte-compared the shipped descriptors; two of its trap rows could not fail. It now drives the real registry across 13 runtime-scope rows including kimi-code and cline-local, and its red-proof is demonstrated by corrupting a live capability.json. Three goldens that had encoded the cline regression as expected behavior were corrected. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * fix(#2875): close findings from both mandated review engines /security-review found the staging source-side walk honouring GSD_ALLOW_SYMLINKED_DEST, an opt-in documented as relaxing only the write destination. A symlinked files/ component dereferenced because copyPreservingSymlink lstats the leaf only, so an intermediate link is followed. The source walk no longer honours the opt-in; the destination check still does. /code-review spec axis found this branch had reintroduced its own bug: migrateLegacyDevPreferencesToSkill's new symlink refusal threw unguarded after the legacy dir was wiped and before the staged batch was restored, so a planted symlink bricked uninstall permanently and orphaned the batch. Refusal kept, abort removed. kimi-code local silently lost its agents, the same class as the cline bug, and the parity harness recorded that exclusion as intentional - the third test in this branch to pin a regression as correct. --minimal now creates an empty agents/ dir that never existed. Behaviour restored rather than softening the changeset, so its byte-identical claim stays true. Standards axis: try/finally removed from twelve test bodies, fast-check properties added for parseOwnerPid, boundary coverage at the grace window and the ancestor-probe depth, a parity assertion for the staging-root helper duplicated across two files, and the 8-deep config walk deduplicated. Records 60-review.json with every finding and disposition from five passes, including the smells left unfixed and why. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * fix(#2875): prune stale agents unconditionally in minimal mode The previous round stopped an empty agents/ directory being created when the resolved profile yields no agents. That was implemented by skipping the agents kind entirely, which also skipped its stale-agent prune - so a full to minimal downgrade left stale gsd-* agents behind. The deleted inline loop pruned unconditionally and only skipped writing. Those are three separate conditions, not one: prune always, write only when there is something to write, create the directory only when writing. Both call sites now run _removeGsdEntries before the empty-staged early exit. The symlink-escape guard moved with it, since the prune also touches dest. Codex .toml agents and the config.toml stanzas are cleaned again, and user-owned agents are still preserved. The agents/ directory is left in place after a prune empties it, matching every sibling kind - none of them remove the destination directory itself. Golden fixtures confirmed byte-identical: the prune is a no-op on a fresh install, so fixture generation is unaffected. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * docs(#2875): document interrupted-install recovery for user-owned files The durable-staging fix is invisible to the user it protects. Someone whose install died mid-flight has no way to know USER-PROFILE.md was staged before the delete, that the next run restores it, or that recovery happens at the start of that run rather than in the background. Written as the task the user has - finish the interrupted command - rather than as a description of the mechanism, and states what it will not do: overwrite a file already present, or touch staging belonging to another install still running. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * chore(#2875): backfill changeset pr number Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * test(#2875): assert the J8 model override without building a regex CodeQL flagged incomplete string escaping: the assertion interpolated the override value into a RegExp while escaping only forward slashes, which is meaningless in a constructor, leaving real metacharacters unescaped. The failure direction was the dangerous one - a metacharacter would have made the match more permissive, so the row would pass when it should fail. That matters here because J8 exists precisely because an earlier revision was a tautology; the rewrite reintroduced a different way for the same assertion to stop discriminating. Replaced with a line-wise exact match, so no regex is constructed at all. Swept the other test files this branch adds; no sibling instances. lint:ci passed on the original - lint-no-adhoc-regex-escape matches a full metachar-escape copy, so a single slash replace slipped under it. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> --------- Co-authored-by: sim <sim@local> Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
30 KiB
ADR-2866: Install-surface resolution — the install pipeline resolves (runtime × scope × trigger) as a value
- Status: Accepted
- Date: 2026-08-09
- Issue: #2866 (epic); Phase 0 tracked by #2869
- Amends: ADR-3660 (widens the Runtime Artifact Layout Module from placement-only to placement + trigger resolution) and ADR-1016 (adds one axis — host trigger precedence — to its closed descriptor vocabulary). Neither is superseded; both remain
Acceptedand live, and both carry the reciprocalAmended byfield. Both widenings shipped in Phase 2 (#2871) — see Reciprocal amendment notes. - Relationship to prior work: completes ADR-58 rather than revising it (its rollout's cleanup step never landed); preserves ADR-1508's dependency direction (installer/layout → conversion, never upward); Phase 3's manifest schema bump is ADR-0008 territory; Phase 2's schema change is additive-with-default per ADR-894. Like the adapters it touches, this ADR sits beneath ADR-1239 (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.mdalready modelscommandanddispatchas two separate interface points —commandis "slash-command routing and invocation",dispatchis "subagent/multi-agent dispatch". Claude's own row citesdispatch.namedDispatch: truewith the evidenceagents: { … subagent_type: block.inp }: an agent is invoked through the Agent/Task tool'ssubagent_type, not by a user typing/gsd-<name>._copyStaged(install-engine.cts:404-493) never applieskind.prefixto anagentskind — the filename passes through verbatim (L474-483). An agent stem carriesgsd-because the source file is namedgsd-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."
Amendment (2026-08-17, #2875): global=[skills] described the descriptor, not the disk
Phase 6 found that the claude row is wrong even as the placement fact the amendment above
preserved. A claude --global install has always written agents/gsd-*.md. It did so through
the inline agent-staging loop in bin/install.js, which was never scope-gated and never consulted
the descriptor — claude was not a member of the _DESCRIPTOR_AGENTS_RUNTIMES allow-list, so it fell
through to that loop on every install, at both scopes.
So global=[skills] accurately described what claude's capability.json declared, and did not
describe what the installer actually wrote. Those two things had silently diverged, and this ADR —
along with the layout module's own golden tests — recorded the declaration as though it were the
outcome.
Phase 6 closes the gap from the other side: the inline loop is deleted, the descriptor is
authoritative for agents on every runtime, and claude's descriptor now declares agents at global
scope. The row's true shape is therefore global=[skills, agents], local=[commands, agents].
On-disk bytes are unchanged — the golden install-tree fixtures did not move, which is the evidence
that the descriptor, not the installer, was the thing that was incomplete.
#2218 is again unaffected, for the reason the previous amendment established. agents is not
trigger-bearing, so widening claude's global row to include it changes nothing about the
commands-versus-skills collision that #2218 is. Anyone re-reading this ADR after Phase 6
should not infer a new shadowing case from the wider row.
The generalizable warning. A descriptor that is merely incomplete is invisible: nothing fails, because a separate code path is quietly doing the work the descriptor should have declared. It surfaces only when something forces the two into agreement — here, deleting the code path. Reviews that check "does the descriptor say the right thing?" cannot catch this; only "does anything install artifacts this descriptor does not declare?" can. Three separate enumerations in Phase 6 were short for a related reason, all recorded in ADR-3574's amendment.
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 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.
It stalled for a deeper reason. The codebase cannot state the problem.
The trigger namespace is modeled nowhere
ADR-3660 answers exactly one question — given this artifact kind and this runtime, where does the file go? — and stops. That narrowness was deliberate and, at the time, correct: ADR-1508 draws the sibling line in one sentence, "Layout owns placement; this module owns content."
The seam has since gained a scope parameter — resolveRuntimeArtifactLayout(runtime, configDir, scope: 'local' | 'global' = 'global', capabilityRegistry?) — but placement-only is a property of the returned value, not of the parameter list. Layout is { runtime, configDir, scope?, kinds: ArtifactKind[] } and ArtifactKind is { kind, destSubpath, prefix, stage, home?, converter? }. Neither carries a trigger.
That absence is the defect. Both the skills and commands kinds derive their stems from the same commands/gsd/*.md sources, so a Claude user with both installs gets two artifacts resolving to one /gsd-<name> trigger. The host resolves skill over command and personal over project, so the project-local tree becomes unreachable. No module and no test can name the collision, because it is an absence rather than a value.
All 19 runtime descriptors declaring artifactLayout, partitioned exhaustively:
| Count | Descriptors | Shape | Shadowing behavior |
|---|---|---|---|
| 12 | antigravity, augment, codebuddy, codex, copilot, cursor, hermes, kilo, opencode, qwen, trae, zcode |
skills at both scopes |
Same mechanic, identical kind — the personal copy wins and points at a tree that exists. A loud override, not silent loss. |
| 1 | claude |
global=[skills], local=[commands, agents] — the only runtime whose scopes emit disjoint trigger-bearing kinds |
The whole local surface vanishes rather than merely being overridden. This is #2218. |
| 1 | windsurf |
global=[agents], local=[commands, agents] — also scope-asymmetric |
Its global scope emits no command trigger, so nothing collides. Does not exhibit #2218's failure. |
| 3 | cline, kimi, kimi-code |
global=[…], local=[] |
No local emission at all, so no cross-scope collision is reachable. |
| 2 | pi, vscode |
global=[], local=[] |
No GSD artifact surface. |
| 0 | — | modules that model the trigger namespace or cross-scope precedence | This is the absence the epic exists to fix. |
The mechanism is general; the failure is specific to claude — and the partition above is the reason: only claude combines a trigger-bearing global kind with a disjoint trigger-bearing local kind.
Scope is a bare string re-derived at every layer
bin/install.js contains 12 literal isGlobal ? 'global' : 'local' sites; the boolean is then reconstructed downstream in runtime-artifact-layout.cts, runtime-artifact-install-plan.cts and surface.cts. There is no shared resolver — runtime-homes.cts's resolveConfigHomeFromDescriptor takes no scope parameter at all, and the one partial projection that exists (hostBehaviors.settingsFileByScope) has a single consumer inside bin/install.js with a hardcoded fallback, so no other module can reach it.
The concept also has three incompatible spellings in src/ alone:
| Site | Representation |
|---|---|
src/runtime-artifact-layout.cts |
scope?: 'local' | 'global' |
src/capability-lifecycle.cts |
scope?: 'global' | 'project' |
src/capability-consent.cts |
ConsentRecord.scope: 'project' — a single literal; global is encoded as absence |
Nothing can answer "what is installed, where": gsd-file-manifest.json records {version, timestamp, mode, files} with no scope and no runtime field, and global and local installs write two manifests to two directories that are never merged and never cross-read.
The consequence
Every remediation proposed for #2218 reads as a special case, because no module owns the concept that would carry the fix — and, as recorded below, two of the three proposed remediations turn out not to work at all.
Decision
The install pipeline resolves surface identity — (runtime × scope × trigger) — as a value, instead of implying it from destination paths.
Concretely: the Runtime Artifact Layout Module returns resolved triggers, not only placements, with host precedence declared as a descriptor axis; scope becomes one resolved value produced by one module rather than a string re-interpreted in seven places; and the install manifest records scope and runtime so an Installed Surface Resolver can answer "which surfaces are installed, at which scopes, for which runtimes — and is one shadowing another?" #2218's shadowing then becomes a field on a returned value rather than an invisible outcome of host resolution.
The four sections below record what that costs the ADRs it touches. Everything else — the phase map, the consequences, the rejected alternatives — follows from them.
1. Amendment to ADR-3660: placement-only stopped paying
ADR-3660 is amended, not superseded. Its placement decision is correct and load-bearing: 19 descriptors and three consumers depend on it, and its Consequences — a new runtime is one table row, and out-of-seam placement knowledge is drift — remain in force.
What changes: the module widens from placement to placement + trigger resolution. It gains a projection returning, per emitted artifact, the /gsd-<name> trigger it will occupy, the kind and scope that produced it, its destination, and whether another install shadows it. resolveRuntimeArtifactLayout stays for callers that only need placement, so no existing consumer is forced to move.
Why the narrowness stopped paying — stated so a future reader does not re-narrow it. ADR-3660's scope was right for the question it was built for (#3659: applySurface failed to prune skill directories — a pure placement omission, and the layout table fixed it). It stops paying at the first question whose answer is not a path. #2218 is that question. The colliding /gsd-<name> trigger is not a value anywhere in the tree, so no module can express the collision, no test can assert it, and no error message can name it. A seam that cannot state a defect in its own domain has drawn its boundary one concept too small. Placement is where the file goes; the trigger is what the user types — and it is the second one the host arbitrates.
This does not absorb historical kinds. Legacy-layout migrations stay in the Installer Migration Module (ADR-0008) and continue to run before layout-driven copy, exactly as ADR-3660 decided. The layout module still describes only the current canonical target.
2. One axis added to ADR-1016's closed descriptor vocabulary: host trigger precedence
ADR-1016 designs the runtime descriptor's vocabulary as closed on purpose — a new axis is intentional friction requiring review, so that "add a runtime" stays "author one capability.json" instead of "teach N modules what this runtime means". This ADR is that review, and it approves exactly one axis: host trigger precedence.
Why it must be a descriptor axis rather than code. Precedence is a fact about the host, not about GSD: Claude resolves skill-over-command and personal-over-project; another host may do neither. Encoding it in a module means a per-runtime if — the precise drift ADR-3660's table exists to prevent and ADR-1016's descriptor exists to prevent. It belongs where every other host fact already lives.
Why one and not two. Scope's own precedence rank is a property of a scope, not of a placement, and it is produced by the Install Scope Module (Phase 1). Only the host's kind-level trigger arbitration is genuinely descriptor-shaped. Widening the vocabulary by two axes when one is enough would spend ADR-1016's friction budget on the wrong thing.
Compatibility. The axis is required-with-default: third-party runtime capabilities authored against today's schema keep working unchanged, which keeps the change additive-only per ADR-894's stability contract. The registry generator and the validator must be updated in the same change as the schema — a descriptor field the validator does not know is a field third-party authors cannot rely on.
3. The @-include constraint — and why #2218 triage option 1 is refuted
The constraint, recorded here as a first-class design fact: markdown @-includes expand ~ but do not expand environment variables, and no conditional-include syntax exists. It is currently written down only as a comment on a SessionStart hook (hooks/gsd-ensure-canonical-path.js), framed as a marketplace-plugin problem — so nothing tells a contributor it also constrains spec-root emission. Whatever literal string is baked into an @… is what the host statically resolves; there is no cwd-conditional spec resolution anywhere in the emitted surface today.
This is the hard constraint on any #2218 fix, and it decides two of the three remediations that #2218's triage proposed:
- Triage option 1 — "make
--localalso emit a skill" — is REFUTED, not deprioritized. The host rule is personal overrides project for identically-named skills. Emitting a project-scope skill changes which artifact wins; it does not change what the winning artifact points at. The local spec tree stays exactly as unreachable as before. Recorded here so it is not re-proposed — it is the intuitive fix, and its failure is not visible from the symptom. - Triage option 3 — "document scope mutual-exclusivity" — is rejected as a fix. The reporter's configuration (per-project customizations plus a global install for projects without one) is legitimate; documenting it away removes a working configuration rather than supporting it. It is retained only as the fallback if the Phase 4b mechanism is rejected, in which case the limitation must be documented explicitly rather than left implicit.
- Triage option 2 — detect and warn — is necessary but not sufficient. It removes the silence, which is #2218's worst property, but leaves the user unable to actually use both installs.
Therefore the only lever is what the winning artifact points at. That is why Phase 4 splits into an unconditional detection floor and a separately-signed-off behavioral fix — see What this ADR does not decide.
4. Non-conflicts: completes ADR-58, preserves ADR-1508's direction
ADR-58 is completed, not revised. Its decision — install logic testable as pure data, resolution free of IO, thin adapters executing — is unchanged and still right. Its rollout sequenced registry → adapter → helpers → cleanup, and the cleanup step never landed: installRuntimeArtifacts() still returns void, so install's correctness is observable only by re-reading disk. The counter-example is already in the tree — createRuntimeArtifactInstallPlan() returns a pure {ok, plan} its test asserts in one comparison with injected stage dependencies and no real filesystem. Phase 5 finishes the sequence ADR-58 wrote. Nothing in ADR-58 is reopened.
ADR-1508's dependency direction is preserved: installer/layout → conversion, never upward. No phase makes src/ depend on bin/install.js. Phase 6's Layout Materializer is extracted from the installer into src/, with bin/install.js as a caller — the same direction ADR-1508 established. bin/install.js remains hand-authored JS, as ADR-1508 explicitly decided ("Only the moved functions become TypeScript; install.js itself is not converted"), which is also why this PR corrects CONTEXT.md's stale "(generated)" annotation for that file.
What this ADR does not decide
Recorded explicitly, because an ADR that quietly ratifies these would be laundering decisions no one made.
- Phase 4b's mechanism is NOT decided here. Making the project-local spec tree reachable requires changing what the winning artifact points at. The epic's recommendation — for the Claude runtime only, emit the spec-root reference only as a two-step imperative resolution (prefer
<cwd>/.claude/gsd-core/…, fall back to~/.claude/gsd-core/…) instead of a single static@-include — is recorded as recommended, pending explicit maintainer sign-off, with its tradeoff stated plainly: an@-include is pre-expanded by the host and is guaranteed inclusion; a resolved reference costs the agent one read and is instruction-following. That is a genuinely weaker promise, and scoping it to the spec-root reference alone (every other@-include stays static) is what makes it acceptable rather than what makes it equivalent. If the tradeoff is rejected, Phase 4a still ships and #2218 downgrades from "broken silently" to "unsupported loudly" — which must then be documented per option 3 above. - The trigger-resolution interface is not specified here.
{trigger, kind, scope, destPath, shadowedBy}is a sketch. Phase 2 (#2871) settles the shape. This ADR decides that the layout module resolves triggers, not its signature. - Phase 6's Layout Materializer is a new module and therefore owes its own ADR. It is not decided here; folding a second module decision into this file would violate CONTRIBUTING's "one issue = one ADR-or-PRD = one PR".
- The
local/projectspelling is not chosen here. Phase 1 reconciles the three-way split and records the chosen spelling inCONTEXT.md's glossary, which is where domain vocabulary is owned. hostIntegration.embeddingMode: "imperative"is unrelated to any of this. Perdocs/reference/host-integration-capability-matrix.mdit classifies the host's plugin API, not spec-path emission. It shares a word with Phase 4b's "imperative reference" and nothing else.
Reciprocal amendment notes
ADR-3660 and ADR-1016 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 already carries the equivalent field for ADR-2782). 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 was recorded when this ADR became Accepted, while the shipped modules still resolved placement only until Phase 2 (#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.
Phase map
Each phase is its own issue and its own PR. 0 → 1 → 2 → 3 → 4 is a hard dependency chain; 5 and 6 are independent of 1–4 once 0 lands; 7 follows 5 and 6.
| Phase | Issue | Deliverable | ADR touched |
|---|---|---|---|
| 0 | #2869 | This ADR + the CONTEXT.md correction |
— |
| 1 | #2870 | Install Scope Module — one resolved scope value | — |
| 2 | #2871 | Trigger resolution + host-precedence axis | ADR-3660, ADR-1016 amended here |
| 3 | #2872 | Manifest scope+runtime; Installed Surface Resolver |
ADR-0008 (migration) |
| 4 | #2873 | Resolves #2218 — detection floor + behavioral fix | — |
| 5 | #2874 | Executed-plan return value | ADR-58 completed |
| 6 | #2875 | Layout Materializer + #1874-F19 durable staging | needs its own ADR |
| 7 | #2876 | Retire dead + pass-through installer exports | ADR-1508 / ADR-857 re-export mandate revisited |
Consequences
- #2218 is fixed and can no longer regress. The coexistence case gets its first test in the suite's history: no test today installs a runtime at both scopes and asserts on the combined result.
- Shadowing is decided in one module. Today it is decided in the host, invisibly, and modeled nowhere. After Phase 2 it is a field on a returned value that an error message,
/gsd-health, and a test can each read. - One resolved projection serves install, uninstall,
/gsd:surface, the migration planner, and the docs matrix. Adding a runtime stays "author onecapability.json" — ADR-1016's stated goal — instead of also teaching N modules what its scopes mean. - Install becomes assertable as a value (Phase 5), so the 10,539-line
install.test.cjscan collapse toward the shaperuntime-artifact-install-plan.test.cjsalready demonstrates. - Deletions, not just additions: 12 scope conversions in
bin/install.js, 12 dead exports, and 2 of the 3 duplicate layout walkers. #1874-F19's durable-staging fix lands inside Phase 6's extraction rather than as a second conflicting pass over the same choreography. - The costs, stated: ADR-1016's closed vocabulary is one axis wider and stays wider forever; the Runtime Artifact Layout Module now owns two concepts instead of one, which is a boundary future reviews must hold rather than keep widening; and Phase 3's manifest schema bump obliges a v1-tolerant read path for the life of that format — users must not need to reinstall. Phase 4a adds install-time output and must not change exit codes: a shadowed install is a warning, not a failure.
- Extraction, not rewrite.
bin/install.jskeeps working at every phase boundary. This is the pattern that has actually worked in this repo — ADR-857, ADR-1508 and ADR-3660 have each moved one slice — and the reason the whole-installer rewrite is rejected below.
Alternatives considered
- Fix #2218 directly today — bespoke detection in
bin/install.js. Rejected as the whole answer. The manifest records neither scope nor runtime, so the check would be a hand-rolled pair of existence probes, untestable through any interface, leaving the next cross-scope question equally unanswerable. Phase 4a delivers the same user-facing outcome built on something that can be asserted. - Triage option 1 — make
--localalso emit a skill. Refuted — see Decision §3. Personal overrides project, so the local tree stays unreachable. - Triage option 3 — document scope mutual-exclusivity. Rejected as a fix; retained only as the documented fallback if Phase 4b's mechanism is not approved.
- A SessionStart hook that repoints
~/.claude/gsd-coreper project. Rejected. There is precedent for the mechanism (hooks/gsd-ensure-canonical-path.jssymlinks a plugin tree into the global dir), but making a machine-global path depend on the cwd of whichever session ran last is unsafe with concurrent sessions in different projects. - One big installer rewrite. Rejected.
bin/install.jsis ~13.5k hand-authored lines with 42 test files bound to its exports. ADR-1016 predicted it would "shrink substantially"; three extraction ADRs have each moved a slice, and that is the approach with a track record here. A complex system that works evolved from a simpler one that worked — a from-scratch installer would have to rediscover every edge case the current one already encodes. - Skip Phase 1 and put precedence directly in the layout module. Rejected. A scope's precedence rank is a property of a scope, not of a placement, and the 12 conversion sites are exactly why nobody can currently thread it through. Putting it in the layout module would make the module's second concept a third.
- Sequence #1874-F19 separately from Phase 6. Rejected on maintainer direction: Phase 6 rewrites that exact choreography, so landing the durable staging inside the extraction is one pass instead of two conflicting ones.
- Write this as a new ADR superseding ADR-3660. Rejected.
Supersededin this corpus means "do not follow this" and obliges naming a replacement; ADR-3660's placement decision is live and correct. Amendment is the accurate relation, and the corpus already models amendments as dated sections on the amended file.
References
- Presenting defect: #2218 —
--localClaude install silently shadowed by a coexisting--globalskills install - Epic: #2866; phases #2869–#2876
- Incorporated: #1874-F19 (durable user-artifact staging) lands with Phase 6; the rest of that epic is untouched
- Placement seam this amends: ADR-3660; content sibling: ADR-1508
- Descriptor vocabulary this widens: ADR-1016; its stability contract: ADR-894
- Install-plan projection this completes: ADR-58; its generalization: ADR-857
- Migration policy for Phase 3's schema bump: ADR-0008
- The frame all of the above sit beneath: ADR-1239 (EoS)
- The
@-include constraint's original site:hooks/gsd-ensure-canonical-path.js