diff --git a/.changeset/2086-eos-claude-imperative-adapter.md b/.changeset/2086-eos-claude-imperative-adapter.md new file mode 100644 index 000000000..73ab37742 --- /dev/null +++ b/.changeset/2086-eos-claude-imperative-adapter.md @@ -0,0 +1,5 @@ +--- +type: Changed +pr: 2106 +--- +**Internal: Claude Code's installer is now driven through the public Host-Integration Interface (ADR-1239 / EoS).** `bin/install.js` routes `claude` install/uninstall through the imperative adapter (`createImperativeAdapter`) instead of calling the engine directly, and its 13 hardcoded `runtime === 'claude'` / `runtime !== 'claude'` branches are folded into descriptor-driven `runtime.hostBehaviors` on `capabilities/claude/capability.json` (permission schema, `settings.local.json` scope routing, `.gsd-source` marker, effort frontmatter, canonical-workflow authorship, and more). Install/uninstall output is **byte-identical** for both the global skills layout and the local legacy layout (golden-parity asserted for both scopes); no other runtime changes. Removes the "add-a-host tax" of scattered string-equality checks for the tier-1 reference host. No user-facing change. (#2086) diff --git a/bin/install.js b/bin/install.js index 6cf14cea7..0a9f2dd10 100755 --- a/bin/install.js +++ b/bin/install.js @@ -43,6 +43,7 @@ const { readBaseRefFromSettings, } = require('../gsd-core/bin/lib/worktree-base-ref.cjs'); const { resolveInstallPlan } = require('../gsd-core/bin/lib/runtime-config-adapter-registry.cjs'); +const { createImperativeAdapter } = require('../gsd-core/bin/lib/adapter-imperative.cjs'); const runtimeArtifactConversion = require('../gsd-core/bin/lib/runtime-artifact-conversion.cjs'); // Canonical set of hook files shipped to users. Imported here so writeManifest() // records exactly the same set that build-hooks.js copies to hooks/dist/, making @@ -126,6 +127,9 @@ function isCodexHooksFeatureKey(key) { // // Merge policy: additive, non-destructive \u2014 existing user entries are preserved; // GSD entries are appended only when not already present (idempotent). +// The reference/default runtime (ADR-1239 reference host). Single-sourced here +// instead of scattered literal 'claude' defaults/rosters (#2086). +const DEFAULT_RUNTIME = 'claude'; const GSD_CLAUDE_ALLOW_PERMISSIONS = Object.freeze([ 'Bash(npx gsd-core *)', 'Read(.planning/*)', @@ -310,6 +314,64 @@ try { } catch (_) { _capabilityRegistry = undefined; } + +// Fail-safe floor for the reference host's #338-privacy-critical behaviors, used +// ONLY when the first-party capability registry cannot be loaded (a broken bundle). +// Without it, a registry-load failure would make `_hostBehaviors('claude')` return +// {} and silently route a claude LOCAL install to the repo-shared, committed +// `settings.json` instead of the gitignored `settings.local.json` (#338) — leaking +// engineer-specific absolute paths. Keyed by runtime id (a DATA lookup, not a +// hardcoded string-equality branch) so behavior degrades CLOSED (safe), never open. +// The live descriptor (capabilities/claude/capability.json) remains the source of +// truth; this mirrors only the privacy-load-bearing subset. (ADR-1239 / #2086) +const FALLBACK_HOST_BEHAVIORS = Object.freeze({ + claude: Object.freeze({ + settingsFileByScope: Object.freeze({ local: 'settings.local.json', global: 'settings.json' }), + permissionsSchema: 'claude', + sourceMarkerFile: '.gsd-source', + }), +}); + +/** + * Resolve a runtime's host behaviors from a capability registry, with the + * #338-privacy fail-safe floor when the registry (or the runtime's descriptor) + * is unavailable. Registry is passed in so this is unit-testable under a + * simulated registry-load failure. (ADR-1239 / #2086) + */ +function _resolveHostBehaviors(runtime, registry) { + const cap = registry && registry.runtimes && registry.runtimes[runtime]; + const declared = cap && cap.runtime && cap.runtime.hostBehaviors; + if (declared) return declared; + return FALLBACK_HOST_BEHAVIORS[runtime] || {}; +} + +/** + * Host-specific install behaviors, declared on the runtime descriptor + * (capabilities//capability.json -> runtime.hostBehaviors) instead of + * scattered `runtime === ''` string checks (ADR-1239 / #2086). Returns {} + * for runtimes that declare none, so every behavior branch degrades to the + * generic path by default — EXCEPT the reference host's #338-critical keys, which + * fall back to FALLBACK_HOST_BEHAVIORS if the registry failed to load. + */ +function _hostBehaviors(runtime) { + return _resolveHostBehaviors(runtime, _capabilityRegistry); +} + +/** + * Construct the imperative Host-Integration adapter (ADR-1239 / #2086), FAIL-OPEN. + * `createImperativeAdapter` composes the capability registry via + * `loadRegistry({includeInstalled:true})`, which require()s several capability + * modules. If any is unavailable (e.g. a packaging regression), return null so + * the caller degrades to the engine directly rather than hard-crashing install/ + * uninstall — matching the optional `capability-registry.cjs` load posture above. + */ +function _runtimeAdapter(runtime) { + try { + return createImperativeAdapter({ runtime }); + } catch { + return null; + } +} const { applyInstallerMigrationPlan, discoverInstallerMigrations, @@ -1244,9 +1306,9 @@ function getCommitAttribution(runtime) { : resolveKiloConfigPath; const config = readSettings(resolveConfigPath(getGlobalConfigDir(runtime, null))); result = (config && config.disable_ai_attribution === true) ? null : undefined; - } else if (runtime === 'claude') { + } else if (_hostBehaviors(runtime).attributionSource === 'settings-json-commit') { // Claude Code - const settings = readSettings(path.join(getGlobalConfigDir('claude', explicitConfigDir), 'settings.json')); + const settings = readSettings(path.join(getGlobalConfigDir(runtime, explicitConfigDir), 'settings.json')); if (!settings || !settings.attribution || settings.attribution.commit === undefined) { result = undefined; } else if (settings.attribution.commit === '') { @@ -6279,7 +6341,7 @@ function copyWithPathReplacement(srcDir, destDir, pathPrefix, runtime, isCommand // copyWithPathReplacement is the emit path for gsd-core/workflows/*.md; // _applyRuntimeRewrites is NOT invoked here, so this is what makes the fix // live in real installs (it is a no-op for files without those lines). - if (runtime !== 'claude') { + if (!_hostBehaviors(runtime).authorsCanonicalWorkflow) { content = _stampNonClaudeRuntimeDefaults(content, runtime); } @@ -6481,7 +6543,7 @@ const GSD_UNINSTALL_HOOKS = [ * @param {boolean} isGlobal - Whether to uninstall from global or local * @param {string} runtime - Target runtime ('claude', 'opencode', 'codex', 'copilot') */ -function uninstall(isGlobal, runtime = 'claude') { +function uninstall(isGlobal, runtime = DEFAULT_RUNTIME) { const { isOpencode, isKilo, isCodex, isCopilot, isAntigravity, isCursor, isWindsurf, isAugment, isTrae, isQwen, isHermes, isCodebuddy, isCline, isKimi } = runtimeFlags(runtime); const dirName = getDirName(runtime); @@ -6539,7 +6601,14 @@ function uninstall(isGlobal, runtime = 'claude') { // 1. Remove GSD commands/skills (layout-driven) const scope = isGlobal ? 'global' : 'local'; - uninstallRuntimeArtifacts(runtime, targetDir, scope); + // ADR-1239 / #2086: drive uninstall through the public Host-Integration Interface. + // Fail-open to the engine directly if the composed-registry adapter can't load. + const _uninstallAdapter = _runtimeAdapter(runtime); + if (_uninstallAdapter) { + _uninstallAdapter.uninstall({ configDir: targetDir, scope }); + } else { + uninstallRuntimeArtifacts(runtime, targetDir, scope); + } removedCount++; // 1a. Non-layout Codex side-effects: agent .toml files, config.toml sections, hooks.json @@ -6705,7 +6774,7 @@ function uninstall(isGlobal, runtime = 'claude') { // 1c. Claude local: remove flat gsd-*.md commands from commands/ (current layout, // #1367 fix). Also remove legacy commands/gsd/ subdirectory from prior installs. - if (!isGlobal && runtime === 'claude') { + if (!isGlobal && _hostBehaviors(runtime).localInstallStyle === 'legacy-flat') { const commandsDir = path.join(targetDir, 'commands'); // Remove flat gsd-*.md files (current layout after #1367 fix) if (fs.existsSync(commandsDir)) { @@ -7008,7 +7077,7 @@ function uninstall(isGlobal, runtime = 'claude') { // to preserve any user-added allow/deny entries. // Uses a local flag to avoid the shared `settingsModified` producing a false // "Removed GSD permissions" message when only hooks/statusline changed. - if (runtime === 'claude' && settings.permissions) { + if (_hostBehaviors(runtime).permissionsSchema === 'claude' && settings.permissions) { let permissionsModified = false; if (Array.isArray(settings.permissions.allow)) { const before = settings.permissions.allow.length; @@ -7483,7 +7552,7 @@ function resolveInstallRelativePath(baseDir, relPath) { /** * Write file manifest after installation for future modification detection */ -function writeManifest(configDir, runtime = 'claude', options = {}) { +function writeManifest(configDir, runtime = DEFAULT_RUNTIME, options = {}) { const { isOpencode, isKilo, isCodex, isCopilot, isAntigravity, isCursor, isWindsurf, isAugment, isTrae, isQwen, isHermes, isCodebuddy, isCline, isKimi } = runtimeFlags(runtime); const gsdDir = path.join(configDir, 'gsd-core'); // #1367: Claude local now writes flat gsd-*.md files at commands/ (not commands/gsd/). @@ -7521,7 +7590,7 @@ function writeManifest(configDir, runtime = 'claude', options = {}) { // Claude local (#1367): flat gsd-*.md files at commands/ level. // Only claude local writes gsd-*.md here; global installs don't emit commands, // so this branch is a no-op for global (no matching files to find). - if (runtime === 'claude' && fs.existsSync(flatCommandsDir)) { + if (_hostBehaviors(runtime).localInstallStyle === 'legacy-flat' && fs.existsSync(flatCommandsDir)) { for (const file of fs.readdirSync(flatCommandsDir)) { if (file.startsWith('gsd-') && file.endsWith('.md')) { manifest.files['commands/' + file] = fileHash(path.join(flatCommandsDir, file)); @@ -7934,7 +8003,7 @@ function saveLocalPatches(configDir, pristineCtx) { /** * After install, report backed-up patches for user to reapply. */ -function reportLocalPatches(configDir, runtime = 'claude') { +function reportLocalPatches(configDir, runtime = DEFAULT_RUNTIME) { const patchesDir = path.join(configDir, PATCHES_DIR_NAME); const metaPath = path.join(patchesDir, 'backup-meta.json'); if (!fs.existsSync(metaPath)) return []; @@ -7977,7 +8046,7 @@ function reportInstallerMigrationResult(result) { } } -function install(isGlobal, runtime = 'claude', options = {}) { +function install(isGlobal, runtime = DEFAULT_RUNTIME, options = {}) { const { isOpencode, isKilo, isZcode, isCodex, isCopilot, isAntigravity, isCursor, isWindsurf, isAugment, isTrae, isQwen, isHermes, isCodebuddy, isCline, isKimi } = runtimeFlags(runtime); const plan = resolveInstallPlan(runtime); const dirName = getDirName(runtime); @@ -8422,7 +8491,7 @@ function install(isGlobal, runtime = 'claude', options = {}) { // (copyWithPathReplacement + stale-skills cleanup). const _isSkillsRuntime = (() => { if (isOpencode || isKilo) return false; // specialized combined path - if (runtime === 'claude' && !isGlobal) return false; // claude-local legacy path + if (_hostBehaviors(runtime).localInstallStyle === 'legacy-flat' && !isGlobal) return false; // legacy flat local path (descriptor-driven; #2086) const cap = _capabilityRegistry && _capabilityRegistry.runtimes && _capabilityRegistry.runtimes[runtime]; const layout = cap && cap.runtime && cap.runtime.artifactLayout; if (!layout) return false; @@ -8433,7 +8502,21 @@ function install(isGlobal, runtime = 'claude', options = {}) { if (_isSkillsRuntime) { // Layout-driven install for skills-based runtimes (full and minimal modes) const scope = isGlobal ? 'global' : 'local'; - installRuntimeArtifacts(runtime, targetDir, scope, _resolvedProfile, getCommitAttribution); + // ADR-1239 / #2086: drive install through the public Host-Integration Interface + // (imperative adapter). The adapter delegates to the SAME installRuntimeArtifacts + // engine call -> byte-identical output (gated by golden-install-parity). Fail-open + // to the engine directly if the composed-registry adapter can't load. + const _adapter = _runtimeAdapter(runtime); + if (_adapter) { + _adapter.install({ + configDir: targetDir, + scope, + resolvedProfile: _resolvedProfile, + resolveAttribution: getCommitAttribution, + }); + } else { + installRuntimeArtifacts(runtime, targetDir, scope, _resolvedProfile, getCommitAttribution); + } // #1326 — Codex only: remove stale agents/openai.yaml sidecars from managed // gsd-* skill dirs. Prior installs wrote these files so Codex would show a @@ -8733,11 +8816,14 @@ function install(isGlobal, runtime = 'claude', options = {}) { // other runtime/scope deploys commands/gsd, so its walk-up already resolves // and needs no marker. Guarded on source presence so a half-published // package never writes a dangling marker. - if (runtime === 'claude' && isGlobal) { + if (_hostBehaviors(runtime).sourceMarkerFile && isGlobal) { const gsdSourceCommands = path.join(src, 'commands', 'gsd'); if (fs.existsSync(gsdSourceCommands)) { try { - fs.writeFileSync(path.join(targetDir, '.gsd-source'), gsdSourceCommands + '\n', 'utf8'); + // ADR-1239 Phase B write-confinement: the descriptor-sourced marker filename + // must resolve under targetDir (parity with the other descriptor-driven writes). + const _markerPath = assertDestWithinConfigHome(targetDir, _hostBehaviors(runtime).sourceMarkerFile); + fs.writeFileSync(_markerPath, gsdSourceCommands + '\n', 'utf8'); } catch (err) { // Non-fatal: install proceeds. But on the Claude-global layout walk-up // also fails (no commands/gsd source tree), so a silent write failure @@ -8932,11 +9018,11 @@ function install(isGlobal, runtime = 'claude', options = {}) { // Claude Code reads per-subagent `effort:` frontmatter (anthropics/claude-code #31536). // Injection is per-runtime at install time because the canonical source // agents/*.md must stay runtime-safe (no effort: key in source). - if (runtime === 'claude') { + if ((_hostBehaviors(runtime).agentFrontmatterExtensions || []).includes('effort')) { const _effortCfg = readGsdEffectiveEffortConfig(targetDir); const _agentName = entry.name.replace(/\.md$/, ''); const _universalEffort = resolveInstallTimeEffort(_effortCfg, _agentName); - const _renderedEffort = _getGsdEffortCatalog().renderEffortForRuntime('claude', _universalEffort).value; + const _renderedEffort = _getGsdEffortCatalog().renderEffortForRuntime(runtime, _universalEffort).value; content = injectEffortFrontmatter(content, _renderedEffort); const _disallowedTools = READONLY_AGENT_DISALLOWED_TOOLS[_agentName]; if (_disallowedTools) content = injectDisallowedToolsFrontmatter(content, _disallowedTools); @@ -9219,7 +9305,7 @@ function install(isGlobal, runtime = 'claude', options = {}) { reportLocalPatches(targetDir, runtime); // Verify no leaked .claude paths in non-Claude runtimes (manifest-scoped) - if (runtime !== 'claude') { + if (!_hostBehaviors(runtime).ownsClaudePaths) { const leakedPaths = []; // Only scan files that were written by this install (manifest-tracked). // Scanning the entire targetDir can match user-authored content that @@ -9742,9 +9828,14 @@ function install(isGlobal, runtime = 'claude', options = {}) { // #338: local Claude installs write to settings.local.json (Claude Code's per-user/gitignored slot) // so engineer-specific absolute paths (Node binary, home dir) never land in the repo-shared // settings.json. Global installs and all other runtimes continue to use settings.json. - const isLocalClaude = (runtime === 'claude' && !isGlobal); - const settingsFileName = isLocalClaude ? 'settings.local.json' : 'settings.json'; - const settingsPath = path.join(targetDir, settingsFileName); + const _scopedSettings = _hostBehaviors(runtime).settingsFileByScope || null; + const isLocalClaude = (!isGlobal && !!(_scopedSettings && _scopedSettings.local)); + const settingsFileName = isLocalClaude + ? _scopedSettings.local + : ((_scopedSettings && _scopedSettings.global) || 'settings.json'); + // ADR-1239 Phase B write-confinement: the descriptor-sourced settings filename + // must resolve under targetDir (this path also drives a recursive mkdirSync). + const settingsPath = assertDestWithinConfigHome(targetDir, settingsFileName); // #338 migration: if a prior local Claude install wrote GSD-shaped entries to settings.json, // relocate them to settings.local.json and clear them from the shared file in the same run. @@ -10010,7 +10101,7 @@ function install(isGlobal, runtime = 'claude', options = {}) { /** * Apply statusline config, then print completion message */ -function finishInstall(settingsPath, settings, statuslineCommand, shouldInstallStatusline, runtime = 'claude', isGlobal = true, configDir = null, bannerOpts = {}) { +function finishInstall(settingsPath, settings, statuslineCommand, shouldInstallStatusline, runtime = DEFAULT_RUNTIME, isGlobal = true, configDir = null, bannerOpts = {}) { const { isOpencode, isKilo, isCodex, isCopilot, isAntigravity, isCursor, isWindsurf, isAugment, isTrae, isQwen, isHermes, isCodebuddy, isCline, isKimi } = runtimeFlags(runtime); const plan = resolveInstallPlan(runtime); @@ -10069,7 +10160,7 @@ function finishInstall(settingsPath, settings, statuslineCommand, shouldInstallS // Merges GSD-owned entries non-destructively (preserves existing user permissions). // Scoped to Claude only: antigravity/qwen/hermes/codebuddy also write // settings.json but use different runtimes and do not use these permission strings. - if (runtime === 'claude') { + if (_hostBehaviors(runtime).permissionsSchema === 'claude') { mergeClaudePermissions(settings); } @@ -10102,7 +10193,7 @@ function finishInstall(settingsPath, settings, statuslineCommand, shouldInstallS // chat model instead of pinning the resolved model. See #1156 (default-to-omit // intent) and #1569 (preserve explicit true). Guard matches the #130-class pattern // on configureOpencodePermissions above. - if (runtime !== 'claude' && !process.env.GSD_TEST_MODE) { + if (!_hostBehaviors(runtime).nativeModelAliases && !process.env.GSD_TEST_MODE) { const gsdDir = path.join(os.homedir(), '.gsd'); const defaultsPath = path.join(gsdDir, 'defaults.json'); try { @@ -10144,7 +10235,7 @@ function finishInstall(settingsPath, settings, statuslineCommand, shouldInstallS // Restart is required for CC to pick up newly-installed skills, and the // slash-menu surface depends on CC version — so the instruction needs to // cover both invocation paths to avoid #2957-style "no commands appear". - if (runtime === 'claude' && isGlobal) { + if (_hostBehaviors(runtime).skillsGlobalOnboarding && isGlobal) { console.log(` ${green}Done!${reset} Restart ${program}, then in any directory either type ${cyan}${command}${reset} or ask Claude to run the ${cyan}gsd-new-project${reset} skill. @@ -10303,7 +10394,7 @@ function parseRuntimeInput(answer) { } } - return selected.length > 0 ? selected : ['claude']; + return selected.length > 0 ? selected : [DEFAULT_RUNTIME]; } function promptRuntime(callback) { @@ -10922,7 +11013,7 @@ function installAllRuntimes(runtimes, isGlobal, isInteractive) { throw error; } - const statuslineRuntimes = ['claude']; + const statuslineRuntimes = [DEFAULT_RUNTIME]; const primaryStatuslineResult = results.find(r => statuslineRuntimes.includes(r.runtime)); const finalize = (shouldInstallStatusline, shouldInstallBanner) => { @@ -11038,6 +11129,9 @@ module.exports = { install, installAllRuntimes, uninstall, + // #2086 — host-behavior resolution + the #338 privacy fail-safe floor (exported for tests) + _resolveHostBehaviors, + FALLBACK_HOST_BEHAVIORS, convertSlashCommandsToCodexSkillMentions, convertClaudeCommandToCodexSkill, convertClaudeCommandToKimiSkill, @@ -11206,7 +11300,7 @@ if (require.main === module && !process.env.GSD_TEST_MODE) { console.error(` ${yellow}--uninstall requires --global or --local${reset}`); process.exit(1); } - const runtimes = selectedRuntimes.length > 0 ? selectedRuntimes : ['claude']; + const runtimes = selectedRuntimes.length > 0 ? selectedRuntimes : [DEFAULT_RUNTIME]; for (const runtime of runtimes) { uninstall(hasGlobal, runtime); } @@ -11218,12 +11312,12 @@ if (require.main === module && !process.env.GSD_TEST_MODE) { } } else if (hasGlobal || hasLocal) { // Default to Claude if no runtime specified but location is - installAllRuntimes(['claude'], hasGlobal, false); + installAllRuntimes([DEFAULT_RUNTIME], hasGlobal, false); } else { // Interactive if (!process.stdin.isTTY) { console.log(` ${yellow}Non-interactive terminal detected, defaulting to Claude Code global install${reset}\n`); - installAllRuntimes(['claude'], true, false); + installAllRuntimes([DEFAULT_RUNTIME], true, false); } else { promptRuntime((runtimes) => { promptLocation(runtimes); diff --git a/capabilities/claude/capability.json b/capabilities/claude/capability.json index 7bdf6bd40..3dd1ddc0b 100644 --- a/capabilities/claude/capability.json +++ b/capabilities/claude/capability.json @@ -79,6 +79,21 @@ "stateIO": "filesystem", "transport": "mcp", "runtime": "node" + }, + "hostBehaviors": { + "attributionSource": "settings-json-commit", + "authorsCanonicalWorkflow": true, + "localInstallStyle": "legacy-flat", + "permissionsSchema": "claude", + "settingsFileByScope": { + "local": "settings.local.json", + "global": "settings.json" + }, + "sourceMarkerFile": ".gsd-source", + "agentFrontmatterExtensions": ["effort"], + "ownsClaudePaths": true, + "nativeModelAliases": true, + "skillsGlobalOnboarding": true } } } diff --git a/docs/how-to/add-or-update-a-host-integration.md b/docs/how-to/add-or-update-a-host-integration.md index 5dd8a1fc5..0c279e70d 100644 --- a/docs/how-to/add-or-update-a-host-integration.md +++ b/docs/how-to/add-or-update-a-host-integration.md @@ -102,6 +102,51 @@ a first-party primitive, reviewed. To add one (e.g. a new `runtime` kind): The parity guard (`tests/host-integration-validator-parity.test.cjs`) fails if these two drift, so they must be updated together. Document the new value's meaning in the matrix legend. +## 7. Fold an already-hardcoded host into the interface (worked example: `claude`) + +Sections 1–6 cover a *green-field* host (`pi`, `antigravity` — a fresh descriptor + reference +binding). This section covers the other case: a host that already has a **real production install** +driven by scattered `runtime === ''` string-equality branches in `bin/install.js`, which you want +to move onto the Host-Integration Interface **without changing a single installed byte**. `claude` +(the tier-1 reference host, #2086) is the worked example. + +The pattern is byte-parity-safe by construction — each string check becomes a **descriptor lookup that +yields the same truth value**, so behavior is unchanged and only the brittle coupling is removed: + +1. **Inventory the branches.** Find every `runtime === ''` / `runtime !== ''` in `bin/install.js` + for the host (`grep -nE "runtime\s*[!=]==\s*'claude'"`). Each is a host behavior encoded as a string + comparison rather than a declared capability. + +2. **Declare the behaviors on the descriptor.** Add a `runtime.hostBehaviors` object to the host's + `capability.json`. Each key names one behavior the branches gated on — e.g. for `claude`: + `permissionsSchema: "claude"`, `settingsFileByScope: { local: "settings.local.json", global: "settings.json" }`, + `sourceMarkerFile: ".gsd-source"`, `agentFrontmatterExtensions: ["effort"]`, `localInstallStyle: "legacy-flat"`, + `authorsCanonicalWorkflow: true`, `ownsClaudePaths: true`, `nativeModelAliases: true`, + `skillsGlobalOnboarding: true`, `attributionSource: "settings-json-commit"`. The validator + (`validateRuntimeBody`) is lenient toward these host-behavior keys; they carry install policy, not the + closed negotiated axes. + +3. **Replace each branch with a descriptor read.** `bin/install.js` exposes a `_hostBehaviors(runtime)` + helper (reads `_capabilityRegistry.runtimes[runtime].runtime.hostBehaviors`, `{}` if absent). Rewrite + `if (runtime === 'claude')` → `if (_hostBehaviors(runtime).permissionsSchema === 'claude')`, and + `if (runtime !== 'claude')` → `if (!_hostBehaviors(runtime).authorsCanonicalWorkflow)`. Only the host + declares the key, so every other runtime keeps the generic path. + +4. **Route install/uninstall through the public adapter.** Replace the direct + `installRuntimeArtifacts(...)` / `uninstallRuntimeArtifacts(...)` calls with + `createImperativeAdapter({ runtime }).install({...})` / `.uninstall({...})`. The imperative adapter + delegates to the *same* engine functions, so the output is byte-identical — that is the point: the + host is now driven **through** the interface, not around it. + +5. **Prove parity, both scopes.** `tests/golden-install-parity.test.cjs` captures a byte-stable manifest + of every emitted file. Assert the host's install is unchanged for **global and local** scopes + (regenerate a baseline from `origin/next` first, then confirm the migrated tree matches it). Exclude + only genuinely volatile / platform-varying files (`settings.json`, `settings.local.json`, `.gsd-source`). + +6. **Guard against regression.** Add a `*-imperative-reference.test.cjs` asserting the adapter classifies + the host correctly, negotiation fails closed on a corrupted descriptor, and — with a source-grep behind + an `// allow-test-rule:` exemption — that **no `runtime === ''` branch remains** in `bin/install.js`. + --- ## Related diff --git a/gsd-core/bin/lib/capability-registry.cjs b/gsd-core/bin/lib/capability-registry.cjs index 106f56433..82900ecaa 100644 --- a/gsd-core/bin/lib/capability-registry.cjs +++ b/gsd-core/bin/lib/capability-registry.cjs @@ -453,6 +453,23 @@ const capabilities = { "stateIO": "filesystem", "transport": "mcp", "runtime": "node" + }, + "hostBehaviors": { + "attributionSource": "settings-json-commit", + "authorsCanonicalWorkflow": true, + "localInstallStyle": "legacy-flat", + "permissionsSchema": "claude", + "settingsFileByScope": { + "local": "settings.local.json", + "global": "settings.json" + }, + "sourceMarkerFile": ".gsd-source", + "agentFrontmatterExtensions": [ + "effort" + ], + "ownsClaudePaths": true, + "nativeModelAliases": true, + "skillsGlobalOnboarding": true } } }, @@ -3877,6 +3894,23 @@ const runtimes = { "stateIO": "filesystem", "transport": "mcp", "runtime": "node" + }, + "hostBehaviors": { + "attributionSource": "settings-json-commit", + "authorsCanonicalWorkflow": true, + "localInstallStyle": "legacy-flat", + "permissionsSchema": "claude", + "settingsFileByScope": { + "local": "settings.local.json", + "global": "settings.json" + }, + "sourceMarkerFile": ".gsd-source", + "agentFrontmatterExtensions": [ + "effort" + ], + "ownsClaudePaths": true, + "nativeModelAliases": true, + "skillsGlobalOnboarding": true } } }, diff --git a/tests/claude-imperative-reference.test.cjs b/tests/claude-imperative-reference.test.cjs new file mode 100644 index 000000000..3cb3245ef --- /dev/null +++ b/tests/claude-imperative-reference.test.cjs @@ -0,0 +1,183 @@ +// allow-test-rule: AC2 requires asserting no `runtime === 'claude'` string-equality branch remains in bin/install.js — the descriptor-migration contract is a property of the source text, so a source-grep is the only faithful check (#2086) +'use strict'; + +/** + * claude imperative reference host — ADR-1239 Phase D / #2086 (EoS/claude). + * + * claude is GSD's tier-1 reference host — "golden parity vs. the Claude reference + * host" (ADR-1239 line 146). This proves claude is driven through the PUBLIC + * Host-Integration Interface (the imperative adapter), that its negotiated axes + * classify + negotiate correctly, that negotiation FAILS CLOSED on a corrupted + * descriptor, and that the migration retired the hardcoded `runtime === 'claude'` + * string-equality branches in bin/install.js (folded into descriptor-driven + * `runtime.hostBehaviors`). + * + * Mirrors tests/pi-imperative-reference.test.cjs + tests/vscode-ide-reference.test.cjs + * but binds against the REAL descriptor + the REAL installer source, since claude + * (unlike pi/vscode) has a real production install being folded in. + */ + +const { test } = require('node:test'); +const assert = require('node:assert/strict'); +const fs = require('node:fs'); +const path = require('node:path'); + +const { createImperativeAdapter } = require('../gsd-core/bin/lib/adapter-imperative.cjs'); +const { + profileOf, + negotiateHostCapabilities, + PROTOCOL_VERSION, + PROFILE_BASELINES, + UNDOCUMENTED, +} = require('../gsd-core/bin/lib/host-integration.cjs'); + +const CLAUDE_CAP = JSON.parse( + fs.readFileSync(path.join(__dirname, '..', 'capabilities', 'claude', 'capability.json'), 'utf8'), +); +const CLAUDE_AXES = CLAUDE_CAP.runtime.hostIntegration; + +// Requiring the installer (not as main) never runs the CLI; GSD_TEST_MODE is set +// defensively to match the install-test convention. +process.env.GSD_TEST_MODE = process.env.GSD_TEST_MODE || '1'; +const installMod = require('../bin/install.js'); + +// -- AC2: driven through the public interface (imperative adapter) ----------- + +test('createImperativeAdapter classifies claude as imperative + composes the registry', () => { + const adapter = createImperativeAdapter({ runtime: 'claude' }); + assert.equal(adapter.kind, 'imperative', "claude embeddingMode is imperative -> adapter kind must be 'imperative'"); + assert.equal(adapter.runtime, 'claude'); + assert.ok(adapter.registry && typeof adapter.registry === 'object', 'imperative adapter exposes the composed capability registry'); + assert.equal(typeof adapter.install, 'function'); + assert.equal(typeof adapter.uninstall, 'function'); +}); + +test('claude descriptor embeddingMode agrees with the imperative adapter kind', () => { + assert.equal(CLAUDE_AXES.embeddingMode, 'imperative', 'capability.json must declare embeddingMode: imperative for the imperative binding'); +}); + +test('claude axes classify as the programmatic-cli reference profile', () => { + assert.equal(profileOf(CLAUDE_AXES), 'programmatic-cli'); + assert.notEqual(profileOf(CLAUDE_AXES), 'ide'); +}); + +// -- AC3: every negotiated axis populated, negotiation is clean --------------- + +test('claude negotiates its declared axes verbatim (no degradation of documented values)', () => { + const result = negotiateHostCapabilities({ ...CLAUDE_AXES, protocolVersion: PROTOCOL_VERSION }); + assert.equal(result.protocolVersion, PROTOCOL_VERSION); + // Every declared scalar axis survives negotiation unchanged (all are known+documented). + assert.equal(result.effective.embeddingMode, 'imperative'); + assert.equal(result.effective.commandSurface, CLAUDE_AXES.commandSurface); + assert.equal(result.effective.modelMode, CLAUDE_AXES.modelMode); + assert.equal(result.effective.hookBus, CLAUDE_AXES.hookBus); + assert.equal(result.effective.stateIO, CLAUDE_AXES.stateIO); + assert.equal(result.effective.transport, CLAUDE_AXES.transport); + assert.equal(result.effective.runtime, CLAUDE_AXES.runtime); + // No `undocumented` sentinel anywhere in claude's declared axes. + assert.ok( + !JSON.stringify(CLAUDE_AXES).includes(UNDOCUMENTED), + 'claude descriptor must carry no `undocumented` sentinel (fully doc-sourced)', + ); +}); + +// -- AC5: negotiation fails CLOSED on a corrupted / partial descriptor -------- + +test('negotiateHostCapabilities never throws for claude — even fully corrupted input', () => { + assert.doesNotThrow(() => negotiateHostCapabilities({})); + assert.doesNotThrow(() => negotiateHostCapabilities({ embeddingMode: UNDOCUMENTED })); + assert.doesNotThrow(() => negotiateHostCapabilities({ embeddingMode: 'wildly-unknown-future-value' })); +}); + +test('a partial/empty claude descriptor degrades to the safe floor — NOT the full programmatic-cli baseline', () => { + const result = negotiateHostCapabilities({}); + // Fail-closed floor (SAFE_DEFAULTS), not the rich profile baseline. + assert.equal(result.effective.embeddingMode, 'declarative', 'omitted embeddingMode degrades closed to declarative'); + assert.equal(result.effective.hookBus, 'none', 'omitted hookBus degrades closed to none'); + assert.equal(result.effective.commandSurface, 'prose-only', 'omitted commandSurface degrades closed to prose-only'); + assert.equal(result.effective.dispatch.namedDispatch, false, 'omitted dispatch degrades closed (no named dispatch)'); + assert.notDeepEqual( + result.effective, + PROFILE_BASELINES['programmatic-cli'], + 'a corrupted descriptor MUST NOT silently reuse the full programmatic-cli baseline', + ); + assert.ok(result.warnings.length > 0, 'degrade-closed must surface warnings'); +}); + +test('an undocumented/unknown claude axis value is not trusted (degraded closed per-axis)', () => { + const corrupted = { ...CLAUDE_AXES, embeddingMode: UNDOCUMENTED, hookBus: 'unknown-bus-kind' }; + const result = negotiateHostCapabilities(corrupted); + assert.equal(result.effective.embeddingMode, 'declarative', 'undocumented embeddingMode -> safe floor'); + assert.equal(result.effective.hookBus, 'none', 'unknown hookBus value -> safe floor'); + // Untouched axes still negotiate to their declared (documented) values. + assert.equal(result.effective.stateIO, CLAUDE_AXES.stateIO); +}); + +// -- AC2: the hardcoded string-equality branches are retired ------------------ + +test('claude descriptor declares runtime.hostBehaviors (the folded-in host behaviors)', () => { + const hb = CLAUDE_CAP.runtime.hostBehaviors; + assert.ok(hb && typeof hb === 'object', 'capabilities/claude/capability.json must declare runtime.hostBehaviors'); + // The behaviors that replaced the 13 `runtime === 'claude'` branches. + assert.equal(hb.permissionsSchema, 'claude'); + assert.equal(hb.localInstallStyle, 'legacy-flat'); + assert.equal(hb.sourceMarkerFile, '.gsd-source'); + assert.equal(hb.authorsCanonicalWorkflow, true); + assert.equal(hb.ownsClaudePaths, true); + assert.equal(hb.nativeModelAliases, true); + assert.equal(hb.skillsGlobalOnboarding, true); + assert.equal(hb.attributionSource, 'settings-json-commit'); + assert.deepEqual(hb.agentFrontmatterExtensions, ['effort']); + assert.equal(hb.settingsFileByScope.local, 'settings.local.json'); + assert.equal(hb.settingsFileByScope.global, 'settings.json'); +}); + +test('bin/install.js contains no `runtime === "claude"` / `runtime !== "claude"` string-equality branches (AC2)', () => { + const src = fs.readFileSync(path.join(__dirname, '..', 'bin', 'install.js'), 'utf8'); + // Strip comments + backtick/inline-code spans so PROSE mentions of the old + // pattern (a comment explaining "not a string-equality branch") do not + // false-positive — only LIVE code counts. + const codeOnly = src + .replace(/\/\*[\s\S]*?\*\//g, '') // block comments + .replace(/\/\/[^\r\n]*/g, '') // line comments (CRLF-safe) + .replace(/`[^`]*`/g, ''); // backtick / inline-code spans + const offenders = codeOnly.match(/runtime\s*[!=]==\s*'claude'/g) || []; + assert.deepEqual( + offenders, + [], + `AC2: every hardcoded runtime==='claude'/!=='claude' branch must be descriptor-driven; found: ${offenders.join(', ')}`, + ); +}); + +// -- Reviewer #2106 (elevated): #338 privacy fail-safe on registry-load failure -- +// If the first-party capability registry fails to load, `_hostBehaviors('claude')` +// would return {} and route a claude LOCAL install to the repo-shared settings.json +// instead of the gitignored settings.local.json — silently reintroducing #338. The +// reference host must degrade CLOSED (safe) for its privacy-critical keys. + +test('claude #338-critical host behaviors degrade CLOSED when the capability registry cannot load', () => { + // Simulate a broken bundle: registry is undefined. + const degraded = installMod._resolveHostBehaviors('claude', undefined); + assert.equal(degraded.settingsFileByScope.local, 'settings.local.json', + '#338: a claude LOCAL install must still route to the gitignored settings.local.json'); + assert.equal(degraded.settingsFileByScope.global, 'settings.json'); + assert.equal(degraded.permissionsSchema, 'claude', 'permission cleanup/merge must still apply'); + assert.equal(degraded.sourceMarkerFile, '.gsd-source'); +}); + +test('with the registry present, claude host behaviors come from the live descriptor (superset of the fail-safe floor)', () => { + const reg = require('../gsd-core/bin/lib/capability-registry.cjs'); + const declared = installMod._resolveHostBehaviors('claude', reg); + assert.equal(declared.settingsFileByScope.local, 'settings.local.json'); + assert.equal(declared.localInstallStyle, 'legacy-flat'); + assert.equal(declared.authorsCanonicalWorkflow, true); + // The fail-safe floor is a strict subset of what the descriptor declares. + for (const k of Object.keys(installMod.FALLBACK_HOST_BEHAVIORS.claude)) { + assert.ok(k in declared, `descriptor must still declare the #338-critical key '${k}'`); + } +}); + +test('a non-reference runtime has no fail-safe fallback (degrades to the generic path)', () => { + assert.deepEqual(installMod._resolveHostBehaviors('opencode', undefined), {}); + assert.deepEqual(installMod._resolveHostBehaviors('codex', undefined), {}); +}); diff --git a/tests/fixtures/golden-install-parity/claude-local.json b/tests/fixtures/golden-install-parity/claude-local.json new file mode 100644 index 000000000..72a5c64bb --- /dev/null +++ b/tests/fixtures/golden-install-parity/claude-local.json @@ -0,0 +1,418 @@ +{ + ".gsd-profile": "0e716a5fef4e6dc1", + "agents/gsd-advisor-researcher.md": "c81b55d567dcd99e", + "agents/gsd-ai-researcher.md": "27c7df941b01cc13", + "agents/gsd-assumptions-analyzer.md": "d796f8245bbe05d2", + "agents/gsd-code-fixer.md": "c6148e5511d02459", + "agents/gsd-code-reviewer.md": "e2c45baa8c0b5f6d", + "agents/gsd-codebase-mapper.md": "f96958e5f85b93fb", + "agents/gsd-debug-session-manager.md": "ec9ca0011a1aab75", + "agents/gsd-debugger.md": "9f35a91f8b3a918e", + "agents/gsd-doc-classifier.md": "a76778bdde1c7f72", + "agents/gsd-doc-synthesizer.md": "8b0b6fc187c9d353", + "agents/gsd-doc-verifier.md": "4232dcf9076e3566", + "agents/gsd-doc-writer.md": "22264239fa0ee611", + "agents/gsd-domain-researcher.md": "f1e03df842ddfb95", + "agents/gsd-eval-auditor.md": "d0f45fff7370bb0b", + "agents/gsd-eval-planner.md": "9cc049b82897daa4", + "agents/gsd-executor.md": "bf1de739df0c9245", + "agents/gsd-framework-selector.md": "85005d716f9d98f7", + "agents/gsd-integration-checker.md": "17a8ee731986564d", + "agents/gsd-intel-updater.md": "4953a465db9dadc1", + "agents/gsd-mempalace-curator.md": "77b53f1b155242b4", + "agents/gsd-nyquist-auditor.md": "f86a28f5c164a0d3", + "agents/gsd-pattern-mapper.md": "b45b5e106775bec1", + "agents/gsd-phase-researcher.md": "4772d9eada32e8bd", + "agents/gsd-plan-checker.md": "75851b147f35354a", + "agents/gsd-planner.md": "9c9ffc56275b8ca2", + "agents/gsd-project-researcher.md": "d7f355894519f9fe", + "agents/gsd-research-synthesizer.md": "1c738df9932d325a", + "agents/gsd-roadmapper.md": "453e9471ad27c7ea", + "agents/gsd-security-auditor.md": "45bd98918cd3a004", + "agents/gsd-ui-auditor.md": "a0b09cc8e4645956", + "agents/gsd-ui-checker.md": "3a7be21f4daa1c05", + "agents/gsd-ui-researcher.md": "a739b0ded9c3ae23", + "agents/gsd-user-profiler.md": "d40584599906f3b7", + "agents/gsd-verifier.md": "628ef3a944a7a6eb", + "commands/gsd-add-tests.md": "057e3e440989e681", + "commands/gsd-ai-integration-phase.md": "998dc23188e131fb", + "commands/gsd-audit-fix.md": "ebdcc267a2bebaab", + "commands/gsd-audit-milestone.md": "319caced85c69ff3", + "commands/gsd-audit-uat.md": "99c9af1b2a2c600a", + "commands/gsd-autonomous.md": "c256989da0d3e736", + "commands/gsd-capture.md": "d3f4353483df3637", + "commands/gsd-cleanup.md": "110473269dabd608", + "commands/gsd-code-review.md": "d2f3160f3a1a790a", + "commands/gsd-complete-milestone.md": "561b8e6bf35ee6e9", + "commands/gsd-config.md": "24d7e8cb8237456e", + "commands/gsd-debug.md": "488ec45a06eb7afa", + "commands/gsd-discuss-phase.md": "e50613dc10e1bab5", + "commands/gsd-docs-update.md": "e314a24fd3926ace", + "commands/gsd-eval-review.md": "c636c832d9a7f5c0", + "commands/gsd-execute-phase.md": "a2c4ad3e635c280a", + "commands/gsd-explore.md": "6d5e1f5f14b2d380", + "commands/gsd-extract-learnings.md": "e43d320cb3ed1aaa", + "commands/gsd-fast.md": "5357a1cf3f363dd2", + "commands/gsd-forensics.md": "b816a6cd5ea304f8", + "commands/gsd-graphify.md": "a07ffe1827c512e1", + "commands/gsd-health.md": "253680291c74b8ed", + "commands/gsd-help.md": "eb2c387f97c59e66", + "commands/gsd-import.md": "bcd7c5018b6c96b2", + "commands/gsd-inbox.md": "e829f45bfe8b4ca5", + "commands/gsd-ingest-docs.md": "ded9013d0de7e77b", + "commands/gsd-manager.md": "72d5b31b88f77703", + "commands/gsd-map-codebase.md": "ecd69887996ae561", + "commands/gsd-mempalace-capture.md": "2e49397072506fd9", + "commands/gsd-mempalace-recall.md": "38716c0983a3ef9c", + "commands/gsd-milestone-summary.md": "908509042caf5beb", + "commands/gsd-mvp-phase.md": "1ef0d7c2871be49a", + "commands/gsd-new-milestone.md": "8ce4861325cd3862", + "commands/gsd-new-project.md": "d68b36481d09bfd4", + "commands/gsd-next.md": "e976ddf80b6cb425", + "commands/gsd-ns-context.md": "011c44e7aa46e64a", + "commands/gsd-ns-ideate.md": "edc5e543512dd48a", + "commands/gsd-ns-manage.md": "0409d810e499357f", + "commands/gsd-ns-project.md": "ff67e85bc6f7fc5a", + "commands/gsd-ns-review.md": "3766ed10827882a0", + "commands/gsd-ns-workflow.md": "c3b3c046a74ec0ee", + "commands/gsd-onboard.md": "d0d9405bc73899bd", + "commands/gsd-pause-work.md": "01dbaebfefacd252", + "commands/gsd-phase.md": "e8c226d2694692a5", + "commands/gsd-plan-phase.md": "518357828182dca7", + "commands/gsd-plan-review-convergence.md": "d5a85a50dcff2dd1", + "commands/gsd-pr-branch.md": "382c23a6a644c0e4", + "commands/gsd-profile-user.md": "adbc5b025b30e836", + "commands/gsd-progress.md": "75a6dc71b74c54c2", + "commands/gsd-quick.md": "fe2f6655e67a4223", + "commands/gsd-resume-work.md": "cb393bc9f46dcc15", + "commands/gsd-review-backlog.md": "434ebe7b63c107e0", + "commands/gsd-review.md": "e990b7adb72472f9", + "commands/gsd-secure-phase.md": "ca4c23a332ca6204", + "commands/gsd-settings.md": "ed3fe2f6c845226d", + "commands/gsd-ship.md": "35a93b86512a7dff", + "commands/gsd-sketch.md": "c35f09bf0698c0c9", + "commands/gsd-spec-phase.md": "383b4928c4df4bf1", + "commands/gsd-spike.md": "98d789db7ca53873", + "commands/gsd-stats.md": "a9c0e3f338bd61a5", + "commands/gsd-surface.md": "998bf327c3f4a001", + "commands/gsd-thread.md": "1b78c7b75b53ad4e", + "commands/gsd-ui-phase.md": "67a759b3973f961f", + "commands/gsd-ui-review.md": "9b4ecf2d40bdc476", + "commands/gsd-ultraplan-phase.md": "4b42890e7d7a0c30", + "commands/gsd-undo.md": "e3cf64bb9562a0d8", + "commands/gsd-update.md": "23342d312e49a125", + "commands/gsd-validate-phase.md": "36c1a57cf7e1e724", + "commands/gsd-verify-work.md": "246ac304c4dfde48", + "commands/gsd-workspace.md": "5928e93b8ab475ac", + "commands/gsd-workstreams.md": "52ab9c585d3a00f3", + "gsd-core/VERSION": "ef0deccd81a6723c", + "gsd-core/bin/check-latest-version.cjs": "e4a224058c8f4d74", + "gsd-core/bin/ensure-runtime-build.cjs": "51bc64467ab30f62", + "gsd-core/bin/gsd-tools.cjs": "6a7616125440c8b1", + "gsd-core/bin/gsd_run": "62d9b647ede212e6", + "gsd-core/bin/shared/config-defaults.manifest.json": "517e6a7c1e9f4f16", + "gsd-core/bin/shared/config-schema.manifest.json": "6bba2b9c9fa47cb8", + "gsd-core/bin/shared/model-catalog.json": "e554a288fcbc1b2e", + "gsd-core/bin/shared/runtime-aliases.manifest.json": "2df2c5ac1957911a", + "gsd-core/bin/verify-reapply-patches.cjs": "caec5dbce11e3904", + "gsd-core/contexts/dev.md": "dcb0de9dce33cf41", + "gsd-core/contexts/research.md": "b3285d8e7209cc3b", + "gsd-core/contexts/review.md": "dc578fdd74bbea11", + "gsd-core/references/agent-contracts.md": "ff65e633c656c0d2", + "gsd-core/references/agent-skills-bootstrap.md": "5ab875054b1adda9", + "gsd-core/references/ai-evals.md": "b5afa786b938671e", + "gsd-core/references/ai-frameworks.md": "f827de93dde124eb", + "gsd-core/references/api-coverage.md": "205a43c5fa7c221c", + "gsd-core/references/artifact-types.md": "251040866a3a1818", + "gsd-core/references/autonomous-smart-discuss.md": "2fc710cde0ec7785", + "gsd-core/references/checkpoints.md": "6aa620c6ca38bdf0", + "gsd-core/references/common-bug-patterns.md": "780145be56352626", + "gsd-core/references/context-budget.md": "6bfac08025ea3106", + "gsd-core/references/continuation-format.md": "580287399ad3ba68", + "gsd-core/references/debugger-philosophy.md": "0466f95a3d6bfcd3", + "gsd-core/references/decimal-phase-calculation.md": "46b5ba045852c474", + "gsd-core/references/doc-conflict-engine.md": "883d0a1b9d9ff96e", + "gsd-core/references/domain-probes.md": "762b965e84035b72", + "gsd-core/references/edge-probe-fixtures/01-round-half-even/expected-coverage.json": "72d1e29cedc854ec", + "gsd-core/references/edge-probe-fixtures/01-round-half-even/requirements.json": "fbc1b355d8625eeb", + "gsd-core/references/edge-probe-fixtures/02-merge-intervals/expected-coverage.json": "fad67dcc8294f6da", + "gsd-core/references/edge-probe-fixtures/02-merge-intervals/requirements.json": "30a78ee9ce3473ea", + "gsd-core/references/edge-probe-fixtures/03-truncate-graphemes/expected-coverage.json": "66dd60957fee45f0", + "gsd-core/references/edge-probe-fixtures/03-truncate-graphemes/requirements.json": "47fca61f076835fa", + "gsd-core/references/edge-probe-fixtures/04-money-rounding/expected-coverage.json": "72d1e29cedc854ec", + "gsd-core/references/edge-probe-fixtures/04-money-rounding/requirements.json": "80f04f5c04fb24cf", + "gsd-core/references/edge-probe-fixtures/05-list-dedupe/expected-coverage.json": "fad67dcc8294f6da", + "gsd-core/references/edge-probe-fixtures/05-list-dedupe/requirements.json": "d38147adb0e5b342", + "gsd-core/references/edge-probe-fixtures/06-resolved-mixed/expected-coverage.json": "bc552c01939bf4f8", + "gsd-core/references/edge-probe-fixtures/06-resolved-mixed/requirements.json": "30a78ee9ce3473ea", + "gsd-core/references/edge-probe-fixtures/06-resolved-mixed/resolutions.json": "688ec62c13e08afe", + "gsd-core/references/edge-probe.md": "36f44960a5c1dc45", + "gsd-core/references/execute-mvp-tdd.md": "a98a270a7ab126bc", + "gsd-core/references/execute-phase-between-wave-reset.md": "3ad96ca0f7fee37e", + "gsd-core/references/execute-phase-context-guard.md": "a5a1058d35806a8e", + "gsd-core/references/execute-phase-wave-guard.md": "de9ac22cead4cfd8", + "gsd-core/references/executor-examples.md": "ba59243ed45c8ab1", + "gsd-core/references/few-shot-examples/plan-checker.md": "2574808188ac9de4", + "gsd-core/references/few-shot-examples/verifier.md": "5badee4560b14ae8", + "gsd-core/references/gate-prompts.md": "099c8d52e3562336", + "gsd-core/references/gates.md": "7dc9fd3a3d6217c6", + "gsd-core/references/git-integration.md": "5c70ef3203b7c9ce", + "gsd-core/references/git-planning-commit.md": "f897a15ebfc3f5a7", + "gsd-core/references/gsd-run-resolver.md": "1541604e8301ff6d", + "gsd-core/references/honest-verifier.md": "809f3488bc5a79d0", + "gsd-core/references/ios-scaffold.md": "5ef0cb7e0fac891f", + "gsd-core/references/loop-hook-dispatch.md": "32e5dfb4dba76987", + "gsd-core/references/mandatory-initial-read.md": "fe59abce693717cf", + "gsd-core/references/model-profile-resolution.md": "18e7cfd4ba0ca9bc", + "gsd-core/references/model-profiles.md": "c249163663bbea53", + "gsd-core/references/mvp-concepts.md": "3464783eaaef5c10", + "gsd-core/references/phase-argument-parsing.md": "e5bbb985f3bc3e34", + "gsd-core/references/planner-antipatterns.md": "7ed54ec1e2cc54ac", + "gsd-core/references/planner-chunked.md": "79fe674221e738e6", + "gsd-core/references/planner-gap-closure.md": "76bee257911413e7", + "gsd-core/references/planner-graphify-auto-update.md": "1ed614dfba72f2a3", + "gsd-core/references/planner-guidance.md": "782fa05092be3ffa", + "gsd-core/references/planner-human-verify-mode.md": "56d05e841630b3f4", + "gsd-core/references/planner-interface-context.md": "b28fa3da6ae739a8", + "gsd-core/references/planner-load-graph-context.md": "add55e135dd968da", + "gsd-core/references/planner-mvp-mode.md": "ffd7b9d0e402714e", + "gsd-core/references/planner-reviews.md": "da39eace09a10743", + "gsd-core/references/planner-revision.md": "86ba8a511f081f05", + "gsd-core/references/planner-source-audit.md": "7de5bdb07232ce0b", + "gsd-core/references/planning-config.md": "b025429fc72f9285", + "gsd-core/references/prohibition-probe-fixtures/01-streak-reminder/expected.json": "f10df472f2846cc6", + "gsd-core/references/prohibition-probe-fixtures/02-clean-utility/expected.json": "31e8a781eeffe020", + "gsd-core/references/prohibition-probe-fixtures/03-multi-prohibition/expected.json": "70a532a7cc1b6ae8", + "gsd-core/references/prohibition-probe.md": "2d3b728f8ef5cf6e", + "gsd-core/references/project-skills-discovery.md": "c155e03dce8dc3c2", + "gsd-core/references/questioning.md": "a8c988cab05f4651", + "gsd-core/references/research-documentation-lookup.md": "c070007d1d72ab71", + "gsd-core/references/research-philosophy.md": "62930e66cc979c1a", + "gsd-core/references/research-verification-protocol.md": "9c38c9d9a687e679", + "gsd-core/references/reviewer-instances.md": "1412472f858b8f41", + "gsd-core/references/revision-loop.md": "e55ff32dd98c63df", + "gsd-core/references/scout-codebase.md": "ba266ecc18fbf172", + "gsd-core/references/security-asvs-levels.md": "4774fac3b94b6ca8", + "gsd-core/references/skeleton-template.md": "528691d1f0efa878", + "gsd-core/references/sketch-interactivity.md": "7d982fe877e1e1cc", + "gsd-core/references/sketch-theme-system.md": "33e2e96e450456f8", + "gsd-core/references/sketch-tooling.md": "df6c4f24c1c27611", + "gsd-core/references/sketch-variant-patterns.md": "66c197aa4fb52810", + "gsd-core/references/specless-probe-fallback.md": "c0331cc8b7df24a8", + "gsd-core/references/spidr-splitting.md": "074ac154c0e4f906", + "gsd-core/references/tdd.md": "e4708ede157478b6", + "gsd-core/references/thinking-models-debug.md": "2da61022b16c4e7c", + "gsd-core/references/thinking-models-execution.md": "dcc650a8b5f3e049", + "gsd-core/references/thinking-models-planning.md": "7e19462313fa028f", + "gsd-core/references/thinking-models-research.md": "5f6bf3f3b889c6e4", + "gsd-core/references/thinking-models-verification.md": "a71a933d51ca3d8d", + "gsd-core/references/thinking-partner.md": "827c1badf3e6df41", + "gsd-core/references/ui-brand.md": "48717bcfcd63bd27", + "gsd-core/references/universal-anti-patterns.md": "6a1245050b21df01", + "gsd-core/references/untrusted-input-boundary.md": "d33b80d4d348599a", + "gsd-core/references/user-profiling.md": "b50416fe57c1b321", + "gsd-core/references/user-story-template.md": "0cc50e06a144ff8a", + "gsd-core/references/verification-overrides.md": "a3e2d5166d16a37b", + "gsd-core/references/verification-patterns.md": "cfaf338f42b1111e", + "gsd-core/references/verify-mvp-mode.md": "534bdc7f2432903a", + "gsd-core/references/workstream-flag.md": "ca99ca79e716f0f5", + "gsd-core/references/worktree-branch-check.md": "21d9c31bf6542b93", + "gsd-core/references/worktree-path-safety.md": "3c8d74756f9b16a8", + "gsd-core/templates/AI-SPEC.md": "24df5fe5ba34e367", + "gsd-core/templates/DEBUG.md": "57bd61bfd1d98e7e", + "gsd-core/templates/README.md": "90d2617778373147", + "gsd-core/templates/SECURITY.md": "b628f7f1c6d2328f", + "gsd-core/templates/UAT.md": "68d32d1fea14e184", + "gsd-core/templates/UI-SPEC.md": "20ca56a4e3e21f01", + "gsd-core/templates/VALIDATION.md": "6144951011cdca57", + "gsd-core/templates/claude-md.md": "d8f0fe8dba3bb28a", + "gsd-core/templates/codebase/architecture.md": "6be88214162fdd89", + "gsd-core/templates/codebase/concerns.md": "efa26d1fb5132f25", + "gsd-core/templates/codebase/conventions.md": "c2e07698dad6b364", + "gsd-core/templates/codebase/integrations.md": "39bd23c71eedd564", + "gsd-core/templates/codebase/stack.md": "116e7e67dd87ddec", + "gsd-core/templates/codebase/structure.md": "d34c1d0eb4f15ed6", + "gsd-core/templates/codebase/testing.md": "76abff7f2050c9ea", + "gsd-core/templates/config.json": "a4b783ef759a0f37", + "gsd-core/templates/context.md": "69b01e7909ea3f66", + "gsd-core/templates/continue-here.md": "f522a51b6895fba8", + "gsd-core/templates/copilot-instructions.md": "aea34bc52ff548ea", + "gsd-core/templates/debug-subagent-prompt.md": "8c18a89e25929d8e", + "gsd-core/templates/dev-preferences.md": "95048a71063d980b", + "gsd-core/templates/discovery.md": "e4ab738326eb70e0", + "gsd-core/templates/discussion-log.md": "cac1b48ec0f4dcb8", + "gsd-core/templates/milestone-archive.md": "591b6decdc0c0e51", + "gsd-core/templates/milestone.md": "74d2f750ae9f4a9c", + "gsd-core/templates/phase-prompt.md": "974daa528c2ce3f1", + "gsd-core/templates/planner-subagent-prompt.md": "6c9f1b23ee3dc05f", + "gsd-core/templates/project.md": "ae1f68db042c2522", + "gsd-core/templates/requirements.md": "a44de4c2f146e473", + "gsd-core/templates/research-project/ARCHITECTURE.md": "746b9ef791d758b0", + "gsd-core/templates/research-project/FEATURES.md": "f2b800de5df91b0f", + "gsd-core/templates/research-project/PITFALLS.md": "3ef75fa768422eec", + "gsd-core/templates/research-project/STACK.md": "82c85799ac4dd344", + "gsd-core/templates/research-project/SUMMARY.md": "dceb2f346388839d", + "gsd-core/templates/research.md": "fa6dfb2ff2e8d273", + "gsd-core/templates/retrospective.md": "03981e30dd760103", + "gsd-core/templates/roadmap.md": "e4e35a9eb5dd4d4f", + "gsd-core/templates/spec.md": "7dc900c355098d8b", + "gsd-core/templates/state.md": "73e424b8c70b765c", + "gsd-core/templates/summary-complex.md": "a5e40574fd8894dc", + "gsd-core/templates/summary-minimal.md": "7d09b5e709e2e67c", + "gsd-core/templates/summary-standard.md": "e8d9cf4a8377cdff", + "gsd-core/templates/summary.md": "23c40f6503b3ea98", + "gsd-core/templates/user-profile.md": "20749f23e4c413fc", + "gsd-core/templates/user-setup.md": "78b7d718b6e8d67c", + "gsd-core/templates/verification-report.md": "dd5faa6254183731", + "gsd-core/workflows/_runtime-launcher.snippet.sh": "bf2dd5d1debd5335", + "gsd-core/workflows/add-backlog.md": "8d05775f367d1e48", + "gsd-core/workflows/add-phase.md": "7285e6e8894a41c5", + "gsd-core/workflows/add-tests.md": "8012263b2d27b83e", + "gsd-core/workflows/add-todo.md": "1fc850476cd8340d", + "gsd-core/workflows/ai-integration-phase.md": "3503f52a7356caf0", + "gsd-core/workflows/analyze-dependencies.md": "77aff48f97fa6f1c", + "gsd-core/workflows/audit-fix.md": "816c71b0ef2d8c1d", + "gsd-core/workflows/audit-milestone.md": "a35795246b955bdb", + "gsd-core/workflows/audit-uat.md": "1c4a02a8c1ab930f", + "gsd-core/workflows/autonomous.md": "6adf957e64518d15", + "gsd-core/workflows/check-todos.md": "8f2c6b27f18cc5e2", + "gsd-core/workflows/cleanup.md": "bfbab4b981d39544", + "gsd-core/workflows/code-review-fix.md": "78c716068ccdf820", + "gsd-core/workflows/code-review.md": "2d21452eb0449fdd", + "gsd-core/workflows/complete-milestone.md": "9962377cddee50d7", + "gsd-core/workflows/debug.md": "3354c726abbfd75b", + "gsd-core/workflows/diagnose-issues.md": "db6a599674efbc4d", + "gsd-core/workflows/discovery-phase.md": "a20dfb32adec51de", + "gsd-core/workflows/discuss-phase-assumptions.md": "35a3b2d1285565d8", + "gsd-core/workflows/discuss-phase-power.md": "290c0d83d783f9f6", + "gsd-core/workflows/discuss-phase.md": "ff2563dc378c5e5c", + "gsd-core/workflows/discuss-phase/modes/advisor.md": "f64ece6d53b6d432", + "gsd-core/workflows/discuss-phase/modes/all.md": "fa70d79066562e54", + "gsd-core/workflows/discuss-phase/modes/analyze.md": "da0788f3be7f8105", + "gsd-core/workflows/discuss-phase/modes/auto.md": "d0d68b06bcd43bcf", + "gsd-core/workflows/discuss-phase/modes/batch.md": "6946597770e2d448", + "gsd-core/workflows/discuss-phase/modes/chain.md": "94548620da9708dd", + "gsd-core/workflows/discuss-phase/modes/default.md": "67d1b67f61f03966", + "gsd-core/workflows/discuss-phase/modes/power.md": "dfcf239382e9bd67", + "gsd-core/workflows/discuss-phase/modes/text.md": "b62c9085d4dc2963", + "gsd-core/workflows/discuss-phase/templates/checkpoint.json": "e3bc3dca49db59eb", + "gsd-core/workflows/discuss-phase/templates/context.md": "6cd929e989fe2b0f", + "gsd-core/workflows/discuss-phase/templates/discussion-log.md": "1bbd7703f11128e1", + "gsd-core/workflows/do.md": "149084d893a23024", + "gsd-core/workflows/docs-update.md": "cd753783ab95da00", + "gsd-core/workflows/edit-phase.md": "dbbb6191f5a8b65e", + "gsd-core/workflows/eval-review.md": "086a1f2b3c11462c", + "gsd-core/workflows/execute-phase.md": "d7d8ac751aa2915e", + "gsd-core/workflows/execute-phase/steps/codebase-drift-gate.md": "6d38bfd540030da4", + "gsd-core/workflows/execute-phase/steps/per-plan-worktree-gate.md": "7ebb7d1af6082028", + "gsd-core/workflows/execute-phase/steps/post-merge-gate.md": "611b2be3bd133eb1", + "gsd-core/workflows/execute-phase/steps/regression-gate.md": "016ac9c7c02b1438", + "gsd-core/workflows/execute-phase/steps/worktree-recovery-policy.md": "be84efbd71e1513e", + "gsd-core/workflows/execute-plan.md": "f17623fd47e795dd", + "gsd-core/workflows/explore.md": "95e463d4bdd6dadd", + "gsd-core/workflows/extract-learnings.md": "fd75072c339b58bd", + "gsd-core/workflows/fast.md": "54fe93778b45a7eb", + "gsd-core/workflows/forensics.md": "857d7b064f4cca21", + "gsd-core/workflows/graduation.md": "ecf8da93e094fd2e", + "gsd-core/workflows/health.md": "551e63aa6f3df711", + "gsd-core/workflows/help.md": "5d040504b9ab35e3", + "gsd-core/workflows/help/modes/brief.md": "fa2675516b40e2e3", + "gsd-core/workflows/help/modes/default.md": "be05e56b2c5ee2c0", + "gsd-core/workflows/help/modes/full.md": "ce40e843f528e327", + "gsd-core/workflows/help/modes/topic.md": "6e42db16f1568be9", + "gsd-core/workflows/import.md": "cc21f3da36403ed3", + "gsd-core/workflows/inbox.md": "91aac6360e1a8672", + "gsd-core/workflows/ingest-docs.md": "1d42ea1be30becc4", + "gsd-core/workflows/insert-phase.md": "ae977afd1509dc86", + "gsd-core/workflows/list-phase-assumptions.md": "2a6b6a5acfb7742c", + "gsd-core/workflows/list-seeds.md": "7576156b91e9abee", + "gsd-core/workflows/list-workspaces.md": "90caeeb11cec0128", + "gsd-core/workflows/manager.md": "36559ff897e55c09", + "gsd-core/workflows/map-codebase.md": "b11ca99a885e93ef", + "gsd-core/workflows/milestone-summary.md": "99636900c216c8d2", + "gsd-core/workflows/mvp-phase.md": "254baac57e85dca7", + "gsd-core/workflows/new-milestone.md": "fcb63a8b13c02d6e", + "gsd-core/workflows/new-project.md": "26907d3cf3630ed0", + "gsd-core/workflows/new-workspace.md": "26615bf710f0a324", + "gsd-core/workflows/next.md": "1193222c5618d3db", + "gsd-core/workflows/node-repair.md": "07a1628e5a1ff96b", + "gsd-core/workflows/note.md": "a2cc926854a5666c", + "gsd-core/workflows/onboard.md": "b86d78eef6c77e5b", + "gsd-core/workflows/pause-work.md": "da902807d2213204", + "gsd-core/workflows/plan-milestone-gaps.md": "7679fac068d1009d", + "gsd-core/workflows/plan-phase.md": "8e8331ca99bc8680", + "gsd-core/workflows/plan-phase/steps/closed-phase-gate.md": "4099ef6d0868de60", + "gsd-core/workflows/plan-phase/steps/prd-express-path.md": "b810f9f2374e23a5", + "gsd-core/workflows/plan-phase/steps/windows-troubleshooting.md": "e9de7a96bbfff261", + "gsd-core/workflows/plan-review-convergence.md": "1cabea77790fc16c", + "gsd-core/workflows/plant-seed.md": "fbe964fcdb244802", + "gsd-core/workflows/pr-branch.md": "513f6cff722eff2d", + "gsd-core/workflows/profile-user.md": "3b34dcb337d50f4b", + "gsd-core/workflows/progress.md": "2be3a57916eccf87", + "gsd-core/workflows/quick.md": "20f9dbfcd20b4b4b", + "gsd-core/workflows/reapply-patches.md": "44a96b52b975e9bb", + "gsd-core/workflows/remove-phase.md": "8effc8742d58a11a", + "gsd-core/workflows/remove-workspace.md": "10882656198d9075", + "gsd-core/workflows/resume-project.md": "af9761bcec0f6fe9", + "gsd-core/workflows/review.md": "34cb7ab671eb8684", + "gsd-core/workflows/scan.md": "75c670d08cee8680", + "gsd-core/workflows/secure-phase.md": "64ec4d06ca85720a", + "gsd-core/workflows/session-report.md": "2e5b1205324ddefa", + "gsd-core/workflows/settings-advanced.md": "2fc2738442f4f9de", + "gsd-core/workflows/settings-integrations.md": "dfe3672c4fabf139", + "gsd-core/workflows/settings.md": "ba138b058d91fd38", + "gsd-core/workflows/ship.md": "44af1c72d86e153b", + "gsd-core/workflows/sketch-wrap-up.md": "d52a5462bafda830", + "gsd-core/workflows/sketch.md": "dbe6acc4d976060c", + "gsd-core/workflows/smart-entry.md": "1850447c045f36d8", + "gsd-core/workflows/spec-phase.md": "e03fa9f1a44613dc", + "gsd-core/workflows/spike-wrap-up.md": "4bdfaf9d05c63e7c", + "gsd-core/workflows/spike.md": "1571a05457beea8d", + "gsd-core/workflows/stats.md": "3953356f476b5053", + "gsd-core/workflows/sync-skills.md": "5624d529dae1ad79", + "gsd-core/workflows/thread.md": "14a9d195572a198f", + "gsd-core/workflows/transition.md": "78a91b0154a93cf5", + "gsd-core/workflows/ui-phase.md": "57664a12509b455d", + "gsd-core/workflows/ui-review.md": "9c6005236e2067b5", + "gsd-core/workflows/ultraplan-phase.md": "b926ba7e4de0c76d", + "gsd-core/workflows/undo.md": "d759702f84e308fa", + "gsd-core/workflows/update.md": "2a59b4edf4a3c8c7", + "gsd-core/workflows/validate-phase.md": "83eeefeeca31c2b5", + "gsd-core/workflows/verify-phase.md": "6ae6f159be75dcdd", + "gsd-core/workflows/verify-work.md": "de14acdc8e925338", + "hooks/gsd-check-update-worker.js": "a530efdb5fdc0da3", + "hooks/gsd-check-update.js": "25cde66a12d6b886", + "hooks/gsd-config-reload.js": "96546e0e8bb47904", + "hooks/gsd-context-monitor.js": "ecbe9747e4a442e0", + "hooks/gsd-cursor-post-tool.js": "8a8a249c0642cc71", + "hooks/gsd-cursor-session-start.js": "05a14e903c5edafa", + "hooks/gsd-ensure-canonical-path.js": "b4b3b88a0e493b16", + "hooks/gsd-graphify-update.sh": "e4c6e14fe6ad64ff", + "hooks/gsd-phase-boundary.sh": "32739d5fbe0d0a1c", + "hooks/gsd-prompt-guard.js": "a749b8cb2c5248de", + "hooks/gsd-read-guard.js": "9e423cd03e2d1b16", + "hooks/gsd-read-injection-scanner.js": "00d2449afefd2e5f", + "hooks/gsd-session-state.sh": "e54379ba86bf1b6d", + "hooks/gsd-statusline.js": "7c315416ffc99a9a", + "hooks/gsd-update-banner.js": "b457746cb76c1957", + "hooks/gsd-validate-commit.sh": "bf5dd61d33cb3a38", + "hooks/gsd-workflow-guard.js": "59b46a74d19d58d3", + "hooks/gsd-worktree-path-guard.js": "02be1bb504b22eb5", + "hooks/lib/git-cmd.js": "268ba15992ca0b23", + "hooks/lib/gsd-graphify-rebuild.sh": "66af89601074d2a9", + "hooks/managed-hooks-registry.cjs": "ea876b1ec185173e", + "package.json": "dbf8353f77358bc1", + "scripts/changeset/README.md": "86ff89331dfd94b2", + "scripts/changeset/cli.cjs": "68f92a344b199271", + "scripts/changeset/github-release-notes.cjs": "795677f0c009b132", + "scripts/changeset/lint.cjs": "0066faed159154f0", + "scripts/changeset/new.cjs": "4991e21fd17f5541", + "scripts/changeset/parse.cjs": "f9a949cbcab56445", + "scripts/changeset/render.cjs": "e47bc3e1587c3cae", + "scripts/changeset/serialize.cjs": "ac0b8fe6f87cdb0e", + "scripts/fix-slash-commands.cjs": "0519742531ff3529", + "scripts/gen-capability-registry.cjs": "c52201ff4d1c2cd7", + "scripts/gen-loop-host-contract.cjs": "c7f15237234811a0", + "scripts/lib/allowlist-ratchet.cjs": "ffaceaac3efc2660", + "scripts/lib/cli-exit.cjs": "612d0c372c75b7e7" +} diff --git a/tests/golden-install-parity.test.cjs b/tests/golden-install-parity.test.cjs index 2bf4ff1e3..1742ab5ce 100644 --- a/tests/golden-install-parity.test.cjs +++ b/tests/golden-install-parity.test.cjs @@ -80,7 +80,9 @@ const PKG_VERSION = require('../package.json').version; // (install-minimal-hooks, sh-hook-paths, codex-config, etc.). Matched by basename. // settings.json = Claude/Antigravity/Augment/etc. hook surface; hooks.json = // Codex/Cursor hook surface — both embed the platform-varying node-runner command. -const HOOK_CONFIG_FILES = new Set(['settings.json', 'hooks.json']); +// settings.local.json = Claude LOCAL hook surface (#338): same platform-varying +// node-runner command as settings.json, so excluded for the same reason (#2086). +const HOOK_CONFIG_FILES = new Set(['settings.json', 'settings.local.json', 'hooks.json']); // Path prefixes excluded from the parity manifest. `gsd-core/bin/lib/` holds the // tsc-built runtime artifacts (compiled from src/*.cts) that the install COPIES @@ -111,6 +113,17 @@ function buildParityManifest(configDir, root) { const allFiles = walk(configDir); const unsorted = {}; + // The claude LOCAL install resolves its config dir via realpath, which on macOS + // prepends `/private` to the temp root (`/var/folders/…` -> `/private/var/folders/…`) + // and embeds that resolved path in the projected agents/commands/workflows (`@…` + // references). On Linux the temp root has no `/private` symlink, so normalizing + // ONLY `root` left the `/private` prefix on macOS and produced platform-divergent + // hashes (#2086). Normalize the realpath form FIRST (it is the longer, `/private`- + // prefixed string) so both platforms collapse to ``. No-op for the global + // fixtures (global install uses the literal `--config-dir`, never realpath-resolved). + let realRoot = root; + try { realRoot = fs.realpathSync(root); } catch { /* root already gone / not resolvable */ } + for (const full of allFiles) { // Build POSIX-style relative path for cross-platform stability const rel = path.relative(configDir, full).split(path.sep).join('/'); @@ -123,7 +136,10 @@ function buildParityManifest(configDir, root) { // Normalize every occurrence of the temp root so hashes are stable across runs. // Also normalize the package version so the golden survives `npm version` bumps // (the rc release step bakes the new version into hook files before running tests). - const normalized = content.toString('utf8').split(root).join('').split(PKG_VERSION).join(''); + const normalized = content.toString('utf8') + .split(realRoot).join('') + .split(root).join('') + .split(PKG_VERSION).join(''); const hash = crypto.createHash('sha256').update(normalized).digest('hex').slice(0, 16); unsorted[rel] = hash; } @@ -194,3 +210,53 @@ for (const runtime of runtimes) { } }); } + +// #2086 (EoS/claude): claude is the reference host and the ONLY runtime with a +// distinct LOCAL "legacy flat-commands" layout (commands/gsd-*.md + agents/gsd-*.md). +// The loop above asserts the GLOBAL skills layout; this asserts the LOCAL +// commands/agents layout is byte-identical too, so folding claude's +// `runtime === 'claude'` branches into descriptor-driven hostBehaviors cannot +// silently change the local install output (AC1: "both scopes"). NOTE: the +// settings.local.json ROUTING itself is excluded here (platform-varying node-runner +// path) — that dimension is covered directly by install.test.cjs's #338 suite. +test('golden parity — claude (local legacy layout)', async (t) => { + if (process.platform === 'win32') { + t.skip('install output is platform-specific on Windows (backslash paths); parity is asserted on macOS + Linux'); + return; + } + const { configDir, root } = runMinimalInstall({ runtime: 'claude', scope: 'local' }); + let actual; + try { + actual = buildParityManifest(configDir, root); + } finally { + cleanup(root); + } + + const fixturePath = path.join(FIXTURE_DIR, 'claude-local.json'); + + if (UPDATE) { + fs.writeFileSync(fixturePath, JSON.stringify(actual, null, 2) + '\n', 'utf8'); + process.stdout.write(` [UPDATE] claude-local: wrote ${Object.keys(actual).length} file hashes → ${fixturePath}\n`); + return; + } + + if (!fs.existsSync(fixturePath)) { + assert.fail( + `Golden fixture missing for claude-local: ${fixturePath}\n` + + 'Run UPDATE_GOLDEN=1 node --test tests/golden-install-parity.test.cjs to capture.', + ); + } + + const golden = JSON.parse(fs.readFileSync(fixturePath, 'utf8')); + const added = Object.keys(actual).filter(k => !(k in golden)); + const removed = Object.keys(golden).filter(k => !(k in actual)); + const changed = Object.keys(actual).filter(k => k in golden && actual[k] !== golden[k]); + if (added.length || removed.length || changed.length) { + const lines = ['Parity mismatch for claude-local:']; + if (added.length) lines.push(` added (${added.length}): ${added.join(', ')}`); + if (removed.length) lines.push(` removed (${removed.length}): ${removed.join(', ')}`); + if (changed.length) lines.push(` changed (${changed.length}): ${changed.join(', ')}`); + lines.push('Run UPDATE_GOLDEN=1 to recapture if the change is intentional.'); + assert.deepEqual(actual, golden, lines.join('\n')); + } +});