feat(#1082): complete phase 5 — descriptor-drive all install surfaces + materialize the InstallPlan — ADR-857/1016/58 (#1080)

* feat(#1077): phase 5f-2 — drive the hookEvents dialect (PostToolUse/AfterTool) from the descriptor

postToolEvent (bin/install.js) and preToolEvent (applySettingsJsonHooks in
runtime-hooks-surface.cts) now select the event-name dialect from
registry.runtimes[id].runtime.hookEvents instead of the hardcoded
(runtime === 'gemini' || runtime === 'antigravity') check: hookEvents === 'gemini'
→ AfterTool/BeforeTool; else → PostToolUse/PreToolUse. hookEvents threaded into the
applySettingsJsonHooks opts bag. Equivalence-preserving (Codex-verified): hookEvents
'gemini' is exactly {gemini, antigravity}, 'claude' the rest; undefined → claude
dialect (matches the old else).

The per-event SET guards (isQwen||claude → SubagentStop/Stop/PreCompact;
runtime==='claude' → FileChanged; isGemini → Gemini agent-events) stay HARDCODED —
hookEvents (2-value) is too coarse to drive them (the event set differs within
hookEvents='claude'); per-event-set drive tracked in #1076.

Registry-parity test (enh-1077): asserts BOTH post-tool (AfterTool/PostToolUse) AND
pre-tool (BeforeTool/PreToolUse) dialects are a pure function of hookEvents, for
gemini/antigravity/claude/augment — non-vacuous (catches a broken hookEvents thread).

Closes #1077

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* test(#1077): build hooks/dist in before() so dialect-drive test passes in scoped CI

hooks/dist is gitignored and absent in scoped/windows CI jobs that do not
pre-run build:hooks. Without it, install() finds no hook files and all
AfterTool/BeforeTool/PostToolUse/PreToolUse event arrays come back empty,
failing every hook-presence assertion. Added an idempotent ensureHooksDist()
called in a top-level before() — mirrors the pattern from
bug-376-claude-js-hook-gsd-rewriter.test.cjs.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* feat(#1055): add installSurface/writesSharedSettings/permissionWriter/extendedHookEvents to runtime descriptors

Purely additive: four new fields on all 16 runtime capability.json descriptors,
validator extended with three new closed-vocab sets, registry regenerated.
Test fixtures (VALID_RUNTIME_CAP and makeRuntimeCap) updated to include the new
required fields so all 255 capability-registry tests continue to pass.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* feat(#1076): drive per-event hook guards from extendedHookEvents descriptor

Replace hardcoded runtime-name checks (isQwen||runtime==='claude',
runtime==='claude', isGemini) in applySettingsJsonHooks with a single
descriptor-driven extendedEvents array derived from the new opts field.
Remove isQwen and isGemini derivations (no remaining uses after the three
guard blocks are migrated). Wire extendedHookEvents from the capability
registry in bin/install.js call site. Add behavioral regression test
(enh-1076-extended-hook-events-drive.test.cjs) confirming the drive is
purely descriptor-based and runtime-name-agnostic.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* feat(#1055): drive resolveRuntimeConfigIntent from the runtime descriptor; retire hand-kept REGISTRY

- Rewrites src/runtime-config-adapter-registry.cts to require capability-registry.cjs
  and read installSurface / writesSharedSettings / permissionWriter from
  runtimes[id].runtime; deletes the hand-kept REGISTRY const (ADR-857 phase 5g drive 2).
- ALLOWED_CONFIG_RUNTIMES is now derived from descriptor entries that have installSurface.
- Fixes the configFormat parity gate in scripts/gen-capability-registry.cjs to read
  installSurface directly from capMap descriptor bodies, breaking the require cycle
  (adapter now requires the generated registry; gen-script must not require the adapter).
- Adds golden-master test tests/enh-1055-config-intent-descriptor-drive.test.cjs (41 tests)
  pinning all 16 runtimes' return shapes and the TypeError-on-unknown contract.
- Updates scripts/lint-test-file-count.allowlist.json (config module, +1 file).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* feat(#1076): make hooksSurface descriptor load-bearing for the settings-json hook-skip

- Adds hooksSurface?: string to ApplySettingsJsonHooksOpts and destructuring in
  applySettingsJsonHooks (src/runtime-hooks-surface.cts).
- Replaces the hardcoded !isOpencode && !isKilo hook-skip guard with
  hooksSurface !== 'none'; removes the now-unused isOpencode/isKilo derivations
  (ADR-857 phase 5g drive 3).
- Passes hooksSurface from the runtime descriptor at the applySettingsJsonHooks
  call site in bin/install.js using the established
  _capabilityRegistry?.runtimes?.[runtime]?.runtime?.hooksSurface idiom.
- Extends tests/enh-1076-extended-hook-events-drive.test.cjs with two new suites
  proving: (a) hooksSurface:'none' writes no hooks regardless of runtime name;
  (b) hooksSurface:'settings-json' writes hooks even for 'opencode' (previously
  hardcoded to skip).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* docs: record installSurface/writesSharedSettings/permissionWriter/extendedHookEvents descriptor axes in ADR-1016

Add Decision 7a documenting the four axes added in the 5f-completion pass,
update axis counts from "six" to "twelve", note 5f-completion drives as done
in Decision 8's ladder, update Out of scope to reflect #1055/#1076 are done
and 5g (InstallPlan capstone) remains the only open phase.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* fix(#1055): parity gate must fire on configFormat↔installSurface mismatch (read installSurface at the descriptor level)

The test fixture makeRuntimeCapMap did not include installSurface in the runtime object,
so the gate's typeof r.installSurface !== 'string' guard always skipped the entry and never threw.
Added installSurface as an optional third parameter to makeRuntimeCapMap and passed the correct
installSurface values ('settings-json' for claude, 'codex-toml' for codex) to the two THROWS tests.
The gate implementation already reads r.installSurface correctly from the descriptor level.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* feat(#1076): add installSurface↔hooksSurface + extendedHookEvents↔hookEvents consistency gates with rejection tests

GATE A: INSTALL_SURFACE_TO_ALLOWED_HOOKS_SURFACES map in validateRuntimeBody enforces that a
runtime's hooksSurface is valid for its installSurface (e.g. profile-marker-only only allows none,
codex-toml only allows codex-hooks-json). Derived from the 16 real runtime descriptors.

GATE B: validateRuntimeBody checks that if extendedHookEvents contains Gemini agent-events
(BeforeAgent/AfterAgent/BeforeModel), hookEvents must be 'gemini'; if it contains Claude-family
events (SubagentStop/Stop/PreCompact/FileChanged), hookEvents must be 'claude'.

Added 10 rejection tests in suite 27 covering each gate + each new field validator.
All 16 real runtimes satisfy both gates (verified before coding).
Exports: INSTALL_SURFACE_TO_ALLOWED_HOOKS_SURFACES, VALID_INSTALL_SURFACES,
VALID_EXTENDED_HOOK_EVENTS, VALID_PERMISSION_WRITERS, validateRuntimeBody.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* test(#1076): strengthen hooksSurface-drive assertions; defensive hooksSurface fallback; drop vacuous dup

1. bin/install.js: add explicit literal fallback for hooksSurface when the committed
   capability registry fails to load (opencode/kilo → 'none', all others → 'settings-json').
   The descriptor is always the source of truth in normal operation.

2. enh-1076 Suite 7: change SessionStart assertion from key-presence (hasOwnProperty)
   to at least-one-command (hasHooksFor), so the test fails if hooks are initialized-but-empty.
   ensureHooksDist() in before() guarantees hook files exist.

3. enh-1055 Test 2: remove vacuous duplicate suite that re-asserted intent.runtime === row.runtime
   already fully covered by Test 1's deepStrictEqual over all four fields.

4. capability-registry.test.cjs: fix stale comments in the grok-skip test that claimed the
   parity gate uses the adapter registry; gate reads purely from the descriptor (installSurface
   absent → typeof r.installSurface !== 'string' → soft-skip).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* feat(#1082): materialize the InstallPlan — collect install-level descriptor axes into resolveInstallPlan; route install()/finishInstall() through it (ADR-58/5g)

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* docs: record 5g InstallPlan materialization (ADR-58 Accepted, ADR-1016 phase-5 complete)

ADR-1016 Decision 8 step 7 updated to DONE: resolveInstallPlan(runtime) in
runtime-config-adapter-registry collects install-level descriptor axes into
the typed InstallPlan consumed by install()/finishInstall(). Out-of-scope
section updated: 5g capstone is complete, phase 5 fully materialized.
ADR-1016 line ~20 updated: InstallPlan IS now materialized (both halves).
ADR-58 Implementation note added (2026-06-11): realized in
runtime-config-adapter-registry (co-located with adapter-selection).
CONTEXT.md Runtime Config Adapter Registry entry extended to document
resolveInstallPlan and both-halves realization.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* test(#1082): update install drift guard to the resolveInstallPlan seam (5g)

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

---------

Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Tom Boucher
2026-06-11 22:36:47 -04:00
committed by GitHub
parent 93c5ecd645
commit 94872662e9
31 changed files with 1996 additions and 235 deletions

View File

@@ -480,6 +480,25 @@ 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']);
const FEATURE_FIELDS_FORBIDDEN_ON_RUNTIME = ['skills', 'agents', 'steps', 'contributions', 'gates', 'hooks'];
const VALID_INSTALL_SURFACES = new Set(['settings-json', 'codex-toml', 'copilot-instructions', 'cline-rules', 'cursor-hooks-json', 'profile-marker-only']);
const VALID_PERMISSION_WRITERS = new Set(['opencode', 'kilo']);
const VALID_EXTENDED_HOOK_EVENTS = new Set(['SubagentStop', 'Stop', 'PreCompact', 'FileChanged', 'BeforeAgent', 'AfterAgent', 'BeforeModel']);
// GATE A: installSurface → allowed hooksSurface values (DEFECT.GENERATIVE-FIX: parity invariant)
// Derived from the actual pairings in the 16 real runtime descriptors.
const INSTALL_SURFACE_TO_ALLOWED_HOOKS_SURFACES = new Map([
['settings-json', new Set(['settings-json', 'none'])],
['codex-toml', new Set(['codex-hooks-json'])],
['copilot-instructions', new Set(['copilot-inline'])],
['cline-rules', new Set(['cline-rules'])],
['cursor-hooks-json', new Set(['cursor-hooks-json'])],
['profile-marker-only', new Set(['none'])],
]);
// GATE B: extended hook event families → required hookEvents value
// Gemini agent-events require hookEvents='gemini'; Claude-family events require hookEvents='claude'.
const GEMINI_AGENT_EVENTS = new Set(['BeforeAgent', 'AfterAgent', 'BeforeModel']);
const CLAUDE_FAMILY_EVENTS = new Set(['SubagentStop', 'Stop', 'PreCompact', 'FileChanged']);
/**
* Validate a runtime.configHome object per ADR-1016 Decision 1.
@@ -747,6 +766,84 @@ function validateRuntimeBody(cap) {
errors.push('runtime.supportTier must be 1 or 2 (got: ' + r.supportTier + ')');
}
// installSurface — required string in closed enum
if (!VALID_INSTALL_SURFACES.has(r.installSurface)) {
errors.push(
'runtime.installSurface must be one of: ' + [...VALID_INSTALL_SURFACES].join(', ') +
' (got: ' + JSON.stringify(r.installSurface) + ')',
);
}
// writesSharedSettings — required boolean
if (typeof r.writesSharedSettings !== 'boolean') {
errors.push(
'runtime.writesSharedSettings must be a boolean (got: ' + JSON.stringify(r.writesSharedSettings) + ')',
);
}
// permissionWriter — required key; value must be null or a string in VALID_PERMISSION_WRITERS
if (!Object.prototype.hasOwnProperty.call(r, 'permissionWriter')) {
errors.push('runtime.permissionWriter is required (must be null or one of: ' + [...VALID_PERMISSION_WRITERS].join(', ') + ')');
} else if (r.permissionWriter !== null && !VALID_PERMISSION_WRITERS.has(r.permissionWriter)) {
errors.push(
'runtime.permissionWriter must be null or one of: ' + [...VALID_PERMISSION_WRITERS].join(', ') +
' (got: ' + JSON.stringify(r.permissionWriter) + ')',
);
}
// extendedHookEvents — required array; every element must be in closed enum
if (!Array.isArray(r.extendedHookEvents)) {
errors.push(
'runtime.extendedHookEvents must be an array (got: ' + JSON.stringify(r.extendedHookEvents) + ')',
);
} else {
for (let i = 0; i < r.extendedHookEvents.length; i++) {
const ev = r.extendedHookEvents[i];
if (typeof ev !== 'string' || !VALID_EXTENDED_HOOK_EVENTS.has(ev)) {
errors.push(
'runtime.extendedHookEvents[' + i + '] must be one of: ' + [...VALID_EXTENDED_HOOK_EVENTS].join(', ') +
' (got: ' + JSON.stringify(ev) + ')',
);
}
}
}
// GATE A: installSurface ↔ hooksSurface consistency (DEFECT.GENERATIVE-FIX)
// Only check if both fields are valid strings (individual field validators above report type errors).
if (typeof r.installSurface === 'string' && typeof r.hooksSurface === 'string') {
const allowedHooksSurfaces = INSTALL_SURFACE_TO_ALLOWED_HOOKS_SURFACES.get(r.installSurface);
if (allowedHooksSurfaces !== undefined && !allowedHooksSurfaces.has(r.hooksSurface)) {
errors.push(
'runtime.hooksSurface "' + r.hooksSurface + '" is not valid for installSurface "' + r.installSurface + '"' +
' — allowed: ' + [...allowedHooksSurfaces].join(', ') +
' (src: INSTALL_SURFACE_TO_ALLOWED_HOOKS_SURFACES in scripts/gen-capability-registry.cjs)',
);
}
}
// GATE B: extendedHookEvents ↔ hookEvents consistency (DEFECT.GENERATIVE-FIX)
// If extendedHookEvents contains Gemini agent-events, hookEvents must be 'gemini'.
// If it contains Claude-family events, hookEvents must be 'claude'.
// Empty extendedHookEvents imposes no constraint.
if (Array.isArray(r.extendedHookEvents) && r.extendedHookEvents.length > 0) {
const hasGeminiEvents = r.extendedHookEvents.some((ev) => GEMINI_AGENT_EVENTS.has(ev));
const hasClaudeEvents = r.extendedHookEvents.some((ev) => CLAUDE_FAMILY_EVENTS.has(ev));
if (hasGeminiEvents && r.hookEvents !== 'gemini') {
errors.push(
'runtime.extendedHookEvents contains Gemini agent-events (' +
r.extendedHookEvents.filter((ev) => GEMINI_AGENT_EVENTS.has(ev)).join(', ') +
') but runtime.hookEvents is "' + r.hookEvents + '" — must be "gemini"',
);
}
if (hasClaudeEvents && r.hookEvents !== 'claude') {
errors.push(
'runtime.extendedHookEvents contains Claude-family events (' +
r.extendedHookEvents.filter((ev) => CLAUDE_FAMILY_EVENTS.has(ev)).join(', ') +
') but runtime.hookEvents is "' + r.hookEvents + '" — must be "claude"',
);
}
}
return errors;
}
@@ -1551,26 +1648,13 @@ function runConsistencyGate(capabilityClusters, profileMembership, capMap) {
// ─── ADR-857 phase 5e: configFormat ↔ installSurface parity gate ─────────────
// Paths are declared at top level; the actual require() call is deferred (lazy) so importing
// this generator on an unbuilt worktree doesn't fail at module load. Mirrors the pattern
// used for install-profiles.cjs and clusters.cjs above.
const RUNTIME_CONFIG_ADAPTER_REGISTRY_PATH = path.join(
ROOT, 'gsd-core', 'bin', 'lib', 'runtime-config-adapter-registry.cjs',
);
let _runtimeConfigAdapterMod = null;
function getRuntimeConfigAdapterRegistry() {
if (!_runtimeConfigAdapterMod) {
_runtimeConfigAdapterMod = require(RUNTIME_CONFIG_ADAPTER_REGISTRY_PATH);
}
return _runtimeConfigAdapterMod;
}
// Map: installSurface → expected configFormat
// Derived from the pairing of runtime-config-adapter-registry.cjs (installSurface)
// and the capability.json descriptors (configFormat). DEFECT.GENERATIVE-FIX: this map
// Derived from the pairing of capability.json descriptors (installSurface)
// and capability.json descriptors (configFormat). DEFECT.GENERATIVE-FIX: this map
// is the single parity contract between the two generated surfaces.
// NOTE: both values come from the descriptor bodies in capMap — no dependency on
// runtime-config-adapter-registry.cjs, which now requires capability-registry.cjs
// (the file this gen-script produces), and thus must not be required here.
const INSTALL_SURFACE_TO_CONFIG_FORMAT = new Map([
['settings-json', 'settings-json'],
['codex-toml', 'toml'],
@@ -1583,64 +1667,31 @@ const INSTALL_SURFACE_TO_CONFIG_FORMAT = new Map([
/**
* ADR-857 phase 5e: configFormat ↔ installSurface parity gate.
*
* For each runtime capability that also appears in INSTALL_SURFACES (i.e. is a
* known config-adapter runtime), assert that its capability.json configFormat
* matches the expected value derived from its installSurface.
* For each runtime capability that has an installSurface in its descriptor,
* assert that its configFormat matches the expected value derived from its
* installSurface. Both values are read directly from the capMap descriptor
* bodies — no dependency on runtime-config-adapter-registry.cjs.
*
* HARD gate — throws on mismatch (DEFECT.GENERATIVE-FIX: this invariant is
* derived from two parallel generated surfaces and must fail loudly).
* SOFT skip — if the runtime-config-adapter-registry.cjs module is not loadable
* (unbuilt worktree), emits a warning to stderr and returns without throwing.
*
* @param {Map<string, object>} capMap Fully-validated capability map.
* @returns {void} Throws on mismatch; returns normally on success or soft-skip.
* @returns {void} Throws on mismatch; returns normally on success.
*/
function runConfigFormatParityGate(capMap) {
let adapterMod;
try {
adapterMod = getRuntimeConfigAdapterRegistry();
} catch (_err) {
// Module not loadable (unbuilt worktree) — soft-skip with warning
process.stderr.write(
'⚠ configFormat parity gate SKIPPED: runtime-config-adapter-registry.cjs not loadable ' +
'(run `npm run build` first)\n',
);
return;
}
// Check that REGISTRY is present and is an object-like registry
// (the .cjs exports resolveRuntimeConfigIntent, ALLOWED_CONFIG_RUNTIMES, INSTALL_SURFACES —
// not REGISTRY directly; we need to reconstruct per-runtime installSurface from the adapter).
// We use ALLOWED_CONFIG_RUNTIMES to know which runtimes are in the adapter, then resolve each.
const { resolveRuntimeConfigIntent, ALLOWED_CONFIG_RUNTIMES: allowedRuntimes } = adapterMod;
if (typeof resolveRuntimeConfigIntent !== 'function' || !(allowedRuntimes instanceof Set)) {
process.stderr.write(
'⚠ configFormat parity gate SKIPPED: runtime-config-adapter-registry.cjs missing expected exports\n',
);
return;
}
// Only check runtimes present in BOTH the capability registry and INSTALL_SURFACES
// Read installSurface directly from the descriptor bodies already loaded into
// capMap — eliminates the require cycle introduced when adapter-registry was
// changed to require capability-registry.cjs (ADR-857 phase 5g drive 2).
for (const [capId, cap] of capMap) {
if (cap.role !== 'runtime') continue;
if (!allowedRuntimes.has(capId)) continue; // grok etc. excluded — not in adapter
const r = cap.runtime;
if (!r || typeof r.configFormat !== 'string') continue; // already validated above
let intent;
try {
intent = resolveRuntimeConfigIntent(capId);
} catch (_err) {
// Should not happen (we checked allowedRuntimes.has(capId)), but be defensive
process.stderr.write(
'⚠ configFormat parity gate: could not resolve installSurface for "' + capId + '" — skipping\n',
);
continue;
}
// Only check runtimes that have an installSurface (i.e. are config-adapter runtimes)
if (typeof r.installSurface !== 'string') continue; // grok etc. excluded — no installSurface
const installSurface = intent.installSurface;
const installSurface = r.installSurface;
const expectedConfigFormat = INSTALL_SURFACE_TO_CONFIG_FORMAT.get(installSurface);
if (expectedConfigFormat === undefined) {
@@ -2223,6 +2274,12 @@ module.exports = {
// ADR-857 phase 5e: configFormat ↔ installSurface parity gate
runConfigFormatParityGate,
INSTALL_SURFACE_TO_CONFIG_FORMAT,
// ADR-857 phase 5f: cross-field consistency gates
INSTALL_SURFACE_TO_ALLOWED_HOOKS_SURFACES,
VALID_INSTALL_SURFACES,
VALID_EXTENDED_HOOK_EVENTS,
VALID_PERMISSION_WRITERS,
validateRuntimeBody,
// FIX 5 (lazy): PROFILE_RANK and CLUSTERS are loaded on first access via getters
// so importing the generator on a fresh/unbuilt worktree doesn't fail at module load.
get PROFILE_RANK() { return getInstallProfiles().PROFILE_RANK; },

View File

@@ -1,5 +1,5 @@
{
"_doc": "Baseline of modules currently exceeding the 2-test-file limit. Each entry locks in TODAY's exact test filenames as the allowlisted set (identity ratchet). Adding a NEW test file to a capped module fails (novel). Removing one requires pruning this list (stale, ratchet-down). When a cluster drops to \u2264 2, remove its entry entirely. New entries require justification in PR description.",
"_doc": "Baseline of modules currently exceeding the 2-test-file limit. Each entry locks in TODAY's exact test filenames as the allowlisted set (identity ratchet). Adding a NEW test file to a capped module fails (novel). Removing one requires pruning this list (stale, ratchet-down). When a cluster drops to ≤ 2, remove its entry entirely. New entries require justification in PR description.",
"modules": {
"audit": {
"files": [
@@ -20,7 +20,8 @@
"config-field-docs.test.cjs",
"config-get-default.test.cjs",
"config-schema.property.test.cjs",
"config.test.cjs"
"config.test.cjs",
"enh-1055-config-intent-descriptor-drive.test.cjs"
],
"issue": "TBD"
},
@@ -130,6 +131,8 @@
"install": {
"files": [
"bug-410-install-defaults-test-mode-guard.test.cjs",
"enh-1077-install-hook-events-dialect-drive.test.cjs",
"enh-1082-install-plan-capstone.test.cjs",
"enh-776-install-gemini-hook-events.test.cjs",
"install-minimal-hooks.test.cjs",
"install-nested-layout.test.cjs",