feat(#1943): extensionEvents vocabulary — separate from hookEvents (extension-system surface) (#1946)

* feat(#1943): extensionEvents vocabulary — separate from hookEvents (extension-system surface)

* fix(#1943): re-export VALID_EXTENSION_EVENTS from gen-capability-registry (test import path)

* fix(#1943): regenerate capability-registry.cjs + add changeset fragment

* fix(#1943): import VALID_EXTENSION_EVENTS from validator, not gen-capability-registry (golden parity)
This commit is contained in:
Tom Boucher
2026-07-02 21:45:35 -04:00
committed by GitHub
parent d3d689a5e9
commit b51cbf96cf
11 changed files with 157 additions and 47 deletions

View File

@@ -0,0 +1,5 @@
---
type: Added
pr: 1946
---
**Host-integration descriptors now carry an `extensionEvents` vocabulary** — the extension-system event surface (OpenCode, pi) is a separate descriptor field from managed `hookEvents`, so OpenCode declares `extensionEvents:opencode` without conflicting with the hooksSurface:none invariant. (#1946)

View File

@@ -61,6 +61,7 @@
},
"commandStyle": "slash-hyphen",
"hooksSurface": "none",
"extensionEvents": "opencode",
"sandboxTier": "none",
"supportTier": 2,
"installSurface": "settings-json",

View File

@@ -93,7 +93,8 @@ Phase A is **implemented** and Phases B–E have landed, so this ADR is **Accept
- **`PROTOCOL_VERSION`** is an integer starting at `1`, **distinct** from the package `version` / `engines.gsd` semver (the `version`/`protocolVersion` overlap, resolved).
- **`negotiateHostCapabilities(host, engine?)`** performs the in-process `initialize` exchange and enforces the trust-boundary invariant `effective ⊆ host-declared ∩ engine-known`: an undeclared axis or an unknown / higher-`protocolVersion` value is **never** trusted — it degrades to the most-restrictive known value (fail-closed), never throws.
- **`degradationFor`** is the typed Full/Degraded/Absent ladder table; **`profileOf` + `PROFILE_BASELINES`** classify each descriptor into `programmatic-cli` (9 hosts: claude, opencode, cursor, cline, hermes, qwen, kilo, trae, kimi), `declarative-cli` (7 hosts: codex, gemini, antigravity, augment, codebuddy, copilot, windsurf), or `ide` (defined as a baseline; no installed host yet — VS Code lands in Phase D).
- **Overlap resolutions (explicit):** `commandStyle` (GSD emission style, retained) ⊥ `commandSurface` (host surface type); `hookEvents` dialect ⊥ `hookBus` ownership (a host with `hooksSurface:none` may still be `hookBus:host` — e.g. opencode); the `opencode-subset` `hookEvents` value remains reserved for the Phase D OpenCode hook-dialect consumer; `runtimeCompat` (feature→host) stays an independent override, orthogonal to these runtime→engine axes.
- **Overlap resolutions (explicit):** `commandStyle` (GSD emission style, retained) ⊥ `commandSurface` (host surface type); `hookEvents` dialect ⊥ `hookBus` ownership (a host with `hooksSurface:none` may still be `hookBus:host` — e.g. opencode); `runtimeCompat` (feature→host) stays an independent override, orthogonal to these runtime→engine axes.
- **`extensionEvents` vocabulary (amendment, #1943).** The OpenCode extension-system event subset is a SEPARATE descriptor field + closed vocabulary, **not** a `hookEvents` value. `hookEvents` is the *managed-hook* dialect only (`claude`/`gemini`) — the event names GSD writes into a declarative host's settings.json. `extensionEvents` is the *plugin/extension-system* event surface an imperative host exposes: `{ opencode, pi, none }` (OpenCode ~25 plugin events; pi ~30 fine-grained events; `none` = the host exposes no extension surface and the engine owns the bus, e.g. VS Code). The former `opencode-subset` `hookEvents` value was this concept misfiled; it is now `extensionEvents: opencode`. Keeping them separate preserves the `hooksSurface:"none" ⇔ no-hookEvents` invariant (OpenCode declares `extensionEvents`, not `hookEvents`). Resolved by `extensionEventSurfaceFor` in `src/host-integration.cts`, validated by `VALID_EXTENSION_EVENTS` in `capability-validator.cjs`.
**Every per-host axis value is documentation-sourced, with citations.** Each of the 8 axes for all 16 installed CLIs was determined from that CLI's authoritative documentation (Context7 + the official dev docs/source), never inferred. The full per-CLI, per-axis matrix — value, source, and an evidence quote — is recorded in [`docs/reference/host-integration-capability-matrix.md`](reference/host-integration-capability-matrix.md), the deployment source-of-truth that Phases B–E build on. Where a CLI's docs genuinely do not state an axis, the descriptor carries the explicit `undocumented` sentinel (which `negotiateHostCapabilities` fail-closes on) rather than a guessed value — 22 such markers exist today, each with its search trail in the matrix. Two findings corrected this ADR's original appendix matrix: (1) current OpenAI **Codex** docs document slash-commands, so its `commandSurface` is `slash-file`, not `prose-only`; (2) several hosts run non-Node runtimes (opencode & kilo on **bun**; hermes & kimi on **python**; antigravity on **go**), so the `runtime` axis vocabulary was widened to `node|bun|sandboxed-web|python|go|rust|electron|other`. The documented `embeddingMode` split (9 imperative / 7 declarative, above) likewise reflects each CLI's real plugin/extension API, not a profile assumption.
@@ -120,7 +121,7 @@ A plugin is a JS/TS module exporting an `async` function that returns a **hooks
| 1 Command | slash-file commands projected to the xdg command dir (`gsd:`-namespaced); plugin may also surface entrypoints as custom `tool()`s and drive `tui.command.execute` | `commandSurface: slash-file` | none (full) |
| 2 Dispatch | `mode: subagent` / `@`-mention; `subtask` is **synchronous-only** | `dispatch: { namedDispatch:true, nested:true, background:false, subagentToolkit:'full' }` | no background → waves run inline (the #853 flatten rule) |
| 3 Model | per-agent `model` field on the agent `.md`; no provider `sendRequest` | `modelMode: passive` | tier routing degrades to per-agent model field |
| 4 Hooks | host `event` bus (~25 events) | `hookBus: host`; ADR-1016 dialect = **`opencode-subset`** | session/tool-scoped only — see gap below |
| 4 Hooks | host `event` bus (~25 events) | `hookBus: host`; `extensionEvents: opencode` (Phase D / #1943) | session/tool-scoped only — see gap below |
| 5 State | filesystem `.planning/` + config under xdg `~/.config/opencode`; `opencode-jsonc` permissions sidecar (`permissionWriter: 'opencode'`) | `stateIO: filesystem` | `configHome` write-confinement applies |
| 6 Artifact | native Agent Skills + `@agent` subagents + slash commands | — | none (full) |
@@ -128,7 +129,7 @@ A plugin is a JS/TS module exporting an `async` function that returns a **hooks
### The load-bearing gap: the loop is phase-scoped, the bus is session-scoped
OpenCode's bus fires on **sessions, tools, files, and permissions** — never on **workflow phases**. GSD's 12 loop extension points (`plan:pre`, `verify:post`, `ship:post`…) have **no event on this bus**. So the imperative adapter for OpenCode cannot drive the loop *from host events*; the engine must own phase sequencing internally and treat OpenCode's bus as a **subset hook surface** (exactly what the ADR-1016 `opencode-subset` dialect already encodes). Concretely:
OpenCode's bus fires on **sessions, tools, files, and permissions** — never on **workflow phases**. GSD's 12 loop extension points (`plan:pre`, `verify:post`, `ship:post`…) have **no event on this bus**. So the imperative adapter for OpenCode cannot drive the loop *from host events*; the engine must own phase sequencing internally and treat OpenCode's bus as a **subset extension-event surface** — exactly what the `extensionEvents: opencode` vocabulary encodes (amendment #1943; formerly misfiled as a `hookEvents` value `opencode-subset`). Concretely:
- **Steps, gates, and most contributions fire from GSD's own workflow/command invocation (point 1), engine-side** — not from the host bus. The plugin invokes `gsd-tools.cjs` (via `$` or the companion MCP server) and the engine runs the loop resolver.
- **Only the contributions that align with a real host event bind to the bus.** The clean case is memory: a MemPalace-style capability's capture/recall already keys on `discuss:post`/`plan:post`/`verify:post`; those can *additionally* bind to `experimental.session.compacting` so memory persists across OpenCode's compaction — a concrete win the host gives us for free.

View File

@@ -1690,6 +1690,7 @@ const capabilities = {
},
"commandStyle": "slash-hyphen",
"hooksSurface": "none",
"extensionEvents": "opencode",
"sandboxTier": "none",
"supportTier": 2,
"installSurface": "settings-json",
@@ -4227,6 +4228,7 @@ const runtimes = {
},
"commandStyle": "slash-hyphen",
"hooksSurface": "none",
"extensionEvents": "opencode",
"sandboxTier": "none",
"supportTier": 2,
"installSurface": "settings-json",

View File

@@ -704,7 +704,12 @@ const VALID_CONFIG_FORMATS = new Set(['settings-json', 'toml', 'markdown', 'mark
const VALID_CONFIG_HOME_KINDS = new Set(['dot-home', 'dot-home-nested', 'xdg', 'generic-agents-root']);
const VALID_COMMAND_STYLES = new Set(['slash-hyphen', 'shell-var']);
const VALID_HOOKS_SURFACES = new Set(['settings-json', 'codex-hooks-json', 'cursor-hooks-json', 'copilot-inline', 'cline-rules', 'none']);
const VALID_HOOK_EVENTS = new Set(['claude', 'gemini', 'opencode-subset']);
const VALID_HOOK_EVENTS = new Set(['claude', 'gemini']);
// extensionEvents — the plugin/extension-system event dialect (ADR-1239 amendment / #1943).
// DISTINCT from hookEvents (managed-hook dialect): extensionEvents describes the
// plugin-owned event subset imperative hosts expose (opencode / pi); 'none' = the
// host exposes no extension surface (engine owns the bus, e.g. VS Code).
const VALID_EXTENSION_EVENTS = new Set(['opencode', 'pi', 'none']);
const VALID_SANDBOX_TIERS = new Set(['none', 'codex-agent-sandbox']);
const VALID_ARTIFACT_KIND_NAMES = new Set(['commands', 'agents', 'skills', 'kimi-agents']);
const VALID_ARTIFACT_NESTINGS = new Set(['flat', 'nested']);
@@ -978,7 +983,9 @@ function validateRuntimeBody(cap) {
);
}
// hookEvents — optional; if present must be in closed 3-enum (ADR-1016 Decision 5)
// hookEvents — optional; if present must be in closed enum (ADR-1016 Decision 5).
// Managed-hook dialect only (claude/gemini). The OpenCode extension-system event
// subset is NOT a hookEvents value — it is `extensionEvents` (ADR-1239 / #1943).
if (r.hookEvents !== undefined) {
if (r.hookEvents === '__proto__' || r.hookEvents === 'constructor' || r.hookEvents === 'prototype') {
errors.push('runtime.hookEvents "' + r.hookEvents + '" is a reserved name');
@@ -990,6 +997,20 @@ function validateRuntimeBody(cap) {
}
}
// extensionEvents — optional; the extension-system event dialect (ADR-1239 amendment / #1943).
// Distinct from hookEvents. Only imperative-embedding hosts (with a plugin/extension
// API) set it; declarative hosts do not.
if (r.extensionEvents !== undefined) {
if (r.extensionEvents === '__proto__' || r.extensionEvents === 'constructor' || r.extensionEvents === 'prototype') {
errors.push('runtime.extensionEvents "' + r.extensionEvents + '" is a reserved name');
} else if (!VALID_EXTENSION_EVENTS.has(r.extensionEvents)) {
errors.push(
'runtime.extensionEvents must be one of: ' + [...VALID_EXTENSION_EVENTS].join(', ') +
' (got: ' + JSON.stringify(r.extensionEvents) + ')',
);
}
}
// sandboxTier — closed 2-enum (ADR-1016 Decision 6); inline literal guard (CodeQL barrier)
if (r.sandboxTier === '__proto__' || r.sandboxTier === 'constructor' || r.sandboxTier === 'prototype') {
errors.push('runtime.sandboxTier "' + r.sandboxTier + '" is a reserved name');
@@ -2225,6 +2246,7 @@ module.exports = {
VALID_COMMAND_STYLES,
VALID_HOOKS_SURFACES,
VALID_HOOK_EVENTS,
VALID_EXTENSION_EVENTS,
VALID_SANDBOX_TIERS,
VALID_ARTIFACT_KIND_NAMES,
VALID_ARTIFACT_NESTINGS,

View File

@@ -40,6 +40,7 @@ const SDK = Object.freeze({
profileOf: hostIntegration.profileOf,
degradationFor: hostIntegration.degradationFor,
hookEventSurfaceFor: hostIntegration.hookEventSurfaceFor,
extensionEventSurfaceFor: hostIntegration.extensionEventSurfaceFor,
shouldFlattenDispatch: hostIntegration.shouldFlattenDispatch,
// ── Embedding + engine adapters ──────────────────────────────────────────

View File

@@ -513,39 +513,66 @@ function shouldFlattenDispatch(dispatch: UnvalidatedDispatch): boolean {
}
// ---------------------------------------------------------------------------
// Hook-event surface per hookEvents dialect (ADR-1239 Phase D / #1682)
// Managed-hook event surface per hookEvents dialect (ADR-1239 / ADR-1016)
// ---------------------------------------------------------------------------
// The set of host-fireable hook events for each hookEvents dialect. This is the
// CONSUMER of the reserved 'opencode-subset' dialect (previously zero consumers):
// it lets the engine ask which events a host's bus actually exposes, so it knows
// workflow-phase hooks (plan:pre / verify:post / …) are NOT available on an
// opencode-subset host and the engine must own phase sequencing internally
// (ADR-1239 §OpenCode binding). 'claude' = full Claude surface; 'gemini' =
// Gemini's BeforeTool/AfterTool family; 'opencode-subset' = OpenCode's
// session/tool/file subset (no workflow-phase events).
// Host-fireable MANAGED-hook events per `hookEvents` dialect. `hookEvents` is the
// managed-hook dialect — the event names GSD writes into a DECLARATIVE host's
// settings.json (claude = SessionStart/PreToolUse/…; gemini = BeforeTool/AfterTool).
// This is DISTINCT from the extension-system event surface (below): a host's
// plugin/extension API fires a different, plugin-owned event set. The two must
// not be conflated (ADR-1239 amendment / #1943 — the former 'opencode-subset'
// `hookEvents` value was this conflation; it is now `extensionEvents: opencode`).
const HOOK_EVENT_SURFACES: Readonly<Record<string, readonly string[]>> = Object.freeze({
claude: Object.freeze(['SessionStart', 'PreToolUse', 'PostToolUse', 'Stop', 'SessionEnd', 'PreCompact']),
gemini: Object.freeze(['SessionStart', 'BeforeTool', 'AfterTool', 'SessionEnd']),
'opencode-subset': Object.freeze([
'session.created', 'session.idle', 'experimental.session.compacting',
'tool.execute.before', 'tool.execute.after', 'file.edited',
]),
});
/**
* Resolve the host-fireable hook-event surface for a hookEvents dialect.
* Resolve the managed-hook event surface for a `hookEvents` dialect.
* Returns null for unknown/missing dialects (fail-closed). Pure, never throws.
*
* A non-null result for 'opencode-subset' is what makes that dialect a CONSUMED
* value rather than reserved vocab: callers can ask `hookEventSurfaceFor('opencode-subset')`
* and learn the host fires no workflow-phase events.
*/
function hookEventSurfaceFor(hookEvents: unknown): readonly string[] | null {
if (typeof hookEvents !== 'string') return null;
return HOOK_EVENT_SURFACES[hookEvents] || null;
}
// ---------------------------------------------------------------------------
// Extension-system event surface (ADR-1239 amendment / #1943)
// ---------------------------------------------------------------------------
// The events a host's PLUGIN/EXTENSION API exposes — for imperative-embedding
// hosts that load GSD as a plugin. This is a SEPARATE vocabulary + descriptor
// field (`extensionEvents`) from `hookEvents`: hookEvents = the managed-hook
// dialect (declarative hosts' settings.json); extensionEvents = the plugin-owned
// event subset (imperative hosts' extension API). They are not the same thing.
//
// Values are documentation-sourced (ADR-1239 §research): OpenCode ~25 plugin
// events (session/tool/file/permission); pi ~30 fine-grained extension events;
// 'none' = the host exposes no extension surface and the engine owns the bus
// (VS Code). Declarative hosts (no plugin API) do not set `extensionEvents`.
const EXTENSION_EVENT_SURFACES: Readonly<Record<string, readonly string[]>> = Object.freeze({
opencode: Object.freeze([
'session.created', 'session.idle', 'experimental.session.compacting',
'tool.execute.before', 'tool.execute.after', 'file.edited',
]),
pi: Object.freeze(['tool_call']),
none: Object.freeze([]),
});
/**
* Resolve the extension-system event surface for an `extensionEvents` dialect.
* Returns null for unknown/missing dialects (fail-closed). Pure, never throws.
*
* A non-null result is what makes an `extensionEvents` value a CONSUMED value
* rather than reserved vocab. For 'opencode' it carries NO workflow-phase events
* — the engine owns phase sequencing internally on such hosts (ADR-1239 §OpenCode).
*/
function extensionEventSurfaceFor(extensionEvents: unknown): readonly string[] | null {
if (typeof extensionEvents !== 'string') return null;
return EXTENSION_EVENT_SURFACES[extensionEvents] || null;
}
// ---------------------------------------------------------------------------
// Module export (CommonJS — matches existing src/*.cts pattern)
// ---------------------------------------------------------------------------
@@ -558,9 +585,11 @@ export = {
PROFILE_BASELINES,
DEFAULT_ENGINE,
HOOK_EVENT_SURFACES,
EXTENSION_EVENT_SURFACES,
degradationFor,
profileOf,
negotiateHostCapabilities,
shouldFlattenDispatch,
hookEventSurfaceFor,
extensionEventSurfaceFor,
};

View File

@@ -3200,6 +3200,11 @@ const {
VALID_ARTIFACT_NESTINGS,
} = require('../scripts/gen-capability-registry.cjs');
// VALID_EXTENSION_EVENTS is imported from the validator directly (not from
// gen-capability-registry) to avoid changing gen-capability-registry.cjs — that
// file is an installed artifact captured by golden-install-parity (#1943).
const VALID_EXTENSION_EVENTS = capValidatorModule.VALID_EXTENSION_EVENTS;
const RUNTIME_IDS = [
'claude', 'codex', 'antigravity', 'gemini', 'cursor', 'opencode',
'kilo', 'copilot', 'augment', 'trae', 'qwen', 'hermes',
@@ -3386,6 +3391,14 @@ describe('ADR-1016 phase 5a: sample axis value assertions', () => {
);
});
test('opencode: extensionEvents === opencode (the extension-system event dialect — #1943)', () => {
const { capMap } = loadAndValidate(new Set());
const registry = buildRegistry(capMap);
const rt = registry.runtimes['opencode'].runtime;
assert.strictEqual(rt.extensionEvents, 'opencode',
'opencode declares the extension-system event subset via extensionEvents (NOT hookEvents)');
});
test('kilo: hooksSurface === none, no hookEvents (registers zero lifecycle hooks)', () => {
const { capMap } = loadAndValidate(new Set());
const registry = buildRegistry(capMap);
@@ -3952,12 +3965,22 @@ describe('ADR-1016 phase 5a: closed-vocab set exports', () => {
assert.strictEqual(VALID_HOOKS_SURFACES.size, 6);
});
test('VALID_HOOK_EVENTS has exactly 3 values', () => {
test('VALID_HOOK_EVENTS has exactly 2 managed-hook dialects (claude/gemini)', () => {
assert.ok(VALID_HOOK_EVENTS instanceof Set);
for (const v of ['claude', 'gemini', 'opencode-subset']) {
for (const v of ['claude', 'gemini']) {
assert.ok(VALID_HOOK_EVENTS.has(v), 'VALID_HOOK_EVENTS must contain "' + v + '"');
}
assert.strictEqual(VALID_HOOK_EVENTS.size, 3);
assert.strictEqual(VALID_HOOK_EVENTS.size, 2);
assert.ok(!VALID_HOOK_EVENTS.has('opencode-subset'),
'opencode-subset is NOT a hookEvents value — it is the extensionEvents vocabulary (#1943)');
});
test('VALID_EXTENSION_EVENTS has the extension-system dialects (opencode/pi/none — #1943)', () => {
assert.ok(VALID_EXTENSION_EVENTS instanceof Set);
for (const v of ['opencode', 'pi', 'none']) {
assert.ok(VALID_EXTENSION_EVENTS.has(v), 'VALID_EXTENSION_EVENTS must contain "' + v + '"');
}
assert.strictEqual(VALID_EXTENSION_EVENTS.size, 3);
});
test('VALID_SANDBOX_TIERS has exactly 2 values', () => {

View File

@@ -23,34 +23,60 @@ const {
negotiateHostCapabilities,
hookEventSurfaceFor,
HOOK_EVENT_SURFACES,
extensionEventSurfaceFor,
EXTENSION_EVENT_SURFACES,
} = hi;
describe('hookEventSurfaceFor (hookEvents dialect consumer — #1682)', () => {
test('returns the full Claude surface for "claude"', () => {
describe('hookEventSurfaceFor (MANAGED-hook dialect consumer — claude/gemini only)', () => {
test('returns the full Claude managed-hook surface for "claude"', () => {
const s = hookEventSurfaceFor('claude');
assert.ok(s && s.includes('PreToolUse') && s.includes('PostToolUse') && s.includes('Stop'));
});
test('returns the Gemini BeforeTool/AfterTool surface for "gemini"', () => {
test('returns the Gemini BeforeTool/AfterTool managed-hook surface for "gemini"', () => {
const s = hookEventSurfaceFor('gemini');
assert.ok(s && s.includes('BeforeTool') && s.includes('AfterTool'));
});
test('CONSUMES "opencode-subset": OpenCode session/tool/file subset with NO workflow-phase events', () => {
const s = hookEventSurfaceFor('opencode-subset');
assert.ok(s, 'opencode-subset must resolve (non-null) — it is consumed, not reserved');
assert.ok(s.includes('experimental.session.compacting'));
assert.ok(s.includes('session.idle'));
assert.ok(s.includes('tool.execute.before') && s.includes('tool.execute.after'));
assert.ok(!s.some((e) => /plan:|verify:|ship:|execute:/.test(e)),
'opencode-subset fires no workflow-phase events (engine owns phase sequencing)');
test('hookEvents is the MANAGED-hook dialect only — opencode-subset is NOT here (#1943)', () => {
assert.equal(hookEventSurfaceFor('opencode-subset'), null,
'opencode-subset is not a hookEvents value — it moved to the extensionEvents vocabulary');
});
test('returns null for unknown / missing / non-string dialect (fail-closed)', () => {
assert.equal(hookEventSurfaceFor('nope'), null);
assert.equal(hookEventSurfaceFor(undefined), null);
assert.equal(hookEventSurfaceFor(123), null);
});
test('HOOK_EVENT_SURFACES is frozen + covers exactly the 3 dialects', () => {
test('HOOK_EVENT_SURFACES is frozen + covers exactly the 2 managed-hook dialects', () => {
assert.equal(Object.isFrozen(HOOK_EVENT_SURFACES), true);
assert.deepEqual(Object.keys(HOOK_EVENT_SURFACES).sort(), ['claude', 'gemini', 'opencode-subset']);
assert.deepEqual(Object.keys(HOOK_EVENT_SURFACES).sort(), ['claude', 'gemini']);
});
});
describe('extensionEventSurfaceFor (extension-system event dialect — #1943)', () => {
test('opencode = OpenCode plugin event subset with NO workflow-phase events', () => {
const s = extensionEventSurfaceFor('opencode');
assert.ok(s, 'opencode must resolve (non-null) — it is a consumed extensionEvents value');
assert.ok(s.includes('experimental.session.compacting'));
assert.ok(s.includes('session.idle'));
assert.ok(s.includes('tool.execute.before') && s.includes('tool.execute.after'));
assert.ok(!s.some((e) => /plan:|verify:|ship:/.test(e)),
'opencode extension events include no workflow-phase events (engine owns phase sequencing)');
});
test('pi resolves (extension-system dialect)', () => {
const s = extensionEventSurfaceFor('pi');
assert.ok(Array.isArray(s), 'pi is a consumed extensionEvents value');
});
test('none = empty surface (host exposes no extension events; engine owns the bus)', () => {
assert.deepEqual(extensionEventSurfaceFor('none'), []);
});
test('returns null for unknown / missing / non-string dialect (fail-closed)', () => {
assert.equal(extensionEventSurfaceFor('opencode-subset'), null,
'the old opencode-subset name is gone — use extensionEventSurfaceFor("opencode")');
assert.equal(extensionEventSurfaceFor('nope'), null);
assert.equal(extensionEventSurfaceFor(undefined), null);
});
test('EXTENSION_EVENT_SURFACES is frozen + covers opencode/pi/none', () => {
assert.equal(Object.isFrozen(EXTENSION_EVENT_SURFACES), true);
assert.deepEqual(Object.keys(EXTENSION_EVENT_SURFACES).sort(), ['none', 'opencode', 'pi']);
});
});

View File

@@ -324,13 +324,13 @@ test('experimental.session.compacting injects the GSD state breadcrumb', async (
assert.ok(output.context.some((c) => /GSD/.test(c)), 'breadcrumb is GSD-tagged');
});
test('plugin implements the full declared opencode-subset hook surface (Claude parity)', async (t) => {
const { hookEventSurfaceFor } = require('../gsd-core/bin/lib/host-integration.cjs');
const surface = hookEventSurfaceFor('opencode-subset');
assert.ok(surface, 'opencode-subset is a consumed dialect (non-null surface)');
test('plugin implements the full declared opencode extension-event surface (Claude parity — #1943)', async (t) => {
const { extensionEventSurfaceFor } = require('../gsd-core/bin/lib/host-integration.cjs');
const surface = extensionEventSurfaceFor('opencode');
assert.ok(surface, 'opencode is a consumed extensionEvents dialect (non-null surface)');
// The engine — not the host bus — owns workflow-phase sequencing on this host.
assert.ok(!surface.some((e) => /plan:|verify:|ship:|execute:/.test(e)),
'opencode-subset fires no workflow-phase events');
assert.ok(!surface.some((e) => /plan:|verify:|ship:/.test(e)),
'opencode extension events include no workflow-phase events');
const { mod } = buildInstalledLayout(t, {});
const handlers = await mod.server({ directory: process.cwd() });
@@ -344,7 +344,7 @@ test('plugin implements the full declared opencode-subset hook surface (Claude p
for (const ev of surface) {
const covered = typeof handlers[ev] === 'function'
|| ev === 'session.created' || ev === 'session.idle' || ev === 'file.edited';
assert.ok(covered, `plugin covers opencode-subset event: ${ev}`);
assert.ok(covered, `plugin covers opencode extension event: ${ev}`);
}
});

View File

@@ -23,7 +23,7 @@ test('SDK exports the full public surface a host-plugin author needs', () => {
const fns = [
'negotiateHostCapabilities', 'profileOf', 'degradationFor',
'hookEventSurfaceFor', 'shouldFlattenDispatch',
'hookEventSurfaceFor', 'extensionEventSurfaceFor', 'shouldFlattenDispatch',
'createDeclarativeAdapter', 'createImperativeAdapter',
'createModelAdapter', 'createHookBus', 'createStateIO',
'buildHandshakeRequest', 'handleHandshakeRequest',