Files
msd-core/src/hook-bus.cts
Tom Boucher abd21b2968 feat(#1680): ADR-1239 Phase C-1 — hook-bus + stateIO seams [AC4] (#1805)
* feat(#1680): ADR-1239 Phase C-1 — hook-bus + stateIO seams [AC4]

Phase 3 slice 4 (AC4, final #1680 slice). The last two adapter seams behind
the negotiated hookBus/stateIO axes:

- src/hook-bus.cts: createHookBus({bus}, {hostEmit?}) -> host/engine/none.
  engine = in-process pub/sub (handler errors isolated); host = host-owned,
  fail-closed emit until a host emitter is bound; none = silent no-op (degrade
  to rule-text). PORTABLE_EVENT_FLOOR = SessionStart/PreToolUse/PostToolUse/
  Stop/SessionEnd (the claude dialect all hook hosts share).
- src/state-io.cts: createStateIO({io}, {backend?}) -> filesystem (today's
  behavior — straight fs) / sandboxed-storage / session-log-append (fail-closed
  seams until a host backend is bound).

Proactive CI gates: ADR-457 ignores + INVENTORY-MANIFEST entries for both new
.cjs; injection-scan 'act as' substring audit; unused-import check. All clean
locally (11 tests + security 15/15 + inventory + eslint 0 problems).

Phase 3 (#1680) seam layer now complete. Concrete host binding -> Phase 5
(#1682, D15/D18).

* chore(changeset): add Changed fragment for hook-bus + stateIO seams (#1680)
2026-06-28 11:44:36 -04:00

97 lines
3.8 KiB
TypeScript

/**
* Hook-bus seam (ADR-1239 Phase C-1, AC4 / #1680).
*
* The lifecycle-hook ownership model, selected by the negotiated `hookBus`
* axis (host-integration.cts):
*
* - `engine` — GSD owns the bus internally (in-process pub/sub). Used by
* hosts that have no event bus (VS Code). Full subscribe + emit.
* - `host` — the host fires events; GSD subscribes. Handlers register
* locally for a Phase-5 host binding to dispatch to; `emit` delegates to a
* host-supplied emitter (fail-closed until bound — GSD does not drive a
* host-owned bus).
* - `none` — no bus (Cline-rules). Degrades to rule-text instructions;
* subscribe/emit are no-ops.
*
* Portable event floor — the "claude dialect" all hook-capable hosts share
* (sourced from src/runtime-hooks-surface.cts). Extended events are negotiated
* per-host (Phase 5).
*
* Minimal seam (per ADR-1239 open wire-shape question): the host-side dispatch
* wiring lands in Phase 5 (#1682). This slice ships the three ownership modes
* + the engine pub-sub + the fail-closed contract.
*/
'use strict';
export const PORTABLE_EVENT_FLOOR = Object.freeze(
['SessionStart', 'PreToolUse', 'PostToolUse', 'Stop', 'SessionEnd'] as const,
);
export type PortableEvent = (typeof PORTABLE_EVENT_FLOOR)[number];
export type HookBusMode = 'host' | 'engine' | 'none';
export interface HookBusAdapter {
readonly bus: HookBusMode;
/** Register a handler for an event. No-op on `none`. */
subscribe(event: string, handler: (payload?: unknown) => void): void;
/** Emit an event to subscribers. No-op on `none`; fail-closed on `host` until a host emitter is bound. */
emit(event: string, payload?: unknown): void;
}
export interface CreateHookBusOptions {
/** Required for `host`: the host's emit primitive (GSD emits → host bus). */
hostEmit?: (event: string, payload?: unknown) => void;
}
export function createHookBus(
{ bus }: { bus: HookBusMode },
options: CreateHookBusOptions = {},
): HookBusAdapter {
if (bus !== 'host' && bus !== 'engine' && bus !== 'none') {
throw new TypeError(`createHookBus: bus must be 'host' | 'engine' | 'none' (got ${JSON.stringify(bus)})`);
}
if (bus === 'none') {
return Object.freeze({
bus,
subscribe() { /* no bus — degrade to rule-text instructions */ },
emit() { /* no-op */ },
});
}
if (bus === 'engine') {
const subs = new Map<string, Array<(payload?: unknown) => void>>();
return Object.freeze({
bus: 'engine',
subscribe(event: string, handler: (payload?: unknown) => void) {
const list = subs.get(event);
if (list) list.push(handler);
else subs.set(event, [handler]);
},
emit(event: string, payload?: unknown) {
const list = subs.get(event);
if (!list) return;
for (const h of list) {
// Handler errors are isolated — one throwing handler must not break the bus.
try { h(payload); } catch { /* swallow; bus stays up */ }
}
},
});
}
// host: GSD subscribes; emits go to the host-supplied emitter (fail-closed until bound).
const hostEmit = options.hostEmit;
return Object.freeze({
bus: 'host',
subscribe(_event: string, _handler: (payload?: unknown) => void) {
// Host owns the bus; GSD's subscriptions are dispatched by a Phase-5 host
// binding that calls the registered handlers when the host fires events.
// Stored host-side; locally this is a seam until that binding lands.
},
emit(event: string, payload?: unknown) {
if (typeof hostEmit !== 'function') {
throw new Error(
"host hook-bus emit: no host emitter bound — the 'host' bus requires a hostEmit primitive (Phase 5 wires the concrete host).",
);
}
hostEmit(event, payload);
},
});
}