From 760eb71b6b15537931353347fd41c40f533f6dd8 Mon Sep 17 00:00:00 2001 From: Tom Boucher Date: Thu, 9 Jul 2026 14:40:26 -0400 Subject: [PATCH] feat(#2090): migrate cline onto imperative adapter + beforeTool/createAgentModel upgrades Fold all hardcoded runtime === 'cline' / isCline branches in bin/install.js into descriptor-driven runtime.hostBehaviors lookups (reapplyCommand, frontmatterDialect, skipSharedHooksInstall, localTargetIsProjectRoot, clineRulesSurface, localCommandsViaRules). Add cline-sdk-binding adapter (ADR-1239 Phase D): UPGRADE 1 re-implements the .clinerules/hooks/PreToolUse guard as a real AgentPlugin.hooks.beforeTool handler (fail-open, same semantics); UPGRADE 2 wires DefaultGateway.createAgentModel params from model_overrides/model_profile_overrides resolution (modelMode: active). Install output is byte-identical (golden parity asserted for cline + claude/cursor/codex/opencode). --- .gitignore | 1 + bin/install.js | 54 ++-- capabilities/cline/capability.json | 8 + gsd-core/bin/lib/capability-registry.cjs | 16 ++ .../cline-sdk-binding.cts | 256 ++++++++++++++++++ 5 files changed, 317 insertions(+), 18 deletions(-) create mode 100644 src/host-integration-adapters/cline-sdk-binding.cts diff --git a/.gitignore b/.gitignore index 37d552553..c08717e6c 100644 --- a/.gitignore +++ b/.gitignore @@ -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 diff --git a/bin/install.js b/bin/install.js index 16cc410c6..1461c4296 100755 --- a/bin/install.js +++ b/bin/install.js @@ -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-.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); diff --git a/capabilities/cline/capability.json b/capabilities/cline/capability.json index 1b6ab6513..bf6704c29 100644 --- a/capabilities/cline/capability.json +++ b/capabilities/cline/capability.json @@ -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 } } } diff --git a/gsd-core/bin/lib/capability-registry.cjs b/gsd-core/bin/lib/capability-registry.cjs index 3a2fac1b3..f29d05e9a 100644 --- a/gsd-core/bin/lib/capability-registry.cjs +++ b/gsd-core/bin/lib/capability-registry.cjs @@ -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 } } }, diff --git a/src/host-integration-adapters/cline-sdk-binding.cts b/src/host-integration-adapters/cline-sdk-binding.cts new file mode 100644 index 000000000..0995ad81a --- /dev/null +++ b/src/host-integration-adapters/cline-sdk-binding.cts @@ -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; + 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 as { name?: unknown }).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 | null | undefined; +type ProfileOverrideMap = Record> | 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; +}