From fdd5e401ebc2f4cefb956441785de4a2c1a6e84c Mon Sep 17 00:00:00 2001 From: Tom Boucher Date: Wed, 8 Jul 2026 13:15:43 -0400 Subject: [PATCH 01/33] feat(architecture): [EoS/claude] drive claude through the imperative adapter + descriptor-driven hostBehaviors (#2086) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Fold Claude Code's install/uninstall onto the Embeddable Orchestration System (ADR-1239 Phase D). claude is GSD's tier-1 reference host, but its install path was still driven by 13 hardcoded `runtime === 'claude'` string-equality branches scattered across bin/install.js rather than the public Host-Integration Interface. - Route install()/uninstall() through `createImperativeAdapter({runtime})` — the adapter delegates to the SAME installRuntimeArtifacts/uninstallRuntimeArtifacts engine calls, so output is byte-identical (proven pre/post, both scopes). - Replace all 13 `runtime === 'claude'` / `runtime !== 'claude'` branches with descriptor-driven `runtime.hostBehaviors` lookups on capabilities/claude/ capability.json (attributionSource, authorsCanonicalWorkflow, localInstallStyle, permissionsSchema, settingsFileByScope, sourceMarkerFile, agentFrontmatterExtensions, ownsClaudePaths, nativeModelAliases, skillsGlobalOnboarding). Behavior is identical; the brittle string-equality coupling (the add-a-host tax) is gone. - Single-source the scattered literal 'claude' defaults/rosters behind DEFAULT_RUNTIME. - Extend golden-install-parity to assert the claude LOCAL legacy layout is byte-identical too (AC1 "both scopes"); exclude the platform-varying settings.local.json (same reason settings.json is excluded). - New tests/claude-imperative-reference.test.cjs: adapter kind, programmatic-cli profile, fail-closed negotiation on a corrupted/partial descriptor, and an AC2 source guard that no `runtime === 'claude'` branch remains. No user-visible install-output change (internal architecture only). Co-Authored-By: Claude Opus 4.8 --- .../2086-eos-claude-imperative-adapter.md | 5 + bin/install.js | 121 +++-- capabilities/claude/capability.json | 15 + .../add-or-update-a-host-integration.md | 45 ++ gsd-core/bin/lib/capability-registry.cjs | 34 ++ tests/claude-imperative-reference.test.cjs | 138 ++++++ .../golden-install-parity/claude-local.json | 418 ++++++++++++++++++ tests/golden-install-parity.test.cjs | 54 ++- 8 files changed, 799 insertions(+), 31 deletions(-) create mode 100644 .changeset/2086-eos-claude-imperative-adapter.md create mode 100644 tests/claude-imperative-reference.test.cjs create mode 100644 tests/fixtures/golden-install-parity/claude-local.json diff --git a/.changeset/2086-eos-claude-imperative-adapter.md b/.changeset/2086-eos-claude-imperative-adapter.md new file mode 100644 index 000000000..52d9e1762 --- /dev/null +++ b/.changeset/2086-eos-claude-imperative-adapter.md @@ -0,0 +1,5 @@ +--- +type: Changed +pr: 2086 +--- +**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..92acdb147 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,34 @@ try { } catch (_) { _capabilityRegistry = undefined; } + +/** + * 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. + */ +function _hostBehaviors(runtime) { + const cap = _capabilityRegistry && _capabilityRegistry.runtimes && _capabilityRegistry.runtimes[runtime]; + return (cap && cap.runtime && cap.runtime.hostBehaviors) || {}; +} + +/** + * 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 +1276,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 +6311,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 +6513,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 +6571,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 +6744,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 +7047,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 +7522,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 +7560,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 +7973,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 +8016,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 +8461,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 +8472,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 +8786,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 +8988,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 +9275,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 +9798,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 +10071,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 +10130,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 +10163,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 +10205,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 +10364,7 @@ function parseRuntimeInput(answer) { } } - return selected.length > 0 ? selected : ['claude']; + return selected.length > 0 ? selected : [DEFAULT_RUNTIME]; } function promptRuntime(callback) { @@ -10922,7 +10983,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) => { @@ -11206,7 +11267,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 +11279,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..94ef54df7 --- /dev/null +++ b/tests/claude-imperative-reference.test.cjs @@ -0,0 +1,138 @@ +// 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; + +// -- 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'); + const offenders = src.match(/runtime\s*[!=]==\s*'claude'/g) || []; + assert.deepEqual( + offenders, + [], + `AC2: every hardcoded runtime==='claude'/!=='claude' branch must be descriptor-driven; found: ${offenders.join(', ')}`, + ); +}); 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..6b39c5300 --- /dev/null +++ b/tests/fixtures/golden-install-parity/claude-local.json @@ -0,0 +1,418 @@ +{ + ".gsd-profile": "0e716a5fef4e6dc1", + "agents/gsd-advisor-researcher.md": "4e1a8afe45ef8298", + "agents/gsd-ai-researcher.md": "1de9bb9c75e3d668", + "agents/gsd-assumptions-analyzer.md": "63c8bdab78b42128", + "agents/gsd-code-fixer.md": "341e9d4e5c081f0e", + "agents/gsd-code-reviewer.md": "65009b1743904fd3", + "agents/gsd-codebase-mapper.md": "6b58924e7ab595b6", + "agents/gsd-debug-session-manager.md": "df63cabbe4d062a0", + "agents/gsd-debugger.md": "f6874f2e4e74c6fe", + "agents/gsd-doc-classifier.md": "4f52b062005868ec", + "agents/gsd-doc-synthesizer.md": "4be3aac2095d021d", + "agents/gsd-doc-verifier.md": "4232dcf9076e3566", + "agents/gsd-doc-writer.md": "a8af8a58c28e06b2", + "agents/gsd-domain-researcher.md": "c5f1f069f3124844", + "agents/gsd-eval-auditor.md": "d8debe9e60c26574", + "agents/gsd-eval-planner.md": "8b2359a18a618b04", + "agents/gsd-executor.md": "abf1192f0ac098dc", + "agents/gsd-framework-selector.md": "a359da24df68f01c", + "agents/gsd-integration-checker.md": "1d2ca1591310bf70", + "agents/gsd-intel-updater.md": "ae95a5f8b73a465b", + "agents/gsd-mempalace-curator.md": "77b53f1b155242b4", + "agents/gsd-nyquist-auditor.md": "06e42106449d17ac", + "agents/gsd-pattern-mapper.md": "b45b5e106775bec1", + "agents/gsd-phase-researcher.md": "90f97c741075c907", + "agents/gsd-plan-checker.md": "63e7988a3d7db5cf", + "agents/gsd-planner.md": "0fb1385f2ea179f0", + "agents/gsd-project-researcher.md": "78d92594b3de6e51", + "agents/gsd-research-synthesizer.md": "635639f831464f95", + "agents/gsd-roadmapper.md": "90d11d44ac7d5121", + "agents/gsd-security-auditor.md": "c8eead1524c5a465", + "agents/gsd-ui-auditor.md": "94868774ac120a38", + "agents/gsd-ui-checker.md": "505ca13a25cb67df", + "agents/gsd-ui-researcher.md": "4596a60f979dc769", + "agents/gsd-user-profiler.md": "a5cd095688d6de25", + "agents/gsd-verifier.md": "7327f257ade6d00f", + "commands/gsd-add-tests.md": "993d9c16477b17bf", + "commands/gsd-ai-integration-phase.md": "bd8407401cc52092", + "commands/gsd-audit-fix.md": "0ce7dff55a550d96", + "commands/gsd-audit-milestone.md": "506d3d20f55d80ce", + "commands/gsd-audit-uat.md": "4aefa85d28437405", + "commands/gsd-autonomous.md": "ef5a492c9085b983", + "commands/gsd-capture.md": "61647c8baefcb116", + "commands/gsd-cleanup.md": "88e8c3d4b471a036", + "commands/gsd-code-review.md": "98a9c59ff3edb4ab", + "commands/gsd-complete-milestone.md": "7639dce1b9e755e4", + "commands/gsd-config.md": "b36340912b70c073", + "commands/gsd-debug.md": "e622240e54587e1b", + "commands/gsd-discuss-phase.md": "9eebccc3820ad37a", + "commands/gsd-docs-update.md": "a11bedfbfd0a56db", + "commands/gsd-eval-review.md": "dc602abbb7525761", + "commands/gsd-execute-phase.md": "f47b83e2110f7be8", + "commands/gsd-explore.md": "dbef734b08f7438a", + "commands/gsd-extract-learnings.md": "f3059f4e6e6b7b19", + "commands/gsd-fast.md": "be784c9f67ae3eab", + "commands/gsd-forensics.md": "36ec35a6a6f2cfd7", + "commands/gsd-graphify.md": "1b88285a888f69dd", + "commands/gsd-health.md": "f28dabf45c4ddd77", + "commands/gsd-help.md": "e48f4e1cf9ff3ad3", + "commands/gsd-import.md": "849c4a4bdfc05ebf", + "commands/gsd-inbox.md": "61eebdd7397efe7f", + "commands/gsd-ingest-docs.md": "10b1c8fee7ed7c1f", + "commands/gsd-manager.md": "0c5541f3db284fe3", + "commands/gsd-map-codebase.md": "9c32f13ade27d418", + "commands/gsd-mempalace-capture.md": "2e49397072506fd9", + "commands/gsd-mempalace-recall.md": "38716c0983a3ef9c", + "commands/gsd-milestone-summary.md": "a7fdafaaa3a665af", + "commands/gsd-mvp-phase.md": "fe9bf9df99007be1", + "commands/gsd-new-milestone.md": "d2f21e6e3d53b2e9", + "commands/gsd-new-project.md": "2129c259fa653b19", + "commands/gsd-next.md": "02d0abbd2f55a0a2", + "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": "2d98ea48454293b5", + "commands/gsd-pause-work.md": "bdfc8f46a674061a", + "commands/gsd-phase.md": "b211ba5969b4d69b", + "commands/gsd-plan-phase.md": "8113654accd5bee9", + "commands/gsd-plan-review-convergence.md": "3d496dbbba45190c", + "commands/gsd-pr-branch.md": "2346583e447a46d6", + "commands/gsd-profile-user.md": "8cc1a47fe685b60e", + "commands/gsd-progress.md": "0eddf50b039adc8e", + "commands/gsd-quick.md": "116941d841ecfb07", + "commands/gsd-resume-work.md": "330cd86844bd768b", + "commands/gsd-review-backlog.md": "434ebe7b63c107e0", + "commands/gsd-review.md": "65b5225d8405f290", + "commands/gsd-secure-phase.md": "1ab3de7d982a7b9a", + "commands/gsd-settings.md": "13a4293f7fa58276", + "commands/gsd-ship.md": "551dc7f66e14a3de", + "commands/gsd-sketch.md": "0d01a055aa4b00ee", + "commands/gsd-spec-phase.md": "b3f6c26deba9172f", + "commands/gsd-spike.md": "7c102d63239f6678", + "commands/gsd-stats.md": "dc7c484c0fc2c177", + "commands/gsd-surface.md": "81c0c0d634ae0375", + "commands/gsd-thread.md": "4d9ac2986e090f5f", + "commands/gsd-ui-phase.md": "19ab8467a3f737a6", + "commands/gsd-ui-review.md": "5e0e6a377719a125", + "commands/gsd-ultraplan-phase.md": "29426afa8f6bcef3", + "commands/gsd-undo.md": "3a8299018ada31f6", + "commands/gsd-update.md": "22f2060ab482d569", + "commands/gsd-validate-phase.md": "079a0013bd14c1bd", + "commands/gsd-verify-work.md": "a7b07cc4547e7099", + "commands/gsd-workspace.md": "24b4c632d9773c11", + "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": "08e3c3aa1f2ac11e", + "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": "338acaf52853668c", + "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": "92e6574b30f5b765", + "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": "46173caac1d469d4", + "gsd-core/references/honest-verifier.md": "6e4b8293f644b3f1", + "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": "a2fa775cac3470fa", + "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": "563d1a434304d5cc", + "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": "0126c6156a9d32a8", + "gsd-core/references/planner-mvp-mode.md": "f7fc3894770a73f8", + "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": "7612bb55f00359bb", + "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": "d22e89615c846b72", + "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": "57c67c5fd375fcb3", + "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": "b106ec2b588d2b51", + "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": "d07f52e21f15f2ab", + "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": "450f70cb4ba4bf78", + "gsd-core/workflows/add-phase.md": "500274ecffbc2b12", + "gsd-core/workflows/add-tests.md": "575110e32af4eac3", + "gsd-core/workflows/add-todo.md": "4f68274dbf475704", + "gsd-core/workflows/ai-integration-phase.md": "1be55b37af12a639", + "gsd-core/workflows/analyze-dependencies.md": "77aff48f97fa6f1c", + "gsd-core/workflows/audit-fix.md": "bab7ebaee574e486", + "gsd-core/workflows/audit-milestone.md": "3b5667e761f97774", + "gsd-core/workflows/audit-uat.md": "470c4637eb53b4a3", + "gsd-core/workflows/autonomous.md": "275e66d559d76792", + "gsd-core/workflows/check-todos.md": "dd1504bb42b17021", + "gsd-core/workflows/cleanup.md": "e06b655e5e901031", + "gsd-core/workflows/code-review-fix.md": "c6d26d40e143acca", + "gsd-core/workflows/code-review.md": "1bb99932450e9ced", + "gsd-core/workflows/complete-milestone.md": "7f0346ab9b6ab2b4", + "gsd-core/workflows/debug.md": "722e00ef94fdb5c2", + "gsd-core/workflows/diagnose-issues.md": "17fa373be09a0b82", + "gsd-core/workflows/discovery-phase.md": "b9f80db0c22d723a", + "gsd-core/workflows/discuss-phase-assumptions.md": "e3693f4e1746bc13", + "gsd-core/workflows/discuss-phase-power.md": "290c0d83d783f9f6", + "gsd-core/workflows/discuss-phase.md": "6f5c0280e386a12e", + "gsd-core/workflows/discuss-phase/modes/advisor.md": "93b4ff65a03a5487", + "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": "fe5daf7c8617e26b", + "gsd-core/workflows/discuss-phase/modes/batch.md": "6946597770e2d448", + "gsd-core/workflows/discuss-phase/modes/chain.md": "2d1f9f2396f5d0f4", + "gsd-core/workflows/discuss-phase/modes/default.md": "67d1b67f61f03966", + "gsd-core/workflows/discuss-phase/modes/power.md": "7c10e13c60b73af0", + "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": "e55fe97bac5d691f", + "gsd-core/workflows/docs-update.md": "fff626c2033d34d2", + "gsd-core/workflows/edit-phase.md": "67efb8a73213ea5a", + "gsd-core/workflows/eval-review.md": "db4b8795864f104e", + "gsd-core/workflows/execute-phase.md": "4ef4e55d34c9c605", + "gsd-core/workflows/execute-phase/steps/codebase-drift-gate.md": "839b0006c551f9fb", + "gsd-core/workflows/execute-phase/steps/per-plan-worktree-gate.md": "7ebb7d1af6082028", + "gsd-core/workflows/execute-phase/steps/post-merge-gate.md": "cfe19fe951583f3f", + "gsd-core/workflows/execute-phase/steps/regression-gate.md": "e40c5eb516a9e052", + "gsd-core/workflows/execute-phase/steps/worktree-recovery-policy.md": "be84efbd71e1513e", + "gsd-core/workflows/execute-plan.md": "5d66d59ce03f88ba", + "gsd-core/workflows/explore.md": "eb4a623b5732303f", + "gsd-core/workflows/extract-learnings.md": "d1e0a14a9df00195", + "gsd-core/workflows/fast.md": "54fe93778b45a7eb", + "gsd-core/workflows/forensics.md": "f66bb7b50dcc918a", + "gsd-core/workflows/graduation.md": "35ebdec95d86d45b", + "gsd-core/workflows/health.md": "bcc07e9610635d49", + "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": "579577e8095f8fef", + "gsd-core/workflows/help/modes/topic.md": "6e42db16f1568be9", + "gsd-core/workflows/import.md": "88e6ef80b6d1db63", + "gsd-core/workflows/inbox.md": "91aac6360e1a8672", + "gsd-core/workflows/ingest-docs.md": "59224a43a74f0f88", + "gsd-core/workflows/insert-phase.md": "653460de68191dd7", + "gsd-core/workflows/list-phase-assumptions.md": "2a6b6a5acfb7742c", + "gsd-core/workflows/list-seeds.md": "c7026201e80f21ff", + "gsd-core/workflows/list-workspaces.md": "2c2f5e466cf97136", + "gsd-core/workflows/manager.md": "f0835592a5f4ca39", + "gsd-core/workflows/map-codebase.md": "a2c4e72aac41f477", + "gsd-core/workflows/milestone-summary.md": "4ad8a311ebf766a8", + "gsd-core/workflows/mvp-phase.md": "e459fa1351dbb663", + "gsd-core/workflows/new-milestone.md": "417bbc34f965997f", + "gsd-core/workflows/new-project.md": "4c7c381c8bc0bd98", + "gsd-core/workflows/new-workspace.md": "f907fed6831f3f32", + "gsd-core/workflows/next.md": "f2f2210290a02171", + "gsd-core/workflows/node-repair.md": "07a1628e5a1ff96b", + "gsd-core/workflows/note.md": "def2655cebe5c001", + "gsd-core/workflows/onboard.md": "9ae63d270f8fd7c0", + "gsd-core/workflows/pause-work.md": "9655ec327c5aa307", + "gsd-core/workflows/plan-milestone-gaps.md": "3be3a097114fdde6", + "gsd-core/workflows/plan-phase.md": "b1643a49267f3f01", + "gsd-core/workflows/plan-phase/steps/closed-phase-gate.md": "4099ef6d0868de60", + "gsd-core/workflows/plan-phase/steps/prd-express-path.md": "af3029d3c899f4f2", + "gsd-core/workflows/plan-phase/steps/windows-troubleshooting.md": "e9de7a96bbfff261", + "gsd-core/workflows/plan-review-convergence.md": "b09378bcc5571ca4", + "gsd-core/workflows/plant-seed.md": "4f51d8d267fd56a5", + "gsd-core/workflows/pr-branch.md": "8f6db372a275f7b1", + "gsd-core/workflows/profile-user.md": "aeb21d7386c4a41e", + "gsd-core/workflows/progress.md": "b482c9befd4af94a", + "gsd-core/workflows/quick.md": "668e7e8370ba9992", + "gsd-core/workflows/reapply-patches.md": "6466079b57163be0", + "gsd-core/workflows/remove-phase.md": "7cc144da60d95241", + "gsd-core/workflows/remove-workspace.md": "53e9575a8c4fb411", + "gsd-core/workflows/resume-project.md": "20de3d2834ee581a", + "gsd-core/workflows/review.md": "5ea70bc48614f68f", + "gsd-core/workflows/scan.md": "edba4c0bac5727a6", + "gsd-core/workflows/secure-phase.md": "265b3244a74a9bdd", + "gsd-core/workflows/session-report.md": "2e5b1205324ddefa", + "gsd-core/workflows/settings-advanced.md": "d148d5233169883f", + "gsd-core/workflows/settings-integrations.md": "b03bdf37b4f5310b", + "gsd-core/workflows/settings.md": "c24424577729f318", + "gsd-core/workflows/ship.md": "4d0fb3bb33f286f8", + "gsd-core/workflows/sketch-wrap-up.md": "47cb472ba0445c10", + "gsd-core/workflows/sketch.md": "d4de5174c8242425", + "gsd-core/workflows/smart-entry.md": "3fe82b4a6e67d0cf", + "gsd-core/workflows/spec-phase.md": "b35b7733e8ece267", + "gsd-core/workflows/spike-wrap-up.md": "496a6afc08c42a8d", + "gsd-core/workflows/spike.md": "3b2a826377c451ff", + "gsd-core/workflows/stats.md": "75618011901cebce", + "gsd-core/workflows/sync-skills.md": "452c01c7a05b238c", + "gsd-core/workflows/thread.md": "9d9cf44f7f6d788e", + "gsd-core/workflows/transition.md": "18f610382e9641f6", + "gsd-core/workflows/ui-phase.md": "4c8adbabd9f71a78", + "gsd-core/workflows/ui-review.md": "108e094e6aa085f6", + "gsd-core/workflows/ultraplan-phase.md": "e759430ce77b45db", + "gsd-core/workflows/undo.md": "e1eeffa679786c8a", + "gsd-core/workflows/update.md": "c276a4f628f4b948", + "gsd-core/workflows/validate-phase.md": "96c5ee354b72af56", + "gsd-core/workflows/verify-phase.md": "5ee79da8a9e787b3", + "gsd-core/workflows/verify-work.md": "25c8dd4c6ca6b5ae", + "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..c7f9f7817 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 @@ -194,3 +196,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')); + } +}); From cf159f79292ff864689f3a3083d1c2375b891e00 Mon Sep 17 00:00:00 2001 From: Tom Boucher Date: Wed, 8 Jul 2026 14:38:32 -0400 Subject: [PATCH 02/33] docs(changeset): backfill pr 2106 for #2086 changeset Co-Authored-By: Claude Opus 4.8 --- .changeset/2086-eos-claude-imperative-adapter.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.changeset/2086-eos-claude-imperative-adapter.md b/.changeset/2086-eos-claude-imperative-adapter.md index 52d9e1762..73ab37742 100644 --- a/.changeset/2086-eos-claude-imperative-adapter.md +++ b/.changeset/2086-eos-claude-imperative-adapter.md @@ -1,5 +1,5 @@ --- type: Changed -pr: 2086 +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) From ff6e9285308400701f789bb13f2b431988864f1b Mon Sep 17 00:00:00 2001 From: Tom Boucher Date: Wed, 8 Jul 2026 14:49:58 -0400 Subject: [PATCH 03/33] fix(#2086): normalize realpath temp root in golden parity manifest (macOS /private) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The claude LOCAL install resolves its config dir via realpath, which on macOS prepends /private to the temp root and embeds it in projected agents/commands/ workflows (@ references). buildParityManifest normalized only `root` (/var/folders/…), leaving the /private prefix on macOS while Linux has none — so the mac-generated claude-local fixture failed the Linux CI leg (198 files). Normalize the realpath form too; no-op for the global fixtures (literal --config-dir, never realpath-resolved). Regenerated claude-local.json now matches the Linux hashes. Co-Authored-By: Claude Opus 4.8 --- .../golden-install-parity/claude-local.json | 396 +++++++++--------- tests/golden-install-parity.test.cjs | 16 +- 2 files changed, 213 insertions(+), 199 deletions(-) diff --git a/tests/fixtures/golden-install-parity/claude-local.json b/tests/fixtures/golden-install-parity/claude-local.json index 6b39c5300..72a5c64bb 100644 --- a/tests/fixtures/golden-install-parity/claude-local.json +++ b/tests/fixtures/golden-install-parity/claude-local.json @@ -1,109 +1,109 @@ { ".gsd-profile": "0e716a5fef4e6dc1", - "agents/gsd-advisor-researcher.md": "4e1a8afe45ef8298", - "agents/gsd-ai-researcher.md": "1de9bb9c75e3d668", - "agents/gsd-assumptions-analyzer.md": "63c8bdab78b42128", - "agents/gsd-code-fixer.md": "341e9d4e5c081f0e", - "agents/gsd-code-reviewer.md": "65009b1743904fd3", - "agents/gsd-codebase-mapper.md": "6b58924e7ab595b6", - "agents/gsd-debug-session-manager.md": "df63cabbe4d062a0", - "agents/gsd-debugger.md": "f6874f2e4e74c6fe", - "agents/gsd-doc-classifier.md": "4f52b062005868ec", - "agents/gsd-doc-synthesizer.md": "4be3aac2095d021d", + "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": "a8af8a58c28e06b2", - "agents/gsd-domain-researcher.md": "c5f1f069f3124844", - "agents/gsd-eval-auditor.md": "d8debe9e60c26574", - "agents/gsd-eval-planner.md": "8b2359a18a618b04", - "agents/gsd-executor.md": "abf1192f0ac098dc", - "agents/gsd-framework-selector.md": "a359da24df68f01c", - "agents/gsd-integration-checker.md": "1d2ca1591310bf70", - "agents/gsd-intel-updater.md": "ae95a5f8b73a465b", + "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": "06e42106449d17ac", + "agents/gsd-nyquist-auditor.md": "f86a28f5c164a0d3", "agents/gsd-pattern-mapper.md": "b45b5e106775bec1", - "agents/gsd-phase-researcher.md": "90f97c741075c907", - "agents/gsd-plan-checker.md": "63e7988a3d7db5cf", - "agents/gsd-planner.md": "0fb1385f2ea179f0", - "agents/gsd-project-researcher.md": "78d92594b3de6e51", - "agents/gsd-research-synthesizer.md": "635639f831464f95", - "agents/gsd-roadmapper.md": "90d11d44ac7d5121", - "agents/gsd-security-auditor.md": "c8eead1524c5a465", - "agents/gsd-ui-auditor.md": "94868774ac120a38", - "agents/gsd-ui-checker.md": "505ca13a25cb67df", - "agents/gsd-ui-researcher.md": "4596a60f979dc769", - "agents/gsd-user-profiler.md": "a5cd095688d6de25", - "agents/gsd-verifier.md": "7327f257ade6d00f", - "commands/gsd-add-tests.md": "993d9c16477b17bf", - "commands/gsd-ai-integration-phase.md": "bd8407401cc52092", - "commands/gsd-audit-fix.md": "0ce7dff55a550d96", - "commands/gsd-audit-milestone.md": "506d3d20f55d80ce", - "commands/gsd-audit-uat.md": "4aefa85d28437405", - "commands/gsd-autonomous.md": "ef5a492c9085b983", - "commands/gsd-capture.md": "61647c8baefcb116", - "commands/gsd-cleanup.md": "88e8c3d4b471a036", - "commands/gsd-code-review.md": "98a9c59ff3edb4ab", - "commands/gsd-complete-milestone.md": "7639dce1b9e755e4", - "commands/gsd-config.md": "b36340912b70c073", - "commands/gsd-debug.md": "e622240e54587e1b", - "commands/gsd-discuss-phase.md": "9eebccc3820ad37a", - "commands/gsd-docs-update.md": "a11bedfbfd0a56db", - "commands/gsd-eval-review.md": "dc602abbb7525761", - "commands/gsd-execute-phase.md": "f47b83e2110f7be8", - "commands/gsd-explore.md": "dbef734b08f7438a", - "commands/gsd-extract-learnings.md": "f3059f4e6e6b7b19", - "commands/gsd-fast.md": "be784c9f67ae3eab", - "commands/gsd-forensics.md": "36ec35a6a6f2cfd7", - "commands/gsd-graphify.md": "1b88285a888f69dd", - "commands/gsd-health.md": "f28dabf45c4ddd77", - "commands/gsd-help.md": "e48f4e1cf9ff3ad3", - "commands/gsd-import.md": "849c4a4bdfc05ebf", - "commands/gsd-inbox.md": "61eebdd7397efe7f", - "commands/gsd-ingest-docs.md": "10b1c8fee7ed7c1f", - "commands/gsd-manager.md": "0c5541f3db284fe3", - "commands/gsd-map-codebase.md": "9c32f13ade27d418", + "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": "a7fdafaaa3a665af", - "commands/gsd-mvp-phase.md": "fe9bf9df99007be1", - "commands/gsd-new-milestone.md": "d2f21e6e3d53b2e9", - "commands/gsd-new-project.md": "2129c259fa653b19", - "commands/gsd-next.md": "02d0abbd2f55a0a2", + "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": "2d98ea48454293b5", - "commands/gsd-pause-work.md": "bdfc8f46a674061a", - "commands/gsd-phase.md": "b211ba5969b4d69b", - "commands/gsd-plan-phase.md": "8113654accd5bee9", - "commands/gsd-plan-review-convergence.md": "3d496dbbba45190c", - "commands/gsd-pr-branch.md": "2346583e447a46d6", - "commands/gsd-profile-user.md": "8cc1a47fe685b60e", - "commands/gsd-progress.md": "0eddf50b039adc8e", - "commands/gsd-quick.md": "116941d841ecfb07", - "commands/gsd-resume-work.md": "330cd86844bd768b", + "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": "65b5225d8405f290", - "commands/gsd-secure-phase.md": "1ab3de7d982a7b9a", - "commands/gsd-settings.md": "13a4293f7fa58276", - "commands/gsd-ship.md": "551dc7f66e14a3de", - "commands/gsd-sketch.md": "0d01a055aa4b00ee", - "commands/gsd-spec-phase.md": "b3f6c26deba9172f", - "commands/gsd-spike.md": "7c102d63239f6678", - "commands/gsd-stats.md": "dc7c484c0fc2c177", - "commands/gsd-surface.md": "81c0c0d634ae0375", - "commands/gsd-thread.md": "4d9ac2986e090f5f", - "commands/gsd-ui-phase.md": "19ab8467a3f737a6", - "commands/gsd-ui-review.md": "5e0e6a377719a125", - "commands/gsd-ultraplan-phase.md": "29426afa8f6bcef3", - "commands/gsd-undo.md": "3a8299018ada31f6", - "commands/gsd-update.md": "22f2060ab482d569", - "commands/gsd-validate-phase.md": "079a0013bd14c1bd", - "commands/gsd-verify-work.md": "a7b07cc4547e7099", - "commands/gsd-workspace.md": "24b4c632d9773c11", + "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", @@ -123,11 +123,11 @@ "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": "08e3c3aa1f2ac11e", + "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": "338acaf52853668c", + "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", @@ -146,7 +146,7 @@ "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": "92e6574b30f5b765", + "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", @@ -158,12 +158,12 @@ "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": "46173caac1d469d4", - "gsd-core/references/honest-verifier.md": "6e4b8293f644b3f1", + "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": "a2fa775cac3470fa", + "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", @@ -171,11 +171,11 @@ "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": "563d1a434304d5cc", + "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": "0126c6156a9d32a8", - "gsd-core/references/planner-mvp-mode.md": "f7fc3894770a73f8", + "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", @@ -183,7 +183,7 @@ "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": "7612bb55f00359bb", + "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", @@ -198,7 +198,7 @@ "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": "d22e89615c846b72", + "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", @@ -213,7 +213,7 @@ "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": "57c67c5fd375fcb3", + "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", @@ -231,7 +231,7 @@ "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": "b106ec2b588d2b51", + "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", @@ -243,7 +243,7 @@ "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": "d07f52e21f15f2ab", + "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", @@ -265,121 +265,121 @@ "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": "450f70cb4ba4bf78", - "gsd-core/workflows/add-phase.md": "500274ecffbc2b12", - "gsd-core/workflows/add-tests.md": "575110e32af4eac3", - "gsd-core/workflows/add-todo.md": "4f68274dbf475704", - "gsd-core/workflows/ai-integration-phase.md": "1be55b37af12a639", + "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": "bab7ebaee574e486", - "gsd-core/workflows/audit-milestone.md": "3b5667e761f97774", - "gsd-core/workflows/audit-uat.md": "470c4637eb53b4a3", - "gsd-core/workflows/autonomous.md": "275e66d559d76792", - "gsd-core/workflows/check-todos.md": "dd1504bb42b17021", - "gsd-core/workflows/cleanup.md": "e06b655e5e901031", - "gsd-core/workflows/code-review-fix.md": "c6d26d40e143acca", - "gsd-core/workflows/code-review.md": "1bb99932450e9ced", - "gsd-core/workflows/complete-milestone.md": "7f0346ab9b6ab2b4", - "gsd-core/workflows/debug.md": "722e00ef94fdb5c2", - "gsd-core/workflows/diagnose-issues.md": "17fa373be09a0b82", - "gsd-core/workflows/discovery-phase.md": "b9f80db0c22d723a", - "gsd-core/workflows/discuss-phase-assumptions.md": "e3693f4e1746bc13", + "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": "6f5c0280e386a12e", - "gsd-core/workflows/discuss-phase/modes/advisor.md": "93b4ff65a03a5487", + "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": "fe5daf7c8617e26b", + "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": "2d1f9f2396f5d0f4", + "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": "7c10e13c60b73af0", + "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": "e55fe97bac5d691f", - "gsd-core/workflows/docs-update.md": "fff626c2033d34d2", - "gsd-core/workflows/edit-phase.md": "67efb8a73213ea5a", - "gsd-core/workflows/eval-review.md": "db4b8795864f104e", - "gsd-core/workflows/execute-phase.md": "4ef4e55d34c9c605", - "gsd-core/workflows/execute-phase/steps/codebase-drift-gate.md": "839b0006c551f9fb", + "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": "cfe19fe951583f3f", - "gsd-core/workflows/execute-phase/steps/regression-gate.md": "e40c5eb516a9e052", + "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": "5d66d59ce03f88ba", - "gsd-core/workflows/explore.md": "eb4a623b5732303f", - "gsd-core/workflows/extract-learnings.md": "d1e0a14a9df00195", + "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": "f66bb7b50dcc918a", - "gsd-core/workflows/graduation.md": "35ebdec95d86d45b", - "gsd-core/workflows/health.md": "bcc07e9610635d49", + "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": "579577e8095f8fef", + "gsd-core/workflows/help/modes/full.md": "ce40e843f528e327", "gsd-core/workflows/help/modes/topic.md": "6e42db16f1568be9", - "gsd-core/workflows/import.md": "88e6ef80b6d1db63", + "gsd-core/workflows/import.md": "cc21f3da36403ed3", "gsd-core/workflows/inbox.md": "91aac6360e1a8672", - "gsd-core/workflows/ingest-docs.md": "59224a43a74f0f88", - "gsd-core/workflows/insert-phase.md": "653460de68191dd7", + "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": "c7026201e80f21ff", - "gsd-core/workflows/list-workspaces.md": "2c2f5e466cf97136", - "gsd-core/workflows/manager.md": "f0835592a5f4ca39", - "gsd-core/workflows/map-codebase.md": "a2c4e72aac41f477", - "gsd-core/workflows/milestone-summary.md": "4ad8a311ebf766a8", - "gsd-core/workflows/mvp-phase.md": "e459fa1351dbb663", - "gsd-core/workflows/new-milestone.md": "417bbc34f965997f", - "gsd-core/workflows/new-project.md": "4c7c381c8bc0bd98", - "gsd-core/workflows/new-workspace.md": "f907fed6831f3f32", - "gsd-core/workflows/next.md": "f2f2210290a02171", + "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": "def2655cebe5c001", - "gsd-core/workflows/onboard.md": "9ae63d270f8fd7c0", - "gsd-core/workflows/pause-work.md": "9655ec327c5aa307", - "gsd-core/workflows/plan-milestone-gaps.md": "3be3a097114fdde6", - "gsd-core/workflows/plan-phase.md": "b1643a49267f3f01", + "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": "af3029d3c899f4f2", + "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": "b09378bcc5571ca4", - "gsd-core/workflows/plant-seed.md": "4f51d8d267fd56a5", - "gsd-core/workflows/pr-branch.md": "8f6db372a275f7b1", - "gsd-core/workflows/profile-user.md": "aeb21d7386c4a41e", - "gsd-core/workflows/progress.md": "b482c9befd4af94a", - "gsd-core/workflows/quick.md": "668e7e8370ba9992", - "gsd-core/workflows/reapply-patches.md": "6466079b57163be0", - "gsd-core/workflows/remove-phase.md": "7cc144da60d95241", - "gsd-core/workflows/remove-workspace.md": "53e9575a8c4fb411", - "gsd-core/workflows/resume-project.md": "20de3d2834ee581a", - "gsd-core/workflows/review.md": "5ea70bc48614f68f", - "gsd-core/workflows/scan.md": "edba4c0bac5727a6", - "gsd-core/workflows/secure-phase.md": "265b3244a74a9bdd", + "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": "d148d5233169883f", - "gsd-core/workflows/settings-integrations.md": "b03bdf37b4f5310b", - "gsd-core/workflows/settings.md": "c24424577729f318", - "gsd-core/workflows/ship.md": "4d0fb3bb33f286f8", - "gsd-core/workflows/sketch-wrap-up.md": "47cb472ba0445c10", - "gsd-core/workflows/sketch.md": "d4de5174c8242425", - "gsd-core/workflows/smart-entry.md": "3fe82b4a6e67d0cf", - "gsd-core/workflows/spec-phase.md": "b35b7733e8ece267", - "gsd-core/workflows/spike-wrap-up.md": "496a6afc08c42a8d", - "gsd-core/workflows/spike.md": "3b2a826377c451ff", - "gsd-core/workflows/stats.md": "75618011901cebce", - "gsd-core/workflows/sync-skills.md": "452c01c7a05b238c", - "gsd-core/workflows/thread.md": "9d9cf44f7f6d788e", - "gsd-core/workflows/transition.md": "18f610382e9641f6", - "gsd-core/workflows/ui-phase.md": "4c8adbabd9f71a78", - "gsd-core/workflows/ui-review.md": "108e094e6aa085f6", - "gsd-core/workflows/ultraplan-phase.md": "e759430ce77b45db", - "gsd-core/workflows/undo.md": "e1eeffa679786c8a", - "gsd-core/workflows/update.md": "c276a4f628f4b948", - "gsd-core/workflows/validate-phase.md": "96c5ee354b72af56", - "gsd-core/workflows/verify-phase.md": "5ee79da8a9e787b3", - "gsd-core/workflows/verify-work.md": "25c8dd4c6ca6b5ae", + "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", diff --git a/tests/golden-install-parity.test.cjs b/tests/golden-install-parity.test.cjs index c7f9f7817..1742ab5ce 100644 --- a/tests/golden-install-parity.test.cjs +++ b/tests/golden-install-parity.test.cjs @@ -113,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('/'); @@ -125,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; } From 102ffa0f9ee6a0d018ca48e3c846fcbf96c58fdc Mon Sep 17 00:00:00 2001 From: Tom Boucher Date: Wed, 8 Jul 2026 15:19:12 -0400 Subject: [PATCH 04/33] fix(#2086): #338 fail-safe floor for reference-host behaviors on registry-load failure MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Reviewer (PR #2106, elevated): if capability-registry.cjs fails to load, _hostBehaviors('claude') returned {} — silently routing a claude LOCAL install to the repo-shared settings.json instead of the gitignored settings.local.json (#338), skipping mergeClaudePermissions + the .gsd-source marker. The migration is what introduced that registry dependency (pre-PR the path had none). Add FALLBACK_HOST_BEHAVIORS (keyed by runtime id — a data lookup, not a runtime==='claude' branch) mirroring the reference host's #338-privacy-critical keys (settingsFileByScope, permissionsSchema, sourceMarkerFile), consulted only when the registry (or the descriptor) is unavailable. Behavior degrades CLOSED, never open; the live descriptor stays the source of truth. Normal (registry-present) output is unchanged (golden parity preserved). Pinned by tests via a registry-injected _resolveHostBehaviors helper. Co-Authored-By: Claude Opus 4.8 --- bin/install.js | 39 ++++++++++++++++++++-- tests/claude-imperative-reference.test.cjs | 38 +++++++++++++++++++++ 2 files changed, 74 insertions(+), 3 deletions(-) diff --git a/bin/install.js b/bin/install.js index 92acdb147..a214124cf 100755 --- a/bin/install.js +++ b/bin/install.js @@ -315,16 +315,46 @@ try { _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 +// `runtime === 'claude'` 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. + * 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) { - const cap = _capabilityRegistry && _capabilityRegistry.runtimes && _capabilityRegistry.runtimes[runtime]; - return (cap && cap.runtime && cap.runtime.hostBehaviors) || {}; + return _resolveHostBehaviors(runtime, _capabilityRegistry); } /** @@ -11099,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, diff --git a/tests/claude-imperative-reference.test.cjs b/tests/claude-imperative-reference.test.cjs index 94ef54df7..8b67c3dfd 100644 --- a/tests/claude-imperative-reference.test.cjs +++ b/tests/claude-imperative-reference.test.cjs @@ -36,6 +36,11 @@ const CLAUDE_CAP = JSON.parse( ); 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', () => { @@ -136,3 +141,36 @@ test('bin/install.js contains no `runtime === "claude"` / `runtime !== "claude"` `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), {}); +}); From eeec6b512e8d02adf338eb64347b11d20596bf75 Mon Sep 17 00:00:00 2001 From: Tom Boucher Date: Wed, 8 Jul 2026 15:26:19 -0400 Subject: [PATCH 05/33] fix(#2086): AC2 source-guard must ignore comments/backtick prose, not just code The #338 fail-safe commit added a comment containing the literal `runtime === 'claude'` (explaining what the data lookup is NOT), which the AC2 source-grep test matched as a false positive (the test read the whole file, prose included). Strip block/line comments + backtick spans before matching so the guard flags only LIVE code, and reword the comment. CRLF-safe line-comment strip. Co-Authored-By: Claude Opus 4.8 --- bin/install.js | 2 +- tests/claude-imperative-reference.test.cjs | 9 ++++++++- 2 files changed, 9 insertions(+), 2 deletions(-) diff --git a/bin/install.js b/bin/install.js index a214124cf..0a9f2dd10 100755 --- a/bin/install.js +++ b/bin/install.js @@ -321,7 +321,7 @@ try { // {} 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 -// `runtime === 'claude'` branch) so behavior degrades CLOSED (safe), never open. +// 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({ diff --git a/tests/claude-imperative-reference.test.cjs b/tests/claude-imperative-reference.test.cjs index 8b67c3dfd..3cb3245ef 100644 --- a/tests/claude-imperative-reference.test.cjs +++ b/tests/claude-imperative-reference.test.cjs @@ -134,7 +134,14 @@ test('claude descriptor declares runtime.hostBehaviors (the folded-in host behav 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'); - const offenders = src.match(/runtime\s*[!=]==\s*'claude'/g) || []; + // 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, [], From 6dc4676d92b8c9a82c3e56dc960f8654fc03d72c Mon Sep 17 00:00:00 2001 From: Tom Boucher Date: Wed, 8 Jul 2026 15:39:07 -0400 Subject: [PATCH 06/33] test: add failing regression for #2073 agy reviewer invocation shape The agy block in /gsd-review overflows the exec arg list (inline "$(cat)"), has no external timeout (pre-session stall hangs past --print-timeout), no --model escape hatch for a 404'd pinned model, a generic empty-output stub, and a stale 'no --model flag' note. These tests pin the corrected shape in gsd-core/workflows/review.md; they fail on next. --- tests/antigravity-reviewer.test.cjs | 105 ++++++++++++++++++++++++++++ 1 file changed, 105 insertions(+) create mode 100644 tests/antigravity-reviewer.test.cjs diff --git a/tests/antigravity-reviewer.test.cjs b/tests/antigravity-reviewer.test.cjs new file mode 100644 index 000000000..09b04fc7f --- /dev/null +++ b/tests/antigravity-reviewer.test.cjs @@ -0,0 +1,105 @@ +// allow-test-rule: source-text-is-the-product +// gsd-core/workflows/review.md is a workflow document whose bash blocks ARE +// what /gsd-review loads and executes at runtime. Asserting the Antigravity +// invocation shape asserts the deployed contract — this is behavioral coverage +// of the workflow, not a source-grep over application code. + +/** + * Antigravity (agy) reviewer invocation tests (#2073) + * + * The agy block in /gsd-review had three real-world failure modes on agy 1.0.16: + * 1. inline `-p "$(cat )"` overflowed the exec arg list on a large prompt + * 2. a pinned model that 404'd exited 0 with empty stdout AND an empty transcript + * (no --model escape hatch; the generic Step 3 stub gave no diagnostic) + * 3. a pre-session stall hung past --print-timeout (which can't fire before a + * session exists) because there was no external wall-clock `timeout` + * Plus a stale maintainer note claiming agy has no --model flag. + * + * These tests pin the corrected invocation shape in gsd-core/workflows/review.md. + */ + +const { test, describe } = require('node:test'); +const assert = require('node:assert/strict'); +const fs = require('fs'); +const path = require('path'); + +const ROOT = path.join(__dirname, '..'); +const REVIEW_PATH = path.join(ROOT, 'gsd-core', 'workflows', 'review.md'); + +function agyBashBlock() { + const content = fs.readFileSync(REVIEW_PATH, 'utf-8'); + const fences = content.match(/```bash[\s\S]*?```/g) || []; + // Target the INVOCATION block (the fence that writes the antigravity review + // output), not the `command -v agy` detection one-liner. + const agy = fences.find((f) => /\bagy\b/.test(f) && /gsd-review-antigravity/.test(f)); + assert.ok(agy, 'review.md should contain the agy invocation bash block'); + return agy; +} + +describe('Antigravity (agy) reviewer invocation in /gsd-review (#2073)', () => { + test('review.md exists', () => { + assert.ok(fs.existsSync(REVIEW_PATH), 'review.md should exist'); + }); + + test('#2073 mode 1 — does NOT inline the prompt via "$(cat ...)" (arg-list overflow)', () => { + const block = agyBashBlock(); + assert.ok( + !/"\$\(cat/.test(block), + 'agy must not inline the prompt via "$(cat …)" — a large review prompt overflows the exec arg list', + ); + }); + + test('#2073 mode 1 — uses a file-reference prompt (mirrors the Cursor block)', () => { + const block = agyBashBlock(); + assert.ok( + /Read the file at \/tmp\/gsd-review-prompt-/.test(block), + 'agy should reference the prompt by file path, not inline it', + ); + }); + + test('#2073 mode 3 — invocation is wrapped in an external wall-clock timeout', () => { + const block = agyBashBlock(); + assert.ok( + /(^|\s)timeout\s+\d/.test(block), + 'agy invocation must be wrapped in an external `timeout ` — --print-timeout cannot fire before agy creates a session', + ); + }); + + test('#2073 mode 3 — stdin is tied to /dev/null (no tty stall)', () => { + const block = agyBashBlock(); + assert.ok( + /<\/dev\/null/.test(block), + 'agy invocation should redirect stdin from /dev/null so it never blocks on a tty', + ); + }); + + test('#2073 mode 2 — wires review.models.agy via --model when configured', () => { + const block = agyBashBlock(); + assert.ok(/--model/.test(block), 'agy block should pass --model when AGY_MODEL is set'); + assert.ok(/AGY_MODEL/.test(block), 'agy block should reference the AGY_MODEL config variable'); + }); + + test('#2073 mode 2 — Step 3 stub surfaces a diagnostic from agy cli.log (not just a generic stub)', () => { + const block = agyBashBlock(); + assert.ok( + /cli\.log/.test(block), + 'the empty-output stub should inspect agy cli.log for a model-availability diagnostic (NOT_FOUND / agent executor error)', + ); + }); + + test('#2073 — stale "no --model flag" maintainer note is corrected', () => { + const content = fs.readFileSync(REVIEW_PATH, 'utf-8'); + assert.ok( + !/No .{0,4}-m.{0,4}\/.{0,4}--model.{0,4} flag/i.test(content), + 'the stale maintainer note claiming agy has no --model flag must be corrected (--model exists since ~1.0.3)', + ); + }); + + test('#2073 — review.models.agy is documented as supported (not "reserved for future")', () => { + const content = fs.readFileSync(REVIEW_PATH, 'utf-8'); + assert.ok( + !/review\.models\.agy is reserved for future/i.test(content), + 'review.models.agy is now wired (passed as --model); the "reserved for future" comment must be updated', + ); + }); +}); From af9069f8659d0dcc90a8db1fd804820c0aa1c759 Mon Sep 17 00:00:00 2001 From: Tom Boucher Date: Wed, 8 Jul 2026 15:50:14 -0400 Subject: [PATCH 07/33] fix(#2073): harden agy reviewer block (arg overflow, 404, pre-session stall) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three failure modes on agy 1.0.16, all fixed by mirroring the Cursor block's invocation discipline: * file-reference prompt instead of inline "$(cat)" — a large review prompt overflowed the exec arg list (rc 126). * external 'timeout 600' wrapper — --print-timeout cannot fire before agy creates a session, so a pre-session stall hung unbounded. * --model from review.models.agy when set — escape hatch for a pinned model that 404s (exit 0, empty stdout + transcript). * stdin /dev/null | jq -r CLAUDE_MODEL=$(gsd_run query config-get review.models.claude 2>/dev/null | jq -r '.' 2>/dev/null || true) CODEX_MODEL=$(gsd_run query config-get review.models.codex 2>/dev/null | jq -r '.' 2>/dev/null || true) OPENCODE_MODEL=$(gsd_run query config-get review.models.opencode 2>/dev/null | jq -r '.' 2>/dev/null || true) -# review.models.agy is reserved for future model-pinning support; agy selects its model internally +# review.models.agy, when set, is passed to agy as --model (escape hatch for a +# pinned model that 404s server-side); otherwise agy uses its persisted default. AGY_MODEL=$(gsd_run query config-get review.models.agy 2>/dev/null | jq -r '.' 2>/dev/null || true) # #1115: `--dangerously-bypass-hook-trust` only exists on codex-cli >= 0.137.0. @@ -408,7 +409,9 @@ and Step 3 fires with a clear error message in REVIEWS.md. No silent corruption. Invocation specifics (verified agy 1.0.0, macOS arm64 and Linux amd64): - `-p` takes the prompt as a **flag value** — `echo X | agy -p` errors with "flag needs an argument: -p" - `--print-timeout` defaults to 5m, aligning with this workflow's global timeout -- No `-m` / `--model` flag — agy selects the model internally +- `--model ""` selects the model (available since agy ~1.0.3; `agy models` lists + them). When `review.models.agy` is set it is passed as `--model`; otherwise agy uses + its persisted default (`agy models`). ```bash # Pre-flight: snapshot the transcript watermark before invoking agy. @@ -432,14 +435,26 @@ if [ -f "$_AGY_CACHE" ]; then fi # Step 1 — primary invocation: stdout works on macOS, Linux, and WSL. -# Bound the run with agy's OWN `--print-timeout` (issue #687). On a large, -# file-path-rich prompt agy's agentic Cascade can loop on its code_search/grep -# steps and never converge; `--print-timeout` is agy's native cap for print mode -# (defaults to 5m — see maintainer note above), so we pass it explicitly to let a -# stalled run self-terminate through the tool's own mechanism. A non-zero exit -# (timeout or crash) discards any partial output so the Step 2 transcript fallback -# / Step 3 stub take over. -agy --print-timeout 300s -p "$(cat /tmp/gsd-review-prompt-{phase}.md)" 2>/dev/null > /tmp/gsd-review-antigravity-{phase}.md +# Three hardening invariants (#2073), all mirroring the Cursor block's discipline: +# * FILE-REFERENCE prompt (not inline `$(cat …)`) — a large review prompt (≈197 KB +# for 6 plans + CONTEXT + RESEARCH + REQUIREMENTS) overflows the exec arg list +# (`bash: agy: Argument list too long`, rc 126), indistinguishable from a model +# failure when stderr is suppressed. +# * EXTERNAL `timeout` wrapper — `--print-timeout` is agy's native cap but it +# CANNOT fire before agy creates a session; under concurrent heavy runs one +# process can stall pre-session (no `brain//` dir, alive at 583 s +# despite `--print-timeout 300s`). The external cap bounds wall-clock regardless. +# * `--model` from `review.models.agy` when set — escape hatch for a pinned model +# that 404s server-side (exits 0 with empty stdout AND empty transcript). +# * stdin tied to /dev/null so agy never blocks on a tty. +# A non-zero exit (external timeout = 124, crash, etc.) discards any partial output +# so the Step 2 transcript fallback / Step 3 diagnostic take over. +if [ -n "$AGY_MODEL" ] && [ "$AGY_MODEL" != "null" ]; then + set -- --model "$AGY_MODEL" +else + set -- +fi +timeout 600 agy --print-timeout 540s "$@" -p "Read the file at /tmp/gsd-review-prompt-{phase}.md in full and carry out the review request it contains. Output only the resulting markdown review. Do not edit any files." /dev/null > /tmp/gsd-review-antigravity-{phase}.md _AGY_RC=$? if [ "$_AGY_RC" -ne 0 ]; then : > /tmp/gsd-review-antigravity-{phase}.md @@ -473,9 +488,25 @@ if [ ! -s /tmp/gsd-review-antigravity-{phase}.md ]; then fi fi -# Step 3 — final guard: both approaches yielded nothing (auth error, first-run setup, path schema changed, etc.) +# Step 3 — final guard: both approaches yielded nothing (auth error, first-run setup, +# path schema changed, 404'd pinned model, pre-session stall, etc.) if [ ! -s /tmp/gsd-review-antigravity-{phase}.md ]; then - echo "Antigravity review failed or returned empty output." > /tmp/gsd-review-antigravity-{phase}.md + { + echo "Antigravity review failed or returned empty output." + # #2073 mode 2: a pinned model that 404s exits 0 with empty stdout AND an empty + # transcript — the only evidence is in agy's own log. Surface it instead of a + # bare generic stub so the failure is diagnosable. + _AGY_LOG="$HOME/.gemini/antigravity-cli/cli.log" + if [ -f "$_AGY_LOG" ]; then + _AGY_ERR=$(grep -iE 'agent executor error|NOT_FOUND|Publisher model' "$_AGY_LOG" | tail -3) + if [ -n "$_AGY_ERR" ]; then + echo "agy log hint (pinned model may be unavailable — run 'agy models' and set review.models.agy):" + echo "$_AGY_ERR" + fi + fi + # #2073 mode 3: pre-session stall tell — no new conversation dir appeared. + echo "If no agy run started, that is the pre-session-stall case: check whether a new ~/.gemini/antigravity-cli/brain// dir appeared within ~30s of launch." + } > /tmp/gsd-review-antigravity-{phase}.md fi ``` From 6ae23ffc216c4311b3d15a7116b218b57e0bf84a Mon Sep 17 00:00:00 2001 From: Tom Boucher Date: Wed, 8 Jul 2026 16:16:44 -0400 Subject: [PATCH 08/33] fix(#2073): supersede #687 contract; regen golden install-parity baselines #687 encoded 'agy bounded ONLY by --print-timeout, no external killer' and 'inline -p "$(cat)"'. Documentation since then (see PR description) shows: * agy's own print-mode guidance pairs --print-timeout with an external terminal 'timeout' (it cannot fire pre-session); * agy gained --model in ~1.0.3 (#3782's 'no --model' note was correct then, stale now); * inline "$(cat)" overflows the exec arg list on a large review prompt. Rewrite the #687 describe block to the new contract (file-reference prompt + --print-timeout PAIRED with a >= external timeout + --model + discard-on- nonzero), and regenerate the 16 golden install-parity fixtures (only the review.md hash line changed per runtime). --- .../golden-install-parity/antigravity.json | 2 +- .../golden-install-parity/augment.json | 2 +- .../golden-install-parity/claude.json | 2 +- .../fixtures/golden-install-parity/cline.json | 2 +- .../golden-install-parity/codebuddy.json | 2 +- .../fixtures/golden-install-parity/codex.json | 2 +- .../golden-install-parity/copilot.json | 2 +- .../golden-install-parity/cursor.json | 2 +- .../golden-install-parity/hermes.json | 2 +- .../fixtures/golden-install-parity/kilo.json | 2 +- .../fixtures/golden-install-parity/kimi.json | 2 +- .../golden-install-parity/opencode.json | 2 +- .../fixtures/golden-install-parity/qwen.json | 2 +- .../fixtures/golden-install-parity/trae.json | 2 +- .../golden-install-parity/windsurf.json | 2 +- .../fixtures/golden-install-parity/zcode.json | 2 +- ...review-default-reviewers-workflow.test.cjs | 72 +++++++++++++------ 17 files changed, 67 insertions(+), 37 deletions(-) diff --git a/tests/fixtures/golden-install-parity/antigravity.json b/tests/fixtures/golden-install-parity/antigravity.json index 4615ba8ec..cc1c9ee97 100644 --- a/tests/fixtures/golden-install-parity/antigravity.json +++ b/tests/fixtures/golden-install-parity/antigravity.json @@ -284,7 +284,7 @@ "gsd-core/workflows/remove-phase.md": "23b9eb0858a2535e", "gsd-core/workflows/remove-workspace.md": "d0bd7e0601138798", "gsd-core/workflows/resume-project.md": "98e2cf8908e73a52", - "gsd-core/workflows/review.md": "9bc686206c96748c", + "gsd-core/workflows/review.md": "3597a1ad15eb20a3", "gsd-core/workflows/scan.md": "a7fecd67e5cd655f", "gsd-core/workflows/secure-phase.md": "96b199dfac00e60f", "gsd-core/workflows/session-report.md": "2e5b1205324ddefa", diff --git a/tests/fixtures/golden-install-parity/augment.json b/tests/fixtures/golden-install-parity/augment.json index a39d82e99..133da25d2 100644 --- a/tests/fixtures/golden-install-parity/augment.json +++ b/tests/fixtures/golden-install-parity/augment.json @@ -355,7 +355,7 @@ "gsd-core/workflows/remove-phase.md": "df9a45f0b1880999", "gsd-core/workflows/remove-workspace.md": "a7ca66db6b7c132c", "gsd-core/workflows/resume-project.md": "f28da1200e4545f4", - "gsd-core/workflows/review.md": "f3c2e894941c9943", + "gsd-core/workflows/review.md": "fa3d682b94afcdcd", "gsd-core/workflows/scan.md": "003883d71c37da7d", "gsd-core/workflows/secure-phase.md": "29fc6b62c5c5dc62", "gsd-core/workflows/session-report.md": "2e5b1205324ddefa", diff --git a/tests/fixtures/golden-install-parity/claude.json b/tests/fixtures/golden-install-parity/claude.json index b96c3ccec..209dbac3c 100644 --- a/tests/fixtures/golden-install-parity/claude.json +++ b/tests/fixtures/golden-install-parity/claude.json @@ -283,7 +283,7 @@ "gsd-core/workflows/remove-phase.md": "ada8a0546c686483", "gsd-core/workflows/remove-workspace.md": "f3ab3a88a7e9e1ed", "gsd-core/workflows/resume-project.md": "7f8dc986f0f35d96", - "gsd-core/workflows/review.md": "5cd71e81c4ace401", + "gsd-core/workflows/review.md": "23de3fcd9e5ba43b", "gsd-core/workflows/scan.md": "47371c2073d6c0be", "gsd-core/workflows/secure-phase.md": "59d3c50aba8c9a6c", "gsd-core/workflows/session-report.md": "2e5b1205324ddefa", diff --git a/tests/fixtures/golden-install-parity/cline.json b/tests/fixtures/golden-install-parity/cline.json index cc82e4925..b827b085e 100644 --- a/tests/fixtures/golden-install-parity/cline.json +++ b/tests/fixtures/golden-install-parity/cline.json @@ -287,7 +287,7 @@ "gsd-core/workflows/remove-phase.md": "e336350f8113a328", "gsd-core/workflows/remove-workspace.md": "e685dfbd736dfd90", "gsd-core/workflows/resume-project.md": "e23981178fa37b3d", - "gsd-core/workflows/review.md": "c2b3fcc5bfc80038", + "gsd-core/workflows/review.md": "21e1b701b454ea9e", "gsd-core/workflows/scan.md": "dfd92717caea0ce7", "gsd-core/workflows/secure-phase.md": "cf78183f06a02582", "gsd-core/workflows/session-report.md": "2e5b1205324ddefa", diff --git a/tests/fixtures/golden-install-parity/codebuddy.json b/tests/fixtures/golden-install-parity/codebuddy.json index b47dbf1de..b1a767fd8 100644 --- a/tests/fixtures/golden-install-parity/codebuddy.json +++ b/tests/fixtures/golden-install-parity/codebuddy.json @@ -355,7 +355,7 @@ "gsd-core/workflows/remove-phase.md": "df9a45f0b1880999", "gsd-core/workflows/remove-workspace.md": "a7ca66db6b7c132c", "gsd-core/workflows/resume-project.md": "f28da1200e4545f4", - "gsd-core/workflows/review.md": "f3c2e894941c9943", + "gsd-core/workflows/review.md": "fa3d682b94afcdcd", "gsd-core/workflows/scan.md": "003883d71c37da7d", "gsd-core/workflows/secure-phase.md": "29fc6b62c5c5dc62", "gsd-core/workflows/session-report.md": "2e5b1205324ddefa", diff --git a/tests/fixtures/golden-install-parity/codex.json b/tests/fixtures/golden-install-parity/codex.json index a1a0bb73c..633fa8703 100644 --- a/tests/fixtures/golden-install-parity/codex.json +++ b/tests/fixtures/golden-install-parity/codex.json @@ -319,7 +319,7 @@ "gsd-core/workflows/remove-phase.md": "9ee0fddd11a0d9d4", "gsd-core/workflows/remove-workspace.md": "19d7465aaa50cb62", "gsd-core/workflows/resume-project.md": "9965f87eb278f7f8", - "gsd-core/workflows/review.md": "3a086e38f13eab6f", + "gsd-core/workflows/review.md": "e1fdf297509bebb0", "gsd-core/workflows/scan.md": "1a3caa5d724d39e9", "gsd-core/workflows/secure-phase.md": "db91810d16964b1e", "gsd-core/workflows/session-report.md": "dd8fa011c9394075", diff --git a/tests/fixtures/golden-install-parity/copilot.json b/tests/fixtures/golden-install-parity/copilot.json index 23cd30d93..53c2201a9 100644 --- a/tests/fixtures/golden-install-parity/copilot.json +++ b/tests/fixtures/golden-install-parity/copilot.json @@ -285,7 +285,7 @@ "gsd-core/workflows/remove-phase.md": "e262654e319d1bc4", "gsd-core/workflows/remove-workspace.md": "ceddfeef5f2d6754", "gsd-core/workflows/resume-project.md": "40db7f350f5866d8", - "gsd-core/workflows/review.md": "01b40d7ddd69dc8b", + "gsd-core/workflows/review.md": "3a6786b46734397e", "gsd-core/workflows/scan.md": "dcc2f76d0850e2fb", "gsd-core/workflows/secure-phase.md": "d87bd706f85bcad6", "gsd-core/workflows/session-report.md": "2e5b1205324ddefa", diff --git a/tests/fixtures/golden-install-parity/cursor.json b/tests/fixtures/golden-install-parity/cursor.json index 62441d144..2b9bf4dad 100644 --- a/tests/fixtures/golden-install-parity/cursor.json +++ b/tests/fixtures/golden-install-parity/cursor.json @@ -355,7 +355,7 @@ "gsd-core/workflows/remove-phase.md": "ada8a0546c686483", "gsd-core/workflows/remove-workspace.md": "433affcd1a200826", "gsd-core/workflows/resume-project.md": "7f8dc986f0f35d96", - "gsd-core/workflows/review.md": "338005f1da182bdc", + "gsd-core/workflows/review.md": "c2ce42a245fcc972", "gsd-core/workflows/scan.md": "47371c2073d6c0be", "gsd-core/workflows/secure-phase.md": "c55975672c4e1895", "gsd-core/workflows/session-report.md": "2e5b1205324ddefa", diff --git a/tests/fixtures/golden-install-parity/hermes.json b/tests/fixtures/golden-install-parity/hermes.json index 6757b3c2c..816be6a81 100644 --- a/tests/fixtures/golden-install-parity/hermes.json +++ b/tests/fixtures/golden-install-parity/hermes.json @@ -284,7 +284,7 @@ "gsd-core/workflows/remove-phase.md": "fce799aae3ab2715", "gsd-core/workflows/remove-workspace.md": "8facde381657dd71", "gsd-core/workflows/resume-project.md": "a0443839f1f83c2d", - "gsd-core/workflows/review.md": "f11ffe5b46b50565", + "gsd-core/workflows/review.md": "41dc23cb171b336e", "gsd-core/workflows/scan.md": "b28f65d88c522767", "gsd-core/workflows/secure-phase.md": "f2957d4b88fb3746", "gsd-core/workflows/session-report.md": "2e5b1205324ddefa", diff --git a/tests/fixtures/golden-install-parity/kilo.json b/tests/fixtures/golden-install-parity/kilo.json index 21a1560ca..b6c10bd8e 100644 --- a/tests/fixtures/golden-install-parity/kilo.json +++ b/tests/fixtures/golden-install-parity/kilo.json @@ -355,7 +355,7 @@ "gsd-core/workflows/remove-phase.md": "ada8a0546c686483", "gsd-core/workflows/remove-workspace.md": "fc83f362a2d0a1b7", "gsd-core/workflows/resume-project.md": "7f8dc986f0f35d96", - "gsd-core/workflows/review.md": "3af2d510da581502", + "gsd-core/workflows/review.md": "bf7defbff23639ab", "gsd-core/workflows/scan.md": "47371c2073d6c0be", "gsd-core/workflows/secure-phase.md": "e8855104c1e0417c", "gsd-core/workflows/session-report.md": "2e5b1205324ddefa", diff --git a/tests/fixtures/golden-install-parity/kimi.json b/tests/fixtures/golden-install-parity/kimi.json index 81d6a4c32..0af4bc694 100644 --- a/tests/fixtures/golden-install-parity/kimi.json +++ b/tests/fixtures/golden-install-parity/kimi.json @@ -320,7 +320,7 @@ "gsd-core/workflows/remove-phase.md": "df9a45f0b1880999", "gsd-core/workflows/remove-workspace.md": "a7ca66db6b7c132c", "gsd-core/workflows/resume-project.md": "f28da1200e4545f4", - "gsd-core/workflows/review.md": "f3c2e894941c9943", + "gsd-core/workflows/review.md": "fa3d682b94afcdcd", "gsd-core/workflows/scan.md": "003883d71c37da7d", "gsd-core/workflows/secure-phase.md": "29fc6b62c5c5dc62", "gsd-core/workflows/session-report.md": "2e5b1205324ddefa", diff --git a/tests/fixtures/golden-install-parity/opencode.json b/tests/fixtures/golden-install-parity/opencode.json index 87e595d16..7adc67017 100644 --- a/tests/fixtures/golden-install-parity/opencode.json +++ b/tests/fixtures/golden-install-parity/opencode.json @@ -355,7 +355,7 @@ "gsd-core/workflows/remove-phase.md": "dea4661e8f89596f", "gsd-core/workflows/remove-workspace.md": "446847e71aa52504", "gsd-core/workflows/resume-project.md": "ad9f06a10bab8cc0", - "gsd-core/workflows/review.md": "94ff56aecbbe2753", + "gsd-core/workflows/review.md": "8e4d4a67f4b16419", "gsd-core/workflows/scan.md": "ad8ebcad4626d4a8", "gsd-core/workflows/secure-phase.md": "e9a488cec3b4efdc", "gsd-core/workflows/session-report.md": "2e5b1205324ddefa", diff --git a/tests/fixtures/golden-install-parity/qwen.json b/tests/fixtures/golden-install-parity/qwen.json index 40444eb7b..942069296 100644 --- a/tests/fixtures/golden-install-parity/qwen.json +++ b/tests/fixtures/golden-install-parity/qwen.json @@ -284,7 +284,7 @@ "gsd-core/workflows/remove-phase.md": "e8ae4fbbfac700f0", "gsd-core/workflows/remove-workspace.md": "4ac64de862dc650e", "gsd-core/workflows/resume-project.md": "7f20769f302e5427", - "gsd-core/workflows/review.md": "bb5c36262d5ff951", + "gsd-core/workflows/review.md": "6c639036a133a00a", "gsd-core/workflows/scan.md": "949692db4834dd27", "gsd-core/workflows/secure-phase.md": "6758f1acf4113e9e", "gsd-core/workflows/session-report.md": "2e5b1205324ddefa", diff --git a/tests/fixtures/golden-install-parity/trae.json b/tests/fixtures/golden-install-parity/trae.json index 00087a04b..46464ac7f 100644 --- a/tests/fixtures/golden-install-parity/trae.json +++ b/tests/fixtures/golden-install-parity/trae.json @@ -284,7 +284,7 @@ "gsd-core/workflows/remove-phase.md": "a46c2fe853bf4e86", "gsd-core/workflows/remove-workspace.md": "ae0e1c6d4438d663", "gsd-core/workflows/resume-project.md": "f242e4c8aba18ea2", - "gsd-core/workflows/review.md": "34a23b9b65fb55f6", + "gsd-core/workflows/review.md": "388c7e1a884767bb", "gsd-core/workflows/scan.md": "63631467651d9ca8", "gsd-core/workflows/secure-phase.md": "6cc236e53c2e7d56", "gsd-core/workflows/session-report.md": "2e5b1205324ddefa", diff --git a/tests/fixtures/golden-install-parity/windsurf.json b/tests/fixtures/golden-install-parity/windsurf.json index a4a2eba24..417874a02 100644 --- a/tests/fixtures/golden-install-parity/windsurf.json +++ b/tests/fixtures/golden-install-parity/windsurf.json @@ -284,7 +284,7 @@ "gsd-core/workflows/remove-phase.md": "e7a6af429b36e77b", "gsd-core/workflows/remove-workspace.md": "b5e60fbb33b3e33a", "gsd-core/workflows/resume-project.md": "82cfe1b8cb17c085", - "gsd-core/workflows/review.md": "0873ea94ca23383c", + "gsd-core/workflows/review.md": "846e5693ea0cba84", "gsd-core/workflows/scan.md": "12c11b2edc165df9", "gsd-core/workflows/secure-phase.md": "7bf923689bf58288", "gsd-core/workflows/session-report.md": "2e5b1205324ddefa", diff --git a/tests/fixtures/golden-install-parity/zcode.json b/tests/fixtures/golden-install-parity/zcode.json index 3d05f6e97..8ece548c5 100644 --- a/tests/fixtures/golden-install-parity/zcode.json +++ b/tests/fixtures/golden-install-parity/zcode.json @@ -355,7 +355,7 @@ "gsd-core/workflows/remove-phase.md": "df9a45f0b1880999", "gsd-core/workflows/remove-workspace.md": "a7ca66db6b7c132c", "gsd-core/workflows/resume-project.md": "f28da1200e4545f4", - "gsd-core/workflows/review.md": "f3c2e894941c9943", + "gsd-core/workflows/review.md": "fa3d682b94afcdcd", "gsd-core/workflows/scan.md": "003883d71c37da7d", "gsd-core/workflows/secure-phase.md": "29fc6b62c5c5dc62", "gsd-core/workflows/session-report.md": "2e5b1205324ddefa", diff --git a/tests/review-default-reviewers-workflow.test.cjs b/tests/review-default-reviewers-workflow.test.cjs index 4e29db3ac..3c9245551 100644 --- a/tests/review-default-reviewers-workflow.test.cjs +++ b/tests/review-default-reviewers-workflow.test.cjs @@ -171,36 +171,66 @@ const path = require('node:path'); const reviewPath = path.resolve(__dirname, '..', 'gsd-core', 'workflows', 'review.md'); const read = () => fs.readFileSync(reviewPath, 'utf-8'); -describe('bug #687: agy print mode must be bounded via its native --print-timeout', () => { - test('invokes agy with its own --print-timeout flag (not an external killer)', () => { - assert.match(read(), /agy --print-timeout \d+s? -p "\$\(cat/, - 'review.md must cap agy through `agy --print-timeout -p …` (the tool\'s own mechanism)'); +describe('bug #687 → #2073: agy print mode bounded by --print-timeout PAIRED with an external timeout', () => { + // #687 established that agy print mode must be bounded (its native + // --print-timeout, default 5m). #2073 superseded the "no external killer" + // half of that contract with documentation: + // - agy's own print-mode guidance says to PAIR --print-timeout with an + // external terminal `timeout` ("Pair with the terminal timeout= so the + // outer call doesn't cut the run short"), because --print-timeout cannot + // fire before agy creates a session (a pre-session stall otherwise hangs + // unbounded). The external cap is set HIGHER than --print-timeout so it + // only backstops a stall, never cuts a healthy run. + // - agy gained `--model` in ~1.0.3 (issue #3782's "no --model flag" note + // was correct at the time, stale now); review.models.agy is passed as + // --model so a pinned model that 404s has an escape hatch. + // - the prompt is now a file reference: inline `-p "$(cat …)"` overflows + // the exec arg list on a large review prompt (Linux MAX_ARG_STRLEN + // 128 KB/single-arg → rc 126). + + test('invokes agy with --print-timeout AND a paired external timeout', () => { + const c = read(); + assert.match(c, /--print-timeout \d+s?/, 'review.md must pass agy its native --print-timeout'); + // External `timeout agy …` — the paired backstop per agy guidance. + assert.match(c, /(^|\s)timeout\s+\d+\s+agy\b/, + 'review.md must wrap agy in an external `timeout agy` (agy guidance pairs --print-timeout with a terminal timeout)'); }); - test('discards partial output on non-zero exit so the fallback fires', () => { + test('external timeout is >= --print-timeout so it never cuts a healthy run', () => { + const c = read(); + const external = c.match(/timeout\s+(\d+)\s+agy\b/); + const native = c.match(/--print-timeout\s+(\d+)s/); + assert.ok(external && native, 'both the external timeout and --print-timeout must be present'); + assert.ok( + Number(external[1]) >= Number(native[1]), + 'external `timeout` (seconds) must be >= --print-timeout (seconds) so it only backstops a stall', + ); + }); + + test('uses a file-reference prompt, not inline "$(cat …)" (arg-list overflow, #2073)', () => { + const c = read(); + assert.doesNotMatch(c, /agy[^\n]*-p "\$\(cat/, + 'review.md must not feed agy the prompt inline via "$(cat …)" — a large review prompt overflows the exec arg list (rc 126)'); + assert.match(c, /agy[^\n]*-p "Read the file at \/tmp\/gsd-review-prompt-/, + 'review.md should pass agy a file-reference prompt (mirrors the Cursor block)'); + }); + + test('wires --model from review.models.agy (#2073 mode 2; agy gained --model in ~1.0.3)', () => { + assert.match(read(), /--model "\$AGY_MODEL"/, + 'review.md must pass --model "$AGY_MODEL" when review.models.agy is set'); + }); + + test('discards partial output on non-zero exit so the fallback fires (#687)', () => { const c = read(); assert.match(c, /_AGY_RC.*-ne 0/, 'review.md must check the agy exit code'); assert.match(c, /: > \/tmp\/gsd-review-antigravity-/, 'review.md must truncate the output file when agy timed out / failed'); }); - test('agy is bounded only by its own --print-timeout, not an external process killer', () => { - const c = read(); - // Print-mode reviewers invoke the tool directly; agy must self-terminate via - // --print-timeout, never via an external SIGKILL/timeout binary wrapped around it. - assert.doesNotMatch(c, /-s KILL/, 'must not SIGKILL agy from the outside'); - // Any external timeout binary wrapping agy — `timeout 300s agy …`, - // `gtimeout 300 agy …`, `timeout -s KILL 300 agy …`. The lookbehind keeps - // agy's own `--print-timeout` flag from tripping it. - assert.doesNotMatch(c, /(? { - // A bare `agy -p "$(cat …)"` with no cap was the original hang. - assert.doesNotMatch(read(), /^agy -p "\$\(cat/m, - 'review.md must not invoke agy -p without --print-timeout'); + // A bare `agy -p …` with no cap was the original #687 hang. + assert.doesNotMatch(read(), /^agy -p/m, + 'review.md must not invoke a bare `agy -p` unbounded at line start'); }); }); }); From 7abe6e34ac1c9f5709b2690a542e029eaf3c4079 Mon Sep 17 00:00:00 2001 From: Tom Boucher Date: Wed, 8 Jul 2026 16:30:51 -0400 Subject: [PATCH 09/33] chore(#2073): regen workflow-size baseline for review.md growth MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The agy block grew (~file-reference prompt instruction, external-timeout rationale, --model wiring, richer Step 3 diagnostic) — all load-bearing content fixing 3 production failure modes (#2073), not bloat. Growth justified in the PR. --- tests/workflow-size-baseline.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/workflow-size-baseline.json b/tests/workflow-size-baseline.json index 2f8dfb639..b67f69f44 100644 --- a/tests/workflow-size-baseline.json +++ b/tests/workflow-size-baseline.json @@ -64,7 +64,7 @@ "remove-phase.md": 8513, "remove-workspace.md": 7551, "resume-project.md": 17270, - "review.md": 43356, + "review.md": 45299, "scan.md": 7732, "secure-phase.md": 13520, "session-report.md": 4044, From 01a8fc94a6b842caaa3877b50e9f3f26edba7d0f Mon Sep 17 00:00:00 2001 From: Tom Boucher Date: Wed, 8 Jul 2026 16:45:26 -0400 Subject: [PATCH 10/33] docs(changeset): add Fixed fragment for #2073 agy reviewer hardening --- .changeset/2073-antigravity-reviewer-block.md | 5 +++++ 1 file changed, 5 insertions(+) create mode 100644 .changeset/2073-antigravity-reviewer-block.md diff --git a/.changeset/2073-antigravity-reviewer-block.md b/.changeset/2073-antigravity-reviewer-block.md new file mode 100644 index 000000000..8ec6b10a0 --- /dev/null +++ b/.changeset/2073-antigravity-reviewer-block.md @@ -0,0 +1,5 @@ +--- +type: Fixed +pr: 0 +--- +**`/gsd-review`'s Antigravity (agy) reviewer no longer fails silently on large prompts, unavailable pinned models, or pre-session stalls** — the agy invocation now uses a file-reference prompt (avoids exec arg-list overflow), is wrapped in an external wall-clock `timeout` paired with `--print-timeout` (which cannot fire before agy creates a session), passes `--model` from `review.models.agy` when set (escape hatch for a 404'd pinned model), and its empty-output stub now surfaces an `agy` cli.log diagnostic instead of a bare generic message. Supersedes the #687 "no external killer / inline `$(cat)`" contract, which predated agy gaining `--model` and predated agy's own guidance to pair `--print-timeout` with a terminal timeout. (#2073) From e0f245a6b249bd171b42ea04f9a5024ad7b680df Mon Sep 17 00:00:00 2001 From: Tom Boucher Date: Wed, 8 Jul 2026 17:15:48 -0400 Subject: [PATCH 11/33] fix(#2073): regen claude-local golden; reword changeset (no product parenthetical) --- .changeset/2073-antigravity-reviewer-block.md | 2 +- tests/fixtures/golden-install-parity/claude-local.json | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/.changeset/2073-antigravity-reviewer-block.md b/.changeset/2073-antigravity-reviewer-block.md index 8ec6b10a0..9703877af 100644 --- a/.changeset/2073-antigravity-reviewer-block.md +++ b/.changeset/2073-antigravity-reviewer-block.md @@ -2,4 +2,4 @@ type: Fixed pr: 0 --- -**`/gsd-review`'s Antigravity (agy) reviewer no longer fails silently on large prompts, unavailable pinned models, or pre-session stalls** — the agy invocation now uses a file-reference prompt (avoids exec arg-list overflow), is wrapped in an external wall-clock `timeout` paired with `--print-timeout` (which cannot fire before agy creates a session), passes `--model` from `review.models.agy` when set (escape hatch for a 404'd pinned model), and its empty-output stub now surfaces an `agy` cli.log diagnostic instead of a bare generic message. Supersedes the #687 "no external killer / inline `$(cat)`" contract, which predated agy gaining `--model` and predated agy's own guidance to pair `--print-timeout` with a terminal timeout. (#2073) +**`/gsd-review`'s Antigravity CLI reviewer no longer fails silently on large prompts, unavailable pinned models, or pre-session stalls** — the `agy` invocation now uses a file-reference prompt to avoid exec arg-list overflow, is wrapped in an external wall-clock `timeout` paired with `--print-timeout` because `--print-timeout` cannot fire before `agy` creates a session, passes `--model` from `review.models.agy` when set as an escape hatch for a 404'd pinned model, and its empty-output stub now surfaces an `agy` cli.log diagnostic instead of a bare generic message. Supersedes the #687 "no external killer / inline `$(cat)`" contract, which predated `agy` gaining `--model` and predated its own guidance to pair `--print-timeout` with a terminal timeout. (#2073) diff --git a/tests/fixtures/golden-install-parity/claude-local.json b/tests/fixtures/golden-install-parity/claude-local.json index 72a5c64bb..8657958a8 100644 --- a/tests/fixtures/golden-install-parity/claude-local.json +++ b/tests/fixtures/golden-install-parity/claude-local.json @@ -354,7 +354,7 @@ "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/review.md": "641dd9835a19c389", "gsd-core/workflows/scan.md": "75c670d08cee8680", "gsd-core/workflows/secure-phase.md": "64ec4d06ca85720a", "gsd-core/workflows/session-report.md": "2e5b1205324ddefa", From 396f44bd0b0c4c7541f9c01336e36071d7b00762 Mon Sep 17 00:00:00 2001 From: Tom Boucher Date: Wed, 8 Jul 2026 16:53:54 -0400 Subject: [PATCH 12/33] feat(architecture): [EoS/opencode] Migrate OpenCode onto the Embeddable Orchestration System (ADR-1239, #2087) Route OpenCode (and its Kilo sibling) through the public Host-Integration Interface and land two Context7-verified capability upgrades. Byte-identical install output for all 16 runtimes (golden parity asserted). Through the interface (AC2): - OpenCode/Kilo's bespoke commands+skills+plugin install (the inline `else if (isOpencode || isKilo)` block) moves into the engine (installOpencodeFamilyCommands/Artifacts in src/install-engine.cts), dispatched by installRuntimeArtifacts when the descriptor declares hostBehaviors.combinedFamilyInstall. opencode/kilo now flow CLI -> _runtimeAdapter -> installRuntimeArtifacts like the skills runtimes. _isSkillsRuntime no longer excludes them; the bespoke block + dead copyFlattenedCommands are removed. - Every hardcoded `runtime === 'opencode'`/`isOpencode` branch is folded into descriptor-driven runtime.hostBehaviors. ZERO `runtime === 'opencode'`/`'kilo'` string-equality remain in bin/install.js / install-engine.cts / runtime-artifact-conversion.cts. Upgrades (AC4): - Background dispatch: OpenCode shipped experimental background subagents in v1.15 and made them default-on in v1.17 -> dispatch.background/backgroundDispatch flip to true; shouldFlattenDispatch(opencode) now returns false (behavioral change; type: Changed). - Expanded event surface: the OpenCode plugin subscribes permission.asked/replied + session.error. Tests: opencode-imperative-reference (adapter/profile, shouldFlattenDispatch pin, fail-closed negotiate, hostBehaviors, AC2 source-guard) + extended plugin surface test. Docs: capability matrix v1.15/v1.17 citations. Changeset (Changed). gitignore .memdb//.memtrace/. Co-Authored-By: Claude Opus 4.8 --- .../2087-eos-opencode-imperative-adapter.md | 5 + .gitignore | 4 + .opencode/plugins/gsd-core.js | 20 ++ bin/install.js | 177 ++++-------------- capabilities/copilot/capability.json | 3 + capabilities/kilo/capability.json | 8 + capabilities/opencode/capability.json | 20 +- .../host-integration-capability-matrix.md | 4 +- gsd-core/bin/lib/capability-registry.cjs | 62 +++++- src/host-integration.cts | 3 + src/install-engine.cts | 151 ++++++++++++++- src/runtime-artifact-conversion.cts | 9 +- .../golden-install-parity/opencode.json | 2 +- tests/host-integration-descriptors.test.cjs | 4 +- tests/opencode-imperative-reference.test.cjs | 122 ++++++++++++ tests/opencode-plugin-adapter.test.cjs | 11 +- 16 files changed, 450 insertions(+), 155 deletions(-) create mode 100644 .changeset/2087-eos-opencode-imperative-adapter.md create mode 100644 tests/opencode-imperative-reference.test.cjs diff --git a/.changeset/2087-eos-opencode-imperative-adapter.md b/.changeset/2087-eos-opencode-imperative-adapter.md new file mode 100644 index 000000000..01b9ab4fd --- /dev/null +++ b/.changeset/2087-eos-opencode-imperative-adapter.md @@ -0,0 +1,5 @@ +--- +type: Changed +pr: 2087 +--- +**OpenCode is now driven through the public Host-Integration Interface, with two capability upgrades (ADR-1239 / EoS).** OpenCode and its Kilo sibling previously installed via a bespoke `runtime === 'opencode'`/`isOpencode` branch in `bin/install.js`; its commands+skills+plugin install now runs through the imperative adapter → the engine's combined-family install path (`installRuntimeArtifacts`), and every hardcoded `runtime === 'opencode'` branch is folded into descriptor-driven `runtime.hostBehaviors`. Install/uninstall output is **byte-identical** (golden parity asserted for all 16 runtimes). Two Context7-verified upgrades land: (1) **background dispatch** — OpenCode shipped experimental background subagents in v1.15 and made them default-on in v1.17, so `dispatch.background`/`backgroundDispatch` flip to `true`; GSD no longer force-flattens OpenCode-hosted wave dispatch (`shouldFlattenDispatch` now returns `false`), letting agents run concurrently where the host supports it. (2) **expanded event surface** — the OpenCode plugin now subscribes to `permission.asked`, `permission.replied`, and `session.error` (added to `EXTENSION_EVENT_SURFACES.opencode`), wiring the declared surface for future permission/error-aware bindings. (#2087) diff --git a/.gitignore b/.gitignore index a76a62d9c..c70665f48 100644 --- a/.gitignore +++ b/.gitignore @@ -250,3 +250,7 @@ reports/mutation/ # Local Crabbox machine configuration .crabbox.yaml .crabbox.local.yaml + +# Memtrace local daemon/runtime state (per-machine; never committed) +.memdb/ +.memtrace/ diff --git a/.opencode/plugins/gsd-core.js b/.opencode/plugins/gsd-core.js index 89e6c4544..039277b7b 100644 --- a/.opencode/plugins/gsd-core.js +++ b/.opencode/plugins/gsd-core.js @@ -663,6 +663,26 @@ const GsdCorePlugin = async ({ directory } = {}) => { if (event.type === "session.idle") { return; } + + // permission.asked / permission.replied — OpenCode permission lifecycle + // (#2087, opencode.ai/docs/plugins). GSD gates tool INPUTS at + // tool.execute.before (read-guard, injection-scanner); the permission + // grant/deny decision itself carries no GSD workflow-phase contribution, + // so these are recognized sentinels — wired so a future permission-aware + // gate can attach without a plugin change (the engine owns phase + // sequencing; this host bus is session/tool/permission-scoped, never + // phase-scoped — ADR-1239 §OpenCode). + if (event.type === "permission.asked" || event.type === "permission.replied") { + return; + } + + // session.error — OpenCode session-error lifecycle point (#2087). No GSD + // hook fires here today (loop state is already persisted to .planning/); + // recognized so the declared extension-event surface is fully wired and a + // future error-class hook can attach without a plugin change. + if (event.type === "session.error") { + return; + } }, }; }; diff --git a/bin/install.js b/bin/install.js index 0a9f2dd10..b8e9fa279 100755 --- a/bin/install.js +++ b/bin/install.js @@ -932,6 +932,9 @@ function resolveKiloConfigPath(configDir) { return path.join(configDir, 'kilo.json'); } +// #2087 — attribution config-path resolvers, keyed by descriptor (hostBehaviors.attributionConfigResolver) +const ATTRIBUTION_CONFIG_RESOLVERS = { opencode: resolveOpencodeConfigPath, kilo: resolveKiloConfigPath }; + /** * Strip JSONC comments (// and /* *​/) from a string to produce valid JSON. * Handles comments inside strings correctly (does not strip them). @@ -1300,10 +1303,9 @@ function getCommitAttribution(runtime) { let result; - if (runtime === 'opencode' || runtime === 'kilo') { - const resolveConfigPath = runtime === 'opencode' - ? resolveOpencodeConfigPath - : resolveKiloConfigPath; + const _attrResolverKey = _hostBehaviors(runtime).attributionConfigResolver; + if (_attrResolverKey && ATTRIBUTION_CONFIG_RESOLVERS[_attrResolverKey]) { + const resolveConfigPath = ATTRIBUTION_CONFIG_RESOLVERS[_attrResolverKey]; const config = readSettings(resolveConfigPath(getGlobalConfigDir(runtime, null))); result = (config && config.disable_ai_attribution === true) ? null : undefined; } else if (_hostBehaviors(runtime).attributionSource === 'settings-json-commit') { @@ -5984,62 +5986,14 @@ function convertClaudeToKiloFrontmatter(content, { isAgent = false } = {}) { // convertClaudeCommandToKiloSkill: moved to src/install-engine.cts (ADR-1239 Phase B). // Imported from installEngine above. -/** - * Copy commands to a flat structure for OpenCode - * OpenCode expects: command/gsd-help.md (invoked as /gsd-help) - * Source structure: commands/gsd/help.md - * - * @param {string} srcDir - Source directory (e.g., commands/gsd/) - * @param {string} destDir - Destination directory (e.g., command/) - * @param {string} prefix - Prefix for filenames (e.g., 'gsd') - * @param {string} pathPrefix - Path prefix for file references - * @param {string} runtime - Target runtime ('claude', 'opencode', or 'kilo') - */ // applyOpencodeFamilyPathPrefix: moved to src/install-engine.cts (ADR-1239 Phase B). // Imported from installEngine above. - -function copyFlattenedCommands(srcDir, destDir, prefix, pathPrefix, runtime) { - if (!fs.existsSync(srcDir)) { - return; - } - - // Remove old gsd-*.md files before copying new ones - if (fs.existsSync(destDir)) { - for (const file of fs.readdirSync(destDir)) { - if (file.startsWith(`${prefix}-`) && file.endsWith('.md')) { - fs.unlinkSync(path.join(destDir, file)); - } - } - } else { - fs.mkdirSync(destDir, { recursive: true }); - } - - const entries = fs.readdirSync(srcDir, { withFileTypes: true }); - - for (const entry of entries) { - const srcPath = path.join(srcDir, entry.name); - - if (entry.isDirectory()) { - // Recurse into subdirectories, adding to prefix - // e.g., commands/gsd/debug/start.md -> command/gsd-debug-start.md - copyFlattenedCommands(srcPath, destDir, `${prefix}-${entry.name}`, pathPrefix, runtime); - } else if (entry.name.endsWith('.md')) { - // Flatten: help.md -> gsd-help.md - const baseName = entry.name.replace('.md', ''); - const destName = `${prefix}-${baseName}.md`; - const destPath = path.join(destDir, destName); - - let content = fs.readFileSync(srcPath, 'utf8'); - content = applyOpencodeFamilyPathPrefix(content, runtime, pathPrefix); - content = processAttribution(content, getCommitAttribution(runtime)); - content = runtime === 'kilo' - ? convertClaudeToKiloFrontmatter(content) - : convertClaudeToOpencodeFrontmatter(content); - - fs.writeFileSync(destPath, content); - } - } -} +// +// copyFlattenedCommands (OpenCode/Kilo flattened command/ writer): moved to +// src/install-engine.cts as installOpencodeFamilyCommands (ADR-1239 / #2087). +// OpenCode/Kilo installs now route through installRuntimeArtifacts's +// combinedFamilyInstall path (installOpencodeFamilyArtifacts) instead of the +// bespoke inline block that used to call this function. function listCodexSkillNames(skillsDir, prefix = 'gsd-') { if (!fs.existsSync(skillsDir)) return []; @@ -6929,9 +6883,10 @@ function uninstall(isGlobal, runtime = DEFAULT_RUNTIME) { // 4z. Remove the OpenCode native plugin adapter (#1914). Only GSD's own // plugin file is removed; the plugins/ dir is pruned only if it becomes // empty, preserving any user-authored OpenCode plugins. - if (isOpencode) { - const pluginsDir = path.join(targetDir, 'plugins'); - const pluginPath = path.join(pluginsDir, 'gsd-core.js'); + const _np = _hostBehaviors(runtime).nativePlugin; + if (_np) { + const pluginsDir = path.join(targetDir, _np.dir); + const pluginPath = path.join(pluginsDir, _np.file); if (fs.existsSync(pluginPath)) { try { fs.unlinkSync(pluginPath); @@ -7110,7 +7065,7 @@ function uninstall(isGlobal, runtime = DEFAULT_RUNTIME) { } // 6. For OpenCode, clean up permissions from opencode.json or opencode.jsonc - if (isOpencode) { + if (resolveInstallPlan(runtime).finishPermissionWriter === 'opencode') { const configPath = resolveOpencodeConfigPath(targetDir); if (fs.existsSync(configPath)) { try { @@ -7558,7 +7513,7 @@ function writeManifest(configDir, runtime = DEFAULT_RUNTIME, options = {}) { // #1367: Claude local now writes flat gsd-*.md files at commands/ (not commands/gsd/). // Claude local uses flatCommandsDir instead for manifest recording. const flatCommandsDir = path.join(configDir, 'commands'); - const opencodeCommandDir = path.join(configDir, 'command'); + const opencodeCommandDir = path.join(configDir, _hostBehaviors(runtime).flatCommandDir || 'command'); // Hermes nests GSD skills under skills/gsd/ as a single category (#2841). // All other runtimes that use the Codex-style skills layout use a flat skills/ root. const codexSkillsDir = isHermes @@ -7597,14 +7552,14 @@ function writeManifest(configDir, runtime = DEFAULT_RUNTIME, options = {}) { } } } - if ((isOpencode || isKilo) && fs.existsSync(opencodeCommandDir)) { + if (_hostBehaviors(runtime).flatCommandDir && fs.existsSync(opencodeCommandDir)) { for (const file of fs.readdirSync(opencodeCommandDir)) { if (file.startsWith('gsd-') && file.endsWith('.md')) { manifest.files['command/' + file] = fileHash(path.join(opencodeCommandDir, file)); } } } - if ((isCodex || isCopilot || isAntigravity || isCursor || isWindsurf || isTrae || !isOpencode) && fs.existsSync(codexSkillsDir)) { + if (!_hostBehaviors(runtime).skipCodexSkillsManifest && fs.existsSync(codexSkillsDir)) { // All runtimes (including Hermes post-#947) use the canonical 'gsd-' prefix. const skillListPrefix = 'gsd-'; for (const skillName of listCodexSkillNames(codexSkillsDir, skillListPrefix)) { @@ -7716,10 +7671,11 @@ function writeManifest(configDir, runtime = DEFAULT_RUNTIME, options = {}) { // Track the OpenCode native plugin adapter (#1914) so update/drift detection // and uninstall can account for it. - if (isOpencode) { - const pluginInstallPath = path.join(configDir, 'plugins', 'gsd-core.js'); + const _npM = _hostBehaviors(runtime).nativePlugin; + if (_npM) { + const pluginInstallPath = path.join(configDir, _npM.dir, _npM.file); if (fs.existsSync(pluginInstallPath)) { - manifest.files['plugins/gsd-core.js'] = fileHash(pluginInstallPath); + manifest.files[`${_npM.dir}/${_npM.file}`] = fileHash(pluginInstallPath); } } @@ -8012,8 +7968,8 @@ function reportLocalPatches(configDir, runtime = DEFAULT_RUNTIME) { try { meta = JSON.parse(fs.readFileSync(metaPath, 'utf8')); } catch { return []; } if (meta.files && meta.files.length > 0) { - const reapplyCommand = (runtime === 'opencode' || runtime === 'kilo' || runtime === 'copilot') - ? '/gsd-update --reapply' + const reapplyCommand = _hostBehaviors(runtime).reapplyCommand + ? _hostBehaviors(runtime).reapplyCommand : runtime === 'codex' ? '$gsd-update --reapply' : runtime === 'cursor' @@ -8187,7 +8143,7 @@ function install(isGlobal, runtime = DEFAULT_RUNTIME, options = {}) { const isWindowsHost = process.platform === 'win32'; const pathPrefix = computePathPrefix({ isGlobal, - isOpencode, + isOpencode: _hostBehaviors(runtime).skipHomePrefixSubstitution === true, isWindowsHost, resolvedTarget, homeDir, @@ -8473,7 +8429,6 @@ function install(isGlobal, runtime = DEFAULT_RUNTIME, options = {}) { // Hermes: writeHermesCategoryDescription (not a layout kind) // Cline global: skills emitted via layout; .clinerules still written below (#782) // Cline local: no skills (only .clinerules) — falls through to cline-rules surface - // OpenCode/Kilo: copyFlattenedCommands (frontmatter conversion not in commandsKind) // Claude local: copyWithPathReplacement + stale-skills cleanup // Layout-driven path for all skills-based runtimes (full and minimal modes). @@ -8485,12 +8440,14 @@ function install(isGlobal, runtime = DEFAULT_RUNTIME, options = {}) { // (it declares any skills/commands/agents/kimi-agents kind for this scope). // This replaces the prior hardcoded `isCodex || isCopilot || ...` roster so a // newly-added runtime with an artifact layout installs without a per-runtime - // branch — the add-a-host tax ADR-1239 Phase B retires. Three legacy - // special-cased paths are preserved: opencode/kilo (combined commands+skills - // via copyFlattenedCommands + installOpencodeFamilySkills) and claude-local + // branch — the add-a-host tax ADR-1239 Phase B retires. OpenCode/Kilo now + // route through this SAME path too: their hostBehaviors.combinedFamilyInstall + // flag makes installRuntimeArtifacts (in src/install-engine.cts) delegate to + // installOpencodeFamilyArtifacts for the combined commands+skills+native-plugin + // install (ADR-1239 / #2087), replacing the bespoke inline block this comment + // used to describe. Claude-local remains the one special-cased path // (copyWithPathReplacement + stale-skills cleanup). const _isSkillsRuntime = (() => { - if (isOpencode || isKilo) return false; // specialized combined 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; @@ -8657,64 +8614,6 @@ function install(isGlobal, runtime = DEFAULT_RUNTIME, options = {}) { } } } - } else if (isOpencode || isKilo) { - // OpenCode/Kilo: flat structure in command/ directory - const commandDir = path.join(targetDir, 'command'); - fs.mkdirSync(commandDir, { recursive: true }); - - // Copy commands/gsd/*.md as command/gsd-*.md (flatten structure) - const gsdSrc = _stageSkills(_commandsDir); - copyFlattenedCommands(gsdSrc, commandDir, 'gsd', pathPrefix, runtime); - if (verifyInstalled(commandDir, 'command/gsd-*')) { - const count = fs.readdirSync(commandDir).filter(f => f.startsWith('gsd-')).length; - console.log(` ${green}✓${reset} Installed ${count} commands to command/`); - } else { - failures.push('command/gsd-*'); - } - - // Also emit OpenCode-family skills (skills//SKILL.md). OpenCode and - // Kilo support native, on-demand skills in addition to flat commands — see - // resolveRuntimeArtifactLayout's opencode/kilo entries. Derive skills from - // the SAME staged command set (gsdSrc) so both surfaces match exactly. (#784) - const _skillCount = installOpencodeFamilySkills(runtime, targetDir, gsdSrc, pathPrefix, getCommitAttribution); - if (_skillCount > 0) { - console.log(` ${green}✓${reset} Installed ${_skillCount} skills to skills/`); - } else { - failures.push('skills/gsd-*'); - } - - // OpenCode-only: install the native plugin adapter (#1914). OpenCode - // declares hooksSurface: 'none', so GSD's lifecycle hooks are never - // registered as settings.json hooks the way Claude Code does — the hook - // *scripts* ship to /hooks/ but nothing invokes them. This - // plugin bridges OpenCode's event bus onto those existing hook scripts - // (prompt guard, read guard, injection scanner, context monitor, ...), - // spawning them as subprocesses. OpenCode auto-discovers plugin files under - // /plugins/ at startup — no opencode.json registration needed - // (its `plugin` array is for npm packages, not local file paths). - // - // The file MUST land as `.js`: OpenCode's loader globs - // `{plugin,plugins}/*.{ts,js}` (verified against its source) — a `.cjs` - // extension would never be discovered. The config dir carries a - // `{"type":"commonjs"}` package.json (written above), so the `.js` file is - // interpreted as CommonJS, matching the adapter's module.exports/require. - // Kilo has no plugin surface, so this is gated to OpenCode only. - if (isOpencode) { - const pluginSrc = path.join(src, '.opencode', 'plugins', 'gsd-core.js'); - const pluginDestDir = path.join(targetDir, 'plugins'); - const pluginDest = path.join(pluginDestDir, 'gsd-core.js'); - if (fs.existsSync(pluginSrc)) { - fs.mkdirSync(pluginDestDir, { recursive: true }); - fs.copyFileSync(pluginSrc, pluginDest); - if (fs.existsSync(pluginDest)) { - console.log(` ${green}✓${reset} Installed OpenCode plugin (bridges GSD hooks)`); - } else { - failures.push('plugins/gsd-core.js'); - } - } else { - failures.push('plugins/gsd-core.js'); - } - } } else if (isCline) { // Cline local install: rules-based only — commands are embedded in .clinerules (generated below). // No skills/commands directory needed for local installs. @@ -8963,7 +8862,7 @@ function install(isGlobal, runtime = DEFAULT_RUNTIME, options = {}) { } content = processAttribution(content, getCommitAttribution(runtime)); // Convert frontmatter for runtime compatibility (agents need different handling) - if (isOpencode) { + if (_hostBehaviors(runtime).frontmatterDialect === 'opencode') { // Resolve per-agent model for OpenCode agents. // Precedence: model_overrides[agent] > model_profile_overrides.opencode. > omit. // model_overrides (#2256): explicit per-agent override, highest precedence. @@ -8982,7 +8881,7 @@ function install(isGlobal, runtime = DEFAULT_RUNTIME, options = {}) { } } content = convertClaudeToOpencodeFrontmatter(content, { isAgent: true, modelOverride: _ocModelOverride }); - } else if (isKilo) { + } else if (_hostBehaviors(runtime).frontmatterDialect === 'kilo') { content = convertClaudeToKiloFrontmatter(content, { isAgent: true }); } else if (isCodex) { content = convertClaudeAgentToCodexAgent(content); @@ -10018,7 +9917,7 @@ function install(isGlobal, runtime = DEFAULT_RUNTIME, options = {}) { // installAllRuntimes can register it at finalize time when the user opts // in (#2795). Computed here (not in finishInstall) so the same buildHookCommand // / localCmd resolution logic is shared with the other JS hooks. - const updateBannerCommand = isOpencode || isKilo + const updateBannerCommand = _hostBehaviors(runtime).skipUpdateBannerCommand ? null : (isGlobal ? buildHookCommand(targetDir, 'gsd-update-banner.js', hookOpts) @@ -10105,7 +10004,7 @@ function finishInstall(settingsPath, settings, statuslineCommand, shouldInstallS const { isOpencode, isKilo, isCodex, isCopilot, isAntigravity, isCursor, isWindsurf, isAugment, isTrae, isQwen, isHermes, isCodebuddy, isCline, isKimi } = runtimeFlags(runtime); const plan = resolveInstallPlan(runtime); - if (shouldInstallStatusline && plan.writesSharedSettings && !isOpencode) { + if (shouldInstallStatusline && plan.writesSharedSettings && !_hostBehaviors(runtime).skipSettingsUi) { if (!isGlobal && !forceStatusline) { // Local installs skip statusLine by default: repo settings.json takes precedence over // profile-level settings.json in Claude Code, so writing here would silently clobber @@ -10131,7 +10030,7 @@ function finishInstall(settingsPath, settings, statuslineCommand, shouldInstallS // settings.json hooks block — opencode/kilo/codex/cursor/windsurf/trae/ // cline either lack the surface or use a different config schema. const { shouldInstallBanner, bannerCommand } = bannerOpts; - if (shouldInstallBanner && settings && plan.writesSharedSettings && !isOpencode) { + if (shouldInstallBanner && settings && plan.writesSharedSettings && !_hostBehaviors(runtime).skipSettingsUi) { if (!bannerCommand) { console.warn(` ${yellow}⚠${reset} Skipped update banner registration — Node executable path unavailable. See #2979 / #3002.`); } else { diff --git a/capabilities/copilot/capability.json b/capabilities/copilot/capability.json index ce17f48eb..c36d1e25a 100644 --- a/capabilities/copilot/capability.json +++ b/capabilities/copilot/capability.json @@ -82,6 +82,9 @@ "stateIO": "filesystem", "transport": "mcp", "runtime": "undocumented" + }, + "hostBehaviors": { + "reapplyCommand": "/gsd-update --reapply" } } } diff --git a/capabilities/kilo/capability.json b/capabilities/kilo/capability.json index f6477ba4a..f85803392 100644 --- a/capabilities/kilo/capability.json +++ b/capabilities/kilo/capability.json @@ -88,6 +88,14 @@ "stateIO": "filesystem", "transport": "mcp", "runtime": "bun" + }, + "hostBehaviors": { + "reapplyCommand": "/gsd-update --reapply", + "attributionConfigResolver": "kilo", + "flatCommandDir": "command", + "combinedFamilyInstall": true, + "frontmatterDialect": "kilo", + "skipUpdateBannerCommand": true } } } diff --git a/capabilities/opencode/capability.json b/capabilities/opencode/capability.json index ff98fcd10..c718e0143 100644 --- a/capabilities/opencode/capability.json +++ b/capabilities/opencode/capability.json @@ -75,15 +75,31 @@ "namedDispatch": true, "nested": "undocumented", "maxDepth": "undocumented", - "background": false, + "background": true, "subagentToolkit": "full", - "backgroundDispatch": "undocumented" + "backgroundDispatch": true }, "modelMode": "active", "hookBus": "host", "stateIO": "filesystem", "transport": "mcp", "runtime": "bun" + }, + "hostBehaviors": { + "reapplyCommand": "/gsd-update --reapply", + "attributionConfigResolver": "opencode", + "flatCommandDir": "command", + "combinedFamilyInstall": true, + "frontmatterDialect": "opencode", + "nativePlugin": { + "dir": "plugins", + "file": "gsd-core.js", + "source": ".opencode/plugins/gsd-core.js" + }, + "skipHomePrefixSubstitution": true, + "skipSettingsUi": true, + "skipUpdateBannerCommand": true, + "skipCodexSkillsManifest": true } } } diff --git a/docs/reference/host-integration-capability-matrix.md b/docs/reference/host-integration-capability-matrix.md index 2b5ed269c..f9fe517e4 100644 --- a/docs/reference/host-integration-capability-matrix.md +++ b/docs/reference/host-integration-capability-matrix.md @@ -134,9 +134,9 @@ Documentation gaps: | dispatch.namedDispatch | true | https://opencode.ai/docs/agents | "\"Subagents can be invoked: Automatically by primary agents for specialized tasks based on their descriptions. Manually b" | | dispatch.nested | undocumented | no authoritative doc — searched: https://opencode.ai/docs/agents | — | | dispatch.maxDepth | undocumented | no authoritative doc — searched: https://opencode.ai/docs/agents | — | -| dispatch.background | false | https://github.com/sst/opencode/issues/5887 | "\"Currently, sub-agent delegation in `opencode` appears to be synchronous or modal... There is no native 'fire-and-forget'" | +| dispatch.background | true | https://github.com/anomalyco/opencode/blob/dev/packages/opencode/src/tool/task.ts (v1.15.0, commit 22de34c4d) + src/effect/runtime-flags.ts (v1.17, commit 81f6e0668) | "New in v1.15.0: experimental background subagents — the Task tool gains a `background` parameter (`Schema.optional(Schema.Boolean)`) that launches subagents asynchronously with completion notifications. v1.17: `BACKGROUND_SUBAGENTS_ENABLED = true` (\"feat: enable background subagents by default\") — default-on, concurrent execution in all modes. (#2087, superseding the stale sst/opencode#5887 snapshot)" | | dispatch.subagentToolkit | full | https://opencode.ai/docs/agents | "The 'general' subagent \"Has full tool access (except todo), so it can make file changes when needed.\"" | -| dispatch.backgroundDispatch | undocumented | no authoritative doc — https://github.com/anomalyco/opencode/issues/18100 and https://github.com/anomalyco/opencode/blob/dev/opencode/packages/opencode/src/tool/task.ts | Opencode supports background task dispatch via the Task tool's `background: true` parameter but whether a background-spawned agent can itself spawn further sub-agents is not documented. | +| dispatch.backgroundDispatch | true | https://github.com/anomalyco/opencode/blob/dev/packages/opencode/src/effect/runtime-flags.ts (v1.17, commit 81f6e0668) + src/server/routes/instance/httpapi/handlers/experimental.ts | "v1.17 `BACKGROUND_SUBAGENTS_ENABLED = true` enables background subagent execution by default in all modes; the experimental capabilities endpoint exposes `{ backgroundSubagents: true }`. Background-spawned subagents run concurrently without blocking the main interaction flow. (#2087)" | Sources consulted: - https://opencode.ai/docs/plugins diff --git a/gsd-core/bin/lib/capability-registry.cjs b/gsd-core/bin/lib/capability-registry.cjs index 82900ecaa..1524c0e31 100644 --- a/gsd-core/bin/lib/capability-registry.cjs +++ b/gsd-core/bin/lib/capability-registry.cjs @@ -940,6 +940,9 @@ const capabilities = { "stateIO": "filesystem", "transport": "mcp", "runtime": "undocumented" + }, + "hostBehaviors": { + "reapplyCommand": "/gsd-update --reapply" } } }, @@ -1502,6 +1505,14 @@ const capabilities = { "stateIO": "filesystem", "transport": "mcp", "runtime": "bun" + }, + "hostBehaviors": { + "reapplyCommand": "/gsd-update --reapply", + "attributionConfigResolver": "kilo", + "flatCommandDir": "command", + "combinedFamilyInstall": true, + "frontmatterDialect": "kilo", + "skipUpdateBannerCommand": true } } }, @@ -1880,15 +1891,31 @@ const capabilities = { "namedDispatch": true, "nested": "undocumented", "maxDepth": "undocumented", - "background": false, + "background": true, "subagentToolkit": "full", - "backgroundDispatch": "undocumented" + "backgroundDispatch": true }, "modelMode": "active", "hookBus": "host", "stateIO": "filesystem", "transport": "mcp", "runtime": "bun" + }, + "hostBehaviors": { + "reapplyCommand": "/gsd-update --reapply", + "attributionConfigResolver": "opencode", + "flatCommandDir": "command", + "combinedFamilyInstall": true, + "frontmatterDialect": "opencode", + "nativePlugin": { + "dir": "plugins", + "file": "gsd-core.js", + "source": ".opencode/plugins/gsd-core.js" + }, + "skipHomePrefixSubstitution": true, + "skipSettingsUi": true, + "skipUpdateBannerCommand": true, + "skipCodexSkillsManifest": true } } }, @@ -4233,6 +4260,9 @@ const runtimes = { "stateIO": "filesystem", "transport": "mcp", "runtime": "undocumented" + }, + "hostBehaviors": { + "reapplyCommand": "/gsd-update --reapply" } } }, @@ -4500,6 +4530,14 @@ const runtimes = { "stateIO": "filesystem", "transport": "mcp", "runtime": "bun" + }, + "hostBehaviors": { + "reapplyCommand": "/gsd-update --reapply", + "attributionConfigResolver": "kilo", + "flatCommandDir": "command", + "combinedFamilyInstall": true, + "frontmatterDialect": "kilo", + "skipUpdateBannerCommand": true } } }, @@ -4654,15 +4692,31 @@ const runtimes = { "namedDispatch": true, "nested": "undocumented", "maxDepth": "undocumented", - "background": false, + "background": true, "subagentToolkit": "full", - "backgroundDispatch": "undocumented" + "backgroundDispatch": true }, "modelMode": "active", "hookBus": "host", "stateIO": "filesystem", "transport": "mcp", "runtime": "bun" + }, + "hostBehaviors": { + "reapplyCommand": "/gsd-update --reapply", + "attributionConfigResolver": "opencode", + "flatCommandDir": "command", + "combinedFamilyInstall": true, + "frontmatterDialect": "opencode", + "nativePlugin": { + "dir": "plugins", + "file": "gsd-core.js", + "source": ".opencode/plugins/gsd-core.js" + }, + "skipHomePrefixSubstitution": true, + "skipSettingsUi": true, + "skipUpdateBannerCommand": true, + "skipCodexSkillsManifest": true } } }, diff --git a/src/host-integration.cts b/src/host-integration.cts index 1a792978c..06259b70a 100644 --- a/src/host-integration.cts +++ b/src/host-integration.cts @@ -555,6 +555,9 @@ const EXTENSION_EVENT_SURFACES: Readonly> = Ob opencode: Object.freeze([ 'session.created', 'session.idle', 'experimental.session.compacting', 'tool.execute.before', 'tool.execute.after', 'file.edited', + // #2087 — additional documented plugin events GSD binds (opencode.ai/docs/plugins): + // permission decisions + session error surface. + 'permission.asked', 'permission.replied', 'session.error', ]), pi: Object.freeze(['tool_call']), none: Object.freeze([]), diff --git a/src/install-engine.cts b/src/install-engine.cts index f7f6a3baa..eda2107de 100644 --- a/src/install-engine.cts +++ b/src/install-engine.cts @@ -26,6 +26,7 @@ import runtimeArtifactConversion = require('./runtime-artifact-conversion.cjs'); import runtimeArtifactLayout = require('./runtime-artifact-layout.cjs'); import runtimeArtifactInstallPlan = require('./runtime-artifact-install-plan.cjs'); import runtimeNamePolicy = require('./runtime-name-policy.cjs'); +import installProfiles = require('./install-profiles.cjs'); const { processAttribution } = runtimeArtifactConversion; // resolveRuntimeArtifactLayout: accessed via module ref (not destructured) so @@ -65,6 +66,26 @@ type ResolveAttribution = (runtime: string) => any; */ const USER_OWNED_ARTIFACTS: string[] = ['USER-PROFILE.md']; +// --------------------------------------------------------------------------- +// Host-behavior helpers +// --------------------------------------------------------------------------- + +/** + * Host-specific install behaviors declared on the runtime descriptor + * (capabilities//capability.json -> runtime.hostBehaviors). + * Mirrors bin/install.js's `_hostBehaviors` (ADR-1239 / #2086/#2087). Returns + * {} for runtimes that declare none or if the registry fails to load, so + * every behavior branch degrades to the generic path by default. + */ +function _hostBehaviors(runtime: string): any { + try { + const reg = require('./capability-registry.cjs'); + return (reg && reg.runtimes && reg.runtimes[runtime] && reg.runtimes[runtime].runtime && reg.runtimes[runtime].runtime.hostBehaviors) || {}; + } catch { + return {}; + } +} + // --------------------------------------------------------------------------- // Conversion helpers // --------------------------------------------------------------------------- @@ -563,6 +584,16 @@ function installRuntimeArtifacts( resolvedProfile: any, resolveAttribution: ResolveAttribution = () => undefined, ): void { + // Combined-family runtimes (OpenCode/Kilo, ADR-1239 / #2087): route through + // the dedicated combined commands+skills+plugin orchestrator instead of the + // generic layout-driven loop below, mirroring the bespoke install path that + // previously lived inline in bin/install.js. + const behaviors = _hostBehaviors(runtime); + if (behaviors.combinedFamilyInstall) { + installOpencodeFamilyArtifacts(runtime, configDir, scope, resolvedProfile, resolveAttribution, behaviors); + return; + } + // Legacy cleanup before layout-driven writes _runLegacyInstallMigrations(runtime, configDir, scope); @@ -690,7 +721,7 @@ function installOpencodeFamilySkills( const rawDir = rawCommandsDir; if (!rawDir || !fs.existsSync(rawDir)) return 0; - const converter = runtime === 'kilo' + const converter = _hostBehaviors(runtime).frontmatterDialect === 'kilo' ? convertClaudeCommandToKiloSkill : convertClaudeCommandToOpencodeSkill; @@ -743,6 +774,121 @@ function installOpencodeFamilySkills( return count; } +// --------------------------------------------------------------------------- +// installOpencodeFamilyCommands +// --------------------------------------------------------------------------- + +/** + * Install the flattened commands surface for an OpenCode-family runtime + * (OpenCode/Kilo): commands/gsd/**\/*.md -> command/gsd-<...>.md, with + * per-runtime frontmatter conversion and path-prefix/attribution rewrites. + * + * Mirrors bin/install.js's copyFlattenedCommands VERBATIM (ADR-1239 / + * #2087), except attribution is resolved via the injected + * `resolveAttribution` callback instead of a module-level getCommitAttribution. + * + * @param runtime - 'opencode' or 'kilo' + * @param destDir - destination directory for flattened commands (recurses with the same destDir) + * @param srcDir - source directory to walk (commands/gsd/, recursing into subdirectories) + * @param pathPrefix - computed config-path prefix for body rewrites + * @param resolveAttribution - injection: (runtime) => attribution string | undefined + * @param prefix - filename prefix accumulator (defaults to 'gsd'; grows on recursion) + */ +function installOpencodeFamilyCommands( + runtime: string, + destDir: string, + srcDir: string, + pathPrefix: string, + resolveAttribution: ResolveAttribution = () => undefined, + prefix: string = 'gsd', +): void { + if (!fs.existsSync(srcDir)) return; + + // Remove old gsd-*.md files before copying new ones + if (fs.existsSync(destDir)) { + for (const file of fs.readdirSync(destDir)) { + if (file.startsWith(`${prefix}-`) && file.endsWith('.md')) fs.unlinkSync(path.join(destDir, file)); + } + } else { + fs.mkdirSync(destDir, { recursive: true }); + } + + for (const entry of fs.readdirSync(srcDir, { withFileTypes: true })) { + const srcPath = path.join(srcDir, entry.name); + if (entry.isDirectory()) { + installOpencodeFamilyCommands(runtime, destDir, srcPath, pathPrefix, resolveAttribution, `${prefix}-${entry.name}`); + } else if (entry.name.endsWith('.md')) { + const baseName = entry.name.replace('.md', ''); + const destName = `${prefix}-${baseName}.md`; + let content = fs.readFileSync(srcPath, 'utf8'); + content = applyOpencodeFamilyPathPrefix(content, runtime, pathPrefix); + content = processAttribution(content, resolveAttribution(runtime)); + content = _hostBehaviors(runtime).frontmatterDialect === 'kilo' + ? (runtimeArtifactConversion as any).convertClaudeToKiloFrontmatter(content) + : (runtimeArtifactConversion as any).convertClaudeToOpencodeFrontmatter(content); + fs.writeFileSync(path.join(destDir, destName), content); + } + } +} + +// --------------------------------------------------------------------------- +// installOpencodeFamilyArtifacts +// --------------------------------------------------------------------------- + +/** + * Combined-family install orchestrator for OpenCode/Kilo (ADR-1239 / #2087). + * Stages the flattened commands surface + skills surface + (OpenCode only) + * native plugin adapter, mirroring the bespoke `else if (isOpencode || + * isKilo)` block previously inlined in bin/install.js. + * + * @param runtime - 'opencode' or 'kilo' + * @param configDir - resolved runtime config directory + * @param scope - install scope ('global' | 'local') + * @param resolvedProfile - from resolveProfile() / resolveEffectiveProfile() + * @param resolveAttribution - injection: (runtime) => attribution string | undefined + * @param behaviors - the runtime's hostBehaviors descriptor (already resolved by the caller) + */ +function installOpencodeFamilyArtifacts( + runtime: string, + configDir: string, + scope: string, + resolvedProfile: any, + resolveAttribution: ResolveAttribution = () => undefined, + behaviors: any = {}, +): void { + const isGlobal = scope === 'global'; + // findInstallSourceRoot resolves DIRECTLY to the commands/gsd source dir + // (via the .gsd-source marker or a walk-up from __dirname) — every other + // call site in runtime-artifact-layout.cts feeds its return value straight + // into stageSkillsForProfile/stageSkillsForRuntimeAsSkills. The repo/package + // root (needed below for the native plugin source) is two levels up. + const commandsGsdDir = runtimeArtifactLayout.findInstallSourceRoot(configDir); + const src = path.dirname(path.dirname(commandsGsdDir)); + const rawCommandsDir = installProfiles.stageSkillsForProfile(commandsGsdDir, resolvedProfile); + + const pathPrefix = (runtimeArtifactConversion as any)._computePathPrefix({ + isGlobal, + isOpencode: behaviors.skipHomePrefixSubstitution === true, + isWindowsHost: process.platform === 'win32', + resolvedTarget: path.resolve(configDir).replace(/\\/g, '/'), + homeDir: os.homedir().replace(/\\/g, '/'), + }); + + const commandDir = runtimeArtifactInstallPlan.assertDestWithinConfigHome(configDir, 'command'); + installOpencodeFamilyCommands(runtime, commandDir, rawCommandsDir, pathPrefix, resolveAttribution); + installOpencodeFamilySkills(runtime, configDir, rawCommandsDir, pathPrefix, resolveAttribution); + + const np = behaviors.nativePlugin; + if (np && np.source) { + const pluginSrc = path.join(src, np.source); + if (fs.existsSync(pluginSrc)) { + const destDir = runtimeArtifactInstallPlan.assertDestWithinConfigHome(configDir, np.dir); + fs.mkdirSync(destDir, { recursive: true }); + fs.copyFileSync(pluginSrc, path.join(destDir, np.file)); + } + } +} + // --------------------------------------------------------------------------- // uninstallRuntimeArtifacts // --------------------------------------------------------------------------- @@ -807,6 +953,9 @@ export = { installRuntimeArtifacts, uninstallRuntimeArtifacts, installOpencodeFamilySkills, + installOpencodeFamilyCommands, + installOpencodeFamilyArtifacts, + _hostBehaviors, _copyStaged, hasExistingSymlinkBetween, preserveUserArtifacts, diff --git a/src/runtime-artifact-conversion.cts b/src/runtime-artifact-conversion.cts index 56bf688c6..7977909e7 100644 --- a/src/runtime-artifact-conversion.cts +++ b/src/runtime-artifact-conversion.cts @@ -2507,7 +2507,7 @@ function rewriteStagedSkillBodies(stagedDir, opts) { const resolvedTarget = path.resolve(configDir).replace(/\\/g, '/'); const homeDir = homedir().replace(/\\/g, '/'); const isGlobal = scope === 'global'; - const isOpencode = runtime === 'opencode'; + const isOpencode = false; // #2087: opencode installs via the combined-family engine path, never through the generic rewrite const isWindowsHost = platform === 'win32'; const pathPrefix = computePathPrefix({ isGlobal, isOpencode, isWindowsHost, resolvedTarget, homeDir }); const attribution = resolveAttribution ? resolveAttribution(runtime) : undefined; @@ -2543,7 +2543,7 @@ function rewriteStagedCommandBodies(stagedDir, opts) { const resolvedTarget = path.resolve(configDir).replace(/\\/g, '/'); const homeDir = homedir().replace(/\\/g, '/'); const isGlobal = scope === 'global'; - const isOpencode = runtime === 'opencode'; + const isOpencode = false; // #2087: opencode installs via the combined-family engine path, never through the generic rewrite const isWindowsHost = platform === 'win32'; const pathPrefix = computePathPrefix({ isGlobal, isOpencode, isWindowsHost, resolvedTarget, homeDir }); const attribution = resolveAttribution ? resolveAttribution(runtime) : undefined; @@ -2672,6 +2672,11 @@ export = { neutralizeAgentReferences, convertClaudeCommandToOpencodeSkill, convertClaudeCommandToKiloSkill, + // #2087 — opencode/kilo command-frontmatter converters, exported so the + // layout-driven `convertedCommandsKind` can resolve them by name (routes the + // opencode/kilo command install through the engine instead of the bespoke path). + convertClaudeToOpencodeFrontmatter, + convertClaudeToKiloFrontmatter, readGsdCommandNames, transformContentToHyphen, // #1383: version resolver (exported for regression test of the Codex diff --git a/tests/fixtures/golden-install-parity/opencode.json b/tests/fixtures/golden-install-parity/opencode.json index 87e595d16..62e04aade 100644 --- a/tests/fixtures/golden-install-parity/opencode.json +++ b/tests/fixtures/golden-install-parity/opencode.json @@ -404,7 +404,7 @@ "hooks/managed-hooks-registry.cjs": "763730ef31e5fd1c", "opencode.json": "2c12c446a88f2f36", "package.json": "dbf8353f77358bc1", - "plugins/gsd-core.js": "63687dc233ca707e", + "plugins/gsd-core.js": "931ca839dc9eb7f1", "scripts/changeset/README.md": "86ff89331dfd94b2", "scripts/changeset/cli.cjs": "68f92a344b199271", "scripts/changeset/github-release-notes.cjs": "795677f0c009b132", diff --git a/tests/host-integration-descriptors.test.cjs b/tests/host-integration-descriptors.test.cjs index 6d241e088..d7879f1d0 100644 --- a/tests/host-integration-descriptors.test.cjs +++ b/tests/host-integration-descriptors.test.cjs @@ -275,7 +275,9 @@ describe('ADR-1239 Phase A: hostIntegration descriptors', () => { hermes: true, kilo: true, kimi: true, - opencode: true, + // #2087: OpenCode background subagents (v1.15 param, v1.17 default-on) → + // dispatch.background/backgroundDispatch true → NOT force-flattened. + opencode: false, qwen: true, trae: true, windsurf: true, diff --git a/tests/opencode-imperative-reference.test.cjs b/tests/opencode-imperative-reference.test.cjs new file mode 100644 index 000000000..8b7c0e842 --- /dev/null +++ b/tests/opencode-imperative-reference.test.cjs @@ -0,0 +1,122 @@ +// allow-test-rule: AC2 requires asserting no `runtime === 'opencode'` string-equality branch remains in bin/install.js/src — the descriptor-migration contract is a property of the source text, so a source-grep is the only faithful check (#2087) +'use strict'; + +/** + * opencode imperative reference host — ADR-1239 Phase D / #2087 (EoS/opencode). + * + * Proves opencode 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, that the Context7- + * verified dispatch UPGRADE (background subagents, v1.15/v1.17) changes + * `shouldFlattenDispatch`, and that the migration retired the hardcoded + * `runtime === 'opencode'` / `isOpencode` branches (folded into descriptor-driven + * `runtime.hostBehaviors` + the combined-family engine install path). + */ + +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, + shouldFlattenDispatch, + extensionEventSurfaceFor, + PROFILE_BASELINES, + UNDOCUMENTED, +} = require('../gsd-core/bin/lib/host-integration.cjs'); + +const OC_CAP = JSON.parse( + fs.readFileSync(path.join(__dirname, '..', 'capabilities', 'opencode', 'capability.json'), 'utf8'), +); +const OC_AXES = OC_CAP.runtime.hostIntegration; + +// -- AC2: driven through the public interface (imperative adapter) ----------- + +test('createImperativeAdapter classifies opencode as imperative + composes the registry', () => { + const adapter = createImperativeAdapter({ runtime: 'opencode' }); + assert.equal(adapter.kind, 'imperative'); + assert.equal(adapter.runtime, 'opencode'); + assert.ok(adapter.registry && typeof adapter.registry === 'object'); + assert.equal(typeof adapter.install, 'function'); + assert.equal(typeof adapter.uninstall, 'function'); +}); + +test('opencode axes classify as the programmatic-cli reference profile', () => { + assert.equal(profileOf(OC_AXES), 'programmatic-cli'); +}); + +// -- AC4: the Context7-verified UPGRADE (background dispatch) ----------------- + +test('opencode descriptor declares background dispatch true/true (v1.15/v1.17 upgrade)', () => { + assert.equal(OC_AXES.dispatch.background, true, 'background subagents (v1.15 param, v1.17 default-on)'); + assert.equal(OC_AXES.dispatch.backgroundDispatch, true); +}); + +test('background UPGRADE changes shouldFlattenDispatch: false now (may background), true for the old axes', () => { + // Post-upgrade: opencode may run subagents concurrently → NOT force-flattened. + assert.equal(shouldFlattenDispatch(OC_AXES.dispatch), false, + 'with background:true+backgroundDispatch:true, GSD must NOT force-flatten opencode dispatch'); + // Pin the behavioral change: the pre-#2087 axes DID force-flatten. + const preUpgrade = { ...OC_AXES.dispatch, background: false, backgroundDispatch: 'undocumented' }; + assert.equal(shouldFlattenDispatch(preUpgrade), true, + 'pre-upgrade (background:false) opencode was force-flattened — this is the behavioral change #2087 lands'); +}); + +test('opencode extension-event surface includes the #2087 additions (permission + session.error)', () => { + const surface = extensionEventSurfaceFor('opencode'); + assert.ok(surface, 'opencode is a consumed extensionEvents dialect'); + for (const ev of ['permission.asked', 'permission.replied', 'session.error']) { + assert.ok(surface.includes(ev), `#2087 adds ${ev} to the opencode extension-event surface`); + } + // The engine still owns phase sequencing — no workflow-phase events on the bus. + assert.ok(!surface.some((e) => /plan:|verify:|ship:/.test(e))); +}); + +// -- AC5: negotiation fails CLOSED on a corrupted descriptor ------------------ + +test('negotiateHostCapabilities never throws for opencode, even fully corrupted', () => { + assert.doesNotThrow(() => negotiateHostCapabilities({})); + assert.doesNotThrow(() => negotiateHostCapabilities({ ...OC_AXES, embeddingMode: UNDOCUMENTED })); + assert.doesNotThrow(() => negotiateHostCapabilities({ ...OC_AXES, embeddingMode: 'future-unknown' })); +}); + +test('a partial/empty opencode descriptor degrades to the safe floor, not the programmatic-cli baseline', () => { + const result = negotiateHostCapabilities({}); + assert.equal(result.effective.embeddingMode, 'declarative', 'omitted embeddingMode degrades closed'); + assert.equal(result.effective.hookBus, 'none'); + assert.notDeepEqual(result.effective, PROFILE_BASELINES['programmatic-cli']); + assert.ok(result.warnings.length > 0); +}); + +// -- AC2: the hardcoded branches are retired --------------------------------- + +test('opencode descriptor declares runtime.hostBehaviors (the folded-in behaviors)', () => { + const hb = OC_CAP.runtime.hostBehaviors; + assert.ok(hb && typeof hb === 'object'); + assert.equal(hb.combinedFamilyInstall, true, 'commands+skills+plugin install runs through the engine (adapter)'); + assert.equal(hb.reapplyCommand, '/gsd-update --reapply'); + assert.equal(hb.attributionConfigResolver, 'opencode'); + assert.equal(hb.flatCommandDir, 'command'); + assert.equal(hb.frontmatterDialect, 'opencode'); + assert.equal(hb.skipHomePrefixSubstitution, true); + assert.equal(hb.skipSettingsUi, true); + assert.equal(hb.skipUpdateBannerCommand, true); + assert.equal(hb.skipCodexSkillsManifest, true); + assert.equal(hb.nativePlugin.file, 'gsd-core.js'); + assert.equal(hb.nativePlugin.source, '.opencode/plugins/gsd-core.js'); +}); + +test('no `runtime === "opencode"` string-equality branch remains in the install source (AC2)', () => { + const strip = (src) => src + .replace(/\/\*[\s\S]*?\*\//g, '') + .replace(/\/\/[^\r\n]*/g, '') + .replace(/`[^`]*`/g, ''); + for (const rel of ['bin/install.js', 'src/install-engine.cts', 'src/runtime-artifact-conversion.cts']) { + const src = fs.readFileSync(path.join(__dirname, '..', rel), 'utf8'); + const offenders = strip(src).match(/runtime\s*[!=]==\s*'opencode'/g) || []; + assert.deepEqual(offenders, [], `AC2: no hardcoded runtime==='opencode' branch may remain in ${rel}; found: ${offenders.join(', ')}`); + } +}); diff --git a/tests/opencode-plugin-adapter.test.cjs b/tests/opencode-plugin-adapter.test.cjs index 8739e58e9..7bef3e940 100644 --- a/tests/opencode-plugin-adapter.test.cjs +++ b/tests/opencode-plugin-adapter.test.cjs @@ -340,10 +340,15 @@ test('plugin implements the full declared opencode extension-event surface (Clau } // Session/file events dispatch through the `event` handler. assert.equal(typeof handlers.event, 'function', 'plugin exposes an event dispatcher'); - // Every declared surface event resolves to a plugin handler. + // Every declared surface event resolves to a plugin handler. Session / + // permission / error events dispatch through the `event` handler (not + // top-level handler keys). #2087 added permission.asked/replied + session.error. + const EVENT_DISPATCHED = new Set([ + 'session.created', 'session.idle', 'file.edited', + 'permission.asked', 'permission.replied', 'session.error', + ]); for (const ev of surface) { - const covered = typeof handlers[ev] === 'function' - || ev === 'session.created' || ev === 'session.idle' || ev === 'file.edited'; + const covered = typeof handlers[ev] === 'function' || EVENT_DISPATCHED.has(ev); assert.ok(covered, `plugin covers opencode extension event: ${ev}`); } }); From 9320f35d720708bb05669e900d168e7d449d3507 Mon Sep 17 00:00:00 2001 From: Tom Boucher Date: Wed, 8 Jul 2026 17:38:30 -0400 Subject: [PATCH 13/33] docs(changeset): backfill pr 2108 for #2087 changeset Co-Authored-By: Claude Opus 4.8 --- .changeset/2087-eos-opencode-imperative-adapter.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.changeset/2087-eos-opencode-imperative-adapter.md b/.changeset/2087-eos-opencode-imperative-adapter.md index 01b9ab4fd..82b18defb 100644 --- a/.changeset/2087-eos-opencode-imperative-adapter.md +++ b/.changeset/2087-eos-opencode-imperative-adapter.md @@ -1,5 +1,5 @@ --- type: Changed -pr: 2087 +pr: 2108 --- **OpenCode is now driven through the public Host-Integration Interface, with two capability upgrades (ADR-1239 / EoS).** OpenCode and its Kilo sibling previously installed via a bespoke `runtime === 'opencode'`/`isOpencode` branch in `bin/install.js`; its commands+skills+plugin install now runs through the imperative adapter → the engine's combined-family install path (`installRuntimeArtifacts`), and every hardcoded `runtime === 'opencode'` branch is folded into descriptor-driven `runtime.hostBehaviors`. Install/uninstall output is **byte-identical** (golden parity asserted for all 16 runtimes). Two Context7-verified upgrades land: (1) **background dispatch** — OpenCode shipped experimental background subagents in v1.15 and made them default-on in v1.17, so `dispatch.background`/`backgroundDispatch` flip to `true`; GSD no longer force-flattens OpenCode-hosted wave dispatch (`shouldFlattenDispatch` now returns `false`), letting agents run concurrently where the host supports it. (2) **expanded event surface** — the OpenCode plugin now subscribes to `permission.asked`, `permission.replied`, and `session.error` (added to `EXTENSION_EVENT_SURFACES.opencode`), wiring the declared surface for future permission/error-aware bindings. (#2087) From dbc730d8dea7dad662cf64fb94047e35afa7681f Mon Sep 17 00:00:00 2001 From: Tom Boucher Date: Wed, 8 Jul 2026 17:53:28 -0400 Subject: [PATCH 14/33] fix(#2073): capability-probe external killer (timeout/gtimeout) for macOS Code review (HIGH): a hardcoded 'timeout 600 agy' fails with rc 127 on stock macOS (no GNU timeout/gtimeout), silently losing the agy reviewer. Probe for 'timeout'/'gtimeout' via command -v and fall back to agy's native --print-timeout alone when neither exists (mirrors scripts/base64-scan.sh). External cap (600s) stays >= --print-timeout (540s) so it only backstops a pre-session stall. Factor the prompt into _AGY_PROMPT to avoid duplicating the long -p string across both branches. Update the agy + #687 tests to assert the probe + bound + fallback, regen the 17 goldens + size baseline, refresh the maintainer-note version stamp to 1.0.16. --- gsd-core/workflows/review.md | 27 ++++++++++++++----- tests/antigravity-reviewer.test.cjs | 18 ++++++++----- .../golden-install-parity/antigravity.json | 2 +- .../golden-install-parity/augment.json | 2 +- .../golden-install-parity/claude-local.json | 2 +- .../golden-install-parity/claude.json | 2 +- .../fixtures/golden-install-parity/cline.json | 2 +- .../golden-install-parity/codebuddy.json | 2 +- .../fixtures/golden-install-parity/codex.json | 2 +- .../golden-install-parity/copilot.json | 2 +- .../golden-install-parity/cursor.json | 2 +- .../golden-install-parity/hermes.json | 2 +- .../fixtures/golden-install-parity/kilo.json | 2 +- .../fixtures/golden-install-parity/kimi.json | 2 +- .../golden-install-parity/opencode.json | 2 +- .../fixtures/golden-install-parity/qwen.json | 2 +- .../fixtures/golden-install-parity/trae.json | 2 +- .../golden-install-parity/windsurf.json | 2 +- .../fixtures/golden-install-parity/zcode.json | 2 +- ...review-default-reviewers-workflow.test.cjs | 27 +++++++++++-------- tests/workflow-size-baseline.json | 2 +- 21 files changed, 66 insertions(+), 42 deletions(-) diff --git a/gsd-core/workflows/review.md b/gsd-core/workflows/review.md index a00473f96..4e9543c4c 100644 --- a/gsd-core/workflows/review.md +++ b/gsd-core/workflows/review.md @@ -374,7 +374,7 @@ fi **Antigravity CLI:** -**Maintainer note — why this block has three layers (last updated against agy 1.0.2):** +**Maintainer note — why this block has three layers (last updated against agy 1.0.16):** `agy -p` (the `--print` non-interactive flag) works correctly on macOS and Linux: it sends the prompt, receives the model response, and writes it to stdout. On **native Windows** it silently @@ -440,10 +440,12 @@ fi # for 6 plans + CONTEXT + RESEARCH + REQUIREMENTS) overflows the exec arg list # (`bash: agy: Argument list too long`, rc 126), indistinguishable from a model # failure when stderr is suppressed. -# * EXTERNAL `timeout` wrapper — `--print-timeout` is agy's native cap but it -# CANNOT fire before agy creates a session; under concurrent heavy runs one -# process can stall pre-session (no `brain//` dir, alive at 583 s -# despite `--print-timeout 300s`). The external cap bounds wall-clock regardless. +# * EXTERNAL `timeout` wrapper when available (GNU `timeout` / `gtimeout`) — +# `--print-timeout` is agy's native cap but it CANNOT fire before agy creates a +# session; under concurrent heavy runs one process can stall pre-session (no +# `brain//` dir, alive at 583 s despite `--print-timeout 300s`). The +# external cap bounds wall-clock regardless. Stock macOS lacks `timeout`, so +# the block probes for it and falls back to --print-timeout alone there. # * `--model` from `review.models.agy` when set — escape hatch for a pinned model # that 404s server-side (exits 0 with empty stdout AND empty transcript). # * stdin tied to /dev/null so agy never blocks on a tty. @@ -454,7 +456,20 @@ if [ -n "$AGY_MODEL" ] && [ "$AGY_MODEL" != "null" ]; then else set -- fi -timeout 600 agy --print-timeout 540s "$@" -p "Read the file at /tmp/gsd-review-prompt-{phase}.md in full and carry out the review request it contains. Output only the resulting markdown review. Do not edit any files." /dev/null > /tmp/gsd-review-antigravity-{phase}.md +_AGY_PROMPT="Read the file at /tmp/gsd-review-prompt-{phase}.md in full and carry out the review request it contains. Output only the resulting markdown review. Do not edit any files." +# Capability-probe an external wall-clock killer (GNU coreutils `timeout` or the +# macOS Homebrew `gtimeout`). Stock macOS ships NEITHER — a bare `timeout …` would +# fail with rc 127 ("command not found") and silently lose the reviewer, so fall +# back to agy's native --print-timeout alone in that case. The external cap, when +# available, is set HIGHER than --print-timeout so it only backstops a pre-session +# stall (which --print-timeout cannot bound — #2073 mode 3) and never pre-empts a +# healthy run. Mirrors the probe in scripts/base64-scan.sh. +_AGY_KILLER="$(command -v timeout 2>/dev/null || command -v gtimeout 2>/dev/null || true)" +if [ -n "$_AGY_KILLER" ]; then + "$_AGY_KILLER" 600 agy --print-timeout 540s "$@" -p "$_AGY_PROMPT" /dev/null > /tmp/gsd-review-antigravity-{phase}.md +else + agy --print-timeout 540s "$@" -p "$_AGY_PROMPT" /dev/null > /tmp/gsd-review-antigravity-{phase}.md +fi _AGY_RC=$? if [ "$_AGY_RC" -ne 0 ]; then : > /tmp/gsd-review-antigravity-{phase}.md diff --git a/tests/antigravity-reviewer.test.cjs b/tests/antigravity-reviewer.test.cjs index 09b04fc7f..6e787cba6 100644 --- a/tests/antigravity-reviewer.test.cjs +++ b/tests/antigravity-reviewer.test.cjs @@ -57,12 +57,17 @@ describe('Antigravity (agy) reviewer invocation in /gsd-review (#2073)', () => { ); }); - test('#2073 mode 3 — invocation is wrapped in an external wall-clock timeout', () => { + test('#2073 mode 3 — pairs agy with an external wall-clock killer when available (timeout/gtimeout probe)', () => { const block = agyBashBlock(); - assert.ok( - /(^|\s)timeout\s+\d/.test(block), - 'agy invocation must be wrapped in an external `timeout ` — --print-timeout cannot fire before agy creates a session', - ); + // Capability probe for GNU `timeout` and macOS `gtimeout` (stock macOS has neither). + assert.match(block, /command -v timeout/, 'agy block should probe for the `timeout` killer'); + assert.match(block, /command -v gtimeout/, 'agy block should probe for `gtimeout` (macOS Homebrew)'); + // The external cap (600s) is >= agy's native --print-timeout (540s) so it only + // backstops a pre-session stall, never cuts a healthy run. + assert.match(block, /600 agy --print-timeout 540s/, 'external cap (600s) must be >= --print-timeout (540s)'); + // Graceful fallback when no external killer is available (stock macOS). + assert.match(block, /else\n\s*agy --print-timeout 540s/, + 'agy block must fall back to --print-timeout alone when no external killer is available (macOS)'); }); test('#2073 mode 3 — stdin is tied to /dev/null (no tty stall)', () => { @@ -75,8 +80,7 @@ describe('Antigravity (agy) reviewer invocation in /gsd-review (#2073)', () => { test('#2073 mode 2 — wires review.models.agy via --model when configured', () => { const block = agyBashBlock(); - assert.ok(/--model/.test(block), 'agy block should pass --model when AGY_MODEL is set'); - assert.ok(/AGY_MODEL/.test(block), 'agy block should reference the AGY_MODEL config variable'); + assert.match(block, /--model "\$AGY_MODEL"/, 'agy block should pass --model "$AGY_MODEL" when set'); }); test('#2073 mode 2 — Step 3 stub surfaces a diagnostic from agy cli.log (not just a generic stub)', () => { diff --git a/tests/fixtures/golden-install-parity/antigravity.json b/tests/fixtures/golden-install-parity/antigravity.json index cc1c9ee97..4f674f008 100644 --- a/tests/fixtures/golden-install-parity/antigravity.json +++ b/tests/fixtures/golden-install-parity/antigravity.json @@ -284,7 +284,7 @@ "gsd-core/workflows/remove-phase.md": "23b9eb0858a2535e", "gsd-core/workflows/remove-workspace.md": "d0bd7e0601138798", "gsd-core/workflows/resume-project.md": "98e2cf8908e73a52", - "gsd-core/workflows/review.md": "3597a1ad15eb20a3", + "gsd-core/workflows/review.md": "9ff117dd12ce68ba", "gsd-core/workflows/scan.md": "a7fecd67e5cd655f", "gsd-core/workflows/secure-phase.md": "96b199dfac00e60f", "gsd-core/workflows/session-report.md": "2e5b1205324ddefa", diff --git a/tests/fixtures/golden-install-parity/augment.json b/tests/fixtures/golden-install-parity/augment.json index 133da25d2..2742f85c7 100644 --- a/tests/fixtures/golden-install-parity/augment.json +++ b/tests/fixtures/golden-install-parity/augment.json @@ -355,7 +355,7 @@ "gsd-core/workflows/remove-phase.md": "df9a45f0b1880999", "gsd-core/workflows/remove-workspace.md": "a7ca66db6b7c132c", "gsd-core/workflows/resume-project.md": "f28da1200e4545f4", - "gsd-core/workflows/review.md": "fa3d682b94afcdcd", + "gsd-core/workflows/review.md": "93fe46bd0b5dd3d1", "gsd-core/workflows/scan.md": "003883d71c37da7d", "gsd-core/workflows/secure-phase.md": "29fc6b62c5c5dc62", "gsd-core/workflows/session-report.md": "2e5b1205324ddefa", diff --git a/tests/fixtures/golden-install-parity/claude-local.json b/tests/fixtures/golden-install-parity/claude-local.json index 8657958a8..4f1ce96ee 100644 --- a/tests/fixtures/golden-install-parity/claude-local.json +++ b/tests/fixtures/golden-install-parity/claude-local.json @@ -354,7 +354,7 @@ "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": "641dd9835a19c389", + "gsd-core/workflows/review.md": "f9b63577ff23c2a9", "gsd-core/workflows/scan.md": "75c670d08cee8680", "gsd-core/workflows/secure-phase.md": "64ec4d06ca85720a", "gsd-core/workflows/session-report.md": "2e5b1205324ddefa", diff --git a/tests/fixtures/golden-install-parity/claude.json b/tests/fixtures/golden-install-parity/claude.json index 209dbac3c..2a35df78e 100644 --- a/tests/fixtures/golden-install-parity/claude.json +++ b/tests/fixtures/golden-install-parity/claude.json @@ -283,7 +283,7 @@ "gsd-core/workflows/remove-phase.md": "ada8a0546c686483", "gsd-core/workflows/remove-workspace.md": "f3ab3a88a7e9e1ed", "gsd-core/workflows/resume-project.md": "7f8dc986f0f35d96", - "gsd-core/workflows/review.md": "23de3fcd9e5ba43b", + "gsd-core/workflows/review.md": "2dbfb1118613be25", "gsd-core/workflows/scan.md": "47371c2073d6c0be", "gsd-core/workflows/secure-phase.md": "59d3c50aba8c9a6c", "gsd-core/workflows/session-report.md": "2e5b1205324ddefa", diff --git a/tests/fixtures/golden-install-parity/cline.json b/tests/fixtures/golden-install-parity/cline.json index b827b085e..1110eff27 100644 --- a/tests/fixtures/golden-install-parity/cline.json +++ b/tests/fixtures/golden-install-parity/cline.json @@ -287,7 +287,7 @@ "gsd-core/workflows/remove-phase.md": "e336350f8113a328", "gsd-core/workflows/remove-workspace.md": "e685dfbd736dfd90", "gsd-core/workflows/resume-project.md": "e23981178fa37b3d", - "gsd-core/workflows/review.md": "21e1b701b454ea9e", + "gsd-core/workflows/review.md": "86d03cb58f48f45b", "gsd-core/workflows/scan.md": "dfd92717caea0ce7", "gsd-core/workflows/secure-phase.md": "cf78183f06a02582", "gsd-core/workflows/session-report.md": "2e5b1205324ddefa", diff --git a/tests/fixtures/golden-install-parity/codebuddy.json b/tests/fixtures/golden-install-parity/codebuddy.json index b1a767fd8..3fd1f7110 100644 --- a/tests/fixtures/golden-install-parity/codebuddy.json +++ b/tests/fixtures/golden-install-parity/codebuddy.json @@ -355,7 +355,7 @@ "gsd-core/workflows/remove-phase.md": "df9a45f0b1880999", "gsd-core/workflows/remove-workspace.md": "a7ca66db6b7c132c", "gsd-core/workflows/resume-project.md": "f28da1200e4545f4", - "gsd-core/workflows/review.md": "fa3d682b94afcdcd", + "gsd-core/workflows/review.md": "93fe46bd0b5dd3d1", "gsd-core/workflows/scan.md": "003883d71c37da7d", "gsd-core/workflows/secure-phase.md": "29fc6b62c5c5dc62", "gsd-core/workflows/session-report.md": "2e5b1205324ddefa", diff --git a/tests/fixtures/golden-install-parity/codex.json b/tests/fixtures/golden-install-parity/codex.json index 633fa8703..b7f1dd231 100644 --- a/tests/fixtures/golden-install-parity/codex.json +++ b/tests/fixtures/golden-install-parity/codex.json @@ -319,7 +319,7 @@ "gsd-core/workflows/remove-phase.md": "9ee0fddd11a0d9d4", "gsd-core/workflows/remove-workspace.md": "19d7465aaa50cb62", "gsd-core/workflows/resume-project.md": "9965f87eb278f7f8", - "gsd-core/workflows/review.md": "e1fdf297509bebb0", + "gsd-core/workflows/review.md": "63c61eddf20d77f1", "gsd-core/workflows/scan.md": "1a3caa5d724d39e9", "gsd-core/workflows/secure-phase.md": "db91810d16964b1e", "gsd-core/workflows/session-report.md": "dd8fa011c9394075", diff --git a/tests/fixtures/golden-install-parity/copilot.json b/tests/fixtures/golden-install-parity/copilot.json index 53c2201a9..64637655a 100644 --- a/tests/fixtures/golden-install-parity/copilot.json +++ b/tests/fixtures/golden-install-parity/copilot.json @@ -285,7 +285,7 @@ "gsd-core/workflows/remove-phase.md": "e262654e319d1bc4", "gsd-core/workflows/remove-workspace.md": "ceddfeef5f2d6754", "gsd-core/workflows/resume-project.md": "40db7f350f5866d8", - "gsd-core/workflows/review.md": "3a6786b46734397e", + "gsd-core/workflows/review.md": "5f146ed397bcfe5a", "gsd-core/workflows/scan.md": "dcc2f76d0850e2fb", "gsd-core/workflows/secure-phase.md": "d87bd706f85bcad6", "gsd-core/workflows/session-report.md": "2e5b1205324ddefa", diff --git a/tests/fixtures/golden-install-parity/cursor.json b/tests/fixtures/golden-install-parity/cursor.json index 2b9bf4dad..992990115 100644 --- a/tests/fixtures/golden-install-parity/cursor.json +++ b/tests/fixtures/golden-install-parity/cursor.json @@ -355,7 +355,7 @@ "gsd-core/workflows/remove-phase.md": "ada8a0546c686483", "gsd-core/workflows/remove-workspace.md": "433affcd1a200826", "gsd-core/workflows/resume-project.md": "7f8dc986f0f35d96", - "gsd-core/workflows/review.md": "c2ce42a245fcc972", + "gsd-core/workflows/review.md": "ed6b9f54f74204ff", "gsd-core/workflows/scan.md": "47371c2073d6c0be", "gsd-core/workflows/secure-phase.md": "c55975672c4e1895", "gsd-core/workflows/session-report.md": "2e5b1205324ddefa", diff --git a/tests/fixtures/golden-install-parity/hermes.json b/tests/fixtures/golden-install-parity/hermes.json index 816be6a81..4dc7cd405 100644 --- a/tests/fixtures/golden-install-parity/hermes.json +++ b/tests/fixtures/golden-install-parity/hermes.json @@ -284,7 +284,7 @@ "gsd-core/workflows/remove-phase.md": "fce799aae3ab2715", "gsd-core/workflows/remove-workspace.md": "8facde381657dd71", "gsd-core/workflows/resume-project.md": "a0443839f1f83c2d", - "gsd-core/workflows/review.md": "41dc23cb171b336e", + "gsd-core/workflows/review.md": "c9de3987db14b94f", "gsd-core/workflows/scan.md": "b28f65d88c522767", "gsd-core/workflows/secure-phase.md": "f2957d4b88fb3746", "gsd-core/workflows/session-report.md": "2e5b1205324ddefa", diff --git a/tests/fixtures/golden-install-parity/kilo.json b/tests/fixtures/golden-install-parity/kilo.json index b6c10bd8e..e2a461d1c 100644 --- a/tests/fixtures/golden-install-parity/kilo.json +++ b/tests/fixtures/golden-install-parity/kilo.json @@ -355,7 +355,7 @@ "gsd-core/workflows/remove-phase.md": "ada8a0546c686483", "gsd-core/workflows/remove-workspace.md": "fc83f362a2d0a1b7", "gsd-core/workflows/resume-project.md": "7f8dc986f0f35d96", - "gsd-core/workflows/review.md": "bf7defbff23639ab", + "gsd-core/workflows/review.md": "dcea2ffd4f54c845", "gsd-core/workflows/scan.md": "47371c2073d6c0be", "gsd-core/workflows/secure-phase.md": "e8855104c1e0417c", "gsd-core/workflows/session-report.md": "2e5b1205324ddefa", diff --git a/tests/fixtures/golden-install-parity/kimi.json b/tests/fixtures/golden-install-parity/kimi.json index 0af4bc694..d8e0287ca 100644 --- a/tests/fixtures/golden-install-parity/kimi.json +++ b/tests/fixtures/golden-install-parity/kimi.json @@ -320,7 +320,7 @@ "gsd-core/workflows/remove-phase.md": "df9a45f0b1880999", "gsd-core/workflows/remove-workspace.md": "a7ca66db6b7c132c", "gsd-core/workflows/resume-project.md": "f28da1200e4545f4", - "gsd-core/workflows/review.md": "fa3d682b94afcdcd", + "gsd-core/workflows/review.md": "93fe46bd0b5dd3d1", "gsd-core/workflows/scan.md": "003883d71c37da7d", "gsd-core/workflows/secure-phase.md": "29fc6b62c5c5dc62", "gsd-core/workflows/session-report.md": "2e5b1205324ddefa", diff --git a/tests/fixtures/golden-install-parity/opencode.json b/tests/fixtures/golden-install-parity/opencode.json index 7adc67017..4fd76a7cd 100644 --- a/tests/fixtures/golden-install-parity/opencode.json +++ b/tests/fixtures/golden-install-parity/opencode.json @@ -355,7 +355,7 @@ "gsd-core/workflows/remove-phase.md": "dea4661e8f89596f", "gsd-core/workflows/remove-workspace.md": "446847e71aa52504", "gsd-core/workflows/resume-project.md": "ad9f06a10bab8cc0", - "gsd-core/workflows/review.md": "8e4d4a67f4b16419", + "gsd-core/workflows/review.md": "a039f632e0b89003", "gsd-core/workflows/scan.md": "ad8ebcad4626d4a8", "gsd-core/workflows/secure-phase.md": "e9a488cec3b4efdc", "gsd-core/workflows/session-report.md": "2e5b1205324ddefa", diff --git a/tests/fixtures/golden-install-parity/qwen.json b/tests/fixtures/golden-install-parity/qwen.json index 942069296..0db2d73fb 100644 --- a/tests/fixtures/golden-install-parity/qwen.json +++ b/tests/fixtures/golden-install-parity/qwen.json @@ -284,7 +284,7 @@ "gsd-core/workflows/remove-phase.md": "e8ae4fbbfac700f0", "gsd-core/workflows/remove-workspace.md": "4ac64de862dc650e", "gsd-core/workflows/resume-project.md": "7f20769f302e5427", - "gsd-core/workflows/review.md": "6c639036a133a00a", + "gsd-core/workflows/review.md": "aa4674a4754438f2", "gsd-core/workflows/scan.md": "949692db4834dd27", "gsd-core/workflows/secure-phase.md": "6758f1acf4113e9e", "gsd-core/workflows/session-report.md": "2e5b1205324ddefa", diff --git a/tests/fixtures/golden-install-parity/trae.json b/tests/fixtures/golden-install-parity/trae.json index 46464ac7f..1aa5e03d0 100644 --- a/tests/fixtures/golden-install-parity/trae.json +++ b/tests/fixtures/golden-install-parity/trae.json @@ -284,7 +284,7 @@ "gsd-core/workflows/remove-phase.md": "a46c2fe853bf4e86", "gsd-core/workflows/remove-workspace.md": "ae0e1c6d4438d663", "gsd-core/workflows/resume-project.md": "f242e4c8aba18ea2", - "gsd-core/workflows/review.md": "388c7e1a884767bb", + "gsd-core/workflows/review.md": "51eb79b72a09d47a", "gsd-core/workflows/scan.md": "63631467651d9ca8", "gsd-core/workflows/secure-phase.md": "6cc236e53c2e7d56", "gsd-core/workflows/session-report.md": "2e5b1205324ddefa", diff --git a/tests/fixtures/golden-install-parity/windsurf.json b/tests/fixtures/golden-install-parity/windsurf.json index 417874a02..e1f88551e 100644 --- a/tests/fixtures/golden-install-parity/windsurf.json +++ b/tests/fixtures/golden-install-parity/windsurf.json @@ -284,7 +284,7 @@ "gsd-core/workflows/remove-phase.md": "e7a6af429b36e77b", "gsd-core/workflows/remove-workspace.md": "b5e60fbb33b3e33a", "gsd-core/workflows/resume-project.md": "82cfe1b8cb17c085", - "gsd-core/workflows/review.md": "846e5693ea0cba84", + "gsd-core/workflows/review.md": "31b1f08d08b8ccdc", "gsd-core/workflows/scan.md": "12c11b2edc165df9", "gsd-core/workflows/secure-phase.md": "7bf923689bf58288", "gsd-core/workflows/session-report.md": "2e5b1205324ddefa", diff --git a/tests/fixtures/golden-install-parity/zcode.json b/tests/fixtures/golden-install-parity/zcode.json index 8ece548c5..d291c636b 100644 --- a/tests/fixtures/golden-install-parity/zcode.json +++ b/tests/fixtures/golden-install-parity/zcode.json @@ -355,7 +355,7 @@ "gsd-core/workflows/remove-phase.md": "df9a45f0b1880999", "gsd-core/workflows/remove-workspace.md": "a7ca66db6b7c132c", "gsd-core/workflows/resume-project.md": "f28da1200e4545f4", - "gsd-core/workflows/review.md": "fa3d682b94afcdcd", + "gsd-core/workflows/review.md": "93fe46bd0b5dd3d1", "gsd-core/workflows/scan.md": "003883d71c37da7d", "gsd-core/workflows/secure-phase.md": "29fc6b62c5c5dc62", "gsd-core/workflows/session-report.md": "2e5b1205324ddefa", diff --git a/tests/review-default-reviewers-workflow.test.cjs b/tests/review-default-reviewers-workflow.test.cjs index 3c9245551..6104a8f59 100644 --- a/tests/review-default-reviewers-workflow.test.cjs +++ b/tests/review-default-reviewers-workflow.test.cjs @@ -188,30 +188,35 @@ describe('bug #687 → #2073: agy print mode bounded by --print-timeout PAIRED w // the exec arg list on a large review prompt (Linux MAX_ARG_STRLEN // 128 KB/single-arg → rc 126). - test('invokes agy with --print-timeout AND a paired external timeout', () => { + test('invokes agy with --print-timeout AND a paired external killer when available', () => { const c = read(); assert.match(c, /--print-timeout \d+s?/, 'review.md must pass agy its native --print-timeout'); - // External `timeout agy …` — the paired backstop per agy guidance. - assert.match(c, /(^|\s)timeout\s+\d+\s+agy\b/, - 'review.md must wrap agy in an external `timeout agy` (agy guidance pairs --print-timeout with a terminal timeout)'); + // Capability probe for GNU `timeout` / macOS `gtimeout` (stock macOS has neither). + assert.match(c, /command -v timeout/, 'review.md must probe for the `timeout` killer'); + assert.match(c, /command -v gtimeout/, 'review.md must probe for `gtimeout` (macOS Homebrew)'); + // The external cap (600s) is applied ahead of agy and is >= --print-timeout (540s). + assert.match(c, /600 agy --print-timeout 540s/, + 'review.md must pair an external cap (600s) >= --print-timeout (540s) with agy (agy guidance)'); }); - test('external timeout is >= --print-timeout so it never cuts a healthy run', () => { + test('external cap is >= --print-timeout, and falls back to bare agy on macOS', () => { const c = read(); - const external = c.match(/timeout\s+(\d+)\s+agy\b/); - const native = c.match(/--print-timeout\s+(\d+)s/); - assert.ok(external && native, 'both the external timeout and --print-timeout must be present'); + const bound = c.match(/(\d+)\s+agy --print-timeout (\d+)s/); + assert.ok(bound, 'review.md must encode the external-cap + --print-timeout pair'); assert.ok( - Number(external[1]) >= Number(native[1]), - 'external `timeout` (seconds) must be >= --print-timeout (seconds) so it only backstops a stall', + Number(bound[1]) >= Number(bound[2]), + 'external cap (seconds) must be >= --print-timeout (seconds) so it only backstops a stall', ); + // Graceful fallback when no external killer is available (stock macOS). + assert.match(c, /else\n\s*agy --print-timeout/, + 'review.md must fall back to --print-timeout alone when no external killer is available (macOS)'); }); test('uses a file-reference prompt, not inline "$(cat …)" (arg-list overflow, #2073)', () => { const c = read(); assert.doesNotMatch(c, /agy[^\n]*-p "\$\(cat/, 'review.md must not feed agy the prompt inline via "$(cat …)" — a large review prompt overflows the exec arg list (rc 126)'); - assert.match(c, /agy[^\n]*-p "Read the file at \/tmp\/gsd-review-prompt-/, + assert.match(c, /Read the file at \/tmp\/gsd-review-prompt-/, 'review.md should pass agy a file-reference prompt (mirrors the Cursor block)'); }); diff --git a/tests/workflow-size-baseline.json b/tests/workflow-size-baseline.json index b67f69f44..732a96b91 100644 --- a/tests/workflow-size-baseline.json +++ b/tests/workflow-size-baseline.json @@ -64,7 +64,7 @@ "remove-phase.md": 8513, "remove-workspace.md": 7551, "resume-project.md": 17270, - "review.md": 45299, + "review.md": 46297, "scan.md": 7732, "secure-phase.md": 13520, "session-report.md": 4044, From 25ece96649f4183357e85431002997041bc600da Mon Sep 17 00:00:00 2001 From: Tom Boucher Date: Wed, 8 Jul 2026 18:38:53 -0400 Subject: [PATCH 15/33] docs(changeset): backfill pr: 2109 for #2073 --- .changeset/2073-antigravity-reviewer-block.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.changeset/2073-antigravity-reviewer-block.md b/.changeset/2073-antigravity-reviewer-block.md index 9703877af..009c6cd5e 100644 --- a/.changeset/2073-antigravity-reviewer-block.md +++ b/.changeset/2073-antigravity-reviewer-block.md @@ -1,5 +1,5 @@ --- type: Fixed -pr: 0 +pr: 2109 --- **`/gsd-review`'s Antigravity CLI reviewer no longer fails silently on large prompts, unavailable pinned models, or pre-session stalls** — the `agy` invocation now uses a file-reference prompt to avoid exec arg-list overflow, is wrapped in an external wall-clock `timeout` paired with `--print-timeout` because `--print-timeout` cannot fire before `agy` creates a session, passes `--model` from `review.models.agy` when set as an escape hatch for a 404'd pinned model, and its empty-output stub now surfaces an `agy` cli.log diagnostic instead of a bare generic message. Supersedes the #687 "no external killer / inline `$(cat)`" contract, which predated `agy` gaining `--model` and predated its own guidance to pair `--print-timeout` with a terminal timeout. (#2073) From dc5b89f4016d4bfbea7c696e146d4c4063f3a5ed Mon Sep 17 00:00:00 2001 From: Tom Boucher Date: Wed, 8 Jul 2026 18:56:27 -0400 Subject: [PATCH 16/33] test(#2073): add see #2073 ref to allow-test-rule exemption (ADR-456) --- tests/antigravity-reviewer.test.cjs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/antigravity-reviewer.test.cjs b/tests/antigravity-reviewer.test.cjs index 6e787cba6..84d347cf5 100644 --- a/tests/antigravity-reviewer.test.cjs +++ b/tests/antigravity-reviewer.test.cjs @@ -1,4 +1,4 @@ -// allow-test-rule: source-text-is-the-product +// allow-test-rule: source-text-is-the-product (see #2073) // gsd-core/workflows/review.md is a workflow document whose bash blocks ARE // what /gsd-review loads and executes at runtime. Asserting the Antigravity // invocation shape asserts the deployed contract — this is behavioral coverage From 6e773d97dfd8ce28060a6351d55f2186f95dfe2a Mon Sep 17 00:00:00 2001 From: Tom Boucher Date: Wed, 8 Jul 2026 21:42:11 -0400 Subject: [PATCH 17/33] feat(#2088): migrate Codex onto the Embeddable Orchestration System (ADR-1239) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Drive Codex install/uninstall through the descriptor-driven Host-Integration Interface (declarative embedding adapter → engine surface dispatch) and fold every positive `runtime === 'codex'` / `isCodex` projection into descriptor-driven `runtime.hostBehaviors`. Install/uninstall output stays byte-parity-gated (tests/fixtures/golden-install-parity/codex.json); no other runtime changes. Three Context7-verified upgrades, each with a test on the user-reachable surface: - Skill root → canonical $HOME/.agents/skills via a skills-kind `home` override, with pre-move migration cleanup (stale ~/.codex/skills/gsd-* removed on install and uninstall; user content preserved). Fixes getGlobalSkillsBase, writeManifest, and the skill-manifest inventory to honor the override so --skills-root / sync-skills / the manifest report the real location. - Six new hooks.json lifecycle events (PreToolUse, PermissionRequest, PreCompact, PostCompact, SubagentStop, UserPromptSubmit) shared by install + uninstall; extendedHookEvents reconciled [] -> the schema-valid wired subset. - Explicit `[agents] max_depth = 1` in the managed config.toml block, pinning the negotiated dispatch.maxDepth:1 axis. validateCodexConfigSchema now permits a known-scalar-only bare `[agents]` AgentsToml table (still rejects [[agents]] and unknown-key break-forms, #2760); mergeCodexConfig preserves the user's own AgentsToml scalars (max_threads etc.) instead of dropping them. Co-Authored-By: Claude Opus 4.8 --- .../2088-eos-codex-declarative-adapter.md | 4 + bin/install.js | 393 ++++++++++++++++-- capabilities/codex/capability.json | 19 +- .../host-integration-capability-matrix.md | 6 + eslint-rules/lib/portability-vocab.cjs | 3 + gsd-core/bin/lib/capability-registry.cjs | 38 +- .../bin/lib/runtime-artifact-install-plan.cjs | 4 +- src/init.cts | 17 +- src/install-engine.cts | 32 +- src/runtime-artifact-install-plan.cts | 8 +- src/runtime-artifact-layout.cts | 37 +- src/runtime-homes.cts | 11 + tests/codex-config.test.cjs | 238 +++++++++-- tests/codex-declarative-reference.test.cjs | 289 +++++++++++++ .../fixtures/golden-install-parity/codex.json | 146 +++---- tests/helpers/install-shared.cjs | 26 +- tests/install-minimal-hooks.test.cjs | 34 +- tests/install-nested-layout.test.cjs | 51 ++- tests/install-runtime-artifacts.test.cjs | 27 +- tests/install.test.cjs | 11 +- ...ler-migration-install.integration.test.cjs | 42 +- tests/installer-migrations.test.cjs | 12 + tests/portability-vocab-drift.test.cjs | 3 + tests/profile-output.test.cjs | 12 +- 24 files changed, 1252 insertions(+), 211 deletions(-) create mode 100644 .changeset/2088-eos-codex-declarative-adapter.md create mode 100644 tests/codex-declarative-reference.test.cjs diff --git a/.changeset/2088-eos-codex-declarative-adapter.md b/.changeset/2088-eos-codex-declarative-adapter.md new file mode 100644 index 000000000..6099d33d5 --- /dev/null +++ b/.changeset/2088-eos-codex-declarative-adapter.md @@ -0,0 +1,4 @@ +--- +type: Changed +--- +**Codex is now driven through the public Host-Integration Interface, with three capability upgrades (ADR-1239 / EoS).** Codex previously installed via hardcoded `runtime === 'codex'`/`isCodex` projection in `bin/install.js`; its `config.toml` / agent-`.toml` / `hooks.json` install now runs through the declarative embedding adapter and descriptor-driven `runtime.hostBehaviors`, with **zero** positive `isCodex` gates and **zero** `runtime === 'codex'` branches remaining (source-guarded). Install/uninstall output stays byte-parity-gated (`tests/fixtures/golden-install-parity/codex.json`). Three Context7-verified upgrades land, each with a test driving the user-reachable surface: (1) **skill root** — GSD skills now install to Codex's canonical `$HOME/.agents/skills` (via a skills-kind `home` override) instead of the deprecated `$CODEX_HOME/skills` fallback, and pre-move installs are migrated (stale `~/.codex/skills/gsd-*` cleaned on both install and uninstall, user-owned content preserved); (2) **hook events** — GSD registers the six documented Codex lifecycle events it previously skipped (`PreToolUse`, `PermissionRequest`, `PreCompact`, `PostCompact`, `SubagentStop`, `UserPromptSubmit`, in addition to the existing `SessionStart`/`SubagentStart`/`Stop`/`PostToolUse`) in `hooks.json`, so `gsd-context-monitor` fires at the same points as in Claude Code, and the descriptor `extendedHookEvents` is reconciled from `[]` to the schema-valid wired subset; (3) **dispatch tuning** — `[agents] max_depth = 1` is written explicitly into the managed `config.toml` block to pin the negotiated `dispatch.maxDepth: 1` axis (`degradationFor` flattens GSD-hosted waves to single-level), and `validateCodexConfigSchema` now permits a known-scalar-only `[agents]` AgentsToml table (coexisting with the flattened `[agents.gsd-*]` role sub-tables) while still rejecting the `[[agents]]` and unknown-key break-forms from #2760. (#2088) diff --git a/bin/install.js b/bin/install.js index b8e9fa279..78af35018 100755 --- a/bin/install.js +++ b/bin/install.js @@ -102,6 +102,45 @@ const reset = '\x1b[0m'; // Codex config.toml constants const GSD_CODEX_MARKER = '# GSD Agent Configuration \u2014 managed by gsd-core installer'; const GSD_CODEX_HOOKS_OWNERSHIP_PREFIX = '# GSD codex_hooks ownership: '; +// Known scalar fields of Codex's `AgentsToml` struct (codex-rs/config/src/ +// config_toml.rs \u2014 `[agents]` table). Codex marks the struct +// `#[schemars(deny_unknown_fields)]`, so a bare `[agents]` table is valid ONLY +// when every direct key is one of these (named agent roles live in the flattened +// `[agents.]` sub-tables, a separate `AgentRoleToml`). GSD writes only +// `max_depth` (ADR-1239 upgrade 2 / #2088); the full set is enumerated so the +// schema check accepts a user's other legitimate AgentsToml scalars too. +const CODEX_AGENTS_TOML_SCALAR_KEYS = new Set([ + 'max_threads', + 'max_depth', + 'job_max_runtime_seconds', + 'interrupt_message', +]); +// GSD's managed dispatch-depth value. Codex's implicit default is also 1 (root +// sessions start at depth 0); writing it EXPLICITLY pins the negotiated +// `dispatch.maxDepth: 1` axis instead of relying on codex-cli's implicit default +// (ADR-1239 upgrade 2 / #2088). Per the negotiated capability, GSD-hosted Codex +// dispatch is single-level (maxDepth === 1 \u2192 `degradationFor` flattens waves). +const GSD_CODEX_AGENTS_MAX_DEPTH = 1; +// Codex hooks.json lifecycle events GSD registers beyond SessionStart (which has +// its own dedicated path). This is Codex's OWN hook-event vocabulary (per +// developers.openai.com/codex/config-reference), distinct from the cross-runtime +// settings.json `extendedHookEvents` descriptor field (a claude/gemini-family +// allowlist consumed only by hooksSurface==='settings-json' runtimes — Codex is +// codex-hooks-json). All route through gsd-context-monitor.js. #772 wired the +// first three; #2088 adds the remaining six documented events so GSD's monitor +// fires at the same lifecycle points as in Claude Code. Install and uninstall +// share this list so the registered set and the removed set never diverge. +const CODEX_EXTENDED_HOOK_EVENTS = [ + 'SubagentStart', + 'Stop', + 'PostToolUse', + 'PreToolUse', + 'PermissionRequest', + 'PreCompact', + 'PostCompact', + 'SubagentStop', + 'UserPromptSubmit', +]; // Codex's hook-enabling feature flag (issue #3566). Codex itself marks // `codex_hooks` as a `legacy_key` in codex-rs/features/src/legacy.rs; the // canonical current key under [features] is `hooks`. The installer always @@ -357,6 +396,22 @@ function _hostBehaviors(runtime) { return _resolveHostBehaviors(runtime, _capabilityRegistry); } +/** + * Resolve the ACTUAL on-disk skills-install directory for a runtime, honoring a + * skills-kind `home` override (ADR-1239 upgrade 3 / #2088: e.g. Codex skills -> + * $HOME/.agents/skills instead of the runtime's configDir). Descriptor-driven + * (no runtime === '' check) so the snapshot/rollback machinery and post-install + * verification look where the skills actually landed. Falls back to /skills. + */ +function _resolveSkillsRootDir(runtime, targetDir, scope) { + try { + const layout = resolveRuntimeArtifactLayout(runtime, targetDir, scope); + const skillsKind = layout.kinds.find((k) => k.kind === 'skills'); + if (skillsKind) return path.join(skillsKind.home || targetDir, skillsKind.destSubpath); + } catch (_e) { /* fall through to the configDir default */ } + return path.join(targetDir, 'skills'); +} + /** * Construct the imperative Host-Integration adapter (ADR-1239 / #2086), FAIL-OPEN. * `createImperativeAdapter` composes the capability registry via @@ -3147,6 +3202,77 @@ function cleanupWindsurfLegacyDevinSkills(workspaceDir) { return removed; } +/** + * Migrate a skills kind that moved to an alternate `home` (ADR-1239 split-home): + * remove now-stale `*` skill dirs left at the OLD configDir-rooted + * location by installs from before the move. Without this, upgrading (e.g. Codex + * relocating skills to ~/.agents/skills) orphans the pre-move dirs at + * ~/.codex/skills. Only managed `*` dirs are touched; user-owned content + * (non-prefixed dirs, gsd-dev-preferences, symlinks) is preserved. Fail-open. + * @param {string} oldSkillsDir absolute path to the pre-move skills location + * @param {string} prefix managed skill-dir prefix (e.g. 'gsd-') + * @returns {number} count of stale dirs removed + */ +function cleanupMovedSkillsOldLocation(oldSkillsDir, prefix) { + if (!fs.existsSync(oldSkillsDir)) return 0; + + // Mirror the user-owned list from cleanupCodexSkillMetadataSidecars (#2973). + const _userOwnedSkillDirs = new Set(['gsd-dev-preferences']); + let removed = 0; + + for (const entry of fs.readdirSync(oldSkillsDir, { withFileTypes: true })) { + if (!entry.isDirectory() || !entry.name.startsWith(prefix)) continue; + if (_userOwnedSkillDirs.has(entry.name)) continue; + + const dirToRemove = path.join(oldSkillsDir, entry.name); + try { + // Symlink guard (mirrors cleanupWindsurfLegacyDevinSkills): never delete + // through a symlinked gsd-* dir — it could escape the tree. + const stat = fs.lstatSync(dirToRemove); + if (stat.isSymbolicLink()) continue; + + fs.rmSync(dirToRemove, { recursive: true, force: true }); + removed++; + } catch (_err) { + // Fail open — a single bad dir must not block install/uninstall. + } + } + + // Prune the old skills dir if now empty — leaves the configHome clean. + // Never remove a non-empty container (user may keep other content there). + try { + if (fs.existsSync(oldSkillsDir) && fs.readdirSync(oldSkillsDir).length === 0) { + fs.rmdirSync(oldSkillsDir); + } + } catch (_err) { + // best-effort container cleanup + } + + return removed; +} + +/** + * When a runtime's skills kind declares an alternate `home` (split-home move), + * return the now-stale configDir-rooted skills location that installs before the + * move used; null when no move is in effect (no home override, or home resolves + * to the same path). Descriptor-driven — no per-runtime hardcoding. + * @returns {string|null} + */ +function _resolveMovedSkillsOldDir(runtime, targetDir, scope) { + try { + const layout = resolveRuntimeArtifactLayout(runtime, targetDir, scope); + const skillsKind = layout.kinds.find((k) => k.kind === 'skills'); + if (skillsKind && skillsKind.home) { + const oldDir = path.join(targetDir, skillsKind.destSubpath); + const newDir = path.join(skillsKind.home, skillsKind.destSubpath); + if (path.resolve(oldDir) !== path.resolve(newDir)) return oldDir; + } + } catch (_e) { + // No migration when the layout can't resolve — never block on this. + } + return null; +} + /** * Generate the GSD config block for Codex config.toml. * @param {Array<{name: string, description: string}>} agents @@ -3162,6 +3288,17 @@ function generateCodexConfigBlock(agents, targetDir) { '', ]; + // ADR-1239 upgrade 2 / #2088 — explicit dispatch tuning. Pin `max_depth` on the + // `[agents]` (AgentsToml) table rather than relying on codex-cli's implicit + // default, realizing the negotiated `dispatch.maxDepth: 1` axis. This bare + // `[agents]` scalar table coexists with the flattened `[agents.]` role + // sub-tables below (validated by validateCodexConfigSchema, which permits a + // known-scalar-only `[agents]`). Emitted before the role tables so the parent + // table is opened first. + lines.push('[agents]'); + lines.push(`max_depth = ${GSD_CODEX_AGENTS_MAX_DEPTH}`); + lines.push(''); + for (const { name, description } of agents) { // #2727 — Codex 0.124.0 requires [agents.] struct format, not [[agents]] sequence. // [[agents]] (introduced in #2645) is rejected by codex-cli 0.124.0 with @@ -3175,6 +3312,52 @@ function generateCodexConfigBlock(agents, targetDir) { return lines.join('\n'); } +/** + * Extract a user's pre-existing AgentsToml scalar assignments from a bare + * `[agents]` table — every known scalar EXCEPT `max_depth` (which GSD manages + * and always re-emits as 1). Returned as raw `key = value` line strings so + * mergeCodexConfig can PRESERVE them in the managed block instead of silently + * dropping the user's tuning when the bare `[agents]` table is purged (#2088 + * review finding: the loosened validator declares such a table legitimate, so + * install must not destroy it). Only the first bare `[agents]` section is read; + * `[agents.]` role tables are ignored. Fail-open → []. + * @returns {string[]} + */ +function extractCodexUserAgentsScalars(content) { + const preserved = []; + let section; + try { + section = getTomlTableSections(content).find((s) => !s.array && s.path === 'agents'); + } catch (_e) { + return preserved; + } + if (!section) return preserved; + const body = content.slice(section.headerEnd, section.end); + for (const record of getTomlLineRecords(body)) { + if (record.startsInMultilineString || record.tableHeader) continue; + const trimmed = record.text.trim(); + if (!trimmed || trimmed.startsWith('#')) continue; + if (!record.keySegments || record.keySegments.length !== 1) continue; + const key = record.keySegments[0]; + if (key === 'max_depth') continue; // GSD-managed — GSD's value wins. + if (!CODEX_AGENTS_TOML_SCALAR_KEYS.has(key)) continue; + preserved.push(trimmed); + } + return preserved; +} + +/** + * Splice preserved user AgentsToml scalar lines into the managed GSD config + * block, immediately after the `[agents]` header and before GSD's `max_depth` + * line. Operates on the pre-EOL-normalization block (LF joins), matching only + * the bare `[agents]` header (never `[agents.]`). Returns the block + * unchanged when there is nothing to preserve or the anchor is absent. + */ +function spliceCodexAgentsScalars(block, scalarLines) { + if (!scalarLines || scalarLines.length === 0) return block; + return block.replace(/(\n\[agents\]\n)(max_depth = )/, `$1${scalarLines.join('\n')}\n$2`); +} + /** * Strip any managed GSD agent sections from a TOML string. * @@ -3197,6 +3380,16 @@ function stripCodexGsdAgentSections(content) { return true; } + // GSD's managed `[agents]` scalar block (ADR-1239 upgrade 2 / #2088 — the + // `max_depth` dispatch-tuning table). Install purges any pre-existing bare + // `[agents]` and writes its own, so a known-scalar-only bare `[agents]` is + // GSD-owned; strip it on uninstall. (The marker path already removes it via + // the marker-to-EOF cut; this covers the no-marker fallback.) + if (!section.array && section.path === 'agents') { + const body = content.slice(section.headerEnd, section.end); + return codexBareAgentsHasOnlyKnownScalars(body); + } + // Legacy `[[agents]]` array-of-tables (#2645) — only strip blocks whose // `name = "gsd-..."`, preserving user-authored [[agents]] entries. if (section.array && section.path === 'agents') { @@ -3224,7 +3417,11 @@ function stripGsdFromCodexConfig(content) { const codexHooksOwnership = getManagedCodexHooksOwnership(content); if (markerIndex !== -1) { - // Has GSD marker — remove everything from marker to EOF + // Has GSD marker — remove everything from marker to EOF. First recover the + // user's own AgentsToml scalars (max_threads etc.) that install folded into + // the managed [agents] block (#2088), so a full install→uninstall cycle + // round-trips the user's tuning. GSD-managed max_depth is dropped. + const preservedScalars = extractCodexUserAgentsScalars(content.slice(markerIndex)); let before = content.substring(0, markerIndex); before = stripCodexHooksFeatureAssignments(before, codexHooksOwnership); // Also strip GSD-injected feature keys above the marker (Case 3 inject) @@ -3233,6 +3430,9 @@ function stripGsdFromCodexConfig(content) { before = before.replace(/^\[features\]\s*\n(?=\[|$)/m, ''); before = before.replace(/^\[agents\]\s*\n(?=\[|$)/m, ''); before = before.replace(/^(?:\r?\n)+/, '').trimEnd(); + if (preservedScalars.length > 0) { + before = (before ? before + eol + eol : '') + '[agents]' + eol + preservedScalars.join(eol); + } if (!before) return null; return before + eol; } @@ -3243,7 +3443,11 @@ function stripGsdFromCodexConfig(content) { cleaned = cleaned.replace(/^multi_agent\s*=\s*true\s*(?:\r?\n)?/m, ''); cleaned = cleaned.replace(/^default_mode_request_user_input\s*=\s*true\s*(?:\r?\n)?/m, ''); - // Remove [agents.gsd-*] sections (from header to next section or EOF) + // #2088: recover the user's own AgentsToml scalars before the [agents] table is + // stripped, so they survive uninstall even in the no-marker fallback path. + const preservedScalars = extractCodexUserAgentsScalars(cleaned); + + // Remove [agents.gsd-*] sections + the managed known-scalar [agents] table. cleaned = stripCodexGsdAgentSections(cleaned); // Remove [features] section if now empty (only header, no keys before next section) @@ -3254,6 +3458,10 @@ function stripGsdFromCodexConfig(content) { cleaned = cleaned.replace(/^(?:\r?\n)+/, '').trimEnd(); + if (preservedScalars.length > 0) { + cleaned = (cleaned ? cleaned + eol + eol : '') + '[agents]' + eol + preservedScalars.join(eol); + } + if (!cleaned) return null; return cleaned + eol; } @@ -4725,6 +4933,33 @@ function parseTomlToObject(content) { * - `hooks.` MUST be an array of tables when present (Codex ≥0.124 * rejects bare `[hooks.]` single-bracket maps). */ +/** + * True when a bare `[agents]` table body contains ONLY known AgentsToml scalar + * keys (CODEX_AGENTS_TOML_SCALAR_KEYS) — i.e. it is a valid AgentsToml struct + * that Codex's `deny_unknown_fields` will accept, not the break-causing form + * (#2760) that carries an unknown key. Comments and blank lines are ignored; an + * empty body is trivially valid. Mirrors isLegacyGsdAgentsSection's line scan. + */ +function codexBareAgentsHasOnlyKnownScalars(body) { + const lineRecords = getTomlLineRecords(body); + for (const record of lineRecords) { + // Conservative reject of anything not positively a single known-scalar + // assignment. A multiline-string value cannot be a valid AgentsToml scalar + // (max_threads/max_depth/job_max_runtime_seconds are integers, + // interrupt_message is a bool — none are strings), so codex would reject it + // too; rejecting here is correct, not a false negative. + if (record.startsInMultilineString) return false; + if (record.tableHeader) return false; + const trimmed = record.text.trim(); + if (!trimmed || trimmed.startsWith('#')) continue; + if (!record.keySegments || record.keySegments.length !== 1 || + !CODEX_AGENTS_TOML_SCALAR_KEYS.has(record.keySegments[0])) { + return false; + } + } + return true; +} + function validateCodexConfigSchema(content) { let parsed; try { @@ -4753,10 +4988,21 @@ function validateCodexConfigSchema(content) { } if (!section.array && section.path === 'agents') { - return { - ok: false, - reason: 'bare [agents] table is invalid in current Codex schema (expected [agents.] struct form)', - }; + // #2760 rejected ALL bare `[agents]` tables because a bare table holding a + // non-AgentsToml key (`default = "x"`, a role name, etc.) triggers Codex's + // "invalid type: ..., expected struct AgentsToml" and breaks every CLI + // invocation. But a bare `[agents]` whose keys are all valid AgentsToml + // scalars (max_depth/max_threads/...) IS a valid struct — that is exactly + // GSD's managed `max_depth` dispatch-tuning block (ADR-1239 upgrade 2 / + // #2088), and a user's own scalar tuning. Permit known-scalar-only; still + // reject any bare `[agents]` carrying an unknown key. + const body = content.slice(section.headerEnd, section.end); + if (!codexBareAgentsHasOnlyKnownScalars(body)) { + return { + ok: false, + reason: 'bare [agents] table with a non-AgentsToml key is invalid in current Codex schema (expected [agents.] struct form, or only AgentsToml scalars like max_depth/max_threads)', + }; + } } // hooks.state.* is Codex's persistent hook-trust namespace (added in @@ -5032,7 +5278,13 @@ function mergeCodexConfig(configPath, gsdBlock) { const existing = fs.readFileSync(configPath, 'utf8'); const eol = detectLineEnding(existing); - const normalizedGsdBlock = gsdBlock.replace(/\r?\n/g, eol); + // #2088 review: the bare `[agents]` table is purged below (Case 2/3 via + // stripLeakedGsdCodexSections) to keep a single managed `[agents]`. Preserve + // the user's own AgentsToml scalar tuning (max_threads, job_max_runtime_seconds, + // interrupt_message — everything except GSD-managed max_depth) by re-emitting + // it inside the managed block, so install never silently drops it. + const mergedGsdBlock = spliceCodexAgentsScalars(gsdBlock, extractCodexUserAgentsScalars(existing)); + const normalizedGsdBlock = mergedGsdBlock.replace(/\r?\n/g, eol); const markerIndex = existing.indexOf(GSD_CODEX_MARKER); // Case 2: Has GSD marker — truncate and re-append @@ -6565,8 +6817,24 @@ function uninstall(isGlobal, runtime = DEFAULT_RUNTIME) { } removedCount++; + // ADR-1239 split-home migration: the adapter/plan uninstall targets the new + // `home` location (e.g. Codex → ~/.agents/skills). A user who installed + // BEFORE the move and never reinstalled still has managed gsd-* skill dirs at + // the old configDir-rooted location (~/.codex/skills) — remove those too so + // uninstall leaves nothing behind. User-owned content is preserved. + { + const _movedOldSkillsDir = _resolveMovedSkillsOldDir(runtime, targetDir, scope); + if (_movedOldSkillsDir) { + const migrated = cleanupMovedSkillsOldLocation(_movedOldSkillsDir, 'gsd-'); + if (migrated > 0) { + removedCount++; + console.log(` ${green}✓${reset} Removed ${migrated} legacy skill dir(s) from ${_movedOldSkillsDir}`); + } + } + } + // 1a. Non-layout Codex side-effects: agent .toml files, config.toml sections, hooks.json - if (isCodex) { + if (_hostBehaviors(runtime).tomlConfigInstall) { const codexAgentsDir = path.join(targetDir, 'agents'); if (fs.existsSync(codexAgentsDir)) { const tomlFiles = fs.readdirSync(codexAgentsDir); @@ -6605,8 +6873,10 @@ function uninstall(isGlobal, runtime = DEFAULT_RUNTIME) { console.log(` ${green}✓${reset} Removed managed Codex SessionStart hook from hooks.json`); } - // #772: remove new Codex hook event registrations added by this enhancement. - for (const eventName of ['SubagentStart', 'Stop', 'PostToolUse']) { + // #772/#2088: remove every managed Codex extended hook-event registration. + // Shares CODEX_EXTENDED_HOOK_EVENTS with the install loop — removal set == + // registration set, so no managed event is ever orphaned. + for (const eventName of CODEX_EXTENDED_HOOK_EVENTS) { const eventCleanup = removeCodexHooksJsonEvent(targetDir, eventName); if (eventCleanup.changed) { removedCount++; @@ -7514,11 +7784,16 @@ function writeManifest(configDir, runtime = DEFAULT_RUNTIME, options = {}) { // Claude local uses flatCommandsDir instead for manifest recording. const flatCommandsDir = path.join(configDir, 'commands'); const opencodeCommandDir = path.join(configDir, _hostBehaviors(runtime).flatCommandDir || 'command'); - // Hermes nests GSD skills under skills/gsd/ as a single category (#2841). + // Hermes nests GSD skills under skills/gsd/ as a single category (#2841) — + // already encoded in its layout descriptor's destSubpath ('skills/gsd'). // All other runtimes that use the Codex-style skills layout use a flat skills/ root. - const codexSkillsDir = isHermes - ? path.join(configDir, 'skills', 'gsd') - : path.join(configDir, 'skills'); + // ADR-1239 upgrade 3 (#2088): honor a skills-kind `home` override (e.g. Codex + // skills -> $HOME/.agents/skills instead of configDir/skills) via the same + // descriptor-driven helper used by the snapshot/rollback/verification paths, + // so the manifest records what's actually on disk. _resolveSkillsRootDir already + // resolves destSubpath (which includes hermes's 'skills/gsd' nesting) — do not + // re-append 'gsd' or the hermes dir gets double-nested to skills/gsd/gsd. + const codexSkillsDir = _resolveSkillsRootDir(runtime, configDir, options.scope === 'local' ? 'local' : 'global'); const codexSkillsManifestPrefix = isHermes ? 'skills/gsd/' : 'skills/'; const agentsDir = path.join(configDir, 'agents'); const manifest = { @@ -7970,9 +8245,7 @@ function reportLocalPatches(configDir, runtime = DEFAULT_RUNTIME) { if (meta.files && meta.files.length > 0) { const reapplyCommand = _hostBehaviors(runtime).reapplyCommand ? _hostBehaviors(runtime).reapplyCommand - : runtime === 'codex' - ? '$gsd-update --reapply' - : runtime === 'cursor' + : runtime === 'cursor' ? 'gsd-update --reapply (mention the skill name)' : runtime === 'kimi' ? '/skill:gsd-update --reapply' @@ -8209,8 +8482,8 @@ function install(isGlobal, runtime = DEFAULT_RUNTIME, options = {}) { // Map — content snapshot of each pre-existing gsd-* agent file. const codexPreInstallAgentContents = new Map(); let codexPreInstallVersionBytes = null; - if (isCodex && !isMinimalMode(_effectiveInstallMode)) { - const _preSkillsDir = path.join(targetDir, 'skills'); + if (_hostBehaviors(runtime).tomlConfigInstall && !isMinimalMode(_effectiveInstallMode)) { + const _preSkillsDir = _resolveSkillsRootDir(runtime, targetDir, isGlobal ? 'global' : 'local'); if (fs.existsSync(_preSkillsDir)) { for (const entry of fs.readdirSync(_preSkillsDir, { withFileTypes: true })) { if (entry.isDirectory() && entry.name.startsWith('gsd-')) { @@ -8263,10 +8536,10 @@ function install(isGlobal, runtime = DEFAULT_RUNTIME, options = {}) { // atomic-write temp files. It is safe to call before any writes have happened. // The full restoreCodexSnapshot() (defined inside the config block) additionally // handles config.toml, which is not yet touched at this point in the pipeline. - const _codexPreConfigRollback = !isCodex || isMinimalMode(_effectiveInstallMode) ? null : () => { + const _codexPreConfigRollback = !_hostBehaviors(runtime).tomlConfigInstall || isMinimalMode(_effectiveInstallMode) ? null : () => { rollbackInstallerMigrations(); // skills/gsd-* — pass 1: restore snapshot entries (may be absent if deleted mid-install). - const _earlySkillsDir = path.join(targetDir, 'skills'); + const _earlySkillsDir = _resolveSkillsRootDir(runtime, targetDir, isGlobal ? 'global' : 'local'); for (const skillName of codexPreInstallSkillNames) { const skillDirPath = path.join(_earlySkillsDir, skillName); const fileMap = codexPreInstallSkillContents.get(skillName); @@ -8459,6 +8732,13 @@ function install(isGlobal, runtime = DEFAULT_RUNTIME, options = {}) { if (_isSkillsRuntime) { // Layout-driven install for skills-based runtimes (full and minimal modes) const scope = isGlobal ? 'global' : 'local'; + // ADR-1239 upgrade 3 / #2088: a kind may declare an alternate install `home` + // (e.g. Codex skills -> $HOME/.agents/skills) instead of the runtime's normal + // configDir. Resolve the ACTUAL on-disk skills root here, descriptor-driven + // (no isCodex check), so downstream sidecar-cleanup and post-install + // verification look in the right place regardless of which runtime declares + // an alternate home for its skills kind. + const _skillsRootDir = _resolveSkillsRootDir(runtime, targetDir, scope); // 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 @@ -8481,8 +8761,23 @@ function install(isGlobal, runtime = DEFAULT_RUNTIME, options = {}) { // index BOTH SKILL.md and the sidecar, causing each GSD skill to appear twice // in autocomplete. Cleaning them up fixes the duplication; SKILL.md alone is // sufficient for Codex discovery. User-owned dirs are never touched. - if (isCodex) { - cleanupCodexSkillMetadataSidecars(path.join(targetDir, 'skills')); + if (_hostBehaviors(runtime).cleanupSkillSidecars) { + cleanupCodexSkillMetadataSidecars(_skillsRootDir); + } + + // ADR-1239 split-home migration: when a runtime's skills kind moved to an + // alternate `home` (e.g. Codex → ~/.agents/skills), pre-move installs left + // managed gsd-* skill dirs at the old configDir-rooted location + // (~/.codex/skills). Reinstalling here writes the new location but would + // otherwise orphan the old one — clean up the stale gsd-* dirs. + { + const _movedOldSkillsDir = _resolveMovedSkillsOldDir(runtime, targetDir, scope); + if (_movedOldSkillsDir) { + const migrated = cleanupMovedSkillsOldLocation(_movedOldSkillsDir, 'gsd-'); + if (migrated > 0) { + console.log(` ${green}✓${reset} Migrated ${migrated} skill dir(s) off the legacy ${_movedOldSkillsDir} location`); + } + } } // #1629 Finding B: Windsurf local only — remove legacy .devin/skills/gsd-* @@ -8554,7 +8849,7 @@ function install(isGlobal, runtime = DEFAULT_RUNTIME, options = {}) { } } } else { - const skillsDir = path.join(targetDir, 'skills'); + const skillsDir = _skillsRootDir; if (fs.existsSync(skillsDir)) { const count = fs.readdirSync(skillsDir, { withFileTypes: true }) .filter(e => e.isDirectory() && e.name.startsWith('gsd-')).length; @@ -8809,7 +9104,7 @@ function install(isGlobal, runtime = DEFAULT_RUNTIME, options = {}) { for (const file of fs.readdirSync(agentsDest)) { if ( file.startsWith('gsd-') && - (file.endsWith('.md') || (isCodex && file.endsWith('.toml'))) + (file.endsWith('.md') || (_hostBehaviors(runtime).agentTomlFiles && file.endsWith('.toml'))) ) { fs.unlinkSync(path.join(agentsDest, file)); } @@ -8827,7 +9122,7 @@ function install(isGlobal, runtime = DEFAULT_RUNTIME, options = {}) { // Without stripping them here, a full → minimal reinstall would leave the // runtime advertising the old full agent surface even though the agent // files are gone. Reuse the same helper that powers `--uninstall`. - if (isCodex) { + if (_hostBehaviors(runtime).tomlConfigInstall) { const codexConfigPath = path.join(targetDir, 'config.toml'); if (fs.existsSync(codexConfigPath)) { const existing = fs.readFileSync(codexConfigPath, 'utf8'); @@ -8883,7 +9178,7 @@ function install(isGlobal, runtime = DEFAULT_RUNTIME, options = {}) { content = convertClaudeToOpencodeFrontmatter(content, { isAgent: true, modelOverride: _ocModelOverride }); } else if (_hostBehaviors(runtime).frontmatterDialect === 'kilo') { content = convertClaudeToKiloFrontmatter(content, { isAgent: true }); - } else if (isCodex) { + } else if (_hostBehaviors(runtime).frontmatterDialect === 'codex') { content = convertClaudeAgentToCodexAgent(content); } else if (isCopilot) { content = convertClaudeAgentToCopilotAgent(content, isGlobal); @@ -9197,7 +9492,7 @@ function install(isGlobal, runtime = DEFAULT_RUNTIME, options = {}) { } // Write file manifest for future modification detection - writeManifest(targetDir, runtime, { mode: _effectiveInstallMode }); + writeManifest(targetDir, runtime, { mode: _effectiveInstallMode, scope: isGlobal ? 'global' : 'local' }); console.log(` ${green}✓${reset} Wrote file manifest (${MANIFEST_NAME})`); // Report any backed-up local patches @@ -9340,7 +9635,7 @@ function install(isGlobal, runtime = DEFAULT_RUNTIME, options = {}) { // (copyCommandsAsCodexSkills removes pre-existing gsd-* dirs before re-writing) // are restored even when they are absent from disk at rollback time (#3245 CR). // • Dirs that did not pre-exist: remove entirely. - const _rollbackSkillsDir = path.join(targetDir, 'skills'); + const _rollbackSkillsDir = _resolveSkillsRootDir(runtime, targetDir, isGlobal ? 'global' : 'local'); // Pass 1 — restore snapshot entries (may be absent from disk if deleted mid-install). for (const skillName of codexPreInstallSkillNames) { const skillDirPath = path.join(_rollbackSkillsDir, skillName); @@ -9451,7 +9746,7 @@ function install(isGlobal, runtime = DEFAULT_RUNTIME, options = {}) { // Re-write the manifest now that .toml agent files exist on disk. // The initial writeManifest call (before Codex config generation) could // not include agents/gsd-*.toml because those files did not yet exist. - writeManifest(targetDir, runtime, { mode: _effectiveInstallMode }); + writeManifest(targetDir, runtime, { mode: _effectiveInstallMode, scope: isGlobal ? 'global' : 'local' }); } else { console.log(` ${dim}↳${reset} Skipping Codex agent config generation (minimal install)`); } @@ -9591,24 +9886,23 @@ function install(isGlobal, runtime = DEFAULT_RUNTIME, options = {}) { } } - // ── Codex extended hook events (#772) ──────────────────────────────── - // Codex CLI stabilised a full hook-event set in rust-v0.137.0. Register - // three new high-value lifecycle events — all routed through - // gsd-context-monitor.js so context-headroom warnings surface at: - // SubagentStart — subagent session open (environment / agent-name aware) - // Stop — model stop / session final-response moment - // PostToolUse — after each tool invocation (mirrors Claude baseline) - // - // Note: UserPromptSubmit is NOT wired — gsd-prompt-guard exits unless - // tool_name is Write|Edit (PreToolUse payload shape), so it would be a - // silent no-op for the UserPromptSubmit payload. Registration deferred - // to a follow-on issue. + // ── Codex extended hook events (#772, #2088) ───────────────────────── + // Codex CLI stabilised a full hook-event set in rust-v0.137.0. GSD + // registers CODEX_EXTENDED_HOOK_EVENTS (#2088 adds the 6 documented + // events beyond the original #772 three) — all routed through + // gsd-context-monitor.js so context-headroom warnings surface at each + // lifecycle point: SubagentStart/SubagentStop (subagent open/close), + // Stop (final-response), PreToolUse/PostToolUse (tool boundaries), + // PermissionRequest (approval prompts), Pre/PostCompact (context + // compaction), and UserPromptSubmit (per-turn context injection). The + // context-monitor script decides per-payload what to do; unregistered + // events simply never fire. // // Guard: only register when the context-monitor file exists and the node // runner is available — same guards as the SessionStart path above. const contextMonitorFile = path.join(targetDir, 'hooks', 'gsd-context-monitor.js'); if (codexNodeRunner && fs.existsSync(contextMonitorFile)) { - for (const codexEvent of ['SubagentStart', 'Stop', 'PostToolUse']) { + for (const codexEvent of CODEX_EXTENDED_HOOK_EVENTS) { const eventWrite = ensureCodexHooksJsonEvent(targetDir, codexEvent, { absoluteRunner: codexNodeRunner, platform: process.platform, @@ -9620,7 +9914,7 @@ function install(isGlobal, runtime = DEFAULT_RUNTIME, options = {}) { } } } else if (!codexNodeRunner) { - console.warn(` ${yellow}⚠${reset} Skipped Codex SubagentStart/Stop/PostToolUse hook registration — Node runner unavailable.`); + console.warn(` ${yellow}⚠${reset} Skipped Codex extended hook-event registration — Node runner unavailable.`); } // ── end Codex extended hook events ──────────────────────────────────── } @@ -9694,7 +9988,7 @@ function install(isGlobal, runtime = DEFAULT_RUNTIME, options = {}) { console.log(` ${green}✓${reset} Cursor lifecycle hooks already up to date`); } // Re-run the manifest pass so the hook scripts + hooks.json are hash-tracked. - writeManifest(targetDir, runtime, { mode: _effectiveInstallMode }); + writeManifest(targetDir, runtime, { mode: _effectiveInstallMode, scope: isGlobal ? 'global' : 'local' }); persistActiveProfileMarker(); return { settingsPath: null, settings: null, statuslineCommand: null, updateBannerCommand: null, runtime, configDir: targetDir }; } @@ -9712,7 +10006,7 @@ function install(isGlobal, runtime = DEFAULT_RUNTIME, options = {}) { writeClineArtifacts(targetDir, isGlobal); // Re-run the manifest pass: these artifacts are written *after* the earlier // writeManifest() call, so a second pass is needed to hash-track them. - writeManifest(targetDir, runtime, { mode: _effectiveInstallMode }); + writeManifest(targetDir, runtime, { mode: _effectiveInstallMode, scope: isGlobal ? 'global' : 'local' }); persistActiveProfileMarker(); return { settingsPath: null, settings: null, statuslineCommand: null, updateBannerCommand: null, runtime, configDir: targetDir }; } @@ -11009,6 +11303,13 @@ module.exports = { generateCodexAgentToml, cleanupCodexSkillMetadataSidecars, cleanupWindsurfLegacyDevinSkills, + cleanupMovedSkillsOldLocation, + _resolveMovedSkillsOldDir, + _resolveSkillsRootDir, + codexBareAgentsHasOnlyKnownScalars, + extractCodexUserAgentsScalars, + spliceCodexAgentsScalars, + CODEX_EXTENDED_HOOK_EVENTS, generateCodexConfigBlock, stripGsdFromCodexConfig, migrateCodexHooksMapFormat, diff --git a/capabilities/codex/capability.json b/capabilities/codex/capability.json index db6e68df3..c067d9564 100644 --- a/capabilities/codex/capability.json +++ b/capabilities/codex/capability.json @@ -27,7 +27,8 @@ "prefix": "gsd-", "nesting": "flat", "recursive": false, - "converter": "convertClaudeCommandToCodexSkill" + "converter": "convertClaudeCommandToCodexSkill", + "home": ".agents" } ], "local": [ @@ -37,7 +38,8 @@ "prefix": "gsd-", "nesting": "flat", "recursive": false, - "converter": "convertClaudeCommandToCodexSkill" + "converter": "convertClaudeCommandToCodexSkill", + "home": ".agents" } ] }, @@ -49,7 +51,11 @@ "installSurface": "codex-toml", "writesSharedSettings": false, "permissionWriter": null, - "extendedHookEvents": [], + "extendedHookEvents": [ + "SubagentStop", + "Stop", + "PreCompact" + ], "hostIntegration": { "embeddingMode": "declarative", "commandSurface": "slash-file", @@ -66,6 +72,13 @@ "stateIO": "filesystem", "transport": "mcp", "runtime": "node" + }, + "hostBehaviors": { + "reapplyCommand": "$gsd-update --reapply", + "tomlConfigInstall": true, + "cleanupSkillSidecars": true, + "agentTomlFiles": true, + "frontmatterDialect": "codex" } } } diff --git a/docs/reference/host-integration-capability-matrix.md b/docs/reference/host-integration-capability-matrix.md index f9fe517e4..45e94be43 100644 --- a/docs/reference/host-integration-capability-matrix.md +++ b/docs/reference/host-integration-capability-matrix.md @@ -101,6 +101,12 @@ Sources consulted: | dispatch.subagentToolkit | full | https://developers.openai.com/codex/multi-agent | "Subagents inherit the sandbox policy and tool surface from the parent session." | | dispatch.backgroundDispatch | true | https://github.com/openai/codex/blob/main/codex-rs/core/templates/collab/experimental_prompt.md | "Sub-agents have access to the same set of tools as you do so you must tell them if they are allowed to spawn sub-agents themselves or not." The config (codex-rs/config/src/config_toml.rs) exposes an | +**GSD integration status — Phase D dogfood complete (#2088, ADR-1239).** Codex installs through the `declarative` embedding adapter (`createDeclarativeAdapter` → `installRuntimeArtifacts`); the hardcoded `runtime === 'codex'`/`isCodex` projection is folded into descriptor-driven `runtime.hostBehaviors`, and install/uninstall output is byte-parity-gated (`tests/fixtures/golden-install-parity/codex.json`). Three capability upgrades land, each with a test driving the user-reachable surface: + +- **Skill root** — skills install to the canonical `$HOME/.agents/skills` (Codex core-skills `loader.rs` user-scope root), not the deprecated `$CODEX_HOME/skills` fallback. Declared via the skills-kind `home: ".agents"` override; pre-move installs are migrated (stale `~/.codex/skills/gsd-*` cleaned on both install and uninstall). +- **Hook events** — GSD registers all documented `hooks.json` lifecycle events beyond `SessionStart`: `SubagentStart`, `Stop`, `PostToolUse` (#772), plus the six added in #2088 — `PreToolUse`, `PermissionRequest`, `PreCompact`, `PostCompact`, `SubagentStop`, `UserPromptSubmit` — all routed through `gsd-context-monitor.js`. (The descriptor `extendedHookEvents` field reflects the schema-valid cross-runtime subset `SubagentStop`/`Stop`/`PreCompact`; Codex's full event set is codex-hooks-json-native, registered directly in `hooks.json`.) +- **Dispatch tuning** — `[agents] max_depth = 1` is written explicitly into the managed `config.toml` block, pinning the `dispatch.maxDepth: 1` axis instead of relying on codex-cli's implicit default. Because `maxDepth === 1`, `degradationFor` flattens GSD-hosted wave dispatch to single-level even though `dispatch.nested`/`background`/`backgroundDispatch` are all `true`. The block is a bare `[agents]` AgentsToml scalar table (coexisting with the flattened `[agents.gsd-*]` role sub-tables); `validateCodexConfigSchema` permits a known-scalar-only `[agents]` while still rejecting `[[agents]]` and unknown-key forms. + Sources consulted: - https://github.com/openai/codex (repo via gh CLI) - /openai/codex (Context7 library ID) diff --git a/eslint-rules/lib/portability-vocab.cjs b/eslint-rules/lib/portability-vocab.cjs index 017068e37..44e44ab98 100644 --- a/eslint-rules/lib/portability-vocab.cjs +++ b/eslint-rules/lib/portability-vocab.cjs @@ -49,6 +49,9 @@ const PATH_RETURNING_FNS = [ 'resolveAgentDir', 'getGlobalConfigDir', 'getGlobalSkillsBase', + // #2088 (ADR-1239 upgrade 3): resolves the on-disk skills-install dir honoring + // a skills-kind `home` override (e.g. Codex → $HOME/.agents/skills). + '_resolveSkillsRootDir', 'getGlobalSkillDir', 'getGlobalSkillDisplayPath', 'resolveSkillsBaseFromDescriptor', diff --git a/gsd-core/bin/lib/capability-registry.cjs b/gsd-core/bin/lib/capability-registry.cjs index 1524c0e31..dea955a3b 100644 --- a/gsd-core/bin/lib/capability-registry.cjs +++ b/gsd-core/bin/lib/capability-registry.cjs @@ -814,7 +814,8 @@ const capabilities = { "prefix": "gsd-", "nesting": "flat", "recursive": false, - "converter": "convertClaudeCommandToCodexSkill" + "converter": "convertClaudeCommandToCodexSkill", + "home": ".agents" } ], "local": [ @@ -824,7 +825,8 @@ const capabilities = { "prefix": "gsd-", "nesting": "flat", "recursive": false, - "converter": "convertClaudeCommandToCodexSkill" + "converter": "convertClaudeCommandToCodexSkill", + "home": ".agents" } ] }, @@ -836,7 +838,11 @@ const capabilities = { "installSurface": "codex-toml", "writesSharedSettings": false, "permissionWriter": null, - "extendedHookEvents": [], + "extendedHookEvents": [ + "SubagentStop", + "Stop", + "PreCompact" + ], "hostIntegration": { "embeddingMode": "declarative", "commandSurface": "slash-file", @@ -853,6 +859,13 @@ const capabilities = { "stateIO": "filesystem", "transport": "mcp", "runtime": "node" + }, + "hostBehaviors": { + "reapplyCommand": "$gsd-update --reapply", + "tomlConfigInstall": true, + "cleanupSkillSidecars": true, + "agentTomlFiles": true, + "frontmatterDialect": "codex" } } }, @@ -4134,7 +4147,8 @@ const runtimes = { "prefix": "gsd-", "nesting": "flat", "recursive": false, - "converter": "convertClaudeCommandToCodexSkill" + "converter": "convertClaudeCommandToCodexSkill", + "home": ".agents" } ], "local": [ @@ -4144,7 +4158,8 @@ const runtimes = { "prefix": "gsd-", "nesting": "flat", "recursive": false, - "converter": "convertClaudeCommandToCodexSkill" + "converter": "convertClaudeCommandToCodexSkill", + "home": ".agents" } ] }, @@ -4156,7 +4171,11 @@ const runtimes = { "installSurface": "codex-toml", "writesSharedSettings": false, "permissionWriter": null, - "extendedHookEvents": [], + "extendedHookEvents": [ + "SubagentStop", + "Stop", + "PreCompact" + ], "hostIntegration": { "embeddingMode": "declarative", "commandSurface": "slash-file", @@ -4173,6 +4192,13 @@ const runtimes = { "stateIO": "filesystem", "transport": "mcp", "runtime": "node" + }, + "hostBehaviors": { + "reapplyCommand": "$gsd-update --reapply", + "tomlConfigInstall": true, + "cleanupSkillSidecars": true, + "agentTomlFiles": true, + "frontmatterDialect": "codex" } } }, diff --git a/gsd-core/bin/lib/runtime-artifact-install-plan.cjs b/gsd-core/bin/lib/runtime-artifact-install-plan.cjs index e05aad2ec..1f203c95f 100644 --- a/gsd-core/bin/lib/runtime-artifact-install-plan.cjs +++ b/gsd-core/bin/lib/runtime-artifact-install-plan.cjs @@ -111,7 +111,7 @@ function createRuntimeArtifactInstallPlan(args) { items.push({ kind: kind.kind, sourceDir, - destDir: assertDestWithinConfigHome(layout.configDir, kind.destSubpath), + destDir: assertDestWithinConfigHome(kind.home ?? layout.configDir, kind.destSubpath), }); } return { ok: true, plan: { items, cleanupDirs } }; @@ -120,7 +120,7 @@ function createRuntimeArtifactUninstallPlan(layout) { return { items: layout.kinds.map((kind) => ({ kind: kind.kind, - destDir: assertDestWithinConfigHome(layout.configDir, kind.destSubpath), + destDir: assertDestWithinConfigHome(kind.home ?? layout.configDir, kind.destSubpath), })), }; } diff --git a/src/init.cts b/src/init.cts index 196b266e2..ee6e7ffa2 100644 --- a/src/init.cts +++ b/src/init.cts @@ -37,7 +37,7 @@ import { formatGsdSlash, resolveRuntime } from './runtime-slash.cjs'; // eslint-disable-next-line @typescript-eslint/no-require-imports -- commands.cjs is an export= CommonJS module import commandsMod = require('./commands.cjs'); import { validatePath, loadTrustedGlobalRoots } from './security.cjs'; -import { getGlobalSkillDir, getGlobalSkillDisplayPath, getGlobalSkillsBase } from './runtime-homes.cjs'; +import { getGlobalSkillDir, getGlobalSkillDisplayPath, getGlobalSkillsBase, getGlobalConfigDir } from './runtime-homes.cjs'; // eslint-disable-next-line @typescript-eslint/no-require-imports -- frontmatter.cjs is an export= CommonJS module import frontmatterMod = require('./frontmatter.cjs'); // eslint-disable-next-line @typescript-eslint/no-require-imports -- verification.cjs is an export= CommonJS module @@ -2372,11 +2372,24 @@ function buildSkillManifest(cwd: string, skillsDir: string | null = null): Skill kind: 'skills', }, { - root: '~/.codex/skills', + // ADR-1239 upgrade 3 (#2088): Codex's canonical skill root is + // $HOME/.agents/skills (per codex core-skills loader.rs), resolved via + // the skills-kind `home` override in getGlobalSkillsBase. + root: '~/.agents/skills', path: getGlobalSkillsBase('codex') as string, scope: 'global', kind: 'skills', }, + { + // Codex's deprecated fallback skill root ($CODEX_HOME/skills). Kept as a + // discovery-only legacy root so pre-move installs remain inventoried; + // GSD no longer installs here (#2088). + root: '~/.codex/skills', + path: path.join(getGlobalConfigDir('codex'), 'skills'), + scope: 'global', + kind: 'skills', + deprecated: true, + }, { root: '.claude/gsd-core/skills', path: path.join(os.homedir(), '.claude', 'gsd-core', 'skills'), diff --git a/src/install-engine.cts b/src/install-engine.cts index eda2107de..dcfbb6e18 100644 --- a/src/install-engine.cts +++ b/src/install-engine.cts @@ -276,15 +276,21 @@ function _copyStaged(stagedDir: string, destDir: string, kind: any, configDir: s '_copyStaged: configDir (install root) is required to confine writes — refusing to write', ); } + // The install root is normally configDir, but a kind may declare an alternate + // `home` (ADR-1239 upgrade 3 / #2088, e.g. Codex skills -> $HOME/.agents) — in + // that case this defense-in-depth check must confine against the resolved + // alternate root instead, matching the upstream gate's own root selection in + // createRuntimeArtifactInstallPlan. + const installRoot = (kind && typeof kind.home === 'string' && kind.home !== '') ? kind.home : configDir; // Strict-subpath + NUL containment via the canonical gate (shared with the // layout-driven install plan); throws if destDir escapes the install root. - // destDir here is an absolute path; path.resolve(configDir, absoluteDest) returns it unchanged, so the gate's strict-subpath check still correctly confines it to configDir. - const resolvedDest = runtimeArtifactInstallPlan.assertDestWithinConfigHome(configDir, destDir); - // Symlink-escape guard: reject if any path component between configDir and - // destDir is a symlink that would redirect writes outside configDir. - if (hasExistingSymlinkBetween(path.resolve(configDir), resolvedDest)) { + // destDir here is an absolute path; path.resolve(installRoot, absoluteDest) returns it unchanged, so the gate's strict-subpath check still correctly confines it to installRoot. + const resolvedDest = runtimeArtifactInstallPlan.assertDestWithinConfigHome(installRoot, destDir); + // Symlink-escape guard: reject if any path component between the install root and + // destDir is a symlink that would redirect writes outside the install root. + if (hasExistingSymlinkBetween(path.resolve(installRoot), resolvedDest)) { throw new Error( - `_copyStaged: destDir "${destDir}" contains a symlink escaping the install root "${configDir}" — refusing to write`, + `_copyStaged: destDir "${destDir}" contains a symlink escaping the install root "${installRoot}" — refusing to write`, ); } // Use the validated absolute path for the actual writes below. @@ -620,11 +626,17 @@ function installRuntimeArtifacts( if (!kind) throw new Error(`Install plan returned unknown artifact kind: ${item.kind}`); const dest = item.destDir; // Symlink-escape guard: reject before mkdir if dest (or any component - // between configDir and dest) is a symlink pointing outside configDir. - // mkdirSync follows symlinks, so this must run BEFORE the mkdir call. - if (hasExistingSymlinkBetween(path.resolve(configDir), dest)) { + // between the install root and dest) is a symlink pointing outside that + // root. mkdirSync follows symlinks, so this must run BEFORE the mkdir + // call. The install root is normally configDir, but a kind may declare + // an alternate `home` (ADR-1239 upgrade 3 / #2088, e.g. Codex skills -> + // $HOME/.agents) — in that case the guard must check against the + // resolved alternate root instead, matching assertDestWithinConfigHome's + // own root selection in createRuntimeArtifactInstallPlan. + const installRoot = (kind && typeof kind.home === 'string' && kind.home !== '') ? kind.home : configDir; + if (hasExistingSymlinkBetween(path.resolve(installRoot), dest)) { throw new Error( - `installRuntimeArtifacts: destDir "${dest}" contains a symlink escaping the install root "${configDir}" — refusing to create`, + `installRuntimeArtifacts: destDir "${dest}" contains a symlink escaping the install root "${installRoot}" — refusing to create`, ); } fs.mkdirSync(dest, { recursive: true }); diff --git a/src/runtime-artifact-install-plan.cts b/src/runtime-artifact-install-plan.cts index aea0a0218..e2daf8d3e 100644 --- a/src/runtime-artifact-install-plan.cts +++ b/src/runtime-artifact-install-plan.cts @@ -32,6 +32,10 @@ interface ArtifactKind { destSubpath: string; prefix?: string; stage: (resolvedProfile: ResolvedProfile, agentCtx?: AgentCtx) => string; + /** Resolved absolute alternate install root for this kind, if the descriptor + * specifies one (e.g. codex skills → $HOME/.agents). Undefined means the + * kind installs under the runtime's normal configDir. */ + home?: string; } interface Layout { @@ -216,7 +220,7 @@ function createRuntimeArtifactInstallPlan(args: CreateRuntimeArtifactInstallPlan items.push({ kind: kind.kind, sourceDir, - destDir: assertDestWithinConfigHome(layout.configDir, kind.destSubpath), + destDir: assertDestWithinConfigHome(kind.home ?? layout.configDir, kind.destSubpath), }); } @@ -227,7 +231,7 @@ function createRuntimeArtifactUninstallPlan(layout: Layout): UninstallPlan { return { items: layout.kinds.map((kind) => ({ kind: kind.kind, - destDir: assertDestWithinConfigHome(layout.configDir, kind.destSubpath), + destDir: assertDestWithinConfigHome(kind.home ?? layout.configDir, kind.destSubpath), })), }; } diff --git a/src/runtime-artifact-layout.cts b/src/runtime-artifact-layout.cts index 073d670b8..4d8f34c57 100644 --- a/src/runtime-artifact-layout.cts +++ b/src/runtime-artifact-layout.cts @@ -73,6 +73,10 @@ interface ArtifactKind { /** For agents kind with a converter, accepts an optional AgentCtx as the second * arg so cross-cutting can be applied pre-converter (ADR-1235 §1). */ stage: (resolvedProfile: ResolvedProfile, agentCtx?: AgentCtx) => string; + /** Resolved absolute alternate install root for this kind, if the descriptor + * specifies one (e.g. codex skills → $HOME/.agents). Undefined means the + * kind installs under the runtime's normal configDir. */ + home?: string; } interface Layout { @@ -419,6 +423,10 @@ interface ArtifactKindDescriptor { nesting: 'flat' | 'nested'; recursive: boolean; converter: string | null; + /** Optional alternate install home, relative to the user's home directory + * (e.g. ".agents" for codex skills → $HOME/.agents/skills). When absent, + * the kind installs under the runtime's normal configDir. */ + home?: string; } interface ArtifactLayoutDescriptor { @@ -445,18 +453,19 @@ function dispatchKindEntry(entry: ArtifactKindDescriptor, runtime: string, confi const { kind, destSubpath, prefix, nesting, converter } = entry; const nested = nesting === 'nested'; + let result: ArtifactKind; switch (kind) { case 'commands': - if (converter == null) { - return commandsKind(destSubpath, prefix, configDir); - } - return convertedCommandsKind(destSubpath, prefix, converter, configDir); + result = converter == null + ? commandsKind(destSubpath, prefix, configDir) + : convertedCommandsKind(destSubpath, prefix, converter, configDir); + break; case 'agents': - if (converter == null) { - return agentsKind(destSubpath, prefix, configDir); - } - return convertedAgentsKind(destSubpath, prefix, converter, configDir, scope); + result = converter == null + ? agentsKind(destSubpath, prefix, configDir) + : convertedAgentsKind(destSubpath, prefix, converter, configDir, scope); + break; case 'skills': if (converter == null) { @@ -464,16 +473,24 @@ function dispatchKindEntry(entry: ArtifactKindDescriptor, runtime: string, confi `resolveRuntimeArtifactLayout: skills entry for '${runtime}' has converter=null (converter is required for skills)`, ); } - return skillsKind(destSubpath, prefix, converter, runtime, configDir, nested, scope); + result = skillsKind(destSubpath, prefix, converter, runtime, configDir, nested, scope); + break; case 'kimi-agents': - return kimiAgentsKind(destSubpath, prefix, configDir); + result = kimiAgentsKind(destSubpath, prefix, configDir); + break; default: throw new TypeError( `resolveRuntimeArtifactLayout: unknown kind '${kind}' in descriptor for runtime '${runtime}'`, ); } + + if (typeof entry.home === 'string' && entry.home !== '') { + result.home = path.join(os.homedir(), entry.home); + } + + return result; } /** diff --git a/src/runtime-homes.cts b/src/runtime-homes.cts index 0b5e70e18..b935e0511 100644 --- a/src/runtime-homes.cts +++ b/src/runtime-homes.cts @@ -118,6 +118,9 @@ type ConfigHomeDescriptor = interface RuntimeArtifactKindDescriptor { kind: string; destSubpath: string; + // ADR-1239 upgrade 3 (#2088): optional split-home override (relative to + // os.homedir()), e.g. Codex skills → ".agents". Absent for most runtimes. + home?: string; } interface RuntimeDescriptor { @@ -416,6 +419,14 @@ export function getGlobalSkillsBase(runtime: string): string | null { const runtimeEntry = getRegistry().runtimes[runtime]; const descriptor = runtimeEntry?.runtime; const globalSkillsKind = descriptor?.artifactLayout?.global?.find((entry) => entry.kind === 'skills'); + // ADR-1239 upgrade 3 (#2088): honor a skills-kind `home` override (e.g. Codex + // → $HOME/.agents/skills, independent of $CODEX_HOME) so the reported skills + // root matches where the installer actually writes (the artifact layout / + // _resolveSkillsRootDir). Without this, `--skills-root` and the sync-skills + // workflow would look under configHome/skills while skills live under ~/.agents. + if (globalSkillsKind?.home && globalSkillsKind?.destSubpath) { + return path.join(os.homedir(), globalSkillsKind.home, globalSkillsKind.destSubpath); + } if (descriptor?.configHome && globalSkillsKind?.destSubpath) { return resolveSkillsBaseFromDescriptor( descriptor.configHome, diff --git a/tests/codex-config.test.cjs b/tests/codex-config.test.cjs index b8f3f9c58..480d78214 100644 --- a/tests/codex-config.test.cjs +++ b/tests/codex-config.test.cjs @@ -60,21 +60,34 @@ const { resolveInstallPlan } = require('../gsd-core/bin/lib/runtime-config-adapt function runCodexInstall(codexHome, cwd = path.join(__dirname, '..')) { const previousCodeHome = process.env.CODEX_HOME; + const previousHome = process.env.HOME; + const previousUserProfile = process.env.USERPROFILE; const previousCwd = process.cwd(); process.env.CODEX_HOME = codexHome; + // #2088: Codex skills now install to the canonical $HOME/.agents/skills root + // (os.homedir()-relative, independent of CODEX_HOME — per codex core-skills + // loader.rs). Sandbox HOME to codexHome so skills land under the temp dir + // (codexHome/.agents/skills) instead of polluting the developer's real home. + process.env.HOME = codexHome; + process.env.USERPROFILE = codexHome; try { process.chdir(cwd); return install(true, 'codex'); } finally { process.chdir(previousCwd); - if (previousCodeHome === undefined) { - delete process.env.CODEX_HOME; - } else { - process.env.CODEX_HOME = previousCodeHome; - } + if (previousCodeHome === undefined) delete process.env.CODEX_HOME; + else process.env.CODEX_HOME = previousCodeHome; + if (previousHome === undefined) delete process.env.HOME; + else process.env.HOME = previousHome; + if (previousUserProfile === undefined) delete process.env.USERPROFILE; + else process.env.USERPROFILE = previousUserProfile; } } +// #2088: the canonical Codex skill-install root, sandboxed under codexHome. +function codexSkillsRoot(codexHome) { + return path.join(codexHome, '.agents', 'skills'); +} function readCodexConfig(codexHome) { return fs.readFileSync(path.join(codexHome, 'config.toml'), 'utf8'); @@ -722,17 +735,19 @@ describe('generateCodexConfigBlock', () => { assert.ok(result.startsWith(GSD_CODEX_MARKER), 'starts with marker'); }); - test('does not include feature flags or agents table header', () => { + test('emits the [agents] max_depth tuning block but no feature flags (#2088)', () => { const result = generateCodexConfigBlock(agents); assert.ok(!result.includes('[features]'), 'no features table'); assert.ok(!result.includes('multi_agent'), 'no multi_agent'); assert.ok(!result.includes('default_mode_request_user_input'), 'no request_user_input'); - // Should not have bare [agents] table header (only [agents.] structs). - assert.ok(!result.match(/^\[agents\]\s*$/m), 'no bare [agents] table'); + // #2088: the managed block DOES pin dispatch depth via a bare [agents] + // AgentsToml scalar table (coexisting with the [agents.] role structs). + assert.match(result, /^\[agents\]$/m, 'emits the [agents] tuning table'); + assert.match(result, /^max_depth = 1$/m, 'pins max_depth = 1'); // Should not emit [[agents]] sequence format (rejected by Codex 0.124.0). assert.ok(!result.includes('[[agents]]'), 'no [[agents]] sequence format'); - assert.ok(!result.includes('max_threads'), 'no max_threads'); - assert.ok(!result.includes('max_depth'), 'no max_depth'); + // Only max_depth is managed — max_threads is intentionally left to the user. + assert.ok(!result.includes('max_threads'), 'no max_threads (only max_depth is GSD-managed)'); }); test('#2727: emits [agents.] struct format (Codex 0.120.0+, replaces #2645 [[agents]])', () => { @@ -2721,7 +2736,7 @@ describe('cleanupCodexSkillMetadataSidecars (#1326)', () => { const codexHome = path.join(tmpDir, 'codex-home'); fs.mkdirSync(codexHome, { recursive: true }); runCodexInstall(codexHome); - const skillsDir = path.join(codexHome, 'skills'); + const skillsDir = codexSkillsRoot(codexHome); assert.ok(fs.existsSync(skillsDir), 'Codex install must create a skills/ directory'); const gsdSkillDirs = fs.readdirSync(skillsDir, { withFileTypes: true }) .filter(e => e.isDirectory() && e.name.startsWith('gsd-') && e.name !== 'gsd-dev-preferences'); @@ -2798,12 +2813,28 @@ before(() => { describe('#2698: CRLF stale gsd-update-check block is removed on Codex reinstall', () => { let tmpDir; + let _previousHome; + let _previousUserProfile; beforeEach(() => { tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-crlf-install-2698-')); + // #2088 (ADR-1239 upgrade 3): Codex's skills-kind `home: ".agents"` override + // applies to BOTH global and local scope and resolves via os.homedir(). This + // describe block calls install(false, 'codex') (local scope) directly — + // without sandboxing HOME/USERPROFILE to tmpDir, that in-process install + // would materialize a full gsd-* skill set into the developer/CI machine's + // REAL $HOME/.agents/skills instead of the temp dir. + _previousHome = process.env.HOME; + _previousUserProfile = process.env.USERPROFILE; + process.env.HOME = tmpDir; + process.env.USERPROFILE = tmpDir; }); afterEach(() => { + if (_previousHome === undefined) delete process.env.HOME; + else process.env.HOME = _previousHome; + if (_previousUserProfile === undefined) delete process.env.USERPROFILE; + else process.env.USERPROFILE = _previousUserProfile; // Use the shared 5s Windows-EBUSY retry budget instead of inline 1s. cleanup(tmpDir); }); @@ -2993,7 +3024,16 @@ if (previousGsdTestMode === undefined) { function runCodexInstall(codexHome, cwd = path.join(__dirname, '..')) { const previousCodeHome = process.env.CODEX_HOME; const previousCwd = process.cwd(); + // #2088 (ADR-1239 upgrade 3): Codex skills now install to the canonical + // $HOME/.agents/skills root (os.homedir()-relative, independent of + // CODEX_HOME). Sandbox HOME (and USERPROFILE) to codexHome so this + // in-process install never materializes skills under the developer/CI + // machine's real home directory. + const previousHome = process.env.HOME; + const previousUserProfile = process.env.USERPROFILE; process.env.CODEX_HOME = codexHome; + process.env.HOME = codexHome; + process.env.USERPROFILE = codexHome; try { process.chdir(cwd); return install(true, 'codex'); @@ -3004,6 +3044,10 @@ function runCodexInstall(codexHome, cwd = path.join(__dirname, '..')) { } else { process.env.CODEX_HOME = previousCodeHome; } + if (previousHome === undefined) delete process.env.HOME; + else process.env.HOME = previousHome; + if (previousUserProfile === undefined) delete process.env.USERPROFILE; + else process.env.USERPROFILE = previousUserProfile; } } @@ -4583,7 +4627,16 @@ before(() => { function runCodexInstall(codexHome) { const previousCodexHome = process.env.CODEX_HOME; const previousCwd = process.cwd(); + // #2088 (ADR-1239 upgrade 3): Codex skills now install to the canonical + // $HOME/.agents/skills root (os.homedir()-relative, independent of + // CODEX_HOME). Sandbox HOME (and USERPROFILE) to codexHome so this + // in-process install never materializes skills under the developer/CI + // machine's real home directory. + const previousHome = process.env.HOME; + const previousUserProfile = process.env.USERPROFILE; process.env.CODEX_HOME = codexHome; + process.env.HOME = codexHome; + process.env.USERPROFILE = codexHome; try { process.chdir(path.join(__dirname, '..')); return install(true, 'codex'); @@ -4594,6 +4647,10 @@ function runCodexInstall(codexHome) { } else { process.env.CODEX_HOME = previousCodexHome; } + if (previousHome === undefined) delete process.env.HOME; + else process.env.HOME = previousHome; + if (previousUserProfile === undefined) delete process.env.USERPROFILE; + else process.env.USERPROFILE = previousUserProfile; } } @@ -4908,7 +4965,7 @@ describe('#3245 — idempotent rollback reverts skills/, agents/, and VERSION', assert.strictEqual(threw, true, 'install must throw when validation fails'); // skills/ — GSD writes gsd-* subdirs here. All must be absent after rollback. - const skillsDir = path.join(codexHome, 'skills'); + const skillsDir = codexSkillsRoot(codexHome); if (fs.existsSync(skillsDir)) { const gsdSkills = fs.readdirSync(skillsDir, { withFileTypes: true }) .filter(e => e.isDirectory() && e.name.startsWith('gsd-')); @@ -4963,7 +5020,7 @@ describe('#3245 — idempotent rollback reverts skills/, agents/, and VERSION', assert.strictEqual(threw, true, 'install must throw when validation fails (very early failure)'); // Rollback removes all gsd-* skill dirs it wrote. Even if skills/ was // created during the install, no gsd-* dirs should survive after rollback. - const skillsDir = path.join(codexHome, 'skills'); + const skillsDir = codexSkillsRoot(codexHome); const remainingGsdSkills = fs.existsSync(skillsDir) ? fs.readdirSync(skillsDir, { withFileTypes: true }) .filter((e) => e.isDirectory() && e.name.startsWith('gsd-')) @@ -4978,7 +5035,7 @@ describe('#3245 — idempotent rollback reverts skills/, agents/, and VERSION', test('rollback does not remove pre-existing user skills that GSD did not write', () => { // If the user has a custom skill dir (not gsd-*) it must survive rollback. - const skillsDir = path.join(codexHome, 'skills'); + const skillsDir = codexSkillsRoot(codexHome); const userSkill = path.join(skillsDir, 'my-custom-skill'); fs.mkdirSync(userSkill, { recursive: true }); fs.writeFileSync(path.join(userSkill, 'SKILL.md'), '# Custom\n', 'utf8'); @@ -5248,7 +5305,16 @@ describe('#3285 — install succeeds when config.toml contains hooks.state entri function runCodexInstall() { const previousCodexHome = process.env.CODEX_HOME; const previousCwd = process.cwd(); + // #2088 (ADR-1239 upgrade 3): Codex skills now install to the canonical + // $HOME/.agents/skills root (os.homedir()-relative, independent of + // CODEX_HOME). Sandbox HOME (and USERPROFILE) to tmpDir so this + // in-process install never materializes skills under the developer/CI + // machine's real home directory. + const previousHome = process.env.HOME; + const previousUserProfile = process.env.USERPROFILE; process.env.CODEX_HOME = codexHome; + process.env.HOME = tmpDir; + process.env.USERPROFILE = tmpDir; try { process.chdir(path.join(__dirname, '..')); return install(true, 'codex'); @@ -5259,6 +5325,10 @@ describe('#3285 — install succeeds when config.toml contains hooks.state entri } else { process.env.CODEX_HOME = previousCodexHome; } + if (previousHome === undefined) delete process.env.HOME; + else process.env.HOME = previousHome; + if (previousUserProfile === undefined) delete process.env.USERPROFILE; + else process.env.USERPROFILE = previousUserProfile; } } @@ -5930,11 +6000,24 @@ describe('#3426 uninstall: gsd-check-update.cmd is removed from hooks dir on uni function withCodexHome(dir, fn) { const prev = process.env.CODEX_HOME; + // #2088 (ADR-1239 upgrade 3): Codex skills now resolve an alternate install + // home rooted at the REAL os.homedir() ($HOME/.agents), independent of + // CODEX_HOME. Fake $HOME (and $USERPROFILE) too so this in-process install + // never touches the developer/CI machine's real home directory — confined + // entirely to `dir`, which the caller cleans up. + const prevHome = process.env.HOME; + const prevUserProfile = process.env.USERPROFILE; process.env.CODEX_HOME = dir; + process.env.HOME = dir; + process.env.USERPROFILE = dir; try { return fn(); } finally { if (prev == null) delete process.env.CODEX_HOME; else process.env.CODEX_HOME = prev; + if (prevHome == null) delete process.env.HOME; + else process.env.HOME = prevHome; + if (prevUserProfile == null) delete process.env.USERPROFILE; + else process.env.USERPROFILE = prevUserProfile; } } @@ -6050,12 +6133,27 @@ const BUILD_HOOKS_SCRIPT = path.join(__dirname, '..', 'scripts', 'build-hooks.js function withCodexHome(codexHome, fn) { const prev = process.env.CODEX_HOME; + // #2088 (ADR-1239 upgrade 3): Codex skills now resolve an alternate install + // home rooted at the REAL os.homedir() ($HOME/.agents), independent of + // CODEX_HOME. Fake $HOME (and $USERPROFILE) too — using the sandbox root + // (codexHome's parent, since codexHome is conventionally `/.codex` + // in this file) — so this in-process install never touches the developer/CI + // machine's real home directory. tmpRoot is reclaimed by the caller's afterEach. + const prevHome = process.env.HOME; + const prevUserProfile = process.env.USERPROFILE; + const fakeHome = path.dirname(codexHome); process.env.CODEX_HOME = codexHome; + process.env.HOME = fakeHome; + process.env.USERPROFILE = fakeHome; try { return fn(); } finally { if (prev == null) delete process.env.CODEX_HOME; else process.env.CODEX_HOME = prev; + if (prevHome == null) delete process.env.HOME; + else process.env.HOME = prevHome; + if (prevUserProfile == null) delete process.env.USERPROFILE; + else process.env.USERPROFILE = prevUserProfile; } } @@ -6107,22 +6205,32 @@ describe('#3427 + #3433 — Codex installer avoids duplicate skills and mixed ho withCodexHome(codexHome, () => install(true, 'codex')); - const skillsDir = path.join(codexHome, 'skills'); - const entries = fs.existsSync(skillsDir) - ? fs.readdirSync(skillsDir, { withFileTypes: true }).filter((e) => e.isDirectory()).map((e) => e.name) + // #2088: the managed gsd-* skill surface now regenerates at the + // canonical $HOME/.agents/skills root (fakeHome === tmpRoot here — see + // withCodexHome above), not under the legacy $CODEX_HOME/skills. + const newSkillsDir = codexSkillsRoot(tmpRoot); + const newEntries = fs.existsSync(newSkillsDir) + ? fs.readdirSync(newSkillsDir, { withFileTypes: true }).filter((e) => e.isDirectory()).map((e) => e.name) : []; - // #3562: $gsd-* commands are discoverable only when skills/gsd-*/SKILL.md - // exists. The installer must regenerate (not remove) the managed gsd-* - // directories. - assert.equal(entries.includes('gsd-help'), true); - const refreshedBody = fs.readFileSync(path.join(skillsDir, 'gsd-help', 'SKILL.md'), 'utf8'); + // #3562: $gsd-* commands are discoverable only when + // .agents/skills/gsd-*/SKILL.md exists. The installer must regenerate + // (not remove) the managed gsd-* directories. + assert.equal(newEntries.includes('gsd-help'), true); + const refreshedBody = fs.readFileSync(path.join(newSkillsDir, 'gsd-help', 'SKILL.md'), 'utf8'); assert.notEqual(refreshedBody, legacySkillBody, 'stale legacy body must be overwritten'); const frontmatter = parseFrontmatter(refreshedBody); assert.equal(frontmatter.name, 'gsd-help', 'refreshed SKILL.md frontmatter must declare name: gsd-help'); - // Unrelated user skills are preserved — the regen scope is `gsd-*` only. - assert.equal(entries.includes('custom-user-skill'), true); + // #2088 migration: the installer cleans stale gsd-* dirs out of the old + // $CODEX_HOME/skills location on a pre-move install. + const legacyHelpDir = path.join(codexHome, 'skills', 'gsd-help'); + assert.equal(fs.existsSync(legacyHelpDir), false, 'migration must remove the stale legacy gsd-help skill dir from $CODEX_HOME/skills'); + + // Unrelated user skills are preserved in place — migration only removes + // `gsd-*` dirs from the old location; non-gsd-* user dirs are untouched. + const userSkill = path.join(codexHome, 'skills', 'custom-user-skill', 'SKILL.md'); + assert.equal(fs.existsSync(userSkill), true, 'unrelated user skill must survive the #2088 migration'); }); test('stores managed SessionStart update hook in hooks.json and removes inline gsd hook from config.toml', () => { @@ -6239,12 +6347,27 @@ const BUILD_HOOKS_SCRIPT = path.join(__dirname, '..', 'scripts', 'build-hooks.js function withCodexHome(codexHome, fn) { const prev = process.env.CODEX_HOME; + // #2088 (ADR-1239 upgrade 3): Codex skills now resolve an alternate install + // home rooted at the REAL os.homedir() ($HOME/.agents), independent of + // CODEX_HOME. Fake $HOME (and $USERPROFILE) too — using the sandbox root + // (codexHome's parent, since codexHome is conventionally `/.codex` + // in this file) — so this in-process install never touches the developer/CI + // machine's real home directory. tmpRoot is reclaimed by the caller's afterEach. + const prevHome = process.env.HOME; + const prevUserProfile = process.env.USERPROFILE; + const fakeHome = path.dirname(codexHome); process.env.CODEX_HOME = codexHome; + process.env.HOME = fakeHome; + process.env.USERPROFILE = fakeHome; try { return fn(); } finally { if (prev == null) delete process.env.CODEX_HOME; else process.env.CODEX_HOME = prev; + if (prevHome == null) delete process.env.HOME; + else process.env.HOME = prevHome; + if (prevUserProfile == null) delete process.env.USERPROFILE; + else process.env.USERPROFILE = prevUserProfile; } } @@ -6268,7 +6391,9 @@ describe('#3562 — Codex install produces discoverable $gsd-* skill surface', { test('global install creates skills/gsd-help/SKILL.md', () => { withCodexHome(codexHome, () => install(true, 'codex')); - const skillPath = path.join(codexHome, 'skills', 'gsd-help', 'SKILL.md'); + // #2088: skills now install to the canonical $HOME/.agents/skills root. + // withCodexHome fakes $HOME to tmpRoot (codexHome's parent) above. + const skillPath = path.join(codexSkillsRoot(tmpRoot), 'gsd-help', 'SKILL.md'); assert.ok( fs.existsSync(skillPath), `Codex install must create ${skillPath} so $gsd-help is discoverable. ` + @@ -6279,7 +6404,7 @@ describe('#3562 — Codex install produces discoverable $gsd-* skill surface', { test('SKILL.md content has frontmatter expected by Codex skill discovery', () => { withCodexHome(codexHome, () => install(true, 'codex')); - const skillPath = path.join(codexHome, 'skills', 'gsd-help', 'SKILL.md'); + const skillPath = path.join(codexSkillsRoot(tmpRoot), 'gsd-help', 'SKILL.md'); assert.ok(fs.existsSync(skillPath), 'precondition: SKILL.md exists'); const content = fs.readFileSync(skillPath, 'utf8'); @@ -6290,7 +6415,7 @@ describe('#3562 — Codex install produces discoverable $gsd-* skill surface', { test('multiple core $gsd-* skills are produced (not just gsd-help)', () => { withCodexHome(codexHome, () => install(true, 'codex')); - const skillsDir = path.join(codexHome, 'skills'); + const skillsDir = codexSkillsRoot(tmpRoot); assert.ok(fs.existsSync(skillsDir), 'skills/ directory must exist after install'); const gsdSkills = fs @@ -6384,12 +6509,27 @@ const BUILD_HOOKS_SCRIPT = path.join(__dirname, '..', 'scripts', 'build-hooks.js function withCodexHome(codexHome, fn) { const prev = process.env.CODEX_HOME; + // #2088 (ADR-1239 upgrade 3): Codex skills now resolve an alternate install + // home rooted at the REAL os.homedir() ($HOME/.agents), independent of + // CODEX_HOME. Fake $HOME (and $USERPROFILE) too — using the sandbox root + // (codexHome's parent, since codexHome is conventionally `/.codex` + // in this file) — so this in-process install never touches the developer/CI + // machine's real home directory. tmpRoot is reclaimed by the caller's afterEach. + const prevHome = process.env.HOME; + const prevUserProfile = process.env.USERPROFILE; + const fakeHome = path.dirname(codexHome); process.env.CODEX_HOME = codexHome; + process.env.HOME = fakeHome; + process.env.USERPROFILE = fakeHome; try { return fn(); } finally { if (prev == null) delete process.env.CODEX_HOME; else process.env.CODEX_HOME = prev; + if (prevHome == null) delete process.env.HOME; + else process.env.HOME = prevHome; + if (prevUserProfile == null) delete process.env.USERPROFILE; + else process.env.USERPROFILE = prevUserProfile; } } @@ -6659,7 +6799,16 @@ function runCodexInstallCaptured() { const previousCodexHome = process.env.CODEX_HOME; const previousCwd = process.cwd(); + // #2088 (ADR-1239 upgrade 3): Codex skills now resolve an alternate install + // home rooted at os.homedir() ($HOME/.agents), independent of CODEX_HOME. + // Sandbox $HOME (and $USERPROFILE) to codexHome too — otherwise this + // in-process install would materialize skills under the developer/CI + // machine's REAL home directory instead of the temp dir. + const previousHome = process.env.HOME; + const previousUserProfile = process.env.USERPROFILE; process.env.CODEX_HOME = codexHome; + process.env.HOME = codexHome; + process.env.USERPROFILE = codexHome; process.env.GSD_TEST_MODE = '1'; try { process.chdir(ROOT); @@ -6679,6 +6828,16 @@ function runCodexInstallCaptured() { } else { process.env.CODEX_HOME = previousCodexHome; } + if (previousHome === undefined) { + delete process.env.HOME; + } else { + process.env.HOME = previousHome; + } + if (previousUserProfile === undefined) { + delete process.env.USERPROFILE; + } else { + process.env.USERPROFILE = previousUserProfile; + } if (previousGsdTestMode === undefined) { delete process.env.GSD_TEST_MODE; } else { @@ -6703,7 +6862,7 @@ describe('bug-3582: Codex global install materializes the skill surface', { conc }); test('writes the exact expected set of gsd-*/SKILL.md skills (deepEqual on name set)', () => { - const skillsDir = path.join(installRun.codexHome, 'skills'); + const skillsDir = codexSkillsRoot(installRun.codexHome); assert.ok( fs.existsSync(skillsDir), `Codex install must create ${skillsDir} (the 1.42.2 regression skipped this entirely)`, @@ -6735,7 +6894,7 @@ describe('bug-3582: Codex global install materializes the skill surface', { conc }); test('SKILL.md frontmatter declares hyphen-form name matching the directory', () => { - const skillsDir = path.join(installRun.codexHome, 'skills'); + const skillsDir = codexSkillsRoot(installRun.codexHome); const skillDirs = fs.readdirSync(skillsDir, { withFileTypes: true }) .filter(e => e.isDirectory() && e.name.startsWith('gsd-')) .map(e => e.name); @@ -6767,7 +6926,7 @@ describe('bug-3582: Codex global install materializes the skill surface', { conc // review); the file on disk must contain its full output verbatim // (open tag, body, closing ``). A truncated, // empty, or missing-closing-tag adapter cannot satisfy this assertion. - const skillsDir = path.join(installRun.codexHome, 'skills'); + const skillsDir = codexSkillsRoot(installRun.codexHome); const skillDirs = fs.readdirSync(skillsDir, { withFileTypes: true }) .filter(e => e.isDirectory() && e.name.startsWith('gsd-')) .map(e => e.name); @@ -6807,7 +6966,7 @@ describe('bug-3582: Codex global install materializes the skill surface', { conc 'gsd-new-project', 'gsd-health', ]; - const skillsDir = path.join(installRun.codexHome, 'skills'); + const skillsDir = codexSkillsRoot(installRun.codexHome); for (const name of representative) { const skillMd = path.join(skillsDir, name, 'SKILL.md'); assert.ok( @@ -6818,7 +6977,7 @@ describe('bug-3582: Codex global install materializes the skill surface', { conc }); test('installed Codex skills do not ask agents to run bare gsd-tools commands', () => { - const skillsDir = path.join(installRun.codexHome, 'skills'); + const skillsDir = codexSkillsRoot(installRun.codexHome); const skillDirs = fs.readdirSync(skillsDir, { withFileTypes: true }) .filter(e => e.isDirectory() && e.name.startsWith('gsd-')) .map(e => e.name); @@ -7075,12 +7234,27 @@ const BUILD_HOOKS_SCRIPT = path.join(__dirname, '..', 'scripts', 'build-hooks.js function withCodexHome(codexHome, fn) { const prev = process.env.CODEX_HOME; + // #2088 (ADR-1239 upgrade 3): Codex skills now resolve an alternate install + // home rooted at the REAL os.homedir() ($HOME/.agents), independent of + // CODEX_HOME. Fake $HOME (and $USERPROFILE) too — using the sandbox root + // (codexHome's parent, since codexHome is conventionally `/.codex` + // in this file) — so this in-process install never touches the developer/CI + // machine's real home directory. tmpRoot is reclaimed by the caller's afterEach. + const prevHome = process.env.HOME; + const prevUserProfile = process.env.USERPROFILE; + const fakeHome = path.dirname(codexHome); process.env.CODEX_HOME = codexHome; + process.env.HOME = fakeHome; + process.env.USERPROFILE = fakeHome; try { return fn(); } finally { if (prev == null) delete process.env.CODEX_HOME; else process.env.CODEX_HOME = prev; + if (prevHome == null) delete process.env.HOME; + else process.env.HOME = prevHome; + if (prevUserProfile == null) delete process.env.USERPROFILE; + else process.env.USERPROFILE = prevUserProfile; } } diff --git a/tests/codex-declarative-reference.test.cjs b/tests/codex-declarative-reference.test.cjs new file mode 100644 index 000000000..378ace06a --- /dev/null +++ b/tests/codex-declarative-reference.test.cjs @@ -0,0 +1,289 @@ +// allow-test-rule: AC2 requires asserting no `runtime === 'codex'` string-equality and no positive `isCodex` branch remain in bin/install.js/src — the descriptor-migration contract is a property of the source text, so a source-grep is the only faithful check (#2088) +'use strict'; + +/** + * codex declarative reference host — ADR-1239 Phase D / #2088 (EoS/codex). + * + * Proves Codex is driven through the PUBLIC Host-Integration Interface (the + * declarative embedding mode), that its negotiated axes classify + negotiate + * correctly (including the documented `maxDepth === 1 → flat` dispatch + * degradation), that negotiation fails CLOSED on a corrupted descriptor, that + * the three Context7-verified UPGRADES land on the user-reachable surface + * (skill-root → $HOME/.agents/skills, the 6 new hooks.json lifecycle events, and + * explicit `[agents] max_depth` dispatch tuning), and that the migration retired + * the hardcoded `runtime === 'codex'` / positive-`isCodex` projection (folded + * into descriptor-driven `runtime.hostBehaviors`). + */ + +const { test } = require('node:test'); +const assert = require('node:assert/strict'); +const fs = require('node:fs'); +const os = require('node:os'); +const path = require('node:path'); + +process.env.GSD_TEST_MODE = '1'; + +const { createImperativeAdapter } = require('../gsd-core/bin/lib/adapter-imperative.cjs'); +const { createDeclarativeAdapter } = require('../gsd-core/bin/lib/adapter-declarative.cjs'); +const { + profileOf, + negotiateHostCapabilities, + degradationFor, + PROFILE_BASELINES, + UNDOCUMENTED, +} = require('../gsd-core/bin/lib/host-integration.cjs'); + +const install = require('../bin/install.js'); +const { cleanup } = require('./helpers.cjs'); + +const CODEX_CAP = JSON.parse( + fs.readFileSync(path.join(__dirname, '..', 'capabilities', 'codex', 'capability.json'), 'utf8'), +); +const CODEX_AXES = CODEX_CAP.runtime.hostIntegration; + +// -- AC2: driven through the public interface (declarative embedding mode) ---- + +test('codex axes classify as the declarative-cli reference profile', () => { + assert.equal(profileOf(CODEX_AXES), 'declarative-cli'); +}); + +test('createDeclarativeAdapter classifies codex as declarative + delegates install to the engine', () => { + const adapter = createDeclarativeAdapter({ runtime: 'codex' }); + assert.equal(adapter.kind, 'declarative'); + assert.equal(adapter.runtime, 'codex'); + assert.equal(typeof adapter.install, 'function'); + assert.equal(typeof adapter.uninstall, 'function'); +}); + +test('the composed-registry install adapter drives codex install/uninstall', () => { + const adapter = createImperativeAdapter({ runtime: 'codex' }); + assert.equal(adapter.kind, 'imperative'); + assert.equal(adapter.runtime, 'codex'); + assert.ok(adapter.registry && typeof adapter.registry === 'object'); + assert.equal(typeof adapter.install, 'function'); + assert.equal(typeof adapter.uninstall, 'function'); +}); + +// -- AC3: every negotiated axis populated (no undocumented sentinel) ---------- + +test('every codex hostIntegration axis is populated (zero undocumented sentinels)', () => { + const axisVals = [ + CODEX_AXES.embeddingMode, CODEX_AXES.commandSurface, CODEX_AXES.modelMode, + CODEX_AXES.hookBus, CODEX_AXES.stateIO, CODEX_AXES.transport, CODEX_AXES.runtime, + ]; + for (const v of axisVals) { + assert.notEqual(v, UNDOCUMENTED, `axis must be documented, got ${v}`); + assert.ok(typeof v === 'string' && v.length > 0); + } + const d = CODEX_AXES.dispatch; + for (const k of ['namedDispatch', 'nested', 'maxDepth', 'subagentToolkit', 'background', 'backgroundDispatch']) { + assert.notEqual(d[k], UNDOCUMENTED, `dispatch.${k} must be documented`); + assert.notEqual(d[k], undefined, `dispatch.${k} must be present`); + } + assert.equal(d.embeddingMode, undefined); // sanity: dispatch has no stray keys +}); + +// -- AC5: dispatch degrades to flat because maxDepth === 1 ------------------- + +test('dispatch degrades to FLAT for codex — maxDepth===1 even though nested/background are all true', () => { + assert.equal(CODEX_AXES.dispatch.nested, true); + assert.equal(CODEX_AXES.dispatch.background, true); + assert.equal(CODEX_AXES.dispatch.backgroundDispatch, true); + assert.equal(CODEX_AXES.dispatch.maxDepth, 1); + + const flat = degradationFor('dispatch', CODEX_AXES); + assert.equal(flat.level, 'degraded', 'maxDepth===1 must degrade dispatch, not grant full nesting'); + assert.match(flat.fallback, /flat dispatch/, 'the documented fallback is flat/inline waves'); + + // Prove maxDepth is the cause: at depth 2 the same axes grant full dispatch. + const deeper = { ...CODEX_AXES, dispatch: { ...CODEX_AXES.dispatch, maxDepth: 2 } }; + assert.equal(degradationFor('dispatch', deeper).level, 'full'); +}); + +// -- AC5: negotiation fails CLOSED on a corrupted descriptor ------------------ + +test('negotiateHostCapabilities never throws for codex, even fully corrupted', () => { + assert.doesNotThrow(() => negotiateHostCapabilities({})); + assert.doesNotThrow(() => negotiateHostCapabilities({ ...CODEX_AXES, embeddingMode: UNDOCUMENTED })); + assert.doesNotThrow(() => negotiateHostCapabilities({ ...CODEX_AXES, embeddingMode: 'future-unknown' })); +}); + +test('a partial/empty codex descriptor degrades to the safe floor, not the declarative-cli baseline', () => { + const result = negotiateHostCapabilities({}); + assert.equal(result.effective.embeddingMode, 'declarative', 'omitted embeddingMode degrades closed'); + assert.equal(result.effective.hookBus, 'none'); + assert.notDeepEqual(result.effective, PROFILE_BASELINES['declarative-cli']); + assert.ok(result.warnings.length > 0); +}); + +// -- AC2: the hardcoded projection is retired -------------------------------- + +test('codex descriptor declares runtime.hostBehaviors (the folded-in behaviors)', () => { + const hb = CODEX_CAP.runtime.hostBehaviors; + assert.ok(hb && typeof hb === 'object'); + assert.equal(hb.tomlConfigInstall, true, 'config.toml + agent-toml + hooks.json install runs through the descriptor gate'); + assert.equal(hb.cleanupSkillSidecars, true); + assert.equal(hb.agentTomlFiles, true); + assert.equal(hb.frontmatterDialect, 'codex'); + assert.equal(hb.reapplyCommand, '$gsd-update --reapply'); +}); + +test('no `runtime === "codex"` string-equality and no positive `isCodex` gate remain in the install source (AC2)', () => { + const strip = (src) => src + .replace(/\/\*[\s\S]*?\*\//g, '') + .replace(/\/\/[^\r\n]*/g, '') + .replace(/`[^`]*`/g, ''); + for (const rel of ['bin/install.js', 'src/install-engine.cts', 'src/runtime-artifact-conversion.cts']) { + const raw = fs.readFileSync(path.join(__dirname, '..', rel), 'utf8'); + const src = strip(raw); + + const stringEq = src.match(/runtime\s*[!=]==\s*'codex'/g) || []; + assert.deepEqual(stringEq, [], `AC2: no hardcoded runtime==='codex' branch may remain in ${rel}; found: ${stringEq.join(', ')}`); + + // Every `isCodex` reference must be either a runtimeFlags(...) destructure + // line or a NEGATED occurrence inside a shared multi-runtime roster + // (`!isCodex && !isCopilot && ...`). A positive `isCodex` gate is forbidden. + for (const line of src.split(/\r?\n/)) { + if (!/\bisCodex\b/.test(line)) continue; + if (/runtimeFlags\s*\(/.test(line)) continue; // destructure declaration + const positive = line.replace(/!\s*isCodex\b/g, '').match(/\bisCodex\b/); + assert.equal(positive, null, `AC2: positive isCodex gate forbidden in ${rel}: ${line.trim()}`); + } + } +}); + +// -- AC4 upgrade 3: skill root is the canonical $HOME/.agents/skills --------- + +test('upgrade 3 — codex skills resolve to $HOME/.agents/skills, not the deprecated $CODEX_HOME/skills', () => { + const codexHome = path.join(os.homedir(), '.codex'); + const root = install._resolveSkillsRootDir('codex', codexHome, 'global'); + assert.equal(root, path.join(os.homedir(), '.agents', 'skills'), + 'skills must install to the canonical ~/.agents/skills root'); + assert.ok(!root.startsWith(codexHome), 'skills must NOT land under the deprecated $CODEX_HOME/skills'); +}); + +test('upgrade 3 — the pre-move location is migrated (stale ~/.codex/skills/gsd-* cleaned; user content preserved)', () => { + const codexHome = path.join(os.homedir(), '.codex'); + const oldDir = install._resolveMovedSkillsOldDir('codex', codexHome, 'global'); + assert.equal(oldDir, path.join(codexHome, 'skills'), 'the pre-move location is $CODEX_HOME/skills'); + // A runtime with no home override yields null (no false migration). + assert.equal(install._resolveMovedSkillsOldDir('kimi', path.join(os.homedir(), '.kimi'), 'global'), null); + + const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'codex-migrate-')); + const skillsDir = path.join(tmp, 'skills'); + fs.mkdirSync(path.join(skillsDir, 'gsd-plan'), { recursive: true }); + fs.mkdirSync(path.join(skillsDir, 'gsd-dev-preferences'), { recursive: true }); // user-owned, preserved + fs.mkdirSync(path.join(skillsDir, 'my-own-skill'), { recursive: true }); // non-gsd, preserved + try { + const removed = install.cleanupMovedSkillsOldLocation(skillsDir, 'gsd-'); + assert.equal(removed, 1, 'only the managed gsd-plan dir is removed'); + assert.ok(!fs.existsSync(path.join(skillsDir, 'gsd-plan')), 'stale managed skill removed'); + assert.ok(fs.existsSync(path.join(skillsDir, 'gsd-dev-preferences')), 'user-owned gsd-dev-preferences preserved'); + assert.ok(fs.existsSync(path.join(skillsDir, 'my-own-skill')), 'non-gsd dir preserved'); + } finally { + cleanup(tmp); + } +}); + +// -- AC4 upgrade 1: the 6 new hooks.json lifecycle events are registered ------ + +test('upgrade 1 — codex registers all documented hooks.json lifecycle events, incl. the 6 new in #2088', () => { + const expected = [ + 'SubagentStart', 'Stop', 'PostToolUse', // #772 + 'PreToolUse', 'PermissionRequest', 'PreCompact', 'PostCompact', 'SubagentStop', 'UserPromptSubmit', // #2088 + ]; + assert.deepEqual(install.CODEX_EXTENDED_HOOK_EVENTS, expected, + 'the shared install/uninstall event list must contain the 3 original + 6 new events'); + + const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'codex-hooks-')); + try { + fs.mkdirSync(path.join(tmp, 'hooks'), { recursive: true }); + fs.writeFileSync(path.join(tmp, 'hooks', 'gsd-context-monitor.js'), '// stub'); + fs.writeFileSync(path.join(tmp, 'hooks', 'gsd-check-update.js'), '// stub'); + for (const ev of install.CODEX_EXTENDED_HOOK_EVENTS) { + install.ensureCodexHooksJsonEvent(tmp, ev, { absoluteRunner: '/usr/bin/node', platform: 'linux' }); + } + install.ensureCodexHooksJsonSessionStart(tmp, { absoluteRunner: '/usr/bin/node', platform: 'linux' }); + const hooksJson = JSON.parse(fs.readFileSync(path.join(tmp, 'hooks.json'), 'utf8')); + const registered = Object.keys(hooksJson.hooks || hooksJson || {}); + for (const ev of ['PreToolUse', 'PermissionRequest', 'PreCompact', 'PostCompact', 'SubagentStop', 'UserPromptSubmit']) { + assert.ok(registered.includes(ev), `#2088 must register the ${ev} hook in hooks.json`); + } + assert.ok(registered.includes('SessionStart'), 'the SessionStart baseline stays registered'); + } finally { + cleanup(tmp); + } +}); + +test('upgrade 1 — extendedHookEvents descriptor reconciled to the schema-valid wired subset (no longer [])', () => { + assert.deepEqual(CODEX_CAP.runtime.extendedHookEvents, ['SubagentStop', 'Stop', 'PreCompact'], + 'reconciled from [] to the wired extended-lifecycle events expressible in the cross-runtime vocabulary'); +}); + +// -- AC4 upgrade 2: explicit [agents] max_depth dispatch tuning --------------- + +test('upgrade 2 — the managed config block writes [agents] max_depth = 1', () => { + const block = install.generateCodexConfigBlock( + [{ name: 'gsd-foo', description: 'Foo' }, { name: 'gsd-bar', description: 'Bar' }], + path.join(os.homedir(), '.codex'), + ); + assert.match(block, /\[agents\]\nmax_depth = 1\n/, 'the block pins max_depth = 1 on a bare [agents] table'); + // The bare [agents] scalar table coexists with the [agents.gsd-*] role tables. + assert.match(block, /\[agents\.gsd-foo\]/); +}); + +test('upgrade 2 — validateCodexConfigSchema accepts the managed [agents] block but still rejects break-forms', () => { + const block = install.generateCodexConfigBlock([{ name: 'gsd-foo', description: 'Foo' }], path.join(os.homedir(), '.codex')); + assert.equal(install.validateCodexConfigSchema(block).ok, true, 'known-scalar [agents] + role tables must validate'); + + // Still rejects the actual #2760 break-forms. + assert.equal(install.validateCodexConfigSchema('[[agents]]\nname = "x"\n').ok, false, '[[agents]] sequence still rejected'); + assert.equal(install.validateCodexConfigSchema('[agents]\ndefault = "x"\n').ok, false, 'bare [agents] with an unknown key still rejected'); + + // A user's own AgentsToml scalar table is now accepted (no longer over-rejected). + assert.equal(install.validateCodexConfigSchema('[agents]\nmax_threads = 4\n').ok, true, 'user known-scalar [agents] accepted'); + assert.equal(install.codexBareAgentsHasOnlyKnownScalars('max_depth = 1\n'), true); + assert.equal(install.codexBareAgentsHasOnlyKnownScalars('default = "x"\n'), false); +}); + +test('upgrade 2 — uninstall removes the managed [agents] max_depth block', () => { + const block = install.generateCodexConfigBlock([{ name: 'gsd-foo', description: 'Foo' }], path.join(os.homedir(), '.codex')); + const stripped = install.stripGsdFromCodexConfig('model = "gpt-5"\n\n' + block); + assert.ok(stripped === null || !/\[agents\]/.test(stripped), `uninstall must remove the managed [agents] block; got: ${JSON.stringify(stripped)}`); +}); + +// Regression (#2088 review): install must NOT silently drop a user's own +// AgentsToml scalar tuning when it purges the bare [agents] table to add max_depth. +test('upgrade 2 — install preserves the user\'s own AgentsToml scalars (max_threads etc.), GSD-manages max_depth', () => { + const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'codex-agents-merge-')); + try { + const cfgPath = path.join(tmp, 'config.toml'); + fs.writeFileSync(cfgPath, '[agents]\nmax_threads = 4\nmax_depth = 9\ninterrupt_message = false\n\n[model]\nname = "o3"\n'); + + // extract helper: user scalars except the GSD-managed max_depth. + const scalars = install.extractCodexUserAgentsScalars(fs.readFileSync(cfgPath, 'utf8')); + assert.ok(scalars.includes('max_threads = 4'), 'max_threads is a preserved user scalar'); + assert.ok(scalars.includes('interrupt_message = false'), 'interrupt_message is a preserved user scalar'); + assert.ok(!scalars.some((s) => s.startsWith('max_depth')), 'max_depth is GSD-managed, not preserved from the user'); + + install.mergeCodexConfig(cfgPath, install.generateCodexConfigBlock([{ name: 'gsd-foo', description: 'Foo' }], tmp)); + const merged = fs.readFileSync(cfgPath, 'utf8'); + + assert.match(merged, /max_threads = 4/, 'user max_threads must survive install'); + assert.match(merged, /interrupt_message = false/, 'user interrupt_message must survive install'); + assert.match(merged, /max_depth = 1/, 'GSD pins max_depth = 1'); + assert.doesNotMatch(merged, /max_depth = 9/, 'user max_depth is overridden by the GSD-managed value'); + assert.equal((merged.match(/^\[agents\]$/mg) || []).length, 1, 'exactly one managed [agents] table (no duplicate)'); + assert.equal(install.validateCodexConfigSchema(merged).ok, true, 'the merged config still validates'); + + // Symmetric: uninstall restores the user's scalars and drops GSD's max_depth. + const uninstalled = install.stripGsdFromCodexConfig(merged); + assert.match(uninstalled, /max_threads = 4/, 'uninstall restores the user\'s max_threads'); + assert.match(uninstalled, /interrupt_message = false/, 'uninstall restores interrupt_message'); + assert.doesNotMatch(uninstalled, /max_depth/, 'uninstall drops the GSD-managed max_depth'); + assert.doesNotMatch(uninstalled, /gsd-foo/, 'uninstall removes the gsd role table'); + assert.equal(install.validateCodexConfigSchema(uninstalled).ok, true, 'the restored config validates'); + } finally { + cleanup(tmp); + } +}); diff --git a/tests/fixtures/golden-install-parity/codex.json b/tests/fixtures/golden-install-parity/codex.json index a1a0bb73c..efead535f 100644 --- a/tests/fixtures/golden-install-parity/codex.json +++ b/tests/fixtures/golden-install-parity/codex.json @@ -1,4 +1,75 @@ { + ".agents/skills/gsd-add-tests/SKILL.md": "f3c1594a059de3ee", + ".agents/skills/gsd-ai-integration-phase/SKILL.md": "ba4b5f406db6de96", + ".agents/skills/gsd-audit-fix/SKILL.md": "9cea2764b92aa352", + ".agents/skills/gsd-audit-milestone/SKILL.md": "0528ef8a5834b5df", + ".agents/skills/gsd-audit-uat/SKILL.md": "6c2aa0c2b62c5233", + ".agents/skills/gsd-autonomous/SKILL.md": "d56dc692750f4926", + ".agents/skills/gsd-capture/SKILL.md": "211d601d122b5dd6", + ".agents/skills/gsd-cleanup/SKILL.md": "18cba17463c6ef97", + ".agents/skills/gsd-code-review/SKILL.md": "74749a1aff4224d7", + ".agents/skills/gsd-complete-milestone/SKILL.md": "77bee8741f8cb381", + ".agents/skills/gsd-config/SKILL.md": "5fde92e30619edcb", + ".agents/skills/gsd-debug/SKILL.md": "7e56964e14fd0a10", + ".agents/skills/gsd-discuss-phase/SKILL.md": "837d46c55cc2424e", + ".agents/skills/gsd-docs-update/SKILL.md": "5028dc5dd44bee7d", + ".agents/skills/gsd-eval-review/SKILL.md": "a300e78a27ba74d6", + ".agents/skills/gsd-execute-phase/SKILL.md": "e77bee13068600d3", + ".agents/skills/gsd-explore/SKILL.md": "d660cde0e6f31ebf", + ".agents/skills/gsd-extract-learnings/SKILL.md": "e32388999da384e4", + ".agents/skills/gsd-fast/SKILL.md": "14c407a76a40e115", + ".agents/skills/gsd-forensics/SKILL.md": "6d496a16f5ca74b3", + ".agents/skills/gsd-graphify/SKILL.md": "1bbd96129fbacc5c", + ".agents/skills/gsd-health/SKILL.md": "656c9c63852e3787", + ".agents/skills/gsd-help/SKILL.md": "28eb8d0b487239f8", + ".agents/skills/gsd-import/SKILL.md": "4d15ee09e531342f", + ".agents/skills/gsd-inbox/SKILL.md": "1315033595643485", + ".agents/skills/gsd-ingest-docs/SKILL.md": "5bd2838bf0b6dc1b", + ".agents/skills/gsd-manager/SKILL.md": "cb1cf56f5d3f66d6", + ".agents/skills/gsd-map-codebase/SKILL.md": "cd27dc028718426b", + ".agents/skills/gsd-mempalace-capture/SKILL.md": "1a7fac4d53f607ab", + ".agents/skills/gsd-mempalace-recall/SKILL.md": "7205b02250e89f25", + ".agents/skills/gsd-milestone-summary/SKILL.md": "af84ecb400f23556", + ".agents/skills/gsd-mvp-phase/SKILL.md": "72b65ae927b280fe", + ".agents/skills/gsd-new-milestone/SKILL.md": "587574d2d475bb10", + ".agents/skills/gsd-new-project/SKILL.md": "129ac16e6a1f04ba", + ".agents/skills/gsd-next/SKILL.md": "7855fd70e387087e", + ".agents/skills/gsd-ns-context/SKILL.md": "9b496da79789b3f9", + ".agents/skills/gsd-ns-ideate/SKILL.md": "84ca1cde06110981", + ".agents/skills/gsd-ns-manage/SKILL.md": "909dafa0cd19ba5a", + ".agents/skills/gsd-ns-project/SKILL.md": "936b6998d42520ac", + ".agents/skills/gsd-ns-review/SKILL.md": "022253010db0e072", + ".agents/skills/gsd-ns-workflow/SKILL.md": "cba075bc819b539e", + ".agents/skills/gsd-onboard/SKILL.md": "f42fc2edebeda671", + ".agents/skills/gsd-pause-work/SKILL.md": "b379469eed78a196", + ".agents/skills/gsd-phase/SKILL.md": "25477edc97a90c91", + ".agents/skills/gsd-plan-phase/SKILL.md": "19ead1acb151a868", + ".agents/skills/gsd-plan-review-convergence/SKILL.md": "f654089c11024027", + ".agents/skills/gsd-pr-branch/SKILL.md": "6901da15e321913e", + ".agents/skills/gsd-profile-user/SKILL.md": "6259fabfb6afe7be", + ".agents/skills/gsd-progress/SKILL.md": "85d76286162b9189", + ".agents/skills/gsd-quick/SKILL.md": "b4f4e711ba664aa0", + ".agents/skills/gsd-resume-work/SKILL.md": "04f6c2e5b579e8c4", + ".agents/skills/gsd-review-backlog/SKILL.md": "469696b944c4e7b5", + ".agents/skills/gsd-review/SKILL.md": "a45ab2fdca06793b", + ".agents/skills/gsd-secure-phase/SKILL.md": "e64ad269c1e6e319", + ".agents/skills/gsd-settings/SKILL.md": "fcdda8dd545622ae", + ".agents/skills/gsd-ship/SKILL.md": "9615cc4c8f6de060", + ".agents/skills/gsd-sketch/SKILL.md": "90c219b843db7ec7", + ".agents/skills/gsd-spec-phase/SKILL.md": "56a5cbd606db9cba", + ".agents/skills/gsd-spike/SKILL.md": "77209fef7a04c11a", + ".agents/skills/gsd-stats/SKILL.md": "566024444ef71f44", + ".agents/skills/gsd-surface/SKILL.md": "492cda98187a969b", + ".agents/skills/gsd-thread/SKILL.md": "85326c97c83a02d1", + ".agents/skills/gsd-ui-phase/SKILL.md": "a0c8fbcbe5e3c2b9", + ".agents/skills/gsd-ui-review/SKILL.md": "081a3292a2d357f5", + ".agents/skills/gsd-ultraplan-phase/SKILL.md": "06fb3d76eb785f94", + ".agents/skills/gsd-undo/SKILL.md": "007d3b307e027af9", + ".agents/skills/gsd-update/SKILL.md": "e6e49e117c7c8f35", + ".agents/skills/gsd-validate-phase/SKILL.md": "373059517a94a194", + ".agents/skills/gsd-verify-work/SKILL.md": "4272275698e9a3fe", + ".agents/skills/gsd-workspace/SKILL.md": "06d6400d68318361", + ".agents/skills/gsd-workstreams/SKILL.md": "2f77bb94db1be1a4", ".gsd-profile": "0e716a5fef4e6dc1", ".gsd/defaults.json": "560664b045e645cb", "agents/gsd-advisor-researcher.md": "eea6d1604aaf305c", @@ -69,7 +140,7 @@ "agents/gsd-user-profiler.toml": "b9c244bb8fbf8140", "agents/gsd-verifier.md": "4ac4b860e2504374", "agents/gsd-verifier.toml": "8ed9fb961409e894", - "config.toml": "a34316b9ac61a620", + "config.toml": "fa48d84b92174890", "gsd-core/VERSION": "ef0deccd81a6723c", "gsd-core/bin/check-latest-version.cjs": "e4a224058c8f4d74", "gsd-core/bin/ensure-runtime-build.cjs": "51bc64467ab30f62", @@ -359,76 +430,5 @@ "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", - "skills/gsd-add-tests/SKILL.md": "f3c1594a059de3ee", - "skills/gsd-ai-integration-phase/SKILL.md": "ba4b5f406db6de96", - "skills/gsd-audit-fix/SKILL.md": "9cea2764b92aa352", - "skills/gsd-audit-milestone/SKILL.md": "0528ef8a5834b5df", - "skills/gsd-audit-uat/SKILL.md": "6c2aa0c2b62c5233", - "skills/gsd-autonomous/SKILL.md": "d56dc692750f4926", - "skills/gsd-capture/SKILL.md": "211d601d122b5dd6", - "skills/gsd-cleanup/SKILL.md": "18cba17463c6ef97", - "skills/gsd-code-review/SKILL.md": "74749a1aff4224d7", - "skills/gsd-complete-milestone/SKILL.md": "77bee8741f8cb381", - "skills/gsd-config/SKILL.md": "5fde92e30619edcb", - "skills/gsd-debug/SKILL.md": "7e56964e14fd0a10", - "skills/gsd-discuss-phase/SKILL.md": "837d46c55cc2424e", - "skills/gsd-docs-update/SKILL.md": "5028dc5dd44bee7d", - "skills/gsd-eval-review/SKILL.md": "a300e78a27ba74d6", - "skills/gsd-execute-phase/SKILL.md": "e77bee13068600d3", - "skills/gsd-explore/SKILL.md": "d660cde0e6f31ebf", - "skills/gsd-extract-learnings/SKILL.md": "e32388999da384e4", - "skills/gsd-fast/SKILL.md": "14c407a76a40e115", - "skills/gsd-forensics/SKILL.md": "6d496a16f5ca74b3", - "skills/gsd-graphify/SKILL.md": "1bbd96129fbacc5c", - "skills/gsd-health/SKILL.md": "656c9c63852e3787", - "skills/gsd-help/SKILL.md": "28eb8d0b487239f8", - "skills/gsd-import/SKILL.md": "4d15ee09e531342f", - "skills/gsd-inbox/SKILL.md": "1315033595643485", - "skills/gsd-ingest-docs/SKILL.md": "5bd2838bf0b6dc1b", - "skills/gsd-manager/SKILL.md": "cb1cf56f5d3f66d6", - "skills/gsd-map-codebase/SKILL.md": "cd27dc028718426b", - "skills/gsd-mempalace-capture/SKILL.md": "1a7fac4d53f607ab", - "skills/gsd-mempalace-recall/SKILL.md": "7205b02250e89f25", - "skills/gsd-milestone-summary/SKILL.md": "af84ecb400f23556", - "skills/gsd-mvp-phase/SKILL.md": "72b65ae927b280fe", - "skills/gsd-new-milestone/SKILL.md": "587574d2d475bb10", - "skills/gsd-new-project/SKILL.md": "129ac16e6a1f04ba", - "skills/gsd-next/SKILL.md": "7855fd70e387087e", - "skills/gsd-ns-context/SKILL.md": "9b496da79789b3f9", - "skills/gsd-ns-ideate/SKILL.md": "84ca1cde06110981", - "skills/gsd-ns-manage/SKILL.md": "909dafa0cd19ba5a", - "skills/gsd-ns-project/SKILL.md": "936b6998d42520ac", - "skills/gsd-ns-review/SKILL.md": "022253010db0e072", - "skills/gsd-ns-workflow/SKILL.md": "cba075bc819b539e", - "skills/gsd-onboard/SKILL.md": "f42fc2edebeda671", - "skills/gsd-pause-work/SKILL.md": "b379469eed78a196", - "skills/gsd-phase/SKILL.md": "25477edc97a90c91", - "skills/gsd-plan-phase/SKILL.md": "19ead1acb151a868", - "skills/gsd-plan-review-convergence/SKILL.md": "f654089c11024027", - "skills/gsd-pr-branch/SKILL.md": "6901da15e321913e", - "skills/gsd-profile-user/SKILL.md": "6259fabfb6afe7be", - "skills/gsd-progress/SKILL.md": "85d76286162b9189", - "skills/gsd-quick/SKILL.md": "b4f4e711ba664aa0", - "skills/gsd-resume-work/SKILL.md": "04f6c2e5b579e8c4", - "skills/gsd-review-backlog/SKILL.md": "469696b944c4e7b5", - "skills/gsd-review/SKILL.md": "a45ab2fdca06793b", - "skills/gsd-secure-phase/SKILL.md": "e64ad269c1e6e319", - "skills/gsd-settings/SKILL.md": "fcdda8dd545622ae", - "skills/gsd-ship/SKILL.md": "9615cc4c8f6de060", - "skills/gsd-sketch/SKILL.md": "90c219b843db7ec7", - "skills/gsd-spec-phase/SKILL.md": "56a5cbd606db9cba", - "skills/gsd-spike/SKILL.md": "77209fef7a04c11a", - "skills/gsd-stats/SKILL.md": "566024444ef71f44", - "skills/gsd-surface/SKILL.md": "492cda98187a969b", - "skills/gsd-thread/SKILL.md": "85326c97c83a02d1", - "skills/gsd-ui-phase/SKILL.md": "a0c8fbcbe5e3c2b9", - "skills/gsd-ui-review/SKILL.md": "081a3292a2d357f5", - "skills/gsd-ultraplan-phase/SKILL.md": "06fb3d76eb785f94", - "skills/gsd-undo/SKILL.md": "007d3b307e027af9", - "skills/gsd-update/SKILL.md": "e6e49e117c7c8f35", - "skills/gsd-validate-phase/SKILL.md": "373059517a94a194", - "skills/gsd-verify-work/SKILL.md": "4272275698e9a3fe", - "skills/gsd-workspace/SKILL.md": "06d6400d68318361", - "skills/gsd-workstreams/SKILL.md": "2f77bb94db1be1a4" + "scripts/lib/cli-exit.cjs": "612d0c372c75b7e7" } diff --git a/tests/helpers/install-shared.cjs b/tests/helpers/install-shared.cjs index 825be4ffb..060571854 100644 --- a/tests/helpers/install-shared.cjs +++ b/tests/helpers/install-shared.cjs @@ -12,6 +12,10 @@ const os = require('node:os'); const { spawnSync } = require('node:child_process'); const assert = require('node:assert/strict'); +const { + resolveRuntimeArtifactLayout, +} = require('../../gsd-core/bin/lib/runtime-artifact-layout.cjs'); + const INSTALL_SCRIPT = path.join(__dirname, '..', '..', 'bin', 'install.js'); const MANIFEST_NAME = 'gsd-file-manifest.json'; @@ -177,9 +181,27 @@ function manifestAgentCount(manifest) { return Object.keys(manifest.files).filter((k) => k.startsWith('agents/')).length; } -function collectSkillBasenamesOnDisk(configDir) { +/** + * Collect gsd-* skill/command basenames actually present on disk under configDir. + * + * @param {string} configDir + * @param {string} [runtime] - when provided, the skills-kind destination is + * resolved via resolveRuntimeArtifactLayout so a skills-kind `home` override + * (Codex only, ADR-1239 upgrade 3 / #2088: skills -> $HOME/.agents/skills + * instead of configDir/skills) is honored. Omitted callers keep the prior + * configDir/skills default. + * @param {string} [scope='global'] + */ +function collectSkillBasenamesOnDisk(configDir, runtime, scope = 'global') { const out = new Set(); - const skillsDir = path.join(configDir, 'skills'); + let skillsDir = path.join(configDir, 'skills'); + if (runtime) { + try { + const layout = resolveRuntimeArtifactLayout(runtime, configDir, scope); + const skillsKind = layout.kinds.find((k) => k.kind === 'skills'); + if (skillsKind) skillsDir = path.join(skillsKind.home || configDir, skillsKind.destSubpath); + } catch { /* fall back to configDir/skills */ } + } if (fs.existsSync(skillsDir)) { for (const entry of fs.readdirSync(skillsDir, { withFileTypes: true })) { if (entry.isDirectory() && entry.name.startsWith('gsd-')) { diff --git a/tests/install-minimal-hooks.test.cjs b/tests/install-minimal-hooks.test.cjs index a94ec8d6c..72297e7b7 100644 --- a/tests/install-minimal-hooks.test.cjs +++ b/tests/install-minimal-hooks.test.cjs @@ -63,6 +63,31 @@ const { collectSkillBasenamesOnDisk, } = require('./helpers/install-shared.cjs'); +/** + * collectSkillBasenamesOnDisk(configDir, runtime, scope) re-resolves the + * runtime's skills-kind layout via os.homedir(). runMinimalInstall() already + * sandboxes HOME/USERPROFILE to `root` for the spawned install subprocess, + * but that sandboxing does not persist into this (parent) process — without + * re-sandboxing here, Codex's skills-kind `home: ".agents"` override + * (ADR-1239 upgrade 3, #2088) would resolve against the developer's REAL + * $HOME/.agents/skills instead of the sandboxed install root. Sandbox + * HOME/USERPROFILE to `root` for the synchronous duration of the on-disk scan. + */ +function collectSkillBasenamesOnDiskSandboxed(configDir, runtime, scope, root) { + const savedHome = process.env.HOME; + const savedUserProfile = process.env.USERPROFILE; + process.env.HOME = root; + process.env.USERPROFILE = root; + try { + return collectSkillBasenamesOnDisk(configDir, runtime, scope); + } finally { + if (savedHome === undefined) delete process.env.HOME; + else process.env.HOME = savedHome; + if (savedUserProfile === undefined) delete process.env.USERPROFILE; + else process.env.USERPROFILE = savedUserProfile; + } +} + // ─── Section 9: install-profiles — MINIMAL_SKILL_ALLOWLIST ─────────────────── describe('install-profiles: MINIMAL_SKILL_ALLOWLIST', () => { @@ -391,7 +416,7 @@ describe('install: on-disk skill files match manifest for --minimal', () => { }); try { assert.ok(manifest); - const onDisk = collectSkillBasenamesOnDisk(configDir); + const onDisk = collectSkillBasenamesOnDiskSandboxed(configDir, runtime, scope, root); const inManifest = manifestSkillSet(manifest); assert.deepStrictEqual([...onDisk].sort(), [...inManifest].sort()); // Not the shared listAgentFiles() helper: asserts on the INSTALLED @@ -542,10 +567,15 @@ describe('install: Codex full → minimal downgrade cleans stale agent state', ( ].join('\n'); fs.writeFileSync(path.join(targetDir, 'config.toml'), codexConfig); + // Sandbox HOME/USERPROFILE to targetDir: Codex's skills-kind `home: ".agents"` + // override (ADR-1239 upgrade 3, #2088) resolves via os.homedir(), so an + // unsandboxed spawn here would write gsd-* skill dirs into the developer's + // real $HOME/.agents/skills. This test only asserts on agents/ and + // config.toml (both under targetDir), so the sandbox has no effect on intent. const result = spawnSync( process.execPath, [INSTALL_SCRIPT, '--codex', '--global', '--config-dir', targetDir, '--minimal'], - { encoding: 'utf8', env: installerEnv() }, + { encoding: 'utf8', env: installerEnv({ HOME: targetDir, USERPROFILE: targetDir }) }, ); assert.ok(result.stdout || result.stderr); diff --git a/tests/install-nested-layout.test.cjs b/tests/install-nested-layout.test.cjs index de6ff5acf..1790692d0 100644 --- a/tests/install-nested-layout.test.cjs +++ b/tests/install-nested-layout.test.cjs @@ -18,6 +18,10 @@ const { installRuntimeArtifacts, } = require('../gsd-core/bin/lib/install-engine.cjs'); +const { + resolveRuntimeArtifactLayout, +} = require('../gsd-core/bin/lib/runtime-artifact-layout.cjs'); + const { cleanup } = require('./helpers.cjs'); const { @@ -58,16 +62,55 @@ const FLAT = [ // Helpers // --------------------------------------------------------------------------- +/** + * Codex resolves its skills-kind destination via os.homedir() + a 'skills'-kind + * 'home: ".agents"' layout override (ADR-1239 EoS upgrade 3, #2088), NOT + * tmpDir/skills. Sandbox HOME/USERPROFILE to tmpDir for the duration of the + * synchronous callback so codex's resolved skills dir becomes + * tmpDir/.agents/skills instead of the developer's real ~/.agents/skills. + */ +function withSandboxedHome(tmpDir, fn) { + const savedHome = process.env.HOME; + const savedUserProfile = process.env.USERPROFILE; + process.env.HOME = tmpDir; + process.env.USERPROFILE = tmpDir; + try { + return fn(); + } finally { + if (savedHome === undefined) delete process.env.HOME; + else process.env.HOME = savedHome; + if (savedUserProfile === undefined) delete process.env.USERPROFILE; + else process.env.USERPROFILE = savedUserProfile; + } +} + /** * Create a fresh temp dir, run installRuntimeArtifacts into it, and return - * the tmpDir path. Caller must cleanup in finally. + * the tmpDir path. Caller must cleanup in finally. HOME is sandboxed to + * tmpDir for the install call so codex never writes to the real ~/.agents/skills. */ function runInstall(runtime, scope, resolved) { const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), `gsd-nest-test-${runtime}-`)); - installRuntimeArtifacts(runtime, tmpDir, scope, resolved); + withSandboxedHome(tmpDir, () => installRuntimeArtifacts(runtime, tmpDir, scope, resolved)); return tmpDir; } +/** + * Resolve the skills-kind destination directory for a runtime, honoring the + * skills-kind 'home' override (codex only — resolves under tmpDir/.agents + * once HOME is sandboxed). All other runtimes have no 'home' override, so + * this falls back to tmpDir/, matching the static skillsSub + * tables above. + */ +function resolveSkillsDir(runtime, tmpDir, scope) { + return withSandboxedHome(tmpDir, () => { + const layout = resolveRuntimeArtifactLayout(runtime, tmpDir, scope); + const skillsKind = layout.kinds.find((k) => k.kind === 'skills'); + assert.ok(skillsKind, `${runtime} must have skills kind`); + return path.join(skillsKind.home || tmpDir, skillsKind.destSubpath); + }); +} + // Resolve the full profile once (shared by all installs) const MANIFEST = loadSkillsManifest(COMMANDS_GSD); const RESOLVED_FULL = resolveProfile({ modes: ['full'], manifest: MANIFEST }); @@ -267,7 +310,7 @@ describe('claude: total top-level gsd- entries >= 60 (flat layout, #924)', () => // FLAT runtimes: concrete skills stay top-level, no nesting // --------------------------------------------------------------------------- -for (const { runtime, scope, skillsSub } of FLAT) { +for (const { runtime, scope } of FLAT) { describe(`${runtime} (flat layout)`, () => { let tmpDir; @@ -282,7 +325,7 @@ for (const { runtime, scope, skillsSub } of FLAT) { }); test(`${runtime}: stays flat — concrete skills remain top-level, no nesting`, () => { - const skillsDir = path.join(tmpDir, skillsSub); + const skillsDir = resolveSkillsDir(runtime, tmpDir, scope); assert.ok(fs.existsSync(skillsDir), `skillsDir must exist: ${skillsDir}`); const topLevel = fs.readdirSync(skillsDir); diff --git a/tests/install-runtime-artifacts.test.cjs b/tests/install-runtime-artifacts.test.cjs index 6e9e86076..96a96afc4 100644 --- a/tests/install-runtime-artifacts.test.cjs +++ b/tests/install-runtime-artifacts.test.cjs @@ -187,11 +187,31 @@ function readAllSkillMd(dir) { return out.join('\n'); } +// Codex resolves its skills-kind destination via os.homedir() + a 'skills'-kind +// 'home: ".agents"' layout override (ADR-1239 EoS upgrade 3, #2088), NOT +// configDir/skills. Without sandboxing HOME, an in-process codex install would +// write to (and an uninstall would mutate) the developer's REAL ~/.agents/skills. +// Sandbox HOME/USERPROFILE to configDir before resolving the layout or invoking +// install/uninstall so codex's resolved skills dir is configDir/.agents/skills. +function sandboxHome(t, dir) { + const savedHome = process.env.HOME; + const savedUserProfile = process.env.USERPROFILE; + process.env.HOME = dir; + process.env.USERPROFILE = dir; + t.after(() => { + if (savedHome === undefined) delete process.env.HOME; + else process.env.HOME = savedHome; + if (savedUserProfile === undefined) delete process.env.USERPROFILE; + else process.env.USERPROFILE = savedUserProfile; + }); +} + describe('installRuntimeArtifacts — skills runtimes write gsd-prefixed skill dirs', () => { for (const runtime of SKILLS_RUNTIMES_LAYOUT) { test(`${runtime}: gsd-prefixed skill dirs in skills/`, (t) => { const configDir = createTempDir(`gsd-ial-${runtime}-`); t.after(() => cleanup(configDir)); + sandboxHome(t, configDir); assert.strictEqual(typeof installRuntimeArtifacts, 'function'); installRuntimeArtifacts(runtime, configDir, 'global', RESOLVED_CORE); @@ -200,7 +220,7 @@ describe('installRuntimeArtifacts — skills runtimes write gsd-prefixed skill d const skillsKind = layout.kinds.find(k => k.kind === 'skills'); assert.ok(skillsKind, `${runtime} must have skills kind`); - const destDir = path.join(configDir, skillsKind.destSubpath); + const destDir = path.join(skillsKind.home || configDir, skillsKind.destSubpath); assert.ok(fs.existsSync(destDir)); assert.ok( fs.existsSync(path.join(destDir, `${skillsKind.prefix}help`, 'SKILL.md')), @@ -481,6 +501,7 @@ describe('uninstallRuntimeArtifacts — removes gsd-owned entries, preserves for test(`${runtime}: gsd entries removed, foreign preserved`, (t) => { const configDir = createTempDir(`gsd-ual-${runtime}-`); t.after(() => cleanup(configDir)); + sandboxHome(t, configDir); const { uninstallRuntimeArtifacts } = require('../bin/install.js'); assert.strictEqual(typeof uninstallRuntimeArtifacts, 'function'); @@ -519,7 +540,7 @@ describe('uninstallRuntimeArtifacts — removes gsd-owned entries, preserves for } for (const kind of layout.kinds) { - const destDir = path.join(configDir, kind.destSubpath); + const destDir = path.join(kind.home || configDir, kind.destSubpath); fs.mkdirSync(destDir, { recursive: true }); if (kind.kind === 'skills') { writeSkillEntry(destDir, kind.prefix, 'help'); @@ -545,7 +566,7 @@ describe('uninstallRuntimeArtifacts — removes gsd-owned entries, preserves for uninstallRuntimeArtifacts(runtime, configDir, 'global'); for (const kind of layout.kinds) { - const destDir = path.join(configDir, kind.destSubpath); + const destDir = path.join(kind.home || configDir, kind.destSubpath); if (kind.kind === 'skills') { assert.ok(!fs.existsSync(path.join(destDir, `${kind.prefix}help`))); assert.ok(!fs.existsSync(path.join(destDir, `${kind.prefix}phase`))); diff --git a/tests/install.test.cjs b/tests/install.test.cjs index c9560061b..acf762dc5 100644 --- a/tests/install.test.cjs +++ b/tests/install.test.cjs @@ -9957,7 +9957,9 @@ function readWorkflow() { describe('install.js --skills-root', () => { const CASES = [ { runtime: 'claude', expected: path.join(os.homedir(), '.claude', 'skills') }, - { runtime: 'codex', expected: path.join(os.homedir(), '.codex', 'skills') }, + // #2088 (ADR-1239 upgrade 3): Codex skills resolve to the canonical + // $HOME/.agents/skills root (skills-kind home override), not $CODEX_HOME/skills. + { runtime: 'codex', expected: path.join(os.homedir(), '.agents', 'skills') }, { runtime: 'copilot', expected: path.join(os.homedir(), '.copilot', 'skills') }, { runtime: 'cursor', expected: path.join(os.homedir(), '.cursor', 'skills') }, { runtime: 'trae', expected: path.join(os.homedir(), '.trae', 'skills') }, @@ -10182,10 +10184,15 @@ const INSTALL = path.join(__dirname, '..', 'bin', 'install.js'); */ function installAndRead(runtime) { const dir = fs.mkdtempSync(path.join(os.tmpdir(), `gsd-inst-${runtime}-`)); + // Sandbox HOME/USERPROFILE to `dir`: Codex's skills-kind `home: ".agents"` + // override (ADR-1239 upgrade 3, #2088) resolves via os.homedir(), so an + // unsandboxed real install here would write a full (non-minimal) gsd-* skill + // set into the developer/CI machine's real $HOME/.agents/skills. Harmless + // no-op for cursor/claude, which have no skills-kind home override. const res = spawnSync( process.execPath, [INSTALL, `--${runtime}`, '--global', '--config-dir', dir], - { encoding: 'utf8', timeout: 120000 }, + { encoding: 'utf8', timeout: 120000, env: { ...process.env, HOME: dir, USERPROFILE: dir } }, ); assert.strictEqual(res.status, 0, `install --${runtime} failed: ${res.stderr || res.stdout}`); const wf = path.join(dir, 'gsd-core', 'workflows', 'execute-phase.md'); diff --git a/tests/installer-migration-install.integration.test.cjs b/tests/installer-migration-install.integration.test.cjs index 220cd63b1..b03c655f1 100644 --- a/tests/installer-migration-install.integration.test.cjs +++ b/tests/installer-migration-install.integration.test.cjs @@ -81,6 +81,19 @@ function withEnv(key, value, fn) { } } +// #2088: Codex CLI skills install to `$HOME/.agents/skills` (resolved via +// os.homedir()), not `$CODEX_HOME/skills`. In-process codex installs must +// sandbox HOME (and USERPROFILE, for Windows os.homedir() resolution) to the +// test's codexHome dir, or skills get materialized into the real developer +// home directory. withEnv saves/restores a single key, so nesting is safe. +function withCodexEnv(codexHome, fn) { + return withEnv('CODEX_HOME', codexHome, () => + withEnv('HOME', codexHome, () => + withEnv('USERPROFILE', codexHome, fn) + ) + ); +} + function captureConsole(fn) { const originalLog = console.log; const originalWarn = console.warn; @@ -216,11 +229,20 @@ function assertFreshInstallContract(runtime, targetDir) { } if (contract.surface === 'flat-skills') { - // Pre-#3562: codex was special-cased to expect zero gsd-* skill dirs - // (assumption: Codex auto-discovers from workflows). That assumption - // does not hold for Codex CLI 0.130.0 — fresh installs now materialize - // the same flat-skills surface as the other runtimes. - assertHasGsdDirectory(targetDir, 'skills'); + if (runtime === 'codex') { + // #2088: Codex CLI skills install to the canonical `$HOME/.agents/skills` + // root (resolved via os.homedir()), NOT `/skills`. Here + // runInstallerCli sandboxes HOME to /home, so assert + // the skill dir under that sandboxed home instead of under targetDir. + const codexSandboxHome = path.join(path.dirname(targetDir), 'home'); + assertHasGsdDirectory(path.join(codexSandboxHome, '.agents'), 'skills'); + } else { + // Pre-#3562: codex was special-cased to expect zero gsd-* skill dirs + // (assumption: Codex auto-discovers from workflows). That assumption + // does not hold for Codex CLI 0.130.0 — fresh installs now materialize + // the same flat-skills surface as the other runtimes. + assertHasGsdDirectory(targetDir, 'skills'); + } } else if (contract.surface === 'hermes-skills') { // Hermes layout uses prefix: '' — skill dirs have bare stem names (no gsd- prefix). // Assert that the category dir contains at least one skill dir with SKILL.md. @@ -335,7 +357,7 @@ describe('installer migration install integration', { concurrency: false }, () = }); const { output } = captureConsole(() => - withEnv('CODEX_HOME', codexHome, () => install(true, 'codex')) + withCodexEnv(codexHome, () => install(true, 'codex')) ); const plainOutput = stripAnsi(output); @@ -353,13 +375,17 @@ describe('installer migration install integration', { concurrency: false }, () = assert.throws( () => captureConsole(() => - withEnv('CODEX_HOME', codexHome, () => install(true, 'codex')) + withCodexEnv(codexHome, () => install(true, 'codex')) ), /installer migration blocked/ ); assert.equal(fs.readFileSync(path.join(codexHome, 'hooks/gsd-retired-hook.txt'), 'utf8'), 'old gsd hook\n'); assert.equal(fs.existsSync(path.join(codexHome, 'skills')), false); + // #2088: with HOME sandboxed to codexHome via withCodexEnv, Codex's + // canonical skill root ($HOME/.agents/skills) resolves under codexHome + // too — assert nothing was materialized there when the install is blocked. + assert.equal(fs.existsSync(path.join(codexHome, '.agents', 'skills')), false); assert.equal(fs.existsSync(path.join(codexHome, 'gsd-core', 'VERSION')), false); }); @@ -431,7 +457,7 @@ describe('installer migration install integration', { concurrency: false }, () = assert.throws( () => captureConsole(() => withEnv('CLAUDE_CONFIG_DIR', claudeHome, () => - withEnv('CODEX_HOME', codexHome, () => + withCodexEnv(codexHome, () => withWriteFailure(path.join(codexHome, 'gsd-core', 'VERSION'), () => installModule.installAllRuntimes(['claude', 'codex'], true, false) ) diff --git a/tests/installer-migrations.test.cjs b/tests/installer-migrations.test.cjs index e64822c9c..3c01bc2f9 100644 --- a/tests/installer-migrations.test.cjs +++ b/tests/installer-migrations.test.cjs @@ -1618,12 +1618,24 @@ const BUILD_HOOKS_SCRIPT = path.join(__dirname, '..', 'scripts', 'build-hooks.js function withCodexHome(codexHome, fn) { const previousCodexHome = process.env.CODEX_HOME; + // #2088 (ADR-1239 upgrade 3): Codex skills now install to $HOME/.agents/skills + // (os.homedir()-relative, independent of CODEX_HOME). Sandbox HOME (and + // USERPROFILE) to codexHome so in-process installs never write to the + // developer/CI machine's real home directory. + const previousHome = process.env.HOME; + const previousUserProfile = process.env.USERPROFILE; process.env.CODEX_HOME = codexHome; + process.env.HOME = codexHome; + process.env.USERPROFILE = codexHome; try { return fn(); } finally { if (previousCodexHome == null) delete process.env.CODEX_HOME; else process.env.CODEX_HOME = previousCodexHome; + if (previousHome == null) delete process.env.HOME; + else process.env.HOME = previousHome; + if (previousUserProfile == null) delete process.env.USERPROFILE; + else process.env.USERPROFILE = previousUserProfile; } } diff --git a/tests/portability-vocab-drift.test.cjs b/tests/portability-vocab-drift.test.cjs index d95872352..0fdd87711 100644 --- a/tests/portability-vocab-drift.test.cjs +++ b/tests/portability-vocab-drift.test.cjs @@ -47,6 +47,9 @@ const INSTALL_JS_PATH_HELPERS = [ 'resolveOpencodeConfigPath', 'computePathPrefix', 'normalizeInstallRelativePath', + // #2088 (ADR-1239 upgrade 3): resolves the skills-install dir honoring a + // skills-kind `home` override (e.g. Codex → $HOME/.agents/skills). + '_resolveSkillsRootDir', ]; describe('portability-vocab drift guard', () => { diff --git a/tests/profile-output.test.cjs b/tests/profile-output.test.cjs index c848e126b..cc40ba566 100644 --- a/tests/profile-output.test.cjs +++ b/tests/profile-output.test.cjs @@ -325,11 +325,14 @@ describe('generate-dev-preferences command', () => { const result = runGsdTools( ['generate-dev-preferences', '--analysis', analysisPath, '--raw'], tmpDir, - { CODEX_HOME: codexHome, GSD_RUNTIME: 'codex' } + // #2088 (ADR-1239 upgrade 3): Codex skills resolve to $HOME/.agents/skills + // (HOME-relative), so sandbox HOME to keep the dev-preferences write inside + // the temp dir rather than the developer's real ~/.agents/skills. + { CODEX_HOME: codexHome, GSD_RUNTIME: 'codex', HOME: codexHome, USERPROFILE: codexHome } ); assert.ok(result.success, `Failed: ${result.error}`); const out = JSON.parse(result.output); - assert.strictEqual(out.command_path, path.join(codexHome, 'skills', 'gsd-dev-preferences', 'SKILL.md')); + assert.strictEqual(out.command_path, path.join(codexHome, '.agents', 'skills', 'gsd-dev-preferences', 'SKILL.md')); assert.ok(fs.existsSync(out.command_path), 'runtime-aware output should be written'); }); @@ -347,11 +350,12 @@ describe('generate-dev-preferences command', () => { const result = runGsdTools( ['generate-dev-preferences', '--analysis', analysisPath, '--raw'], tmpDir, - { CODEX_HOME: codexHome, GSD_RUNTIME: 'codex-app' } + // #2088: codex-app alias canonicalizes to codex → $HOME/.agents/skills. + { CODEX_HOME: codexHome, GSD_RUNTIME: 'codex-app', HOME: codexHome, USERPROFILE: codexHome } ); assert.ok(result.success, `Failed: ${result.error}`); const out = JSON.parse(result.output); - assert.strictEqual(out.command_path, path.join(codexHome, 'skills', 'gsd-dev-preferences', 'SKILL.md')); + assert.strictEqual(out.command_path, path.join(codexHome, '.agents', 'skills', 'gsd-dev-preferences', 'SKILL.md')); }); test('uses runtime-aware skills dir for cline by default (#782)', () => { From e8c8a5b914d7c2865cef00db85f8f1a07d6d3066 Mon Sep 17 00:00:00 2001 From: Tom Boucher Date: Wed, 8 Jul 2026 22:12:56 -0400 Subject: [PATCH 18/33] docs(changeset): backfill pr 2110 for #2088 changeset Co-Authored-By: Claude Opus 4.8 --- .changeset/2088-eos-codex-declarative-adapter.md | 1 + 1 file changed, 1 insertion(+) diff --git a/.changeset/2088-eos-codex-declarative-adapter.md b/.changeset/2088-eos-codex-declarative-adapter.md index 6099d33d5..e929c3ddc 100644 --- a/.changeset/2088-eos-codex-declarative-adapter.md +++ b/.changeset/2088-eos-codex-declarative-adapter.md @@ -1,4 +1,5 @@ --- type: Changed +pr: 2110 --- **Codex is now driven through the public Host-Integration Interface, with three capability upgrades (ADR-1239 / EoS).** Codex previously installed via hardcoded `runtime === 'codex'`/`isCodex` projection in `bin/install.js`; its `config.toml` / agent-`.toml` / `hooks.json` install now runs through the declarative embedding adapter and descriptor-driven `runtime.hostBehaviors`, with **zero** positive `isCodex` gates and **zero** `runtime === 'codex'` branches remaining (source-guarded). Install/uninstall output stays byte-parity-gated (`tests/fixtures/golden-install-parity/codex.json`). Three Context7-verified upgrades land, each with a test driving the user-reachable surface: (1) **skill root** — GSD skills now install to Codex's canonical `$HOME/.agents/skills` (via a skills-kind `home` override) instead of the deprecated `$CODEX_HOME/skills` fallback, and pre-move installs are migrated (stale `~/.codex/skills/gsd-*` cleaned on both install and uninstall, user-owned content preserved); (2) **hook events** — GSD registers the six documented Codex lifecycle events it previously skipped (`PreToolUse`, `PermissionRequest`, `PreCompact`, `PostCompact`, `SubagentStop`, `UserPromptSubmit`, in addition to the existing `SessionStart`/`SubagentStart`/`Stop`/`PostToolUse`) in `hooks.json`, so `gsd-context-monitor` fires at the same points as in Claude Code, and the descriptor `extendedHookEvents` is reconciled from `[]` to the schema-valid wired subset; (3) **dispatch tuning** — `[agents] max_depth = 1` is written explicitly into the managed `config.toml` block to pin the negotiated `dispatch.maxDepth: 1` axis (`degradationFor` flattens GSD-hosted waves to single-level), and `validateCodexConfigSchema` now permits a known-scalar-only `[agents]` AgentsToml table (coexisting with the flattened `[agents.gsd-*]` role sub-tables) while still rejecting the `[[agents]]` and unknown-key break-forms from #2760. (#2088) From 8b99f4f3c30c2dd0ad8d12dcbe50983c1cc41161 Mon Sep 17 00:00:00 2001 From: Tom Boucher Date: Wed, 8 Jul 2026 22:49:25 -0400 Subject: [PATCH 19/33] fix(#2088): weight install-heavy test files so they spread across chunks MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The targeted CI lane runs changed files UNSHARDED; #2088 touched 13 install-heavy test files that all landed in one chunk, blowing the 600s per-chunk backstop on the slow Windows runner (pure slowness, not a leak — per run-tests.cjs's own comment). Weight install*/codex-* files (~10x a unit file) toward the per-chunk budget so they spread across chunks instead of clustering; light-file chunking is unchanged (weight 1). Adds harness regression tests (heavy split vs light control). Co-Authored-By: Claude Opus 4.8 --- scripts/run-tests.cjs | 22 +++++++++++++++++++- tests/run-tests-harness.test.cjs | 35 ++++++++++++++++++++++++++++++++ 2 files changed, 56 insertions(+), 1 deletion(-) diff --git a/scripts/run-tests.cjs b/scripts/run-tests.cjs index e52c7fe92..e03ab8a98 100644 --- a/scripts/run-tests.cjs +++ b/scripts/run-tests.cjs @@ -577,6 +577,23 @@ function main() { const MAX_FILES_PER_CHUNK = process.env.RUN_TESTS_MAX_FILES_PER_CHUNK ? Number(process.env.RUN_TESTS_MAX_FILES_PER_CHUNK) : 60; + // #2088: file COUNT is a poor proxy for a chunk's wall-clock — install-heavy + // files (real installs; install-minimal-hooks.test.cjs alone runs ~250 cases + // doing dozens of installs) are ~10× a unit file. When several land in the SAME + // chunk — e.g. a PR touching the whole install surface, whose *targeted* lane is + // unsharded (13 install-heavy files → one chunk) — that chunk blows the 600s + // backstop while unit-only chunks finish in seconds. WEIGHT install-heavy files + // so they fill a chunk's budget faster and therefore SPREAD across chunks + // instead of clustering. Light files keep weight 1, so pure-unit chunking (and + // its harness tests) is byte-for-byte unchanged. `MAX_FILES_PER_CHUNK` is now a + // per-chunk WEIGHT budget (backwards-compatible: it equals the file count when + // every file is light). Tune via RUN_TESTS_HEAVY_FILE_WEIGHT; classify via the + // basename prefix (install*/installer*/codex-* are the real install-heavy suites). + const HEAVY_TEST_RE = /^(?:install|codex-)/; + const HEAVY_FILE_WEIGHT = process.env.RUN_TESTS_HEAVY_FILE_WEIGHT + ? Number(process.env.RUN_TESTS_HEAVY_FILE_WEIGHT) + : 12; + const fileWeight = (f) => (HEAVY_TEST_RE.test(basename(f)) ? HEAVY_FILE_WEIGHT : 1); // node:test does not exit until the event loop drains. A unit test that leaks // an open handle (un-terminated Worker, un-killed child_process, ref'd timer) @@ -595,18 +612,21 @@ function main() { const chunks = []; let current = []; let currentLen = FIXED_OVERHEAD; + let currentWeight = 0; for (const file of selected) { const add = file.length + 1; // +1 for the inter-arg separator if ( current.length > 0 && - (currentLen + add > MAX_CMDLINE_CHARS || current.length >= MAX_FILES_PER_CHUNK) + (currentLen + add > MAX_CMDLINE_CHARS || currentWeight >= MAX_FILES_PER_CHUNK) ) { chunks.push(current); current = []; currentLen = FIXED_OVERHEAD; + currentWeight = 0; } current.push(file); currentLen += add; + currentWeight += fileWeight(file); // heavy install files count for more } if (current.length > 0) chunks.push(current); diff --git a/tests/run-tests-harness.test.cjs b/tests/run-tests-harness.test.cjs index 6a1310676..7310ff42a 100644 --- a/tests/run-tests-harness.test.cjs +++ b/tests/run-tests-harness.test.cjs @@ -389,6 +389,41 @@ test('ambient GSD workstream vars are stripped by the runner', () => { `expected final file-count chunking marker in stderr; STDERR:\n${r.stderr}`, ); }); + + // #2088: install-heavy files (real installs) are weighted so they never all + // land in one chunk — otherwise the unsharded targeted lane packs the whole + // install surface into a single chunk that blows the 600s per-chunk backstop + // on the slow Windows runner. + test('install-heavy files carry more weight so they SPREAD across chunks (#2088)', () => { + // 4 install-* files at weight 3 = 12 against a 6-weight cap → 2 chunks + // (2 heavy each). The whole install load is never in a single chunk. + const heavy = Array.from({ length: 4 }, (_, i) => `install-weighttest-${i}.test.cjs`); + seed(tmpDir, heavy); + const rh = runHarness(tmpDir, [], { + RUN_TESTS_MAX_CMDLINE_CHARS: '100000', + RUN_TESTS_MAX_FILES_PER_CHUNK: '6', + RUN_TESTS_HEAVY_FILE_WEIGHT: '3', + }); + assert.strictEqual(rh.status, 0, `heavy: expected zero exit; STDERR:\n${rh.stderr}`); + assert.match(rh.stderr, /run-tests: chunk 1\/2 — 2 files/, `install-heavy files must split into 2 chunks; STDERR:\n${rh.stderr}`); + assert.match(rh.stderr, /run-tests: chunk 2\/2 — 2 files/, `STDERR:\n${rh.stderr}`); + }); + + test('light files of the same count stay in ONE chunk — split is weight-driven, not count-driven (#2088)', () => { + // Same file COUNT (4) but LIGHT (weight 1 each): 4 < 6 cap → a single + // chunk. Proves the split above is driven by install-heavy WEIGHT. + const light = Array.from({ length: 4 }, (_, i) => `lightweighttest-${i}.test.cjs`); + seed(tmpDir, light); + const rl = runHarness(tmpDir, [], { + RUN_TESTS_MAX_CMDLINE_CHARS: '100000', + RUN_TESTS_MAX_FILES_PER_CHUNK: '6', + RUN_TESTS_HEAVY_FILE_WEIGHT: '3', + }); + assert.strictEqual(rl.status, 0, `light: expected zero exit; STDERR:\n${rl.stderr}`); + // A single chunk emits NO `chunk N/M` split marker (it only prints when + // chunks.length > 1), so its absence proves the 4 light files stayed together. + assert.doesNotMatch(rl.stderr, /run-tests: chunk \d+\/\d+ — /, `4 light files must stay in one chunk (no split marker); STDERR:\n${rl.stderr}`); + }); }); describe('shard partitioning CLI (#1212)', () => { From c5e5211db04bb5260435bd50cb25eff0fc453cf2 Mon Sep 17 00:00:00 2001 From: Tom Boucher Date: Thu, 9 Jul 2026 00:21:28 -0400 Subject: [PATCH 20/33] test(#2089): add cursor EoS migration test scaffolding (red) --- tests/cursor-dispatch-upgrade.test.cjs | 71 ++++++++ tests/cursor-hook-bus-upgrade.test.cjs | 181 +++++++++++++++++++++ tests/cursor-imperative-reference.test.cjs | 126 ++++++++++++++ 3 files changed, 378 insertions(+) create mode 100644 tests/cursor-dispatch-upgrade.test.cjs create mode 100644 tests/cursor-hook-bus-upgrade.test.cjs create mode 100644 tests/cursor-imperative-reference.test.cjs diff --git a/tests/cursor-dispatch-upgrade.test.cjs b/tests/cursor-dispatch-upgrade.test.cjs new file mode 100644 index 000000000..f99d8d608 --- /dev/null +++ b/tests/cursor-dispatch-upgrade.test.cjs @@ -0,0 +1,71 @@ +'use strict'; + +/** + * cursor dispatch UPGRADE — ADR-1239 / #2089 AC4b. + * + * Proves GSD's wave-based execution drives Cursor's native named/background + * nested subagent dispatch (background:true, backgroundDispatch:true, + * nested:true, maxDepth:2) instead of flattening to inline sequential calls. + * + * Cite: + * https://cursor.com/docs/subagents — named + background dispatch + * https://cursor.com/docs/sdk/typescript — nested subagent depth-2 constraint + */ + +const { test } = require('node:test'); +const assert = require('node:assert/strict'); +const fs = require('node:fs'); +const path = require('node:path'); + +const { shouldFlattenDispatch } = require('../gsd-core/bin/lib/host-integration.cjs'); + +const CUR_CAP = JSON.parse( + fs.readFileSync(path.join(__dirname, '..', 'capabilities', 'cursor', 'capability.json'), 'utf8'), +); +const CUR_DISPATCH = CUR_CAP.runtime.hostIntegration.dispatch; + +// -- AC4b: cursor dispatch axes ---------------------------------------------- + +test('cursor dispatch declares namedDispatch + nested + background + backgroundDispatch', () => { + assert.equal(CUR_DISPATCH.namedDispatch, true, + 'cite https://cursor.com/docs/subagents — named subagent invocation'); + assert.equal(CUR_DISPATCH.nested, true, + 'cite https://cursor.com/docs/sdk/typescript — nested subagents'); + assert.equal(CUR_DISPATCH.background, true, + 'cite https://cursor.com/docs/subagents — background dispatch'); + assert.equal(CUR_DISPATCH.backgroundDispatch, true, + 'cite https://cursor.com/docs/subagents FAQ — subagents can launch child subagents'); +}); + +test('cursor dispatch respects maxDepth: 2 (the documented constraint)', () => { + assert.equal(CUR_DISPATCH.maxDepth, 2, + 'cite https://cursor.com/docs/sdk/typescript — "a subagent launched by another subagent can\'t launch further"'); +}); + +// -- AC4b: shouldFlattenDispatch returns false (NOT force-flattened) ---------- + +test('shouldFlattenDispatch(cursor) is false — GSD uses native background dispatch', () => { + assert.equal(shouldFlattenDispatch(CUR_DISPATCH), false, + 'cursor has background:true + backgroundDispatch:true → GSD must NOT force-flatten'); +}); + +test('pre-upgrade cursor axes (background:false) DID force-flatten', () => { + const preUpgrade = { ...CUR_DISPATCH, background: false, backgroundDispatch: 'undocumented' }; + assert.equal(shouldFlattenDispatch(preUpgrade), true, + 'pre-upgrade cursor (no background dispatch) was force-flattened — the behavioral change #2089 lands'); +}); + +test('shouldFlattenDispatch is true when backgroundDispatch is false (depth-2 but no bg dispatch)', () => { + const noBgDispatch = { ...CUR_DISPATCH, backgroundDispatch: false }; + assert.equal(shouldFlattenDispatch(noBgDispatch), true, + 'background without backgroundDispatch still flattens (the #853 rule)'); +}); + +// -- AC4b: boundary — maxDepth 2 is the discriminator vs unbounded ----------- + +test('maxDepth 2 is the documented constraint (not -1 unbounded)', () => { + assert.notEqual(CUR_DISPATCH.maxDepth, -1, + 'cursor is NOT unbounded — depth-2 is the documented hard limit'); + assert.ok(CUR_DISPATCH.maxDepth > 0 && CUR_DISPATCH.maxDepth <= 2, + 'maxDepth must be a positive integer ≤ 2 per cursor docs'); +}); diff --git a/tests/cursor-hook-bus-upgrade.test.cjs b/tests/cursor-hook-bus-upgrade.test.cjs new file mode 100644 index 000000000..23ca22e4d --- /dev/null +++ b/tests/cursor-hook-bus-upgrade.test.cjs @@ -0,0 +1,181 @@ +'use strict'; + +/** + * cursor hook-bus UPGRADE — ADR-1239 / #2089 AC4a. + * + * Proves the expanded hook-bus coverage: GSD registers for subagentStart, + * subagentStop, preToolUse, and stop IN ADDITION to the baseline sessionStart + * and postToolUse. Cite: https://cursor.com/docs/hooks + * + * Tests the descriptor-driven adapter module (pure) + the reconcile behavior + * (all 6 managed events in the generated hooks.json). + */ + +const { test } = require('node:test'); +const assert = require('node:assert/strict'); +const fs = require('node:fs'); +const path = require('node:path'); +const os = require('node:os'); + +const { + CURSOR_HOOK_EVENTS, + CURSOR_EVENT_SCRIPT_MAP, + resolveManagedHookEvents, + resolveHookScripts, +} = require('../gsd-core/bin/lib/host-integration-adapters/imperative-hook-bus.cjs'); + +const { + reconcileCursorHooksJson, + GSD_CURSOR_HOOK_MARKER, +} = require('../bin/install.js'); + +const { cleanup } = require('./helpers.cjs'); + +const CUR_CAP = JSON.parse( + fs.readFileSync(path.join(__dirname, '..', 'capabilities', 'cursor', 'capability.json'), 'utf8'), +); + +const EXPECTED_EVENTS = [ + 'sessionStart', + 'postToolUse', + 'preToolUse', + 'stop', + 'subagentStart', + 'subagentStop', +]; + +// -- AC4a: the adapter declares all 6 managed events ------------------------- + +test('CURSOR_HOOK_EVENTS contains all 6 managed events', () => { + for (const ev of EXPECTED_EVENTS) { + assert.ok(CURSOR_HOOK_EVENTS.includes(ev), + `CURSOR_HOOK_EVENTS must include ${ev}`); + } + assert.equal(CURSOR_HOOK_EVENTS.length, 6, + 'exactly 6 managed events (no extras)'); +}); + +test('CURSOR_EVENT_SCRIPT_MAP maps every event to a script', () => { + for (const ev of EXPECTED_EVENTS) { + const script = CURSOR_EVENT_SCRIPT_MAP[ev]; + assert.ok(typeof script === 'string' && script.endsWith('.js'), + `${ev} must map to a .js script, got: ${script}`); + } +}); + +test('descriptor managedHookEvents matches the adapter event list', () => { + const declared = CUR_CAP.runtime.hostBehaviors.managedHookEvents; + assert.deepEqual(declared.sort(), [...EXPECTED_EVENTS].sort(), + 'descriptor managedHookEvents must match the 6-event managed set'); +}); + +// -- AC4a: resolveManagedHookEvents + resolveHookScripts --------------------- + +test('resolveManagedHookEvents returns all 6 from the descriptor list', () => { + const resolved = resolveManagedHookEvents(CUR_CAP.runtime.hostBehaviors.managedHookEvents); + assert.equal(resolved.length, 6); + for (const ev of EXPECTED_EVENTS) { + assert.ok(resolved.includes(ev), `resolveManagedHookEvents must include ${ev}`); + } +}); + +test('resolveManagedHookEvents filters unknown events (fail-closed)', () => { + const resolved = resolveManagedHookEvents(['sessionStart', 'bogusEvent', 'stop']); + assert.deepEqual([...resolved].sort(), ['sessionStart', 'stop']); +}); + +test('resolveManagedHookEvents falls back to full set when descriptor is empty', () => { + const resolved = resolveManagedHookEvents(null); + assert.equal(resolved.length, 6); +}); + +test('resolveHookScripts returns a script for every managed event', () => { + const scripts = resolveHookScripts(EXPECTED_EVENTS); + assert.equal(scripts.length, 6); + for (const s of scripts) { + assert.ok(s.startsWith('gsd-cursor-') && s.endsWith('.js'), + `script must follow gsd-cursor-*.js convention: ${s}`); + } +}); + +// -- AC4a: hook scripts exist on disk --------------------------------------- + +test('all 6 hook scripts exist under hooks/', () => { + for (const ev of EXPECTED_EVENTS) { + const script = CURSOR_EVENT_SCRIPT_MAP[ev]; + const scriptPath = path.join(__dirname, '..', 'hooks', script); + assert.ok(fs.existsSync(scriptPath), + `hook script must exist: hooks/${script} (event: ${ev})`); + } +}); + +// -- AC4a: reconcile generates hooks.json with all 6 events ------------------ + +test('reconcileCursorHooksJson writes all 6 managed events into hooks.json', (t) => { + const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-cursor-hook-bus-')); + t.after(() => cleanup(tmpDir)); + try { + const hooksJsonPath = path.join(tmpDir, 'hooks.json'); + const managedEntries = {}; + for (const ev of EXPECTED_EVENTS) { + managedEntries[ev] = { + type: 'command', + command: `node /fake/${ev}.js`, + [GSD_CURSOR_HOOK_MARKER]: true, + }; + } + const result = reconcileCursorHooksJson(hooksJsonPath, managedEntries); + assert.ok(result.changed, 'first write must report changed=true'); + + const written = JSON.parse(fs.readFileSync(hooksJsonPath, 'utf8')); + const hookTable = written.hooks; + assert.ok(hookTable && typeof hookTable === 'object'); + for (const ev of EXPECTED_EVENTS) { + assert.ok(Array.isArray(hookTable[ev]), + `hooks.json must have a ${ev} array`); + assert.equal(hookTable[ev].length, 1, + `${ev} must have exactly 1 managed entry`); + assert.equal(hookTable[ev][0][GSD_CURSOR_HOOK_MARKER], true, + `${ev} entry must carry the GSD managed marker`); + } + } finally { + cleanup(tmpDir); + } +}); + +test('reconcileCursorHooksJson preserves user entries across all 6 events', (t) => { + const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-cursor-hook-bus-')); + t.after(() => cleanup(tmpDir)); + try { + const hooksJsonPath = path.join(tmpDir, 'hooks.json'); + // Seed with user-owned entries in two events. + const seed = { + version: 1, + hooks: { + sessionStart: [{ type: 'command', command: 'user-start.sh' }], + preToolUse: [{ type: 'command', command: 'user-pre.sh' }], + }, + }; + fs.writeFileSync(hooksJsonPath, JSON.stringify(seed, null, 2) + '\n'); + + const managedEntries = {}; + for (const ev of EXPECTED_EVENTS) { + managedEntries[ev] = { + type: 'command', + command: `node /gsd/${ev}.js`, + [GSD_CURSOR_HOOK_MARKER]: true, + }; + } + reconcileCursorHooksJson(hooksJsonPath, managedEntries); + + const written = JSON.parse(fs.readFileSync(hooksJsonPath, 'utf8')); + // sessionStart: 1 user + 1 managed + assert.equal(written.hooks.sessionStart.length, 2); + // preToolUse: 1 user + 1 managed + assert.equal(written.hooks.preToolUse.length, 2); + // postToolUse: 1 managed only + assert.equal(written.hooks.postToolUse.length, 1); + } finally { + cleanup(tmpDir); + } +}); diff --git a/tests/cursor-imperative-reference.test.cjs b/tests/cursor-imperative-reference.test.cjs new file mode 100644 index 000000000..a13f2970a --- /dev/null +++ b/tests/cursor-imperative-reference.test.cjs @@ -0,0 +1,126 @@ +// allow-test-rule: AC2 requires asserting no `runtime === 'cursor'` string-equality branch remains in bin/install.js/src — the descriptor-migration contract is a property of the source text, so a source-grep is the only faithful check (#2089) +'use strict'; + +/** + * cursor imperative reference host — ADR-1239 Phase D / #2089 (EoS/cursor). + * + * Proves cursor 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, that the Context7- + * verified dispatch UPGRADE (named/background nested subagents) changes + * `shouldFlattenDispatch`, and that the migration retired the hardcoded + * `runtime === 'cursor'` / `isCursor` branches (folded into descriptor-driven + * `runtime.hostBehaviors`). + */ + +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, + shouldFlattenDispatch, + PROFILE_BASELINES, + UNDOCUMENTED, +} = require('../gsd-core/bin/lib/host-integration.cjs'); + +const CUR_CAP = JSON.parse( + fs.readFileSync(path.join(__dirname, '..', 'capabilities', 'cursor', 'capability.json'), 'utf8'), +); +const CUR_AXES = CUR_CAP.runtime.hostIntegration; + +// -- AC2: driven through the public interface (imperative adapter) ----------- + +test('createImperativeAdapter classifies cursor as imperative + composes the registry', () => { + const adapter = createImperativeAdapter({ runtime: 'cursor' }); + assert.equal(adapter.kind, 'imperative'); + assert.equal(adapter.runtime, 'cursor'); + assert.ok(adapter.registry && typeof adapter.registry === 'object'); + assert.equal(typeof adapter.install, 'function'); + assert.equal(typeof adapter.uninstall, 'function'); +}); + +test('cursor axes classify as the programmatic-cli reference profile', () => { + assert.equal(profileOf(CUR_AXES), 'programmatic-cli'); +}); + +// -- AC3: all axes populated + validated ------------------------------------- + +test('cursor descriptor declares all 8 axes + 6 dispatch sub-axes (no undocumented)', () => { + assert.equal(CUR_AXES.embeddingMode, 'imperative'); + assert.equal(CUR_AXES.commandSurface, 'slash-file'); + assert.equal(CUR_AXES.modelMode, 'passive'); + assert.equal(CUR_AXES.hookBus, 'host'); + assert.equal(CUR_AXES.stateIO, 'filesystem'); + assert.equal(CUR_AXES.transport, 'mcp'); + assert.equal(CUR_AXES.runtime, 'node'); + const d = CUR_AXES.dispatch; + assert.equal(d.namedDispatch, true); + assert.equal(d.nested, true); + assert.equal(d.maxDepth, 2); + assert.equal(d.background, true); + assert.equal(d.subagentToolkit, 'full'); + assert.equal(d.backgroundDispatch, true); +}); + +// -- AC4b: the Context3-verified dispatch UPGRADE (named/background nested) --- + +test('cursor descriptor declares background dispatch true/true + nested + maxDepth 2', () => { + assert.equal(CUR_AXES.dispatch.background, true); + assert.equal(CUR_AXES.dispatch.backgroundDispatch, true); + assert.equal(CUR_AXES.dispatch.nested, true); + assert.equal(CUR_AXES.dispatch.maxDepth, 2, 'cite https://cursor.com/docs/sdk/typescript'); +}); + +test('dispatch UPGRADE changes shouldFlattenDispatch: false now (may background), true for pre-upgrade axes', () => { + assert.equal(shouldFlattenDispatch(CUR_AXES.dispatch), false, + 'with background:true+backgroundDispatch:true, GSD must NOT force-flatten cursor dispatch'); + const preUpgrade = { ...CUR_AXES.dispatch, background: false, backgroundDispatch: 'undocumented' }; + assert.equal(shouldFlattenDispatch(preUpgrade), true, + 'pre-upgrade (background:false) cursor was force-flattened — this is the behavioral change #2089 lands'); +}); + +// -- AC5: negotiation fails CLOSED on a corrupted descriptor ------------------ + +test('negotiateHostCapabilities never throws for cursor, even fully corrupted', () => { + assert.doesNotThrow(() => negotiateHostCapabilities({})); + assert.doesNotThrow(() => negotiateHostCapabilities({ ...CUR_AXES, embeddingMode: UNDOCUMENTED })); + assert.doesNotThrow(() => negotiateHostCapabilities({ ...CUR_AXES, embeddingMode: 'future-unknown' })); +}); + +test('a partial/empty cursor descriptor degrades to the safe floor, not the programmatic-cli baseline', () => { + const result = negotiateHostCapabilities({}); + assert.equal(result.effective.embeddingMode, 'declarative', 'omitted embeddingMode degrades closed'); + assert.equal(result.effective.hookBus, 'none'); + assert.notDeepEqual(result.effective, PROFILE_BASELINES['programmatic-cli']); + assert.ok(result.warnings.length > 0); +}); + +// -- AC2: the hardcoded branches are retired --------------------------------- + +test('cursor descriptor declares runtime.hostBehaviors (the folded-in behaviors)', () => { + const hb = CUR_CAP.runtime.hostBehaviors; + assert.ok(hb && typeof hb === 'object'); + assert.equal(hb.reapplyCommand, 'gsd-update --reapply (mention the skill name)'); + assert.equal(hb.frontmatterDialect, 'cursor'); + assert.equal(hb.hooksJsonSurface, true); + assert.equal(hb.skipSharedHooksInstall, true); + assert.equal(hb.reportCommandsDir, true); + assert.ok(Array.isArray(hb.managedHookEvents) && hb.managedHookEvents.length >= 6, + 'managedHookEvents must list at least 6 events (AC4a)'); +}); + +test('no `runtime === "cursor"` string-equality branch remains in the install source (AC2)', () => { + const strip = (src) => src + .replace(/\/\*[\s\S]*?\*\//g, '') + .replace(/\/\/[^\r\n]*/g, '') + .replace(/`[^`]*`/g, ''); + for (const rel of ['bin/install.js', 'src/install-engine.cts', 'src/runtime-artifact-conversion.cts']) { + const src = fs.readFileSync(path.join(__dirname, '..', rel), 'utf8'); + const offenders = strip(src).match(/runtime\s*[!=]==\s*'cursor'/g) || []; + assert.deepEqual(offenders, [], `AC2: no hardcoded runtime==='cursor' branch may remain in ${rel}; found: ${offenders.join(', ')}`); + } +}); From b0d985ccb3acdf06186cc920ab364990b944ae0b Mon Sep 17 00:00:00 2001 From: Tom Boucher Date: Thu, 9 Jul 2026 00:21:39 -0400 Subject: [PATCH 21/33] feat(#2089): migrate cursor onto imperative adapter + hook-bus/dispatch upgrades --- bin/install.js | 74 ++++++--- capabilities/cursor/capability.json | 17 ++ gsd-core/bin/lib/capability-registry.cjs | 34 ++++ hooks/gsd-cursor-pre-tool.js | 76 +++++++++ hooks/gsd-cursor-stop.js | 48 ++++++ hooks/gsd-cursor-subagent-start.js | 50 ++++++ hooks/gsd-cursor-subagent-stop.js | 40 +++++ .../imperative-hook-bus.cts | 153 ++++++++++++++++++ src/runtime-hooks-surface.cts | 74 ++++++--- 9 files changed, 518 insertions(+), 48 deletions(-) create mode 100644 hooks/gsd-cursor-pre-tool.js create mode 100644 hooks/gsd-cursor-stop.js create mode 100644 hooks/gsd-cursor-subagent-start.js create mode 100644 hooks/gsd-cursor-subagent-stop.js create mode 100644 src/host-integration-adapters/imperative-hook-bus.cts diff --git a/bin/install.js b/bin/install.js index 78af35018..f36debfd0 100755 --- a/bin/install.js +++ b/bin/install.js @@ -255,12 +255,30 @@ const GSD_COPILOT_SESSION_HOOK_PWSH = // Cursor reads hook configs from /.cursor/hooks.json (local) or // ~/.cursor/hooks.json (global) with the shape { version: 1, hooks: { : [...] } }. // Events use camelCase: sessionStart, postToolUse, preToolUse, etc. -// A `command` hook entry runs an external script. GSD registers two managed hooks: -// sessionStart → gsd-cursor-session-start.js (context injection) -// postToolUse → gsd-cursor-post-tool.js (STATE.md update monitor) +// A `command` hook entry runs an external script. GSD registers six managed hooks +// (AC4a upgrade, #2089 — ADR-1239): +// sessionStart → gsd-cursor-session-start.js (context injection) +// postToolUse → gsd-cursor-post-tool.js (STATE.md update monitor) +// preToolUse → gsd-cursor-pre-tool.js (write-path guard) +// stop → gsd-cursor-stop.js (verify-work reminder) +// subagentStart → gsd-cursor-subagent-start.js (subagent context injection) +// subagentStop → gsd-cursor-subagent-stop.js (subagent completion reminder) // Cursor docs: https://cursor.com/docs/hooks const GSD_CURSOR_SESSION_HOOK_SCRIPT = 'gsd-cursor-session-start.js'; const GSD_CURSOR_POST_TOOL_HOOK_SCRIPT = 'gsd-cursor-post-tool.js'; +const GSD_CURSOR_PRE_TOOL_HOOK_SCRIPT = 'gsd-cursor-pre-tool.js'; +const GSD_CURSOR_STOP_HOOK_SCRIPT = 'gsd-cursor-stop.js'; +const GSD_CURSOR_SUBAGENT_START_HOOK_SCRIPT = 'gsd-cursor-subagent-start.js'; +const GSD_CURSOR_SUBAGENT_STOP_HOOK_SCRIPT = 'gsd-cursor-subagent-stop.js'; +// All GSD-managed Cursor hook scripts (used by uninstall cleanup). +const GSD_CURSOR_HOOK_SCRIPTS = [ + GSD_CURSOR_SESSION_HOOK_SCRIPT, + GSD_CURSOR_POST_TOOL_HOOK_SCRIPT, + GSD_CURSOR_PRE_TOOL_HOOK_SCRIPT, + GSD_CURSOR_STOP_HOOK_SCRIPT, + GSD_CURSOR_SUBAGENT_START_HOOK_SCRIPT, + GSD_CURSOR_SUBAGENT_STOP_HOOK_SCRIPT, +]; // Marker comment embedded in managed hook entries so GSD can find+remove them. const GSD_CURSOR_HOOK_MARKER = 'gsd-managed'; @@ -6969,17 +6987,20 @@ function uninstall(isGlobal, runtime = DEFAULT_RUNTIME) { } } - // 1b-cursor. Non-layout Cursor side-effects (issue #777): remove GSD-managed - // hook entries from hooks.json and clean up the managed hook scripts. - if (isCursor) { + // 1b-cursor. Descriptor-driven hook-bus cleanup (ADR-1239 / #2089): remove + // GSD-managed hook entries from hooks.json and clean up the managed hook + // scripts. Gated by the hostBehaviors.hooksJsonSurface descriptor axis, not a + // hardcoded `isCursor` branch. + if (_hostBehaviors(runtime).hooksJsonSurface) { const hooksJsonCleanup = removeCursorHooksJson(targetDir); if (hooksJsonCleanup.changed) { removedCount++; console.log(` ${green}✓${reset} Removed GSD-managed Cursor hooks from hooks.json`); } - // Remove the managed hook scripts (session-start + post-tool). + // Remove all GSD-managed hook scripts (sessionStart, postToolUse, preToolUse, + // stop, subagentStart, subagentStop — AC4a, #2089). const hooksDir = path.join(targetDir, 'hooks'); - for (const script of [GSD_CURSOR_SESSION_HOOK_SCRIPT, GSD_CURSOR_POST_TOOL_HOOK_SCRIPT]) { + for (const script of GSD_CURSOR_HOOK_SCRIPTS) { const p = path.join(hooksDir, script); try { if (fs.existsSync(p)) { @@ -8245,11 +8266,9 @@ function reportLocalPatches(configDir, runtime = DEFAULT_RUNTIME) { if (meta.files && meta.files.length > 0) { const reapplyCommand = _hostBehaviors(runtime).reapplyCommand ? _hostBehaviors(runtime).reapplyCommand - : runtime === 'cursor' - ? 'gsd-update --reapply (mention the skill name)' - : runtime === 'kimi' - ? '/skill:gsd-update --reapply' - : '/gsd-update --reapply'; + : runtime === 'kimi' + ? '/skill:gsd-update --reapply' + : '/gsd-update --reapply'; console.log(''); console.log(' ' + yellow + 'Local patches detected' + reset + ' (from v' + meta.from_version + '):'); for (const f of meta.files) { @@ -8877,8 +8896,9 @@ function install(isGlobal, runtime = DEFAULT_RUNTIME, options = {}) { } } - // Cursor only: also report the commands/ output (#785 — Cursor 1.6 slash commands) - if (isCursor) { + // Descriptor-driven commands/ output report (#785 — Cursor 1.6 slash commands). + // Gated by hostBehaviors.reportCommandsDir, not a hardcoded `isCursor` branch (#2089). + if (_hostBehaviors(runtime).reportCommandsDir) { const commandsDir = path.join(targetDir, 'commands'); if (fs.existsSync(commandsDir)) { const cmdCount = fs.readdirSync(commandsDir) @@ -9184,8 +9204,6 @@ function install(isGlobal, runtime = DEFAULT_RUNTIME, options = {}) { content = convertClaudeAgentToCopilotAgent(content, isGlobal); } else if (isAntigravity) { content = convertClaudeAgentToAntigravityAgent(content, isGlobal); - } else if (isCursor) { - content = convertClaudeAgentToCursorAgent(content); } else if (isWindsurf) { content = convertClaudeAgentToWindsurfAgent(content); } else if (isAugment) { @@ -9266,7 +9284,9 @@ function install(isGlobal, runtime = DEFAULT_RUNTIME, options = {}) { // its native plugin adapter (#1914, installed above under plugins/gsd-core.js) // spawns the staged hooks/*.js scripts via OpenCode's event bus and needs both // them and the CommonJS package.json marker written below. - if (!isCodex && !isCopilot && !isCursor && !isWindsurf && !isTrae && !isCline && !isKimi && !isKilo && !isZcode) { + // #2089: Cursor's exclusion is now descriptor-driven via + // hostBehaviors.skipSharedHooksInstall (was hardcoded !isCursor). + if (!isCodex && !isCopilot && _hostBehaviors(runtime).skipSharedHooksInstall !== true && !isWindsurf && !isTrae && !isCline && !isKimi && !isKilo && !isZcode) { // Write package.json to force CommonJS mode for GSD scripts // Prevents "require is not defined" errors when project has "type": "module" // Node.js walks up looking for package.json - this stops inheritance from project @@ -9357,7 +9377,8 @@ function install(isGlobal, runtime = DEFAULT_RUNTIME, options = {}) { // Gate hooks/lib/ install on the same runtimes that receive hooks (see line ~8702). // Codex/Copilot/Cursor/Windsurf/Trae/Cline do not use the shared hooks/lib/ helpers - // (Cursor uses standalone .js hook scripts registered via hooks.json; Codex uses + // (Cursor uses standalone .js hook scripts registered via hooks.json — gated + // descriptor-driven via hostBehaviors.skipSharedHooksInstall, #2089; Codex uses // hooks.json directly; the others skip hooks entirely); Kilo and ZCode also skip // hooks entirely (hooksSurface:'none' with no plugin surface — #1821). OpenCode // is NOT excluded: its #1914 plugin adapter spawns the staged hooks and requires @@ -9365,7 +9386,7 @@ function install(isGlobal, runtime = DEFAULT_RUNTIME, options = {}) { // helpers — otherwise the Codex comment downstream ("we deliberately do *not* // copy hooks/lib/ for Codex") is contradicted in practice. const hooksLibSrc = path.join(src, 'hooks', 'lib'); - if (!isCodex && !isCopilot && !isCursor && !isWindsurf && !isTrae && !isCline && !isKimi && !isKilo && !isZcode && fs.existsSync(hooksLibSrc)) { + if (!isCodex && !isCopilot && _hostBehaviors(runtime).skipSharedHooksInstall !== true && !isWindsurf && !isTrae && !isCline && !isKimi && !isKilo && !isZcode && fs.existsSync(hooksLibSrc)) { const hooksLibDest = path.join(targetDir, 'hooks', 'lib'); fs.mkdirSync(hooksLibDest, { recursive: true }); copyLibDir(hooksLibSrc, hooksLibDest, GSD_HOOK_LIB_FILES); @@ -9979,11 +10000,13 @@ function install(isGlobal, runtime = DEFAULT_RUNTIME, options = {}) { } if (plan.installSurface === 'cursor-hooks-json') { - // #777: Cursor v2.4+ supports hooks.json. Register sessionStart + postToolUse. - // Hook scripts are copied to /hooks/ and referenced by hooks.json. + // ADR-1239 / #2089: Cursor hooks.json driven by the descriptor-managed hook-bus + // adapter. Registers all 6 managed events (sessionStart, postToolUse, preToolUse, + // stop, subagentStart, subagentStop) via runtime-hooks-surface.cts, which reads + // the event list from the descriptor-driven adapter module. const cursorHookResult = writeCursorHooksJson(targetDir, src, {}); if (cursorHookResult.changed) { - console.log(` ${green}✓${reset} Configured Cursor lifecycle hooks (sessionStart, postToolUse)`); + console.log(` ${green}✓${reset} Configured Cursor lifecycle hooks (sessionStart, postToolUse, preToolUse, stop, subagentStart, subagentStop)`); } else { console.log(` ${green}✓${reset} Cursor lifecycle hooks already up to date`); } @@ -11396,6 +11419,11 @@ module.exports = { mergeGsdAgentsMd, GSD_CURSOR_SESSION_HOOK_SCRIPT, GSD_CURSOR_POST_TOOL_HOOK_SCRIPT, + GSD_CURSOR_PRE_TOOL_HOOK_SCRIPT, + GSD_CURSOR_STOP_HOOK_SCRIPT, + GSD_CURSOR_SUBAGENT_START_HOOK_SCRIPT, + GSD_CURSOR_SUBAGENT_STOP_HOOK_SCRIPT, + GSD_CURSOR_HOOK_SCRIPTS, GSD_CURSOR_HOOK_MARKER, buildCursorHookEntry, isManagedCursorHookEntry, diff --git a/capabilities/cursor/capability.json b/capabilities/cursor/capability.json index b4877e40f..1d58b7100 100644 --- a/capabilities/cursor/capability.json +++ b/capabilities/cursor/capability.json @@ -98,6 +98,23 @@ "stateIO": "filesystem", "transport": "mcp", "runtime": "node" + }, + "hostBehaviors": { + "reapplyCommand": "gsd-update --reapply (mention the skill name)", + "frontmatterDialect": "cursor", + "hooksJsonSurface": true, + "skipSharedHooksInstall": true, + "reportCommandsDir": true, + "skipUpdateBannerCommand": true, + "skipSettingsUi": true, + "managedHookEvents": [ + "sessionStart", + "postToolUse", + "preToolUse", + "stop", + "subagentStart", + "subagentStop" + ] } } } diff --git a/gsd-core/bin/lib/capability-registry.cjs b/gsd-core/bin/lib/capability-registry.cjs index dea955a3b..3a2fac1b3 100644 --- a/gsd-core/bin/lib/capability-registry.cjs +++ b/gsd-core/bin/lib/capability-registry.cjs @@ -1059,6 +1059,23 @@ const capabilities = { "stateIO": "filesystem", "transport": "mcp", "runtime": "node" + }, + "hostBehaviors": { + "reapplyCommand": "gsd-update --reapply (mention the skill name)", + "frontmatterDialect": "cursor", + "hooksJsonSurface": true, + "skipSharedHooksInstall": true, + "reportCommandsDir": true, + "skipUpdateBannerCommand": true, + "skipSettingsUi": true, + "managedHookEvents": [ + "sessionStart", + "postToolUse", + "preToolUse", + "stop", + "subagentStart", + "subagentStop" + ] } } }, @@ -4392,6 +4409,23 @@ const runtimes = { "stateIO": "filesystem", "transport": "mcp", "runtime": "node" + }, + "hostBehaviors": { + "reapplyCommand": "gsd-update --reapply (mention the skill name)", + "frontmatterDialect": "cursor", + "hooksJsonSurface": true, + "skipSharedHooksInstall": true, + "reportCommandsDir": true, + "skipUpdateBannerCommand": true, + "skipSettingsUi": true, + "managedHookEvents": [ + "sessionStart", + "postToolUse", + "preToolUse", + "stop", + "subagentStart", + "subagentStop" + ] } } }, diff --git a/hooks/gsd-cursor-pre-tool.js b/hooks/gsd-cursor-pre-tool.js new file mode 100644 index 000000000..a608255c0 --- /dev/null +++ b/hooks/gsd-cursor-pre-tool.js @@ -0,0 +1,76 @@ +#!/usr/bin/env node +// gsd-hook-version: {{GSD_VERSION}} +// gsd-cursor-pre-tool.js — Cursor preToolUse hook (ADR-1239 / #2089) +// +// Cursor invokes this script before each tool call executes. +// Protocol: JSON from Cursor on stdin; JSON response on stdout. +// +// Input schema (cursor preToolUse): +// { tool_name, tool_input, conversation_id, generation_id, model, +// hook_event_name, cursor_version, workspace_roots, user_email, +// transcript_path } +// +// Output schema (cursor preToolUse): +// { additional_context?: string, block?: boolean, reason?: string } +// +// Behaviour: +// - If a write-class tool targets .planning/, reminds the agent to keep +// STATE.md current before the write proceeds. +// - Fails open: any error silently exits 0 so a hook bug never wedges Cursor. +// +// Cursor docs: https://cursor.com/docs/hooks + +'use strict'; + +const fs = require('fs'); +const path = require('path'); + +const WRITE_TOOL_RE = /write|edit|replace|create|delete|remove|append|apply|patch|insert|mkdir/i; +const PATH_KEY_RE = /^(path|file|file_?path|filepath|target_?path|target|dir|directory|uri|filename)$/i; +const PLANNING_PATH_RE = /(^|[\\/])\.planning([\\/]|$)/; + +let raw = ''; +const stdinTimeout = setTimeout(() => { + process.exit(0); +}, 10000); + +process.stdin.setEncoding('utf8'); +process.stdin.on('data', (chunk) => { raw += chunk; }); +process.stdin.on('end', () => { + clearTimeout(stdinTimeout); + try { + let input; + try { input = JSON.parse(raw || '{}'); } catch { process.stdout.write(JSON.stringify({})); return; } + + const toolName = String( + input.tool_name || input.toolName || '' + ).toLowerCase(); + + const isWrite = WRITE_TOOL_RE.test(toolName); + if (!isWrite) { process.stdout.write(JSON.stringify({})); return; } + + const paths = []; + const walk = (v, depth) => { + if (depth > 5 || paths.length > 64) return; + if (Array.isArray(v)) { for (const x of v) walk(x, depth + 1); return; } + if (v && typeof v === 'object') { + for (const k of Object.keys(v)) { + const val = v[k]; + if (typeof val === 'string' && PATH_KEY_RE.test(k)) paths.push(val); + else walk(val, depth + 1); + } + } + }; + walk(input.tool_input || input.toolInput || {}, 0); + + if (paths.some((p) => PLANNING_PATH_RE.test(p))) { + process.stdout.write(JSON.stringify({ + additional_context: + 'GSD: .planning/ write detected — ensure STATE.md reflects the latest phase and progress after this change.', + })); + return; + } + } catch { /* fall through to empty response */ } + + process.stdout.write(JSON.stringify({})); +}); diff --git a/hooks/gsd-cursor-stop.js b/hooks/gsd-cursor-stop.js new file mode 100644 index 000000000..4c69bbbaf --- /dev/null +++ b/hooks/gsd-cursor-stop.js @@ -0,0 +1,48 @@ +#!/usr/bin/env node +// gsd-hook-version: {{GSD_VERSION}} +// gsd-cursor-stop.js — Cursor stop hook (ADR-1239 / #2089) +// +// Cursor invokes this script when the agent stops responding. +// Protocol: JSON from Cursor on stdin; JSON response on stdout. +// +// Input schema (cursor stop): +// { conversation_id, generation_id, model, hook_event_name, +// cursor_version, workspace_roots, user_email, transcript_path } +// +// Output schema (cursor stop): +// { additional_context?: string } +// +// Behaviour: +// - Reminds the user to verify work if .planning/ is present. +// - Fails open: any error silently exits 0. +// +// Cursor docs: https://cursor.com/docs/hooks + +'use strict'; + +const fs = require('fs'); +const path = require('path'); + +let raw = ''; +const stdinTimeout = setTimeout(() => { + process.exit(0); +}, 10000); + +process.stdin.setEncoding('utf8'); +process.stdin.on('data', (chunk) => { raw += chunk; }); +process.stdin.on('end', () => { + clearTimeout(stdinTimeout); + try { + const statePath = path.join(process.cwd(), '.planning', 'STATE.md'); + if (fs.existsSync(statePath)) { + process.stdout.write(JSON.stringify({ + additional_context: + 'GSD: Agent stopping — run /gsd:verify-work or /gsd:progress to confirm the phase goal is met before ending the session.', + })); + } else { + process.stdout.write(JSON.stringify({})); + } + } catch { + process.stdout.write(JSON.stringify({})); + } +}); diff --git a/hooks/gsd-cursor-subagent-start.js b/hooks/gsd-cursor-subagent-start.js new file mode 100644 index 000000000..ad1e3ca48 --- /dev/null +++ b/hooks/gsd-cursor-subagent-start.js @@ -0,0 +1,50 @@ +#!/usr/bin/env node +// gsd-hook-version: {{GSD_VERSION}} +// gsd-cursor-subagent-start.js — Cursor subagentStart hook (ADR-1239 / #2089) +// +// Cursor invokes this script when a subagent session starts. +// Protocol: JSON from Cursor on stdin; JSON response on stdout. +// +// Input schema (cursor subagentStart): +// { session_id, is_background_agent, conversation_id, generation_id, +// model, hook_event_name, cursor_version, workspace_roots, +// user_email, transcript_path } +// +// Output schema (cursor subagentStart): +// { additional_context?: string } +// +// Behaviour: +// - Injects a brief GSD state reminder so subagents (planner, executor, +// verifier) have the current phase context. +// - Fails open: any error silently exits 0. +// +// Cursor docs: https://cursor.com/docs/hooks + +'use strict'; + +const fs = require('fs'); +const path = require('path'); + +const MSG_PRESENT = + 'GSD: Subagent session started — review .planning/STATE.md for the current phase and any blockers before acting.'; +const MSG_ABSENT = + 'GSD: Subagent session started — no .planning/ workflow found.'; + +let raw = ''; +const stdinTimeout = setTimeout(() => { + process.exit(0); +}, 10000); + +process.stdin.setEncoding('utf8'); +process.stdin.on('data', (chunk) => { raw += chunk; }); +process.stdin.on('end', () => { + clearTimeout(stdinTimeout); + try { + const statePath = path.join(process.cwd(), '.planning', 'STATE.md'); + const statePresent = fs.existsSync(statePath); + const msg = statePresent ? MSG_PRESENT : MSG_ABSENT; + process.stdout.write(JSON.stringify({ additional_context: msg })); + } catch { + process.stdout.write(JSON.stringify({})); + } +}); diff --git a/hooks/gsd-cursor-subagent-stop.js b/hooks/gsd-cursor-subagent-stop.js new file mode 100644 index 000000000..fa5bb6826 --- /dev/null +++ b/hooks/gsd-cursor-subagent-stop.js @@ -0,0 +1,40 @@ +#!/usr/bin/env node +// gsd-hook-version: {{GSD_VERSION}} +// gsd-cursor-subagent-stop.js — Cursor subagentStop hook (ADR-1239 / #2089) +// +// Cursor invokes this script when a subagent session completes. +// Protocol: JSON from Cursor on stdin; JSON response on stdout. +// +// Input schema (cursor subagentStop): +// { session_id, conversation_id, generation_id, model, hook_event_name, +// cursor_version, workspace_roots, user_email, transcript_path } +// +// Output schema (cursor subagentStop): +// { additional_context?: string } +// +// Behaviour: +// - Reminds the orchestrating agent to check the subagent's output. +// - Fails open: any error silently exits 0. +// +// Cursor docs: https://cursor.com/docs/hooks + +'use strict'; + +let raw = ''; +const stdinTimeout = setTimeout(() => { + process.exit(0); +}, 10000); + +process.stdin.setEncoding('utf8'); +process.stdin.on('data', (chunk) => { raw += chunk; }); +process.stdin.on('end', () => { + clearTimeout(stdinTimeout); + try { + process.stdout.write(JSON.stringify({ + additional_context: + 'GSD: Subagent completed — review its output and update .planning/STATE.md if the phase progressed.', + })); + } catch { + process.stdout.write(JSON.stringify({})); + } +}); diff --git a/src/host-integration-adapters/imperative-hook-bus.cts b/src/host-integration-adapters/imperative-hook-bus.cts new file mode 100644 index 000000000..1638a5481 --- /dev/null +++ b/src/host-integration-adapters/imperative-hook-bus.cts @@ -0,0 +1,153 @@ +/** + * Imperative hook-bus adapter — descriptor-driven hooks.json binding + * (ADR-1239 Phase D / #2089). + * + * Generalizes the Cursor-specific `writeCursorHooksJson`/`removeCursorHooksJson` + * into a descriptor-driven hook-bus binding that reads the negotiated `hookBus` + * axis + the host's documented hook-event list (from + * `runtime.hostBehaviors.managedHookEvents`), NOT a hardcoded + * `sessionStart`/`postToolUse` pair. + * + * This module is PURE (no I/O): it resolves the event→script mapping and builds + * the hooks.json entry manifest. The actual file I/O (copying scripts, writing + * hooks.json) stays in `runtime-hooks-surface.cts`, which calls into the pure + * functions exported here. This separation makes the binding testable without a + * filesystem. + * + * Cursor hook-event universe (closed vocabulary per ADR-1239, + * https://cursor.com/docs/hooks): + * sessionStart, sessionEnd, preToolUse, postToolUse, subagentStart, + * subagentStop, beforeShellExecution, afterShellExecution, + * afterMCPExecution, afterFileEdit, preCompact, stop, + * beforeTabFileRead, afterTabFileEdit, workspaceOpen + * + * GSD registers for the 6 events in the portable floor + subagent lifecycle + * (AC4a upgrade, #2089): + * sessionStart, postToolUse, preToolUse, stop, subagentStart, subagentStop + */ +'use strict'; + +/** + * The full set of Cursor hook events GSD can register for. + * Frozen closed vocabulary — adding an event requires updating both this set + * and the event→script mapping below. + */ +export const CURSOR_HOOK_EVENTS = Object.freeze([ + 'sessionStart', + 'postToolUse', + 'preToolUse', + 'stop', + 'subagentStart', + 'subagentStop', +] as const); + +export type CursorHookEvent = (typeof CURSOR_HOOK_EVENTS)[number]; + +/** + * Event → hook-script mapping. Each event maps to a standalone `.js` script + * under `hooks/` that Cursor invokes via `hooks.json`. + * + * Convention: `gsd-cursor-.js`. The script files are authored in + * `hooks/` and copied to `/hooks/` during install by + * `runtime-hooks-surface.cts`. + */ +export const CURSOR_EVENT_SCRIPT_MAP: Readonly> = Object.freeze({ + sessionStart: 'gsd-cursor-session-start.js', + postToolUse: 'gsd-cursor-post-tool.js', + preToolUse: 'gsd-cursor-pre-tool.js', + stop: 'gsd-cursor-stop.js', + subagentStart: 'gsd-cursor-subagent-start.js', + subagentStop: 'gsd-cursor-subagent-stop.js', +}); + +/** + * The GSD-managed marker written into each hooks.json entry so the + * reconcile pass can distinguish GSD-owned entries from user-owned ones. + */ +export const GSD_HOOK_MARKER = 'gsd-managed'; + +/** + * Resolve the managed hook events from a runtime descriptor's + * `hostBehaviors.managedHookEvents` list. Falls back to the full + * `CURSOR_HOOK_EVENTS` set when the descriptor does not declare the list + * (backward-compat for descriptors predating #2089). + * + * Pure: no I/O, never throws. Unknown event names are silently filtered + * (fail-closed — an unrecognized event is never registered). + * + * @param managedHookEvents - the descriptor's `hostBehaviors.managedHookEvents` array + * @returns a deduplicated, validated array of event names + */ +export function resolveManagedHookEvents( + managedHookEvents: readonly string[] | null | undefined, +): readonly string[] { + if (!Array.isArray(managedHookEvents) || managedHookEvents.length === 0) { + return CURSOR_HOOK_EVENTS; + } + const valid = new Set(CURSOR_HOOK_EVENTS); + const seen = new Set(); + const result: string[] = []; + for (const ev of managedHookEvents) { + if (typeof ev === 'string' && valid.has(ev) && !seen.has(ev)) { + seen.add(ev); + result.push(ev); + } + } + return result.length > 0 ? result : CURSOR_HOOK_EVENTS; +} + +/** + * Build the list of hook script files that need to be copied for the given + * managed events. Each event maps to a script via `CURSOR_EVENT_SCRIPT_MAP`. + * + * Pure: returns a deduplicated array of script filenames. + * + * @param events - the managed event names (validated by `resolveManagedHookEvents`) + * @returns array of script filenames (e.g. `['gsd-cursor-session-start.js', ...]`) + */ +export function resolveHookScripts( + events: readonly string[], +): readonly string[] { + const scripts: string[] = []; + const seen = new Set(); + for (const ev of events) { + const script = CURSOR_EVENT_SCRIPT_MAP[ev]; + if (script && !seen.has(script)) { + seen.add(script); + scripts.push(script); + } + } + return scripts; +} + +/** + * Build the hooks.json managed-entry manifest for the given events. + * Each entry is `{ type: 'command', command: , [GSD_HOOK_MARKER]: true }`. + * + * The `command` string is built by the caller (it requires platform-specific + * node-runner resolution from `runtime-hooks-surface.cts`). This function + * receives a pre-built `event → command` map and attaches the marker. + * + * Pure: no I/O. + * + * @param events - the managed event names + * @param commands - a map of event → command string (built by the caller) + * @returns a map of event → managed entry, ready for hooks.json reconciliation + */ +export function buildHookBusEntries( + events: readonly string[], + commands: Readonly>, +): Record { + const entries: Record = {}; + for (const ev of events) { + const cmd = commands[ev]; + if (cmd) { + entries[ev] = { + type: 'command', + command: cmd, + [GSD_HOOK_MARKER]: true, + }; + } + } + return entries; +} diff --git a/src/runtime-hooks-surface.cts b/src/runtime-hooks-surface.cts index cb0151da0..09fc530bd 100644 --- a/src/runtime-hooks-surface.cts +++ b/src/runtime-hooks-surface.cts @@ -80,8 +80,25 @@ const GSD_COPILOT_SESSION_HOOK_PWSH = // --------------------------------------------------------------------------- const GSD_CURSOR_SESSION_HOOK_SCRIPT = 'gsd-cursor-session-start.js'; const GSD_CURSOR_POST_TOOL_HOOK_SCRIPT = 'gsd-cursor-post-tool.js'; +const GSD_CURSOR_PRE_TOOL_HOOK_SCRIPT = 'gsd-cursor-pre-tool.js'; +const GSD_CURSOR_STOP_HOOK_SCRIPT = 'gsd-cursor-stop.js'; +const GSD_CURSOR_SUBAGENT_START_HOOK_SCRIPT = 'gsd-cursor-subagent-start.js'; +const GSD_CURSOR_SUBAGENT_STOP_HOOK_SCRIPT = 'gsd-cursor-subagent-stop.js'; const GSD_CURSOR_HOOK_MARKER = 'gsd-managed'; +// The full set of Cursor hook events GSD manages (AC4a upgrade, #2089). +// Sourced from the descriptor-driven adapter module +// (src/host-integration-adapters/imperative-hook-bus.cts). This replaces the +// hardcoded ['sessionStart', 'postToolUse'] pair with the 6-event managed set. +const CURSOR_MANAGED_EVENTS = [ + 'sessionStart', + 'postToolUse', + 'preToolUse', + 'stop', + 'subagentStart', + 'subagentStop', +]; + // --------------------------------------------------------------------------- // Cline / AGENTS.md constants // --------------------------------------------------------------------------- @@ -976,9 +993,8 @@ function reconcileCursorHooksJson(hooksJsonPath: string, managedEntries: CursorM const hasNestedHooksObject = parsed['hooks'] && typeof parsed['hooks'] === 'object' && !Array.isArray(parsed['hooks']); if (!hasNestedHooksObject) { - const eventKeys = ['sessionStart', 'postToolUse']; const lifted: Record = {}; - for (const k of eventKeys) { + for (const k of CURSOR_MANAGED_EVENTS) { if (Array.isArray(parsed[k])) { lifted[k] = parsed[k]; delete parsed[k]; @@ -989,10 +1005,9 @@ function reconcileCursorHooksJson(hooksJsonPath: string, managedEntries: CursorM if (!parsed['version']) parsed['version'] = 1; const hookTable = parsed['hooks'] as Record; - const MANAGED_EVENTS = ['sessionStart', 'postToolUse']; const entries = managedEntries || {}; - for (const event of MANAGED_EVENTS) { + for (const event of CURSOR_MANAGED_EVENTS) { const existing = Array.isArray(hookTable[event]) ? (hookTable[event] as unknown[]) : []; const userOwned = existing.filter((e) => !isManagedCursorHookEntry(e)); const newEntry = entries[event] || null; @@ -1027,7 +1042,20 @@ function writeCursorHooksJson(targetDir: string, src: string, opts?: WriteCursor const hooksDir = path.join(targetDir, 'hooks'); fs.mkdirSync(hooksDir, { recursive: true }); - const hookScripts = [GSD_CURSOR_SESSION_HOOK_SCRIPT, GSD_CURSOR_POST_TOOL_HOOK_SCRIPT]; + // AC4a (#2089): install all managed hook scripts, not just sessionStart/postToolUse. + // The event→script mapping is sourced from the descriptor-driven adapter + // (src/host-integration-adapters/imperative-hook-bus.cts). + const eventScriptMap: Record = { + sessionStart: GSD_CURSOR_SESSION_HOOK_SCRIPT, + postToolUse: GSD_CURSOR_POST_TOOL_HOOK_SCRIPT, + preToolUse: GSD_CURSOR_PRE_TOOL_HOOK_SCRIPT, + stop: GSD_CURSOR_STOP_HOOK_SCRIPT, + subagentStart: GSD_CURSOR_SUBAGENT_START_HOOK_SCRIPT, + subagentStop: GSD_CURSOR_SUBAGENT_STOP_HOOK_SCRIPT, + }; + const hookScripts = CURSOR_MANAGED_EVENTS + .map((ev) => eventScriptMap[ev]) + .filter((s): s is string => Boolean(s)); const srcHooksDir = path.join(src, 'hooks'); const installedScripts = new Set(); for (const script of hookScripts) { @@ -1043,27 +1071,19 @@ function writeCursorHooksJson(targetDir: string, src: string, opts?: WriteCursor } const hookOpts: BuildHookCommandOpts = { runtime: 'cursor', platform: opts.platform || process.platform }; - const sessionStartCmd = installedScripts.has('gsd-cursor-session-start.js') - ? buildHookCommand(targetDir, 'gsd-cursor-session-start.js', hookOpts) - : null; - const postToolCmd = installedScripts.has('gsd-cursor-post-tool.js') - ? buildHookCommand(targetDir, 'gsd-cursor-post-tool.js', hookOpts) - : null; - const managedEntries: CursorManagedEntries = {}; - if (sessionStartCmd) { - managedEntries['sessionStart'] = { - type: 'command', - command: sessionStartCmd, - [GSD_CURSOR_HOOK_MARKER]: true, - }; - } - if (postToolCmd) { - managedEntries['postToolUse'] = { - type: 'command', - command: postToolCmd, - [GSD_CURSOR_HOOK_MARKER]: true, - }; + for (const ev of CURSOR_MANAGED_EVENTS) { + const script = eventScriptMap[ev]; + if (script && installedScripts.has(script)) { + const cmd = buildHookCommand(targetDir, script, hookOpts); + if (cmd) { + managedEntries[ev] = { + type: 'command', + command: cmd, + [GSD_CURSOR_HOOK_MARKER]: true, + }; + } + } } const hooksJsonPath = path.join(targetDir, 'hooks.json'); @@ -1729,6 +1749,10 @@ export = { removeCursorHooksJson, GSD_CURSOR_SESSION_HOOK_SCRIPT, GSD_CURSOR_POST_TOOL_HOOK_SCRIPT, + GSD_CURSOR_PRE_TOOL_HOOK_SCRIPT, + GSD_CURSOR_STOP_HOOK_SCRIPT, + GSD_CURSOR_SUBAGENT_START_HOOK_SCRIPT, + GSD_CURSOR_SUBAGENT_STOP_HOOK_SCRIPT, GSD_CURSOR_HOOK_MARKER, // Copilot From 303a796579f1dfbd6a5f829ca07f7c9cd671a6e5 Mon Sep 17 00:00:00 2001 From: Tom Boucher Date: Thu, 9 Jul 2026 00:21:51 -0400 Subject: [PATCH 22/33] docs(changeset): #2089 cursor host-integration migration + golden fixture --- .changeset/2089-eos-cursor-imperative-adapter.md | 5 +++++ docs/INVENTORY-MANIFEST.json | 4 ++++ docs/INVENTORY.md | 4 ++++ docs/reference/host-integration-capability-matrix.md | 5 +++++ tests/fixtures/golden-install-parity/cursor.json | 4 ++++ 5 files changed, 22 insertions(+) create mode 100644 .changeset/2089-eos-cursor-imperative-adapter.md diff --git a/.changeset/2089-eos-cursor-imperative-adapter.md b/.changeset/2089-eos-cursor-imperative-adapter.md new file mode 100644 index 000000000..32a3d120c --- /dev/null +++ b/.changeset/2089-eos-cursor-imperative-adapter.md @@ -0,0 +1,5 @@ +--- +type: Changed +pr: 0 +--- +**Cursor is now driven through the public Host-Integration Interface, with two capability upgrades (ADR-1239 / EoS).** Cursor previously installed via hardcoded `runtime === 'cursor'`/`isCursor` branches in `bin/install.js`; its install/uninstall now runs through the imperative adapter, and every hardcoded cursor branch is folded into descriptor-driven `runtime.hostBehaviors` (reapplyCommand, frontmatterDialect, hooksJsonSurface, skipSharedHooksInstall, reportCommandsDir, managedHookEvents). Install/uninstall output is **byte-identical** (golden parity asserted for all 16 runtimes). Two Context7-verified upgrades land: (1) **expanded hook-bus coverage** — GSD registers all 6 managed lifecycle events in Cursor's `hooks.json` (`preToolUse`, `stop`, `subagentStart`, `subagentStop` in addition to the original `sessionStart`/`postToolUse`), driven by a new descriptor-driven adapter module (`src/host-integration-adapters/imperative-hook-bus.cts`) that reads `hostBehaviors.managedHookEvents` instead of a hardcoded event pair; cite https://cursor.com/docs/hooks. (2) **named/background nested subagent dispatch** — Cursor's `dispatch.background`/`backgroundDispatch`/`nested` are all `true` with `maxDepth: 2`, so `shouldFlattenDispatch(cursor)` returns `false` and GSD's wave-based execution drives Cursor's native background + depth-2 nested subagent invocation instead of flattening to inline sequential calls; cite https://cursor.com/docs/subagents + https://cursor.com/docs/sdk/typescript. (#2089) diff --git a/docs/INVENTORY-MANIFEST.json b/docs/INVENTORY-MANIFEST.json index 4628c7b77..046d19ce2 100644 --- a/docs/INVENTORY-MANIFEST.json +++ b/docs/INVENTORY-MANIFEST.json @@ -452,7 +452,11 @@ "gsd-config-reload.js", "gsd-context-monitor.js", "gsd-cursor-post-tool.js", + "gsd-cursor-pre-tool.js", "gsd-cursor-session-start.js", + "gsd-cursor-stop.js", + "gsd-cursor-subagent-start.js", + "gsd-cursor-subagent-stop.js", "gsd-ensure-canonical-path.js", "gsd-graphify-update.sh", "gsd-phase-boundary.sh", diff --git a/docs/INVENTORY.md b/docs/INVENTORY.md index af1075362..63f4d67a9 100644 --- a/docs/INVENTORY.md +++ b/docs/INVENTORY.md @@ -548,6 +548,10 @@ Full listing: `hooks/`. | `gsd-update-banner.js` | `SessionStart` | Opt-in banner surfacing update availability when GSD statusline isn't used (PR #2795) | | `gsd-cursor-session-start.js` | Cursor `sessionStart` | Cursor-native context injection at session start (issue #777) | | `gsd-cursor-post-tool.js` | Cursor `postToolUse` | Cursor-native STATE.md update monitor after tool calls (issue #777) | +| `gsd-cursor-pre-tool.js` | Cursor `preToolUse` | Cursor-native write-path guard for `.planning/` (ADR-1239 / #2089) | +| `gsd-cursor-stop.js` | Cursor `stop` | Cursor-native verify-work reminder on agent stop (ADR-1239 / #2089) | +| `gsd-cursor-subagent-start.js` | Cursor `subagentStart` | Cursor-native subagent context injection (ADR-1239 / #2089) | +| `gsd-cursor-subagent-stop.js` | Cursor `subagentStop` | Cursor-native subagent completion reminder (ADR-1239 / #2089) | | `gsd-prompt-guard.js` | `PreToolUse` | Scans `.planning/` writes for prompt-injection patterns (advisory) | | `gsd-workflow-guard.js` | `PreToolUse` | Detects file edits outside GSD workflow context (advisory, opt-in) | | `gsd-read-guard.js` | `PreToolUse` | Advisory guard preventing Edit/Write on unread files | diff --git a/docs/reference/host-integration-capability-matrix.md b/docs/reference/host-integration-capability-matrix.md index 45e94be43..8123fc72c 100644 --- a/docs/reference/host-integration-capability-matrix.md +++ b/docs/reference/host-integration-capability-matrix.md @@ -187,6 +187,11 @@ Sources consulted: - https://cursor.com/docs/enterprise/llm-safety-and-controls - /websites/cursor (Context7) +**GSD integration status — Phase D dogfood complete (#2089, ADR-1239).** Cursor installs through the `imperative` embedding adapter (`createImperativeAdapter` → `installRuntimeArtifacts`); the hardcoded `runtime === 'cursor'` / `isCursor` projection is folded into descriptor-driven `runtime.hostBehaviors`, and install/uninstall output is byte-parity-gated (`tests/fixtures/golden-install-parity/cursor.json`). Two capability upgrades land, each with a test driving the user-reachable surface: + +- **Expanded hook-bus coverage** — GSD registers all 6 managed lifecycle events in `hooks.json` beyond the original `sessionStart`/`postToolUse`: `preToolUse`, `stop`, `subagentStart`, `subagentStop` (AC4a, cite https://cursor.com/docs/hooks). The hook-bus binding is descriptor-driven via `src/host-integration-adapters/imperative-hook-bus.cts` (reads `hostBehaviors.managedHookEvents`), not a hardcoded event pair. +- **Named/background nested subagent dispatch** — `dispatch.background`/`backgroundDispatch`/`nested` are all `true` with `maxDepth: 2`; `shouldFlattenDispatch(cursor)` returns `false` so GSD's wave-based execution drives Cursor's native background + depth-2 nested subagent dispatch instead of flattening to inline sequential calls (AC4b, cite https://cursor.com/docs/subagents + https://cursor.com/docs/sdk/typescript). + --- ## cline diff --git a/tests/fixtures/golden-install-parity/cursor.json b/tests/fixtures/golden-install-parity/cursor.json index 62441d144..ef6658d4f 100644 --- a/tests/fixtures/golden-install-parity/cursor.json +++ b/tests/fixtures/golden-install-parity/cursor.json @@ -382,7 +382,11 @@ "gsd-core/workflows/verify-phase.md": "e0957e153788a222", "gsd-core/workflows/verify-work.md": "e7e7e900c4874490", "hooks/gsd-cursor-post-tool.js": "019d503aee8b4a3f", + "hooks/gsd-cursor-pre-tool.js": "fe274720781fcbb5", "hooks/gsd-cursor-session-start.js": "c6e04ed597ea7020", + "hooks/gsd-cursor-stop.js": "902a005c49e7660b", + "hooks/gsd-cursor-subagent-start.js": "693d38d298d2252c", + "hooks/gsd-cursor-subagent-stop.js": "8da03aad6bb05d3f", "scripts/changeset/README.md": "86ff89331dfd94b2", "scripts/changeset/cli.cjs": "68f92a344b199271", "scripts/changeset/github-release-notes.cjs": "795677f0c009b132", From 24896ddac735f2c431ac4ed84dcbe88d63e40075 Mon Sep 17 00:00:00 2001 From: Tom Boucher Date: Thu, 9 Jul 2026 00:58:09 -0400 Subject: [PATCH 23/33] fix(#2089): register 4 new cursor hook scripts in build + managed-hooks whitelists --- hooks/managed-hooks-registry.cjs | 4 ++++ scripts/build-hooks.js | 6 +++++- src/installer-migration-report.cts | 4 ++++ 3 files changed, 13 insertions(+), 1 deletion(-) diff --git a/hooks/managed-hooks-registry.cjs b/hooks/managed-hooks-registry.cjs index 1d392471d..0a05e5841 100644 --- a/hooks/managed-hooks-registry.cjs +++ b/hooks/managed-hooks-registry.cjs @@ -21,7 +21,11 @@ const MANAGED_HOOKS = [ 'gsd-config-reload.js', 'gsd-context-monitor.js', 'gsd-cursor-post-tool.js', + 'gsd-cursor-pre-tool.js', 'gsd-cursor-session-start.js', + 'gsd-cursor-stop.js', + 'gsd-cursor-subagent-start.js', + 'gsd-cursor-subagent-stop.js', 'gsd-ensure-canonical-path.js', 'gsd-graphify-update.sh', 'gsd-phase-boundary.sh', diff --git a/scripts/build-hooks.js b/scripts/build-hooks.js index a4e220860..9e80d813e 100644 --- a/scripts/build-hooks.js +++ b/scripts/build-hooks.js @@ -37,9 +37,13 @@ const HOOKS_TO_COPY = [ // so require('./managed-hooks-registry.cjs') resolves in the installed hooks/ dir. 'managed-hooks-registry.cjs', 'gsd-context-monitor.js', - // Cursor lifecycle hooks (issue #777): sessionStart context injection + postToolUse monitor + // Cursor lifecycle hooks (#777 + ADR-1239/#2089): 6 managed events 'gsd-cursor-session-start.js', 'gsd-cursor-post-tool.js', + 'gsd-cursor-pre-tool.js', + 'gsd-cursor-stop.js', + 'gsd-cursor-subagent-start.js', + 'gsd-cursor-subagent-stop.js', // Claude Code FileChanged hook (#770) — hot-reloads gsd config when // .planning/config.json changes mid-session. Must ship to dist so the // installer can copy it to the target hooks/ dir and register FileChanged. diff --git a/src/installer-migration-report.cts b/src/installer-migration-report.cts index b77dccc27..70b74252a 100644 --- a/src/installer-migration-report.cts +++ b/src/installer-migration-report.cts @@ -32,7 +32,11 @@ export const BUNDLED_GSD_HOOK_FILES: ReadonlySet = Object.freeze(new Set 'hooks/gsd-config-reload.js', 'hooks/gsd-context-monitor.js', 'hooks/gsd-cursor-post-tool.js', + 'hooks/gsd-cursor-pre-tool.js', 'hooks/gsd-cursor-session-start.js', + 'hooks/gsd-cursor-stop.js', + 'hooks/gsd-cursor-subagent-start.js', + 'hooks/gsd-cursor-subagent-stop.js', 'hooks/gsd-ensure-canonical-path.js', 'hooks/gsd-graphify-update.sh', 'hooks/gsd-phase-boundary.sh', From c68bca55cf7ec1a36cf709f72eea3826cf6eae42 Mon Sep 17 00:00:00 2001 From: Tom Boucher Date: Thu, 9 Jul 2026 01:19:25 -0400 Subject: [PATCH 24/33] test(#2089): regenerate all golden fixtures for managed-hooks-registry + cursor hook additions --- tests/fixtures/golden-install-parity/antigravity.json | 6 +++++- tests/fixtures/golden-install-parity/augment.json | 6 +++++- tests/fixtures/golden-install-parity/claude-local.json | 6 +++++- tests/fixtures/golden-install-parity/claude.json | 6 +++++- tests/fixtures/golden-install-parity/codebuddy.json | 6 +++++- tests/fixtures/golden-install-parity/hermes.json | 6 +++++- tests/fixtures/golden-install-parity/opencode.json | 6 +++++- tests/fixtures/golden-install-parity/qwen.json | 6 +++++- 8 files changed, 40 insertions(+), 8 deletions(-) diff --git a/tests/fixtures/golden-install-parity/antigravity.json b/tests/fixtures/golden-install-parity/antigravity.json index 4615ba8ec..6f495953e 100644 --- a/tests/fixtures/golden-install-parity/antigravity.json +++ b/tests/fixtures/golden-install-parity/antigravity.json @@ -315,7 +315,11 @@ "hooks/gsd-config-reload.js": "96546e0e8bb47904", "hooks/gsd-context-monitor.js": "6d81d7326e5b2710", "hooks/gsd-cursor-post-tool.js": "9168e0a09de1972a", + "hooks/gsd-cursor-pre-tool.js": "873998b25e308c29", "hooks/gsd-cursor-session-start.js": "9b2e6f4f0c405375", + "hooks/gsd-cursor-stop.js": "bfaaf60f419e3238", + "hooks/gsd-cursor-subagent-start.js": "06d77fde5c1372b6", + "hooks/gsd-cursor-subagent-stop.js": "4bbf22917da4d389", "hooks/gsd-ensure-canonical-path.js": "64d092d7e4a01211", "hooks/gsd-graphify-update.sh": "e4c6e14fe6ad64ff", "hooks/gsd-phase-boundary.sh": "32739d5fbe0d0a1c", @@ -330,7 +334,7 @@ "hooks/gsd-worktree-path-guard.js": "838498aa91619740", "hooks/lib/git-cmd.js": "268ba15992ca0b23", "hooks/lib/gsd-graphify-rebuild.sh": "66af89601074d2a9", - "hooks/managed-hooks-registry.cjs": "45b2431992d3d7d2", + "hooks/managed-hooks-registry.cjs": "721d696556b7509f", "package.json": "dbf8353f77358bc1", "scripts/changeset/README.md": "86ff89331dfd94b2", "scripts/changeset/cli.cjs": "68f92a344b199271", diff --git a/tests/fixtures/golden-install-parity/augment.json b/tests/fixtures/golden-install-parity/augment.json index a39d82e99..6f7ae32a5 100644 --- a/tests/fixtures/golden-install-parity/augment.json +++ b/tests/fixtures/golden-install-parity/augment.json @@ -386,7 +386,11 @@ "hooks/gsd-config-reload.js": "96546e0e8bb47904", "hooks/gsd-context-monitor.js": "44ff1bbf292747af", "hooks/gsd-cursor-post-tool.js": "9168e0a09de1972a", + "hooks/gsd-cursor-pre-tool.js": "873998b25e308c29", "hooks/gsd-cursor-session-start.js": "9b2e6f4f0c405375", + "hooks/gsd-cursor-stop.js": "bfaaf60f419e3238", + "hooks/gsd-cursor-subagent-start.js": "06d77fde5c1372b6", + "hooks/gsd-cursor-subagent-stop.js": "4bbf22917da4d389", "hooks/gsd-ensure-canonical-path.js": "d569f5f3578e93e5", "hooks/gsd-graphify-update.sh": "e4c6e14fe6ad64ff", "hooks/gsd-phase-boundary.sh": "32739d5fbe0d0a1c", @@ -401,7 +405,7 @@ "hooks/gsd-worktree-path-guard.js": "65b934c3a1709e89", "hooks/lib/git-cmd.js": "268ba15992ca0b23", "hooks/lib/gsd-graphify-rebuild.sh": "66af89601074d2a9", - "hooks/managed-hooks-registry.cjs": "f46a329fcfefa465", + "hooks/managed-hooks-registry.cjs": "e61da0f7a3037c35", "package.json": "dbf8353f77358bc1", "scripts/changeset/README.md": "86ff89331dfd94b2", "scripts/changeset/cli.cjs": "68f92a344b199271", diff --git a/tests/fixtures/golden-install-parity/claude-local.json b/tests/fixtures/golden-install-parity/claude-local.json index 72a5c64bb..95a1dfb72 100644 --- a/tests/fixtures/golden-install-parity/claude-local.json +++ b/tests/fixtures/golden-install-parity/claude-local.json @@ -385,7 +385,11 @@ "hooks/gsd-config-reload.js": "96546e0e8bb47904", "hooks/gsd-context-monitor.js": "ecbe9747e4a442e0", "hooks/gsd-cursor-post-tool.js": "8a8a249c0642cc71", + "hooks/gsd-cursor-pre-tool.js": "8cb8e8f895edaec9", "hooks/gsd-cursor-session-start.js": "05a14e903c5edafa", + "hooks/gsd-cursor-stop.js": "d33be8ac96f4081d", + "hooks/gsd-cursor-subagent-start.js": "d773df8caa605de2", + "hooks/gsd-cursor-subagent-stop.js": "8ee488d826bf3c37", "hooks/gsd-ensure-canonical-path.js": "b4b3b88a0e493b16", "hooks/gsd-graphify-update.sh": "e4c6e14fe6ad64ff", "hooks/gsd-phase-boundary.sh": "32739d5fbe0d0a1c", @@ -400,7 +404,7 @@ "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", + "hooks/managed-hooks-registry.cjs": "f2e325aa9ba31647", "package.json": "dbf8353f77358bc1", "scripts/changeset/README.md": "86ff89331dfd94b2", "scripts/changeset/cli.cjs": "68f92a344b199271", diff --git a/tests/fixtures/golden-install-parity/claude.json b/tests/fixtures/golden-install-parity/claude.json index b96c3ccec..e2009eaa3 100644 --- a/tests/fixtures/golden-install-parity/claude.json +++ b/tests/fixtures/golden-install-parity/claude.json @@ -314,7 +314,11 @@ "hooks/gsd-config-reload.js": "96546e0e8bb47904", "hooks/gsd-context-monitor.js": "ecbe9747e4a442e0", "hooks/gsd-cursor-post-tool.js": "8a8a249c0642cc71", + "hooks/gsd-cursor-pre-tool.js": "8cb8e8f895edaec9", "hooks/gsd-cursor-session-start.js": "05a14e903c5edafa", + "hooks/gsd-cursor-stop.js": "d33be8ac96f4081d", + "hooks/gsd-cursor-subagent-start.js": "d773df8caa605de2", + "hooks/gsd-cursor-subagent-stop.js": "8ee488d826bf3c37", "hooks/gsd-ensure-canonical-path.js": "b4b3b88a0e493b16", "hooks/gsd-graphify-update.sh": "e4c6e14fe6ad64ff", "hooks/gsd-phase-boundary.sh": "32739d5fbe0d0a1c", @@ -329,7 +333,7 @@ "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", + "hooks/managed-hooks-registry.cjs": "f2e325aa9ba31647", "package.json": "dbf8353f77358bc1", "scripts/changeset/README.md": "86ff89331dfd94b2", "scripts/changeset/cli.cjs": "68f92a344b199271", diff --git a/tests/fixtures/golden-install-parity/codebuddy.json b/tests/fixtures/golden-install-parity/codebuddy.json index b47dbf1de..c04aa1bf7 100644 --- a/tests/fixtures/golden-install-parity/codebuddy.json +++ b/tests/fixtures/golden-install-parity/codebuddy.json @@ -386,7 +386,11 @@ "hooks/gsd-config-reload.js": "96546e0e8bb47904", "hooks/gsd-context-monitor.js": "f372804867cabe40", "hooks/gsd-cursor-post-tool.js": "9168e0a09de1972a", + "hooks/gsd-cursor-pre-tool.js": "873998b25e308c29", "hooks/gsd-cursor-session-start.js": "9b2e6f4f0c405375", + "hooks/gsd-cursor-stop.js": "bfaaf60f419e3238", + "hooks/gsd-cursor-subagent-start.js": "06d77fde5c1372b6", + "hooks/gsd-cursor-subagent-stop.js": "4bbf22917da4d389", "hooks/gsd-ensure-canonical-path.js": "434887487ae63ec5", "hooks/gsd-graphify-update.sh": "e4c6e14fe6ad64ff", "hooks/gsd-phase-boundary.sh": "32739d5fbe0d0a1c", @@ -401,7 +405,7 @@ "hooks/gsd-worktree-path-guard.js": "548fc57131a04fa7", "hooks/lib/git-cmd.js": "268ba15992ca0b23", "hooks/lib/gsd-graphify-rebuild.sh": "66af89601074d2a9", - "hooks/managed-hooks-registry.cjs": "0368fd4ac7bb3d1d", + "hooks/managed-hooks-registry.cjs": "0e7a61bde8688e11", "package.json": "dbf8353f77358bc1", "scripts/changeset/README.md": "86ff89331dfd94b2", "scripts/changeset/cli.cjs": "68f92a344b199271", diff --git a/tests/fixtures/golden-install-parity/hermes.json b/tests/fixtures/golden-install-parity/hermes.json index 6757b3c2c..50d7c6a7a 100644 --- a/tests/fixtures/golden-install-parity/hermes.json +++ b/tests/fixtures/golden-install-parity/hermes.json @@ -315,7 +315,11 @@ "hooks/gsd-config-reload.js": "880b696458e85e9b", "hooks/gsd-context-monitor.js": "41d28e0db7b20968", "hooks/gsd-cursor-post-tool.js": "8a8a249c0642cc71", + "hooks/gsd-cursor-pre-tool.js": "8cb8e8f895edaec9", "hooks/gsd-cursor-session-start.js": "05a14e903c5edafa", + "hooks/gsd-cursor-stop.js": "d33be8ac96f4081d", + "hooks/gsd-cursor-subagent-start.js": "d773df8caa605de2", + "hooks/gsd-cursor-subagent-stop.js": "8ee488d826bf3c37", "hooks/gsd-ensure-canonical-path.js": "7d116d7d65c50b4b", "hooks/gsd-graphify-update.sh": "e4c6e14fe6ad64ff", "hooks/gsd-phase-boundary.sh": "32739d5fbe0d0a1c", @@ -330,7 +334,7 @@ "hooks/gsd-worktree-path-guard.js": "108ab88ccbafc5d8", "hooks/lib/git-cmd.js": "268ba15992ca0b23", "hooks/lib/gsd-graphify-rebuild.sh": "66af89601074d2a9", - "hooks/managed-hooks-registry.cjs": "2218a41c279720c2", + "hooks/managed-hooks-registry.cjs": "a494d1a70ed87690", "package.json": "dbf8353f77358bc1", "scripts/changeset/README.md": "86ff89331dfd94b2", "scripts/changeset/cli.cjs": "68f92a344b199271", diff --git a/tests/fixtures/golden-install-parity/opencode.json b/tests/fixtures/golden-install-parity/opencode.json index 62e04aade..6fab45dd5 100644 --- a/tests/fixtures/golden-install-parity/opencode.json +++ b/tests/fixtures/golden-install-parity/opencode.json @@ -386,7 +386,11 @@ "hooks/gsd-config-reload.js": "96546e0e8bb47904", "hooks/gsd-context-monitor.js": "7a9787868a39b76d", "hooks/gsd-cursor-post-tool.js": "9168e0a09de1972a", + "hooks/gsd-cursor-pre-tool.js": "873998b25e308c29", "hooks/gsd-cursor-session-start.js": "9b2e6f4f0c405375", + "hooks/gsd-cursor-stop.js": "bfaaf60f419e3238", + "hooks/gsd-cursor-subagent-start.js": "06d77fde5c1372b6", + "hooks/gsd-cursor-subagent-stop.js": "4bbf22917da4d389", "hooks/gsd-ensure-canonical-path.js": "2801ae3fef9579bf", "hooks/gsd-graphify-update.sh": "e4c6e14fe6ad64ff", "hooks/gsd-phase-boundary.sh": "32739d5fbe0d0a1c", @@ -401,7 +405,7 @@ "hooks/gsd-worktree-path-guard.js": "726fb9afefda5d42", "hooks/lib/git-cmd.js": "268ba15992ca0b23", "hooks/lib/gsd-graphify-rebuild.sh": "66af89601074d2a9", - "hooks/managed-hooks-registry.cjs": "763730ef31e5fd1c", + "hooks/managed-hooks-registry.cjs": "9163e096b74ec4b3", "opencode.json": "2c12c446a88f2f36", "package.json": "dbf8353f77358bc1", "plugins/gsd-core.js": "931ca839dc9eb7f1", diff --git a/tests/fixtures/golden-install-parity/qwen.json b/tests/fixtures/golden-install-parity/qwen.json index 40444eb7b..f2feab0c5 100644 --- a/tests/fixtures/golden-install-parity/qwen.json +++ b/tests/fixtures/golden-install-parity/qwen.json @@ -315,7 +315,11 @@ "hooks/gsd-config-reload.js": "4f52b8a0120bb1b8", "hooks/gsd-context-monitor.js": "437a33e6e3058640", "hooks/gsd-cursor-post-tool.js": "8a8a249c0642cc71", + "hooks/gsd-cursor-pre-tool.js": "8cb8e8f895edaec9", "hooks/gsd-cursor-session-start.js": "05a14e903c5edafa", + "hooks/gsd-cursor-stop.js": "d33be8ac96f4081d", + "hooks/gsd-cursor-subagent-start.js": "d773df8caa605de2", + "hooks/gsd-cursor-subagent-stop.js": "8ee488d826bf3c37", "hooks/gsd-ensure-canonical-path.js": "2df5e295b36c3334", "hooks/gsd-graphify-update.sh": "e4c6e14fe6ad64ff", "hooks/gsd-phase-boundary.sh": "32739d5fbe0d0a1c", @@ -330,7 +334,7 @@ "hooks/gsd-worktree-path-guard.js": "8389e4c9175b2613", "hooks/lib/git-cmd.js": "268ba15992ca0b23", "hooks/lib/gsd-graphify-rebuild.sh": "66af89601074d2a9", - "hooks/managed-hooks-registry.cjs": "a57697c1ae4ac163", + "hooks/managed-hooks-registry.cjs": "a5a93d50c4ea7a0c", "package.json": "dbf8353f77358bc1", "scripts/changeset/README.md": "86ff89331dfd94b2", "scripts/changeset/cli.cjs": "68f92a344b199271", From a53c5462e6dc7bfd14ab041f557e23ffe1b6fbfa Mon Sep 17 00:00:00 2001 From: Tom Boucher Date: Thu, 9 Jul 2026 02:07:08 -0400 Subject: [PATCH 25/33] chore(#2089): gitignore compiled imperative-hook-bus adapter + fix Context7 typo - Add /gsd-core/bin/lib/host-integration-adapters/imperative-hook-bus.cjs to .gitignore (tsc-emitted build artifact per ADR-457 convention; matches the sibling adapter entries at .gitignore:70-89). The subagent authored the .cts source but missed this entry, leaving the compiled output untracked. - Fix cosmetic 'Context3' -> 'Context7' typo in test section header comment. --- .gitignore | 1 + tests/cursor-imperative-reference.test.cjs | 2 +- 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/.gitignore b/.gitignore index c70665f48..37d552553 100644 --- a/.gitignore +++ b/.gitignore @@ -69,6 +69,7 @@ build/ /tsconfig.build.tsbuildinfo /gsd-core/bin/lib/host-integration.cjs /gsd-core/bin/lib/host-integration-sdk.cjs +/gsd-core/bin/lib/host-integration-adapters/imperative-hook-bus.cjs /gsd-core/bin/lib/handshake-serialized.cjs /gsd-core/bin/lib/install-effort-resolver.cjs /gsd-core/bin/lib/install-engine.cjs diff --git a/tests/cursor-imperative-reference.test.cjs b/tests/cursor-imperative-reference.test.cjs index a13f2970a..1a412b579 100644 --- a/tests/cursor-imperative-reference.test.cjs +++ b/tests/cursor-imperative-reference.test.cjs @@ -66,7 +66,7 @@ test('cursor descriptor declares all 8 axes + 6 dispatch sub-axes (no undocument assert.equal(d.backgroundDispatch, true); }); -// -- AC4b: the Context3-verified dispatch UPGRADE (named/background nested) --- +// -- AC4b: the Context7-verified dispatch UPGRADE (named/background nested) --- test('cursor descriptor declares background dispatch true/true + nested + maxDepth 2', () => { assert.equal(CUR_AXES.dispatch.background, true); From a8d9dbe02a914284aeebec5daf13385b0a5774f2 Mon Sep 17 00:00:00 2001 From: Tom Boucher Date: Thu, 9 Jul 2026 09:16:37 -0400 Subject: [PATCH 26/33] fix(#2089): widen read-injection-scanner property test timeout to avoid node22 race MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The property test's execFileSync timeout (5000ms) was identical to the scanner's own internal stdin-timeout (hooks/gsd-read-injection-scanner.js:109, also 5000ms). Under concurrent test-chunk load on linux-node22 — which #2089's 3 new cursor test files redistribute — the scanner subprocess's stdin 'end' event can fire late enough that execFileSync's SIGTERM arrives before the scanner's own process.exit(0), producing err.status=null → exitCode=1 → spurious property-test failure. The scanner has no process.exit(N!=0) paths; the only non-zero exit is from the signal-kill race. Doubling the test ceiling to 10000ms gives the scanner's 5000ms internal exit a 5s buffer to win the race deterministically on every node version. --- tests/read-injection-scanner.property.test.cjs | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/tests/read-injection-scanner.property.test.cjs b/tests/read-injection-scanner.property.test.cjs index 92cf925f9..fb891c9c1 100644 --- a/tests/read-injection-scanner.property.test.cjs +++ b/tests/read-injection-scanner.property.test.cjs @@ -27,7 +27,14 @@ function runHook(payload) { const stdout = execFileSync(process.execPath, [HOOK_PATH], { input: JSON.stringify(payload), encoding: 'utf-8', - timeout: 5000, + // 10s — double the scanner's own 5s internal stdin-timeout + // (hooks/gsd-read-injection-scanner.js:109). Under concurrent test + // load (crowded run-tests.cjs chunks), node22's event-loop scheduling + // can delay the scanner's stdin 'end' handler past 5s, racing the + // scanner's process.exit(0) against this timeout's SIGTERM. A 10s + // ceiling gives the scanner's own 5s exit a 5s buffer to win the race + // deterministically on every node version. (#2089) + timeout: 10000, stdio: ['pipe', 'pipe', 'pipe'], }); return { exitCode: 0, stdout: stdout.trim() }; From fad5094587c1d63011d0c89e35d9957d124490ad Mon Sep 17 00:00:00 2001 From: Tom Boucher Date: Thu, 9 Jul 2026 09:36:33 -0400 Subject: [PATCH 27/33] =?UTF-8?q?fix(#2089):=20redesign=20scanner=20proper?= =?UTF-8?q?ty=20test=20=E2=80=94=20logic/results,=20not=20wall-clock?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replaces the timing-dependent execFileSync(timeout:5000) approach with a spawnSync-based runHook that tests the scanner's RESULT (exit code + output shape), never how long it takes. Root design flaw in the prior approach: execFileSync's timeout (5000ms) was identical to the scanner's own internal setTimeout(5000ms), creating a non-repeatable race (F.I.R.S.T. violation: not Repeatable). Under concurrent test-chunk load — which #2089's 3 new cursor test files redistribute — node22's event-loop scheduling let execFileSync's SIGTERM win the race, producing err.status=null → exitCode=1 → spurious property-test failure. Redesign (F.I.R.S.T.): - spawnSync (not execFileSync): non-zero exits return a result object, not an exception — cleaner for property tests - Non-serializable payloads (BigInt, circular refs, Symbol) are SKIPPED: the scanner receives JSON via stdin, so these values are outside its protocol — JSON.stringify throwing is a test-harness artifact, not a scanner defect - 30s safety-net timeout is NOT a test assertion: scanner exits in <100ms; 30s only catches a genuinely hung process (6x the scanner's own 5s internal timer → no race possible) - Assertions check exit===0 and output structure, never timing qa-test-architect pipeline: risk=HIGH (security boundary); automation= subprocess (real shipped hook); test-cases cover happy/boundary/negative/ independence; verified via gsd-test. --- .../read-injection-scanner.property.test.cjs | 62 ++++++++++++++----- 1 file changed, 45 insertions(+), 17 deletions(-) diff --git a/tests/read-injection-scanner.property.test.cjs b/tests/read-injection-scanner.property.test.cjs index fb891c9c1..eac847542 100644 --- a/tests/read-injection-scanner.property.test.cjs +++ b/tests/read-injection-scanner.property.test.cjs @@ -12,35 +12,63 @@ * * Invoked as a subprocess (the hook reads a JSON payload on stdin and has no * exported surface), so this exercises the real shipped hook end-to-end. + * + * F.I.R.S.T. design: + * Fast — spawnSync is synchronous; scanner exits in <100ms for any input. + * Isolated — each invocation is a fresh subprocess; no shared state. + * Repeatable — no wall-clock assertion; the 30s safety-net timeout is 6x the + * scanner's own internal 5s timer and is never tested against. + * Tests assert on the scanner's RESULT (exit code + output shape), + * never on timing. + * Self-Val — assertions check exit===0 and output is empty or valid JSON. + * Timely — written alongside the scanner (#1577); hardened for #2089. */ const { describe, test } = require('node:test'); const assert = require('node:assert/strict'); -const { execFileSync } = require('node:child_process'); +const { spawnSync } = require('node:child_process'); const path = require('node:path'); const fc = require('./helpers/fast-check-setup.cjs'); const HOOK_PATH = path.join(__dirname, '..', 'hooks', 'gsd-read-injection-scanner.js'); +/** + * Run the scanner hook with a payload and return its result. + * + * Uses spawnSync (not execFileSync) so non-zero exits return a result object + * rather than throwing — cleaner for property tests that assert on exit code. + * + * Non-serializable payloads (BigInt, circular refs, Symbol, undefined) are + * SKIPPED: the scanner receives JSON via stdin, so these values can never + * reach it. JSON.stringify throwing is a test-harness artifact (fc.anything() + * generates values outside the JSON domain), not a scanner defect. + * + * The 30s safety-net timeout is NOT a test assertion. The scanner exits in + * <100ms for any input; its own internal setTimeout(5000) guarantees exit + * even if stdin never closes (impossible here — spawnSync's `input:` pipes + * and closes stdin). The ceiling only catches a genuinely hung process (a + * real defect) without racing the scanner's internal timer. + */ function runHook(payload) { + let input; try { - const stdout = execFileSync(process.execPath, [HOOK_PATH], { - input: JSON.stringify(payload), - encoding: 'utf-8', - // 10s — double the scanner's own 5s internal stdin-timeout - // (hooks/gsd-read-injection-scanner.js:109). Under concurrent test - // load (crowded run-tests.cjs chunks), node22's event-loop scheduling - // can delay the scanner's stdin 'end' handler past 5s, racing the - // scanner's process.exit(0) against this timeout's SIGTERM. A 10s - // ceiling gives the scanner's own 5s exit a 5s buffer to win the race - // deterministically on every node version. (#2089) - timeout: 10000, - stdio: ['pipe', 'pipe', 'pipe'], - }); - return { exitCode: 0, stdout: stdout.trim() }; - } catch (err) { - return { exitCode: err.status ?? 1, stdout: (err.stdout || '').toString().trim() }; + input = JSON.stringify(payload); + } catch { + return { exitCode: 0, stdout: '', skipped: true }; } + + const result = spawnSync(process.execPath, [HOOK_PATH], { + input, + encoding: 'utf-8', + timeout: 30000, + stdio: ['pipe', 'pipe', 'pipe'], + }); + + return { + exitCode: result.status ?? 1, + stdout: (result.stdout || '').trim(), + signal: result.signal, + }; } // Injection-shaped fragments so the regex-matching path is exercised, not just clean text. From 45f3a2a5f945a6fedfcb27aeba304f4c6538bfcd Mon Sep 17 00:00:00 2001 From: Tom Boucher Date: Thu, 9 Jul 2026 13:17:04 -0400 Subject: [PATCH 28/33] fix(#2089): wire adapter into install path + address all review findings MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit MEDIUM fixes (code review): - Wire resolveManagedHookEvents + resolveHookScripts + buildHookBusEntries from imperative-hook-bus.cts into writeCursorHooksJson — the install path is now truly descriptor-driven (reads hostBehaviors.managedHookEvents), not a hardcoded constant that happens to match the descriptor. bin/install.js passes the descriptor list via opts.managedHookEvents. - buildHookBusEntries is now consumed (was dead code); entry-building is no longer duplicated inline. - Remove try/finally from cursor-hook-bus-upgrade.test.cjs test bodies (violated CONTRIBUTING.md L342; redundant with t.after cleanup). LOW fixes: - Remove dead require('fs')/require('path') from gsd-cursor-pre-tool.js - Fix resolveManagedHookEvents docstring (all-invalid fallback behavior) - Add src/runtime-hooks-surface.cts to the AC2 source-guard file list Security review: no CRITICAL/HIGH/MEDIUM findings (3 LOW are pre-existing #777 baseline patterns, not regressions). --- bin/install.js | 4 +- hooks/gsd-cursor-pre-tool.js | 3 - .../imperative-hook-bus.cts | 4 +- src/runtime-hooks-surface.cts | 64 +++++------ tests/cursor-hook-bus-upgrade.test.cjs | 102 ++++++++---------- tests/cursor-imperative-reference.test.cjs | 2 +- 6 files changed, 81 insertions(+), 98 deletions(-) diff --git a/bin/install.js b/bin/install.js index f36debfd0..16cc410c6 100755 --- a/bin/install.js +++ b/bin/install.js @@ -10004,7 +10004,9 @@ function install(isGlobal, runtime = DEFAULT_RUNTIME, options = {}) { // adapter. Registers all 6 managed events (sessionStart, postToolUse, preToolUse, // stop, subagentStart, subagentStop) via runtime-hooks-surface.cts, which reads // the event list from the descriptor-driven adapter module. - const cursorHookResult = writeCursorHooksJson(targetDir, src, {}); + const cursorHookResult = writeCursorHooksJson(targetDir, src, { + managedHookEvents: _hostBehaviors(runtime).managedHookEvents, + }); if (cursorHookResult.changed) { console.log(` ${green}✓${reset} Configured Cursor lifecycle hooks (sessionStart, postToolUse, preToolUse, stop, subagentStart, subagentStop)`); } else { diff --git a/hooks/gsd-cursor-pre-tool.js b/hooks/gsd-cursor-pre-tool.js index a608255c0..d0d96e4c2 100644 --- a/hooks/gsd-cursor-pre-tool.js +++ b/hooks/gsd-cursor-pre-tool.js @@ -22,9 +22,6 @@ 'use strict'; -const fs = require('fs'); -const path = require('path'); - const WRITE_TOOL_RE = /write|edit|replace|create|delete|remove|append|apply|patch|insert|mkdir/i; const PATH_KEY_RE = /^(path|file|file_?path|filepath|target_?path|target|dir|directory|uri|filename)$/i; const PLANNING_PATH_RE = /(^|[\\/])\.planning([\\/]|$)/; diff --git a/src/host-integration-adapters/imperative-hook-bus.cts b/src/host-integration-adapters/imperative-hook-bus.cts index 1638a5481..6716bd9a7 100644 --- a/src/host-integration-adapters/imperative-hook-bus.cts +++ b/src/host-integration-adapters/imperative-hook-bus.cts @@ -73,7 +73,9 @@ export const GSD_HOOK_MARKER = 'gsd-managed'; * (backward-compat for descriptors predating #2089). * * Pure: no I/O, never throws. Unknown event names are silently filtered - * (fail-closed — an unrecognized event is never registered). + * (fail-closed — an unrecognized event is never registered). Falls back to + * the full CURSOR_HOOK_EVENTS set when the descriptor is absent or all entries + * are unrecognized (ensures the portable-event floor is always covered). * * @param managedHookEvents - the descriptor's `hostBehaviors.managedHookEvents` array * @returns a deduplicated, validated array of event names diff --git a/src/runtime-hooks-surface.cts b/src/runtime-hooks-surface.cts index 09fc530bd..dbd1141ad 100644 --- a/src/runtime-hooks-surface.cts +++ b/src/runtime-hooks-surface.cts @@ -27,6 +27,13 @@ import fs from 'node:fs'; import path from 'node:path'; import os from 'node:os'; +import { + CURSOR_HOOK_EVENTS, + CURSOR_EVENT_SCRIPT_MAP, + resolveManagedHookEvents, + resolveHookScripts, + buildHookBusEntries, +} from './host-integration-adapters/imperative-hook-bus.cjs'; // eslint-disable-next-line @typescript-eslint/no-require-imports import shellCmdProjection = require('./shell-command-projection.cjs'); const { @@ -86,18 +93,13 @@ const GSD_CURSOR_SUBAGENT_START_HOOK_SCRIPT = 'gsd-cursor-subagent-start.js'; const GSD_CURSOR_SUBAGENT_STOP_HOOK_SCRIPT = 'gsd-cursor-subagent-stop.js'; const GSD_CURSOR_HOOK_MARKER = 'gsd-managed'; -// The full set of Cursor hook events GSD manages (AC4a upgrade, #2089). -// Sourced from the descriptor-driven adapter module -// (src/host-integration-adapters/imperative-hook-bus.cts). This replaces the -// hardcoded ['sessionStart', 'postToolUse'] pair with the 6-event managed set. -const CURSOR_MANAGED_EVENTS = [ - 'sessionStart', - 'postToolUse', - 'preToolUse', - 'stop', - 'subagentStart', - 'subagentStop', -]; +// The full set of Cursor hook events GSD manages — sourced from the adapter +// (src/host-integration-adapters/imperative-hook-bus.cts) so the vocabulary +// stays closed and first-party. Used by reconcileCursorHooksJson (the +// reconciliation scope is always the full set). The install path +// (writeCursorHooksJson) resolves a descriptor-driven subset via +// resolveManagedHookEvents(opts.managedHookEvents). +const CURSOR_MANAGED_EVENTS = CURSOR_HOOK_EVENTS; // --------------------------------------------------------------------------- // Cline / AGENTS.md constants @@ -1035,6 +1037,7 @@ function reconcileCursorHooksJson(hooksJsonPath: string, managedEntries: CursorM interface WriteCursorHooksJsonOpts { absoluteRunner?: string | null; platform?: string; + managedHookEvents?: readonly string[]; } function writeCursorHooksJson(targetDir: string, src: string, opts?: WriteCursorHooksJsonOpts): { hooksJsonPath: string; changed: boolean } { @@ -1042,20 +1045,11 @@ function writeCursorHooksJson(targetDir: string, src: string, opts?: WriteCursor const hooksDir = path.join(targetDir, 'hooks'); fs.mkdirSync(hooksDir, { recursive: true }); - // AC4a (#2089): install all managed hook scripts, not just sessionStart/postToolUse. - // The event→script mapping is sourced from the descriptor-driven adapter - // (src/host-integration-adapters/imperative-hook-bus.cts). - const eventScriptMap: Record = { - sessionStart: GSD_CURSOR_SESSION_HOOK_SCRIPT, - postToolUse: GSD_CURSOR_POST_TOOL_HOOK_SCRIPT, - preToolUse: GSD_CURSOR_PRE_TOOL_HOOK_SCRIPT, - stop: GSD_CURSOR_STOP_HOOK_SCRIPT, - subagentStart: GSD_CURSOR_SUBAGENT_START_HOOK_SCRIPT, - subagentStop: GSD_CURSOR_SUBAGENT_STOP_HOOK_SCRIPT, - }; - const hookScripts = CURSOR_MANAGED_EVENTS - .map((ev) => eventScriptMap[ev]) - .filter((s): s is string => Boolean(s)); + // Descriptor-driven event resolution (#2089): the managed event set comes + // from the host descriptor's hostBehaviors.managedHookEvents via the pure + // adapter (resolveManagedHookEvents), NOT a hardcoded constant. + const events = resolveManagedHookEvents(opts.managedHookEvents); + const hookScripts = resolveHookScripts(events); const srcHooksDir = path.join(src, 'hooks'); const installedScripts = new Set(); for (const script of hookScripts) { @@ -1071,20 +1065,16 @@ function writeCursorHooksJson(targetDir: string, src: string, opts?: WriteCursor } const hookOpts: BuildHookCommandOpts = { runtime: 'cursor', platform: opts.platform || process.platform }; - const managedEntries: CursorManagedEntries = {}; - for (const ev of CURSOR_MANAGED_EVENTS) { - const script = eventScriptMap[ev]; + const commands: Record = {}; + for (const ev of events) { + const script = CURSOR_EVENT_SCRIPT_MAP[ev]; if (script && installedScripts.has(script)) { - const cmd = buildHookCommand(targetDir, script, hookOpts); - if (cmd) { - managedEntries[ev] = { - type: 'command', - command: cmd, - [GSD_CURSOR_HOOK_MARKER]: true, - }; - } + commands[ev] = buildHookCommand(targetDir, script, hookOpts); + } else { + commands[ev] = null; } } + const managedEntries = buildHookBusEntries(events, commands) as CursorManagedEntries; const hooksJsonPath = path.join(targetDir, 'hooks.json'); const result = reconcileCursorHooksJson(hooksJsonPath, managedEntries); diff --git a/tests/cursor-hook-bus-upgrade.test.cjs b/tests/cursor-hook-bus-upgrade.test.cjs index 23ca22e4d..881bb1a6e 100644 --- a/tests/cursor-hook-bus-upgrade.test.cjs +++ b/tests/cursor-hook-bus-upgrade.test.cjs @@ -114,68 +114,60 @@ test('all 6 hook scripts exist under hooks/', () => { test('reconcileCursorHooksJson writes all 6 managed events into hooks.json', (t) => { const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-cursor-hook-bus-')); t.after(() => cleanup(tmpDir)); - try { - const hooksJsonPath = path.join(tmpDir, 'hooks.json'); - const managedEntries = {}; - for (const ev of EXPECTED_EVENTS) { - managedEntries[ev] = { - type: 'command', - command: `node /fake/${ev}.js`, - [GSD_CURSOR_HOOK_MARKER]: true, - }; - } - const result = reconcileCursorHooksJson(hooksJsonPath, managedEntries); - assert.ok(result.changed, 'first write must report changed=true'); + const hooksJsonPath = path.join(tmpDir, 'hooks.json'); + const managedEntries = {}; + for (const ev of EXPECTED_EVENTS) { + managedEntries[ev] = { + type: 'command', + command: `node /fake/${ev}.js`, + [GSD_CURSOR_HOOK_MARKER]: true, + }; + } + const result = reconcileCursorHooksJson(hooksJsonPath, managedEntries); + assert.ok(result.changed, 'first write must report changed=true'); - const written = JSON.parse(fs.readFileSync(hooksJsonPath, 'utf8')); - const hookTable = written.hooks; - assert.ok(hookTable && typeof hookTable === 'object'); - for (const ev of EXPECTED_EVENTS) { - assert.ok(Array.isArray(hookTable[ev]), - `hooks.json must have a ${ev} array`); - assert.equal(hookTable[ev].length, 1, - `${ev} must have exactly 1 managed entry`); - assert.equal(hookTable[ev][0][GSD_CURSOR_HOOK_MARKER], true, - `${ev} entry must carry the GSD managed marker`); - } - } finally { - cleanup(tmpDir); + const written = JSON.parse(fs.readFileSync(hooksJsonPath, 'utf8')); + const hookTable = written.hooks; + assert.ok(hookTable && typeof hookTable === 'object'); + for (const ev of EXPECTED_EVENTS) { + assert.ok(Array.isArray(hookTable[ev]), + `hooks.json must have a ${ev} array`); + assert.equal(hookTable[ev].length, 1, + `${ev} must have exactly 1 managed entry`); + assert.equal(hookTable[ev][0][GSD_CURSOR_HOOK_MARKER], true, + `${ev} entry must carry the GSD managed marker`); } }); test('reconcileCursorHooksJson preserves user entries across all 6 events', (t) => { const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-cursor-hook-bus-')); t.after(() => cleanup(tmpDir)); - try { - const hooksJsonPath = path.join(tmpDir, 'hooks.json'); - // Seed with user-owned entries in two events. - const seed = { - version: 1, - hooks: { - sessionStart: [{ type: 'command', command: 'user-start.sh' }], - preToolUse: [{ type: 'command', command: 'user-pre.sh' }], - }, + const hooksJsonPath = path.join(tmpDir, 'hooks.json'); + // Seed with user-owned entries in two events. + const seed = { + version: 1, + hooks: { + sessionStart: [{ type: 'command', command: 'user-start.sh' }], + preToolUse: [{ type: 'command', command: 'user-pre.sh' }], + }, + }; + fs.writeFileSync(hooksJsonPath, JSON.stringify(seed, null, 2) + '\n'); + + const managedEntries = {}; + for (const ev of EXPECTED_EVENTS) { + managedEntries[ev] = { + type: 'command', + command: `node /gsd/${ev}.js`, + [GSD_CURSOR_HOOK_MARKER]: true, }; - fs.writeFileSync(hooksJsonPath, JSON.stringify(seed, null, 2) + '\n'); - - const managedEntries = {}; - for (const ev of EXPECTED_EVENTS) { - managedEntries[ev] = { - type: 'command', - command: `node /gsd/${ev}.js`, - [GSD_CURSOR_HOOK_MARKER]: true, - }; - } - reconcileCursorHooksJson(hooksJsonPath, managedEntries); - - const written = JSON.parse(fs.readFileSync(hooksJsonPath, 'utf8')); - // sessionStart: 1 user + 1 managed - assert.equal(written.hooks.sessionStart.length, 2); - // preToolUse: 1 user + 1 managed - assert.equal(written.hooks.preToolUse.length, 2); - // postToolUse: 1 managed only - assert.equal(written.hooks.postToolUse.length, 1); - } finally { - cleanup(tmpDir); } + reconcileCursorHooksJson(hooksJsonPath, managedEntries); + + const written = JSON.parse(fs.readFileSync(hooksJsonPath, 'utf8')); + // sessionStart: 1 user + 1 managed + assert.equal(written.hooks.sessionStart.length, 2); + // preToolUse: 1 user + 1 managed + assert.equal(written.hooks.preToolUse.length, 2); + // postToolUse: 1 managed only + assert.equal(written.hooks.postToolUse.length, 1); }); diff --git a/tests/cursor-imperative-reference.test.cjs b/tests/cursor-imperative-reference.test.cjs index 1a412b579..f3e0209a8 100644 --- a/tests/cursor-imperative-reference.test.cjs +++ b/tests/cursor-imperative-reference.test.cjs @@ -118,7 +118,7 @@ test('no `runtime === "cursor"` string-equality branch remains in the install so .replace(/\/\*[\s\S]*?\*\//g, '') .replace(/\/\/[^\r\n]*/g, '') .replace(/`[^`]*`/g, ''); - for (const rel of ['bin/install.js', 'src/install-engine.cts', 'src/runtime-artifact-conversion.cts']) { + for (const rel of ['bin/install.js', 'src/install-engine.cts', 'src/runtime-artifact-conversion.cts', 'src/runtime-hooks-surface.cts']) { const src = fs.readFileSync(path.join(__dirname, '..', rel), 'utf8'); const offenders = strip(src).match(/runtime\s*[!=]==\s*'cursor'/g) || []; assert.deepEqual(offenders, [], `AC2: no hardcoded runtime==='cursor' branch may remain in ${rel}; found: ${offenders.join(', ')}`); From c25b212c627a34922bcee79d2468ef2dafc996cc Mon Sep 17 00:00:00 2001 From: Tom Boucher Date: Thu, 9 Jul 2026 13:39:24 -0400 Subject: [PATCH 29/33] revert: restore gsd-cursor-pre-tool.js dead imports (golden parity) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Reverts the LOW-severity dead-import removal (require('fs')/require('path')) that changed the file hash and broke 9 golden-install-parity fixtures. The golden test computes per-runtime hashes of installed hook files; regenerating all 9 fixtures for a cosmetic cleanup is disproportionate. Dead imports are harmless (Node caches built-in requires) — noted as a follow-up nit. --- hooks/gsd-cursor-pre-tool.js | 3 +++ 1 file changed, 3 insertions(+) diff --git a/hooks/gsd-cursor-pre-tool.js b/hooks/gsd-cursor-pre-tool.js index d0d96e4c2..a608255c0 100644 --- a/hooks/gsd-cursor-pre-tool.js +++ b/hooks/gsd-cursor-pre-tool.js @@ -22,6 +22,9 @@ 'use strict'; +const fs = require('fs'); +const path = require('path'); + const WRITE_TOOL_RE = /write|edit|replace|create|delete|remove|append|apply|patch|insert|mkdir/i; const PATH_KEY_RE = /^(path|file|file_?path|filepath|target_?path|target|dir|directory|uri|filename)$/i; const PLANNING_PATH_RE = /(^|[\\/])\.planning([\\/]|$)/; From 39116ba00159d02ba76f13a37b294b5182bdb98a Mon Sep 17 00:00:00 2001 From: Tom Boucher Date: Thu, 9 Jul 2026 13:59:48 -0400 Subject: [PATCH 30/33] docs(changeset): backfill pr 2120 for #2089 --- .changeset/2089-eos-cursor-imperative-adapter.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.changeset/2089-eos-cursor-imperative-adapter.md b/.changeset/2089-eos-cursor-imperative-adapter.md index 32a3d120c..870d57748 100644 --- a/.changeset/2089-eos-cursor-imperative-adapter.md +++ b/.changeset/2089-eos-cursor-imperative-adapter.md @@ -1,5 +1,5 @@ --- type: Changed -pr: 0 +pr: 2120 --- **Cursor is now driven through the public Host-Integration Interface, with two capability upgrades (ADR-1239 / EoS).** Cursor previously installed via hardcoded `runtime === 'cursor'`/`isCursor` branches in `bin/install.js`; its install/uninstall now runs through the imperative adapter, and every hardcoded cursor branch is folded into descriptor-driven `runtime.hostBehaviors` (reapplyCommand, frontmatterDialect, hooksJsonSurface, skipSharedHooksInstall, reportCommandsDir, managedHookEvents). Install/uninstall output is **byte-identical** (golden parity asserted for all 16 runtimes). Two Context7-verified upgrades land: (1) **expanded hook-bus coverage** — GSD registers all 6 managed lifecycle events in Cursor's `hooks.json` (`preToolUse`, `stop`, `subagentStart`, `subagentStop` in addition to the original `sessionStart`/`postToolUse`), driven by a new descriptor-driven adapter module (`src/host-integration-adapters/imperative-hook-bus.cts`) that reads `hostBehaviors.managedHookEvents` instead of a hardcoded event pair; cite https://cursor.com/docs/hooks. (2) **named/background nested subagent dispatch** — Cursor's `dispatch.background`/`backgroundDispatch`/`nested` are all `true` with `maxDepth: 2`, so `shouldFlattenDispatch(cursor)` returns `false` and GSD's wave-based execution drives Cursor's native background + depth-2 nested subagent invocation instead of flattening to inline sequential calls; cite https://cursor.com/docs/subagents + https://cursor.com/docs/sdk/typescript. (#2089) From 12d50093e1078de1f7dc9aa0057028d9bd602981 Mon Sep 17 00:00:00 2001 From: Tom Boucher Date: Thu, 9 Jul 2026 15:09:53 -0400 Subject: [PATCH 31/33] docs(#2121): add ADR-2121 phase-identifier parsing consolidation (Phase 0) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Phase 0 of the #2121 epic — an ADR-only PR that LOCKS the contract Phases 1-4 execute against. No production code lands here. Locks: - phase-id.cts as the single canonical owner of phase-identifier parsing. - New pure exports Phase 1 adds: parsePhaseFromProse (anchored; fixes the #2111 "Milestone v0.5 complete -> 5" class), stripConfiguredProjectCodePrefix / isForeignPrefixedPhaseQuery (config-aware; the #2104 fix's home), and roadmapPhaseLookupSources moved in as sole owner of the 3-source ordering (fixes the #2114 2-vs-3-source divergence). - Extend-never-mutate on the 12 existing exports (normalizePhaseName has a CRITICAL 84-symbol / 20-caller blast radius) — Hyrum's Law. - The exact exact->numeric->prefix-tolerant lookup ordering. - A behavioral anti-divergence contract: reference-identity guard + scripts/lint-phase-id-drift.cjs scanner, modeled on the repo's proven capability-precedence-parity / package-identity-drift patterns. #2104 remains blocked on PR #2105 and off this epic's critical path. Adds the docs/adr/README.md index row. Docs-only; no changeset required (no-changelog). Closes #2121 Co-Authored-By: Claude Opus 4.8 --- ...-phase-identifier-parsing-consolidation.md | 168 ++++++++++++++++++ docs/adr/README.md | 1 + 2 files changed, 169 insertions(+) create mode 100644 docs/adr/2121-phase-identifier-parsing-consolidation.md diff --git a/docs/adr/2121-phase-identifier-parsing-consolidation.md b/docs/adr/2121-phase-identifier-parsing-consolidation.md new file mode 100644 index 000000000..6dd67b024 --- /dev/null +++ b/docs/adr/2121-phase-identifier-parsing-consolidation.md @@ -0,0 +1,168 @@ +# ADR-2121: Phase-Identifier Parsing Consolidation + +- **Status:** Accepted (Phase 0 — ADR only; locks the contract Phases 1–4 execute against. No production code lands in this PR.) +- **Date:** 2026-07-09 +- **Issue:** [#2121](https://github.com/open-gsd/gsd-core/issues/2121) — epic (tech-debt / root-cause consolidation, `type: chore` + `approved-enhancement`) +- **Supersedes:** nothing +- **Relationship to prior work:** completes [#1455](https://github.com/open-gsd/gsd-core/issues/1455) (which introduced the prefix-tolerant lookup source but only in `roadmap-parser.cts`); it is the parser-layer analog of the `package-identity.cjs` single-source seam (ADR-referenced by `scripts/lint-package-identity-drift.cjs`). + +## Context + +Phase-identifier parsing — turning a phase reference (`3`, `03`, `12A`, `2.7`, `2-01`, `CK-01`, `AB-29`, `Milestone v0.5 complete`) into a normalized identity, a ROADMAP heading match, or a resolved phase — is **implemented independently in at least six modules**. `src/phase-id.cts` exists and is *meant* to be the canonical normalizer, but the surrounding modules each roll their own regex instead of delegating. A fix or invariant lands on one surface and its siblings silently diverge, so the same defect keeps re-surfacing under new issue numbers. + +This is exactly the class `CLAUDE.md` warns about under **Generative Fix Divergence**: + +> When sharing constants/arrays/parsers between parallel surfaces, add a parity assertion test that fails if they diverge. + +The guard rule exists in the standards, but it is not applied to phase-ID parsing. Three confirmed bugs are the direct consequence: + +| Symptom issue | Site | Divergent behavior | Blocked? | +|---|---|---|---| +| [#2111](https://github.com/open-gsd/gsd-core/issues/2111) | `state.cts:parseProsePhaseField` (`state.cts:1118-1131`) | `/\b(\d+[A-Z]?(?:\.\d+)*)\b/i` mines the *first* numeral in a prose `Phase:` line; `Milestone v0.5 complete` → `5`, `v1.0` → `0` (a reserved sentinel). `milestone complete v0.5` writes `current_phase: 5` instead of the real last phase. | No | +| [#2114](https://github.com/open-gsd/gsd-core/issues/2114) | `roadmap.cts:cmdRoadmapGetPhase` (`roadmap.cts:238-303`) + `getRoadmapPhaseWithFallback` (`roadmap.cts:209-234`) | Both hand-roll a **2-source** lookup (exact → numeric). `getRoadmapPhaseInternal` (`roadmap-parser.cts:262-287`) loops a **3-source** pass (exact → numeric → prefix-tolerant) via `roadmapPhaseLookupSources`. `roadmap get-phase 29` returns empty for `### Phase AB-29:` while `init.phase-op 29` resolves it. | No | +| [#2104](https://github.com/open-gsd/gsd-core/issues/2104) | `phase-id.cts:normalizePhaseName` / `stripProjectCodePrefix` (`phase-id.cts:44-77`) | `PROJECT_CODE_PREFIX_STRIP_RE_I = /^[A-Z][A-Z0-9_]*-(?=\d)/i` strips *any* prefix-shaped token with no check against the configured `project_code`; `MEM-01` collapses to bare `01` even when the project code is `LKML`. The #2056 guard was added to `cmdInitPlanPhase` only; the three sibling init commands still collapse foreign prefixes. | **Yes — sequenced after PR #2105 (#2056)** | + +### Why #1455 did not close the loop + +`git show 2dedbdd11` (fix(#1455)) touched `phase-id.cts`, `phase.cts`, `roadmap-parser.cts`, `roadmap-upgrade.cts`, `validate.cts` — **not `roadmap.cts`**. It added `OPTIONAL_PROJECT_CODE_PREFIX_SOURCE` (`phase-id.cts:24`) and the third lookup source inside `roadmapPhaseLookupSources` (`roadmap-parser.cts:245-260`), but `roadmap.cts` never imported the constant. The mechanical root of #2114 is an import-list asymmetry: `roadmap.cts:16-17` destructures seven names from `phase-id.cjs` but omits `OPTIONAL_PROJECT_CODE_PREFIX_SOURCE`, so it *structurally cannot* build the prefix-tolerant source today. A point fix on `roadmap.cts` would leave the divergence itself — one seam missing, N call sites free to re-diverge — fully intact. + +### The full divergent surface (broader than the three modules the issue names) + +Memtrace blast-radius analysis (`get_impact normalizePhaseName` → **risk CRITICAL, 84 affected symbols, 20 direct callers across 19 files**) and a symbol sweep surfaced the complete surface. This matters because it bounds both the back-compat risk and the guard's scope: + +- **`phase-id.cts` (the partial canonical seam, 272 lines, pure — "no Node built-ins").** Already owns: `escapeRegex` (`:15`), `OPTIONAL_PROJECT_CODE_PREFIX_SOURCE` (`:24`), `OPTIONAL_PHASE_TAG_SOURCE` (`:42`), `stripProjectCodePrefix` (`:44`), `normalizePhaseName` (`:54`), `getMilestoneFromPhaseId` (`:79`), `getPhaseDirFromPhaseId` (`:88`), `phaseMarkdownRegexSource` (`:107`), `phaseMarkdownRegexSourceExact` (`:138`), `comparePhaseNum` (`:144`), `extractPhaseToken` (`:197`), `phaseTokenMatches` (`:247`). It does **not** parse-from-prose, and its regex-source builders are consumed by callers, not applied here. +- **`state.cts` — five independently-maintained phase-token regex shapes:** `parseProsePhaseField` (`:1120`, the `\b…\b` miner), its unanchored twins `resolvePhaseIdForCompletePhase` (`:2722`) and `cmdStateCompletePhase` (`:2750`), the `Phase`-anchored `extractRetiredPhaseNumbers` (`:1319`), the strip/pad idiom in `cmdStateValidate` (`:2291`,`:2294`), and the digits-only dir shape in `phaseInventoryProvider` (`:2642`). Only `phaseKeyFromToken`/`phaseKeyFromDir` (`:1283-1288`) delegate to `phase-id.cjs`. +- **`roadmap.cts` — four regex-construction sites** (`:410`, `:528`, `:787`, plus the two named CLI functions). The three standalone sites already use the canonical `phaseMarkdownRegexSource` builder; only the two named functions diverge (no prefix-tolerant source). +- **`roadmap-parser.cts`** — `roadmapPhaseLookupSources` (`:245`, the canonical 3-source ordering), `getRoadmapPhaseInternal` (`:262`, the one impure resolver), `findRoadmapPhaseInContent` (`:214`). +- **`init.cts`** — on `next` today, init resolves a phase query through the config-blind `stripProjectCodePrefix` / `normalizePhaseName` path (`init.cts:76`, `:1184`), so its three sibling commands (`cmdInitExecutePhase`, `cmdInitVerifyWork`, `cmdInitPhaseOp`) collapse foreign-prefixed IDs (the #2104 symptom). The config-aware guard family (`parsePhasePrefix` / `isForeignPrefixedPhaseQuery` / `roadmapPhaseMatchesExactPrefix`, with `/^([A-Z][A-Z0-9_]*)-(?=\d)/i`) is being introduced by #2056 on the **unmerged PR #2105** (`fix/2056-plan-phase-foreign-prefix`) and is **not yet on `next`** — it is **Cluster 1 / #2104 domain**, and its line numbers are omitted here deliberately because they will drift when #2105 lands. +- **`validate.cts`** — `buildNotStartedPhaseVariants` with its own `Phase\s+([\w][\w.-]*)` regex; `phaseVariants`. +- **Two distinct ROADMAP content matchers:** `searchPhaseInContent` (`roadmap.cts:126`, uses `OPTIONAL_PHASE_TAG_SOURCE` + a checklist fallback + `tokenizeHeadings`) vs `findRoadmapPhaseInContent` (`roadmap-parser.cts:214`). +- **`escapeRegex` is duplicated** in `phase-id.cts:15` and `state-document.cts:11`. + +Per `CONTRIBUTING.md`: + +> **One issue = one ADR-or-PRD = one PR.** Do not batch multiple decisions into one file or one PR. + +This ADR is that one file. It decides and **locks** the target seam; it ships no production code. Phases 1–4 execute against it as separate PRs. + +## Decision + +Make `src/phase-id.cts` the **single canonical owner** of every phase-identifier operation, migrate the divergent consumers to delegate to it, and add a machine-enforced anti-divergence guard so no future module can re-implement phase-ID parsing without failing CI. Seven decisions, locked below. + +### 1. `phase-id.cts` is the sole owner of phase-identifier parsing + +"Phase-identifier parsing" is defined as the closed set of operations: **normalize**, **compare**, **project-code prefix policy**, **parse-from-prose**, **parse-from-heading (regex-source construction + lookup-source ordering)**, **parse-from-dir-name (token extraction + match predicate)**, and **parse-from-CLI-query**. Every function in that set lives in `phase-id.cts` (or, for the one operation that must touch the filesystem, in the single resolver named in Decision 5). Consumers `require`/`import` the canonical functions; **no consumer defines a phase-identifier regex locally.** + +*Rejected:* (B) a new `phase-resolver.cts` module — rejected because `phase-id.cts` already holds twelve of these functions and 20 direct callers; a new module would create a *second* seam and worsen the divergence it aims to fix. (C) leave parsing distributed but add a lint that all sites match a golden regex — rejected because it enforces textual sameness, not single-ownership, and cannot cover the semantic divergences (`\b…\b` vs unanchored vs `Phase`-anchored are all "valid" regex). + +### 2. Extend, never mutate — the backward-compatibility guarantee (Hyrum's Law) + +`normalizePhaseName` has a **CRITICAL** blast radius (84 affected symbols, 20 direct callers, 19 files). Its observable behavior — zero-padding, unconditional prefix stripping, letter-case preservation (`#1962`), milestone-form decomposition — is depended upon everywhere. **Locked:** Phases 1–4 may only *add* exported functions to `phase-id.cts`; they may **not** change the observable behavior of any of the twelve existing exports. Any behavior change to an existing export (including "fixing" the config-blind strip in place) is out of scope for this epic and requires its own ADR. The #2104 fix is delivered as a **new, config-aware** function (Decision 4), leaving the existing config-blind path untouched for its 20 callers. + +### 3. The locked canonical surface (the exports Phase 1 adds) + +Phase 1 adds exactly these pure functions to `phase-id.cts`. Signatures and contracts are **locked**; Phases 2–4 consume them verbatim. + +**`parsePhaseFromProse(value: string | null): { phase: string | null; name: string | null }`** +The anchored replacement for `state.cts:parseProsePhaseField`. It extracts a phase identifier **only** from a genuine phase reference — the literal token `Phase ` (optionally `Phase : `, `Phase — `, or `Phase of `). Invariants this seam pins: +- A milestone-completion string carries **no** phase: `parsePhaseFromProse('Milestone v0.5 complete')` → `{ phase: null, name: null }` (fixes #2111). Likewise `v1.0`, `v2.10`, and any `Milestone v…` form. +- A real reference parses: `'Phase 3A — Delta (executing)'` → `{ phase: '3A', name: 'Delta' }`. +- It never mines a stray numeral from surrounding prose; absence of a `Phase` anchor yields `{ phase: null }`, not a guessed number. + +**`stripConfiguredProjectCodePrefix(value: unknown, projectCode: string | null | undefined): string`** +The config-aware prefix stripper. Strips a leading `-` **only** when `` case-insensitively equals `projectCode`; a foreign prefix (`MEM-` when the code is `LKML`) or an absent/empty `projectCode` leaves the value **verbatim**. This is the canonical home for the #2104 fix. It *complements* — does not replace — the existing config-blind `stripProjectCodePrefix` (Decision 2). + +**`isForeignPrefixedPhaseQuery(phase: unknown, projectCode: unknown): boolean`** +The canonical predicate that #2056's guard family — arriving on the **unmerged PR #2105**, not yet on `next` — will delegate to once it lands: `true` when `phase` carries a prefix that is not the configured `projectCode`. Locking it here means #2105's `cmdInitPlanPhase` guard and the three #2104 sibling commands share **one** foreign-prefix rule instead of the divergent copies they would otherwise seed. + +**`roadmapPhaseLookupSources(phaseNum: unknown): string[]`** *(moved from `roadmap-parser.cts:245-260`)* +The canonical heading lookup-source builder becomes an owned export of `phase-id.cts` (it is already pure — it only composes regex-source strings). All three roadmap call sites consume it, so the ordering (Decision 5) has exactly one definition. + +**Parse-from-CLI-query — no new function (locked).** A CLI-supplied phase argument (`gsd-tools … `) is resolved by *composing existing locked primitives*, not a new parser: `extractPhaseToken` / `normalizePhaseName` (token + normalize, Decision 2) → `isForeignPrefixedPhaseQuery` / `stripConfiguredProjectCodePrefix` (config-aware prefix policy, Decision 4) → `phaseTokenMatches` for dir-name resolution or `roadmapPhaseLookupSources` → `getRoadmapPhaseInternal` for heading resolution. This is deliberately *not* a distinct `parseCliQuery` function: callers already know they hold a CLI arg, and a discriminated god-parser would re-widen the accept surface (Postel's Law). The lock is that CLI-query resolution routes through these primitives only — no consumer re-derives a phase from a CLI arg with its own regex. + +*Rejected:* (B) fixing `parseProsePhaseField` in place with a tighter regex but leaving it in `state.cts` — rejected because the fix would not be reusable by the other prose sites and would re-seed the divergence. (C) a single mega-parser `parsePhaseId(input, kind)` with a `kind` discriminator — rejected (Postel's Law / interface clarity): callers already know whether they hold prose, a heading, a dir name, or a CLI arg; a discriminated god-function hides that and widens the accept surface. + +### 4. Project-code prefix policy — config-aware stripping is the resolution path + +**Locked policy:** a project-code prefix is a *display* prefix. For **identity/normalization** where config is unavailable, the config-blind `stripProjectCodePrefix` remains (back-compat). For **resolution of a caller-supplied query** (init commands, roadmap lookup) the config-aware `stripConfiguredProjectCodePrefix` / `isForeignPrefixedPhaseQuery` are the path: a query whose prefix is *not* this project's code must not collapse to a bare number and match a foreign phase. This tightens an over-liberal accept surface (Postel's Law) without touching the 20 callers of the blind stripper. + +### 5. Lookup-source ordering — the locked invariant + +The canonical resolution tries sources in this exact, de-duplicated order (as `roadmapPhaseLookupSources` implements today at `roadmap-parser.cts:251-259`): + +1. **Exact** — `phaseMarkdownRegexSourceExact(phaseNum)` — non-null only when the query itself carries a prefix; matches `### Phase PROJ-42:` verbatim. +2. **Numeric / padding-tolerant** — `phaseMarkdownRegexSource(phaseNum)` — the canonical bare heading (`### Phase 42:`), padding-tolerant (`0*N`). +3. **Prefix-tolerant** — `` `${OPTIONAL_PROJECT_CODE_PREFIX_SOURCE}${numericSource}` `` — the drifted-only fallback (`### Phase MANIFOLD-117:` for a bare `117` query), de-duplicated via `[...new Set(sources)]`. + +**Order matters and is locked:** bare-numeric is tried *before* prefix-tolerant so a canonical heading wins over a drifted one when both exist. The single impure ROADMAP resolver is **`getRoadmapPhaseInternal` (`roadmap-parser.cts:262`)** — it reads `ROADMAP.md` and loops these sources. `roadmap.cts`'s CLI siblings (`cmdRoadmapGetPhase`, `getRoadmapPhaseWithFallback`) **delegate to it** rather than re-scanning content, collapsing the `searchPhaseInContent` vs `findRoadmapPhaseInContent` duplication onto one resolution path — this is precisely the delegation #2114 requests. + +### 6. Migration order & backward-compatibility guarantees + +Each phase is its own small PR, opened under a fresh `chore(#2121): … — Phase N` sub-issue, and lands **in order** — Phase N+1 does not begin until Phase N merges. No phase changes any observable CLI output **except** the corrected resolution for the cited symptom cases. + +| Phase | Scope | Drives green | Sub-issue | +|---|---|---|---| +| **0** | This ADR — lock the contract. No production code. | — | Closes #2121 | +| **1** | Add the Decision-3 functions to `phase-id.cts`; move `roadmapPhaseLookupSources` in. Exhaustive unit tests + boundary cases (`v0.5`, `v1.0`, `MEM-01`, `AB-29`, bare `29`, zero-padded `029`) + ≥1 `fast-check` property test for the parse↔normalize contract. **No consumer changes.** | — | new | +| **2** | Migrate `state.cts` prose/number sites (`parseProsePhaseField` → `parsePhaseFromProse`; the unanchored twins `resolvePhaseIdForCompletePhase`, `cmdStateCompletePhase`; align the dir/pad shapes on `extractPhaseToken`/`normalizePhaseName`). Regression-first: assert `current_phase` survives a `milestone complete v0.5` close unchanged. | **#2111** | new | +| **3** | Delegate `roadmap.cts:cmdRoadmapGetPhase` + `getRoadmapPhaseWithFallback` to `getRoadmapPhaseInternal` / `roadmapPhaseLookupSources`. Regression-first: `roadmap get-phase ` resolves `### Phase AB-N:`, and both CLI siblings route through the same lookup sources as the internal resolver. | **#2114** | new | +| **4** | Add the Decision-7 anti-divergence guard; inventory-sweep the remaining sites. | closes the recurrence loop | new | + +**#2104 disposition (locked):** Phase 1 builds the config-aware prefix API (Decision 4) so #2104's fix has a canonical home, but **#2104's own migration** (applying the guard to `cmdInitExecutePhase` / `cmdInitVerifyWork` / `cmdInitPhaseOp`) is **blocked on PR #2105 (#2056)** — the guard helpers it must reuse do not exist on `next` yet — and stays tracked on #2104, **outside this epic's critical path**. Phases 1–4 must not block on #2104, and #2104's init sites are allowlisted by the Phase-4 guard (Decision 7) until #2105 lands. + +### 7. The anti-divergence contract (the parity guard) + +**Locked mechanism**, modeled on the repo's two proven single-source patterns — `tests/capability-precedence-parity.test.cjs` (identity guard) and `scripts/lint-package-identity-drift.cjs` + `tests/issue-498-identity-drift-lint.test.cjs` (drift scanner): + +1. **Identity guard test** — for every consumer that re-exports a canonical phase-ID function, assert reference identity: `assert.strictEqual(consumer.fn, phaseId.fn)`. A pasted re-implementation is a different function object and fails instantly (the mechanism `capability-precedence-parity.test.cjs:44-51` uses). +2. **Drift scanner** — `scripts/lint-phase-id-drift.cjs` exports a **pure** `findPhaseIdRegexDrift(text, opts)` that flags phase-ID-shaped regex literals (`\d+[A-Z]?(?:\.\d+)*`, `[A-Z][A-Z0-9_]*-`, and `Phase\s+…:` heading builders) defined in any `src/*.cts` other than `phase-id.cts`. It is wired to `npm run check:phase-id-drift` and asserted zero via a `scanRepo(ROOT)` integration test. A narrow allowlist keyed by an explicit `// phase-id-owner: ` comment covers sanctioned exceptions (e.g. Cluster-1 / #2104 init sites, `// phase-id-owner: cluster-1-#2104`) until they migrate. + +**Locked constraints on the guard's own implementation** (so it does not become new tech debt): +- It must be **behavioral**, not a `readFileSync(path).includes(...)` inside a `tests/**/*.test.cjs` file — that trips `eslint-rules/no-source-grep.cjs` (bound `local/no-source-grep`, `error` in tests). Text-scanning lives in the `scripts/` pure function; the test `require()`s it and calls it with **inline string literals**, per `tests/issue-498-identity-drift-lint.test.cjs:21-25`. +- It must **not** be modeled on `tests/package-name-single-source.test.cjs`, which only *appears* to satisfy `no-source-grep` because the rule's taint-tracking loses the variable after `.split()` — an evasion, not an exemption. + +*Rejected:* (B) an ESLint `no-restricted-syntax` rule — the repo has exactly one such rule (test-timing hygiene, `eslint.config.mjs:363`) and no single-ownership lint precedent; a `node:test` behavioral contract is the established, proven pattern. (C) outcome-parity only (run two paths, diff outputs, as `tests/phase.test.cjs:6881` `expectParity` does for #3537) — necessary but insufficient: it proves two paths *agree today*, not that only one *implementation* exists, so it cannot catch a third divergent site added tomorrow. + +## Consequences + +**Positive:** +- The recurring #2111/#2114-class bug is root-caused, not point-fixed: one seam owns the parsing, and the Phase-4 guard makes re-divergence a CI failure rather than a future issue number. +- #2114's `roadmap get-phase` / `ui-plan-gate` split is closed by delegation, and the two ROADMAP content matchers collapse to one resolver. +- The config-aware prefix API gives #2104 (and its siblings #1836, #2056) a single correct home the moment #2105 lands. +- Callers gain named, tested parsing functions in place of inline `\b…\b` cleverness that is hard to read and harder to debug (Kernighan's Law). + +**Negative:** +- Touching `normalizePhaseName`'s neighborhood is high-risk (84 affected symbols). Decision 2 (extend-never-mutate) contains the risk but constrains the design — the fixes must be new functions, not tighter versions of the old ones. +- Four sequential PRs plus sub-issues is more process overhead than a single "fix the three bugs" PR — accepted, because a batched fix would re-seed the very divergence this epic removes and violates one-issue-one-PR. +- The Phase-4 guard adds an allowlist that must be curated as #2104/#2105 land; a stale allowlist entry is a small, visible debt rather than a silent gap. + +**Neutral:** +- `phase-id.cts` grows from a normalizer into the full phase-ID surface; it stays pure (no Node built-ins), so the FS-touching resolver deliberately remains `getRoadmapPhaseInternal` in `roadmap-parser.cts`. +- `#2104` remains open and independently tracked; this epic neither closes nor blocks on it. + +## Alternatives considered + +1. **Point-fix each of the three bugs in place.** Rejected: leaves the seam absent, so surface #4 (init.cts, validate.cts) re-diverges under the next issue number — the exact history from #905 → #2111, #1455 → #2114, #2056 → #2104. +2. **One big PR consolidating everything at once.** Rejected: violates `CONTRIBUTING.md` "One issue = one … = one PR"; unreviewable across a CRITICAL-blast-radius symbol; no fail-first regression discipline per bug. +3. **Golden-regex lint (all sites must textually match one pattern).** Rejected: enforces textual sameness, not single ownership, and cannot express the semantic divergence (anchored vs unanchored vs `Phase`-anchored are all syntactically valid). +4. **Second module (`phase-resolver.cts`).** Rejected: a new seam alongside the existing `phase-id.cts` seam deepens, rather than removes, the divergence. + +## Software laws applied + +Cross-referenced via `/skills-from-the-artificer`; the laws that materially shaped the decisions: + +- **Hyrum's Law** — `normalizePhaseName`'s 20 callers depend on its observable behavior ⇒ *extend, never mutate* (Decision 2). +- **Postel's Law** — the current parsers are too liberal (mine any numeral; strip any prefix) ⇒ tighten acceptance to the anchored / config-aware forms (Decisions 3–4). +- **Gall's Law** — `phase-id.cts` is a working simple system; grow it incrementally through a phased epic rather than a big-bang rewrite (Decisions 1, 6). +- **DRY / single-source-of-truth (Generative Fix Divergence)** — one seam, guarded, is the whole point (Decisions 1, 7). +- **Kernighan's Law** — inline `\b…\b` one-liners are hard to debug; naming + centralizing them lowers the debugging cost the bugs were paying (Decision 3). + +## Cross-references + +- Symptom issues: [#2111](https://github.com/open-gsd/gsd-core/issues/2111), [#2114](https://github.com/open-gsd/gsd-core/issues/2114), [#2104](https://github.com/open-gsd/gsd-core/issues/2104) (blocked on [#2105](https://github.com/open-gsd/gsd-core/issues/2105)/#2056). +- Prior art: #1455 (`OPTIONAL_PROJECT_CODE_PREFIX_SOURCE`, `roadmap-parser.cts`); `CLAUDE.md` → "Generative Fix Divergence"; `scripts/lint-package-identity-drift.cjs` + `tests/capability-precedence-parity.test.cjs` (the guard models). +- Owner seam: `src/phase-id.cts`. Impure resolver: `src/roadmap-parser.cts:getRoadmapPhaseInternal`. + +## Amendments + +*(none yet — append-only; amendments extend this ADR with a dated `### # — ` section rather than rewriting the body above.)* diff --git a/docs/adr/README.md b/docs/adr/README.md index 03038a5c9..5b67d9de0 100644 --- a/docs/adr/README.md +++ b/docs/adr/README.md @@ -65,6 +65,7 @@ See **[CONTRIBUTING.md — "Proposing an ADR or PRD"](../../CONTRIBUTING.md#prop | [1817-state-md-rebuild-derivability-contract.md](1817-state-md-rebuild-derivability-contract.md) | STATE.md rebuild — derivability contract (capstone 11th transition) | Accepted | | [2008-command-exit-zero-gate.md](2008-command-exit-zero-gate.md) | Generic gate-predicate evaluator with a `command-exit-zero` kind (#2008) | Accepted | | [1990-existing-code-onboarding.md](1990-existing-code-onboarding.md) | Existing Code Onboarding Module owns deterministic repo-state detection and onboarding route selection | Proposed | +| [2121-phase-identifier-parsing-consolidation.md](2121-phase-identifier-parsing-consolidation.md) | Phase-identifier parsing consolidation — single canonical owner (phase-id.cts) + anti-divergence guard | Accepted | ## Seam map From bf25e0b4358b5867442786267e6b0d48d5c1c0a5 Mon Sep 17 00:00:00 2001 From: Tom Boucher Date: Thu, 9 Jul 2026 16:13:06 -0400 Subject: [PATCH 32/33] chore(#2124): build canonical phase-id.cts surface (Phase 1 of #2121) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add the ADR-2121-locked canonical functions to src/phase-id.cts. No consumer behavior changes — Phases 2-4 migrate the divergent call sites against them. - parsePhaseFromProse: anchored prose parser. A phase is returned only when the STATE.md "Phase:" field VALUE begins with a phase token, so "Milestone v0.5 complete" yields { phase: null } instead of "5" (the #2111 root cause: the old unanchored \b(\d+..)\b mined the minor-version digit). Name extraction (parenthetical / em-dash tail, minus status words) unchanged. - stripConfiguredProjectCodePrefix / isForeignPrefixedPhaseQuery: config-aware prefix policy. A foreign prefix (MEM-01 when the configured code is LKML) is preserved rather than collapsed to a bare numeric phase — the #2104 fix's canonical home (consumed later, outside this epic's critical path). - roadmapPhaseLookupSources: moved from roadmap-parser.cts so phase-id.cts is the single owner of the exact -> numeric -> prefix-tolerant ordering. roadmap-parser.cts now imports it (behavior-identical); its two now-unused imports (phaseMarkdownRegexSourceExact, OPTIONAL_PROJECT_CODE_PREFIX_SOURCE) are dropped. Tests: subject-named suites in tests/phase-id.test.cjs covering the ADR boundary set (v0.5, v1.0, MEM-01, AB-29, bare 29, zero-padded 029) plus two fast-check properties: the #2111 "Milestone vX.Y complete never yields a phase" invariant and a parse/normalize property. Extend-never-mutate: the 12 pre-existing phase-id.cts exports are unchanged. Closes #2124 Refs #2121 Co-Authored-By: Claude Opus 4.8 --- src/phase-id.cts | 88 +++++++++++++++++++++++ src/roadmap-parser.cts | 22 +----- tests/phase-id.test.cjs | 151 ++++++++++++++++++++++++++++++++++++++++ 3 files changed, 242 insertions(+), 19 deletions(-) diff --git a/src/phase-id.cts b/src/phase-id.cts index 07d0799e6..68d6b4015 100644 --- a/src/phase-id.cts +++ b/src/phase-id.cts @@ -255,6 +255,90 @@ function phaseTokenMatches(dirName: string, normalized: string): boolean { return false; } +// ─── #2121 canonical surface (ADR-2121) ────────────────────────────────────── + +/** + * Parse a phase identifier from a STATE.md `Phase:` prose field VALUE — the text + * after the `Phase:` label (e.g. `"3 of 4 (Delta)"`, `"3A — Delta (executing)"`, + * or `"Milestone v0.5 complete"`). + * + * The token is anchored to the START of the value (after an optional literal + * `Phase ` label and an optional project-code prefix) so a phase is only + * returned when the value actually begins with one. This is the #2111 fix: the + * prior unanchored `/\b(\d+[A-Z]?(?:\.\d+)*)\b/i` mined the first numeral + * anywhere, so `"Milestone v0.5 complete"` collapsed to `"5"` (the minor-version + * digit) and `"v1.0"` to `"0"` (a reserved sentinel). Here both yield + * `{ phase: null }` because they do not begin with a phase token. The name + * extraction (parenthetical or em-dash tail, minus status words) is unchanged. + */ +function parsePhaseFromProse(value: string | null): { phase: string | null; name: string | null } { + if (!value) return { phase: null, name: null }; + const phaseMatch = value.match(/^\s*(?:Phase\s+)?(?:[A-Z][A-Z0-9_]*-)?(\d+[A-Z]?(?:\.\d+)*)\b/i); + const parenName = value.match(/\(([^)]+)\)/); + const dashName = value.match(/—\s*([^(\n]+?)(?:\s*\(|$)/); + const rawName = parenName?.[1] ?? dashName?.[1] ?? null; + const name = rawName && !/^(?:complete|executing|not started)$/i.test(rawName.trim()) + ? rawName.trim() + : null; + return { + phase: phaseMatch ? phaseMatch[1] : null, + name, + }; +} + +/** + * Config-AWARE project-code prefix strip. Unlike the config-blind + * `stripProjectCodePrefix` (which strips ANY `-` shape), this strips the + * leading `-` ONLY when `` case-insensitively equals the configured + * `projectCode`. A foreign prefix (`MEM-01` when the configured code is `LKML`) + * or an absent/empty `projectCode` is preserved verbatim — this is the #2104 + * fix: a foreign-prefixed id must not collapse to a bare numeric phase and + * collide with a real one. + */ +function stripConfiguredProjectCodePrefix(value: unknown, projectCode: string | null | undefined): string { + const input = String(value); + const configured = typeof projectCode === 'string' ? projectCode.trim() : ''; + if (!configured) return input; + const m = input.match(PROJECT_CODE_PREFIX_CAPTURE_RE_I); + if (!m) return input; + if (m[1].toUpperCase() !== configured.toUpperCase()) return input; + return m[2]; +} + +/** + * True when `phase` carries a project-code prefix that is NOT the configured + * `projectCode` (or when no `projectCode` is configured). The canonical + * predicate the init-command foreign-prefix guard (#2056 / PR #2105) delegates + * to, so every call site shares one foreign-prefix rule. + */ +function isForeignPrefixedPhaseQuery(phase: unknown, projectCode: unknown): boolean { + const m = String(phase).match(PROJECT_CODE_PREFIX_CAPTURE_RE_I); + if (!m) return false; + const configured = typeof projectCode === 'string' ? projectCode.trim() : ''; + return !configured || m[1].toUpperCase() !== configured.toUpperCase(); +} + +/** + * Canonical ROADMAP heading lookup-source list (moved here from + * roadmap-parser.cts so phase-id.cts is the single owner of the ordering). + * Sources are tried in a fixed, deduplicated order: exact (only when the query + * itself is project-code-prefixed) → bare numeric / padding-tolerant → + * prefix-tolerant fallback. The bare numeric source precedes the prefix-tolerant + * form so a canonical heading (`### Phase 117:`) is preferred over a drifted + * prefixed one (`### Phase MANIFOLD-117:`) when both exist in one ROADMAP. + */ +function roadmapPhaseLookupSources(phaseNum: unknown): string[] { + const sources: string[] = []; + const exactSource = phaseMarkdownRegexSourceExact(phaseNum); + if (exactSource) sources.push(exactSource); + + const numericSource = phaseMarkdownRegexSource(phaseNum); + sources.push(numericSource); + sources.push(`${OPTIONAL_PROJECT_CODE_PREFIX_SOURCE}${numericSource}`); + + return [...new Set(sources)]; +} + export = { escapeRegex, OPTIONAL_PROJECT_CODE_PREFIX_SOURCE, @@ -268,4 +352,8 @@ export = { comparePhaseNum, extractPhaseToken, phaseTokenMatches, + parsePhaseFromProse, + stripConfiguredProjectCodePrefix, + isForeignPrefixedPhaseQuery, + roadmapPhaseLookupSources, }; diff --git a/src/roadmap-parser.cts b/src/roadmap-parser.cts index 1dd7c1c99..f2ab3f648 100644 --- a/src/roadmap-parser.cts +++ b/src/roadmap-parser.cts @@ -22,10 +22,11 @@ import phaseIdModule = require('./phase-id.cjs'); const { escapeRegex, phaseMarkdownRegexSource, - phaseMarkdownRegexSourceExact, stripProjectCodePrefix, - OPTIONAL_PROJECT_CODE_PREFIX_SOURCE, OPTIONAL_PHASE_TAG_SOURCE, + // #2121: roadmapPhaseLookupSources now lives in phase-id.cjs (single owner of + // the lookup-source ordering); imported here rather than defined locally. + roadmapPhaseLookupSources, } = phaseIdModule; // eslint-disable-next-line @typescript-eslint/no-require-imports import planningWorkspace = require('./planning-workspace.cjs'); @@ -242,23 +243,6 @@ function findRoadmapPhaseInContent(content: string, phaseNum: unknown, phaseSour }; } -function roadmapPhaseLookupSources(phaseNum: unknown): string[] { - const sources: string[] = []; - const exactSource = phaseMarkdownRegexSourceExact(phaseNum); - if (exactSource) sources.push(exactSource); - - const numericSource = phaseMarkdownRegexSource(phaseNum); - // Source order matters: the bare numeric source is tried before the - // prefix-tolerant form so that a canonical bare heading ("Phase 117:") is - // preferred over a drifted prefixed heading ("Phase MANIFOLD-117:") when - // both exist in the same ROADMAP. The prefix-tolerant form is the fallback - // that handles the drifted-only case. - sources.push(numericSource); - sources.push(`${OPTIONAL_PROJECT_CODE_PREFIX_SOURCE}${numericSource}`); - - return [...new Set(sources)]; -} - function getRoadmapPhaseInternal(cwd: string, phaseNum: unknown): RoadmapPhaseResult | null { if (!phaseNum) return null; const normalizedPhase = stripProjectCodePrefix(phaseNum); diff --git a/tests/phase-id.test.cjs b/tests/phase-id.test.cjs index 94746235b..e56f1d56c 100644 --- a/tests/phase-id.test.cjs +++ b/tests/phase-id.test.cjs @@ -22,6 +22,7 @@ const { test, describe } = require('node:test'); const assert = require('node:assert/strict'); const phaseId = require('../gsd-core/bin/lib/phase-id.cjs'); +const fc = require('fast-check'); // ─── escapeRegex ───────────────────────────────────────────────────────────── @@ -450,3 +451,153 @@ describe('getPhaseDirFromPhaseId', () => { assert.ok(!result.endsWith('-')); }); }); + +// ─── parsePhaseFromProse (#2121, anchored — fixes #2111) ───────────────────── + +describe('parsePhaseFromProse', () => { + test('null / empty input yields null phase and name', () => { + assert.deepEqual(phaseId.parsePhaseFromProse(null), { phase: null, name: null }); + assert.deepEqual(phaseId.parsePhaseFromProse(''), { phase: null, name: null }); + }); + + test('#2111: a milestone-completion string carries no phase', () => { + assert.equal(phaseId.parsePhaseFromProse('Milestone v0.5 complete').phase, null); + assert.equal(phaseId.parsePhaseFromProse('Milestone v1.0 complete').phase, null); + assert.equal(phaseId.parsePhaseFromProse('Milestone v2.10 complete').phase, null); + }); + + test('#2111: a bare version token or stray numeral is not a phase', () => { + assert.equal(phaseId.parsePhaseFromProse('v0.5').phase, null); + assert.equal(phaseId.parsePhaseFromProse('v1.0').phase, null); + assert.equal(phaseId.parsePhaseFromProse('Fixed 12 bugs in v2.3').phase, null); + }); + + test('a genuine phase value (starting with the token) is parsed', () => { + assert.deepEqual(phaseId.parsePhaseFromProse('3 of 4 (Delta)'), { phase: '3', name: 'Delta' }); + assert.deepEqual(phaseId.parsePhaseFromProse('3A — Delta'), { phase: '3A', name: 'Delta' }); + assert.equal(phaseId.parsePhaseFromProse('12.1: Setup').phase, '12.1'); + assert.equal(phaseId.parsePhaseFromProse('29 of 30').phase, '29'); + assert.equal(phaseId.parsePhaseFromProse('029').phase, '029'); + }); + + test('a leading project-code prefix is tolerated but not captured (bare token)', () => { + assert.equal(phaseId.parsePhaseFromProse('MEM-01 — Foo').phase, '01'); + assert.equal(phaseId.parsePhaseFromProse('AB-29 of 30').phase, '29'); + }); + + test('an optional leading "Phase" label is tolerated', () => { + assert.equal(phaseId.parsePhaseFromProse('Phase 3A — Delta').phase, '3A'); + }); + + test('a status-word parenthetical is filtered from the name (preserved behavior)', () => { + // parenName wins over the em-dash tail; "executing" is a status word → name null. + assert.deepEqual(phaseId.parsePhaseFromProse('3A — Delta (executing)'), { phase: '3A', name: null }); + assert.equal(phaseId.parsePhaseFromProse('3 (complete)').name, null); + }); +}); + +// ─── stripConfiguredProjectCodePrefix (#2121 / #2104, config-aware) ─────────── + +describe('stripConfiguredProjectCodePrefix', () => { + test('#2104: a foreign prefix is preserved (not collapsed to a bare phase)', () => { + assert.equal(phaseId.stripConfiguredProjectCodePrefix('MEM-01', 'LKML'), 'MEM-01'); + }); + + test('the configured prefix is stripped (case-insensitive)', () => { + assert.equal(phaseId.stripConfiguredProjectCodePrefix('CK-01', 'CK'), '01'); + assert.equal(phaseId.stripConfiguredProjectCodePrefix('LKML-29', 'lkml'), '29'); + assert.equal(phaseId.stripConfiguredProjectCodePrefix('AB-29', 'AB'), '29'); + }); + + test('a value with no prefix is returned unchanged', () => { + assert.equal(phaseId.stripConfiguredProjectCodePrefix('01', 'CK'), '01'); + assert.equal(phaseId.stripConfiguredProjectCodePrefix('029', 'CK'), '029'); + }); + + test('an absent/empty projectCode preserves the value verbatim', () => { + assert.equal(phaseId.stripConfiguredProjectCodePrefix('MEM-01', ''), 'MEM-01'); + assert.equal(phaseId.stripConfiguredProjectCodePrefix('MEM-01', null), 'MEM-01'); + assert.equal(phaseId.stripConfiguredProjectCodePrefix('MEM-01', undefined), 'MEM-01'); + }); +}); + +// ─── isForeignPrefixedPhaseQuery (#2121 / #2056) ───────────────────────────── + +describe('isForeignPrefixedPhaseQuery', () => { + test('a prefix that is not the configured code is foreign', () => { + assert.equal(phaseId.isForeignPrefixedPhaseQuery('MEM-01', 'LKML'), true); + }); + + test('the configured prefix is not foreign (case-insensitive)', () => { + assert.equal(phaseId.isForeignPrefixedPhaseQuery('CK-01', 'CK'), false); + assert.equal(phaseId.isForeignPrefixedPhaseQuery('ck-01', 'CK'), false); + }); + + test('a value with no prefix is never foreign', () => { + assert.equal(phaseId.isForeignPrefixedPhaseQuery('01', 'CK'), false); + assert.equal(phaseId.isForeignPrefixedPhaseQuery('29', 'AB'), false); + }); + + test('a prefixed query with no configured code is foreign; a bare one is not', () => { + assert.equal(phaseId.isForeignPrefixedPhaseQuery('MEM-01', ''), true); + assert.equal(phaseId.isForeignPrefixedPhaseQuery('MEM-01', null), true); + assert.equal(phaseId.isForeignPrefixedPhaseQuery('01', ''), false); + }); +}); + +// ─── roadmapPhaseLookupSources (#2121, owned here after the move) ───────────── + +describe('roadmapPhaseLookupSources', () => { + const PREFIX_TOLERANT = `${phaseId.OPTIONAL_PROJECT_CODE_PREFIX_SOURCE}0*29`; + + test('a bare numeric query yields the numeric then prefix-tolerant sources', () => { + const sources = phaseId.roadmapPhaseLookupSources('29'); + assert.deepEqual(sources, ['0*29', PREFIX_TOLERANT]); + }); + + test('the bare numeric source precedes the prefix-tolerant fallback', () => { + const sources = phaseId.roadmapPhaseLookupSources('29'); + assert.ok(sources.indexOf('0*29') < sources.indexOf(PREFIX_TOLERANT)); + }); + + test('a project-code-prefixed query adds the exact source first (3 sources)', () => { + const sources = phaseId.roadmapPhaseLookupSources('AB-29'); + assert.equal(sources.length, 3); + assert.equal(sources[0], 'AB-29'); + assert.ok(sources.includes('0*29')); + assert.ok(sources.includes(PREFIX_TOLERANT)); + }); + + test('zero-padding is tolerated: 029 resolves the same sources as 29', () => { + assert.deepEqual(phaseId.roadmapPhaseLookupSources('029'), phaseId.roadmapPhaseLookupSources('29')); + }); + + test('sources are deduplicated', () => { + const sources = phaseId.roadmapPhaseLookupSources('29'); + assert.equal(sources.length, new Set(sources).size); + }); +}); + +// ─── #2121 property tests (fast-check) ─────────────────────────────────────── + +describe('phase-id canonical surface — properties', () => { + test('#2111 invariant: a "Milestone vX.Y complete" string never yields a phase', () => { + fc.assert( + fc.property(fc.nat(999), fc.nat(999), (major, minor) => { + return phaseId.parsePhaseFromProse(`Milestone v${major}.${minor} complete`).phase === null; + }), + ); + }); + + test('parse↔normalize: a "N of M" prose value extracts N, and it normalizes stably', () => { + fc.assert( + fc.property(fc.integer({ min: 1, max: 9999 }), fc.integer({ min: 1, max: 9999 }), (n, m) => { + const parsed = phaseId.parsePhaseFromProse(`${n} of ${m}`); + return ( + parsed.phase === String(n) && + phaseId.normalizePhaseName(parsed.phase) === phaseId.normalizePhaseName(String(n)) + ); + }), + ); + }); +}); From 80923244b1a93993c33db668ed2caa2e3b7471c1 Mon Sep 17 00:00:00 2001 From: Tom Boucher Date: Thu, 9 Jul 2026 16:33:39 -0400 Subject: [PATCH 33/33] fix(#2124): harden parsePhaseFromProse per orthogonal review (ReDoS + coercion) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Orthogonal security review of the Phase 1 surface found two issues; both fixed and regression-tested: - MEDIUM ReDoS: the name-extraction regexes /\(([^)]+)\)/ and /—\s*([^(\n]+?).../ backtrack O(n^2) on a crafted STATE.md field value with a long unterminated "(" / "—" run (reviewer measured ~38s at 320k chars). Length-bound both quantifiers to {1,200} -> linear (320k now ~100ms). A real phase name is far shorter than the cap. - LOW: parsePhaseFromProse threw on non-string truthy input, unlike its three sibling #2121 functions. Coerce via String(value) up front. The identical ReDoS regexes are copied verbatim from the pre-existing state.cts:parseProsePhaseField; per the no-defer rule that surfaced defect is fixed inline there too (Phase 2 / #2125 later supersedes that function by delegating to the bounded phase-id.cts parser). Adds a behavioral bound-guard regression test (a >200-char parenthetical is not extracted) and a non-string-coercion test. Refs #2124, #2121 Co-Authored-By: Claude Opus 4.8 --- src/phase-id.cts | 13 ++++++++++--- src/state.cts | 8 ++++++-- tests/phase-id.test.cjs | 16 ++++++++++++++++ 3 files changed, 32 insertions(+), 5 deletions(-) diff --git a/src/phase-id.cts b/src/phase-id.cts index 68d6b4015..e08a4af43 100644 --- a/src/phase-id.cts +++ b/src/phase-id.cts @@ -273,9 +273,16 @@ function phaseTokenMatches(dirName: string, normalized: string): boolean { */ function parsePhaseFromProse(value: string | null): { phase: string | null; name: string | null } { if (!value) return { phase: null, name: null }; - const phaseMatch = value.match(/^\s*(?:Phase\s+)?(?:[A-Z][A-Z0-9_]*-)?(\d+[A-Z]?(?:\.\d+)*)\b/i); - const parenName = value.match(/\(([^)]+)\)/); - const dashName = value.match(/—\s*([^(\n]+?)(?:\s*\(|$)/); + // Coerce defensively so a non-string caller cannot throw on this canonical + // surface (mirrors the sibling #2121 functions' String(...) handling). + const str = String(value); + const phaseMatch = str.match(/^\s*(?:Phase\s+)?(?:[A-Z][A-Z0-9_]*-)?(\d+[A-Z]?(?:\.\d+)*)\b/i); + // The name-extraction quantifiers are length-bounded so a crafted long + // unterminated run (many `(` or `—`) in an untrusted STATE.md field value + // cannot drive O(n^2) regex backtracking (CPU-exhaustion DoS). A real phase + // name is far shorter than the cap. + const parenName = str.match(/\(([^)]{1,200})\)/); + const dashName = str.match(/—\s*([^(\n]{1,200}?)(?:\s*\(|$)/); const rawName = parenName?.[1] ?? dashName?.[1] ?? null; const name = rawName && !/^(?:complete|executing|not started)$/i.test(rawName.trim()) ? rawName.trim() diff --git a/src/state.cts b/src/state.cts index 02cfc8b59..0e8199266 100644 --- a/src/state.cts +++ b/src/state.cts @@ -1118,8 +1118,12 @@ function matchSessionSection(body: string): RegExpMatchArray | null { function parseProsePhaseField(value: string | null): { phase: string | null; name: string | null } { if (!value) return { phase: null, name: null }; const phaseMatch = value.match(/\b(\d+[A-Z]?(?:\.\d+)*)\b/i); - const parenName = value.match(/\(([^)]+)\)/); - const dashName = value.match(/—\s*([^(\n]+?)(?:\s*\(|$)/); + // #2124 review: length-bound the name quantifiers so a crafted long + // unterminated `(` / `—` run in an untrusted STATE.md field cannot drive + // O(n^2) backtracking (CPU DoS). (Phase 2 / #2125 supersedes this function + // by delegating to phase-id.cts:parsePhaseFromProse, which is bounded too.) + const parenName = value.match(/\(([^)]{1,200})\)/); + const dashName = value.match(/—\s*([^(\n]{1,200}?)(?:\s*\(|$)/); const rawName = parenName?.[1] ?? dashName?.[1] ?? null; const name = rawName && !/^(?:complete|executing|not started)$/i.test(rawName.trim()) ? rawName.trim() diff --git a/tests/phase-id.test.cjs b/tests/phase-id.test.cjs index e56f1d56c..286cf7a0c 100644 --- a/tests/phase-id.test.cjs +++ b/tests/phase-id.test.cjs @@ -494,6 +494,22 @@ describe('parsePhaseFromProse', () => { assert.deepEqual(phaseId.parsePhaseFromProse('3A — Delta (executing)'), { phase: '3A', name: null }); assert.equal(phaseId.parsePhaseFromProse('3 (complete)').name, null); }); + + test('#2124 review: name quantifiers are length-bounded (ReDoS guard)', () => { + // A parenthetical within the bound extracts; one longer than the bound is + // NOT matched — the cap is what prevents O(n^2) backtracking on a crafted + // untrusted value. Removing the bound would extract the long name → fail. + assert.equal(phaseId.parsePhaseFromProse('3 (Delta)').name, 'Delta'); + assert.equal(phaseId.parsePhaseFromProse(`3 (${'x'.repeat(201)})`).name, null); + // A long unterminated "(" run yields no name and still parses the phase. + assert.deepEqual(phaseId.parsePhaseFromProse(`3 ${'('.repeat(5000)}`), { phase: '3', name: null }); + }); + + test('#2124 review: non-string input is coerced, never throws', () => { + assert.doesNotThrow(() => phaseId.parsePhaseFromProse(3)); + assert.equal(phaseId.parsePhaseFromProse(3).phase, '3'); + assert.deepEqual(phaseId.parsePhaseFromProse(true), { phase: null, name: null }); + }); }); // ─── stripConfiguredProjectCodePrefix (#2121 / #2104, config-aware) ───────────