diff --git a/.changeset/1682-opencode-subset-dialect.md b/.changeset/1682-opencode-subset-dialect.md new file mode 100644 index 000000000..ef9e18138 --- /dev/null +++ b/.changeset/1682-opencode-subset-dialect.md @@ -0,0 +1,6 @@ +--- +type: Added +pr: 1930 +--- + +**OpenCode plugin handles `session.idle` + the `opencode-subset` hook dialect is implemented** — the GSD OpenCode plugin now recognizes `session.idle` (↔ Claude `Stop` lifecycle point), completing the compaction/idle pair (#1914 shipped compaction). The reserved `opencode-subset` dialect gains a consumer — `hookEventSurfaceFor()` in `host-integration.cts` — describing OpenCode's session/tool/file event subset (no workflow-phase events; the engine owns phase sequencing, ADR-1239 §OpenCode binding). Adds a Claude-parity test asserting the plugin covers the full declared subset. (#1682) diff --git a/.opencode/plugins/gsd-core.js b/.opencode/plugins/gsd-core.js index 0f89d3c9f..89e6c4544 100644 --- a/.opencode/plugins/gsd-core.js +++ b/.opencode/plugins/gsd-core.js @@ -651,6 +651,18 @@ const GsdCorePlugin = async ({ directory } = {}) => { handleHookResult(r); return; } + + // session.idle ↔ Claude Stop lifecycle point (#1682 Slice 1b/c). + // OpenCode fires session.idle when the run quiesces. GSD maps it to the + // Stop equivalent — the opencode-subset lifecycle peer of compaction + // (compaction preserves state across context-window summarization; idle + // marks end-of-turn). No-op sentinel today (GSD state is already + // persisted to .planning/), but it MUST be recognized so the declared + // opencode-subset surface is fully wired and a future Stop-class hook can + // attach without a plugin change. + if (event.type === "session.idle") { + return; + } }, }; }; diff --git a/src/host-integration.cts b/src/host-integration.cts index e428cafd0..3a93fe76b 100644 --- a/src/host-integration.cts +++ b/src/host-integration.cts @@ -512,6 +512,40 @@ function shouldFlattenDispatch(dispatch: UnvalidatedDispatch): boolean { return !canBackground; } +// --------------------------------------------------------------------------- +// Hook-event surface per hookEvents dialect (ADR-1239 Phase D / #1682) +// --------------------------------------------------------------------------- + +// 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). +const HOOK_EVENT_SURFACES: Readonly> = 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. + * 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; +} + // --------------------------------------------------------------------------- // Module export (CommonJS — matches existing src/*.cts pattern) // --------------------------------------------------------------------------- @@ -523,8 +557,10 @@ export = { INTERFACE_POINTS, PROFILE_BASELINES, DEFAULT_ENGINE, + HOOK_EVENT_SURFACES, degradationFor, profileOf, negotiateHostCapabilities, shouldFlattenDispatch, + hookEventSurfaceFor, }; diff --git a/tests/fixtures/golden-install-parity/opencode.json b/tests/fixtures/golden-install-parity/opencode.json index 12916c10c..2abbee7fe 100644 --- a/tests/fixtures/golden-install-parity/opencode.json +++ b/tests/fixtures/golden-install-parity/opencode.json @@ -392,7 +392,7 @@ "hooks/managed-hooks-registry.cjs": "763730ef31e5fd1c", "opencode.json": "2c12c446a88f2f36", "package.json": "dbf8353f77358bc1", - "plugins/gsd-core.js": "8ae69107bf3036a0", + "plugins/gsd-core.js": "63687dc233ca707e", "scripts/changeset/README.md": "86ff89331dfd94b2", "scripts/changeset/cli.cjs": "68f92a344b199271", "scripts/changeset/github-release-notes.cjs": "795677f0c009b132", diff --git a/tests/host-integration.test.cjs b/tests/host-integration.test.cjs index a4fd96cfd..a594a3f23 100644 --- a/tests/host-integration.test.cjs +++ b/tests/host-integration.test.cjs @@ -21,8 +21,39 @@ const { degradationFor, profileOf, negotiateHostCapabilities, + hookEventSurfaceFor, + HOOK_EVENT_SURFACES, } = hi; +describe('hookEventSurfaceFor (hookEvents dialect consumer — #1682)', () => { + test('returns the full Claude 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"', () => { + 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('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', () => { + assert.equal(Object.isFrozen(HOOK_EVENT_SURFACES), true); + assert.deepEqual(Object.keys(HOOK_EVENT_SURFACES).sort(), ['claude', 'gemini', 'opencode-subset']); + }); +}); + // --------------------------------------------------------------------------- // CONTRACT-PIN: constants and vocabulary // --------------------------------------------------------------------------- diff --git a/tests/opencode-plugin-adapter.test.cjs b/tests/opencode-plugin-adapter.test.cjs index 58fb11127..34ae424d6 100644 --- a/tests/opencode-plugin-adapter.test.cjs +++ b/tests/opencode-plugin-adapter.test.cjs @@ -300,6 +300,54 @@ test('config hook is a no-op in installed (non-package) layout', async (t) => { assert.deepEqual(config, {}); }); +// --------------------------------------------------------------------------- +// Session lifecycle + opencode-subset surface parity (#1682 Slice 1b/c) +// --------------------------------------------------------------------------- + +test('session.idle event is handled (no-op sentinel) without throwing', async (t) => { + const { mod } = buildInstalledLayout(t, {}); + const handlers = await mod.server({ directory: process.cwd() }); + // session.idle ↔ Claude Stop lifecycle point; recognized no-op today. + await assert.doesNotReject(() => handlers.event({ event: { type: 'session.idle' } })); +}); + +test('experimental.session.compacting injects the GSD state breadcrumb', async (t) => { + const { mod } = buildInstalledLayout(t, { 'gsd-context-monitor.js': stubHook('') }); + const handlers = await mod.server({ directory: process.cwd() }); + // Compaction fires only with an active session; session.created sets it. + await handlers.event({ + event: { type: 'session.created', properties: { info: { id: 's1', directory: process.cwd() } } }, + }); + const output = {}; + await handlers['experimental.session.compacting']({}, output); + assert.ok(Array.isArray(output.context) && output.context.length > 0, 'compaction injects a GSD breadcrumb'); + 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)'); + // 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'); + + const { mod } = buildInstalledLayout(t, {}); + const handlers = await mod.server({ directory: process.cwd() }); + // Tool + compaction events are top-level handler keys. + for (const ev of ['tool.execute.before', 'tool.execute.after', 'experimental.session.compacting']) { + assert.equal(typeof handlers[ev], 'function', `plugin exposes a handler for ${ev}`); + } + // 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. + 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}`); + } +}); + // --------------------------------------------------------------------------- // Installer integration: copy → manifest → uninstall (real bin/install.js) // ---------------------------------------------------------------------------