feat(#2100): drive Windsurf through the EoS descriptor + wire Cascade's blocking hook bus (ADR-1239)

Fold all 10 residual isWindsurf branches in bin/install.js onto descriptor-driven
hostBehaviors (byte-parity — no fold changes any install output):
- 2 dead destructures dropped (uninstall, finishInstall); the dead
  `else if (isWindsurf)` legacy agent-loop arm removed (windsurf ∈
  _DESCRIPTOR_AGENTS_RUNTIMES → unreachable).
- skipSharedHooksInstall:true folds the two `!isWindsurf` shared-hooks exclusions.
- legacyDevinSkillsCleanup:true folds the `.devin`→`.windsurf` one-time cleanup gate.
- installsCommandBodiesForWorkflowDelegation:true folds the #1629 command-body copy
  (workflow-delegation target — load-bearing; local-install verified intact).
- verificationStyle:"windsurf-workflows" folds the workflow-count report.
- Corrected stale _LEGACY_SCAN_SUBDIR_NAMES + hooks-json manifest comments (cursor + windsurf).
Zero live runtime==='windsurf'/isWindsurf branches remain across bin/install.js,
install-engine.cts, surface.cts, runtime-artifact-conversion.cts (AC2 guard scans all four).

UPGRADE (Cascade hook bus): wire GSD's write/command safety guards into Windsurf's
native hook bus. New hooksSurface 'windsurf-hooks-json' (VALID_HOOKS_SURFACES 7→8, GATE A
profile-marker-only allowlist, the HooksSurface union) + writeWindsurfHooksJson
(Cursor-templated, Cascade's flat {hooks:{<event>:[{command}]}} shape) writing
.windsurf/hooks.json with two BLOCKING pre-hooks:
- pre_write_code → gsd-windsurf-pre-write.js: blocks writes to a file outside the
  active git worktree / into .git internals.
- pre_run_command → gsd-windsurf-pre-command.js: conservative destructive-command
  deny-list (rm -rf of root/home incl. sudo/env/path-prefixed forms; fork bombs;
  force-push refspec forms — HEAD:main, +main, --force/-f — to main/master/next).
Both use Cascade's protocol (stdin JSON, exit 2 + stderr to block, exit 0 to allow,
fail-open on error/timeout). Tokenize-based classifier (no catastrophic-backtracking regex;
4096-char cap) with the fail-closed false-positives fixed post-review.

The 4 advisory GSD guards + pre_mcp_tool_use + 5 post_* logging events are deliberately
NOT wired: Cascade has no context-injection channel for advisory hooks and GSD has no MCP
guard — porting them would be non-functional padding (documented; codebuddy #2098 / copilot
#2099 faithful-subset precedent). extendedHookEvents stays [].

Golden: the 2 guard scripts ship in the shared hook bundle (HOOKS_TO_COPY + the shared
managed-hooks-registry), exactly like cursor's 6 gsd-cursor-*.js scripts — so the 8
shared-bundle runtimes' fixtures gain the 2 inert windsurf scripts + the registry hash
(functionally inert for non-windsurf; the established cursor pattern). No install-output
change beyond that (the folds are byte-parity; skip-bundle runtimes untouched). New scripts
registered in managed-hooks-registry + build-hooks + INVENTORY. Tests: declarative-reference-
windsurf (adapter/axes/fail-closed + AC2 guard) + windsurf-hooks-bridge (live exit-2 blocking
+ allow/fail-open + ReDoS-bound + writer/reconcile/remove idempotency); VALID_HOOKS_SURFACES
pin updated to 8. Matrix hookBus delta + changeset (Changed). capability-registry regenerated.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Tom Boucher
2026-07-11 14:38:32 -04:00
parent 30feeaa8be
commit bd613566cb
31 changed files with 1513 additions and 36 deletions

View File

@@ -37,6 +37,9 @@ export const BUNDLED_GSD_HOOK_FILES: ReadonlySet<string> = Object.freeze(new Set
'hooks/gsd-cursor-stop.js',
'hooks/gsd-cursor-subagent-start.js',
'hooks/gsd-cursor-subagent-stop.js',
// Windsurf/Cascade blocking hooks — registered by writeWindsurfHooksJson (#2100).
'hooks/gsd-windsurf-pre-write.js',
'hooks/gsd-windsurf-pre-command.js',
'hooks/gsd-ensure-canonical-path.js',
'hooks/gsd-graphify-update.sh',
'hooks/gsd-phase-boundary.sh',

View File

@@ -57,6 +57,7 @@ type HooksSurface =
| 'cline-rules'
| 'copilot-inline'
| 'kimi-hooks-toml'
| 'windsurf-hooks-json'
| 'none';
interface RuntimeConfigIntent {

View File

@@ -1164,6 +1164,215 @@ function removeCursorHooksJson(targetDir: string): { changed: boolean } {
return { changed: result.changed };
}
// ---------------------------------------------------------------------------
// Windsurf/Cascade hook functions (ADR-1239 / #2100 Stage 2 — HOOK-BRIDGE)
//
// Cascade (Windsurf's agent) hooks.json format is DISTINCT from Cursor's:
// { "hooks": { "<event>": [ { "command": "<shell cmd>", ... } ] } }
// Each entry carries a bare `command` STRING (a shell command line) — not
// Cursor's `{ type: 'command', command: <cmd> }` wrapper — and there is no
// top-level `version` field. Docs (reference): https://docs.windsurf.com/llms-full.txt ,
// https://docs.devin.ai/desktop/cascade/hooks
//
// Cascade blocks via EXIT CODE 2 (+ a stderr reason), not Cursor's stdout-JSON
// `{ block: true, reason }` form — so the two hook scripts installed here
// (hooks/gsd-windsurf-pre-write.js, hooks/gsd-windsurf-pre-command.js) speak a
// different protocol than the Cursor scripts, even though the surrounding
// install/reconcile infra mirrors writeCursorHooksJson/removeCursorHooksJson.
//
// Only 2 of GSD's 6 Cursor-parity hook events have a Cascade counterpart with
// BLOCKING semantics: pre_write_code and pre_run_command. Cascade has no
// context-injection channel (no `additional_context`-style advisory
// response), so the 4 advisory events GSD registers on Cursor (sessionStart,
// postToolUse, stop, subagentStart/subagentStop) are deliberately NOT ported.
// ---------------------------------------------------------------------------
const GSD_WINDSURF_PRE_WRITE_HOOK_SCRIPT = 'gsd-windsurf-pre-write.js';
const GSD_WINDSURF_PRE_COMMAND_HOOK_SCRIPT = 'gsd-windsurf-pre-command.js';
const GSD_WINDSURF_HOOK_MARKER = 'gsd-managed';
/** The 2 Cascade hook events GSD wires with blocking (exit-code-2) guards. */
const WINDSURF_HOOK_EVENTS = Object.freeze(['pre_write_code', 'pre_run_command'] as const);
/** Event → hook-script mapping (mirrors CURSOR_EVENT_SCRIPT_MAP's convention). */
const WINDSURF_EVENT_SCRIPT_MAP: Readonly<Record<string, string>> = Object.freeze({
pre_write_code: GSD_WINDSURF_PRE_WRITE_HOOK_SCRIPT,
pre_run_command: GSD_WINDSURF_PRE_COMMAND_HOOK_SCRIPT,
});
/** All GSD-managed Windsurf hook scripts (used by uninstall cleanup). */
const GSD_WINDSURF_HOOK_SCRIPTS = [
GSD_WINDSURF_PRE_WRITE_HOOK_SCRIPT,
GSD_WINDSURF_PRE_COMMAND_HOOK_SCRIPT,
];
/**
* Build a single Cascade hooks.json managed entry. Cascade's entry shape has
* no `type` field (unlike Cursor's `{ type: 'command', command }`) — just a
* bare `command` shell string plus the GSD marker.
*/
function buildWindsurfHookEntry(command: string): Record<string, unknown> {
return {
command,
[GSD_WINDSURF_HOOK_MARKER]: true,
};
}
function isManagedWindsurfHookEntry(entry: unknown): boolean {
return Boolean(entry && typeof entry === 'object' && (entry as Record<string, unknown>)[GSD_WINDSURF_HOOK_MARKER]);
}
interface WindsurfManagedEntries {
pre_write_code?: Record<string, unknown> | null;
pre_run_command?: Record<string, unknown> | null;
[event: string]: Record<string, unknown> | null | undefined;
}
/**
* Reconcile GSD's managed Cascade hook entries into `<targetDir>/hooks.json`,
* preserving any user-owned entries. Mirrors reconcileCursorHooksJson's
* merge/no-write-when-unchanged semantics, adapted to Cascade's flatter
* `{ hooks: { <event>: [...] } }` shape (no `version` field, no legacy
* top-level-array lift — Cascade's hooks.json is a brand-new surface with no
* prior shape to migrate from).
*/
function reconcileWindsurfHooksJson(hooksJsonPath: string, managedEntries: WindsurfManagedEntries | null): ReconcileResult {
let parsed: Record<string, unknown> = {};
let currentContent: string | null = null;
if (fs.existsSync(hooksJsonPath)) {
const raw = fs.readFileSync(hooksJsonPath, 'utf8');
currentContent = raw;
if (raw.trim()) {
try {
parsed = JSON.parse(raw) as Record<string, unknown>;
} catch (err) {
throw new Error(`Windsurf hooks.json parse failed: ${err && (err as Error).message ? (err as Error).message : String(err)}`);
}
}
}
if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) parsed = {};
const hasNestedHooksObject =
parsed['hooks'] && typeof parsed['hooks'] === 'object' && !Array.isArray(parsed['hooks']);
if (!hasNestedHooksObject) parsed['hooks'] = {};
const hookTable = parsed['hooks'] as Record<string, unknown>;
const entries = managedEntries || {};
for (const event of WINDSURF_HOOK_EVENTS) {
const existing = Array.isArray(hookTable[event]) ? (hookTable[event] as unknown[]) : [];
const userOwned = existing.filter((e) => !isManagedWindsurfHookEntry(e));
const newEntry = entries[event] || null;
if (newEntry) {
hookTable[event] = [...userOwned, newEntry];
} else if (userOwned.length > 0) {
hookTable[event] = userOwned;
} else {
delete hookTable[event];
}
}
// Avoid writing an empty `{ "hooks": {} }` artifact.
if (Object.keys(hookTable).length === 0) delete parsed['hooks'];
const nextContent = `${JSON.stringify(parsed, null, 2)}\n`;
const changed = currentContent !== nextContent;
const shouldWrite = changed && (currentContent !== null || Object.keys(parsed).length > 0);
if (shouldWrite) {
atomicWriteFileSync(hooksJsonPath, nextContent, 'utf8');
}
return { changed: changed, wrote: shouldWrite, path: hooksJsonPath };
}
interface WriteWindsurfHooksJsonOpts {
platform?: string;
}
/**
* Write GSD-managed Cascade lifecycle hooks into `<targetDir>/hooks.json`.
* Both managed hook scripts (gsd-windsurf-pre-write.js,
* gsd-windsurf-pre-command.js) are copied from the GSD hooks/ source to
* `<targetDir>/hooks/` first, so the hooks.json entries never reference a
* script that wasn't installed. Mirrors writeCursorHooksJson's structure;
* `buildHookCommand` is runtime-agnostic (it already returns a plain shell
* command string), so it is reused as-is with `runtime: 'windsurf'` — only
* the hooks.json ENTRY shape (buildWindsurfHookEntry) and the reconcile
* function differ from Cursor's.
*
* @param targetDir - The Windsurf config dir (global: ~/.codeium/windsurf; local: .windsurf)
* @param src - The GSD install source root (for copying hook scripts)
* @param opts - `{ platform? }`
* @returns `{ hooksJsonPath, changed }`
*/
function writeWindsurfHooksJson(targetDir: string, src: string, opts?: WriteWindsurfHooksJsonOpts): { hooksJsonPath: string; changed: boolean } {
opts = opts || {};
const hooksDir = path.join(targetDir, 'hooks');
fs.mkdirSync(hooksDir, { recursive: true });
const srcHooksDir = path.join(src, 'hooks');
const installedScripts = new Set<string>();
for (const script of GSD_WINDSURF_HOOK_SCRIPTS) {
const srcPath = path.join(srcHooksDir, script);
const destPath = path.join(hooksDir, script);
if (fs.existsSync(srcPath)) {
let content = fs.readFileSync(srcPath, 'utf8');
content = content.replace(/gsd:/gi, 'gsd-');
fs.writeFileSync(destPath, content);
try { fs.chmodSync(destPath, 0o755); } catch { /* Windows: ignore chmod */ }
installedScripts.add(script);
}
}
const hookOpts: BuildHookCommandOpts = { runtime: 'windsurf', platform: opts.platform || process.platform };
const commands: Record<string, string | null> = {};
for (const ev of WINDSURF_HOOK_EVENTS) {
const script = WINDSURF_EVENT_SCRIPT_MAP[ev];
commands[ev] = (script && installedScripts.has(script)) ? buildHookCommand(targetDir, script, hookOpts) : null;
}
const managedEntries: WindsurfManagedEntries = {};
for (const ev of WINDSURF_HOOK_EVENTS) {
const cmd = commands[ev];
if (cmd) managedEntries[ev] = buildWindsurfHookEntry(cmd);
}
const hooksJsonPath = path.join(targetDir, 'hooks.json');
const result = reconcileWindsurfHooksJson(hooksJsonPath, managedEntries);
return { hooksJsonPath, changed: result.changed };
}
/**
* Remove all GSD-managed Cascade hook entries from hooks.json. User-owned
* entries are preserved. If the file becomes empty, it is removed.
*
* @param targetDir - The Windsurf config dir
* @returns `{ changed }`
*/
function removeWindsurfHooksJson(targetDir: string): { changed: boolean } {
const hooksJsonPath = path.join(targetDir, 'hooks.json');
if (!fs.existsSync(hooksJsonPath)) return { changed: false };
const result = reconcileWindsurfHooksJson(hooksJsonPath, null);
if (result.changed) {
try {
const contentRaw = fs.readFileSync(hooksJsonPath, 'utf8');
const parsed = JSON.parse(contentRaw) as Record<string, unknown>;
const hookTable = (parsed['hooks'] && typeof parsed['hooks'] === 'object' && !Array.isArray(parsed['hooks']))
? (parsed['hooks'] as Record<string, unknown>)
: {};
const hasAnyEvents = Object.keys(hookTable).some(
(k) => Array.isArray(hookTable[k]) && (hookTable[k] as unknown[]).length > 0,
);
if (!hasAnyEvents) {
fs.unlinkSync(hooksJsonPath);
return { changed: true };
}
} catch { /* best-effort: leave the file */ }
}
return { changed: result.changed };
}
// ---------------------------------------------------------------------------
// Copilot hook functions
// ---------------------------------------------------------------------------
@@ -2065,6 +2274,19 @@ export = {
GSD_CURSOR_SUBAGENT_STOP_HOOK_SCRIPT,
GSD_CURSOR_HOOK_MARKER,
// Windsurf/Cascade
buildWindsurfHookEntry,
isManagedWindsurfHookEntry,
reconcileWindsurfHooksJson,
writeWindsurfHooksJson,
removeWindsurfHooksJson,
WINDSURF_HOOK_EVENTS,
WINDSURF_EVENT_SCRIPT_MAP,
GSD_WINDSURF_PRE_WRITE_HOOK_SCRIPT,
GSD_WINDSURF_PRE_COMMAND_HOOK_SCRIPT,
GSD_WINDSURF_HOOK_SCRIPTS,
GSD_WINDSURF_HOOK_MARKER,
// Copilot
buildCopilotHookConfig,
writeCopilotHookConfig,