feat(#1680): ADR-1239 Phase C-1 — imperative embedding adapter (composes loadRegistry) [AC2] (#1803)
* 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.
This commit is contained in:
7
.changeset/happy-jays-travel.md
Normal file
7
.changeset/happy-jays-travel.md
Normal file
@@ -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.
|
||||
|
||||
<!-- docs-exempt: internal adapter infrastructure; no user-facing command/flag/config/schema/doc surface -->
|
||||
@@ -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",
|
||||
|
||||
@@ -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',
|
||||
],
|
||||
},
|
||||
|
||||
|
||||
77
src/adapter-imperative.cts
Normal file
77
src/adapter-imperative.cts
Normal file
@@ -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<typeof capabilityLoader.loadRegistry>;
|
||||
}
|
||||
|
||||
export interface CreateImperativeAdapterOptions {
|
||||
/** Optional overrides forwarded to loadRegistry (cwd, gsdHome, hostVersion). */
|
||||
loadOptions?: Record<string, unknown>;
|
||||
}
|
||||
|
||||
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);
|
||||
},
|
||||
});
|
||||
}
|
||||
98
tests/adapter-imperative.test.cjs
Normal file
98
tests/adapter-imperative.test.cjs
Normal file
@@ -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');
|
||||
});
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user