diff --git a/.changeset/kind-tigers-dart.md b/.changeset/kind-tigers-dart.md new file mode 100644 index 000000000..88eab7510 --- /dev/null +++ b/.changeset/kind-tigers-dart.md @@ -0,0 +1,5 @@ +--- +type: Fixed +pr: 2309 +--- +**Runtime brand-swap no longer mislabels `` comparison tables** — every runtime installer that rebrands "Claude Code" to its own name (Cursor, Windsurf, Trae, Cline, CodeBuddy, Qwen, Hermes) also swapped it inside the runtime-comparison tables in shipped workflows, where "Claude Code" is a compared-runtime label, not a host self-reference — corrupting the comparison. Branding now protects `` regions while still rebranding genuine self-references. (#2284) diff --git a/.changeset/sturdy-lemurs-forage.md b/.changeset/sturdy-lemurs-forage.md new file mode 100644 index 000000000..9a70a6269 --- /dev/null +++ b/.changeset/sturdy-lemurs-forage.md @@ -0,0 +1,5 @@ +--- +type: Fixed +pr: 2309 +--- +**Hermes installs now project named-agent dispatch onto `delegate_task` instead of asserting a nonexistent `Agent` tool** — installed Hermes workflows brand-swapped "Claude Code"→"Hermes Agent" but kept literal `Agent(...)` calls and falsely claimed "The Agent tool IS available", which Hermes doesn't expose. A Hermes `.md` converter now rewrites named dispatch onto Hermes's `delegate_task` contract (embedding the resolved role prompt since Hermes has no named-agent lookup, mapping background dispatch, dropping unsupported per-call model), driven by the runtime's documented dispatch facts, and fails closed if a referenced role prompt is missing. (#2284) diff --git a/bin/install.js b/bin/install.js index cb5b267ec..479bea555 100755 --- a/bin/install.js +++ b/bin/install.js @@ -464,6 +464,25 @@ function _hostBehaviors(runtime) { return _resolveHostBehaviors(runtime, _capabilityRegistry); } +/** + * Read a runtime's documentation-sourced `hostIntegration.dispatch` axes + * (ADR-1239 Phase A — `capabilities//capability.json` + * `runtime.hostIntegration.dispatch`): `{namedDispatch, nested, maxDepth, + * background, backgroundDispatch, subagentToolkit}`. These are validated, + * closed-vocabulary FACTS about what the runtime's real dispatch primitive + * supports (never inferred) — see `docs/reference/host-integration-capability- + * matrix.md` for citations. Unlike `_hostBehaviors` (install *policy*), this is + * the negotiated *capability* surface; #2284 is its first content-projection + * consumer (previously read only by `shouldFlattenDispatch`). Returns `{}` if + * the registry or the runtime's descriptor is unavailable, so callers must + * treat every axis as absent/unknown (fail-closed) rather than assume a value. + */ +function _hostIntegrationDispatch(runtime) { + const cap = _capabilityRegistry && _capabilityRegistry.runtimes && _capabilityRegistry.runtimes[runtime]; + const dispatch = cap && cap.runtime && cap.runtime.hostIntegration && cap.runtime.hostIntegration.dispatch; + return dispatch || {}; +} + /** * Resolve the ACTUAL on-disk skills-install directory for a runtime, honoring a * skills-kind `home` override (ADR-1239 upgrade 3 / #2088: e.g. Codex skills -> @@ -2391,6 +2410,56 @@ function extractFrontmatterField(frontmatter, fieldName) { return match[1].trim().replace(/^['"]|['"]$/g, ''); } +// #2284 finding (b): the `` 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 = /[\s\S]*?<\/runtime_compatibility>/g; + +/** + * Rewrite bare "Claude Code" self-references in workflow content to + * `brandName`, EXCEPT inside `...` + * 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', @@ -2423,8 +2492,9 @@ function convertClaudeToCursorMarkdown(content) { // 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 "Cursor" - converted = converted.replace(/\bClaude Code\b/g, 'Cursor'); + // Replace "Claude Code" brand references with "Cursor" — #2284(b): skips + // comparison-table content (protected region). + converted = applyClaudeCodeBrandSwap(converted, 'Cursor'); return converted; } @@ -2556,8 +2626,9 @@ function convertClaudeToWindsurfMarkdown(content) { // 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" - converted = converted.replace(/\bClaude Code\b/g, 'Windsurf'); + // Replace "Claude Code" brand references with "Windsurf" — #2284(b): skips + // comparison-table content (protected region). + converted = applyClaudeCodeBrandSwap(converted, 'Windsurf'); return converted; } @@ -2691,7 +2762,8 @@ function convertClaudeToTraeMarkdown(content) { converted = converted.replace(/\bCLAUDE_CONFIG_DIR\b/g, 'TRAE_CONFIG_DIR'); converted = converted.replace(/\*\*Known Claude Code bug \(classifyHandoffIfNeeded\):\*\*[^\n]*\n/g, ''); converted = converted.replace(/- \*\*classifyHandoffIfNeeded false failure:\*\*[^\n]*\n/g, ''); - converted = converted.replace(/\bClaude Code\b/g, 'Trae'); + // #2284(b): skips comparison-table content (protected region). + converted = applyClaudeCodeBrandSwap(converted, 'Trae'); return converted; } @@ -2763,7 +2835,8 @@ function convertClaudeToCodebuddyMarkdown(content) { converted = converted.replace(/\.claude\//g, '.codebuddy/'); converted = converted.replace(/\*\*Known Claude Code bug \(classifyHandoffIfNeeded\):\*\*[^\n]*\n/g, ''); converted = converted.replace(/- \*\*classifyHandoffIfNeeded false failure:\*\*[^\n]*\n/g, ''); - converted = converted.replace(/\bClaude Code\b/g, 'CodeBuddy'); + // #2284(b): skips comparison-table content (protected region). + converted = applyClaudeCodeBrandSwap(converted, 'CodeBuddy'); return converted; } @@ -2858,7 +2931,8 @@ function convertClaudeToCliineMarkdown(content) { converted = converted.replace(/\bCLAUDE_CONFIG_DIR\b/g, 'CLINE_CONFIG_DIR'); converted = converted.replace(/\*\*Known Claude Code bug \(classifyHandoffIfNeeded\):\*\*[^\n]*\n/g, ''); converted = converted.replace(/- \*\*classifyHandoffIfNeeded false failure:\*\*[^\n]*\n/g, ''); - converted = converted.replace(/\bClaude Code\b/g, 'Cline'); + // #2284(b): skips comparison-table content (protected region). + converted = applyClaudeCodeBrandSwap(converted, 'Cline'); return converted; } @@ -2911,6 +2985,825 @@ function convertClaudeCommandToClineSkill(content, skillName, runtime = null, cm // ── End Cline converters ───────────────────────────────────────────────────── +// ── Hermes converters (#2284) ──────────────────────────────────────────────── +// +// Hermes exposes `delegate_task` for subagent dispatch, not the Claude-shaped +// `Agent(...)` tool the host-neutral `gsd-core/workflows/*.md` corpus assumes. +// Prior to this fix, the hermes `.md` hook (RUNTIME_CONTENT_DISPATCH.hermes) +// only brand-swapped "Claude Code" → "Hermes Agent" via +// hostBehaviors.brandingRewrites, leaving the false "Agent tool IS available" +// assertion and literal `Agent(...)` call syntax installed verbatim. +// +// `projectNamedDispatchToStructuralDelegate` is GENERIC projection machinery: +// it branches ENTIRELY on the runtime's documentation-sourced +// `hostIntegration.dispatch` facts (read via `_hostIntegrationDispatch`, +// capabilities//capability.json — never hardcoded here) and a +// `toolConfig` that supplies only the target primitive's own vocabulary (its +// call name + native parameter names — not a capability claim; there is no +// `dispatch` axis for "the call's own parameter names", so that vocabulary is +// necessarily supplied by the caller, exactly as every other runtime's +// converter supplies its own tool-name vocabulary, e.g. Trae's `Shell(`). +// +// Hermes-specific facts consumed (capabilities/hermes/capability.json, +// docs/reference/host-integration-capability-matrix.md:244-249 — UNCHANGED by +// this fix): +// - dispatch.namedDispatch: false — Hermes's delegate_task has no named- +// agent lookup ("Subagents are identified only by role ('leaf' or +// 'orchestrator')"). GSD resolves the referenced gsd-* role itself +// (fail-closed against the staged agents/ dir) and embeds the loaded +// PROMPT CONTENT into the delegate_task payload. +// - dispatch.background: true — `delegate_task(background=true)` "returns a +// handle immediately"; Claude's `run_in_background=` maps onto Hermes's +// own `background=` parameter, preserving the async-handle / no-busy-poll +// / resume-on-completion wording already used throughout these workflows. +// - dispatch.subagentToolkit: "read-only" / dispatch.maxDepth: 1 — dispatched +// roles never themselves further delegate, so no nested-delegation +// instruction is ever emitted toward them. + +/** + * Resolve the set of gsd-* role-prompt stems actually shipped in this + * package's `agents/` directory (the FULL source set, not profile-staged — + * `--minimal`/`--profile=core` intentionally excludes many agents from a + * given install without those workflows being unreachable, so validating + * against the profile-filtered subset would fail every restricted-profile + * Hermes install; validating against the shipped source catches genuine + * authoring bugs — a stale/typo'd role reference — without that regression). + * Returns `null` if the directory cannot be resolved (fail-closed: callers + * must refuse to install rather than skip validation). + */ +function _resolveAvailableGsdRoles() { + try { + const agentsDir = path.join(__dirname, '..', 'agents'); + return new Set( + fs.readdirSync(agentsDir, { withFileTypes: true }) + .filter((e) => e.isFile() && e.name.endsWith('.md')) + .map((e) => e.name.slice(0, -3)), + ); + } catch (_e) { + return null; + } +} + +/** + * Fail-closed validation (#2284 AC: "Missing role prompts fail closed" / + * "never emit a workflow referencing an unresolvable role"). A single literal + * `gsd-*` role value must resolve to a real `agents/.md` file — throws + * an explicit Error otherwise, aborting the install (the standard + * `copyWithPathReplacement` failure path already used for its own + * confinement-violation throws). Called per extracted role value from EVERY + * call-syntax form (`subagent_type=`, `subagent_type:`, post-rename + * `gsd_role=`) — the check operates on the resolved value, independent of + * which source syntax produced it. Non-literal / dynamic expressions (e.g. + * `research_hook.ref.agent`) are not quoted strings and are never passed + * here; they carry their own runtime resolution + fail-closed instruction via + * the injected per-call resolution line. + */ +function _assertRoleResolvable(role, availableRoles, runtime, sourceDescription) { + if (!availableRoles) { + throw new Error( + `${runtime} workflow install: could not resolve the shipped agents/ directory to validate named-role ` + + 'dispatch references — refusing to install (fail-closed, #2284)', + ); + } + if (role.startsWith('gsd-') && !availableRoles.has(role)) { + throw new Error( + `${runtime} workflow install: dispatch references role "${role}" via ${sourceDescription}, but no ` + + `matching agents/${role}.md prompt file is shipped — refusing to install a workflow that dispatches ` + + 'an unresolvable role (fail-closed, #2284)', + ); + } +} + +/** + * Segment `text` into 'code' and 'string' runs (recognizes `"..."`, `'...'`, + * and Python-style `"""..."""`, with backslash-escaping). Required because + * the real corpus embeds unescaped parens inside quoted prompt bodies (e.g. + * discuss-phase-assumptions.md's `(e.g., "Technical Approach")` inside a + * `"""`-quoted prompt) — naive paren/keyword scanning across raw text would + * desync on these. Downstream call-span detection and header-token + * extraction operate on a same-length MASK derived from this segmentation + * (see `maskStringLiterals`) so string content can never be mistaken for + * call structure. + */ +function _segmentCodeAndStrings(text) { + const segments = []; + let i = 0; + let segStart = 0; + const flushCode = (end) => { if (end > segStart) segments.push({ type: 'code', start: segStart, end }); }; + while (i < text.length) { + const ch = text[i]; + if (ch === '"' && text[i + 1] === '"' && text[i + 2] === '"') { + flushCode(i); + const strStart = i; + i += 3; + while (i < text.length && !(text[i] === '"' && text[i + 1] === '"' && text[i + 2] === '"')) { + i += text[i] === '\\' ? 2 : 1; + } + i = Math.min(i + 3, text.length); + segments.push({ type: 'string', start: strStart, end: i, quoteLen: 3 }); + segStart = i; + continue; + } + // Only `"` is recognized as a single-char string delimiter — NOT `'`. + // The corpus is markdown prose, not code: apostrophes are routine English + // contractions/possessives ("install's", "don't") and treating them as + // string delimiters would swallow everything up to the next unrelated + // apostrophe as "inside a string" (verified against the real corpus — + // this was a real, disqualifying bug during development of this fix). + // Every real call-argument value in the corpus uses `"`/`"""` only. + if (ch === '"') { + flushCode(i); + const strStart = i; + i += 1; + while (i < text.length && text[i] !== '"') { + i += text[i] === '\\' ? 2 : 1; + } + i = Math.min(i + 1, text.length); + segments.push({ type: 'string', start: strStart, end: i, quoteLen: 1 }); + segStart = i; + continue; + } + i += 1; + } + flushCode(text.length); + return segments; +} + +/** + * Same-length mask of `text` with the INTERIOR of every string literal + * replaced by a space (newlines preserved, so line-based regexes still work). + * The delimiting quote character(s) themselves (`"`, `'`, `"""`) are kept + * verbatim so a value-extraction regex like `key\s*[=:]\s*"[^"]*"` still + * matches correctly against the mask — only the STRING CONTENT is blanked, + * never the quote structure. Positions in the mask line up 1:1 with `text`, + * so match indices/offsets found against the mask are valid offsets into the + * original. + */ +function maskStringLiterals(text) { + let mask = ''; + for (const seg of _segmentCodeAndStrings(text)) { + const slice = text.slice(seg.start, seg.end); + if (seg.type === 'code') { mask += slice; continue; } + const q = seg.quoteLen; + if (slice.length <= q) { mask += slice; continue; } // truncated/unterminated — keep verbatim + const closeLen = Math.min(q, slice.length - q); + const open = slice.slice(0, q); + const close = slice.slice(slice.length - closeLen); + const interiorLen = slice.length - q - closeLen; + const interior = interiorLen > 0 ? slice.slice(q, q + interiorLen) : ''; + mask += open + interior.replace(/[^\n]/g, ' ') + close; + } + return mask; +} + +/** + * Locate every `(` / `({` call span in `text`. + * + * #2284 round-2 CRITICAL fix: this MUST NOT rely on whole-document quote + * parity. A markdown workflow file mixes prose, ```bash code fences (full of + * their own double-quoted strings), and shell quoting — there is no single + * document-wide quote grammar, so a `"`-heavy bash `echo` upstream of a real + * call (e.g. code-review.md's fenced `echo "..."` block before its + * `Agent(subagent_type="gsd-code-reviewer", ...)` call) can desync a + * CUMULATIVE quote-state scan, making the scanner believe the real call's + * `Agent(` sits "inside a string" and silently skipping it entirely — the + * call then survives completely unnormalized. (Reproduced and root-caused + * against the real corpus.) + * + * Fixed shape: find each `(` occurrence via a PLAIN literal-text + * search (`indexOf`, immune to any prior document content), then run a + * balanced paren-matching scan whose quote-tracking state STARTS FRESH AT + * THE HEAD — local to this one call, never inherited from (or able to be + * corrupted by) anything earlier in the document. Handles all three real + * corpus shapes: multi-line one-key-per-line, single-line object-literal + * (`Agent({ ... })`), and single-line compact + * (`Agent(subagent_type="x", model="y", prompt="...")`) — including prompt + * bodies containing their own unescaped `()`/`{}` (skipped via the SAME + * span-local quote tracking, e.g. discuss-phase-assumptions.md's + * `"""`-quoted parenthetical prose). + * + * Returns `[{start, end, hasBraceWrapper}]` — `start`/`end` bound the FULL + * call INCLUDING the head word and the closing `)`/`})`. + */ +function findDispatchCallSpans(text, headWord) { + const spans = []; + const headToken = `${headWord}(`; + let searchFrom = 0; + for (;;) { + const start = text.indexOf(headToken, searchFrom); + if (start === -1) break; + const prevChar = start > 0 ? text[start - 1] : ''; + if (/[A-Za-z0-9_]/.test(prevChar)) { searchFrom = start + 1; continue; } // word-boundary guard + + let i = start + headToken.length; // just past the '(' + let j = i; + while (j < text.length && /\s/.test(text[j])) j++; + const hasBraceWrapper = text[j] === '{'; + + // LOCAL scan — quote/paren state is fresh here, never inherited from + // anything before `start` in the document. + let parenDepth = 1; + let inString = null; // null | '"' | 'triple' + let end = -1; + for (; i < text.length; i++) { + const ch = text[i]; + if (inString) { + if (ch === '\\') { i++; continue; } + if (inString === 'triple') { + if (ch === '"' && text[i + 1] === '"' && text[i + 2] === '"') { inString = null; i += 2; } + continue; + } + if (ch === inString) inString = null; + continue; + } + if (ch === '"' && text[i + 1] === '"' && text[i + 2] === '"') { inString = 'triple'; i += 2; continue; } + if (ch === '"') { inString = '"'; continue; } + if (ch === '(') { parenDepth++; continue; } + if (ch === ')') { + parenDepth--; + if (parenDepth === 0) { end = i + 1; break; } + continue; + } + } + if (end === -1) { searchFrom = start + 1; continue; } // unterminated — skip past, keep scanning + spans.push({ start, end, hasBraceWrapper }); + searchFrom = end; + } + return spans; +} + +/** + * Remove a call argument's `[matchStart, matchEnd)` token from `spanText`, + * consuming its surrounding comma/whitespace so no dangling `, ,` or trailing + * comment survives. When the argument owns its whole line, the whole line + * (including a trailing inline `# comment`) is removed; `consumeLeadingComments` + * additionally removes contiguous comment-only lines immediately ABOVE it — + * #2284 Finding 5: explanatory prose describing a now-removed conditional + * (e.g. execute-phase.md's "# Only include model= when ...") must not survive + * describing a branch that no longer exists. Inline (single-line-compact / + * object-literal) occurrences instead eat one adjacent comma. + */ +function _stripCallArgument(spanText, matchStart, matchEnd, { consumeLeadingComments = false } = {}) { + let end = matchEnd; + const afterRe = /^[ \t]*,?[ \t]*(#[^\n]*)?\r?\n?/; + const afterMatch = afterRe.exec(spanText.slice(end)); + const hadTrailingComma = !!(afterMatch && /,/.test(afterMatch[0])); + if (afterMatch) end += afterMatch[0].length; + + let start = matchStart; + const lineStart = spanText.lastIndexOf('\n', start - 1) + 1; + const ownLine = /^[ \t]*$/.test(spanText.slice(lineStart, start)); + if (ownLine) { + start = lineStart; + if (consumeLeadingComments) { + for (;;) { + const prevLineStart = start > 0 ? spanText.lastIndexOf('\n', start - 2) + 1 : 0; + const prevLine = spanText.slice(prevLineStart, start); + if (/^[ \t]*#[^\n]*\r?\n$/.test(prevLine)) { + start = prevLineStart; + if (prevLineStart === 0) break; + } else break; + } + } + } else if (!hadTrailingComma) { + // Inline form and this was the LAST arg (no trailing comma) — eat a + // leading comma so the previous arg doesn't dangle one. + const before = spanText.slice(0, start); + const cm = /,[ \t]*$/.exec(before); + if (cm) start -= cm[0].length; + } + return spanText.slice(0, start) + spanText.slice(end); +} + +/** + * Replace a named-role argument token's `[matchStart, matchEnd)` span + * (`subagent_type=`/`subagent_type:` + its value) with the projected + * `gsd_role=` / role-prompt-resolution / structural-role argument group. + * Preserves the pretty multi-line one-arg-per-line style when the original + * token owned its own line; falls back to an inline, comma-joined group for + * the single-line-compact and object-literal forms. + */ +function _projectRoleArgument(spanText, matchStart, matchEnd, roleValueExpr, toolConfig, canOrchestrate) { + const { namedRoleParam, promptContentParam, structuralRoleParam, leafRoleValue } = toolConfig; + const lineStart = spanText.lastIndexOf('\n', matchStart - 1) + 1; + const startsOwnLine = /^[ \t]*$/.test(spanText.slice(lineStart, matchStart)); + + // Consume an immediately-following separator comma (+ same-line whitespace/ + // newline) into `end` — never leave it dangling AFTER an injected trailing + // `# comment` (a bare `,` after `#...` would sit on the comment's own line, + // outside any real argument list). + let end = matchEnd; + const afterRe = /^[ \t]*,[ \t]*\r?\n?/; + const afterMatch = afterRe.exec(spanText.slice(end)); + const hadTrailingComma = !!afterMatch; + if (afterMatch) end += afterMatch[0].length; + const ownLine = startsOwnLine && hadTrailingComma && /\n$/.test(afterMatch[0]); + + const promptContentPhrase = + `${promptContentParam}='; + + let replacement; + if (ownLine) { + const indent = spanText.slice(lineStart, matchStart); + const depthNote = canOrchestrate ? '' : ' # nested delegation is unavailable at this dispatch depth/toolkit'; + replacement = + `${namedRoleParam}=${roleValueExpr},\n` + + `${indent}${promptContentPhrase},\n` + + `${indent}${structuralRoleParam}="${leafRoleValue}",${depthNote}\n`; + } else { + // Inline forms never carry a trailing `#` comment mid-argument-list (it + // would silently "comment out" the remainder of the call), so the + // depth/toolkit caveat is only ever emitted in the pretty own-line form. + // Re-emit exactly the separator that originally followed this argument + // (a comma if more args follow; nothing if it was the last one). + replacement = + `${namedRoleParam}=${roleValueExpr}, ${promptContentPhrase}, ${structuralRoleParam}="${leafRoleValue}"` + + (hadTrailingComma ? ', ' : ''); + } + return spanText.slice(0, matchStart) + replacement + spanText.slice(end); +} + +// Matches a `subagent_type`/`model` argument's key+delimiter+value across all +// three corpus forms: quoted-string values ("gsd-planner", "{model}") and +// bare dynamic-expression values (ref.agent, research_hook.ref.agent, +// executor_model). The captured group is always the value (a suffix of the +// whole match), so its start offset is `match.index + match[0].length - +// match[1].length` — avoids needing the regex `d` (indices) flag. +function _callArgValueRe(key) { + return new RegExp(`\\b${key}\\s*[=:]\\s*("(?:[^"\\\\]|\\\\.)*"|[A-Za-z_][\\w.]*)`); +} + +/** + * Returns the literal role name from a captured role-argument value EXPR + * (e.g. `"gsd-planner"`) — or `null` when it is not a genuine static + * literal: a bare dynamic expression (`ref.agent`), OR a quoted value that + * still contains `{...}` template interpolation (the corpus's own + * placeholder convention, e.g. `model="{researcher_model}"` — and, + * critically, `subagent_type: "gsd-{agent}"` in + * gsd-core/references/universal-anti-patterns.md, a DOCUMENTATION template + * illustrating the naming pattern, never a concrete role to resolve). + * Fail-closed validation only ever runs on a genuine static literal; a + * template/dynamic value still gets the full role-prompt-resolution + * projection treatment (the resolve+fail-closed instruction applies equally + * once a template is substituted at runtime) — only the STATIC CHECK is + * skipped, never the projection itself. + */ +function _literalRoleValue(roleValueExpr) { + const m = /^"([^"]*)"$/.exec(roleValueExpr); + if (!m) return null; + if (/[{}]/.test(m[1])) return null; + return m[1]; +} + +/** + * `maskStringLiterals` PLUS `#`-to-end-of-line comment blanking (comments are + * never string literals, so they survive string-masking as literal `#...` + * text). Header-token searches (subagent_type/model/run_in_background) must + * use THIS mask, not the string-only one — verified necessary against the + * real corpus: execute-phase.md's explanatory comment "# Only include + * model= when executor_model is..." literally contains the substring + * "model= when", which a comment-blind `model` regex mismatches as a real + * `model=when` argument, corrupting the comment AND missing the real + * `model="{executor_model}"` line beneath it. Scoped to call-span text only + * (never the whole document), so markdown `#`/`##` headings elsewhere are + * unaffected. + */ +function _maskStringsAndComments(text) { + return maskStringLiterals(text).replace(/#[^\n]*/g, (m) => ' '.repeat(m.length)); +} + +/** + * Normalize ONE `Agent(...)`/`Agent({...})` call span (already isolated by + * `findDispatchCallSpans`) onto the target's real dispatch primitive. Every + * behavioral branch reads `dispatch` (the runtime's sourced + * `hostIntegration.dispatch` facts) — none is hardcoded. Handles all three + * corpus call-argument shapes uniformly via string-aware token location + * (`maskStringLiterals` recomputed after each structural edit, since prior + * edits shift offsets). + */ +function _normalizeDispatchCallSpan(spanText, hasBraceWrapper, dispatch, toolConfig) { + const namedDispatch = dispatch.namedDispatch === true; + const backgroundCapable = dispatch.background === true; + const canOrchestrate = dispatch.subagentToolkit === 'full' + && (dispatch.maxDepth === -1 || (typeof dispatch.maxDepth === 'number' && dispatch.maxDepth > 1)); + const { toolName, backgroundParam, supportsPerCallModel, availableRoles, runtime } = toolConfig; + + let text = spanText; + + // 1. Named-role argument (subagent_type= / subagent_type:) — only when the + // target has no native named-agent lookup (dispatch.namedDispatch). + // Fail-closed validation runs on the extracted value REGARDLESS of + // which source syntax produced it (#2284 requirement 2). + if (!namedDispatch) { + const roleRe = _callArgValueRe('subagent_type'); + const rm = roleRe.exec(_maskStringsAndComments(text)); + if (rm) { + // Read the VALUE from the original (unmasked) text at the matched + // offset — `rm[1]` was captured against the mask, whose string + // INTERIOR is blanked, so it must never be used as the real value. + const roleValueExpr = text.slice(rm.index + rm[0].length - rm[1].length, rm.index + rm[0].length); + const literalRole = _literalRoleValue(roleValueExpr); + if (literalRole !== null) { + _assertRoleResolvable(literalRole, availableRoles, runtime, 'subagent_type'); + } else if (!availableRoles) { + // No literal value to check, but a null availableRoles still means + // the shipped agents/ dir couldn't be resolved at all — fail closed + // unconditionally rather than silently install an unverifiable call. + _assertRoleResolvable('', availableRoles, runtime, 'subagent_type'); + } + text = _projectRoleArgument(text, rm.index, rm.index + rm[0].length, roleValueExpr, toolConfig, canOrchestrate); + } + } + + // 2. Per-call model argument (model= / model:) — stripped entirely when the + // target has no per-call model-selection parameter (there is no + // `dispatch` axis for this — it is inherent tool vocabulary, like the + // parameter names themselves). Also removes now-dead explanatory + // comment lines directly above a `model=` line that owns its own line + // (#2284 Finding 5). + if (!supportsPerCallModel) { + const modelRe = _callArgValueRe('model'); + const mm = modelRe.exec(_maskStringsAndComments(text)); + if (mm) { + text = _stripCallArgument(text, mm.index, mm.index + mm[0].length, { consumeLeadingComments: true }); + } + } + + // 3. Background-dispatch flag (run_in_background= / run_in_background:) — + // maps onto the target's own background parameter ONLY when documented + // to support it; otherwise stripped rather than forwarding a parameter + // the primitive doesn't accept. + { + const bgRe = /\brun_in_background\s*[=:]\s*(?:true|false)/; + const bm = bgRe.exec(_maskStringsAndComments(text)); + if (bm) { + if (backgroundCapable) { + const matched = text.slice(bm.index, bm.index + bm[0].length); + const replaced = matched.replace(/^run_in_background(\s*[=:]\s*)/, `${backgroundParam}$1`); + text = text.slice(0, bm.index) + replaced + text.slice(bm.index + bm[0].length); + } else { + text = _stripCallArgument(text, bm.index, bm.index + bm[0].length); + } + } + } + + // 4. Call-syntax head rename + object-literal brace stripping. Hermes's + // delegate_task is a flat kwarg call — `Agent({...})`'s wrapper braces + // are dropped rather than carried through, so every projected call ends + // up in the same flat shape regardless of source syntax. + text = text.replace(/^Agent\(/, `${toolName}(`); + if (hasBraceWrapper) { + const openMask = maskStringLiterals(text); + const braceOpenIdx = openMask.indexOf('{'); + if (braceOpenIdx !== -1) text = text.slice(0, braceOpenIdx) + text.slice(braceOpenIdx + 1); + const closeMask = maskStringLiterals(text); + const braceCloseIdx = closeMask.lastIndexOf('}'); + if (braceCloseIdx !== -1) text = text.slice(0, braceCloseIdx) + text.slice(braceCloseIdx + 1); + } + + return text; +} + +/** + * Blank the interior (and delimiters) of every string literal inside + * `spanText` to spaces — same length, newlines preserved — using a fresh, + * LOCAL quote-tracking scan that starts at `spanText[0]` with NO inherited + * state. This is deliberately the SAME state-machine shape as the + * `inString`/`\\`/triple-quote handling inside `findDispatchCallSpans` + * (double-quoted and `"""`-triple-quoted, backslash-escape aware) — reused + * here so a call span's quoted argument VALUES (documentation prose, prompt + * bodies) never masquerade as real call syntax, without EVER falling back to + * a whole-document cumulative quote-parity mask (the round-2 defect + * documented on `findDispatchCallSpans` above). + */ +function _blankStringLiteralInteriors(spanText) { + let out = ''; + let inString = null; // null | '"' | 'triple' + for (let i = 0; i < spanText.length; i++) { + const ch = spanText[i]; + if (inString) { + if (ch === '\\') { + out += ' '; + i++; + if (i < spanText.length) out += (spanText[i] === '\n') ? '\n' : ' '; + continue; + } + if (inString === 'triple') { + if (ch === '"' && spanText[i + 1] === '"' && spanText[i + 2] === '"') { + inString = null; + out += ' '; + i += 2; + continue; + } + out += (ch === '\n') ? '\n' : ' '; + continue; + } + if (ch === inString) { inString = null; out += ' '; continue; } + out += (ch === '\n') ? '\n' : ' '; + continue; + } + if (ch === '"' && spanText[i + 1] === '"' && spanText[i + 2] === '"') { + inString = 'triple'; + out += ' '; + i += 2; + continue; + } + if (ch === '"') { inString = '"'; out += ' '; continue; } + out += ch; + } + return out; +} + +/** + * Quote-aware view of `content` for the completeness checks below: for every + * REAL call span located via `findDispatchCallSpans` (once per head word in + * `headWords`), the string-literal ARGUMENT VALUES inside that span are + * blanked via `_blankStringLiteralInteriors`; the call's own head word and + * bare (unquoted) argument tokens are left untouched. `headWords` is + * processed in order and each pass re-scans the PROGRESSIVELY-masked string + * — `toolName` first, then `'Agent'` — so a spurious `Agent(` that + * `findDispatchCallSpans('Agent')` would otherwise "find" purely because it + * sits inside an outer call's quoted string (e.g. a `description="...Agent() + * ...subagent_type=x"` argument value) has ALREADY been blanked away by the + * outer `toolName` pass by the time the `'Agent'` pass runs, so it is never + * mistaken for a real, independent call. A genuinely real (unquoted) `Agent(` + * — including one nested as a raw, un-renamed argument value — survives every + * pass and remains visible to the caller's regex checks. + */ +function _maskQuotedRegionsWithinCallSpans(content, headWords) { + let masked = content; + for (const headWord of headWords) { + const spans = findDispatchCallSpans(masked, headWord); + for (let i = spans.length - 1; i >= 0; i--) { + const { start, end } = spans[i]; + const maskedSpan = _blankStringLiteralInteriors(masked.slice(start, end)); + masked = masked.slice(0, start) + maskedSpan + masked.slice(end); + } + } + return masked; +} + +/** + * Post-projection guard (#2284 requirement 3 — belt-and-suspenders): after + * projection, assert the corpus form the projection could not anticipate + * never silently ships. Throws an explicit install error (fail-LOUD) rather + * than let an unprojected/incompletely-projected dispatch call install. + * + * #2284 round-2 CRITICAL fix: this is an INDEPENDENT check — it does NOT use + * `maskStringLiterals` over the whole document (the round-1 primitive whose + * cumulative, document-wide quote-parity tracking was the root cause of the + * round-2 defect: a `"`-heavy bash fence upstream of a real call desynced + * quote state and made `findDispatchCallSpans` blind to that call, shipping + * a Frankenstein `Agent(gsd_role="...", model="...")` with no detection). + * + * #2284 round-3 fix: a BLUNT, mask-free literal check over the whole + * document (round-2's fix) over-throws — it cannot tell a real residual + * `Agent(`/`subagent_type` call from the SAME text appearing INSIDE a quoted + * string (documentation/prompt prose, e.g. `description="...Agent()..."`). + * The completeness checks (residual `subagent_type` / literal `Agent(`) now + * run against `_maskQuotedRegionsWithinCallSpans` — quote-aware, but scoped + * strictly to already-correctly-bounded, per-occurrence-LOCAL call spans + * (never a whole-document cumulative mask), so a real Frankenstein call + * (unquoted, real call syntax) still fires while a same-text mention genuinely + * inside a quoted string does not. + * + * The completeness checks also only apply when `namedDispatch` is false: when + * `dispatch.namedDispatch === true`, `_normalizeDispatchCallSpan` step 1 + * INTENTIONALLY leaves `subagent_type` unprojected (the target primitive + * resolves named agents itself) — a residual `subagent_type` in that case is + * the correct, intended output, not a defect. (The call HEAD is still renamed + * unconditionally regardless of `namedDispatch` — see step 4 there — so a + * literal `Agent(` residual is gated the same way purely for symmetry with + * the dispatch-facts-driven contract; it is never actually left unrenamed by + * the projection in practice.) + * + * The model-leak check is unaffected by either fix above — it is orthogonal + * to `namedDispatch` (gated only by `supportsPerCallModel`) and already + * bounds each real call via the independently-fixed, per-occurrence-local, + * non-cumulative `findDispatchCallSpans`, then does a raw substring check + * within that bound. + */ +function _assertProjectionComplete(content, toolConfig, namedDispatch = false) { + const { toolName, runtime, supportsPerCallModel } = toolConfig; + + if (!namedDispatch) { + const quoteAware = _maskQuotedRegionsWithinCallSpans(content, [toolName, 'Agent']); + if (/\bsubagent_type\s*[=:]/.test(quoteAware)) { + throw new Error( + `${runtime} workflow install: projection left a residual subagent_type reference — refusing to install ` + + '(fail-closed post-projection guard, #2284)', + ); + } + if (/\bAgent\(/.test(quoteAware)) { + throw new Error( + `${runtime} workflow install: projection left literal Agent( call syntax — refusing to install ` + + '(fail-closed post-projection guard, #2284)', + ); + } + } + + if (!supportsPerCallModel) { + for (const span of findDispatchCallSpans(content, toolName)) { + const rawSpanText = content.slice(span.start, span.end); + if (/\bmodel\s*[=:]/.test(rawSpanText)) { + throw new Error( + `${runtime} workflow install: projection left a leaked model= argument inside a ${toolName}(...) call ` + + '— refusing to install (fail-closed post-projection guard, #2284)', + ); + } + } + } +} + +/** + * Project host-neutral `Agent(...)` named-subagent dispatch prose onto a + * target runtime's real dispatch primitive. See the file-header comment above + * for the governing rule: every behavioral branch reads `dispatch` (the + * runtime's sourced `hostIntegration.dispatch` facts) — none is a hardcoded + * assumption about a specific runtime. Handles all three real corpus call + * forms (multi-line one-key-per-line, single-line object-literal, single-line + * compact) via string-aware call-span detection rather than three independent + * line-anchored regexes, and closes with a post-projection guard that fails + * loud on any form it did not anticipate (#2284). + * + * @param {string} content + * @param {{namedDispatch?: boolean, nested?: boolean, maxDepth?: number, background?: boolean, backgroundDispatch?: boolean, subagentToolkit?: string}} dispatch + * @param {{toolName: string, namedRoleParam: string, promptContentParam: string, structuralRoleParam: string, leafRoleValue: string, backgroundParam: string, supportsPerCallModel: boolean, availableRoles: Set|null, runtime: string}} toolConfig + */ +function projectNamedDispatchToStructuralDelegate(content, dispatch, toolConfig) { + const d = dispatch || {}; + const namedDispatch = d.namedDispatch === true; + const backgroundCapable = d.background === true; + const { toolName, promptContentParam } = toolConfig; + + let converted = content; + + // 1. The "Agent tool IS available" contract assertion (currently unique to + // plan-phase.md, matched generically in case of future reuse elsewhere). + const assertionRe = /The Agent tool IS available in a top-level ([^\n]+?) session\.\s+Always spawn\s+([\s\S]*?)\s+as separate Agent\(\) calls\./; + converted = converted.replace(assertionRe, (_m, sessionName, roster) => { + const rosterFlat = roster.replace(/\s+/g, ' ').trim(); + if (namedDispatch) { + return `The \`${toolName}\` tool IS available in a top-level ${sessionName} session. Always dispatch ${rosterFlat} as separate \`${toolName}()\` calls.`; + } + return ( + `${sessionName} has no \`Agent\` tool. It exposes \`${toolName}\`, which dispatches by structural role — ` + + 'it has no concept of a named subagent identity. GSD projects each named gsd-* role onto this primitive ' + + `itself: resolve the role's prompt file from the active install, load its contents, and embed them in the ` + + `\`${toolName}\` payload via \`${promptContentParam}\` as the dispatched task's operating instructions. ` + + 'FAIL CLOSED — surface an explicit error and stop — if a referenced role prompt cannot be resolved; never ' + + `execute the role inline as a substitute. In a top-level ${sessionName} session, always dispatch ` + + `${rosterFlat} as separate \`${toolName}\` calls.` + ); + }); + + // 1b. Dispatch-depth-availability prose immediately adjacent to a renamed + // `Agent()` mention in the SAME sentence (plan-review-convergence.md + // ~lines 108, 347, 355) — a bare "Agent" left un-renamed right next to + // the projection's own `Agent()`→`${toolName}()` rename produced + // self-contradictory installed text (e.g. "...delegate_task(...)... + // with Agent available..."). Narrowly scoped to the EXACT known + // phrases the projection itself creates the inconsistency beside — + // never a broad bare-word `Agent` rename, which would corrupt + // legitimate `Agent`-adjacent prose elsewhere in the corpus (role + // names, "Agent Brief", agent-file references). + converted = converted.replace( + /\borchestrator runs at depth 0 with Agent available\b/g, + `orchestrator runs at depth 0 with ${toolName} available`, + ); + converted = converted.replace( + /\(bug #936: depth-1 Agent has no Agent tool\)/g, + `(bug #936: depth-1 ${toolName} has no nested ${toolName})`, + ); + + // 2. Per-call model-selection prose ("Model resolution:" paragraph, + // execute-phase.md) + inline backtick-quoted model-mention prose + // examples (not live call sites) — only rewritten when the target + // primitive has no per-call model parameter at all. + if (!toolConfig.supportsPerCallModel) { + const modelResolutionRe = /\*\*Model resolution:\*\* If `executor_model` is `"inherit"`, omit the `model=` parameter from all `Agent\(\)` calls — do NOT pass `model="inherit"` to Agent\. Omitting the `model=` parameter causes [^.]+\. Only set `model=` when `executor_model` is an explicit model name \(e\.g\., `"claude-sonnet-5"`, `"claude-opus-4-8"`\)\./; + converted = converted.replace( + modelResolutionRe, + `**Model resolution:** \`${toolName}\` has no per-call model-selection parameter — every dispatched role ` + + `always inherits the host session's active model. Never pass \`model=\` to \`${toolName}\`; drop the ` + + '`executor_model` value entirely for this runtime.', + ); + converted = converted.replace(/`model="[^"`\n]*"`,?\s*(?:and\s+)?/g, ''); + } + + // 3. Background-dispatch PROSE mentions outside any real call span (e.g. + // execute-phase.md:595,600 — `run_in_background: true` inline + // documentation, not a call argument) — #2284 Finding 3. Only rewritten + // when the target is documented to support background dispatch (a + // prose mention of an unsupported capability would be equally + // misleading as a real leaked argument). + if (backgroundCapable) { + converted = converted.replace( + /\brun_in_background(\s*[=:]\s*(?:true|false))/g, + `${toolConfig.backgroundParam}$1`, + ); + } + + // 4. Call-span-based normalization — the core of the fix. Every + // `Agent(...)`/`Agent({...})` occurrence (all three corpus forms) is + // located via string-aware balanced paren/brace matching, then + // normalized as a unit; spans are rebuilt right-to-left so earlier + // offsets stay valid while later ones are rewritten. + const spans = findDispatchCallSpans(converted, 'Agent'); + for (let i = spans.length - 1; i >= 0; i--) { + const { start, end, hasBraceWrapper } = spans[i]; + const rebuilt = _normalizeDispatchCallSpan(converted.slice(start, end), hasBraceWrapper, d, toolConfig); + converted = converted.slice(0, start) + rebuilt + converted.slice(end); + } + + // 5. "Agent tool" capability mentions (conditions gating parallel vs. + // sequential dispatch, e.g. map-codebase.md) → the real target primitive + // name, which resolves these conditions accurately since it IS a real, + // always-available dispatch primitive for this target. + converted = converted.replace(/\bAgent tool\b/g, toolName); + + // 5b. Catch-all: a `subagent_type` mention that is NOT part of any real + // `Agent(...)` call span (e.g. map-codebase.md's inline documentation + // prose ``Use Agent tool with `subagent_type="X"`, ...`` — disconnected + // example syntax, not a live call). Renamed for the same accuracy the + // real calls get; a literal quoted role value is still fail-closed + // validated even though there is no call structure to inject + // role-prompt/fail-closed guidance INTO. + if (!namedDispatch) { + converted = converted.replace( + /\bsubagent_type(\s*[=:]\s*"[^"]*")/g, + (_m, rest) => { + const literalRole = _literalRoleValue(rest.replace(/^\s*[=:]\s*/, '')); + if (literalRole !== null) { + _assertRoleResolvable(literalRole, toolConfig.availableRoles, toolConfig.runtime, 'subagent_type (prose mention)'); + } + return `${toolConfig.namedRoleParam}${rest}`; + }, + ); + converted = converted.replace(/\bsubagent_type(\s*[=:])/g, `${toolConfig.namedRoleParam}$1`); + } + + // 5c. Safety net (#2284 requirement 2): the PRIMARY mechanism for + // eliminating literal `Agent(` syntax is complete span detection (step + // 4) — this unconditional final rename exists only so that even a call + // span detection somehow misses at least loses its `Agent(` head + // rather than shipping the literal Claude-shaped tool name verbatim. + // A call caught only by this safety net is still INCOMPLETELY + // normalized (no role/model handling) and gets caught by the + // independent post-projection guard below via its OTHER invariants + // (residual subagent_type / leaked model=), which this safety net does + // not touch — the install still fails closed for a missed span. + converted = converted.replace(/\bAgent\(/g, `${toolName}(`); + + // 6. Post-projection guard (#2284 requirement 3): fail loud, never ship + // silently, on any residual/leaked form the projection above did not + // anticipate. `namedDispatch` gates the completeness checks — a + // residual subagent_type is INTENTIONAL, not a defect, when the target + // resolves named agents itself (see `_assertProjectionComplete`). + _assertProjectionComplete(converted, toolConfig, namedDispatch); + + return converted; +} + +const HERMES_DISPATCH_TOOL_CONFIG = Object.freeze({ + toolName: 'delegate_task', + namedRoleParam: 'gsd_role', + promptContentParam: 'gsd_role_prompt', + structuralRoleParam: 'role', + leafRoleValue: 'leaf', + backgroundParam: 'background', + supportsPerCallModel: false, +}); + +/** + * Hermes `.md` content converter (#2284): brand-swap (unchanged behavior, + * descriptor-driven per `hostBehaviors.brandingRewrites`) followed by the + * generic named-dispatch → `delegate_task` projection above, driven by + * `capabilities/hermes/capability.json`'s `hostIntegration.dispatch` (read + * via `_hostIntegrationDispatch`, values UNCHANGED by this fix — they are + * already documentation-sourced and correct). + */ +function convertClaudeToHermesMarkdown(content, ctx) { + const runtime = (ctx && ctx.runtime) || 'hermes'; + const b = _hostBehaviors(runtime).brandingRewrites; + let converted = content; + if (b) { + converted = converted.replace(/CLAUDE\.md/g, b['CLAUDE.md']); + // #2284(b): skips comparison-table content (protected region). + converted = applyClaudeCodeBrandSwap(converted, b['Claude Code']); + converted = converted.replace(/\.claude\//g, b['.claude/']); + } + const dispatch = _hostIntegrationDispatch(runtime); + const toolConfig = Object.assign({}, HERMES_DISPATCH_TOOL_CONFIG, { + availableRoles: _resolveAvailableGsdRoles(), + runtime, + }); + return projectNamedDispatchToStructuralDelegate(converted, dispatch, toolConfig); +} + +// ── End Hermes converters ──────────────────────────────────────────────────── + function convertSlashCommandsToCodexSkillMentions(content) { // Colon-style /gsd: never appears as a filesystem path segment, so no boundary guard is needed (unlike the hyphen-style below). let converted = content.replace(/\/gsd:([a-z0-9-]+)/gi, (_, commandName) => { @@ -6605,7 +7498,8 @@ const RUNTIME_CONTENT_DISPATCH = { const b = _hostBehaviors(ctx.runtime).brandingRewrites; if (b) { content = content.replace(/CLAUDE\.md/g, b['CLAUDE.md']); - content = content.replace(/\bClaude Code\b/g, b['Claude Code']); + // #2284(b): skips comparison-table content (protected region). + content = applyClaudeCodeBrandSwap(content, b['Claude Code']); content = content.replace(/\.claude\//g, b['.claude/']); } return content; @@ -6622,16 +7516,13 @@ const RUNTIME_CONTENT_DISPATCH = { }, }, hermes: { - md: (content, ctx) => { - // Guarded (post-review #2092): see qwen entry above. - const b = _hostBehaviors(ctx.runtime).brandingRewrites; - if (b) { - content = content.replace(/CLAUDE\.md/g, b['CLAUDE.md']); - content = content.replace(/\bClaude Code\b/g, b['Claude Code']); - content = content.replace(/\.claude\//g, b['.claude/']); - } - return content; - }, + // #2284: brand-swap alone left the false "Agent tool IS available" + // assertion + literal `Agent(...)` call syntax installed verbatim — see + // convertClaudeToHermesMarkdown / projectNamedDispatchToStructuralDelegate + // above (the Hermes converters section) for the full named-dispatch → + // `delegate_task` projection, driven by capabilities/hermes/capability.json's + // hostIntegration.dispatch facts. + md: (content, ctx) => convertClaudeToHermesMarkdown(content, ctx), js: (content, ctx) => { const b = _hostBehaviors(ctx.runtime).brandingRewrites; if (b) { @@ -12233,6 +13124,18 @@ module.exports = { convertClaudeToCliineMarkdown, convertClaudeCommandToClineSkill, convertClaudeAgentToClineAgent, + // #2284(b) — cross-cutting branding protected-region helper + applyClaudeCodeBrandSwap, + // #2284 — Hermes named-dispatch → delegate_task projection + convertClaudeToHermesMarkdown, + projectNamedDispatchToStructuralDelegate, + _hostIntegrationDispatch, + _resolveAvailableGsdRoles, + HERMES_DISPATCH_TOOL_CONFIG, + maskStringLiterals, + findDispatchCallSpans, + _assertProjectionComplete, + _normalizeDispatchCallSpan, buildClineRulesBody, buildClineAgentsMdBody, buildClinePreToolUseHook, diff --git a/tests/fix-2284-hermes-agent-delegate-task-projection.test.cjs b/tests/fix-2284-hermes-agent-delegate-task-projection.test.cjs new file mode 100644 index 000000000..82b59dce5 --- /dev/null +++ b/tests/fix-2284-hermes-agent-delegate-task-projection.test.cjs @@ -0,0 +1,895 @@ +// allow-test-rule: source-text-is-the-product — see #2284 +// Reads installed .md workflow files whose deployed text IS the contract the +// Hermes host reads at runtime — testing text content tests the deployed +// contract, exactly like the sibling hermes-skills-migration test. + +/** + * #2284 — Hermes named-dispatch → delegate_task projection. + * + * The Hermes installer previously only brand-swapped "Claude Code" → + * "Hermes Agent" in shipped `gsd-core/workflows/*.md`, leaving a false + * "The Agent tool IS available" assertion and literal `Agent(...)` call + * syntax installed verbatim — Hermes exposes `delegate_task`, not `Agent`. + * + * Covers: + * 1. Direct converter contract — projectNamedDispatchToStructuralDelegate / + * convertClaudeToHermesMarkdown against representative fixture prose, + * across ALL THREE real corpus call-argument shapes: multi-line + * one-key-per-line, single-line object-literal (`Agent({...})` — + * import.md/ingest-docs.md), and single-line compact + * (`Agent(subagent_type="x", model="y", prompt="...")` — + * code-review-fix.md/ship.md/etc). + * 2. Real disposable-HOME `--hermes --global` e2e install — no literal + * `Agent(` survives, `delegate_task` is present, commands→skill path + * (convertClaudeCommandToClaudeSkill) still works unregressed; spot- + * checks import.md, ingest-docs.md, and code-review-fix.md specifically + * (the object-literal and single-line-compact sites). + * 3. Fail-closed role resolution — a referenced gsd-* role prompt missing + * from the shipped agents/ directory aborts install with an explicit + * error, in EVERY call-argument shape, both at the converter level and + * through the real install path (deterministic fs.readdirSync + * injection per the repo's cross-platform IO-failure-injection + * convention — never chmod/permission tricks). + * 4. The post-projection guard (belt-and-suspenders) — fails loud on any + * residual subagent_type / leaked model= / unprojected Agent( the + * projection above did not anticipate, rather than silently shipping it. + */ + +'use strict'; + +process.env.GSD_TEST_MODE = '1'; + +const { test, describe, beforeEach, afterEach } = require('node:test'); +const assert = require('node:assert/strict'); +const fs = require('node:fs'); +const path = require('node:path'); +const os = require('node:os'); + +const { + convertClaudeToHermesMarkdown, + projectNamedDispatchToStructuralDelegate, + _hostIntegrationDispatch, + _resolveAvailableGsdRoles, + HERMES_DISPATCH_TOOL_CONFIG, + maskStringLiterals, + findDispatchCallSpans, + _assertProjectionComplete, + applyClaudeCodeBrandSwap, + convertClaudeToWindsurfMarkdown, + install, + uninstall, +} = require('../bin/install.js'); + +const { cleanup } = require('./helpers.cjs'); +const { nestedSkillPath } = require('./helpers/nested-layout.cjs'); + +const HERMES_DISPATCH = _hostIntegrationDispatch('hermes'); + +// Representative fixture prose mirroring the real shape found in +// gsd-core/workflows/plan-phase.md — the "Agent tool IS available" contract +// assertion followed by a literal, multi-arg Agent(...) dispatch call whose +// subagent_type resolves to a real shipped role. +const FIXTURE_ASSERTION_AND_CALL = [ + 'The Agent tool IS available in a top-level Hermes Agent session. Always spawn', + 'gsd-phase-researcher, gsd-planner, and gsd-plan-checker as separate Agent() calls.', + '', + '```', + 'Agent(', + ' prompt=filled_research_hook_fragment,', + ' subagent_type="gsd-planner",', + ' model="{researcher_model}",', + ' description="Research Phase {phase}"', + ')', + '```', + '', + '> **ORCHESTRATOR RULE — ALL RUNTIMES**: After calling Agent() above, stop working on this task immediately.', + 'Wait for the subagent to return its result. Only resume when the subagent result is available.', +].join('\n'); + +function hermesToolConfig(overrides = {}) { + return Object.assign({}, HERMES_DISPATCH_TOOL_CONFIG, { + availableRoles: _resolveAvailableGsdRoles(), + runtime: 'hermes', + }, overrides); +} + +// ─── 1. Direct converter contract ──────────────────────────────────────────── + +describe('#2284 convertClaudeToHermesMarkdown / projectNamedDispatchToStructuralDelegate — converter contract', () => { + test('capabilities/hermes/capability.json dispatch facts are unchanged (docs-sourced, not touched by this fix)', () => { + // Locks in the maintainer-confirmed constraint: this fix reads the + // existing sourced facts, it never edits them. + assert.strictEqual(HERMES_DISPATCH.namedDispatch, false); + assert.strictEqual(HERMES_DISPATCH.background, true); + assert.strictEqual(HERMES_DISPATCH.backgroundDispatch, false); + assert.strictEqual(HERMES_DISPATCH.subagentToolkit, 'read-only'); + assert.strictEqual(HERMES_DISPATCH.maxDepth, 1); + assert.strictEqual(HERMES_DISPATCH.nested, true); + }); + + test('no literal Agent( call syntax survives the projection', () => { + const out = convertClaudeToHermesMarkdown(FIXTURE_ASSERTION_AND_CALL, { runtime: 'hermes' }); + assert.ok(!/\bAgent\(/.test(out), `literal Agent( survived:\n${out}`); + }); + + test('emits a delegate_task-shaped dispatch call with the resolved role reference', () => { + const out = convertClaudeToHermesMarkdown(FIXTURE_ASSERTION_AND_CALL, { runtime: 'hermes' }); + assert.ok(/delegate_task\(/.test(out), 'delegate_task( call syntax present'); + assert.ok(/gsd_role="gsd-planner"/.test(out), 'gsd_role carries the resolved role identifier'); + assert.ok(/gsd_role_prompt=/.test(out), 'gsd_role_prompt carries the loaded-content instruction'); + assert.ok(/role="leaf"/.test(out), 'structural role pinned to Hermes\'s non-orchestrating leaf value'); + }); + + test('drops per-call model forwarding (host-model inheritance is explicit)', () => { + const out = convertClaudeToHermesMarkdown(FIXTURE_ASSERTION_AND_CALL, { runtime: 'hermes' }); + assert.ok(!/model="\{researcher_model\}"/.test(out), 'per-call model="{researcher_model}" line stripped'); + assert.ok(!/\bmodel=/.test(out), 'no model= parameter forwarded anywhere in the projected call'); + }); + + test('the "Agent tool IS available" assertion becomes an accurate delegate_task statement', () => { + const out = convertClaudeToHermesMarkdown(FIXTURE_ASSERTION_AND_CALL, { runtime: 'hermes' }); + assert.ok(!/Agent tool IS available/.test(out), 'false Claude-shaped assertion removed'); + assert.ok(/delegate_task/.test(out), 'assertion references the real Hermes dispatch primitive'); + assert.ok(/no concept of a named subagent identity/i.test(out), 'assertion states the roleless-lookup contract (namedDispatch: false)'); + }); + + test('async halt/resume wording is preserved (no busy-poll)', () => { + const out = convertClaudeToHermesMarkdown(FIXTURE_ASSERTION_AND_CALL, { runtime: 'hermes' }); + assert.ok(/stop working on this task immediately/.test(out), 'halt-after-dispatch instruction preserved'); + assert.ok(/Wait for the subagent to return its result/.test(out), 'resume-on-completion instruction preserved'); + }); + + test('fail-closed wording is present for the role-resolution step', () => { + const out = convertClaudeToHermesMarkdown(FIXTURE_ASSERTION_AND_CALL, { runtime: 'hermes' }); + assert.ok(/FAIL CLOSED/.test(out), 'explicit FAIL CLOSED instruction present'); + assert.ok(/never execute the role inline/i.test(out), 'explicit prohibition on silent inline execution'); + }); + + test('run_in_background= maps onto Hermes\'s native background= (dispatch.background: true)', () => { + const fixture = 'Agent(\n prompt=x,\n subagent_type="gsd-executor",\n run_in_background=true,\n description="d"\n)'; + const out = convertClaudeToHermesMarkdown(fixture, { runtime: 'hermes' }); + assert.ok(/\bbackground=true\b/.test(out), 'background=true present'); + assert.ok(!/run_in_background=/.test(out), 'Claude-native run_in_background= param name gone'); + }); + + test('genuinely branches on dispatch.background: false — strips (never renames) the background flag', () => { + const fixture = 'Agent(\n prompt=x,\n subagent_type="gsd-executor",\n run_in_background=true,\n description="d"\n)'; + const out = projectNamedDispatchToStructuralDelegate( + fixture, + Object.assign({}, HERMES_DISPATCH, { background: false }), + hermesToolConfig(), + ); + assert.ok(!/run_in_background=/.test(out), 'unsupported flag not left in Claude form'); + assert.ok(!/\bbackground=true\b/.test(out), 'flag not forwarded when dispatch.background is false'); + }); + + test('genuinely branches on dispatch.namedDispatch: true — passes named dispatch through unprojected', () => { + const fixture = 'Agent(\n prompt=x,\n subagent_type="gsd-executor",\n description="d"\n)'; + const out = projectNamedDispatchToStructuralDelegate( + fixture, + Object.assign({}, HERMES_DISPATCH, { namedDispatch: true }), + hermesToolConfig(), + ); + // No role-prompt-embedding machinery should be injected when the target + // primitive can resolve named agents itself. + assert.ok(!/gsd_role_prompt=/.test(out), 'no prompt-content-embedding injected when namedDispatch is true'); + assert.ok(!/FAIL CLOSED/.test(out), 'no fail-closed role-resolution injected when namedDispatch is true'); + assert.ok(/delegate_task\(/.test(out), 'call syntax still renamed to the target tool name'); + }); + + test('genuinely branches on subagentToolkit/maxDepth — omits the depth/toolkit caveat when the target can orchestrate', () => { + const fixture = 'Agent(\n prompt=x,\n subagent_type="gsd-executor",\n description="d"\n)'; + const restrictedOut = projectNamedDispatchToStructuralDelegate( + fixture, HERMES_DISPATCH, hermesToolConfig(), + ); + assert.ok(/nested delegation is unavailable/.test(restrictedOut), 'read-only/depth-1 caveat present for the real sourced facts'); + + const orchestrateCapableOut = projectNamedDispatchToStructuralDelegate( + fixture, + Object.assign({}, HERMES_DISPATCH, { subagentToolkit: 'full', maxDepth: -1 }), + hermesToolConfig(), + ); + assert.ok(!/nested delegation is unavailable/.test(orchestrateCapableOut), 'caveat omitted when the target genuinely supports nested delegation'); + }); + + test('preserves body content and prose the projection does not target', () => { + const fixture = 'Some unrelated prose.\n\nAgent(\n prompt=x,\n subagent_type="gsd-verifier",\n description="d"\n)\n\nMore unrelated prose.'; + const out = convertClaudeToHermesMarkdown(fixture, { runtime: 'hermes' }); + assert.ok(out.includes('Some unrelated prose.')); + assert.ok(out.includes('More unrelated prose.')); + }); +}); + +// ─── 1b. All three real corpus call-argument shapes ───────────────────────── + +describe('#2284 all three real corpus Agent(...) call-argument shapes', () => { + // (a) multi-line, one key= per line — plan-phase.md/execute-phase.md/etc. + const MULTI_LINE = 'Agent(\n prompt=x,\n subagent_type="gsd-planner",\n model="{researcher_model}",\n description="d"\n)'; + // (b) single-line object-literal (colon syntax) — import.md/ingest-docs.md. + const OBJECT_LITERAL = 'Agent({\n subagent_type: "gsd-plan-checker",\n prompt: "Validate the plan."\n})'; + // (c) single-line compact — code-review-fix.md/code-review.md/ship.md/etc. + const SINGLE_LINE_COMPACT = 'Agent(subagent_type="gsd-code-fixer", model="{FIXER_MODEL}", prompt="Fix the findings.")'; + + const forms = [ + ['multi-line one-key-per-line', MULTI_LINE, 'gsd-planner'], + ['single-line object-literal', OBJECT_LITERAL, 'gsd-plan-checker'], + ['single-line compact', SINGLE_LINE_COMPACT, 'gsd-code-fixer'], + ]; + + for (const [label, fixture, role] of forms) { + test(`${label}: projects to delegate_task with gsd_role_prompt + role="leaf"`, () => { + const out = convertClaudeToHermesMarkdown(fixture, { runtime: 'hermes' }); + assert.ok(/delegate_task\(/.test(out), `${label}: delegate_task( present`); + assert.ok(out.includes(`gsd_role="${role}"`), `${label}: gsd_role carries "${role}"`); + assert.ok(/gsd_role_prompt=/.test(out), `${label}: gsd_role_prompt injected`); + assert.ok(/role="leaf"/.test(out), `${label}: structural role="leaf" injected`); + assert.ok(/FAIL CLOSED/.test(out), `${label}: fail-closed wording present`); + }); + + test(`${label}: no residual subagent_type (either = or : syntax)`, () => { + const out = convertClaudeToHermesMarkdown(fixture, { runtime: 'hermes' }); + assert.ok(!/\bsubagent_type\s*[=:]/.test(out), `${label}: subagent_type token gone:\n${out}`); + }); + + test(`${label}: no leaked model= (host-model inheritance, never forwarded)`, () => { + const out = convertClaudeToHermesMarkdown(fixture, { runtime: 'hermes' }); + const mask = maskStringLiterals(out); + assert.ok(!/\bmodel\s*[=:]/.test(mask), `${label}: no model= or model: token survives:\n${out}`); + }); + + test(`${label}: a bogus role triggers the fail-closed throw`, () => { + const bogusFixture = fixture.replace(role, 'gsd-totally-fake-role-2284'); + assert.throws( + () => convertClaudeToHermesMarkdown(bogusFixture, { runtime: 'hermes' }), + /gsd-totally-fake-role-2284/, + `${label}: expected an explicit fail-closed error naming the bogus role`, + ); + }); + } + + test('object-literal wrapper braces are stripped (Hermes delegate_task is a flat kwarg call)', () => { + const out = convertClaudeToHermesMarkdown(OBJECT_LITERAL, { runtime: 'hermes' }); + assert.ok(!/delegate_task\(\s*\{/.test(out), 'no leftover "{" immediately after delegate_task('); + assert.ok(!/\}\s*\)\s*$/.test(out.trim()), 'no leftover "}" immediately before the closing )'); + }); + + test('object-literal form: non-role/model keys (e.g. prompt:) are left in their original colon style', () => { + const out = convertClaudeToHermesMarkdown(OBJECT_LITERAL, { runtime: 'hermes' }); + assert.ok(out.includes('prompt: "Validate the plan."'), 'untouched arg keys keep their original syntax'); + }); + + test('single-line-compact: a real corpus fixture identical to code-review-fix.md:201 shape (multi-line prompt body opened on the compact head)', () => { + // code-review-fix.md's real shape: `Agent(subagent_type="x", model="y", prompt="` opens a + // MULTI-LINE prompt body (no escaping) that closes many lines later with `")`. + const fixture = [ + 'Agent(subagent_type="gsd-code-fixer", model="{FIXER_MODEL}", prompt="', + '', + '${REVIEW_PATH}', + '', + '', + 'Read REVIEW.md findings, apply fixes.', + '${AGENT_SKILLS_FIXER}")', + ].join('\n'); + const out = convertClaudeToHermesMarkdown(fixture, { runtime: 'hermes' }); + assert.ok(!/\bAgent\(/.test(out), 'no literal Agent( survives a multi-line-body compact-head call'); + assert.ok(/delegate_task\(/.test(out)); + assert.ok(out.includes('gsd_role="gsd-code-fixer"')); + assert.ok(!/\bmodel\s*[=:]/.test(maskStringLiterals(out)), 'model stripped even though the prompt body spans many lines'); + assert.ok(out.includes(''), 'multi-line prompt BODY content is preserved verbatim'); + assert.ok(out.includes('${REVIEW_PATH}'), 'interpolation placeholders inside the prompt body are untouched'); + }); + + test('disconnected prose mention (not part of any real Agent(...) call, e.g. map-codebase.md-style) is still renamed and validated', () => { + const fixture = 'Use Agent tool with `subagent_type="gsd-codebase-mapper"` for parallel execution.'; + const out = convertClaudeToHermesMarkdown(fixture, { runtime: 'hermes' }); + assert.ok(out.includes('gsd_role="gsd-codebase-mapper"'), 'prose mention renamed to gsd_role='); + assert.ok(!/\bsubagent_type\s*[=:]/.test(out)); + }); + + test('a documentation TEMPLATE placeholder role (curly-brace interpolation, e.g. universal-anti-patterns.md\'s subagent_type: "gsd-{agent}") is renamed but NOT fail-closed validated', () => { + const fixture = 'ALWAYS use `subagent_type: "gsd-{agent}"` (e.g., `gsd-phase-researcher`, `gsd-executor`).'; + assert.doesNotThrow(() => convertClaudeToHermesMarkdown(fixture, { runtime: 'hermes' })); + const out = convertClaudeToHermesMarkdown(fixture, { runtime: 'hermes' }); + assert.ok(out.includes('gsd_role: "gsd-{agent}"'), 'template placeholder renamed, value preserved verbatim'); + }); + + test('run_in_background: true (colon-prose form, e.g. execute-phase.md) maps onto background: true, same as the = form', () => { + const fixture = 'Dispatch each `Agent()` call one at a time with `run_in_background: true`.'; + const out = convertClaudeToHermesMarkdown(fixture, { runtime: 'hermes' }); + assert.ok(/background:\s*true/.test(out), 'colon-prose form mapped to the native background param'); + assert.ok(!/run_in_background/.test(out), 'Claude-native run_in_background token gone'); + }); + + test('a call site preceded by explanatory comments describing the now-removed model= conditional strips both the arg AND the dead comments (Finding 5)', () => { + const fixture = [ + 'Agent(', + ' subagent_type="gsd-executor",', + ' description="Execute plan",', + ' # Only include model= when executor_model is an explicit model name.', + ' # When executor_model is "inherit", omit this parameter entirely so', + ' # Claude Code inherits the orchestrator model automatically.', + ' model="{executor_model}", # omit this line when executor_model == "inherit"', + ' isolation="worktree",', + ' prompt="Execute the plan."', + ')', + ].join('\n'); + const out = convertClaudeToHermesMarkdown(fixture, { runtime: 'hermes' }); + assert.ok(!/model="\{executor_model\}"/.test(out), 'model= argument line removed'); + assert.ok(!/Only include model=/.test(out), 'dead explanatory comment (line 1) removed'); + assert.ok(!/omit this parameter entirely/.test(out), 'dead explanatory comment (line 2) removed'); + assert.ok(!/inherits the orchestrator model/.test(out), 'dead explanatory comment (line 3) removed'); + assert.ok(out.includes('isolation="worktree"'), 'unrelated surrounding arguments preserved'); + }); +}); + +// ─── 1c. Post-projection guard (belt-and-suspenders, #2284 requirement 3) ─── + +describe('#2284 post-projection guard — fails loud on any unanticipated residual form', () => { + const toolConfig = hermesToolConfig(); + + test('throws when a residual subagent_type token survives (any syntax)', () => { + assert.throws( + () => _assertProjectionComplete('delegate_task(subagent_type="gsd-planner")', toolConfig), + /residual subagent_type/i, + ); + assert.throws( + () => _assertProjectionComplete('delegate_task(subagent_type: "gsd-planner")', toolConfig), + /residual subagent_type/i, + ); + }); + + test('throws when literal Agent( call syntax survives', () => { + // Isolated from the subagent_type check above (which fires first and + // would otherwise mask this assertion) — a bare Agent() mention with no + // remaining subagent_type token. + assert.throws( + () => _assertProjectionComplete('Please call Agent() to dispatch.', toolConfig), + /literal Agent\(/i, + ); + }); + + test('throws when a model= argument leaks inside a delegate_task(...) call', () => { + assert.throws( + () => _assertProjectionComplete('delegate_task(gsd_role="gsd-planner", model="{m}")', toolConfig), + /leaked model=/i, + ); + }); + + test('does NOT throw on a clean, fully-projected document', () => { + const clean = 'delegate_task(gsd_role="gsd-planner", gsd_role_prompt=, role="leaf", prompt="x")'; + assert.doesNotThrow(() => _assertProjectionComplete(clean, toolConfig)); + }); + + test('does NOT flag Agent( or subagent_type mentioned INSIDE a quoted string (not real call syntax)', () => { + // e.g. settings.md: `description: "Chain stages via Agent() subagents"`. + const proseInsideString = 'delegate_task(description="Chain stages via Agent() subagents, not subagent_type=x")'; + assert.doesNotThrow(() => _assertProjectionComplete(proseInsideString, toolConfig)); + }); + + test('findDispatchCallSpans correctly balances parens across a quoted prompt body containing its own parens', () => { + // Mirrors discuss-phase-assumptions.md's real shape: parenthetical prose + // ("(e.g., ...)") embedded inside a triple-quoted prompt body. + const fixture = 'Agent(subagent_type="gsd-verifier", prompt="""\nAnalyze (e.g., "Technical Approach") the codebase.\n(3-5 areas, calibrated by tier)\n""")'; + const spans = findDispatchCallSpans(fixture, 'Agent'); + assert.strictEqual(spans.length, 1, 'exactly one call span found despite embedded parens'); + assert.strictEqual(spans[0].end, fixture.length, 'span correctly extends to the TRUE closing paren, not a premature one inside the string'); + }); +}); + +// ─── 2. Real disposable-HOME e2e install ───────────────────────────────────── + +describe('#2284 real disposable-HOME --hermes --global install', () => { + let tmpHome; + let savedHome; + let savedUserProfile; + let savedHermesHome; + + beforeEach(() => { + tmpHome = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-2284-hermes-home-')); + savedHome = process.env.HOME; + savedUserProfile = process.env.USERPROFILE; + savedHermesHome = process.env.HERMES_HOME; + process.env.HOME = tmpHome; + process.env.USERPROFILE = tmpHome; + process.env.HERMES_HOME = path.join(tmpHome, '.hermes'); + }); + + afterEach(() => { + try { + uninstall(true, 'hermes'); + } catch (_e) { + // best-effort — some fail-closed tests intentionally leave a partial install + } + if (savedHome === undefined) delete process.env.HOME; else process.env.HOME = savedHome; + if (savedUserProfile === undefined) delete process.env.USERPROFILE; else process.env.USERPROFILE = savedUserProfile; + if (savedHermesHome === undefined) delete process.env.HERMES_HOME; else process.env.HERMES_HOME = savedHermesHome; + cleanup(tmpHome); + }); + + test('installed plan-phase.md has no literal Agent( and does contain delegate_task', () => { + const result = install(true, 'hermes'); + assert.strictEqual(result.runtime, 'hermes'); + + const planPhasePath = path.join(result.configDir, 'gsd-core', 'workflows', 'plan-phase.md'); + assert.ok(fs.existsSync(planPhasePath), `expected installed workflow at ${planPhasePath}`); + const content = fs.readFileSync(planPhasePath, 'utf8'); + + assert.ok(!/\bAgent\(/.test(content), 'no literal Agent( call syntax in installed plan-phase.md'); + assert.ok(/delegate_task\(/.test(content), 'delegate_task( present in installed plan-phase.md'); + assert.ok(!/Agent tool IS available/.test(content), 'false assertion not installed verbatim'); + }); + + test('spot-check a second workflow (execute-phase.md) — same guarantees hold', () => { + const result = install(true, 'hermes'); + const executePhasePath = path.join(result.configDir, 'gsd-core', 'workflows', 'execute-phase.md'); + assert.ok(fs.existsSync(executePhasePath)); + const content = fs.readFileSync(executePhasePath, 'utf8'); + + assert.ok(!/\bAgent\(/.test(content), 'no literal Agent( call syntax in installed execute-phase.md'); + assert.ok(/delegate_task\(/.test(content), 'delegate_task( present in installed execute-phase.md'); + }); + + test('spot-check import.md (object-literal Agent({...}) form) in the installed tree', () => { + const result = install(true, 'hermes'); + const importPath = path.join(result.configDir, 'gsd-core', 'workflows', 'import.md'); + assert.ok(fs.existsSync(importPath)); + const content = fs.readFileSync(importPath, 'utf8'); + assert.ok(!/\bAgent\(/.test(content), 'no literal Agent( in installed import.md'); + assert.ok(!/\bsubagent_type\s*[=:]/.test(content), 'no residual subagent_type in installed import.md'); + assert.ok(content.includes('gsd_role="gsd-plan-checker"'), 'gsd_role carries the resolved role'); + assert.ok(/gsd_role_prompt=/.test(content), 'role-prompt-resolution injected'); + }); + + test('spot-check ingest-docs.md (object-literal Agent({...}) form, two call sites) in the installed tree', () => { + const result = install(true, 'hermes'); + const ingestPath = path.join(result.configDir, 'gsd-core', 'workflows', 'ingest-docs.md'); + assert.ok(fs.existsSync(ingestPath)); + const content = fs.readFileSync(ingestPath, 'utf8'); + assert.ok(!/\bAgent\(/.test(content), 'no literal Agent( in installed ingest-docs.md'); + assert.ok(!/\bsubagent_type\s*[=:]/.test(content), 'no residual subagent_type in installed ingest-docs.md'); + assert.ok(content.includes('gsd_role="gsd-doc-synthesizer"'), 'first call site role resolved'); + assert.ok(content.includes('gsd_role="gsd-roadmapper"'), 'second call site role resolved'); + }); + + test('spot-check code-review-fix.md (single-line-compact Agent(subagent_type=..., model=..., prompt="multi-line body) form) in the installed tree', () => { + const result = install(true, 'hermes'); + const crfPath = path.join(result.configDir, 'gsd-core', 'workflows', 'code-review-fix.md'); + assert.ok(fs.existsSync(crfPath)); + const content = fs.readFileSync(crfPath, 'utf8'); + assert.ok(!/\bAgent\(/.test(content), 'no literal Agent( in installed code-review-fix.md'); + assert.ok(!/\bsubagent_type\s*[=:]/.test(content), 'no residual subagent_type in installed code-review-fix.md'); + const mask = maskStringLiterals(content); + assert.ok(!/\bmodel\s*[=:]/.test(mask), 'no leaked model= inside any real call in installed code-review-fix.md'); + assert.ok(content.includes('gsd_role="gsd-code-fixer"'), 'gsd-code-fixer role resolved'); + assert.ok(content.includes('gsd_role="gsd-code-reviewer"'), 'gsd-code-reviewer role resolved (2nd/3rd call sites)'); + }); + + test('EVERY installed workflow file is free of literal Agent( call syntax', () => { + const result = install(true, 'hermes'); + const workflowsDir = path.join(result.configDir, 'gsd-core', 'workflows'); + assert.ok(fs.existsSync(workflowsDir)); + const files = fs.readdirSync(workflowsDir).filter((f) => f.endsWith('.md')); + assert.ok(files.length > 10, 'sanity: a real corpus of workflow files was installed'); + for (const f of files) { + const content = fs.readFileSync(path.join(workflowsDir, f), 'utf8'); + assert.ok(!/\bAgent\(/.test(content), `${f} still contains literal Agent( call syntax`); + } + }); + + test('commands/gsd/*.md → Hermes-skill path (convertClaudeCommandToClaudeSkill) still works, unregressed', () => { + const result = install(true, 'hermes'); + const categoryDir = path.join(result.configDir, 'skills', 'gsd'); + assert.ok(fs.existsSync(categoryDir), 'skills/gsd category dir installed'); + + const helpSkillPath = nestedSkillPath(categoryDir, 'gsd-', 'help'); + assert.ok(fs.existsSync(helpSkillPath), `expected nested skill at ${helpSkillPath}`); + const skillContent = fs.readFileSync(helpSkillPath, 'utf8'); + assert.ok(/^---/.test(skillContent), 'skill file has YAML frontmatter'); + assert.ok(/name:\s*gsd-help/.test(skillContent), 'skill frontmatter name is the canonical gsd-help'); + }); +}); + +// ─── 3. Fail-closed role resolution ────────────────────────────────────────── + +describe('#2284 fail-closed role resolution', () => { + test('converter throws when a literal gsd_role reference has no matching shipped role', () => { + const fixture = 'Agent(\n prompt=x,\n subagent_type="gsd-totally-fake-role-2284",\n description="d"\n)'; + assert.throws( + () => convertClaudeToHermesMarkdown(fixture, { runtime: 'hermes' }), + /gsd-totally-fake-role-2284/, + 'expected an explicit error naming the unresolvable role', + ); + }); + + test('converter throws (never silently installs) when the agents/ directory cannot be resolved at all', () => { + const fixture = 'Agent(\n prompt=x,\n subagent_type="gsd-planner",\n description="d"\n)'; + assert.throws( + () => projectNamedDispatchToStructuralDelegate(fixture, HERMES_DISPATCH, hermesToolConfig({ availableRoles: null })), + /could not resolve/i, + ); + }); + + test('a literal reference to a role that DOES exist never throws', () => { + const fixture = 'Agent(\n prompt=x,\n subagent_type="gsd-verifier",\n description="d"\n)'; + assert.doesNotThrow(() => convertClaudeToHermesMarkdown(fixture, { runtime: 'hermes' })); + }); + + test('dynamic (non-literal) role references are not statically checked and never throw', () => { + // Mirrors the real plan-phase.md shape: subagent_type=research_hook.ref.agent + // is resolved at runtime by the host, not a literal string install.js can verify. + const fixture = 'Agent(\n prompt=x,\n subagent_type=research_hook.ref.agent,\n description="d"\n)'; + assert.doesNotThrow(() => convertClaudeToHermesMarkdown(fixture, { runtime: 'hermes' })); + }); + + describe('real install path — deterministic fs.readdirSync injection (never chmod/permission tricks)', () => { + let tmpHome; + let savedHome; + let savedUserProfile; + let savedHermesHome; + let origReaddirSync; + let injectedAgentsDir; + + beforeEach(() => { + tmpHome = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-2284-hermes-failclosed-')); + savedHome = process.env.HOME; + savedUserProfile = process.env.USERPROFILE; + savedHermesHome = process.env.HERMES_HOME; + process.env.HOME = tmpHome; + process.env.USERPROFILE = tmpHome; + process.env.HERMES_HOME = path.join(tmpHome, '.hermes'); + injectedAgentsDir = path.resolve(__dirname, '..', 'agents'); + origReaddirSync = fs.readdirSync; + }); + + afterEach(() => { + fs.readdirSync = origReaddirSync; + try { + uninstall(true, 'hermes'); + } catch (_e) { + // best-effort — the install intentionally failed partway through + } + if (savedHome === undefined) delete process.env.HOME; else process.env.HOME = savedHome; + if (savedUserProfile === undefined) delete process.env.USERPROFILE; else process.env.USERPROFILE = savedUserProfile; + if (savedHermesHome === undefined) delete process.env.HERMES_HOME; else process.env.HERMES_HOME = savedHermesHome; + cleanup(tmpHome); + }); + + test('a real --hermes --global install aborts with an explicit error when the shipped agents/ dir is unreadable', () => { + fs.readdirSync = function (p, opts) { + if (typeof p === 'string' && path.resolve(p) === injectedAgentsDir) { + throw new Error('#2284 injected fs.readdirSync failure — simulated unreadable agents/ dir'); + } + return origReaddirSync.call(fs, p, opts); + }; + + assert.throws( + () => install(true, 'hermes'), + /could not resolve|refusing to install/i, + 'a real hermes install must fail closed, never silently install workflows with unverifiable role references', + ); + }); + }); +}); + +// ─── 5. Corpus-wide invariant (round-2 CRITICAL regression guard) ─────────── +// +// #2284 round-2: `findDispatchCallSpans` originally relied on WHOLE-DOCUMENT +// cumulative quote parity (`maskStringLiterals` run once over the entire +// file). A markdown workflow mixes prose, ```bash fences full of their own +// double-quoted strings, and shell quoting — there is no single document-wide +// quote grammar. In the real corpus, a `"`-heavy bash block upstream of +// gsd-core/workflows/code-review.md's real +// `Agent(subagent_type="gsd-code-reviewer", model="{REVIEWER_MODEL}", ...)` +// call (~line 488) desynced that cumulative state, making the span detector +// blind to the call. It shipped completely unnormalized except for the +// catch-all's `subagent_type=`→`gsd_role=` rename: a Frankenstein +// `Agent(gsd_role="gsd-code-reviewer", model="{REVIEWER_MODEL}", ...)` — head +// still literal `Agent(`, `model=` leaked, no `gsd_role_prompt`/`role="leaf"` +// injected. A per-file/spot-check test suite did not exercise this file's +// exact shape and missed it; THIS is the real regression protection — +// hash-only goldens cannot catch a semantic defect like this. +describe('#2284 corpus-wide invariant — every shipped workflow/reference/template .md', () => { + function walkMarkdown(dir) { + if (!fs.existsSync(dir)) return []; + let out = []; + for (const entry of fs.readdirSync(dir, { withFileTypes: true })) { + const full = path.join(dir, entry.name); + if (entry.isDirectory()) out = out.concat(walkMarkdown(full)); + else if (entry.name.endsWith('.md')) out.push(full); + } + return out; + } + + const CORPUS_ROOT = path.join(__dirname, '..', 'gsd-core'); + const CORPUS_FILES = ['workflows', 'references', 'templates', 'contexts'] + .flatMap((sub) => walkMarkdown(path.join(CORPUS_ROOT, sub))); + + test('sanity: a real, substantial corpus was found to scan', () => { + assert.ok(CORPUS_FILES.length > 100, `expected >100 shipped .md files, found ${CORPUS_FILES.length}`); + }); + + test('every shipped .md file projects with ZERO residual Agent(, ZERO residual subagent_type, and ZERO leaked model= inside any delegate_task(...) call', () => { + const failures = []; + let totalDelegateTaskCalls = 0; + for (const file of CORPUS_FILES) { + const rel = path.relative(CORPUS_ROOT, file); + const content = fs.readFileSync(file, 'utf8'); + let out; + try { + out = convertClaudeToHermesMarkdown(content, { runtime: 'hermes' }); + } catch (e) { + failures.push(`${rel}: converter threw unexpectedly: ${e.message}`); + continue; + } + if (/\bAgent\(/.test(out)) failures.push(`${rel}: residual literal Agent( survives`); + if (/\bsubagent_type\s*[=:]/.test(out)) failures.push(`${rel}: residual subagent_type survives`); + for (const span of findDispatchCallSpans(out, 'delegate_task')) { + const rawSpanText = out.slice(span.start, span.end); + if (/\bmodel\s*[=:]/.test(rawSpanText)) failures.push(`${rel}: leaked model= inside a delegate_task(...) call`); + } + totalDelegateTaskCalls += (out.match(/delegate_task\(/g) || []).length; + } + assert.deepStrictEqual(failures, [], `corpus-wide invariant violations:\n${failures.join('\n')}`); + assert.ok(totalDelegateTaskCalls > 50, `sanity: expected a substantial number of real delegate_task( calls emitted, got ${totalDelegateTaskCalls}`); + }); + + test('code-review.md specifically: the real Agent(subagent_type="gsd-code-reviewer", model=..., prompt=...) call (~line 488) projects cleanly despite an upstream `"`-heavy bash fence', () => { + const file = path.join(CORPUS_ROOT, 'workflows', 'code-review.md'); + const content = fs.readFileSync(file, 'utf8'); + const out = convertClaudeToHermesMarkdown(content, { runtime: 'hermes' }); + + assert.ok(!/\bAgent\(/.test(out), 'no literal Agent( survives in code-review.md'); + assert.ok(!/\bsubagent_type\s*[=:]/.test(out), 'no residual subagent_type in code-review.md'); + assert.ok(out.includes('gsd_role="gsd-code-reviewer"'), 'the real call\'s role is resolved, not just the catch-all rename'); + + const callStart = out.indexOf('delegate_task(gsd_role="gsd-code-reviewer"'); + assert.ok(callStart !== -1, 'the real call head IS delegate_task( — not a bare catch-all-renamed Agent( survivor'); + const callSpans = findDispatchCallSpans(out, 'delegate_task'); + const realCallSpan = callSpans.find((s) => s.start === callStart); + assert.ok(realCallSpan, 'the real call is detected as a complete, well-formed delegate_task(...) span'); + const rawCall = out.slice(realCallSpan.start, realCallSpan.end); + assert.ok(/gsd_role_prompt=/.test(rawCall), 'gsd_role_prompt injected into the real call'); + assert.ok(/role="leaf"/.test(rawCall), 'role="leaf" injected into the real call'); + assert.ok(!/\bmodel\s*[=:]/.test(rawCall), 'no model= leaked inside the real call'); + }); +}); + +// ─── 6. Bash-fence quote-imbalance regression (round-2 root cause) ────────── + +describe('#2284 bash-fence quote-imbalance before a real call (round-2 root cause)', () => { + // Minimal repro of code-review.md's real shape: a ```bash fence containing + // an ODD/unbalanced count of literal double-quotes (ordinary, realistic + // shell prose — `echo "..."` plus a nested escaped quote), followed by a + // real Agent(...) call further down in the SAME document. Under the + // round-1 whole-document cumulative-quote-parity bug, the fence's + // unbalanced quoting flipped the parser's "am I inside a string" state by + // the time it reached the real call, making the call invisible to + // `findDispatchCallSpans` entirely. + const FIXTURE = [ + '```bash', + 'echo "Warning: skipping structural findings embed (${SIZE} bytes). Re-run if needed."', + 'if [ -n "$X" ]; then echo "note: check the \\"quoted\\" value"; fi', + '```', + '', + 'Spawn the reviewer:', + '', + '```', + 'Agent(subagent_type="gsd-code-reviewer", model="{REVIEWER_MODEL}", prompt="', + '', + '${FILES_TO_READ}', + '', + 'Review and report.', + '")', + '```', + ].join('\n'); + + test('findDispatchCallSpans finds the real call despite the upstream quote-heavy bash fence', () => { + const spans = findDispatchCallSpans(FIXTURE, 'Agent'); + assert.strictEqual(spans.length, 1, 'exactly one Agent(...) call span found'); + assert.ok(FIXTURE.slice(spans[0].start, spans[0].end).startsWith('Agent(subagent_type="gsd-code-reviewer"')); + }); + + test('the fixture projects fully and correctly (delegate_task head, role injected, no model leak, no residual Agent()', () => { + const out = convertClaudeToHermesMarkdown(FIXTURE, { runtime: 'hermes' }); + assert.ok(!/\bAgent\(/.test(out), 'no literal Agent( survives'); + assert.ok(!/\bsubagent_type\s*[=:]/.test(out), 'no residual subagent_type'); + assert.ok(out.includes('delegate_task(gsd_role="gsd-code-reviewer"'), 'real call head IS delegate_task(, role resolved inline at the head — not a bare catch-all rename'); + assert.ok(/gsd_role_prompt=/.test(out), 'role-prompt-resolution injected'); + assert.ok(/role="leaf"/.test(out), 'structural role injected'); + const mask = maskStringLiterals(out); + assert.ok(!/\bmodel\s*[=:]/.test(mask), 'no model= leaked'); + }); + + test('the independent post-projection guard catches the deliberately-broken (un-normalized) output this exact fixture used to produce', () => { + // The round-1/round-2 Frankenstein output: catch-all renamed + // subagent_type=→gsd_role= but the head stayed literal Agent( and + // model= leaked through, because the call was never detected as a span. + const frankenstein = 'Agent(gsd_role="gsd-code-reviewer", model="{REVIEWER_MODEL}", prompt="Review and report.")'; + const toolConfig = hermesToolConfig(); + assert.throws( + () => _assertProjectionComplete(frankenstein, toolConfig), + /literal Agent\(/i, + 'the independent guard must fail loud on the exact Frankenstein shape the bug produced', + ); + }); +}); + +// ─── 7. plan-review-convergence.md dispatch-adjacent terminology (LOW finding a) ── +// +// The projection renamed `Agent(`→`delegate_task(` but originally left +// adjacent bare-word "Agent" references in the SAME sentence/paragraph +// un-normalized (gsd-core/workflows/plan-review-convergence.md ~lines 108, +// 347, 355), producing self-contradictory installed Hermes text (e.g. +// "...delegate_task(...)... the convergence orchestrator runs at depth 0 +// with Agent available..."). Fixed via two narrowly-scoped exact-phrase +// replacements (NOT a broad bare-word `Agent` rename, which would corrupt +// legitimate `Agent`-adjacent prose elsewhere — role names, "Agent Brief", +// agent-file references). +describe('#2284 plan-review-convergence.md dispatch-adjacent terminology consistency (LOW finding a)', () => { + const FILE = path.join(__dirname, '..', 'gsd-core', 'workflows', 'plan-review-convergence.md'); + const CONTENT = fs.readFileSync(FILE, 'utf8'); + const OUT = convertClaudeToHermesMarkdown(CONTENT, { runtime: 'hermes' }); + + test('sanity: the source file still contains the two flagged dispatch-adjacent phrases (regression canary for this test itself)', () => { + assert.ok(/orchestrator runs at depth 0 with Agent available/.test(CONTENT), 'source phrase 1 present'); + assert.ok(/\(bug #936: depth-1 Agent has no Agent tool\)/.test(CONTENT), 'source phrase 2 present'); + }); + + test('no literal Agent( survives and no residual bare "Agent available"/"Agent has no Agent tool" contradiction', () => { + assert.ok(!/\bAgent\(/.test(OUT), 'no literal Agent( call syntax survives'); + assert.ok(!/\bAgent available\b/.test(OUT), 'no bare "Agent available" left adjacent to a renamed delegate_task( mention'); + assert.ok(!/depth-1 Agent has no/.test(OUT), 'no bare "depth-1 Agent" left adjacent to the renamed delegate_task('); + }); + + test('both flagged paragraphs (source ~lines 108, 347) consistently say "delegate_task available"', () => { + const matches = OUT.match(/orchestrator runs at depth 0 with delegate_task available/g) || []; + assert.strictEqual(matches.length, 2, 'both paragraphs (initial planning + replan) normalized consistently'); + }); + + test('the success_criteria bullet (source ~line 355) reads consistently: "depth-1 delegate_task has no nested delegate_task"', () => { + assert.ok(OUT.includes('(bug #936: depth-1 delegate_task has no nested delegate_task)')); + }); + + test('unrelated bare "Agent" mentions NOT adjacent to a renamed dispatch call are left untouched (no broad rename)', () => { + // "Review via Agent → Skill(...)" (success_criteria) has no Agent(...) + // call in the same bullet — the projection never touched it, so it must + // not be renamed either. + assert.ok(OUT.includes('Review via Agent → Skill("gsd-review")'), 'unrelated bare "Agent" prose left intact — no broad bare-word rename'); + // "Hermes Agent" is the runtime's own brand name (from brandingRewrites), + // never the dispatch primitive — must never be touched by this fix. + assert.ok(OUT.includes('the one level of nesting that works on Hermes Agent'), 'runtime brand name "Hermes Agent" untouched by the dispatch-terminology fix'); + }); +}); + +// ─── 8. Branding protected-region — tables (finding b) ── +// +// The shared "Claude Code" → host-brand-name swap (applied by EVERY runtime +// that brands workflow content: cursor/windsurf/trae/cline/codebuddy +// hardcoded, qwen/hermes descriptor-driven) rewrote "Claude Code" even +// inside `` comparison tables +// (gsd-core/workflows/{plan-phase,execute-phase}.md), where "Claude Code" is +// a COMPARED-RUNTIME LABEL, not a host self-reference — mislabeling the +// comparison. Cross-cutting: reproduces on every branding runtime, not just +// Hermes. Fixed via `applyClaudeCodeBrandSwap`, a protected-region +// extract/restore wrapper used by every runtime's brand-swap call site. +describe('#2284(b) branding protected-region — comparison tables', () => { + test('applyClaudeCodeBrandSwap leaves content byte-identical, but still swaps self-references outside it', () => { + const fixture = [ + 'This tool runs on Claude Code and other hosts.', + '', + '', + '- **Claude Code:** Uses `Agent(...)` — blocks until complete', + '- **Other runtimes:** sequential inline execution', + '', + '', + 'Claude Code users should also read CONTRIBUTING.md.', + ].join('\n'); + + const out = applyClaudeCodeBrandSwap(fixture, 'Windsurf'); + const block = out.match(/[\s\S]*?<\/runtime_compatibility>/)[0]; + assert.ok(block.includes('**Claude Code:**'), 'compared-runtime label inside the block is untouched'); + assert.ok(!block.includes('Windsurf'), 'the block never gains the installing runtime\'s own brand name'); + assert.ok(out.includes('This tool runs on Windsurf and other hosts.'), 'genuine self-reference BEFORE the block is branded'); + assert.ok(out.includes('Windsurf users should also read CONTRIBUTING.md.'), 'genuine self-reference AFTER the block is branded'); + }); + + test('a no-op brand name (falsy) returns content unchanged (fail-closed default, matches the qwen/hermes "guarded" no-op pattern)', () => { + const fixture = 'Claude Code does the thing.'; + assert.strictEqual(applyClaudeCodeBrandSwap(fixture, undefined), fixture); + assert.strictEqual(applyClaudeCodeBrandSwap(fixture, null), fixture); + assert.strictEqual(applyClaudeCodeBrandSwap(fixture, ''), fixture); + }); + + test('multiple blocks in the same document are each protected independently', () => { + const fixture = [ + 'Claude Code: A', + 'Claude Code self-reference.', + 'Claude Code: B', + ].join('\n'); + const out = applyClaudeCodeBrandSwap(fixture, 'Trae'); + assert.ok(out.includes('Claude Code: A')); + assert.ok(out.includes('Claude Code: B')); + assert.ok(out.includes('Trae self-reference.')); + }); + + test('collision-robust: arbitrary sentinel-like content in surrounding prose (NUL byte, token) round-trips untouched while genuine self-references are still swapped — the split-and-rejoin rewrite has no sentinel/placeholder to collide with', () => { + const fixture = [ + 'Claude Code embeds a literal NUL byte here: [] and a placeholder-shaped token in its prose.', + '', + '', + '- **Claude Code:** reference implementation', + '', + '', + 'Claude Code again, after the block.', + ].join('\n'); + + const out = applyClaudeCodeBrandSwap(fixture, 'Trae'); + + // Genuine self-references outside the block ARE swapped. + assert.ok(out.startsWith('Trae embeds'), 'leading self-reference swapped'); + assert.ok(out.includes('Trae again, after the block.'), 'trailing self-reference swapped'); + + // The NUL byte survives verbatim, exactly once, with no corruption. + assert.ok(out.includes('[]'), 'NUL byte preserved verbatim'); + assert.strictEqual(out.split('').length - 1, 1, 'NUL byte appears exactly once — not duplicated or leaked'); + + // The placeholder-shaped token survives verbatim, exactly once — proving + // there is no internal sentinel this content could collide with. + assert.ok(out.includes(''), 'placeholder-shaped token preserved verbatim'); + assert.strictEqual((out.match(//g) || []).length, 1, 'placeholder-shaped token appears exactly once — not duplicated or leaked'); + + // The protected block is untouched, including its interior "Claude Code" label. + const block = out.match(/[\s\S]*?<\/runtime_compatibility>/)[0]; + assert.ok(block.includes('**Claude Code:**'), 'block interior "Claude Code" left verbatim'); + assert.ok(!block.includes('Trae'), 'block never gains the installing runtime\'s own brand name'); + }); + + test('inside-AND-outside: a fixture with "Claude Code" both inside a block and in surrounding prose swaps only the outside occurrence', () => { + const fixture = [ + 'Claude Code is the host running this installer.', + '', + '- **Claude Code:** compared-runtime label, must stay verbatim', + '', + 'This is still Claude Code speaking.', + ].join('\n'); + + const out = applyClaudeCodeBrandSwap(fixture, 'Cursor'); + + assert.ok(out.includes('Cursor is the host running this installer.'), 'outside occurrence before the block is swapped'); + assert.ok(out.includes('This is still Cursor speaking.'), 'outside occurrence after the block is swapped'); + + const block = out.match(/[\s\S]*?<\/runtime_compatibility>/)[0]; + assert.ok(block.includes('**Claude Code:**'), 'inside occurrence is preserved verbatim'); + assert.ok(!block.includes('Cursor'), 'inside occurrence is never swapped'); + }); + + describe('real corpus: gsd-core/workflows/execute-phase.md table', () => { + const FILE = path.join(__dirname, '..', 'gsd-core', 'workflows', 'execute-phase.md'); + const CONTENT = fs.readFileSync(FILE, 'utf8'); + + test('sanity: the source file has a block containing "Claude Code:" as a compared-runtime label', () => { + assert.ok(//.test(CONTENT)); + const block = CONTENT.match(/[\s\S]*?<\/runtime_compatibility>/)[0]; + assert.ok(/\*\*Claude Code:\*\*/.test(block)); + }); + + test('Windsurf: the compared-runtime label "Claude Code:" is NOT swapped to "Windsurf:", but genuine self-references elsewhere ARE', () => { + const out = convertClaudeToWindsurfMarkdown(CONTENT); + const block = out.match(/[\s\S]*?<\/runtime_compatibility>/)[0]; + assert.ok(/\*\*Claude Code:\*\*/.test(block), 'comparison-table label preserved for Windsurf'); + assert.ok(!/\*\*Windsurf:\*\*/.test(block), 'comparison table never mislabeled with the installing runtime\'s own name'); + const outsideBlock = out.replace(/[\s\S]*?<\/runtime_compatibility>/g, ''); + assert.ok(/\bWindsurf\b/.test(outsideBlock), 'genuine self-references outside the block ARE branded to Windsurf'); + assert.ok(!/\bClaude Code\b/.test(outsideBlock), 'no residual "Claude Code" self-reference survives outside the block'); + }); + + test('Hermes: the compared-runtime label "Claude Code:" is NOT swapped to "Hermes Agent:", but genuine self-references elsewhere ARE', () => { + const out = convertClaudeToHermesMarkdown(CONTENT, { runtime: 'hermes' }); + const block = out.match(/[\s\S]*?<\/runtime_compatibility>/)[0]; + assert.ok(/\*\*Claude Code:\*\*/.test(block), 'comparison-table label preserved for Hermes'); + assert.ok(!/\*\*Hermes Agent:\*\*/.test(block), 'comparison table never mislabeled with Hermes\'s own brand name'); + const outsideBlock = out.replace(/[\s\S]*?<\/runtime_compatibility>/g, ''); + assert.ok(/\bHermes Agent\b/.test(outsideBlock), 'genuine self-references outside the block ARE branded to Hermes Agent'); + }); + }); +}); diff --git a/tests/fixtures/golden-install-parity/cline.json b/tests/fixtures/golden-install-parity/cline.json index a782fa2b0..e161d2107 100644 --- a/tests/fixtures/golden-install-parity/cline.json +++ b/tests/fixtures/golden-install-parity/cline.json @@ -236,7 +236,7 @@ "gsd-core/workflows/docs-update.md": "39f288623a8f6f32", "gsd-core/workflows/edit-phase.md": "9c9fadc047c61d74", "gsd-core/workflows/eval-review.md": "3e1d7829ed2ed494", - "gsd-core/workflows/execute-phase.md": "0d9ff2faecb72225", + "gsd-core/workflows/execute-phase.md": "6213f0d6e64dc524", "gsd-core/workflows/execute-phase/steps/codebase-drift-gate.md": "67ebc93f51968cb6", "gsd-core/workflows/execute-phase/steps/per-plan-worktree-gate.md": "7ebb7d1af6082028", "gsd-core/workflows/execute-phase/steps/post-merge-gate.md": "82e6cfe1e1b1ec0e", @@ -274,7 +274,7 @@ "gsd-core/workflows/onboard.md": "6f9e6c0b484271a9", "gsd-core/workflows/pause-work.md": "3530607514b0ac00", "gsd-core/workflows/plan-milestone-gaps.md": "bafdc6945cd2bd87", - "gsd-core/workflows/plan-phase.md": "801be4f6db080adb", + "gsd-core/workflows/plan-phase.md": "32c4a6cb4937cce3", "gsd-core/workflows/plan-phase/steps/closed-phase-gate.md": "b36f77ac7344a072", "gsd-core/workflows/plan-phase/steps/prd-express-path.md": "4b0a2cb0f4f28179", "gsd-core/workflows/plan-phase/steps/windows-troubleshooting.md": "090c31e22b1508fe", diff --git a/tests/fixtures/golden-install-parity/cursor.json b/tests/fixtures/golden-install-parity/cursor.json index 53b311f03..88712fe22 100644 --- a/tests/fixtures/golden-install-parity/cursor.json +++ b/tests/fixtures/golden-install-parity/cursor.json @@ -304,7 +304,7 @@ "gsd-core/workflows/docs-update.md": "e86d7d7e2e3dac6d", "gsd-core/workflows/edit-phase.md": "8323bfe10faa0c0a", "gsd-core/workflows/eval-review.md": "a86279dd98dd5c03", - "gsd-core/workflows/execute-phase.md": "b3267f23af6444b2", + "gsd-core/workflows/execute-phase.md": "a761ee37b32a6fe2", "gsd-core/workflows/execute-phase/steps/codebase-drift-gate.md": "facb0e816d87a0c7", "gsd-core/workflows/execute-phase/steps/per-plan-worktree-gate.md": "7ebb7d1af6082028", "gsd-core/workflows/execute-phase/steps/post-merge-gate.md": "811b6d8489571581", @@ -342,7 +342,7 @@ "gsd-core/workflows/onboard.md": "20c28136423d40ac", "gsd-core/workflows/pause-work.md": "5716362557f44ce4", "gsd-core/workflows/plan-milestone-gaps.md": "1b43d12812f7bc1e", - "gsd-core/workflows/plan-phase.md": "846e7347c40cc643", + "gsd-core/workflows/plan-phase.md": "2aa913f604e21704", "gsd-core/workflows/plan-phase/steps/closed-phase-gate.md": "e06ccd4d4c0703fb", "gsd-core/workflows/plan-phase/steps/prd-express-path.md": "197c0590326371b2", "gsd-core/workflows/plan-phase/steps/windows-troubleshooting.md": "3bed01c3c906ac52", diff --git a/tests/fixtures/golden-install-parity/hermes.json b/tests/fixtures/golden-install-parity/hermes.json index 282104f14..348704ed1 100644 --- a/tests/fixtures/golden-install-parity/hermes.json +++ b/tests/fixtures/golden-install-parity/hermes.json @@ -78,7 +78,7 @@ "gsd-core/references/edge-probe-fixtures/06-resolved-mixed/resolutions.json": "688ec62c13e08afe", "gsd-core/references/edge-probe.md": "5687eba25a078561", "gsd-core/references/execute-mvp-tdd.md": "a98a270a7ab126bc", - "gsd-core/references/execute-phase-between-wave-reset.md": "2c5cbdbc73cb053e", + "gsd-core/references/execute-phase-between-wave-reset.md": "4d6ea8e85e88b1fe", "gsd-core/references/execute-phase-context-guard.md": "a5a1058d35806a8e", "gsd-core/references/execute-phase-wave-guard.md": "b13d8860a76b7a41", "gsd-core/references/executor-examples.md": "ba59243ed45c8ab1", @@ -91,9 +91,9 @@ "gsd-core/references/gsd-run-resolver.md": "769d1472c989cd54", "gsd-core/references/honest-verifier.md": "8815c9fc18c35719", "gsd-core/references/ios-scaffold.md": "5ef0cb7e0fac891f", - "gsd-core/references/loop-hook-dispatch.md": "32e5dfb4dba76987", + "gsd-core/references/loop-hook-dispatch.md": "7135dd5d81246b0e", "gsd-core/references/mandatory-initial-read.md": "fe59abce693717cf", - "gsd-core/references/model-profile-resolution.md": "f32bb05102839767", + "gsd-core/references/model-profile-resolution.md": "8a1267752a05af27", "gsd-core/references/model-profiles.md": "6012c3b53473f04f", "gsd-core/references/mvp-concepts.md": "3464783eaaef5c10", "gsd-core/references/phase-argument-parsing.md": "e5bbb985f3bc3e34", @@ -139,7 +139,7 @@ "gsd-core/references/thinking-partner.md": "827c1badf3e6df41", "gsd-core/references/ui-brand.md": "48717bcfcd63bd27", "gsd-core/references/ui-consideration-probe.md": "7e019dfaae47f4c4", - "gsd-core/references/universal-anti-patterns.md": "6a1245050b21df01", + "gsd-core/references/universal-anti-patterns.md": "15ebe63b708d46b8", "gsd-core/references/untrusted-input-boundary.md": "d33b80d4d348599a", "gsd-core/references/user-profiling.md": "b50416fe57c1b321", "gsd-core/references/user-story-template.md": "0cc50e06a144ff8a", @@ -168,14 +168,14 @@ "gsd-core/templates/context.md": "69b01e7909ea3f66", "gsd-core/templates/continue-here.md": "f522a51b6895fba8", "gsd-core/templates/copilot-instructions.md": "aea34bc52ff548ea", - "gsd-core/templates/debug-subagent-prompt.md": "8c18a89e25929d8e", + "gsd-core/templates/debug-subagent-prompt.md": "855f2457818b4955", "gsd-core/templates/dev-preferences.md": "95048a71063d980b", "gsd-core/templates/discovery.md": "e4ab738326eb70e0", "gsd-core/templates/discussion-log.md": "cac1b48ec0f4dcb8", "gsd-core/templates/milestone-archive.md": "591b6decdc0c0e51", "gsd-core/templates/milestone.md": "74d2f750ae9f4a9c", "gsd-core/templates/phase-prompt.md": "213ccd947451ff2b", - "gsd-core/templates/planner-subagent-prompt.md": "6c9f1b23ee3dc05f", + "gsd-core/templates/planner-subagent-prompt.md": "d2a1373a1d8f53ae", "gsd-core/templates/project.md": "ae1f68db042c2522", "gsd-core/templates/requirements.md": "a44de4c2f146e473", "gsd-core/templates/research-project/ARCHITECTURE.md": "746b9ef791d758b0", @@ -202,22 +202,22 @@ "gsd-core/workflows/add-todo.md": "5fe3ddc3227e0e22", "gsd-core/workflows/ai-integration-phase.md": "ddab2912d025db65", "gsd-core/workflows/analyze-dependencies.md": "77aff48f97fa6f1c", - "gsd-core/workflows/audit-fix.md": "2eee4a0be82ef951", - "gsd-core/workflows/audit-milestone.md": "690289689e9eda7a", + "gsd-core/workflows/audit-fix.md": "2f21bd575bcedaf1", + "gsd-core/workflows/audit-milestone.md": "b96a78c6bd2e7690", "gsd-core/workflows/audit-uat.md": "86d9131fccabf2ad", - "gsd-core/workflows/autonomous.md": "dd572ca89862e5ec", + "gsd-core/workflows/autonomous.md": "853dd93846332743", "gsd-core/workflows/check-todos.md": "ea9a303c48a5d752", "gsd-core/workflows/cleanup.md": "6c488059fc152a49", - "gsd-core/workflows/code-review-fix.md": "e829d3baf9901b54", - "gsd-core/workflows/code-review.md": "50a05ab8957bd05f", + "gsd-core/workflows/code-review-fix.md": "5e396494a79c3f19", + "gsd-core/workflows/code-review.md": "64a62562611f7f2b", "gsd-core/workflows/complete-milestone.md": "f1866541148dc291", - "gsd-core/workflows/debug.md": "0289d3caa7252779", - "gsd-core/workflows/diagnose-issues.md": "16f2d2a85335641f", + "gsd-core/workflows/debug.md": "a8132ea4480a4730", + "gsd-core/workflows/diagnose-issues.md": "385096674965c043", "gsd-core/workflows/discovery-phase.md": "6161c60d752d0058", - "gsd-core/workflows/discuss-phase-assumptions.md": "3a1e215890d2b3f4", + "gsd-core/workflows/discuss-phase-assumptions.md": "71b86e3cf85d8697", "gsd-core/workflows/discuss-phase-power.md": "290c0d83d783f9f6", "gsd-core/workflows/discuss-phase.md": "0aa052ae4ee70bf6", - "gsd-core/workflows/discuss-phase/modes/advisor.md": "536e2fa842f1bc95", + "gsd-core/workflows/discuss-phase/modes/advisor.md": "624db27cd84c4626", "gsd-core/workflows/discuss-phase/modes/all.md": "fa70d79066562e54", "gsd-core/workflows/discuss-phase/modes/analyze.md": "da0788f3be7f8105", "gsd-core/workflows/discuss-phase/modes/auto.md": "af2d9d6ebb3f69c9", @@ -230,17 +230,17 @@ "gsd-core/workflows/discuss-phase/templates/context.md": "6cd929e989fe2b0f", "gsd-core/workflows/discuss-phase/templates/discussion-log.md": "1bbd7703f11128e1", "gsd-core/workflows/do.md": "e37540cc874fb782", - "gsd-core/workflows/docs-update.md": "4d6c06e611d83b6d", + "gsd-core/workflows/docs-update.md": "ba4cf926fc463cbe", "gsd-core/workflows/edit-phase.md": "7f27003f20e88fb8", "gsd-core/workflows/eval-review.md": "f510e5762212dc6f", - "gsd-core/workflows/execute-phase.md": "371ab0a4dae7a049", - "gsd-core/workflows/execute-phase/steps/codebase-drift-gate.md": "ceb8758c22660c1a", - "gsd-core/workflows/execute-phase/steps/per-plan-worktree-gate.md": "7ebb7d1af6082028", + "gsd-core/workflows/execute-phase.md": "e99263e6cfe3bdb2", + "gsd-core/workflows/execute-phase/steps/codebase-drift-gate.md": "14a27cc0828f59d3", + "gsd-core/workflows/execute-phase/steps/per-plan-worktree-gate.md": "26ee34c543926402", "gsd-core/workflows/execute-phase/steps/post-merge-gate.md": "c9ad17d6cc6dfe45", "gsd-core/workflows/execute-phase/steps/regression-gate.md": "5a78d5dfea6a911a", "gsd-core/workflows/execute-phase/steps/worktree-recovery-policy.md": "be84efbd71e1513e", - "gsd-core/workflows/execute-plan.md": "5a6b2176073db3fd", - "gsd-core/workflows/explore.md": "48770d68e8b9c132", + "gsd-core/workflows/execute-plan.md": "274b22f8e53c88d1", + "gsd-core/workflows/explore.md": "d931b4d85a74c795", "gsd-core/workflows/extract-learnings.md": "e9e167c718949c0b", "gsd-core/workflows/fast.md": "c801145115755524", "gsd-core/workflows/forensics.md": "91961b811917c5c4", @@ -251,19 +251,19 @@ "gsd-core/workflows/help/modes/default.md": "8874dac94eb68ae6", "gsd-core/workflows/help/modes/full.md": "cad4eed3907e8a58", "gsd-core/workflows/help/modes/topic.md": "6e42db16f1568be9", - "gsd-core/workflows/import.md": "a441445515bc2dd5", + "gsd-core/workflows/import.md": "c6a19584809ea635", "gsd-core/workflows/inbox.md": "91aac6360e1a8672", - "gsd-core/workflows/ingest-docs.md": "ad496a4c423f1ab7", + "gsd-core/workflows/ingest-docs.md": "716276a2f4999ab7", "gsd-core/workflows/insert-phase.md": "a0537c09191121f6", "gsd-core/workflows/list-phase-assumptions.md": "2a6b6a5acfb7742c", "gsd-core/workflows/list-seeds.md": "ad1d81683e48c4e1", "gsd-core/workflows/list-workspaces.md": "1fe28ba52cee1394", - "gsd-core/workflows/manager.md": "b0322e4514de4c4d", - "gsd-core/workflows/map-codebase.md": "7db2e66647dd3c34", + "gsd-core/workflows/manager.md": "68e6e70c4ebe0986", + "gsd-core/workflows/map-codebase.md": "420caa00abbd73f5", "gsd-core/workflows/milestone-summary.md": "353fffb60749d892", "gsd-core/workflows/mvp-phase.md": "4d102523b5d81f95", - "gsd-core/workflows/new-milestone.md": "4988e2b33d02075d", - "gsd-core/workflows/new-project.md": "c596ec378d462fdb", + "gsd-core/workflows/new-milestone.md": "4dc1da729fd1438c", + "gsd-core/workflows/new-project.md": "291d623166b97754", "gsd-core/workflows/new-workspace.md": "5c78c58ad84e886b", "gsd-core/workflows/next.md": "8e1fa29751d96564", "gsd-core/workflows/node-repair.md": "07a1628e5a1ff96b", @@ -271,28 +271,28 @@ "gsd-core/workflows/onboard.md": "3c50ed1f1fd07619", "gsd-core/workflows/pause-work.md": "ae2d5789a95f70fe", "gsd-core/workflows/plan-milestone-gaps.md": "c86cdc1964256b98", - "gsd-core/workflows/plan-phase.md": "79bc3a45f034d851", + "gsd-core/workflows/plan-phase.md": "2f0c5b0b66ebcf1d", "gsd-core/workflows/plan-phase/steps/closed-phase-gate.md": "4099ef6d0868de60", "gsd-core/workflows/plan-phase/steps/prd-express-path.md": "9607e6d03e93c1c2", "gsd-core/workflows/plan-phase/steps/windows-troubleshooting.md": "62f8e4f3b475fe5f", - "gsd-core/workflows/plan-review-convergence.md": "277e3495fb982045", + "gsd-core/workflows/plan-review-convergence.md": "52c9175d8d04a6de", "gsd-core/workflows/plant-seed.md": "f862f77fca983749", "gsd-core/workflows/pr-branch.md": "ecabd55e4eabf229", "gsd-core/workflows/profile-user.md": "de5030437226cf2c", "gsd-core/workflows/progress.md": "2641aa5457ab784f", - "gsd-core/workflows/quick.md": "731c20b5605bd319", + "gsd-core/workflows/quick.md": "01560a250a6040ea", "gsd-core/workflows/reapply-patches.md": "158083a310859594", "gsd-core/workflows/remove-phase.md": "fce799aae3ab2715", "gsd-core/workflows/remove-workspace.md": "8facde381657dd71", "gsd-core/workflows/resume-project.md": "a0443839f1f83c2d", "gsd-core/workflows/review.md": "d8c53e495b067afe", - "gsd-core/workflows/scan.md": "b28f65d88c522767", - "gsd-core/workflows/secure-phase.md": "a503dc469fd7a252", + "gsd-core/workflows/scan.md": "ebc3faaf1170dd12", + "gsd-core/workflows/secure-phase.md": "5284cfc143ad0e2c", "gsd-core/workflows/session-report.md": "2e5b1205324ddefa", "gsd-core/workflows/settings-advanced.md": "49be159144d7f426", "gsd-core/workflows/settings-integrations.md": "1dce76db0aca08a5", - "gsd-core/workflows/settings.md": "0845d073009a4619", - "gsd-core/workflows/ship.md": "f2c98eac4edcd333", + "gsd-core/workflows/settings.md": "9ced580679ed0255", + "gsd-core/workflows/ship.md": "9ec9f316622ccfd9", "gsd-core/workflows/sketch-wrap-up.md": "f1ece50ac65ea281", "gsd-core/workflows/sketch.md": "d5887983e62b574a", "gsd-core/workflows/smart-entry.md": "47f5c5e8608e5f7a", @@ -303,14 +303,14 @@ "gsd-core/workflows/sync-skills.md": "b505e6f8331c0918", "gsd-core/workflows/thread.md": "5a6759e01763c2a6", "gsd-core/workflows/transition.md": "bcfaad44668d07fd", - "gsd-core/workflows/ui-phase.md": "a45a9409c26699f4", - "gsd-core/workflows/ui-review.md": "bc0ae72c0e1cab94", + "gsd-core/workflows/ui-phase.md": "64a49ae4ec7e11a9", + "gsd-core/workflows/ui-review.md": "2f669a3f62913703", "gsd-core/workflows/ultraplan-phase.md": "0d103bf2622436f7", "gsd-core/workflows/undo.md": "791e0bf96d9a057f", "gsd-core/workflows/update.md": "7499bb4cb2a3ce6f", - "gsd-core/workflows/validate-phase.md": "88edc724568337d3", + "gsd-core/workflows/validate-phase.md": "24be83176c7db9f9", "gsd-core/workflows/verify-phase.md": "5c8d1305b47fbef4", - "gsd-core/workflows/verify-work.md": "7a9c9541d2d73fdc", + "gsd-core/workflows/verify-work.md": "3847a7ccd4bd88a8", "hooks/gsd-check-update-worker.js": "7989cc2bedd1138d", "hooks/gsd-check-update.js": "25f5ad726f76fc11", "hooks/gsd-config-reload.js": "880b696458e85e9b", diff --git a/tests/fixtures/golden-install-parity/qwen.json b/tests/fixtures/golden-install-parity/qwen.json index cb99c2bae..7017e7e27 100644 --- a/tests/fixtures/golden-install-parity/qwen.json +++ b/tests/fixtures/golden-install-parity/qwen.json @@ -233,7 +233,7 @@ "gsd-core/workflows/docs-update.md": "45d2f0d173c84e07", "gsd-core/workflows/edit-phase.md": "0fb5e0123cfc6f36", "gsd-core/workflows/eval-review.md": "6dee8a1e40ececd4", - "gsd-core/workflows/execute-phase.md": "b6d1f7dbc3c81a4f", + "gsd-core/workflows/execute-phase.md": "38370aa7653a1c5b", "gsd-core/workflows/execute-phase/steps/codebase-drift-gate.md": "36af8d91e4ae8b9c", "gsd-core/workflows/execute-phase/steps/per-plan-worktree-gate.md": "7ebb7d1af6082028", "gsd-core/workflows/execute-phase/steps/post-merge-gate.md": "98db1ba4c39cd784", @@ -271,7 +271,7 @@ "gsd-core/workflows/onboard.md": "3c50ed1f1fd07619", "gsd-core/workflows/pause-work.md": "be33f84dc1d4822f", "gsd-core/workflows/plan-milestone-gaps.md": "d98e98486123eb97", - "gsd-core/workflows/plan-phase.md": "2d1f7925036bd901", + "gsd-core/workflows/plan-phase.md": "2b0110c2cd8de18a", "gsd-core/workflows/plan-phase/steps/closed-phase-gate.md": "4099ef6d0868de60", "gsd-core/workflows/plan-phase/steps/prd-express-path.md": "c22ff5ea46de665a", "gsd-core/workflows/plan-phase/steps/windows-troubleshooting.md": "d050d8d551ed1756", diff --git a/tests/fixtures/golden-install-parity/trae.json b/tests/fixtures/golden-install-parity/trae.json index bb088279a..367c924ff 100644 --- a/tests/fixtures/golden-install-parity/trae.json +++ b/tests/fixtures/golden-install-parity/trae.json @@ -233,7 +233,7 @@ "gsd-core/workflows/docs-update.md": "f13571f08e083356", "gsd-core/workflows/edit-phase.md": "7facd0faa33c8cad", "gsd-core/workflows/eval-review.md": "37d545d4f0db4927", - "gsd-core/workflows/execute-phase.md": "80ffe75fda2a90ba", + "gsd-core/workflows/execute-phase.md": "8d197109e4d60522", "gsd-core/workflows/execute-phase/steps/codebase-drift-gate.md": "c985a30317a1aa6b", "gsd-core/workflows/execute-phase/steps/per-plan-worktree-gate.md": "7ebb7d1af6082028", "gsd-core/workflows/execute-phase/steps/post-merge-gate.md": "0a9e915170c7121c", @@ -271,7 +271,7 @@ "gsd-core/workflows/onboard.md": "61f111302af0f404", "gsd-core/workflows/pause-work.md": "c20d267e28ce92f0", "gsd-core/workflows/plan-milestone-gaps.md": "26db7b9329b7ddc8", - "gsd-core/workflows/plan-phase.md": "c4d0d0558a3b116e", + "gsd-core/workflows/plan-phase.md": "ab5ecd1e6a256c11", "gsd-core/workflows/plan-phase/steps/closed-phase-gate.md": "e06ccd4d4c0703fb", "gsd-core/workflows/plan-phase/steps/prd-express-path.md": "778b73a8db6f7c32", "gsd-core/workflows/plan-phase/steps/windows-troubleshooting.md": "619946c879f33b9d", diff --git a/tests/fixtures/golden-install-parity/windsurf.json b/tests/fixtures/golden-install-parity/windsurf.json index 04f2d6ba6..070219b58 100644 --- a/tests/fixtures/golden-install-parity/windsurf.json +++ b/tests/fixtures/golden-install-parity/windsurf.json @@ -233,7 +233,7 @@ "gsd-core/workflows/docs-update.md": "70f73cc8c27e0ca0", "gsd-core/workflows/edit-phase.md": "c0ae7d0063f3e789", "gsd-core/workflows/eval-review.md": "b28be79ef29f16fd", - "gsd-core/workflows/execute-phase.md": "d6bf589fb0370bc7", + "gsd-core/workflows/execute-phase.md": "7d88881ded1b575e", "gsd-core/workflows/execute-phase/steps/codebase-drift-gate.md": "47ae5482f8e64100", "gsd-core/workflows/execute-phase/steps/per-plan-worktree-gate.md": "7ebb7d1af6082028", "gsd-core/workflows/execute-phase/steps/post-merge-gate.md": "15bca39a75c664be", @@ -271,7 +271,7 @@ "gsd-core/workflows/onboard.md": "20c28136423d40ac", "gsd-core/workflows/pause-work.md": "93fcc1c845da6396", "gsd-core/workflows/plan-milestone-gaps.md": "7880866ee1caf923", - "gsd-core/workflows/plan-phase.md": "b0d5d582ef76c0ff", + "gsd-core/workflows/plan-phase.md": "0404dbc786881567", "gsd-core/workflows/plan-phase/steps/closed-phase-gate.md": "e06ccd4d4c0703fb", "gsd-core/workflows/plan-phase/steps/prd-express-path.md": "80b1ba493a9a967f", "gsd-core/workflows/plan-phase/steps/windows-troubleshooting.md": "3fed4740a91d0443", diff --git a/tests/install.test.cjs b/tests/install.test.cjs index 2e9af2860..408a9573c 100644 --- a/tests/install.test.cjs +++ b/tests/install.test.cjs @@ -573,6 +573,20 @@ describe('uninstall skills cleanup — hermes', () => { // ─── Section 4: No Claude references leak into non-Claude runtimes ──────────── +// #2284(b): a `...` block (e.g. +// execute-phase.md/plan-phase.md's "**Subagent spawning is runtime-specific:**" +// table) INTENTIONALLY retains "Claude Code" as a COMPARISON-RUNTIME LABEL — +// "- **Claude Code:** Uses `Agent(subagent_type=..., ...)`" documents how +// Claude Code behaves for a reader on ANY installed runtime; it is never a +// host-self-reference the install is supposed to rebrand away. Strip these +// blocks before the leak scan below so that legitimate retention doesn't trip +// the "zero Claude references" invariant — everything OUTSIDE a +// `runtime_compatibility` block is still held to zero, so an actual leaked +// self-reference elsewhere in the file still fails this test. +function stripRuntimeCompatibilityBlocks(content) { + return content.replace(/[\s\S]*?<\/runtime_compatibility>/g, ''); +} + for (const runtime of ['hermes', 'qwen']) { describe(`no Claude references leak into ${runtime} install`, () => { let tmpDir; @@ -628,7 +642,9 @@ for (const runtime of ['hermes', 'qwen']) { path.basename(f) !== 'CHANGELOG.md' ); const leaks = allFiles.filter(f => { - const c = fs.readFileSync(f, 'utf8'); + // #2284(b): exempt `` comparison-table content + // — see the exemption's rationale above the `for` loop. + const c = stripRuntimeCompatibilityBlocks(fs.readFileSync(f, 'utf8')); return /\bCLAUDE\.md\b/.test(c) || /\bClaude Code\b/.test(c) || /\.claude\//.test(c); }).map(f => path.relative(tmpDir, f)); assert.strictEqual(leaks.length, 0, `Leaking: ${leaks.join(', ')}`);