* fix(#2931): preserve protected regions and cap emitted per-runtime bytes Route every runtime brand swap through applyClaudeCodeBrandSwap so "Claude Code" survives verbatim inside <runtime_compatibility> regions (#2284b). The fix existed only in bin/install.js's local copies; the src/*.cts exports still used a naive replace, so binding install.js to the single source -- as this phase does for the Windsurf family -- would have silently regressed those runtimes. A table-driven parity guard now covers all nine brand-swapping converters. De-duplicate the Windsurf converter family: delete the six local copies in bin/install.js and bind the four exported ones by reference, guarded by reference-identity assertions (the ADR-1508/#1675 pattern). The two unexported helpers and an unused tool table go with them. Replace the Windsurf 12,000-byte throw with description truncation, matching the bound its sibling skill converter already applied. The throw could only fire on an ~11.7 KB frontmatter description: the largest emitted workflow is 311 bytes. Truncation makes the cap unreachable by construction and leaves 12,000 in exactly one place, eliminating the dual-surface duplication rather than testing for it. Add the emitted-byte cap gate: buildEmittedSizes captures LF- and <HOME>-normalized bytes from the walk buildParityManifest already performs, and evaluateEmittedCaps asserts them against a per-runtime cap table with dead-rule detection. buildParityManifest's return shape is deliberately unchanged -- diffEmitted compares its values with ===, so making them objects would report all 8,529 emitted paths as moved. A regression test pins the values as strings. Add a deterministic trim-safety gate over composeWithinBudget's omitted/shrunk/floored/isolatePrefix metadata, with an anti-vacuity rule, replacing the model-graded eval gate the issue described. * docs(#2931): correct ADR-1671 windsurf premise and trim-safety contract * fix(#2931): bound the windsurf command name and single-source the brand swap Review findings from the orthogonal passes, all fixed inline. The claim that removing the 12,000-byte throw left total emission "bounded by construction" was false. The #1615 regex constrains the character class but not the length, and commandName is interpolated three times into the emitted workflow: a 20,000-character name emitted 60,162 bytes silently. Add WINDSURF_COMMAND_NAME_MAX=128 as a separate, clearly-labelled size control that THROWS -- commandName is the @-ref path target, so truncating it would point the workflow at a file that does not exist (DEFECT.WORKFLOW-DELEGATION-TARGET-NOT-INSTALLED). The #1615 security regex is untouched and still runs first. 128 is generous: the longest shipped name is gsd-plan-review-convergence at 27. Harmonize convertClaudeCommandToWindsurfSkill onto the code-point-safe truncation helper. It still used a UTF-16 slice(0,177) -- the exact surrogate-splitting bug the helper was written to avoid, in the very sibling the helper's comment cites as its model. Bounds are unchanged, so output is byte-identical for every shipped command (descriptions max out at 99 chars). Export applyClaudeCodeBrandSwap and bind it in bin/install.js, deleting the local copy. Adding it to the .cts left two unlinked implementations of identical logic -- the drift class this change exists to remove. Verified byte-identical across eight fixtures and five sequential calls before merging, and guarded by a reference-identity assertion. Convert three try/finally test bodies to t.after (CONTRIBUTING.md:344), add fast-check property coverage for the trim-safety contract, and use fc.pre instead of a bare return in a property callback. * test(#2931): fix three test-authoring bugs the remote matrix caught The remote runner returned 8 unique failures on 6f15cdeb8. All three causes were in the test files, not the modules under test -- local harnesses exercise the modules directly, so nothing executed the test bodies until the matrix did. `{ __proto__: [...] }` in an object literal sets the prototype instead of an own key, so the JSON round-trip erased it and the cap table never saw a reserved runtime key. The production rejection was already correct; the test could not reach it. Use a computed key. Two cap fixtures tripped orthogonal error paths rather than the paths they name: one declared windsurf in the cap table but omitted it from sizes (UNKNOWN_RUNTIME), the other left the sole windsurf pattern matching nothing (a genuine dead rule). Both now include a compliant artifact so the intended branch is what is asserted. The dead-rule and unknown-runtime contracts are deliberate and unchanged. `const { root } = makeSyntheticConfig({ ... `${root}` })` referenced `root` from inside its own initializer -- a temporal dead zone error. makeSyntheticConfig now optionally takes a (root) => files factory. Also raise the npm pack --dry-run bound 60s -> 120s in the shipped- scripts packaging test. That failure is NOT from this branch: the file is byte-identical to next, a fresh tsc measures 1.98s there vs 2.14s here, and the run recorded 60,637ms against a 60,000ms bound -- a timeout under 28,948-test parallel contention, not a slowdown. Fixed rather than deferred because a bound that tight is fragile regardless of which branch trips it. * chore(#2931): backfill changeset pr number to 2984 --------- Co-authored-by: sim <sim@local>
This commit is contained in:
221
bin/install.js
221
bin/install.js
@@ -955,6 +955,31 @@ const applyRuntimeContentRewritesForCommandsInPlace = runtimeArtifactConversion.
|
||||
const convertClaudeToAugmentMarkdown = runtimeArtifactConversion.convertClaudeToAugmentMarkdown;
|
||||
const convertClaudeCommandToAugmentSkill = runtimeArtifactConversion.convertClaudeCommandToAugmentSkill;
|
||||
const convertClaudeAgentToAugmentAgent = runtimeArtifactConversion.convertClaudeAgentToAugmentAgent;
|
||||
// #2931 (ADR-1508): the windsurf converter family is single-sourced in the
|
||||
// conversion module, same pattern as the #1675 Augment dedup above. install.js
|
||||
// re-binds (does not re-define) these so there is exactly one body — the
|
||||
// generative-drift hazard the dedup removes. The two private helpers
|
||||
// (getWindsurfSkillAdapterHeader, convertSlashCommandsToWindsurfSkillMentions)
|
||||
// live only in the conversion module now; they are no longer duplicated here.
|
||||
// The reference-identity parity guard lives in
|
||||
// tests/install-runtime-artifacts.test.cjs (single-owner reference-identity
|
||||
// guard describe block), not tests/enh-1511-rewrite-engine-relocation.test.cjs
|
||||
// as the Augment comment above stated — that reference was stale.
|
||||
// (All call sites are below this line → no TDZ hazard.)
|
||||
const convertClaudeToWindsurfMarkdown = runtimeArtifactConversion.convertClaudeToWindsurfMarkdown;
|
||||
const convertClaudeCommandToWindsurfSkill = runtimeArtifactConversion.convertClaudeCommandToWindsurfSkill;
|
||||
const convertClaudeCommandToWindsurfWorkflow = runtimeArtifactConversion.convertClaudeCommandToWindsurfWorkflow;
|
||||
const convertClaudeAgentToWindsurfAgent = runtimeArtifactConversion.convertClaudeAgentToWindsurfAgent;
|
||||
// #2931 (ADR-1508): single-sourced in the conversion module — was a second,
|
||||
// unlinked verbatim copy here (used by the local Cursor/Trae/CodeBuddy/Cline
|
||||
// converters below), the exact drift class this PR exists to reduce. Verified
|
||||
// behaviorally identical (no block / one block / adjacent blocks / whole-
|
||||
// content block / unclosed opening tag / nested-looking tags / repeated
|
||||
// sequential calls for global-regex lastIndex leakage) before merging.
|
||||
// install.js re-binds (does not re-define) — RUNTIME_COMPATIBILITY_BLOCK_RE
|
||||
// is no longer duplicated here either. (All call sites are below this line
|
||||
// → no TDZ hazard.)
|
||||
const applyClaudeCodeBrandSwap = runtimeArtifactConversion.applyClaudeCodeBrandSwap;
|
||||
|
||||
function rewriteLegacyManagedNodeHookCommands(settings, absoluteRunner, opts) {
|
||||
return hooksSurface.rewriteLegacyManagedNodeHookCommands(settings, absoluteRunner, opts);
|
||||
@@ -2504,56 +2529,6 @@ function extractFrontmatterField(frontmatter, fieldName) {
|
||||
return match[1].trim().replace(/^['"]|['"]$/g, '');
|
||||
}
|
||||
|
||||
// #2284 finding (b): the `<runtime_compatibility>` block appearing in
|
||||
// gsd-core/workflows/{plan-phase,execute-phase}.md is a runtime-COMPARISON
|
||||
// table ("**Claude Code:** Uses `Agent(...)`" / "a backgrounded Claude Code
|
||||
// agent" / "top-level Claude Code") — every "Claude Code" mention inside it
|
||||
// is a COMPARED-RUNTIME LABEL, not a host self-reference. The brand swap
|
||||
// below (`Claude Code` → the installing runtime's own display name) is
|
||||
// meant only for host self-references; applying it inside this block
|
||||
// mislabels the comparison (e.g. Windsurf installs would read "**Windsurf:**
|
||||
// Uses `Agent(...)`" describing what is actually Claude Code's behavior).
|
||||
// This is cross-cutting across every runtime that brand-swaps workflow
|
||||
// content (cursor/windsurf/trae/cline/codebuddy hardcoded; qwen/hermes
|
||||
// descriptor-driven via hostBehaviors.brandingRewrites) — confirmed to
|
||||
// reproduce on unmodified Windsurf, not Hermes-specific.
|
||||
const RUNTIME_COMPATIBILITY_BLOCK_RE = /<runtime_compatibility>[\s\S]*?<\/runtime_compatibility>/g;
|
||||
|
||||
/**
|
||||
* Rewrite bare "Claude Code" self-references in workflow content to
|
||||
* `brandName`, EXCEPT inside `<runtime_compatibility>...</runtime_compatibility>`
|
||||
* blocks, which are left byte-for-byte verbatim. Every other content
|
||||
* transform in a runtime's `.md` converter (tool-name renames, path
|
||||
* rewrites, etc.) is unaffected — only this literal brand-name swap is
|
||||
* protected-region-aware, since only it risks mislabeling a
|
||||
* runtime-comparison table.
|
||||
*
|
||||
* Implementation: SPLIT `content` on the protected-block regex, brand-swap
|
||||
* only the GAP text between (and around) matches, then rejoin gap+block
|
||||
* alternately. No placeholder/sentinel token of any kind is substituted in
|
||||
* — a prior version used a sentinel-token mask/restore, which is exactly the
|
||||
* kind of invisible landmine this rewrite eliminates (a sentinel string, no
|
||||
* matter how obscure, is a theoretical collision risk with real content and
|
||||
* is easy to silently reintroduce in a future edit without it showing in a
|
||||
* diff). Behavior-identical to the removed sentinel-token version — verified
|
||||
* via `npm run gen:golden` producing zero further diff.
|
||||
*/
|
||||
function applyClaudeCodeBrandSwap(content, brandName) {
|
||||
if (!brandName) return content;
|
||||
let result = '';
|
||||
let lastIndex = 0;
|
||||
RUNTIME_COMPATIBILITY_BLOCK_RE.lastIndex = 0; // reset shared global-regex state before each use
|
||||
let m;
|
||||
while ((m = RUNTIME_COMPATIBILITY_BLOCK_RE.exec(content))) {
|
||||
const gap = content.slice(lastIndex, m.index);
|
||||
result += gap.replace(/\bClaude Code\b/g, brandName);
|
||||
result += m[0]; // protected block, verbatim — never brand-swapped
|
||||
lastIndex = m.index + m[0].length;
|
||||
}
|
||||
result += content.slice(lastIndex).replace(/\bClaude Code\b/g, brandName);
|
||||
return result;
|
||||
}
|
||||
|
||||
// Tool name mapping from Claude Code to Cursor CLI
|
||||
const claudeToCursorTools = {
|
||||
Bash: 'Shell',
|
||||
@@ -2684,142 +2659,16 @@ function convertClaudeAgentToCursorAgent(content) {
|
||||
}
|
||||
|
||||
// --- Windsurf converters ---
|
||||
// Windsurf uses a tool set similar to Cursor.
|
||||
// Config lives in .windsurf/ (local) and ~/.codeium/windsurf/ (global).
|
||||
|
||||
// Tool name mapping from Claude Code to Windsurf Cascade
|
||||
const claudeToWindsurfTools = {
|
||||
Bash: 'Shell',
|
||||
Edit: 'StrReplace',
|
||||
AskUserQuestion: null, // No direct equivalent — use conversational prompting
|
||||
SlashCommand: null, // No equivalent — skills are auto-discovered
|
||||
};
|
||||
|
||||
function convertSlashCommandsToWindsurfSkillMentions(content) {
|
||||
// Keep leading "/" for slash commands; only normalize gsd: -> gsd-.
|
||||
return content.replace(/gsd:/gi, 'gsd-');
|
||||
}
|
||||
|
||||
function convertClaudeToWindsurfMarkdown(content) {
|
||||
let converted = convertSlashCommandsToWindsurfSkillMentions(content);
|
||||
// Replace tool name references in body text
|
||||
converted = converted.replace(/\bBash\(/g, 'Shell(');
|
||||
converted = converted.replace(/\bEdit\(/g, 'StrReplace(');
|
||||
converted = converted.replace(/\bAskUserQuestion\b/g, 'conversational prompting');
|
||||
// Replace subagent_type from Claude to Windsurf format
|
||||
converted = converted.replace(/subagent_type="general-purpose"/g, 'subagent_type="generalPurpose"');
|
||||
converted = converted.replace(/\$ARGUMENTS\b/g, '{{GSD_ARGS}}');
|
||||
// Replace project-level Claude conventions with Windsurf equivalents.
|
||||
converted = converted.replace(/`\.\/CLAUDE\.md`/g, '`.windsurf/rules`');
|
||||
converted = converted.replace(/\.\/CLAUDE\.md/g, '.windsurf/rules');
|
||||
converted = converted.replace(/`CLAUDE\.md`/g, '`.windsurf/rules`');
|
||||
converted = converted.replace(/\bCLAUDE\.md\b/g, '.windsurf/rules');
|
||||
converted = converted.replace(/\.claude\/skills\//g, '.windsurf/skills/');
|
||||
converted = converted.replace(/\.\/\.claude\//g, './.windsurf/');
|
||||
converted = converted.replace(/\.claude\//g, '.windsurf/');
|
||||
// Bare forms (no trailing slash) — after slash forms to avoid double-rewrite.
|
||||
// Use negative lookahead (?![\w-]) to preserve .claude-plugin and .claudeignore.
|
||||
converted = converted.replace(/~\/\.claude(?![\w-])/g, '~/.windsurf');
|
||||
converted = converted.replace(/\$HOME\/\.claude(?![\w-])/g, '$HOME/.windsurf');
|
||||
// Environment variable name rewrite
|
||||
converted = converted.replace(/\bCLAUDE_CONFIG_DIR\b/g, 'WINDSURF_CONFIG_DIR');
|
||||
// Remove Claude Code-specific bug workarounds before brand replacement
|
||||
converted = converted.replace(/\*\*Known Claude Code bug \(classifyHandoffIfNeeded\):\*\*[^\n]*\n/g, '');
|
||||
converted = converted.replace(/- \*\*classifyHandoffIfNeeded false failure:\*\*[^\n]*\n/g, '');
|
||||
// Replace "Claude Code" brand references with "Windsurf" — #2284(b): skips
|
||||
// <runtime_compatibility> comparison-table content (protected region).
|
||||
converted = applyClaudeCodeBrandSwap(converted, 'Windsurf');
|
||||
return converted;
|
||||
}
|
||||
|
||||
function getWindsurfSkillAdapterHeader(skillName) {
|
||||
return `<windsurf_skill_adapter>
|
||||
## A. Skill Invocation
|
||||
- This skill is invoked when the user mentions \`${skillName}\` or describes a task matching this skill.
|
||||
- Treat all user text after the skill mention as \`{{GSD_ARGS}}\`.
|
||||
- If no arguments are present, treat \`{{GSD_ARGS}}\` as empty.
|
||||
|
||||
## B. User Prompting
|
||||
When the workflow needs user input, prompt the user conversationally:
|
||||
- Present options as a numbered list in your response text
|
||||
- Ask the user to reply with their choice
|
||||
- For multi-select, ask for comma-separated numbers
|
||||
|
||||
## C. Tool Usage
|
||||
Use these Windsurf tools when executing GSD workflows:
|
||||
- \`Shell\` for running commands (terminal operations)
|
||||
- \`StrReplace\` for editing existing files
|
||||
- \`Read\`, \`Write\`, \`Glob\`, \`Grep\`, \`Task\`, \`WebSearch\`, \`WebFetch\`, \`TodoWrite\` as needed
|
||||
|
||||
## D. Subagent Spawning
|
||||
When the workflow needs to spawn a subagent:
|
||||
- Use \`Task(subagent_type="generalPurpose", ...)\`
|
||||
- The \`model\` parameter maps to Windsurf's model options (e.g., "fast")
|
||||
</windsurf_skill_adapter>`;
|
||||
}
|
||||
|
||||
function convertClaudeCommandToWindsurfSkill(content, skillName) {
|
||||
const converted = convertClaudeToWindsurfMarkdown(content);
|
||||
const { frontmatter, body } = extractFrontmatterAndBody(converted);
|
||||
let description = `Run GSD workflow ${skillName}.`;
|
||||
if (frontmatter) {
|
||||
const maybeDescription = extractFrontmatterField(frontmatter, 'description');
|
||||
if (maybeDescription) {
|
||||
description = maybeDescription;
|
||||
}
|
||||
}
|
||||
description = toSingleLine(description);
|
||||
const shortDescription = description.length > 180 ? `${description.slice(0, 177)}...` : description;
|
||||
const adapter = getWindsurfSkillAdapterHeader(skillName);
|
||||
|
||||
return `---\nname: ${yamlIdentifier(skillName)}\ndescription: ${yamlQuote(shortDescription)}\n---\n\n${adapter}\n\n${body.trimStart()}`;
|
||||
}
|
||||
|
||||
function convertClaudeCommandToWindsurfWorkflow(content, commandName) {
|
||||
// #1615 security: commandName flows unsanitized into a markdown body that
|
||||
// Windsurf loads as an LLM-readable workflow. Validate at entry to prevent
|
||||
// (a) prompt injection via newlines / markdown structure in the filename,
|
||||
// (b) path-component injection via .., /, \ in stem → @-reference target.
|
||||
// Pattern: optional gsd- prefix + lowercase alphanumeric + dashes; rejects
|
||||
// everything else. See DEFECT.PROMPT-INJECTION-SCAN-COLLISION and the
|
||||
// PR #1622 security review.
|
||||
if (typeof commandName !== 'string' || !/^(?:gsd-)?[a-z0-9](?:[a-z0-9-]*[a-z0-9])?$/.test(commandName)) {
|
||||
const preview = typeof commandName === 'string' ? JSON.stringify(commandName.slice(0, 60)) : String(commandName);
|
||||
throw new Error(
|
||||
`convertClaudeCommandToWindsurfWorkflow: rejected commandName ${preview}; ` +
|
||||
'must match /^(?:gsd-)?[a-z0-9](?:[a-z0-9-]*[a-z0-9])?$/ (no slashes, backslashes, spaces, dots, trailing dash, or control chars — prevents prompt injection and path-component injection into the workflow body)'
|
||||
);
|
||||
}
|
||||
const converted = convertClaudeToWindsurfMarkdown(content);
|
||||
const { frontmatter } = extractFrontmatterAndBody(converted);
|
||||
const description = frontmatter ? extractFrontmatterField(frontmatter, 'description') : '';
|
||||
const stem = commandName.startsWith('gsd-') ? commandName.slice(4) : commandName;
|
||||
const workflow = `# ${commandName}\n\n${toSingleLine(description || `Run ${commandName}.`)}\n\nRead and execute the GSD command at @~/.claude/gsd-core/commands/gsd/${stem}.md end-to-end. Treat the user's message after /${commandName} as the command arguments.`;
|
||||
const byteLength = Buffer.byteLength(workflow, 'utf8');
|
||||
if (byteLength > 12000) {
|
||||
throw new Error(`Windsurf workflow ${commandName} exceeds 12000 bytes (${byteLength}); extract references before installing`);
|
||||
}
|
||||
return workflow;
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert Claude Code agent markdown to Windsurf agent format.
|
||||
* Strips frontmatter fields Windsurf doesn't support (color, skills),
|
||||
* converts tool references, and adds a role context header.
|
||||
*/
|
||||
function convertClaudeAgentToWindsurfAgent(content) {
|
||||
let converted = convertClaudeToWindsurfMarkdown(content);
|
||||
|
||||
const { frontmatter, body } = extractFrontmatterAndBody(converted);
|
||||
if (!frontmatter) return converted;
|
||||
|
||||
const name = extractFrontmatterField(frontmatter, 'name') || 'unknown';
|
||||
const description = extractFrontmatterField(frontmatter, 'description') || '';
|
||||
|
||||
const cleanFrontmatter = `---\nname: ${yamlIdentifier(name)}\ndescription: ${yamlQuote(toSingleLine(description))}\n---`;
|
||||
|
||||
return `${cleanFrontmatter}\n${body}`;
|
||||
}
|
||||
// #2931 (ADR-1508): single-sourced in runtimeArtifactConversion, bound near
|
||||
// the top of this file alongside the #1675 Augment family. This block
|
||||
// previously carried byte-identical local duplicates of
|
||||
// convertSlashCommandsToWindsurfSkillMentions, convertClaudeToWindsurfMarkdown,
|
||||
// getWindsurfSkillAdapterHeader, convertClaudeCommandToWindsurfSkill,
|
||||
// convertClaudeCommandToWindsurfWorkflow, and convertClaudeAgentToWindsurfAgent,
|
||||
// plus an unused claudeToWindsurfTools table. Deleted here; the two
|
||||
// unexported helpers (getWindsurfSkillAdapterHeader,
|
||||
// convertSlashCommandsToWindsurfSkillMentions) now live only in the
|
||||
// conversion module, with no other caller in this file.
|
||||
|
||||
// --- Augment converters ---
|
||||
// Augment uses a tool set similar to Cursor/Windsurf.
|
||||
|
||||
Reference in New Issue
Block a user