Merge pull request #2132 from open-gsd/feat/2090-cline-eos-migration
feat(#2090): migrate Cline onto EoS imperative adapter + beforeTool/createAgentModel upgrades (ADR-1239)
This commit is contained in:
5
.changeset/2090-eos-cline-imperative-adapter.md
Normal file
5
.changeset/2090-eos-cline-imperative-adapter.md
Normal file
@@ -0,0 +1,5 @@
|
||||
---
|
||||
type: Changed
|
||||
pr: 2132
|
||||
---
|
||||
**Cline is now driven through the public Host-Integration Interface, with two capability upgrades (ADR-1239 / EoS).** Cline previously installed via hardcoded `runtime === 'cline'`/`isCline` branches in `bin/install.js`; its install/uninstall now runs through the imperative adapter, and every hardcoded cline branch is folded into descriptor-driven `runtime.hostBehaviors` (reapplyCommand, frontmatterDialect, skipSharedHooksInstall, localTargetIsProjectRoot, clineRulesSurface, localCommandsViaRules). Install/uninstall output is **byte-identical** (golden parity asserted for cline + claude/cursor/codex/opencode). Two Context7-verified upgrades land: (1) **`AgentPlugin.hooks.beforeTool` planning guard** — the `.clinerules/hooks/PreToolUse` file-convention hook (#787) is re-implemented as a real Cline SDK `AgentPlugin` that cancels write-class calls targeting `.planning/` (same fail-open semantics), driven by a new descriptor-driven adapter module (`src/host-integration-adapters/cline-sdk-binding.cts`); cite https://github.com/cline/cline/blob/main/docs/sdk/plugins.mdx. (2) **`createAgentModel` model overrides** — `DefaultGateway.createAgentModel({providerId, modelId})` is wired so GSD's per-subagent `model_overrides`/`model_profile_overrides` resolution applies to Cline subagents (`modelMode: active`); cite https://github.com/cline/cline/blob/main/docs/sdk/reference/gateway.mdx. Cline's dispatch deliberately stays **degraded/flat** (`maxDepth: 1`, read-only, no nested spawning) per the documented host restriction — never silently upgraded to full nested/background. (#2090)
|
||||
1
.gitignore
vendored
1
.gitignore
vendored
@@ -70,6 +70,7 @@ build/
|
||||
/gsd-core/bin/lib/host-integration.cjs
|
||||
/gsd-core/bin/lib/host-integration-sdk.cjs
|
||||
/gsd-core/bin/lib/host-integration-adapters/imperative-hook-bus.cjs
|
||||
/gsd-core/bin/lib/host-integration-adapters/cline-sdk-binding.cjs
|
||||
/gsd-core/bin/lib/handshake-serialized.cjs
|
||||
/gsd-core/bin/lib/install-effort-resolver.cjs
|
||||
/gsd-core/bin/lib/install-engine.cjs
|
||||
|
||||
@@ -6774,9 +6774,13 @@ function uninstall(isGlobal, runtime = DEFAULT_RUNTIME) {
|
||||
// Get the target directory based on runtime and install type. Cline local
|
||||
// installs write to the project root (.clinerules/ lives at the root, not in
|
||||
// a .cline/ subdir), mirroring the install() path resolution (#787).
|
||||
// Descriptor-driven (ADR-1239 / #2090): cline local installs write to the
|
||||
// project root (.clinerules/ lives at the root, not in a .cline/ subdir),
|
||||
// mirroring the install() path resolution (#787). Folded from a hardcoded
|
||||
// `runtime === 'cline'` branch into hostBehaviors.localTargetIsProjectRoot.
|
||||
const targetDir = isGlobal
|
||||
? getGlobalConfigDir(runtime, explicitConfigDir)
|
||||
: runtime === 'cline'
|
||||
: _hostBehaviors(runtime).localTargetIsProjectRoot
|
||||
? process.cwd()
|
||||
: path.join(process.cwd(), dirName);
|
||||
|
||||
@@ -6941,7 +6945,9 @@ function uninstall(isGlobal, runtime = DEFAULT_RUNTIME) {
|
||||
// 1b-cline. Non-layout Cline side-effects (issue #787): remove the
|
||||
// directory-form rules + PreToolUse hook, and strip the GSD block from the
|
||||
// global cross-tool ~/.agents/AGENTS.md target.
|
||||
if (runtime === 'cline') {
|
||||
// Descriptor-driven (ADR-1239 / #2090): folded from `runtime === 'cline'`
|
||||
// into hostBehaviors.clineRulesSurface.
|
||||
if (_hostBehaviors(runtime).clineRulesSurface) {
|
||||
const clinerulesDir = path.join(targetDir, '.clinerules');
|
||||
for (const rel of ['gsd.md', path.join('hooks', 'PreToolUse')]) {
|
||||
const p = path.join(clinerulesDir, rel);
|
||||
@@ -7892,7 +7898,9 @@ function writeManifest(configDir, runtime = DEFAULT_RUNTIME, options = {}) {
|
||||
// Track Cline directory-form artifacts in the manifest (issue #787): the
|
||||
// rules file and the PreToolUse hook. (~/.agents/AGENTS.md is tracked via its
|
||||
// marker block, not the per-configDir manifest, since it lives outside it.)
|
||||
if (isCline) {
|
||||
// Descriptor-driven (ADR-1239 / #2090): folded from `isCline` into
|
||||
// hostBehaviors.clineRulesSurface.
|
||||
if (_hostBehaviors(runtime).clineRulesSurface) {
|
||||
for (const rel of ['.clinerules/gsd.md', '.clinerules/hooks/PreToolUse']) {
|
||||
const dest = path.join(configDir, rel);
|
||||
if (fs.existsSync(dest)) {
|
||||
@@ -7903,7 +7911,9 @@ function writeManifest(configDir, runtime = DEFAULT_RUNTIME, options = {}) {
|
||||
|
||||
// Track hook files so saveLocalPatches() can detect user modifications
|
||||
// Hooks are only installed for runtimes that use settings.json (not Codex/Copilot/Cline)
|
||||
if (!isCodex && !isCopilot && !isCline && !isKimi) {
|
||||
// Descriptor-driven (ADR-1239 / #2089+#2090): cline's exclusion is via
|
||||
// hostBehaviors.skipSharedHooksInstall (was hardcoded !isCline).
|
||||
if (!isCodex && !isCopilot && _hostBehaviors(runtime).skipSharedHooksInstall !== true && !isWindsurf && !isTrae && !isKimi) {
|
||||
const hooksDir = path.join(configDir, 'hooks');
|
||||
if (fs.existsSync(hooksDir)) {
|
||||
// Drive from INSTALLED_HOOK_FILES (the canonical HOOKS_TO_COPY set from
|
||||
@@ -8348,15 +8358,17 @@ function install(isGlobal, runtime = DEFAULT_RUNTIME, options = {}) {
|
||||
};
|
||||
|
||||
// Get the target directory based on runtime and install type.
|
||||
// Cline local installs write to the project root (like Claude Code) — .clinerules
|
||||
// lives at the root, not inside a .cline/ subdirectory.
|
||||
// Descriptor-driven (ADR-1239 / #2090): cline local installs write to the
|
||||
// project root (like Claude Code) — .clinerules lives at the root, not inside
|
||||
// a .cline/ subdirectory. Folded from `isCline` into
|
||||
// hostBehaviors.localTargetIsProjectRoot.
|
||||
// #791: antigravity local installs write to .agents/ (canonical). The legacy .agent/
|
||||
// directory is recognized by RUNTIME_DIRS (update-context) and _LEGACY_SCAN_SUBDIR_NAMES
|
||||
// but NOT auto-removed here; legacy .agent/ gsd artifacts are recognized but not
|
||||
// auto-removed on reinstall (dual-read fallback per issue #791 spec).
|
||||
const targetDir = isGlobal
|
||||
? getGlobalConfigDir(runtime, explicitConfigDir)
|
||||
: isCline
|
||||
: _hostBehaviors(runtime).localTargetIsProjectRoot
|
||||
? process.cwd()
|
||||
: path.join(process.cwd(), dirName);
|
||||
|
||||
@@ -8929,10 +8941,12 @@ function install(isGlobal, runtime = DEFAULT_RUNTIME, options = {}) {
|
||||
}
|
||||
}
|
||||
}
|
||||
} else if (isCline) {
|
||||
} else if (_hostBehaviors(runtime).localCommandsViaRules) {
|
||||
// Cline local install: rules-based only — commands are embedded in .clinerules (generated below).
|
||||
// No skills/commands directory needed for local installs.
|
||||
// Global installs are handled above by _isSkillsRuntime (#782).
|
||||
// Descriptor-driven (ADR-1239 / #2090): folded from `isCline` into
|
||||
// hostBehaviors.localCommandsViaRules.
|
||||
console.log(` ${green}✓${reset} Cline: commands will be available via .clinerules`);
|
||||
} else {
|
||||
// Claude Code local: flat gsd-<cmd>.md layout — Claude Code registers
|
||||
@@ -9212,7 +9226,9 @@ function install(isGlobal, runtime = DEFAULT_RUNTIME, options = {}) {
|
||||
content = convertClaudeAgentToTraeAgent(content);
|
||||
} else if (isCodebuddy) {
|
||||
content = convertClaudeAgentToCodebuddyAgent(content);
|
||||
} else if (isCline) {
|
||||
} else if (_hostBehaviors(runtime).frontmatterDialect === 'cline') {
|
||||
// Descriptor-driven (ADR-1239 / #2090): folded from `isCline` into
|
||||
// hostBehaviors.frontmatterDialect === 'cline'.
|
||||
content = convertClaudeAgentToClineAgent(content);
|
||||
} else if (isQwen) {
|
||||
content = content.replace(/CLAUDE\.md/g, 'QWEN.md');
|
||||
@@ -9286,7 +9302,9 @@ function install(isGlobal, runtime = DEFAULT_RUNTIME, options = {}) {
|
||||
// them and the CommonJS package.json marker written below.
|
||||
// #2089: Cursor's exclusion is now descriptor-driven via
|
||||
// hostBehaviors.skipSharedHooksInstall (was hardcoded !isCursor).
|
||||
if (!isCodex && !isCopilot && _hostBehaviors(runtime).skipSharedHooksInstall !== true && !isWindsurf && !isTrae && !isCline && !isKimi && !isKilo && !isZcode) {
|
||||
// #2090: Cline's exclusion is likewise descriptor-driven (cline declares
|
||||
// skipSharedHooksInstall:true) — the redundant `&& !isCline` was removed.
|
||||
if (!isCodex && !isCopilot && _hostBehaviors(runtime).skipSharedHooksInstall !== true && !isWindsurf && !isTrae && !isKimi && !isKilo && !isZcode) {
|
||||
// Write package.json to force CommonJS mode for GSD scripts
|
||||
// Prevents "require is not defined" errors when project has "type": "module"
|
||||
// Node.js walks up looking for package.json - this stops inheritance from project
|
||||
@@ -9378,15 +9396,15 @@ function install(isGlobal, runtime = DEFAULT_RUNTIME, options = {}) {
|
||||
// Gate hooks/lib/ install on the same runtimes that receive hooks (see line ~8702).
|
||||
// Codex/Copilot/Cursor/Windsurf/Trae/Cline do not use the shared hooks/lib/ helpers
|
||||
// (Cursor uses standalone .js hook scripts registered via hooks.json — gated
|
||||
// descriptor-driven via hostBehaviors.skipSharedHooksInstall, #2089; Codex uses
|
||||
// hooks.json directly; the others skip hooks entirely); Kilo and ZCode also skip
|
||||
// hooks entirely (hooksSurface:'none' with no plugin surface — #1821). OpenCode
|
||||
// is NOT excluded: its #1914 plugin adapter spawns the staged hooks and requires
|
||||
// hooks/lib/ helpers. None of the excluded runtimes must receive the hooks/lib/
|
||||
// helpers — otherwise the Codex comment downstream ("we deliberately do *not*
|
||||
// copy hooks/lib/ for Codex") is contradicted in practice.
|
||||
// descriptor-driven via hostBehaviors.skipSharedHooksInstall, #2089; Cline likewise
|
||||
// #2090; Codex uses hooks.json directly; the others skip hooks entirely); Kilo and
|
||||
// ZCode also skip hooks entirely (hooksSurface:'none' with no plugin surface — #1821).
|
||||
// OpenCode is NOT excluded: its #1914 plugin adapter spawns the staged hooks and
|
||||
// requires hooks/lib/ helpers. None of the excluded runtimes must receive the
|
||||
// hooks/lib/ helpers — otherwise the Codex comment downstream ("we deliberately do
|
||||
// *not* copy hooks/lib/ for Codex") is contradicted in practice.
|
||||
const hooksLibSrc = path.join(src, 'hooks', 'lib');
|
||||
if (!isCodex && !isCopilot && _hostBehaviors(runtime).skipSharedHooksInstall !== true && !isWindsurf && !isTrae && !isCline && !isKimi && !isKilo && !isZcode && fs.existsSync(hooksLibSrc)) {
|
||||
if (!isCodex && !isCopilot && _hostBehaviors(runtime).skipSharedHooksInstall !== true && !isWindsurf && !isTrae && !isKimi && !isKilo && !isZcode && fs.existsSync(hooksLibSrc)) {
|
||||
const hooksLibDest = path.join(targetDir, 'hooks', 'lib');
|
||||
fs.mkdirSync(hooksLibDest, { recursive: true });
|
||||
copyLibDir(hooksLibSrc, hooksLibDest, GSD_HOOK_LIB_FILES);
|
||||
|
||||
@@ -56,6 +56,14 @@
|
||||
"stateIO": "filesystem",
|
||||
"transport": "mcp",
|
||||
"runtime": "node"
|
||||
},
|
||||
"hostBehaviors": {
|
||||
"reapplyCommand": "/gsd-update --reapply",
|
||||
"frontmatterDialect": "cline",
|
||||
"skipSharedHooksInstall": true,
|
||||
"localTargetIsProjectRoot": true,
|
||||
"clineRulesSurface": true,
|
||||
"localCommandsViaRules": true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -222,6 +222,12 @@ Sources consulted:
|
||||
- https://github.com/cline/cline/blob/main/sdk/packages/llms/README.md
|
||||
- /cline/cline (Context7)
|
||||
|
||||
**GSD integration status — Phase D dogfood complete (#2090, ADR-1239).** Cline installs through the `imperative` embedding adapter (`createImperativeAdapter` → `installRuntimeArtifacts`); the hardcoded `runtime === 'cline'` / `isCline` projection is folded into descriptor-driven `runtime.hostBehaviors`, and install/uninstall output is byte-parity-gated (`tests/fixtures/golden-install-parity/cline.json`). Two capability upgrades land, each with a test driving the user-reachable surface:
|
||||
|
||||
- **`AgentPlugin.hooks.beforeTool` planning guard** — the `.clinerules/hooks/PreToolUse` file-convention hook (#787) is re-implemented as a real Cline SDK `AgentPlugin` registered through the negotiated `hookBus: host` interface point. Guard semantics are preserved exactly (fail-open, cancels write-class calls targeting `.planning/`); the SDK maps the file hook's `{cancel, errorMessage}` to `{skip, reason}`. The binding lives in `src/host-integration-adapters/cline-sdk-binding.cts` (cite https://github.com/cline/cline/blob/main/docs/sdk/plugins.mdx).
|
||||
- **`createAgentModel` per-subagent model overrides** — `DefaultGateway.createAgentModel({providerId, modelId})` is wired so GSD's `model_overrides` / `model_profile_overrides` resolution (already used for OpenCode/Codex passive hosts) applies to cline subagents (`modelMode: active`), instead of leaving model selection untouched (cite https://github.com/cline/cline/blob/main/docs/sdk/reference/gateway.mdx).
|
||||
- **Dispatch stays degraded/flat (deliberate)** — unlike cursor's dispatch upgrade, cline's `dispatch` is `maxDepth: 1`, `nested: false`, `subagentToolkit: 'read-only'`, `backgroundDispatch: false`. `shouldFlattenDispatch(cline)` returns `true` and `degradationFor('dispatch', cline)` returns `{level:'degraded', fallback:'flat dispatch — waves run inline'}`. This is NOT upgraded: cline's own docs restrict subagents to a single level with a read-only toolkit and no nested spawning, so claiming full dispatch would misrepresent the host and violate the fail-closed negotiation contract (cite https://github.com/cline/cline/blob/main/docs/features/subagents.mdx).
|
||||
|
||||
---
|
||||
|
||||
## hermes
|
||||
|
||||
@@ -618,6 +618,14 @@ const capabilities = {
|
||||
"stateIO": "filesystem",
|
||||
"transport": "mcp",
|
||||
"runtime": "node"
|
||||
},
|
||||
"hostBehaviors": {
|
||||
"reapplyCommand": "/gsd-update --reapply",
|
||||
"frontmatterDialect": "cline",
|
||||
"skipSharedHooksInstall": true,
|
||||
"localTargetIsProjectRoot": true,
|
||||
"clineRulesSurface": true,
|
||||
"localCommandsViaRules": true
|
||||
}
|
||||
}
|
||||
},
|
||||
@@ -4029,6 +4037,14 @@ const runtimes = {
|
||||
"stateIO": "filesystem",
|
||||
"transport": "mcp",
|
||||
"runtime": "node"
|
||||
},
|
||||
"hostBehaviors": {
|
||||
"reapplyCommand": "/gsd-update --reapply",
|
||||
"frontmatterDialect": "cline",
|
||||
"skipSharedHooksInstall": true,
|
||||
"localTargetIsProjectRoot": true,
|
||||
"clineRulesSurface": true,
|
||||
"localCommandsViaRules": true
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
256
src/host-integration-adapters/cline-sdk-binding.cts
Normal file
256
src/host-integration-adapters/cline-sdk-binding.cts
Normal file
@@ -0,0 +1,256 @@
|
||||
/**
|
||||
* Cline SDK binding — AgentPlugin + createAgentModel adapters
|
||||
* (ADR-1239 Phase D / #2090).
|
||||
*
|
||||
* Two Context7-verified UPGRADES the file-convention projection ignored, now
|
||||
* delivered through the negotiated `hookBus: host` + `modelMode: active`
|
||||
* interface points:
|
||||
*
|
||||
* UPGRADE 1 — `AgentPlugin.hooks.beforeTool` planning-artifact guard.
|
||||
* Re-implements the `.clinerules/hooks/PreToolUse` file-convention hook
|
||||
* (issue #787) as a real Cline SDK AgentPlugin. Guard semantics are
|
||||
* preserved EXACTLY: fail-open, cancel (skip) write-class calls targeting
|
||||
* `.planning/`, pass through everything else. The SDK maps the file hook's
|
||||
* `{cancel:true, errorMessage}` to `{skip:true, reason}` (beforeTool
|
||||
* contract).
|
||||
* Cite: https://github.com/cline/cline/blob/main/docs/sdk/plugins.mdx
|
||||
* https://github.com/cline/cline/blob/main/sdk/packages/agents/README.md
|
||||
*
|
||||
* UPGRADE 2 — `DefaultGateway.createAgentModel({providerId, modelId})`.
|
||||
* Resolves GSD's per-subagent `model_overrides` / `model_profile_overrides`
|
||||
* (already used for OpenCode/Codex passive hosts) into the createAgentModel
|
||||
* call params for cline's active model mode. The host gateway owns the
|
||||
* actual model instantiation; this binding resolves WHICH model an
|
||||
* overridden subagent should use.
|
||||
* Cite: https://github.com/cline/cline/blob/main/docs/sdk/reference/gateway.mdx
|
||||
* https://github.com/cline/cline/blob/main/sdk/packages/llms/README.md
|
||||
*
|
||||
* This module is PURE (no I/O, no SDK import): the real `@cline/sdk` is a
|
||||
* fast-moving package set not linked at build/test time, so the binding exposes
|
||||
* the decision functions a host plugin/gateway would call. Tests drive payloads
|
||||
* through them directly (same mock-the-SDK pattern as the VS Code reference
|
||||
* binding, tests/fixtures/vscode-host-binding.cjs).
|
||||
*/
|
||||
'use strict';
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// UPGRADE 1 — beforeTool planning-artifact guard
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Write-class tool-verb detector. Matches the SAME regex as the #787
|
||||
* PreToolUse file-convention hook so the guard behaves identically.
|
||||
* Case-insensitive (the SDK delivers tool names in varying case).
|
||||
*/
|
||||
export const WRITE_TOOL_PATTERN = /write|edit|replace|create|delete|remove|append|apply|patch|insert|mkdir/i;
|
||||
|
||||
/**
|
||||
* `.planning/` path detector. Matches `.planning` preceded by start-of-string
|
||||
* or a path separator (posix `/` or windows `\`) and followed by a separator or
|
||||
* end-of-string — so `.planning-readme.txt` is NOT falsely matched. Mirrors the
|
||||
* PreToolUse hook's `(^|[\\/])\.planning([\\/]|$)` exactly.
|
||||
*/
|
||||
export const PLANNING_PATH_PATTERN = /(^|[\\/])\.planning([\\/]|$)/;
|
||||
|
||||
/**
|
||||
* The user-visible reason returned when a `.planning/` write is blocked.
|
||||
* Preserves the PreToolUse hook's errorMessage text so the guard behaves
|
||||
* identically to the user (cancel→skip, errorMessage→reason).
|
||||
*/
|
||||
export const PLANNING_GUARD_REASON: string = Object.freeze(
|
||||
'GSD: .planning/ artifacts are managed by GSD workflows. Edit them only through a /gsd-* command, not directly.',
|
||||
);
|
||||
|
||||
/**
|
||||
* Path-bearing field-name detector. Only PATH-keyed field values are inspected,
|
||||
* so a document that merely mentions ".planning/" in its body content is never
|
||||
* falsely blocked. Mirrors the PreToolUse hook's PATH_KEY regex exactly.
|
||||
*/
|
||||
const PATH_KEY_PATTERN = /^(path|file|file_?path|filepath|target_?path|target|dir|directory|uri|filename)$/i;
|
||||
|
||||
type BeforeToolPayload = {
|
||||
tool?: { name?: unknown } | string | null | undefined;
|
||||
input?: unknown;
|
||||
};
|
||||
|
||||
type BeforeToolDecision = { decision: 'skip'; reason: string } | { decision: 'allow'; reason?: undefined };
|
||||
|
||||
/**
|
||||
* Collect PATH-bearing string field values from an object tree, mirroring the
|
||||
* PreToolUse hook's bounded walk. Pure; never throws.
|
||||
*/
|
||||
function collectPathValues(root: unknown): string[] {
|
||||
const paths: string[] = [];
|
||||
const walk = (v: unknown, depth: number): void => {
|
||||
if (depth > 5 || paths.length > 64) return;
|
||||
if (Array.isArray(v)) {
|
||||
for (const x of v) walk(x, depth + 1);
|
||||
return;
|
||||
}
|
||||
if (v && typeof v === 'object') {
|
||||
const obj = v as Record<string, unknown>;
|
||||
for (const k of Object.keys(obj)) {
|
||||
const val = obj[k];
|
||||
if (typeof val === 'string' && PATH_KEY_PATTERN.test(k)) {
|
||||
paths.push(val);
|
||||
} else {
|
||||
walk(val, depth + 1);
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
walk(root, 0);
|
||||
return paths;
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve a tool name from a beforeTool payload's `tool` field, which may be a
|
||||
* string or an object with a `name` property. Returns '' when absent (treated
|
||||
* as non-write-class → allow, fail-open).
|
||||
*/
|
||||
function resolveToolName(tool: BeforeToolPayload['tool']): string {
|
||||
if (!tool) return '';
|
||||
if (typeof tool === 'string') return tool;
|
||||
const name = tool.name;
|
||||
return typeof name === 'string' ? name : '';
|
||||
}
|
||||
|
||||
/**
|
||||
* The pure guard decision: given a beforeTool payload, decide skip (cancel) or
|
||||
* allow. Fail-OPEN — any malformed input, missing tool, or thrown error returns
|
||||
* 'allow' (the guard never blocks on a defect, mirroring the PreToolUse hook).
|
||||
*
|
||||
* @returns `{decision:'skip', reason}` for a write-class call targeting
|
||||
* `.planning/`; `{decision:'allow'}` for everything else.
|
||||
*/
|
||||
export function evaluateBeforeTool(payload: BeforeToolPayload | null | undefined): BeforeToolDecision {
|
||||
try {
|
||||
if (!payload) return { decision: 'allow' };
|
||||
const toolName = resolveToolName(payload.tool);
|
||||
if (!toolName) return { decision: 'allow' };
|
||||
const isWrite = WRITE_TOOL_PATTERN.test(toolName);
|
||||
if (!isWrite) return { decision: 'allow' };
|
||||
const paths = collectPathValues(payload.input);
|
||||
const isPlanningPath = (s: string): boolean => PLANNING_PATH_PATTERN.test(s);
|
||||
if (paths.some(isPlanningPath)) {
|
||||
return { decision: 'skip', reason: PLANNING_GUARD_REASON };
|
||||
}
|
||||
return { decision: 'allow' };
|
||||
} catch {
|
||||
// Fail-open: a defect in the guard never blocks the user's operation.
|
||||
return { decision: 'allow' };
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The Cline `AgentPlugin` shape (Context7 /cline/cline). A plugin implements
|
||||
* `setup({agentId})` returning `{hooks, tools}`. The `beforeTool` hook returns
|
||||
* `{skip:true, reason}` to block or `undefined` to allow.
|
||||
*
|
||||
* This object is the portable plugin a host loads from `~/.cline/plugins/`
|
||||
* (analogous to `.opencode/plugins/gsd-core.js`). Its `beforeTool` delegates to
|
||||
* the pure `evaluateBeforeTool` so the decision logic is testable without the
|
||||
* SDK linked.
|
||||
*/
|
||||
export const clineGsdPlugin: {
|
||||
name: string;
|
||||
setup: (ctx: { agentId?: string }) => {
|
||||
hooks: {
|
||||
beforeTool: (payload: BeforeToolPayload) => { skip: true; reason: string } | undefined;
|
||||
};
|
||||
};
|
||||
} = Object.freeze({
|
||||
name: 'gsd-planning-guard',
|
||||
setup(_ctx: { agentId?: string }) {
|
||||
return {
|
||||
hooks: {
|
||||
beforeTool(payload: BeforeToolPayload): { skip: true; reason: string } | undefined {
|
||||
const decision = evaluateBeforeTool(payload);
|
||||
return decision.decision === 'skip' ? { skip: true, reason: decision.reason } : undefined;
|
||||
},
|
||||
},
|
||||
};
|
||||
},
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// UPGRADE 2 — createAgentModel model-override resolution
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* The fallback provider id when a model id does not match a known provider
|
||||
* family. Anthropic is cline's most common default; the host gateway retains
|
||||
* the final say over provider resolution.
|
||||
*/
|
||||
export const DEFAULT_CLINE_PROVIDER_ID: string = 'anthropic';
|
||||
|
||||
/**
|
||||
* Infer a `providerId` (the createAgentModel first arg) from a model id by
|
||||
* matching known provider families. Returns DEFAULT_CLINE_PROVIDER_ID for an
|
||||
* unrecognized or empty id (fail-safe — the gateway applies its own default).
|
||||
*
|
||||
* Pure string-prefix classification; does not validate the id is a real model.
|
||||
*/
|
||||
export function inferProviderId(modelId: string): string {
|
||||
if (typeof modelId !== 'string' || modelId.length === 0) return DEFAULT_CLINE_PROVIDER_ID;
|
||||
const lower = modelId.toLowerCase();
|
||||
if (lower.startsWith('claude')) return 'anthropic';
|
||||
if (lower.startsWith('gpt') || lower.startsWith('o1') || lower.startsWith('o3') || lower.startsWith('o4')) return 'openai';
|
||||
if (lower.startsWith('gemini')) return 'google';
|
||||
if (lower.startsWith('deepseek')) return 'deepseek';
|
||||
return DEFAULT_CLINE_PROVIDER_ID;
|
||||
}
|
||||
|
||||
type ModelOverrideMap = Record<string, string> | null | undefined;
|
||||
type ProfileOverrideMap = Record<string, Record<string, string>> | null | undefined;
|
||||
|
||||
type AgentModelParams = { providerId: string; modelId: string };
|
||||
|
||||
/**
|
||||
* Resolve the createAgentModel call params for a cline subagent from GSD's model
|
||||
* override config. Mirrors the precedence OpenCode/Codex use (passive hosts
|
||||
* embed the resolved model into agent frontmatter); for cline's active model
|
||||
* mode the same resolution flows to `gateway.createAgentModel(params)`.
|
||||
*
|
||||
* Precedence (matches GSD's model_overrides > model_profile_overrides contract):
|
||||
* 1. `modelOverrides[agentType]` — direct per-agent override
|
||||
* 2. `modelProfileOverrides[profile][agentType]` — profile-scoped override
|
||||
* 3. null — no override; the host gateway applies its own default
|
||||
*
|
||||
* Pure; never throws. Non-string / empty override values are ignored (fail-safe).
|
||||
*
|
||||
* @returns the `{providerId, modelId}` for createAgentModel, or null when no
|
||||
* override is configured (the gateway default applies — GSD does NOT
|
||||
* call createAgentModel in that case).
|
||||
*/
|
||||
export function resolveClineAgentModelParams(args: {
|
||||
agentType: string;
|
||||
modelOverrides?: ModelOverrideMap;
|
||||
modelProfileOverrides?: ProfileOverrideMap;
|
||||
profile?: string;
|
||||
}): AgentModelParams | null {
|
||||
const { agentType, modelOverrides, modelProfileOverrides, profile } = args;
|
||||
if (!agentType || typeof agentType !== 'string') return null;
|
||||
|
||||
// 1. Direct per-agent override wins.
|
||||
if (modelOverrides && typeof modelOverrides === 'object') {
|
||||
const direct = modelOverrides[agentType];
|
||||
if (typeof direct === 'string' && direct.length > 0) {
|
||||
return { providerId: inferProviderId(direct), modelId: direct };
|
||||
}
|
||||
}
|
||||
|
||||
// 2. Profile-scoped override.
|
||||
if (modelProfileOverrides && typeof modelProfileOverrides === 'object' && profile) {
|
||||
const profileEntry = modelProfileOverrides[profile];
|
||||
if (profileEntry && typeof profileEntry === 'object') {
|
||||
const profileModel = profileEntry[agentType];
|
||||
if (typeof profileModel === 'string' && profileModel.length > 0) {
|
||||
return { providerId: inferProviderId(profileModel), modelId: profileModel };
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 3. No override — gateway default applies.
|
||||
return null;
|
||||
}
|
||||
168
tests/cline-beforetool-upgrade.test.cjs
Normal file
168
tests/cline-beforetool-upgrade.test.cjs
Normal file
@@ -0,0 +1,168 @@
|
||||
'use strict';
|
||||
|
||||
/**
|
||||
* cline beforeTool plugin UPGRADE — ADR-1239 / #2090 AC (upgrade 1).
|
||||
*
|
||||
* Proves the `.clinerules/hooks/PreToolUse` file-convention planning-artifact
|
||||
* guard is re-implemented as a real Cline SDK `AgentPlugin.hooks.beforeTool`
|
||||
* handler, delivered through the negotiated `hookBus: host` interface point.
|
||||
* Guard semantics are preserved EXACTLY from the PreToolUse script: fail-open,
|
||||
* cancel (skip) write-class calls targeting `.planning/`, pass through
|
||||
* everything else.
|
||||
*
|
||||
* The adapter is exercised directly (mocked SDK payload shape) since the real
|
||||
* `@cline/sdk` is a fast-moving package set not linked at test time — same
|
||||
* pattern as the VS Code reference binding (tests/fixtures/vscode-host-binding.cjs).
|
||||
*
|
||||
* Cite:
|
||||
* https://github.com/cline/cline/blob/main/docs/sdk/plugins.mdx
|
||||
* — "Lifecycle hooks ... include beforeRun, afterRun, beforeModel,
|
||||
* afterModel, beforeTool, afterTool, and onEvent."
|
||||
* https://github.com/cline/cline/blob/main/sdk/packages/agents/README.md
|
||||
* — beforeTool({ tool, input }) => { skip: true, reason } | undefined
|
||||
*/
|
||||
|
||||
const { test } = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
|
||||
const {
|
||||
WRITE_TOOL_PATTERN,
|
||||
PLANNING_PATH_PATTERN,
|
||||
PLANNING_GUARD_REASON,
|
||||
evaluateBeforeTool,
|
||||
clineGsdPlugin,
|
||||
} = require('../gsd-core/bin/lib/host-integration-adapters/cline-sdk-binding.cjs');
|
||||
|
||||
// -- upgrade 1: write-class detection ---------------------------------------
|
||||
|
||||
test('WRITE_TOOL_PATTERN matches the write-class tool verbs (parity with PreToolUse)', () => {
|
||||
const writeVerbs = ['write_to_file', 'edit_file', 'replace_in_file', 'create_file',
|
||||
'delete_file', 'remove_file', 'append_to_file', 'apply_patch', 'insert_edit', 'mkdir'];
|
||||
for (const v of writeVerbs) {
|
||||
assert.ok(WRITE_TOOL_PATTERN.test(v), `write-class verb must match: ${v}`);
|
||||
}
|
||||
});
|
||||
|
||||
test('WRITE_TOOL_PATTERN does NOT match read-class tools', () => {
|
||||
const readVerbs = ['read_file', 'list_files', 'search_files', 'execute_command', 'ask_user'];
|
||||
for (const v of readVerbs) {
|
||||
assert.ok(!WRITE_TOOL_PATTERN.test(v), `read-class verb must NOT match: ${v}`);
|
||||
}
|
||||
});
|
||||
|
||||
// -- upgrade 1: planning-path detection -------------------------------------
|
||||
|
||||
test('PLANNING_PATH_PATTERN matches .planning/ paths on posix + windows separators', () => {
|
||||
const planningPaths = ['.planning/state.md', '.planning/phases/1/PLAN.md',
|
||||
'/home/u/proj/.planning/config.json', 'proj\\.planning\\foo', './.planning/x'];
|
||||
for (const p of planningPaths) {
|
||||
assert.ok(PLANNING_PATH_PATTERN.test(p), `planning path must match: ${p}`);
|
||||
}
|
||||
});
|
||||
|
||||
test('PLANNING_PATH_PATTERN does NOT match non-planning paths', () => {
|
||||
// .planning-readme.txt must not match (boundary after .planning required)
|
||||
for (const p of ['src/planning-utils.ts', 'docs/plan.md']) {
|
||||
assert.ok(!PLANNING_PATH_PATTERN.test(p), `non-planning path must NOT match: ${p}`);
|
||||
}
|
||||
});
|
||||
|
||||
// -- upgrade 1: evaluateBeforeTool — the guard decision ----------------------
|
||||
|
||||
test('write-class tool targeting .planning/ is SKIPPED (cancel)', () => {
|
||||
const result = evaluateBeforeTool({
|
||||
tool: { name: 'write_to_file' },
|
||||
input: { path: '.planning/phases/1/PLAN.md', content: '...' },
|
||||
});
|
||||
assert.equal(result.decision, 'skip');
|
||||
assert.equal(result.reason, PLANNING_GUARD_REASON);
|
||||
});
|
||||
|
||||
test('write-class tool targeting a NON-planning path is ALLOWED', () => {
|
||||
const result = evaluateBeforeTool({
|
||||
tool: { name: 'write_to_file' },
|
||||
input: { path: 'src/index.ts', content: '...' },
|
||||
});
|
||||
assert.equal(result.decision, 'allow');
|
||||
assert.equal(result.reason, undefined);
|
||||
});
|
||||
|
||||
test('read-class tool targeting .planning/ is ALLOWED (reads are safe)', () => {
|
||||
const result = evaluateBeforeTool({
|
||||
tool: { name: 'read_file' },
|
||||
input: { path: '.planning/state.md' },
|
||||
});
|
||||
assert.equal(result.decision, 'allow');
|
||||
});
|
||||
|
||||
test('write tool with .planning/ in a non-PATH field (content body) is ALLOWED', () => {
|
||||
// A doc that merely mentions ".planning/" in its body is never falsely blocked —
|
||||
// only PATH-bearing field values are inspected (parity with PreToolUse).
|
||||
const result = evaluateBeforeTool({
|
||||
tool: { name: 'write_to_file' },
|
||||
input: { path: 'docs/guide.md', content: 'see .planning/ for details' },
|
||||
});
|
||||
assert.equal(result.decision, 'allow');
|
||||
});
|
||||
|
||||
test('write tool with .planning/ in a recognized nested path-key is SKIPPED', () => {
|
||||
// The guard walks the input object tree collecting values from PATH-keyed
|
||||
// fields (path|file|target|dir|...). A recognized key nested anywhere in the
|
||||
// payload is caught — parity with the PreToolUse hook's bounded walk.
|
||||
const result = evaluateBeforeTool({
|
||||
tool: { name: 'apply_patch' },
|
||||
input: { target: '.planning/config.json' },
|
||||
});
|
||||
assert.equal(result.decision, 'skip');
|
||||
});
|
||||
|
||||
// -- upgrade 1: fail-open on malformed/missing input ------------------------
|
||||
|
||||
test('evaluateBeforeTool fails OPEN on null tool/input (never throws, never blocks)', () => {
|
||||
assert.equal(evaluateBeforeTool({ tool: null, input: null }).decision, 'allow');
|
||||
assert.equal(evaluateBeforeTool({}).decision, 'allow');
|
||||
assert.equal(evaluateBeforeTool(null).decision, 'allow');
|
||||
});
|
||||
|
||||
test('evaluateBeforeTool fails OPEN on a tool with no name', () => {
|
||||
assert.equal(evaluateBeforeTool({ tool: {}, input: { path: '.planning/x' } }).decision, 'allow');
|
||||
});
|
||||
|
||||
// -- upgrade 1: the AgentPlugin wrapper shape -------------------------------
|
||||
|
||||
test('clineGsdPlugin is an AgentPlugin with a beforeTool hook', () => {
|
||||
assert.equal(typeof clineGsdPlugin, 'object');
|
||||
assert.equal(clineGsdPlugin.name, 'gsd-planning-guard');
|
||||
assert.equal(typeof clineGsdPlugin.setup, 'function');
|
||||
});
|
||||
|
||||
test('clineGsdPlugin.setup returns hooks.beforeTool that maps skip→{skip,reason}', () => {
|
||||
const { hooks } = clineGsdPlugin.setup({ agentId: 'test-agent' });
|
||||
assert.equal(typeof hooks.beforeTool, 'function');
|
||||
// planning write → { skip: true, reason }
|
||||
const blocked = hooks.beforeTool({
|
||||
tool: { name: 'write_to_file' },
|
||||
input: { path: '.planning/state.md' },
|
||||
});
|
||||
assert.deepEqual(blocked, { skip: true, reason: PLANNING_GUARD_REASON });
|
||||
});
|
||||
|
||||
test('clineGsdPlugin.beforeTool returns undefined for allowed calls (SDK contract)', () => {
|
||||
const { hooks } = clineGsdPlugin.setup({ agentId: 'test-agent' });
|
||||
const allowed = hooks.beforeTool({
|
||||
tool: { name: 'write_to_file' },
|
||||
input: { path: 'src/foo.ts' },
|
||||
});
|
||||
assert.equal(allowed, undefined, 'undefined = allow (Cline SDK beforeTool contract)');
|
||||
});
|
||||
|
||||
// -- upgrade 1: parity with the existing PreToolUse script semantics --------
|
||||
|
||||
test('the guard reason matches the PreToolUse script errorMessage contract', () => {
|
||||
// The file-convention hook wrote { cancel:true, errorMessage:'GSD: ...' }.
|
||||
// The SDK plugin maps cancel→skip and errorMessage→reason. The user-visible
|
||||
// message text is preserved so the guard behaves identically to the user.
|
||||
assert.ok(typeof PLANNING_GUARD_REASON === 'string' && PLANNING_GUARD_REASON.length > 0);
|
||||
assert.ok(PLANNING_GUARD_REASON.includes('.planning/'),
|
||||
'reason must explain the .planning/ protection so the user can act on it');
|
||||
});
|
||||
101
tests/cline-dispatch-degradation.test.cjs
Normal file
101
tests/cline-dispatch-degradation.test.cjs
Normal file
@@ -0,0 +1,101 @@
|
||||
'use strict';
|
||||
|
||||
/**
|
||||
* cline dispatch DEGRADATION — ADR-1239 / #2090.
|
||||
*
|
||||
* Proves cline's dispatch STAYS degraded/flat and is NEVER silently upgraded to
|
||||
* the programmatic-cli profile baseline's full nested/background dispatch.
|
||||
* Cline's own docs (docs/features/subagents.mdx) restrict subagents to a single
|
||||
* level, read-only toolkit, no nested spawning — so claiming full dispatch would
|
||||
* misrepresent a documented host restriction and violate the fail-closed
|
||||
* negotiation contract. This is the cline counterpart to cursor's dispatch
|
||||
* UPGRADE (#2089), asserting the OPPOSITE invariant: cline flattens.
|
||||
*
|
||||
* Cite:
|
||||
* https://github.com/cline/cline/blob/main/docs/features/subagents.mdx
|
||||
* — "subagents are restricted from editing files, using the browser,
|
||||
* accessing MCP servers, or creating nested subagents."
|
||||
* https://github.com/cline/cline/blob/main/docs/features/subagents.mdx
|
||||
* — "They are explicitly prohibited from ... spawning other subagents."
|
||||
*/
|
||||
|
||||
const { test } = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
const fs = require('node:fs');
|
||||
const path = require('node:path');
|
||||
|
||||
const {
|
||||
shouldFlattenDispatch,
|
||||
degradationFor,
|
||||
} = require('../gsd-core/bin/lib/host-integration.cjs');
|
||||
|
||||
const CLN_CAP = JSON.parse(
|
||||
fs.readFileSync(path.join(__dirname, '..', 'capabilities', 'cline', 'capability.json'), 'utf8'),
|
||||
);
|
||||
const CLN_DISPATCH = CLN_CAP.runtime.hostIntegration.dispatch;
|
||||
|
||||
// -- cline dispatch axes: documented restrictions ----------------------------
|
||||
|
||||
test('cline dispatch declares namedDispatch but NOT nested (single-level only)', () => {
|
||||
assert.equal(CLN_DISPATCH.namedDispatch, true,
|
||||
'cite agents-squad example — start_subagent(preset:..., task:...) named dispatch');
|
||||
assert.equal(CLN_DISPATCH.nested, false,
|
||||
'cite subagents.mdx — subagents cannot create nested subagents');
|
||||
});
|
||||
|
||||
test('cline dispatch respects maxDepth: 1 (the documented hard limit)', () => {
|
||||
assert.equal(CLN_DISPATCH.maxDepth, 1,
|
||||
'cite subagents.mdx — "explicitly prohibited from ... spawning other subagents"');
|
||||
});
|
||||
|
||||
test('cline subagentToolkit is read-only (no write/browser/mcp for subagents)', () => {
|
||||
assert.equal(CLN_DISPATCH.subagentToolkit, 'read-only',
|
||||
'cite subagents.mdx — "strictly limited to read-only operations"');
|
||||
});
|
||||
|
||||
test('cline backgroundDispatch is false (background commands run, but no nested-dispatch)', () => {
|
||||
assert.equal(CLN_DISPATCH.background, true,
|
||||
'cite subagents.mdx — "Commands executed by subagents run in the background"');
|
||||
assert.equal(CLN_DISPATCH.backgroundDispatch, false,
|
||||
'cite subagents.mdx — cannot spawn nested subagents from a background context');
|
||||
});
|
||||
|
||||
// -- shouldFlattenDispatch: cline MUST flatten (degraded) --------------------
|
||||
|
||||
test('shouldFlattenDispatch(cline) is true — waves run inline (the #853 rule)', () => {
|
||||
assert.equal(shouldFlattenDispatch(CLN_DISPATCH), true,
|
||||
'cline has backgroundDispatch:false → GSD must force-flatten (run inline)');
|
||||
});
|
||||
|
||||
test('a hypothetical full-upgrade (backgroundDispatch:true) would NOT flatten — proving the discriminator', () => {
|
||||
const hypothetical = { ...CLN_DISPATCH, backgroundDispatch: true, nested: true, maxDepth: 2, subagentToolkit: 'full' };
|
||||
assert.equal(shouldFlattenDispatch(hypothetical), false,
|
||||
'only background:true AND backgroundDispatch:true escapes flattening — cline lacks both');
|
||||
});
|
||||
|
||||
// -- degradationFor: cline dispatch is 'degraded' / flat ---------------------
|
||||
|
||||
test('degradationFor("dispatch", cline) is degraded with the flat-dispatch fallback', () => {
|
||||
const result = degradationFor('dispatch', CLN_CAP.runtime.hostIntegration);
|
||||
assert.equal(result.level, 'degraded',
|
||||
'maxDepth:1 (flat) is a degraded dispatch surface, never full');
|
||||
assert.equal(result.fallback, 'flat dispatch — waves run inline');
|
||||
assert.notEqual(result.level, 'full',
|
||||
'cline dispatch must NEVER be classified as full — that would misrepresent the host');
|
||||
});
|
||||
|
||||
test('cline dispatch is never silently upgraded to the programmatic-cli baseline', () => {
|
||||
// The full baseline requires nested + maxDepth>=2 + subagentToolkit 'full'.
|
||||
// Cline violates ALL three (nested:false, maxDepth:1, read-only) — so it must
|
||||
// stay degraded regardless of any negotiation defaults.
|
||||
const result = degradationFor('dispatch', CLN_CAP.runtime.hostIntegration);
|
||||
assert.notEqual(result.level, 'full');
|
||||
});
|
||||
|
||||
// -- boundary: maxDepth 1 vs 0 vs -1 ----------------------------------------
|
||||
|
||||
test('maxDepth 1 is flat (NOT absent, NOT unbounded)', () => {
|
||||
assert.ok(CLN_DISPATCH.maxDepth > 0, 'maxDepth must be positive (not absent/single-agent)');
|
||||
assert.notEqual(CLN_DISPATCH.maxDepth, -1, 'cline is NOT unbounded — depth-1 is the documented limit');
|
||||
assert.notEqual(CLN_DISPATCH.maxDepth, 0, 'maxDepth:0 would mean no named dispatch — cline HAS named dispatch');
|
||||
});
|
||||
113
tests/cline-imperative-reference.test.cjs
Normal file
113
tests/cline-imperative-reference.test.cjs
Normal file
@@ -0,0 +1,113 @@
|
||||
// allow-test-rule: AC2 requires asserting no `runtime === 'cline'` string-equality branch remains in bin/install.js/src — the descriptor-migration contract is a property of the source text, so a source-grep is the only faithful check (#2090)
|
||||
'use strict';
|
||||
|
||||
/**
|
||||
* cline imperative reference host — ADR-1239 Phase D / #2090 (EoS/cline).
|
||||
*
|
||||
* Proves cline is driven through the PUBLIC Host-Integration Interface (the
|
||||
* imperative adapter), that its negotiated axes classify + negotiate correctly,
|
||||
* that negotiation fails CLOSED on a corrupted descriptor, that the
|
||||
* Context7-sourced dispatch classification STAYS degraded/flat (cline subagents
|
||||
* are documented as single-level read-only — maxDepth:1 — and must never be
|
||||
* silently upgraded to full nested/background dispatch), and that the migration
|
||||
* retired the hardcoded `runtime === 'cline'` / `isCline` branches (folded into
|
||||
* descriptor-driven `runtime.hostBehaviors`).
|
||||
*
|
||||
* Contrast with cursor (#2089): cursor's dispatch got an UPGRADE (background +
|
||||
* nested). cline's dispatch is a deliberate DEGRADATION that must be preserved —
|
||||
* upgrading it would misrepresent a documented host restriction and violate the
|
||||
* fail-closed negotiation contract (see dispatch-degradation test).
|
||||
*/
|
||||
|
||||
const { test } = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
const fs = require('node:fs');
|
||||
const path = require('node:path');
|
||||
|
||||
const { createImperativeAdapter } = require('../gsd-core/bin/lib/adapter-imperative.cjs');
|
||||
const {
|
||||
profileOf,
|
||||
negotiateHostCapabilities,
|
||||
PROFILE_BASELINES,
|
||||
UNDOCUMENTED,
|
||||
} = require('../gsd-core/bin/lib/host-integration.cjs');
|
||||
|
||||
const CLN_CAP = JSON.parse(
|
||||
fs.readFileSync(path.join(__dirname, '..', 'capabilities', 'cline', 'capability.json'), 'utf8'),
|
||||
);
|
||||
const CLN_AXES = CLN_CAP.runtime.hostIntegration;
|
||||
|
||||
// -- AC2: driven through the public interface (imperative adapter) -----------
|
||||
|
||||
test('createImperativeAdapter classifies cline as imperative + composes the registry', () => {
|
||||
const adapter = createImperativeAdapter({ runtime: 'cline' });
|
||||
assert.equal(adapter.kind, 'imperative');
|
||||
assert.equal(adapter.runtime, 'cline');
|
||||
assert.ok(adapter.registry && typeof adapter.registry === 'object');
|
||||
assert.equal(typeof adapter.install, 'function');
|
||||
assert.equal(typeof adapter.uninstall, 'function');
|
||||
});
|
||||
|
||||
test('cline axes classify as the programmatic-cli reference profile (imperative embedding)', () => {
|
||||
assert.equal(profileOf(CLN_AXES), 'programmatic-cli');
|
||||
});
|
||||
|
||||
// -- AC3: all axes populated + validated -------------------------------------
|
||||
|
||||
test('cline descriptor declares all 8 axes + 6 dispatch sub-axes (no undocumented)', () => {
|
||||
assert.equal(CLN_AXES.embeddingMode, 'imperative');
|
||||
assert.equal(CLN_AXES.commandSurface, 'slash-file');
|
||||
assert.equal(CLN_AXES.modelMode, 'active');
|
||||
assert.equal(CLN_AXES.hookBus, 'host');
|
||||
assert.equal(CLN_AXES.stateIO, 'filesystem');
|
||||
assert.equal(CLN_AXES.transport, 'mcp');
|
||||
assert.equal(CLN_AXES.runtime, 'node');
|
||||
const d = CLN_AXES.dispatch;
|
||||
assert.equal(d.namedDispatch, true);
|
||||
assert.equal(d.nested, false);
|
||||
assert.equal(d.maxDepth, 1);
|
||||
assert.equal(d.background, true);
|
||||
assert.equal(d.subagentToolkit, 'read-only');
|
||||
assert.equal(d.backgroundDispatch, false);
|
||||
});
|
||||
|
||||
// -- AC5: negotiation fails CLOSED on a corrupted descriptor ------------------
|
||||
|
||||
test('negotiateHostCapabilities never throws for cline, even fully corrupted', () => {
|
||||
assert.doesNotThrow(() => negotiateHostCapabilities({}));
|
||||
assert.doesNotThrow(() => negotiateHostCapabilities({ ...CLN_AXES, embeddingMode: UNDOCUMENTED }));
|
||||
assert.doesNotThrow(() => negotiateHostCapabilities({ ...CLN_AXES, embeddingMode: 'future-unknown' }));
|
||||
});
|
||||
|
||||
test('a partial/empty cline descriptor degrades to the safe floor, not the programmatic-cli baseline', () => {
|
||||
const result = negotiateHostCapabilities({});
|
||||
assert.equal(result.effective.embeddingMode, 'declarative', 'omitted embeddingMode degrades closed');
|
||||
assert.equal(result.effective.hookBus, 'none');
|
||||
assert.notDeepEqual(result.effective, PROFILE_BASELINES['programmatic-cli']);
|
||||
assert.ok(result.warnings.length > 0);
|
||||
});
|
||||
|
||||
// -- AC2: the hardcoded branches are retired ---------------------------------
|
||||
|
||||
test('cline descriptor declares runtime.hostBehaviors (the folded-in behaviors)', () => {
|
||||
const hb = CLN_CAP.runtime.hostBehaviors;
|
||||
assert.ok(hb && typeof hb === 'object');
|
||||
assert.equal(hb.reapplyCommand, '/gsd-update --reapply');
|
||||
assert.equal(hb.frontmatterDialect, 'cline');
|
||||
assert.equal(hb.skipSharedHooksInstall, true);
|
||||
assert.equal(hb.localTargetIsProjectRoot, true);
|
||||
assert.equal(hb.clineRulesSurface, true);
|
||||
assert.equal(hb.localCommandsViaRules, true);
|
||||
});
|
||||
|
||||
test('no `runtime === "cline"` string-equality branch remains in the install source (AC2)', () => {
|
||||
const strip = (src) => src
|
||||
.replace(/\/\*[\s\S]*?\*\//g, '')
|
||||
.replace(/\/\/[^\r\n]*/g, '')
|
||||
.replace(/`[^`]*`/g, '');
|
||||
for (const rel of ['bin/install.js', 'src/install-engine.cts', 'src/runtime-artifact-conversion.cts', 'src/runtime-hooks-surface.cts']) {
|
||||
const src = fs.readFileSync(path.join(__dirname, '..', rel), 'utf8');
|
||||
const offenders = strip(src).match(/runtime\s*[!=]==\s*'cline'/g) || [];
|
||||
assert.deepEqual(offenders, [], `AC2: no hardcoded runtime==='cline' branch may remain in ${rel}; found: ${offenders.join(', ')}`);
|
||||
}
|
||||
});
|
||||
161
tests/cline-model-override-upgrade.test.cjs
Normal file
161
tests/cline-model-override-upgrade.test.cjs
Normal file
@@ -0,0 +1,161 @@
|
||||
'use strict';
|
||||
|
||||
/**
|
||||
* cline createAgentModel UPGRADE — ADR-1239 / #2090 AC (upgrade 2).
|
||||
*
|
||||
* Proves GSD's per-subagent `model_overrides` / `model_profile_overrides`
|
||||
* resolution (already used for OpenCode/Codex) now applies to cline subagents
|
||||
* via `DefaultGateway.createAgentModel({ providerId, modelId })`, instead of
|
||||
* leaving model selection untouched. Cline's `modelMode: active` (the host
|
||||
* exposes provider registration via createLlmsRuntime) is what makes this
|
||||
* wiring possible — passive hosts can only inject a per-agent model field.
|
||||
*
|
||||
* The binding resolves the createAgentModel call parameters from GSD config;
|
||||
* the real gateway call is the host's responsibility (mocked here, same pattern
|
||||
* as tests/fixtures/vscode-host-binding.cjs).
|
||||
*
|
||||
* Cite:
|
||||
* https://github.com/cline/cline/blob/main/docs/sdk/reference/gateway.mdx
|
||||
* — createAgentModel({ providerId, modelId }) returns an AgentModel
|
||||
* https://github.com/cline/cline/blob/main/sdk/packages/llms/README.md
|
||||
* — createLlmsRuntime(...) provider registry (modelMode: active)
|
||||
*/
|
||||
|
||||
const { test } = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
|
||||
const {
|
||||
resolveClineAgentModelParams,
|
||||
inferProviderId,
|
||||
DEFAULT_CLINE_PROVIDER_ID,
|
||||
} = require('../gsd-core/bin/lib/host-integration-adapters/cline-sdk-binding.cjs');
|
||||
|
||||
// -- upgrade 2: provider inference from a model id --------------------------
|
||||
|
||||
test('inferProviderId maps anthropic model ids to "anthropic"', () => {
|
||||
assert.equal(inferProviderId('claude-sonnet-4-5'), 'anthropic');
|
||||
assert.equal(inferProviderId('claude-opus-4-1'), 'anthropic');
|
||||
});
|
||||
|
||||
test('inferProviderId maps openai model ids to "openai"', () => {
|
||||
assert.equal(inferProviderId('gpt-4o'), 'openai');
|
||||
assert.equal(inferProviderId('o1-preview'), 'openai');
|
||||
});
|
||||
|
||||
test('inferProviderId falls back to DEFAULT_CLINE_PROVIDER_ID for unknown ids', () => {
|
||||
assert.equal(inferProviderId('some-custom-model'), DEFAULT_CLINE_PROVIDER_ID);
|
||||
assert.equal(inferProviderId(''), DEFAULT_CLINE_PROVIDER_ID);
|
||||
});
|
||||
|
||||
// -- upgrade 2: model_overrides resolution ----------------------------------
|
||||
|
||||
test('a per-agent model_overrides entry resolves to createAgentModel params', () => {
|
||||
const result = resolveClineAgentModelParams({
|
||||
agentType: 'planner',
|
||||
modelOverrides: { planner: 'claude-sonnet-4-5' },
|
||||
modelProfileOverrides: null,
|
||||
profile: 'balanced',
|
||||
});
|
||||
assert.deepEqual(result, { providerId: 'anthropic', modelId: 'claude-sonnet-4-5' });
|
||||
});
|
||||
|
||||
test('model_overrides takes precedence over model_profile_overrides', () => {
|
||||
const result = resolveClineAgentModelParams({
|
||||
agentType: 'planner',
|
||||
modelOverrides: { planner: 'claude-sonnet-4-5' },
|
||||
modelProfileOverrides: { balanced: { planner: 'claude-opus-4-1' } },
|
||||
profile: 'balanced',
|
||||
});
|
||||
assert.equal(result.modelId, 'claude-sonnet-4-5', 'direct model_overrides wins');
|
||||
});
|
||||
|
||||
test('model_profile_overrides resolves when no direct model_overrides entry exists', () => {
|
||||
const result = resolveClineAgentModelParams({
|
||||
agentType: 'executor',
|
||||
modelOverrides: null,
|
||||
modelProfileOverrides: { balanced: { executor: 'gpt-4o' } },
|
||||
profile: 'balanced',
|
||||
});
|
||||
assert.deepEqual(result, { providerId: 'openai', modelId: 'gpt-4o' });
|
||||
});
|
||||
|
||||
// -- upgrade 2: no override → null (gateway default applies) ----------------
|
||||
|
||||
test('returns null when no override is configured (gateway picks the default model)', () => {
|
||||
const result = resolveClineAgentModelParams({
|
||||
agentType: 'planner',
|
||||
modelOverrides: null,
|
||||
modelProfileOverrides: null,
|
||||
profile: 'balanced',
|
||||
});
|
||||
assert.equal(result, null, 'null = no override; host gateway default applies');
|
||||
});
|
||||
|
||||
test('returns null when the agentType has no matching override', () => {
|
||||
const result = resolveClineAgentModelParams({
|
||||
agentType: 'planner',
|
||||
modelOverrides: { executor: 'claude-sonnet-4-5' },
|
||||
modelProfileOverrides: null,
|
||||
profile: 'balanced',
|
||||
});
|
||||
assert.equal(result, null);
|
||||
});
|
||||
|
||||
// -- upgrade 2: the gateway binding (createAgentModel call shape) -----------
|
||||
|
||||
test('a resolved override drives DefaultGateway.createAgentModel with the right params', () => {
|
||||
// Simulate the host gateway (mocked — the real @cline/sdk is not linked here).
|
||||
const calls = [];
|
||||
const fakeGateway = {
|
||||
createAgentModel(selection) { calls.push(selection); return { providerId: selection.providerId, modelId: selection.modelId }; },
|
||||
};
|
||||
const params = resolveClineAgentModelParams({
|
||||
agentType: 'planner',
|
||||
modelOverrides: { planner: 'claude-sonnet-4-5' },
|
||||
modelProfileOverrides: null,
|
||||
profile: 'balanced',
|
||||
});
|
||||
assert.ok(params, 'override must resolve to non-null params');
|
||||
const model = fakeGateway.createAgentModel(params);
|
||||
assert.equal(calls.length, 1);
|
||||
assert.deepEqual(calls[0], { providerId: 'anthropic', modelId: 'claude-sonnet-4-5' });
|
||||
assert.equal(model.modelId, 'claude-sonnet-4-5');
|
||||
});
|
||||
|
||||
test('no override → createAgentModel is NOT called (gateway default, not GSD override)', () => {
|
||||
const calls = [];
|
||||
const fakeGateway = {
|
||||
createAgentModel(selection) { calls.push(selection); return {}; },
|
||||
};
|
||||
const params = resolveClineAgentModelParams({
|
||||
agentType: 'planner',
|
||||
modelOverrides: null,
|
||||
modelProfileOverrides: null,
|
||||
profile: 'balanced',
|
||||
});
|
||||
if (params) fakeGateway.createAgentModel(params);
|
||||
assert.equal(calls.length, 0, 'no override → gateway must use its own default, GSD does not call createAgentModel');
|
||||
});
|
||||
|
||||
// -- upgrade 2: fail-safe / malformed config --------------------------------
|
||||
|
||||
test('malformed override values (non-string) are ignored (fail-safe, not crash)', () => {
|
||||
const result = resolveClineAgentModelParams({
|
||||
agentType: 'planner',
|
||||
modelOverrides: { planner: 42, executor: 'claude-sonnet-4-5' },
|
||||
modelProfileOverrides: null,
|
||||
profile: 'balanced',
|
||||
});
|
||||
// planner's non-string override is ignored; falls through to null (no executor match for agentType planner)
|
||||
assert.equal(result, null);
|
||||
});
|
||||
|
||||
test('empty-string override is treated as absent', () => {
|
||||
const result = resolveClineAgentModelParams({
|
||||
agentType: 'planner',
|
||||
modelOverrides: { planner: '' },
|
||||
modelProfileOverrides: null,
|
||||
profile: 'balanced',
|
||||
});
|
||||
assert.equal(result, null);
|
||||
});
|
||||
Reference in New Issue
Block a user