From da368311ea0847d2777e3630ba791807ca88a05e Mon Sep 17 00:00:00 2001 From: Tom Boucher Date: Sun, 28 Jun 2026 11:10:59 -0400 Subject: [PATCH] =?UTF-8?q?feat(#1680):=20ADR-1239=20Phase=20C-1=20?= =?UTF-8?q?=E2=80=94=20imperative=20embedding=20adapter=20(composes=20load?= =?UTF-8?q?Registry)=20[AC2]=20(#1803)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat(#1680): ADR-1239 Phase C-1 — imperative embedding adapter (composes loadRegistry) [AC2] Phase 3 slice 2 (AC2). The engine-as-library path: createImperativeAdapter composes loadRegistry({includeInstalled:true}) — first-party-wins + consent + fail-closed gates, identical trust semantics to the CLI — and binds the engine surface behind the SAME HostIntegrationInterface the declarative adapter (AC1) satisfies, plus a registry accessor for the composed capability set. - src/adapter-imperative.cts: createImperativeAdapter({runtime}, {loadOptions}) → ImperativeAdapter (kind:'imperative' + .registry + install/uninstall delegating to install-engine). Thin: delegates the loop, does not reimplement. - tests/adapter-imperative.test.cjs: kind (16 runtimes), registry composition (loadRegistry called with includeInstalled:true), loadOptions pass-through, install/uninstall delegation, fail-closed construction. - eslint.config.mjs + docs/INVENTORY-MANIFEST.json: ADR-457 ignores entry + cli_modules entry for the new emitted .cjs (the two drift gates that bit AC1, applied proactively here). Concrete host binding (OpenCode/VS Code/pi) deferred to Phase 5 (#1682). * test: remove dead readStateMd helper from bug-1760 test readStateMd was defined but never called (writeStateMd is the only state-md helper this test uses). Clears the lone no-unused-vars warning so the repo lints fully clean (0 problems). No behavior change — test still passes 2/2. * chore(changeset): add Changed fragment for imperative embedding adapter (#1680) * fix(adapter-imperative): reword comment to avoid injection-scan substring match The prompt-injection scan regex 'act\s+as\s+(?:a|an|the)' was matching the 'act as the' substring inside 'contract as the declarative adapter' (contrACT AS THE). Reword 'contract as' -> 'shape as' — no 'act' substring, identical meaning. Clears the 'lib source files are clean' + 'codebase prompt injection scan' security-gate failures. --- .changeset/happy-jays-travel.md | 7 ++ docs/INVENTORY-MANIFEST.json | 1 + eslint.config.mjs | 1 + src/adapter-imperative.cts | 77 +++++++++++++++ tests/adapter-imperative.test.cjs | 98 +++++++++++++++++++ ...0-state-prune-noop-template-field.test.cjs | 3 - 6 files changed, 184 insertions(+), 3 deletions(-) create mode 100644 .changeset/happy-jays-travel.md create mode 100644 src/adapter-imperative.cts create mode 100644 tests/adapter-imperative.test.cjs diff --git a/.changeset/happy-jays-travel.md b/.changeset/happy-jays-travel.md new file mode 100644 index 000000000..c580ce023 --- /dev/null +++ b/.changeset/happy-jays-travel.md @@ -0,0 +1,7 @@ +--- +type: Changed +pr: 1803 +--- +**Internal: the imperative embedding adapter now composes the capability registry behind the same `HostIntegrationInterface`** — `createImperativeAdapter({runtime})` (new `src/adapter-imperative.cts`) calls `loadRegistry({includeInstalled:true})` (first-party-wins + consent + fail-closed — identical trust semantics to the CLI) and binds the engine surface behind the same contract the declarative adapter (AC1) satisfies, plus a `registry` accessor for an in-process host to bind its primitives to (ADR-1239 Phase C-1 / #1680 AC2). Concrete host binding is deferred to Phase 5. No user-facing change — the adapter is not yet wired to any runtime path. + + diff --git a/docs/INVENTORY-MANIFEST.json b/docs/INVENTORY-MANIFEST.json index bc5a8e5e3..9cc2031ef 100644 --- a/docs/INVENTORY-MANIFEST.json +++ b/docs/INVENTORY-MANIFEST.json @@ -280,6 +280,7 @@ "cli_modules": [ "active-workstream-store.cjs", "adapter-declarative.cjs", + "adapter-imperative.cjs", "adr-parser.cjs", "agent-command-router.cjs", "agent-install-check.cjs", diff --git a/eslint.config.mjs b/eslint.config.mjs index e4ba00cc2..bebd85ff3 100644 --- a/eslint.config.mjs +++ b/eslint.config.mjs @@ -203,6 +203,7 @@ export default tseslint.config( // ADR-1239 Phase C-1 (#1680): tsc-generated — lint src/embedding-adapter.cts + src/adapter-declarative.cts. 'gsd-core/bin/lib/embedding-adapter.cjs', 'gsd-core/bin/lib/adapter-declarative.cjs', + 'gsd-core/bin/lib/adapter-imperative.cjs', ], }, diff --git a/src/adapter-imperative.cts b/src/adapter-imperative.cts new file mode 100644 index 000000000..9cfd72947 --- /dev/null +++ b/src/adapter-imperative.cts @@ -0,0 +1,77 @@ +/** + * Imperative embedding adapter (ADR-1239 Phase C-1, AC2 / #1680). + * + * The engine-as-library path: an in-process host plugin calls + * `createImperativeAdapter({runtime})`, which composes the capability registry + * via `loadRegistry({includeInstalled:true})` (first-party-wins + consent + + * fail-closed gates) and binds the engine surface behind the SAME + * `HostIntegrationInterface` the declarative adapter satisfies. The adapter + * stays thin — it does NOT reimplement the loop resolver; it delegates to the + * engine + exposes the composed registry so a host (Phase 5) can bind its + * primitives (command/dispatch/model/hooks/state/artifact) to the registry's + * declared capability set. + * + * Concrete host binding (OpenCode/VS Code/pi) is deferred to Phase 5 (#1682, + * D15/D18). This slice ships the adapter + the composed-registry seam. + * + * Minimal interface (per ADR-1239 open wire-shape question): satisfies the + * same `{kind, runtime, install, uninstall}` shape as the declarative adapter, + * plus an imperative-specific `registry` accessor (the composed loadRegistry + * result). The full 6-point binding surface grows when a real host consumer + * fixes the shape. + */ +'use strict'; + +// eslint-disable-next-line @typescript-eslint/no-require-imports +import installEngine = require('./install-engine.cjs'); +// eslint-disable-next-line @typescript-eslint/no-require-imports +import capabilityLoader = require('./capability-loader.cjs'); +import type { HostIntegrationInterface, AdapterInstallIntent, AdapterUninstallIntent } from './embedding-adapter.cjs'; + +/** + * The imperative adapter: the shared contract PLUS the composed capability + * registry an in-process host binds its primitives to. + */ +export interface ImperativeAdapter extends HostIntegrationInterface { + readonly kind: 'imperative'; + /** The composed capability registry (loadRegistry({includeInstalled:true})). */ + readonly registry: ReturnType; +} + +export interface CreateImperativeAdapterOptions { + /** Optional overrides forwarded to loadRegistry (cwd, gsdHome, hostVersion). */ + loadOptions?: Record; +} + +export function createImperativeAdapter( + { runtime }: { runtime: string }, + options: CreateImperativeAdapterOptions = {}, +): ImperativeAdapter { + if (!runtime || typeof runtime !== 'string') { + throw new TypeError('createImperativeAdapter: runtime is required (non-empty string)'); + } + // Compose first-party ∪ installed capability overlays with the SAME + // precedence, consent, and fail-closed-gate guarantees the CLI enforces — + // an in-process host gets identical trust semantics, not a parallel path. + const registry = capabilityLoader.loadRegistry({ + includeInstalled: true, + ...(options.loadOptions ?? {}), + }); + return Object.freeze({ + kind: 'imperative' as const, + runtime, + registry, + install(intent: AdapterInstallIntent): void { + installEngine.installRuntimeArtifacts( + runtime, + intent.configDir, + intent.scope, + intent.resolvedProfile, + intent.resolveAttribution, + ); + }, + uninstall(intent: AdapterUninstallIntent): void { + installEngine.uninstallRuntimeArtifacts(runtime, intent.configDir, intent.scope); + }, + }); +} diff --git a/tests/adapter-imperative.test.cjs b/tests/adapter-imperative.test.cjs new file mode 100644 index 000000000..48cc450b3 --- /dev/null +++ b/tests/adapter-imperative.test.cjs @@ -0,0 +1,98 @@ +'use strict'; +/** + * Tests for the imperative embedding adapter (ADR-1239 Phase C-1, AC2 / #1680). + * + * Pins: + * 1. KIND — `kind: 'imperative'`, satisfies the same HostIntegrationInterface + * as the declarative adapter (both bind one engine). + * 2. REGISTRY COMPOSITION — the adapter composes loadRegistry({includeInstalled: + * true}) and exposes the result as `.registry` (first-party ∪ installed, so + * an in-process host gets identical trust semantics to the CLI). + * 3. DELEGATION — install/uninstall delegate in-process to install-engine + * (byte-identity link, same as the declarative adapter). + * 4. FAIL-CLOSED CONSTRUCTION — missing/invalid runtime throws. + * + * Behavioral tests only; delegation + loadRegistry verified via module-ref + * monkeypatch (Node module cache shares the one module object). + */ + +const { test } = require('node:test'); +const assert = require('node:assert/strict'); +const { createImperativeAdapter } = require('../gsd-core/bin/lib/adapter-imperative.cjs'); +const installEngine = require('../gsd-core/bin/lib/install-engine.cjs'); +const capabilityLoader = require('../gsd-core/bin/lib/capability-loader.cjs'); +const registry = require('../gsd-core/bin/lib/capability-registry.cjs'); + +const RUNTIMES = Object.keys(registry.runtimes); + +test('imperative adapter: kind === "imperative" + runtime echoed + registry present, for all 16 runtimes', () => { + for (const r of RUNTIMES) { + const adapter = createImperativeAdapter({ runtime: r }); + assert.strictEqual(adapter.kind, 'imperative', `${r}: kind must be 'imperative'`); + assert.strictEqual(adapter.runtime, r, `${r}: adapter must echo the runtime id`); + assert.ok(adapter.registry && typeof adapter.registry === 'object', `${r}: registry must be present (composed loadRegistry result)`); + assert.strictEqual(typeof adapter.install, 'function', `${r}: install must be a function`); + assert.strictEqual(typeof adapter.uninstall, 'function', `${r}: uninstall must be a function`); + } +}); + +test('imperative adapter: loadRegistry composed with includeInstalled:true (host gets CLI-equivalent trust semantics)', () => { + // Restore-able spy on capabilityLoader.loadRegistry (module-ref, shared via Node cache). + const original = capabilityLoader.loadRegistry; + const calls = []; + capabilityLoader.loadRegistry = function (opts) { + calls.push(opts); + // Return a minimal stand-in registry so the factory does not crash. + return { _spy: true, runtimes: registry.runtimes }; + }; + try { + const adapter = createImperativeAdapter({ runtime: 'opencode' }); + assert.ok(calls.length >= 1, 'loadRegistry must be invoked once during construction'); + assert.strictEqual(calls[0].includeInstalled, true, 'loadRegistry must be called with includeInstalled:true (compose first-party ∪ installed)'); + assert.strictEqual(adapter.registry._spy, true, 'adapter.registry must expose the composed loadRegistry result'); + } finally { + capabilityLoader.loadRegistry = original; + } +}); + +test('imperative adapter: loadOptions forwarded to loadRegistry (cwd/gsdHome/hostVersion pass-through)', () => { + const original = capabilityLoader.loadRegistry; + let captured = null; + capabilityLoader.loadRegistry = function (opts) { captured = opts; return { runtimes: registry.runtimes }; }; + try { + createImperativeAdapter({ runtime: 'codex' }, { loadOptions: { cwd: '/tmp/proj', hostVersion: '1.7.0' } }); + assert.strictEqual(captured.includeInstalled, true, 'includeInstalled default preserved'); + assert.strictEqual(captured.cwd, '/tmp/proj', 'loadOptions.cwd forwarded'); + assert.strictEqual(captured.hostVersion, '1.7.0', 'loadOptions.hostVersion forwarded'); + } finally { + capabilityLoader.loadRegistry = original; + } +}); + +test('imperative adapter.install/uninstall delegate in-process to install-engine with exact args', () => { + const origInstall = installEngine.installRuntimeArtifacts; + const origUninstall = installEngine.uninstallRuntimeArtifacts; + try { + for (const r of RUNTIMES) { + const adapter = createImperativeAdapter({ runtime: r }); + let installArgs = null; + let uninstallArgs = null; + installEngine.installRuntimeArtifacts = function (...a) { installArgs = a; return undefined; }; + installEngine.uninstallRuntimeArtifacts = function (...a) { uninstallArgs = a; return undefined; }; + adapter.install({ configDir: '/tmp/imp/' + r, scope: 'global', resolvedProfile: { p: 1 } }); + adapter.uninstall({ configDir: '/tmp/imp/' + r, scope: 'local' }); + assert.deepStrictEqual(installArgs, [r, '/tmp/imp/' + r, 'global', { p: 1 }, undefined], `${r}: install delegation args`); + assert.deepStrictEqual(uninstallArgs, [r, '/tmp/imp/' + r, 'local'], `${r}: uninstall delegation args`); + } + } finally { + installEngine.installRuntimeArtifacts = origInstall; + installEngine.uninstallRuntimeArtifacts = origUninstall; + } +}); + +test('createImperativeAdapter: throws on missing/invalid runtime (fail-closed construction)', () => { + for (const bad of ['', undefined, null]) { + assert.throws(() => createImperativeAdapter({ runtime: bad }), TypeError, `runtime=${JSON.stringify(bad)} must throw`); + } + assert.throws(() => createImperativeAdapter({}), TypeError, 'missing runtime must throw'); +}); diff --git a/tests/bug-1760-state-prune-noop-template-field.test.cjs b/tests/bug-1760-state-prune-noop-template-field.test.cjs index 9b4402b2b..7809a33be 100644 --- a/tests/bug-1760-state-prune-noop-template-field.test.cjs +++ b/tests/bug-1760-state-prune-noop-template-field.test.cjs @@ -16,9 +16,6 @@ const { runGsdTools, createTempProject, cleanup } = require('./helpers.cjs'); function writeStateMd(tmpDir, content) { fs.writeFileSync(path.join(tmpDir, '.planning', 'STATE.md'), content); } -function readStateMd(tmpDir) { - return fs.readFileSync(path.join(tmpDir, '.planning', 'STATE.md'), 'utf-8'); -} describe('#1760: state prune engages on template-conformant STATE.md (Phase: X of Y)', () => { let tmpDir;