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:
5
.changeset/serene-birds-rest.md
Normal file
5
.changeset/serene-birds-rest.md
Normal 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)
|
||||||
@@ -61,6 +61,7 @@
|
|||||||
},
|
},
|
||||||
"commandStyle": "slash-hyphen",
|
"commandStyle": "slash-hyphen",
|
||||||
"hooksSurface": "none",
|
"hooksSurface": "none",
|
||||||
|
"extensionEvents": "opencode",
|
||||||
"sandboxTier": "none",
|
"sandboxTier": "none",
|
||||||
"supportTier": 2,
|
"supportTier": 2,
|
||||||
"installSurface": "settings-json",
|
"installSurface": "settings-json",
|
||||||
|
|||||||
@@ -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).
|
- **`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.
|
- **`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).
|
- **`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.
|
**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) |
|
| 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) |
|
| 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 |
|
| 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 |
|
| 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) |
|
| 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
|
### 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.
|
- **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.
|
- **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.
|
||||||
|
|||||||
@@ -1690,6 +1690,7 @@ const capabilities = {
|
|||||||
},
|
},
|
||||||
"commandStyle": "slash-hyphen",
|
"commandStyle": "slash-hyphen",
|
||||||
"hooksSurface": "none",
|
"hooksSurface": "none",
|
||||||
|
"extensionEvents": "opencode",
|
||||||
"sandboxTier": "none",
|
"sandboxTier": "none",
|
||||||
"supportTier": 2,
|
"supportTier": 2,
|
||||||
"installSurface": "settings-json",
|
"installSurface": "settings-json",
|
||||||
@@ -4227,6 +4228,7 @@ const runtimes = {
|
|||||||
},
|
},
|
||||||
"commandStyle": "slash-hyphen",
|
"commandStyle": "slash-hyphen",
|
||||||
"hooksSurface": "none",
|
"hooksSurface": "none",
|
||||||
|
"extensionEvents": "opencode",
|
||||||
"sandboxTier": "none",
|
"sandboxTier": "none",
|
||||||
"supportTier": 2,
|
"supportTier": 2,
|
||||||
"installSurface": "settings-json",
|
"installSurface": "settings-json",
|
||||||
|
|||||||
@@ -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_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_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_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_SANDBOX_TIERS = new Set(['none', 'codex-agent-sandbox']);
|
||||||
const VALID_ARTIFACT_KIND_NAMES = new Set(['commands', 'agents', 'skills', 'kimi-agents']);
|
const VALID_ARTIFACT_KIND_NAMES = new Set(['commands', 'agents', 'skills', 'kimi-agents']);
|
||||||
const VALID_ARTIFACT_NESTINGS = new Set(['flat', 'nested']);
|
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 !== undefined) {
|
||||||
if (r.hookEvents === '__proto__' || r.hookEvents === 'constructor' || r.hookEvents === 'prototype') {
|
if (r.hookEvents === '__proto__' || r.hookEvents === 'constructor' || r.hookEvents === 'prototype') {
|
||||||
errors.push('runtime.hookEvents "' + r.hookEvents + '" is a reserved name');
|
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)
|
// sandboxTier — closed 2-enum (ADR-1016 Decision 6); inline literal guard (CodeQL barrier)
|
||||||
if (r.sandboxTier === '__proto__' || r.sandboxTier === 'constructor' || r.sandboxTier === 'prototype') {
|
if (r.sandboxTier === '__proto__' || r.sandboxTier === 'constructor' || r.sandboxTier === 'prototype') {
|
||||||
errors.push('runtime.sandboxTier "' + r.sandboxTier + '" is a reserved name');
|
errors.push('runtime.sandboxTier "' + r.sandboxTier + '" is a reserved name');
|
||||||
@@ -2225,6 +2246,7 @@ module.exports = {
|
|||||||
VALID_COMMAND_STYLES,
|
VALID_COMMAND_STYLES,
|
||||||
VALID_HOOKS_SURFACES,
|
VALID_HOOKS_SURFACES,
|
||||||
VALID_HOOK_EVENTS,
|
VALID_HOOK_EVENTS,
|
||||||
|
VALID_EXTENSION_EVENTS,
|
||||||
VALID_SANDBOX_TIERS,
|
VALID_SANDBOX_TIERS,
|
||||||
VALID_ARTIFACT_KIND_NAMES,
|
VALID_ARTIFACT_KIND_NAMES,
|
||||||
VALID_ARTIFACT_NESTINGS,
|
VALID_ARTIFACT_NESTINGS,
|
||||||
|
|||||||
@@ -40,6 +40,7 @@ const SDK = Object.freeze({
|
|||||||
profileOf: hostIntegration.profileOf,
|
profileOf: hostIntegration.profileOf,
|
||||||
degradationFor: hostIntegration.degradationFor,
|
degradationFor: hostIntegration.degradationFor,
|
||||||
hookEventSurfaceFor: hostIntegration.hookEventSurfaceFor,
|
hookEventSurfaceFor: hostIntegration.hookEventSurfaceFor,
|
||||||
|
extensionEventSurfaceFor: hostIntegration.extensionEventSurfaceFor,
|
||||||
shouldFlattenDispatch: hostIntegration.shouldFlattenDispatch,
|
shouldFlattenDispatch: hostIntegration.shouldFlattenDispatch,
|
||||||
|
|
||||||
// ── Embedding + engine adapters ──────────────────────────────────────────
|
// ── Embedding + engine adapters ──────────────────────────────────────────
|
||||||
|
|||||||
@@ -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
|
// Host-fireable MANAGED-hook events per `hookEvents` dialect. `hookEvents` is the
|
||||||
// CONSUMER of the reserved 'opencode-subset' dialect (previously zero consumers):
|
// managed-hook dialect — the event names GSD writes into a DECLARATIVE host's
|
||||||
// it lets the engine ask which events a host's bus actually exposes, so it knows
|
// settings.json (claude = SessionStart/PreToolUse/…; gemini = BeforeTool/AfterTool).
|
||||||
// workflow-phase hooks (plan:pre / verify:post / …) are NOT available on an
|
// This is DISTINCT from the extension-system event surface (below): a host's
|
||||||
// opencode-subset host and the engine must own phase sequencing internally
|
// plugin/extension API fires a different, plugin-owned event set. The two must
|
||||||
// (ADR-1239 §OpenCode binding). 'claude' = full Claude surface; 'gemini' =
|
// not be conflated (ADR-1239 amendment / #1943 — the former 'opencode-subset'
|
||||||
// Gemini's BeforeTool/AfterTool family; 'opencode-subset' = OpenCode's
|
// `hookEvents` value was this conflation; it is now `extensionEvents: opencode`).
|
||||||
// session/tool/file subset (no workflow-phase events).
|
|
||||||
const HOOK_EVENT_SURFACES: Readonly<Record<string, readonly string[]>> = Object.freeze({
|
const HOOK_EVENT_SURFACES: Readonly<Record<string, readonly string[]>> = Object.freeze({
|
||||||
claude: Object.freeze(['SessionStart', 'PreToolUse', 'PostToolUse', 'Stop', 'SessionEnd', 'PreCompact']),
|
claude: Object.freeze(['SessionStart', 'PreToolUse', 'PostToolUse', 'Stop', 'SessionEnd', 'PreCompact']),
|
||||||
gemini: Object.freeze(['SessionStart', 'BeforeTool', 'AfterTool', 'SessionEnd']),
|
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.
|
* 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 {
|
function hookEventSurfaceFor(hookEvents: unknown): readonly string[] | null {
|
||||||
if (typeof hookEvents !== 'string') return null;
|
if (typeof hookEvents !== 'string') return null;
|
||||||
return HOOK_EVENT_SURFACES[hookEvents] || 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)
|
// Module export (CommonJS — matches existing src/*.cts pattern)
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
@@ -558,9 +585,11 @@ export = {
|
|||||||
PROFILE_BASELINES,
|
PROFILE_BASELINES,
|
||||||
DEFAULT_ENGINE,
|
DEFAULT_ENGINE,
|
||||||
HOOK_EVENT_SURFACES,
|
HOOK_EVENT_SURFACES,
|
||||||
|
EXTENSION_EVENT_SURFACES,
|
||||||
degradationFor,
|
degradationFor,
|
||||||
profileOf,
|
profileOf,
|
||||||
negotiateHostCapabilities,
|
negotiateHostCapabilities,
|
||||||
shouldFlattenDispatch,
|
shouldFlattenDispatch,
|
||||||
hookEventSurfaceFor,
|
hookEventSurfaceFor,
|
||||||
|
extensionEventSurfaceFor,
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -3200,6 +3200,11 @@ const {
|
|||||||
VALID_ARTIFACT_NESTINGS,
|
VALID_ARTIFACT_NESTINGS,
|
||||||
} = require('../scripts/gen-capability-registry.cjs');
|
} = 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 = [
|
const RUNTIME_IDS = [
|
||||||
'claude', 'codex', 'antigravity', 'gemini', 'cursor', 'opencode',
|
'claude', 'codex', 'antigravity', 'gemini', 'cursor', 'opencode',
|
||||||
'kilo', 'copilot', 'augment', 'trae', 'qwen', 'hermes',
|
'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)', () => {
|
test('kilo: hooksSurface === none, no hookEvents (registers zero lifecycle hooks)', () => {
|
||||||
const { capMap } = loadAndValidate(new Set());
|
const { capMap } = loadAndValidate(new Set());
|
||||||
const registry = buildRegistry(capMap);
|
const registry = buildRegistry(capMap);
|
||||||
@@ -3952,12 +3965,22 @@ describe('ADR-1016 phase 5a: closed-vocab set exports', () => {
|
|||||||
assert.strictEqual(VALID_HOOKS_SURFACES.size, 6);
|
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);
|
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.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', () => {
|
test('VALID_SANDBOX_TIERS has exactly 2 values', () => {
|
||||||
|
|||||||
@@ -23,34 +23,60 @@ const {
|
|||||||
negotiateHostCapabilities,
|
negotiateHostCapabilities,
|
||||||
hookEventSurfaceFor,
|
hookEventSurfaceFor,
|
||||||
HOOK_EVENT_SURFACES,
|
HOOK_EVENT_SURFACES,
|
||||||
|
extensionEventSurfaceFor,
|
||||||
|
EXTENSION_EVENT_SURFACES,
|
||||||
} = hi;
|
} = hi;
|
||||||
|
|
||||||
describe('hookEventSurfaceFor (hookEvents dialect consumer — #1682)', () => {
|
describe('hookEventSurfaceFor (MANAGED-hook dialect consumer — claude/gemini only)', () => {
|
||||||
test('returns the full Claude surface for "claude"', () => {
|
test('returns the full Claude managed-hook surface for "claude"', () => {
|
||||||
const s = hookEventSurfaceFor('claude');
|
const s = hookEventSurfaceFor('claude');
|
||||||
assert.ok(s && s.includes('PreToolUse') && s.includes('PostToolUse') && s.includes('Stop'));
|
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');
|
const s = hookEventSurfaceFor('gemini');
|
||||||
assert.ok(s && s.includes('BeforeTool') && s.includes('AfterTool'));
|
assert.ok(s && s.includes('BeforeTool') && s.includes('AfterTool'));
|
||||||
});
|
});
|
||||||
test('CONSUMES "opencode-subset": OpenCode session/tool/file subset with NO workflow-phase events', () => {
|
test('hookEvents is the MANAGED-hook dialect only — opencode-subset is NOT here (#1943)', () => {
|
||||||
const s = hookEventSurfaceFor('opencode-subset');
|
assert.equal(hookEventSurfaceFor('opencode-subset'), null,
|
||||||
assert.ok(s, 'opencode-subset must resolve (non-null) — it is consumed, not reserved');
|
'opencode-subset is not a hookEvents value — it moved to the extensionEvents vocabulary');
|
||||||
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('returns null for unknown / missing / non-string dialect (fail-closed)', () => {
|
test('returns null for unknown / missing / non-string dialect (fail-closed)', () => {
|
||||||
assert.equal(hookEventSurfaceFor('nope'), null);
|
assert.equal(hookEventSurfaceFor('nope'), null);
|
||||||
assert.equal(hookEventSurfaceFor(undefined), null);
|
assert.equal(hookEventSurfaceFor(undefined), null);
|
||||||
assert.equal(hookEventSurfaceFor(123), 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.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']);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -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');
|
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) => {
|
test('plugin implements the full declared opencode extension-event surface (Claude parity — #1943)', async (t) => {
|
||||||
const { hookEventSurfaceFor } = require('../gsd-core/bin/lib/host-integration.cjs');
|
const { extensionEventSurfaceFor } = require('../gsd-core/bin/lib/host-integration.cjs');
|
||||||
const surface = hookEventSurfaceFor('opencode-subset');
|
const surface = extensionEventSurfaceFor('opencode');
|
||||||
assert.ok(surface, 'opencode-subset is a consumed dialect (non-null surface)');
|
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.
|
// The engine — not the host bus — owns workflow-phase sequencing on this host.
|
||||||
assert.ok(!surface.some((e) => /plan:|verify:|ship:|execute:/.test(e)),
|
assert.ok(!surface.some((e) => /plan:|verify:|ship:/.test(e)),
|
||||||
'opencode-subset fires no workflow-phase events');
|
'opencode extension events include no workflow-phase events');
|
||||||
|
|
||||||
const { mod } = buildInstalledLayout(t, {});
|
const { mod } = buildInstalledLayout(t, {});
|
||||||
const handlers = await mod.server({ directory: process.cwd() });
|
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) {
|
for (const ev of surface) {
|
||||||
const covered = typeof handlers[ev] === 'function'
|
const covered = typeof handlers[ev] === 'function'
|
||||||
|| ev === 'session.created' || ev === 'session.idle' || ev === 'file.edited';
|
|| 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}`);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -23,7 +23,7 @@ test('SDK exports the full public surface a host-plugin author needs', () => {
|
|||||||
|
|
||||||
const fns = [
|
const fns = [
|
||||||
'negotiateHostCapabilities', 'profileOf', 'degradationFor',
|
'negotiateHostCapabilities', 'profileOf', 'degradationFor',
|
||||||
'hookEventSurfaceFor', 'shouldFlattenDispatch',
|
'hookEventSurfaceFor', 'extensionEventSurfaceFor', 'shouldFlattenDispatch',
|
||||||
'createDeclarativeAdapter', 'createImperativeAdapter',
|
'createDeclarativeAdapter', 'createImperativeAdapter',
|
||||||
'createModelAdapter', 'createHookBus', 'createStateIO',
|
'createModelAdapter', 'createHookBus', 'createStateIO',
|
||||||
'buildHandshakeRequest', 'handleHandshakeRequest',
|
'buildHandshakeRequest', 'handleHandshakeRequest',
|
||||||
|
|||||||
Reference in New Issue
Block a user