diff --git a/.changeset/plucky-moles-sing.md b/.changeset/plucky-moles-sing.md new file mode 100644 index 000000000..5a674322f --- /dev/null +++ b/.changeset/plucky-moles-sing.md @@ -0,0 +1,7 @@ +--- +type: Changed +pr: 1805 +--- +**Internal: hook-bus + stateIO adapter seams** — `createHookBus({bus})` (new `src/hook-bus.cts`, `host`/`engine`/`none` — engine is in-process pub/sub, host fail-closed, none silent) + `createStateIO({io})` (new `src/state-io.cts`, `filesystem`/`sandboxed-storage`/`session-log-append` — filesystem delegates to fs, the rest are fail-closed seams) (ADR-1239 Phase C-1 / #1680 AC4). Completes the Phase 3 adapter seam layer; concrete host binding is Phase 5. No user-facing change. + + diff --git a/docs/INVENTORY-MANIFEST.json b/docs/INVENTORY-MANIFEST.json index 106d8e77c..bcccec64b 100644 --- a/docs/INVENTORY-MANIFEST.json +++ b/docs/INVENTORY-MANIFEST.json @@ -335,6 +335,7 @@ "graphify-command-router.cjs", "graphify.cjs", "gsd2-import.cjs", + "hook-bus.cjs", "host-integration.cjs", "init-command-router.cjs", "init.cjs", @@ -398,6 +399,7 @@ "stale-bake-guard.cjs", "state-command-router.cjs", "state-document.cjs", + "state-io.cjs", "state-transition.cjs", "state.cjs", "surface.cjs", diff --git a/eslint.config.mjs b/eslint.config.mjs index 115b69482..5d2fdcfc2 100644 --- a/eslint.config.mjs +++ b/eslint.config.mjs @@ -205,6 +205,8 @@ export default tseslint.config( 'gsd-core/bin/lib/adapter-declarative.cjs', 'gsd-core/bin/lib/adapter-imperative.cjs', 'gsd-core/bin/lib/model-adapter.cjs', + 'gsd-core/bin/lib/hook-bus.cjs', + 'gsd-core/bin/lib/state-io.cjs', ], }, diff --git a/src/hook-bus.cts b/src/hook-bus.cts new file mode 100644 index 000000000..6ecc586ed --- /dev/null +++ b/src/hook-bus.cts @@ -0,0 +1,96 @@ +/** + * 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 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); + }, + }); +} diff --git a/src/state-io.cts b/src/state-io.cts new file mode 100644 index 000000000..60be8c38b --- /dev/null +++ b/src/state-io.cts @@ -0,0 +1,75 @@ +/** + * State IO seam (ADR-1239 Phase C-1, AC4 / #1680). + * + * Abstracts `.planning/` + config IO behind the negotiated `stateIO` axis + * (host-integration.cts): + * + * - `filesystem` — most hosts: reads/writes under `.planning/` + + * `configHome`. TODAY's behavior. Delegates to fs. + * - `sandboxed-storage` — VS Code web (no arbitrary FS). Seam: a + * host-supplied backend; fail-closed until Phase 5. + * - `session-log-append` — pi (JSONL session log). Seam: host-supplied + * backend; fail-closed until Phase 5. + * + * `filesystem` is the default and reproduces today's IO byte-for-behavior + * (planning-workspace.cts keeps routing its fs ops; this seam is the + * abstraction a non-filesystem host swaps in). `configHome` write-confinement + * (ADR-1239 Phase B / #1679) applies to the filesystem path. + * + * Minimal seam: the host-backend protocol is fixed when a real non-filesystem + * host lands (Phase 5 / #1682). + */ +'use strict'; + +import fs from 'node:fs'; + +export type StateIOMode = 'filesystem' | 'sandboxed-storage' | 'session-log-append'; + +export interface StateIOAdapter { + readonly io: StateIOMode; + read(path: string): string; + write(path: string, content: string): void; +} + +export interface StateIOBackend { + read(path: string): string; + write(path: string, content: string): void; +} + +export interface CreateStateIOOptions { + /** Required for non-filesystem modes: the host's storage backend. */ + backend?: StateIOBackend; +} + +export function createStateIO( + { io }: { io: StateIOMode }, + options: CreateStateIOOptions = {}, +): StateIOAdapter { + if (io !== 'filesystem' && io !== 'sandboxed-storage' && io !== 'session-log-append') { + throw new TypeError(`createStateIO: io must be 'filesystem' | 'sandboxed-storage' | 'session-log-append' (got ${JSON.stringify(io)})`); + } + if (io === 'filesystem') { + // Today's behavior — straight fs. planning-workspace.cts keeps its routing; + // this is the swap-point a non-filesystem host replaces. + return Object.freeze({ + io: 'filesystem', + read(path: string) { return fs.readFileSync(path, 'utf-8'); }, + write(path: string, content: string) { fs.writeFileSync(path, content, 'utf-8'); }, + }); + } + // sandboxed-storage / session-log-append: host backend, fail-closed until bound. + const backend = options.backend; + const unbound = (): never => { + throw new Error( + `${io} stateIO: no host backend bound — non-filesystem state requires a backend (Phase 5 wires the concrete host).`, + ); + }; + if (!backend || typeof backend.read !== 'function' || typeof backend.write !== 'function') { + return Object.freeze({ io, read: unbound, write: unbound }); + } + return Object.freeze({ + io, + read(path: string) { return backend.read(path); }, + write(path: string, content: string) { backend.write(path, content); }, + }); +} diff --git a/tests/hook-bus.test.cjs b/tests/hook-bus.test.cjs new file mode 100644 index 000000000..972df5b71 --- /dev/null +++ b/tests/hook-bus.test.cjs @@ -0,0 +1,57 @@ +'use strict'; +/** + * Tests for the hook-bus seam (ADR-1239 Phase C-1, AC4 / #1680). + * Pins the three ownership modes + portable floor + fail-closed. + */ + +const { test } = require('node:test'); +const assert = require('node:assert/strict'); +const { createHookBus, PORTABLE_EVENT_FLOOR } = require('../gsd-core/bin/lib/hook-bus.cjs'); + +test('PORTABLE_EVENT_FLOOR: the 5 portable events (the claude dialect all hook hosts share)', () => { + assert.deepStrictEqual([...PORTABLE_EVENT_FLOOR], ['SessionStart', 'PreToolUse', 'PostToolUse', 'Stop', 'SessionEnd']); +}); + +test('engine bus: in-process pub/sub (subscribe + emit reaches handlers)', () => { + const bus = createHookBus({ bus: 'engine' }); + assert.strictEqual(bus.bus, 'engine'); + const received = []; + bus.subscribe('PreToolUse', (p) => received.push(['PreToolUse', p])); + bus.subscribe('Stop', () => received.push(['Stop'])); + bus.emit('PreToolUse', { tool: 'Edit' }); + bus.emit('SessionStart'); + bus.emit('Stop'); + assert.deepStrictEqual(received, [['PreToolUse', { tool: 'Edit' }], ['Stop']]); +}); + +test('engine bus: a throwing handler is isolated (does not break the bus or other handlers)', () => { + const bus = createHookBus({ bus: 'engine' }); + const seen = []; + bus.subscribe('PostToolUse', () => { throw new Error('boom'); }); + bus.subscribe('PostToolUse', (p) => seen.push(p)); + assert.doesNotThrow(() => bus.emit('PostToolUse', { ok: true })); + assert.deepStrictEqual(seen, [{ ok: true }]); +}); + +test('none bus: subscribe + emit are silent no-ops (degrade to rule-text)', () => { + const bus = createHookBus({ bus: 'none' }); + assert.strictEqual(bus.bus, 'none'); + assert.doesNotThrow(() => bus.subscribe('SessionStart', () => { throw new Error('must not be called'); })); + assert.doesNotThrow(() => bus.emit('SessionStart', {})); +}); + +test('host bus: emit delegates to the bound host emitter; fail-closed when unbound', () => { + const emitted = []; + const bound = createHookBus({ bus: 'host' }, { hostEmit: (e, p) => emitted.push([e, p]) }); + assert.strictEqual(bound.bus, 'host'); + bound.emit('Stop', { reason: 'done' }); + assert.deepStrictEqual(emitted, [['Stop', { reason: 'done' }]]); + const unbound = createHookBus({ bus: 'host' }); + assert.throws(() => unbound.emit('Stop'), /no host emitter bound/, 'unbound host emit must fail closed'); +}); + +test('createHookBus: invalid mode throws (fail-closed construction)', () => { + for (const bad of ['cloud', '', null, undefined, 1]) { + assert.throws(() => createHookBus({ bus: bad }), TypeError, `bus=${JSON.stringify(bad)} must throw`); + } +}); diff --git a/tests/state-io.test.cjs b/tests/state-io.test.cjs new file mode 100644 index 000000000..2a358a927 --- /dev/null +++ b/tests/state-io.test.cjs @@ -0,0 +1,59 @@ +'use strict'; +/** + * Tests for the state IO seam (ADR-1239 Phase C-1, AC4 / #1680). + * Pins: filesystem (today's behavior) + fail-closed non-filesystem seams + + * host-backend binding + construction gating. + */ + +const { test } = require('node:test'); +const assert = require('node:assert/strict'); +const fs = require('node:fs'); +const path = require('node:path'); +const { createStateIO } = require('../gsd-core/bin/lib/state-io.cjs'); +const { createTempDir, cleanup } = require('./helpers.cjs'); + +test('filesystem stateIO: read/write delegate to fs (today behavior)', () => { + const io = createStateIO({ io: 'filesystem' }); + assert.strictEqual(io.io, 'filesystem'); + const dir = createTempDir(); + try { + const file = path.join(dir, 'STATE.md'); + io.write(file, '# State\n'); + assert.strictEqual(io.read(file), '# State\n'); + assert.strictEqual(fs.readFileSync(file, 'utf-8'), '# State\n', 'must write through to real fs'); + } finally { + cleanup(dir); + } +}); + +test('sandboxed-storage stateIO: fail-closed until a host backend is bound', () => { + const io = createStateIO({ io: 'sandboxed-storage' }); + assert.strictEqual(io.io, 'sandboxed-storage'); + assert.throws(() => io.read('/x'), /no host backend bound/, 'read must fail closed when unbound'); + assert.throws(() => io.write('/x', 'y'), /no host backend bound/, 'write must fail closed when unbound'); +}); + +test('session-log-append stateIO: fail-closed until a host backend is bound', () => { + const io = createStateIO({ io: 'session-log-append' }); + assert.throws(() => io.read('/x'), /no host backend bound/); +}); + +test('non-filesystem stateIO: host backend is used when bound', () => { + const calls = []; + const io = createStateIO( + { io: 'sandboxed-storage' }, + { backend: { + read: (p) => { calls.push(['read', p]); return 'BACKEND:' + p; }, + write: (p, c) => { calls.push(['write', p, c]); }, + } }, + ); + assert.strictEqual(io.read('/state/log'), 'BACKEND:/state/log'); + io.write('/state/log', 'entry'); + assert.deepStrictEqual(calls, [['read', '/state/log'], ['write', '/state/log', 'entry']]); +}); + +test('createStateIO: invalid io throws (fail-closed construction)', () => { + for (const bad of ['memory', '', null, undefined, 2]) { + assert.throws(() => createStateIO({ io: bad }), TypeError, `io=${JSON.stringify(bad)} must throw`); + } +});