From 396f44bd0b0c4c7541f9c01336e36071d7b00762 Mon Sep 17 00:00:00 2001 From: Tom Boucher Date: Wed, 8 Jul 2026 16:53:54 -0400 Subject: [PATCH] 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}`); } });