* feat(#1682): OpenCode session.idle + opencode-subset dialect + Claude parity — Slice 1b/c - Plugin (.opencode/plugins/gsd-core.js): handle session.idle (↔ Claude Stop lifecycle point; no-op sentinel — state already persisted to .planning/). Completes the compaction/idle pair (#1914 shipped compaction). - Declare hookEvents: 'opencode-subset' in the OpenCode descriptor — the reserved dialect now has a real consumer (no longer zero-consumer). - host-integration.cts: add HOOK_EVENT_SURFACES + hookEventSurfaceFor() — the pure consumer that resolves a dialect to its host-fireable event surface. opencode-subset = session/tool/file subset with NO workflow-phase events (engine owns phase sequencing; ADR-1239 §OpenCode binding). - Tests: hookEventSurfaceFor unit tests (claude/gemini/opencode-subset/null); plugin session.idle no-throw; compaction breadcrumb; opencode-subset surface parity vs the plugin's handlers (Claude parity). * fix(#1682): don't declare hookEvents on opencode (hooksSurface:none invariant) The repo invariant couples runtime.hookEvents to the managed settings.json hook surface: hooksSurface:'none' runtimes (opencode — plugin owns hooks) must NOT declare hookEvents. Declaring 'opencode-subset' there violated 3 capability- registry invariants + 2 install-plan golden masters + opencode golden parity. The opencode-subset dialect is still IMPLEMENTED — just not via the legacy descriptor field: hookEventSurfaceFor() (host-integration.cts) is its consumer, and the OpenCode plugin consumes the subset events at runtime (session.idle added here; compaction shipped in #1914). Declaring it on the descriptor would require weakening the hooksSurface:none ⇔ no-hookEvents invariant (flagged for decision). * fix(#1682): refresh opencode golden parity for plugins/gsd-core.js (session.idle) * docs(changeset): OpenCode session.idle + opencode-subset dialect (#1682) * docs(changeset): backfill PR #1930
This commit is contained in:
6
.changeset/1682-opencode-subset-dialect.md
Normal file
6
.changeset/1682-opencode-subset-dialect.md
Normal file
@@ -0,0 +1,6 @@
|
||||
---
|
||||
type: Added
|
||||
pr: 1930
|
||||
---
|
||||
<!-- docs-exempt: Phase 5 (#1682) Slice 1b/c; consolidated Phase 5 docs land with phase completion. -->
|
||||
**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)
|
||||
@@ -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;
|
||||
}
|
||||
},
|
||||
};
|
||||
};
|
||||
|
||||
@@ -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<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.
|
||||
* 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,
|
||||
};
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
@@ -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)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
Reference in New Issue
Block a user