Commit Graph

3 Commits

Author SHA1 Message Date
Tom Boucher
7a7bf19fc1 enhance(#2872): record scope and runtime in the install manifest (#3323)
* enhance(#2872): record scope and runtime in the install manifest

gsd-file-manifest.json gains manifestVersion, runtime and scope, and a new
read-only Installed Surface Resolver Module reads both install scopes for a
runtime in one call -- the first code path in the repo that does.

Phase 3 of epic #2866 (ADR-2866). Blocks Phase 4 (#2873), which resolves
#2218: the resolver's shadowedBy field is that defect expressed as a value
for the first time. It ships computed-and-unread here.

Installed-ness is decided by manifest PRESENCE, never by the new fields, so
a manifest written by an older GSD stays fully functional and no user needs
to reinstall. Recorded runtime/scope are corroboration: a disagreement with
the probed config dir is reported as declaredScopeMatchesProbe: false, never
silently corrected.

readInstallManifest is widened additively -- version/timestamp/mode/files
keep their exact names, types and meanings for all four existing callers.
manifestVersion is a new field rather than a reinterpretation of version,
which holds the package version and is read by the golden-parity fixtures.

Stems are derived from the installed manifest's own file keys, the inverse
of Phase 2's filename composition, guarded by a fast-check round-trip
property plus a kebab-case charset check so a crafted manifest key cannot
put a traversal segment, control character or ANSI escape into a trigger
that Phase 4 renders back to the user.

Also fixes two defects found while working:
- bin/install.js hardcoded manifestVersion: 2 while the reader owned
  MANIFEST_SCHEMA_VERSION = 2. Now single-sourced, with a parity test.
- docs/installer-migrations.md documented an install-state schema of five
  snake_case fields that have never been written; InstallState has only ever
  been { schemaVersion, appliedMigrations }. Corrected with a dated note.

Verification runs on the remote runner.

* fix(#2872): fold review findings from three independent engines

Standards axis:
- convert the manifest-schema suite from a hybrid setup(t) closure to
  beforeEach/afterEach (CONTRIBUTING.md:319-354 Pattern 1). The hybrid was
  neither approved pattern and a new test forgetting the call got no warning.
- SCOPE_ORDER was declared twice with no parity test -- this repo's recorded
  generative-fix-divergence class. Give the ordering one owner: install-scope
  exports it frozen, the layout module and the resolver both import it, and a
  test locks it against scopeRank so the constant and the ranks cannot drift.
- drop the defaultReadManifest passthrough (Middle Man).

Spec axis:
- add the VOLATILE_FILES exclusion test and source comment the acceptance
  table promised and did not deliver. gsd-file-manifest.json stays excluded:
  the new fields are deterministic, but timestamp -- the original reason --
  is unchanged.

Security axis:
- bound the reported manifest runtime at 64 chars, matching the
  truncatePostureValue convention already used in this subsystem. It reached
  declaredRuntime unbounded while the adjacent stems were gated by SAFE_STEM;
  an inconsistent posture on the same attacker-influenceable document. The
  charset stays ungated on purpose -- declaredRuntimeMatchesProbe needs to see
  the real value -- so Phase 4 must sanitize before rendering, recorded in the
  design's Known limits.

Both new parity tests were verified to FAIL when the two sides are made to
disagree, then pass again on revert. Verification runs on the remote runner.

* chore(#2872): backfill changeset pr number to 3323

* fix(#2872): give git fixture construction its own timeout class

PR #3323's full test (windows-latest, 22, shard 2/3) failed with

  gitOrThrow: 'git init' failed -- outcome=timed_out exitCode=null
  gitOrThrow: 'git commit --allow-empty' failed -- outcome=timed_out

from drift-detection.test.cjs's beforeEach, a file this branch never touched.
Every other lane passed the same commit, including windows-latest node 24 on
all three shards, and next is green.

Root cause is a bound sized for the wrong class. DEFAULT_GIT_TIMEOUT_MS is
15000 and its own comment scopes it to plumbing READS -- rev-parse, branch,
log -- against an existing repo. createFixture uses it for six sequential
repo-CONSTRUCTION spawns: init, three config writes, add -A, commit. init and
commit each write dozens of files, and on Windows every spawn is
Defender-scanned. Sibling tests in the failing block took 15.6-22.0s against
a 15000ms bound.

This repo already diagnosed this exact shape once: timeouts.cjs's
HOOK_FANOUT_TIMEOUT_MS records PR #3285 failing in the SAME job with the SAME
outcome=timed_out exitCode=null signature at the SAME bound while every other
lane passed, and concludes 'a bound sized for the wrong class, not a slow
machine'. It was fixed by splitting out a heavier class-norm at 60000. Same
remedy here: GIT_FIXTURE_TIMEOUT_MS = 60000, 4x the bound that failed and half
INSTALL_TIMEOUT_MS.

DEFAULT_GIT_TIMEOUT_MS deliberately stays at 15000 -- a blanket raise would
stop a genuinely hung plumbing read from surfacing fast.

Verified the value reaches the spawn rather than being an ignored option:
spawnSync was monkeypatched before requiring the fixture module, and all six
git construction calls were captured carrying timeout: 60000.

This branch's two new test files shift shard composition, which is how a
pre-existing fragility landed in the heaviest shard on the slowest lane.
Fixed here rather than deferred, per the no-defer rule.

Verification runs on the remote runner.

---------

Co-authored-by: sim <sim@local>
2026-08-10 15:50:55 -04:00
Tom Boucher
cf6de5e1c0 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>
2026-08-09 22:25:42 -04:00
Tom Boucher
c2f24265f2 feat(#2870): resolve install scope as a value (#3278)
* test(#2870): failing-first suite for the Install Scope Module

19 tests over the 50-test-matrix rows 1-19. RED by construction: the
module under test does not exist yet, so the suite fails at require with
MODULE_NOT_FOUND until src/install-scope.cts lands.

Every row asserts a returned value with injected env/home/existsSync --
no filesystem, per the issue's acceptance criterion that tests assert the
resolved value directly.

Row 7 asserts the RELATION rank(global) > rank(local) rather than a
literal, so Phase 2 (#2871) can re-base the numbers without a fixture
edit. Row 4 iterates the real runtime registry rather than a hardcoded
list, excluding vscode, which declares configHome.kind none and is never
CLI-installed.

* feat(#2870): add the Install Scope Module

Scope becomes one resolved value instead of a bare string re-derived at
every layer. resolveScope({id, runtime, ...}) returns
{id, configHome, settingsFile, consentRequired, hostPrecedenceRank}.

It COMPOSES resolveConfigHomeFromDescriptor rather than extending it.
That function has 60 dependents across 13 files and 2 process flows -- a
CRITICAL blast radius -- so adding a scope parameter to it, which the
issue's framing invites, would ripple through all of them. Composing
costs nothing and leaves every existing caller byte-identical.

The module owns the InstallScope type name, which previously lived
privately in runtime-artifact-install-plan.cts; that module now imports
it. A fifth spelling of the same concept would have defeated the phase.

settingsFile is null for the 18 runtimes that declare no
settingsFileByScope -- absence is a value, not an error, and inventing a
Claude-shaped default would leak that host's shape onto every other one.

hostPrecedenceRank ships unread: Phase 2 (#2871) is its first consumer.
It is carried as data only, per this issue's out-of-scope note that
precedence semantics belong to that phase.

Vocabulary: the install axis standardizes on local. ConsentRecord.scope
keeps project deliberately -- that literal is serialized into consent
records in the user's home, and renaming it would silently deactivate
every project-scoped capability on the machine. CONTEXT.md records the
boundary mapping instead.

Every environmental input is injectable (env, home, existsSync, cwd), so
the resolved value is assertable with no filesystem at all.

Registration ripple: .gitignore, eslint.config.mjs, CONTEXT.md glossary,
docs/INVENTORY.md, and the inventory manifest (regenerated after
build:lib, never before).

Verified via the remote runner.

* refactor(#2870): route scope re-derivations through the module

bin/install.js resolves scope once per function instead of inline at
each of its 12 sites, and the settingsFileByScope consumer reads it
through resolveScope().

Seven downstream boolean re-derivations now call the module's
isGlobalScope() instead of comparing the literal independently:
runtime-artifact-install-plan, both runtime-artifact-layout kind
builders, dispatchKindEntry, surface, and two install-engine sites. The
fifth through seventh were not named in the issue -- they are the same
re-derivation class, and leaving them would have made the acceptance
criterion false.

_computePathPrefix keeps its isGlobal boolean API, so the projection is
centralized rather than eliminated. resolveScope and isGlobalScope share
one validator, so the two surfaces cannot drift.

TWO SITES DELIBERATELY NOT ROUTED: runtime-artifact-conversion's
rewriteStagedSkillBodies and rewriteStagedCommandBodies. ADR-1508 fixes
the direction as installer/layout -> conversion, never upward, and
install-scope composes runtime-homes, so importing it into the
conversion module would invert that direction. Left as-is on purpose.

Behavior-preserving throughout. Each step was proven by capturing full
layout and plan output -- including every kind's home field and the
hashed contents of emitted files -- before and after, across both scopes
for claude, codex, opencode, hermes, kimi and kilo. Byte-identical.

surface.cts keeps a scope ?? 'global' default before the call because
Layout.scope is optional there; isGlobalScope throws where the old
inline compare returned false, and that difference would have been a
placement regression.

Verified via the remote runner.

* fix(#2870): cover the no-config-home throw and document the strictness

Two findings from the isolated adversarial review.

The vscode case was implemented but untested. resolveScope throws for a
runtime whose descriptor declares configHome.kind 'none', which is the
design's own behavior-table row 13, but the registry sweep excluded
vscode rather than asserting the throw -- so the behavior shipped with
no test. The exclusion is now legitimate because the case has its own
test naming the runtime in the assertion.

isGlobalScope throws where the inline compare it replaced returned
false. No reachable caller can deliver an out-of-union value today, but
the types are not enforced at runtime, so a future caller passing an
optional Layout.scope would crash rather than silently misroute. That is
the better failure -- misrouting writes artifacts to the wrong place --
but it was undocumented, so the reason is now on the function.

Adds the changeset the acceptance criteria require.

* refactor(#2870): route the last two sites; correct the ADR-1508 claim

The previous commit declined to route runtime-artifact-conversion's
rewriteStagedSkillBodies and rewriteStagedCommandBodies, claiming
ADR-1508's dependency direction forbade the import. That reasoning was
wrong, and this commit corrects it.

Two independent reviewers checked the actual import graph:
runtime-artifact-conversion already imports capability-registry,
command-roster, runtime-name-policy and shell-command-projection -- it
depends on leaf-tier siblings today. install-scope imports only
runtime-homes plus node builtins, and runtime-homes imports only node
builtins, so there is no cycle at any depth. ADR-1508 governs the
installer/layout to conversion boundary, not a leaf-to-leaf sibling
import of the same shape conversion already makes.

With those two routed, every isGlobal re-derivation in the tree now goes
through one owner and acceptance criterion 1 is fully met rather than
partially. Nine sites, not the four the issue enumerated.

Also from the review:

Tests were falling through to the real process.cwd() at five local-scope
call sites, which contradicts the acceptance criterion that the resolved
value be assertable with no filesystem. Every one now injects a cwd. One
of the five was a site the review had not spotted.

bin/install.js carried two near-identical copies of the guarded
resolveScope block, one in install() and one in uninstall() -- duplicated
scope logic in the phase whose purpose is removing it. Extracted to one
helper, and the new sites use the file's existing ternary idiom rather
than the if/else that replaced it.

Equivalence re-proven across both scopes for claude, codex, opencode,
kilo and hermes, now including the staged skill and command body
rewrites hashed per file, since those decide the literal spec-root path
baked into every emitted artifact. Byte-identical.

Verified via the remote runner.

* fix(#2870): assert configHome portably instead of with a native separator

The windows-latest node24 shard failed on two install-scope assertions.
The module was right and the tests were wrong: they built their expected
value with path.join, which emits \fake\home\.claude on Windows, while
resolveScope normalizes separators unconditionally to /fake/home/.claude.

That unconditional normalization is deliberate -- backslash paths arrive
on Linux too, so normalizing via path.sep is the documented defect this
repo guards against. Weakening it to make the assertion pass would have
inverted the fix.

Every path.join-built expectation in the suite now goes through
toPosixPath from tests/helpers.cjs, which is the pattern the
no-path-literal-in-assert rule's own valid-case list sanctions. It splits
on the running platform's path.sep and rejoins with forward slashes, so
it reverses whatever path.join produced on that same platform and the
expectation is invariant everywhere.

Two more call sites had the same latent problem and passed on Linux and
macOS by luck; they are fixed too.

This is the class of defect the remote runner structurally cannot catch
-- its matrix is Linux-only, so a green pass there is not evidence of
portability, and CI's Windows lane is the only place it surfaces.

Verified via the remote runner.

---------

Co-authored-by: sim <sim@local>
2026-08-09 20:16:23 -04:00