* 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)
This commit is contained in:
7
.changeset/plucky-moles-sing.md
Normal file
7
.changeset/plucky-moles-sing.md
Normal file
@@ -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.
|
||||
|
||||
<!-- docs-exempt: internal adapter seams; no user-facing command/flag/config/schema/doc surface -->
|
||||
@@ -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",
|
||||
|
||||
@@ -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',
|
||||
],
|
||||
},
|
||||
|
||||
|
||||
96
src/hook-bus.cts
Normal file
96
src/hook-bus.cts
Normal file
@@ -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<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);
|
||||
},
|
||||
});
|
||||
}
|
||||
75
src/state-io.cts
Normal file
75
src/state-io.cts
Normal file
@@ -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); },
|
||||
});
|
||||
}
|
||||
57
tests/hook-bus.test.cjs
Normal file
57
tests/hook-bus.test.cjs
Normal file
@@ -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`);
|
||||
}
|
||||
});
|
||||
59
tests/state-io.test.cjs
Normal file
59
tests/state-io.test.cjs
Normal file
@@ -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`);
|
||||
}
|
||||
});
|
||||
Reference in New Issue
Block a user