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).
This commit is contained in:
Tom Boucher
2026-07-09 14:40:26 -04:00
parent a9f9c130ef
commit 760eb71b6b
5 changed files with 317 additions and 18 deletions

1
.gitignore vendored
View File

@@ -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

View File

@@ -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);

View File

@@ -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
}
}
}

View File

@@ -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
}
}
},

View 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 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<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;
}