diff --git a/docs/adr/1239-gsd-embeddable-orchestration-engine.md b/docs/adr/1239-gsd-embeddable-orchestration-engine.md index 5ba27d05d..34201d562 100644 --- a/docs/adr/1239-gsd-embeddable-orchestration-engine.md +++ b/docs/adr/1239-gsd-embeddable-orchestration-engine.md @@ -1,6 +1,6 @@ # ADR-1239: GSD as an Embeddable Orchestration Engine -- **Status:** Proposed +- **Status:** Accepted - **Date:** 2026-06-14 - **Issue:** [#1239](https://github.com/open-gsd/gsd-core/issues/1239) - **Epic:** [#857](https://github.com/open-gsd/gsd-core/issues/857) (Capability system) @@ -87,7 +87,7 @@ Each phase is its own `approved-*` issue + PR with equivalence/parity proof. ### Amendment — Phase A implemented (#1684, v1.7.0) -Phase A is **implemented** (the ADR itself remains `Proposed` overall until Phases B–E land). The negotiated capability schema is materialized as a pure, additive, no-I/O module — the **Host-Integration Interface** (`src/host-integration.cts` → `gsd-core/bin/lib/host-integration.cjs`): +Phase A is **implemented** and Phases B–E have landed, so this ADR is **Accepted** (Status flipped from `Proposed` once Phase E shipped the published SDK + serialized handshake + versioning policy + Diátaxis docs). The negotiated capability schema is materialized as a pure, additive, no-I/O module — the **Host-Integration Interface** (`src/host-integration.cts` → `gsd-core/bin/lib/host-integration.cjs`): - **The eight negotiated axes** are carried under `capability.json` `runtime.hostIntegration` (extending, not replacing, the ADR-1016 axes), validated by `validateRuntimeBody` (`capability-validator.cjs`) across all 16 runtime descriptors, with the closed vocabulary kept in lock-step by a parity guard. - **`PROTOCOL_VERSION`** is an integer starting at `1`, **distinct** from the package `version` / `engines.gsd` semver (the `version`/`protocolVersion` overlap, resolved). diff --git a/docs/explanation/interface-versioning-policy.md b/docs/explanation/interface-versioning-policy.md new file mode 100644 index 000000000..8c5ebfd2f --- /dev/null +++ b/docs/explanation/interface-versioning-policy.md @@ -0,0 +1,59 @@ +# Interface versioning and deprecation policy + +This explains how the GSD Host-Integration Interface (ADR-1239) evolves over +time — what is an additive change, what is breaking, how `PROTOCOL_VERSION` +bumps, and what deprecation window host-plugin authors can rely on. The policy +exists because a published SDK is a long-term compatibility commitment (Hyrum's +Law: once external hosts depend on it, the observable behavior is the contract). + +--- + +## The version knob: `PROTOCOL_VERSION` + +`PROTOCOL_VERSION` is a positive integer carried by the engine and exchanged in +every negotiation (in-process and serialized). It governs the negotiated +capability **set** — which axes, values, and adapter shapes exist. A host +declares the version it targets; the engine negotiates down to +`min(host, engine)` and warns when a host declares a newer version than the +engine (capabilities beyond the engine's version are not trusted). + +## Additive vs. breaking + +- **Additive (no version bump required):** adding a new *optional* axis value, a + new adapter option, or a new reference profile. Existing host-plugins keep + working — the engine negotiates to their declared version and ignores + unknowns. Additive changes are the default; the interface prefers growing the + vocabulary over changing it. + +- **Breaking (requires a `PROTOCOL_VERSION` bump):** removing or renaming an + axis, changing an adapter's call/return shape, changing the negotiation result + shape, or narrowing a value set. A breaking change is never silent — it lands + behind a version bump so a host that declares the old version negotiates down + and gets a warning, not a crash. + +## Deprecation window + +A value or adapter shape is **deprecated**, not removed, for at least **one +minor release cycle**. During the window: + +1. The old form still works at its declared version. +2. The reference docs mark it deprecated with the successor + the removal version. +3. The negotiation emits a `warnings` entry when a host uses a deprecated value. + +Removal is a breaking change and follows the version-bump rule above. The +`undocumented` sentinel is never deprecated — it is the permanent fail-closed +fallback for a host that omits an axis. + +## What this means for host-plugin authors + +- Pin to a `PROTOCOL_VERSION` and assert on it in your host-plugin's test. +- Treat unknown axis values / adapter options as additive (ignore, don't crash). +- Watch negotiation `warnings` — they surface both "host declared a newer + version" and "host used a deprecated value." +- The serialized handshake's consistency with the in-process negotiation is + itself part of the contract: a change that breaks that consistency is breaking. + +## See also + +- [Reference: the Host-Integration Interface](../reference/host-integration-interface.md) +- ADR-1239 (Status: Accepted) diff --git a/docs/how-to/author-a-host-plugin.md b/docs/how-to/author-a-host-plugin.md new file mode 100644 index 000000000..8f488bd3e --- /dev/null +++ b/docs/how-to/author-a-host-plugin.md @@ -0,0 +1,79 @@ +# How to author a host-plugin for GSD + +This guide is for **external tool authors** who want to embed GSD's orchestration +loop (dispatch + hooks + model + state) into a new host — a CLI, IDE, or agent +runtime GSD does not special-case — by writing a **host-plugin** against the +published Host-Integration SDK, **without modifying gsd-core source**. + +The contract is the SDK entry (`src/host-integration-sdk.cts` → +`gsd-core/bin/lib/host-integration-sdk.cjs`): import only from it. Everything it +re-exports is public and versioned (`PROTOCOL_VERSION`); everything else in +gsd-core is internal and may change. A smoke test +(`tests/sdk-smoke.test.cjs`) proves a third-party host can be built from this +surface alone. + +--- + +## 1. Decide your host's profile + +Classify your host into one of three profiles (the SDK's `profileOf` does this +from your axes): + +| Profile | Example hosts | Binding | +|---|---|---| +| `programmatic-cli` | OpenCode, pi | imperative adapter (`createImperativeAdapter`) | +| `declarative-cli` | Antigravity, Codex | declarative adapter (`createDeclarativeAdapter`) | +| `ide` | VS Code | imperative adapter + engine-owned hook bus + active model + sandboxed state | + +## 2. Declare your host's integration axes + +Declare the eight negotiated axes (`embeddingMode`, `commandSurface`, `dispatch`, +`modelMode`, `hookBus`, `stateIO`, `transport`, `runtime`) from your host's +**authoritative documentation** — never infer. Where the docs are silent, use the +`undocumented` sentinel (the SDK degrades it fail-closed). See +`docs/reference/host-integration-interface.md` for the closed vocabulary. + +## 3. Compose the engine adapters + +```js +const SDK = require('@opengsd/gsd-core/sdk'); // the published entry + +// Engine-owned hook bus (for hosts with no event bus, e.g. VS Code): +const hookBus = SDK.createHookBus({ bus: 'engine' }); + +// Active model via a host provider (e.g. vscode.lm); or 'passive' for CLIs: +const model = SDK.createModelAdapter({ modelMode: 'active' }, { sendRequest: hostSendRequest }); + +// State IO — filesystem for CLIs, sandboxed-storage for IDEs/web: +const stateIO = SDK.createStateIO({ io: 'filesystem' }); + +// The engine-as-library adapter bound to your runtime id: +const adapter = SDK.createImperativeAdapter({ runtime: 'my-host' }); +``` + +## 4. Negotiate capabilities (serialized, for out-of-process hosts) + +If your host runs out-of-process (can't share object refs with the engine), use +the serialized handshake instead of the in-process `negotiateHostCapabilities`: + +```js +const req = SDK.buildHandshakeRequest({ ...myAxes }); +const { effective, points, warnings } = SDK.handleHandshakeRequest(req); +``` + +The result is identical to the in-process negotiation for the same axes — that +consistency is the contract. + +## 5. Bind your host primitives + ship + +Map the six interface points (command, dispatch, model, hooks, state, artifact) +to your host's primitives (palette/chat, plugin API, provider calls, etc.). The +reference host-plugins (OpenCode, Antigravity, pi, VS Code — see Phase 5) are +canonical examples to mirror. Your host-plugin is now a self-contained artifact; +no gsd-core source change is required. + +## See also + +- **Tutorial:** `docs/tutorials/embed-gsd-in-a-new-host.md` — a complete end-to-end embedding. +- **Reference:** `docs/reference/host-integration-interface.md` — every axis, adapter, and the handshake. +- **Versioning:** `docs/explanation/interface-versioning-policy.md` — what evolves additively vs. breaking. diff --git a/docs/reference/host-integration-interface.md b/docs/reference/host-integration-interface.md new file mode 100644 index 000000000..55b628108 --- /dev/null +++ b/docs/reference/host-integration-interface.md @@ -0,0 +1,79 @@ +# Reference: the Host-Integration Interface + +This is the normative reference for the GSD Host-Integration Interface (ADR-1239) +— the versioned, negotiated contract over which any host embeds GSD's +orchestration loop. The published surface is the SDK entry +(`src/host-integration-sdk.cts`); this document specifies every symbol on it. + +The governing principle: **every axis value a host declares must come from that +host's authoritative documentation.** Where docs are silent, the host declares +the `undocumented` sentinel and the engine degrades fail-closed — it never +assumes a capability. + +--- + +## Protocol version + +`PROTOCOL_VERSION` (a positive integer) is the interface version. It governs the +negotiated capability set; see the [versioning policy](../explanation/interface-versioning-policy.md) +for what a bump means. + +## The eight negotiated axes + +`HOST_INTEGRATION_AXES` is the closed vocabulary. Each axis takes a documented +value (or the `undocumented` sentinel): + +| Axis | Values | +|---|---| +| `embeddingMode` | `imperative` \| `declarative` | +| `commandSurface` | `slash-file` \| `slash-programmatic` \| `slash-toml` \| `palette` \| `prose-only` | +| `dispatch` | struct: `{ namedDispatch, nested, maxDepth, background, subagentToolkit, backgroundDispatch }` | +| `modelMode` | `active` \| `passive` | +| `hookBus` | `host` \| `engine` \| `none` | +| `stateIO` | `filesystem` \| `sandboxed-storage` \| `session-log-append` | +| `transport` | `mcp` \| `native-extension` | +| `runtime` | `node` \| `bun` \| `sandboxed-web` \| `python` \| `go` \| `rust` \| `electron` \| `other` | + +## Classification + negotiation + +- `profileOf(axes)` → `'programmatic-cli'` \| `'declarative-cli'` \| `'ide'` \| `null`. +- `negotiateHostCapabilities(host, engine)` → `{ protocolVersion, effective, points, warnings }` — the in-process negotiation. Pure; never throws. +- `handleHandshakeRequest(request)` / `buildHandshakeRequest(descriptor)` — the **serialized** (out-of-process) form of the same negotiation, JSON-safe across a wire boundary. The two are consistent: a serialized request yields the same `effective` axes as the in-process call. +- `degradationFor(point, axes)` → `{ level, fallback, unknown? }` for one of the six interface points (`command` \| `dispatch` \| `model` \| `hooks` \| `state` \| `artifact`). +- `hookEventSurfaceFor(hookEvents)` → the host-fireable hook events for a dialect (`'claude'` \| `'gemini'` \| `'opencode-subset'`), or `null` if unknown. +- `shouldFlattenDispatch(dispatch)` → `true` when the orchestrator must run inline (fail-closed). + +## The engine adapters + +All five satisfy a common `{ kind, runtime, install, uninstall }` shape and are +constructed fail-closed (they throw if a required host primitive is absent): + +| Adapter | Factory | Selected by | +|---|---|---| +| Embedding (declarative) | `createDeclarativeAdapter({runtime})` | `embeddingMode: 'declarative'` | +| Embedding (imperative) | `createImperativeAdapter({runtime})` | `embeddingMode: 'imperative'` (also exposes the composed `registry`) | +| Model | `createModelAdapter({modelMode}, {sendRequest?})` | `modelMode` (`'active'` needs a host `sendRequest`) | +| Hook bus | `createHookBus({bus}, {hostEmit?})` | `hookBus` (`'host'` needs a host `hostEmit`) | +| State IO | `createStateIO({io}, {backend?})` | `stateIO` (`'sandboxed-storage'`/`'session-log-append'` need a host `backend`) | + +The declarative + imperative adapters delegate install/uninstall in-process to +the **same** `installRuntimeArtifacts` engine function `bin/install.js` uses, so +adapter output is byte-identical to a first-party install (gated by +`tests/golden-install-parity.test.cjs`). + +## Profiles + +`PROFILE_BASELINES` fixes the three reference profiles: + +| Profile | Baseline | +|---|---| +| `programmatic-cli` | imperative, slash-file, host bus, filesystem, mcp, node | +| `declarative-cli` | declarative, slash-file, host bus, filesystem, mcp, node | +| `ide` | imperative, palette, **active** model, **engine** bus, **sandboxed-storage**, sandboxed-web | + +## See also + +- [How-to: author a host-plugin](../how-to/author-a-host-plugin.md) +- [Tutorial: embed GSD in a new host](../tutorials/embed-gsd-in-a-new-host.md) +- [Interface versioning policy](../explanation/interface-versioning-policy.md) +- ADR-1239 (the design) · `docs/reference/host-integration-capability-matrix.md` (per-host values + citations) diff --git a/docs/tutorials/embed-gsd-in-a-new-host.md b/docs/tutorials/embed-gsd-in-a-new-host.md new file mode 100644 index 000000000..1c9f71c6a --- /dev/null +++ b/docs/tutorials/embed-gsd-in-a-new-host.md @@ -0,0 +1,77 @@ +# Tutorial: embed GSD in a new host + +This tutorial walks through embedding GSD's orchestration loop into a brand-new +host end-to-end — from classifying the host, to composing the engine adapters, +to running a GSD command through the embedded engine. It is the learning path +that pairs with the [how-to](../how-to/author-a-host-plugin.md) (steps) and the +[reference](../reference/host-integration-interface.md) (spec). + +You will build a minimal **programmatic-cli** host-plugin that invokes a GSD +command via the embedded engine. No gsd-core source changes. + +--- + +## Prerequisites + +- The GSD Host-Integration SDK entry importable in your host's runtime. +- Your host's authoritative docs open (you'll declare axes from them, never infer). + +## Step 1 — Classify the host + +Your host is a programmatic CLI with an event bus and a passive model (it can +only receive prompts, not call a model for you). That is the `programmatic-cli` +profile. + +```js +const SDK = require('@opengsd/gsd-core/sdk'); +// Confirm the profile from your declared axes: +const profile = SDK.profileOf({ embeddingMode: 'imperative', runtime: 'node' }); +// → 'programmatic-cli' +``` + +## Step 2 — Compose the engine adapters + +```js +const hookBus = SDK.createHookBus({ bus: 'host' }); // your host fires events +const model = SDK.createModelAdapter({ modelMode: 'passive' }); +const stateIO = SDK.createStateIO({ io: 'filesystem' }); +const adapter = SDK.createImperativeAdapter({ runtime: 'my-cli' }); +``` + +## Step 3 — Negotiate capabilities + +```js +const req = SDK.buildHandshakeRequest({ + embeddingMode: 'imperative', commandSurface: 'slash-file', modelMode: 'passive', + hookBus: 'host', stateIO: 'filesystem', transport: 'mcp', runtime: 'node', +}); +const { effective, points, warnings } = SDK.handleHandshakeRequest(req); +// `effective` is the negotiated capability set; `points` is per-interface-point +// degradation; `warnings` flags any axis the host omitted. +``` + +## Step 4 — Run a GSD command through the embedded engine + +The imperative adapter exposes the engine surface; your host binds its command +surface (slash commands, palette, chat) to it. A user invoking `/gsd:phase` in +your host dispatches through the embedded engine exactly as it would in a +first-party host — that is the parity the interface guarantees. + +## Step 5 — Verify parity + +Assert that your host-plugin, built from the SDK entry alone, produces the same +negotiated result as the in-process path. The SDK smoke test +(`tests/sdk-smoke.test.cjs`) is the template — copy its structure for your +host's own parity test. + +## Recap + +You classified a host, declared its axes from authoritative docs, composed the +adapters, negotiated capabilities over the (serialized) handshake, and ran a GSD +command through the embedded engine — all without touching gsd-core source. A +new host is now a plugin you wrote. + +## Next + +- [How-to: author a host-plugin](../how-to/author-a-host-plugin.md) — the step-by-step reference. +- [Interface versioning policy](../explanation/interface-versioning-policy.md) — how the surface evolves.