From c6ce110efaa703988ffde46e2d967607b3e6c190 Mon Sep 17 00:00:00 2001 From: Tom Boucher Date: Fri, 10 Jul 2026 14:46:59 -0400 Subject: [PATCH] feat(#2092): migrate Qwen Code onto EoS imperative adapter + native subagents + SubagentStart (ADR-1239) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Fold all runtime==='qwen'/isQwen logic branches (skill-priority frontmatter, branding/path rewrites, legacy commands/gsd cleanup, hyphen-namespace normalization, RUNTIME_CONTENT_DISPATCH, hooks-surface label) into descriptor-driven runtime.hostBehaviors on capabilities/qwen/capability.json, read via _hostBehaviors(). Shared claude/qwen/hermes legacy-migration branches in install-engine.cts folded to descriptor flags (claude+hermes descriptors updated; FALLBACK_HOST_BEHAVIORS.claude floored). Byte-identical golden parity for qwen/hermes/claude(global+local). UPGRADE 1: native .qwen/agents/*.md subagent projection — new agents artifact-layout kind + convertClaudeAgentToQwenAgent converter (name + description + tools YAML block list; color/model dropped). qwen routed onto the descriptor-driven agents path (_DESCRIPTOR_AGENTS_RUNTIMES). UPGRADE 2: SubagentStart hook wired into extendedHookEvents + the descriptor-gated hook-writer loop (activates only for qwen). Tests: qwen-imperative-reference (adapter/axes/fail-closed/hostBehaviors + no runtime==='qwen' source-grep across 4 files) + qwen-upgrades (agents file validity + SubagentStart mirrors SubagentStop, descriptor-gated). Docs matrix + how-to updated; changeset added. Co-Authored-By: Claude Opus 4.8 --- .../2092-eos-qwen-imperative-adapter.md | 5 + bin/install.js | 131 ++++++++------ capabilities/antigravity/capability.json | 3 + capabilities/claude/capability.json | 6 +- capabilities/codex/capability.json | 3 +- capabilities/cursor/capability.json | 3 +- capabilities/hermes/capability.json | 5 +- capabilities/opencode/capability.json | 3 +- capabilities/qwen/capability.json | 28 ++- docs/how-to/install-on-your-runtime.md | 3 + .../host-integration-capability-matrix.md | 2 + gsd-core/bin/lib/capability-registry.cjs | 110 ++++++++++-- gsd-core/bin/lib/capability-validator.cjs | 10 +- src/install-engine.cts | 5 +- src/review-reviewer-selection.cts | 37 +++- src/runtime-artifact-conversion.cts | 164 +++++++++++++++--- src/runtime-artifact-layout.cts | 34 ++-- src/runtime-hooks-surface.cts | 55 ++++-- tests/capability-registry.test.cjs | 13 +- .../fixtures/golden-install-parity/qwen.json | 68 ++++---- tests/qwen-imperative-reference.test.cjs | 127 ++++++++++++++ tests/qwen-upgrades.test.cjs | 152 ++++++++++++++++ ...-artifact-layout-descriptor-drive.test.cjs | 4 + tests/runtime-artifact-layout.test.cjs | 20 ++- 24 files changed, 819 insertions(+), 172 deletions(-) create mode 100644 .changeset/2092-eos-qwen-imperative-adapter.md create mode 100644 tests/qwen-imperative-reference.test.cjs create mode 100644 tests/qwen-upgrades.test.cjs diff --git a/.changeset/2092-eos-qwen-imperative-adapter.md b/.changeset/2092-eos-qwen-imperative-adapter.md new file mode 100644 index 000000000..d85dcc8af --- /dev/null +++ b/.changeset/2092-eos-qwen-imperative-adapter.md @@ -0,0 +1,5 @@ +--- +type: Changed +pr: 2153 +--- +**Qwen Code now projects GSD's specialist agents as native subagents** — installing GSD into Qwen Code writes `~/.qwen/agents/gsd-*.md` files you can invoke directly (planner, executor, code-reviewer, …) instead of reaching them only through skill prose, and a `SubagentStart` hook now fires alongside `SubagentStop`. Qwen's install is driven by its negotiated capability descriptor instead of hardcoded runtime special-cases. (#2092) diff --git a/bin/install.js b/bin/install.js index 032739890..4945e70d1 100755 --- a/bin/install.js +++ b/bin/install.js @@ -58,25 +58,20 @@ const INSTALLED_HOOK_FILES = new Set(_HOOKS_TO_COPY); const hooksSurface = require('../gsd-core/bin/lib/runtime-hooks-surface.cjs'); /** - * Runtimes that register hyphen-form `name:` per #2808 AND copy agent bodies - * verbatim (only branding swaps, no namespace conversion), so retired - * `/gsd:` colon refs leak into installed agent prose. Sibling fixes + * #3677 predicate — true when an agent body needs `/gsd:` → `/gsd-` + * normalization at install time. Descriptor-driven + * (capabilities//capability.json -> runtime.hostBehaviors.hyphenNameAgentBody) + * instead of a hardcoded runtime allow-list (ADR-1239 / #2086). Sibling fixes * #3583 / #3629 covered SKILL.md bodies, #3584 / #3606 covered runtime * emissions — this is the agent-body surface (#3677). * - * Explicit allow-list rather than deny-list so unknown / future runtimes - * default to "no rewrite" (better to leak than to mangle a runtime whose - * namespace behavior we haven't verified). - */ -const HYPHEN_NAME_AGENT_RUNTIMES = new Set(['claude', 'qwen', 'hermes']); - -/** - * #3677 predicate — true when an agent body needs `/gsd:` → `/gsd-` - * normalization at install time. + * Unknown / future runtimes that don't declare the flag default to "no + * rewrite" (better to leak than to mangle a runtime whose namespace + * behavior we haven't verified). */ function shouldNormalizeHyphenNamespaceInAgentBody(runtime) { if (typeof runtime !== 'string' || runtime === '') return false; - return HYPHEN_NAME_AGENT_RUNTIMES.has(runtime); + return _hostBehaviors(runtime).hyphenNameAgentBody === true; } /** @@ -386,6 +381,9 @@ const FALLBACK_HOST_BEHAVIORS = Object.freeze({ settingsFileByScope: Object.freeze({ local: 'settings.local.json', global: 'settings.json' }), permissionsSchema: 'claude', sourceMarkerFile: '.gsd-source', + hyphenNameAgentBody: true, + legacyCommandsGsdInstallMigration: true, + legacyCommandsGsdUninstall: 'global', }), }); @@ -1852,10 +1850,12 @@ function convertClaudeCommandToClaudeSkill(content, skillName, runtime = null, c // Track GSD's package version so Hermes' skill_view() reports a stable // identifier per install. if (_hostBehaviors(runtime).skillFrontmatterVersion) fm += `version: ${yamlQuote(pkg.version)}\n`; - // #778 (b) — Qwen-only numeric priority for /skills ordering. Scoped to qwen - // so Claude/Hermes skill frontmatter is unchanged (they ignore the field, but - // we keep their output byte-stable). skillName is the `gsd-` dir name. - if (runtime === 'qwen') { + // #778 (b) — numeric priority for /skills ordering, declared on the runtime + // descriptor (runtime.hostBehaviors.skillPriorityFrontmatter). Scoped to + // runtimes that declare the flag so Claude/Hermes skill frontmatter is + // unchanged (they ignore the field, but we keep their output byte-stable). + // skillName is the `gsd-` dir name. (ADR-1239 / #2086) + if (_hostBehaviors(runtime).skillPriorityFrontmatter) { const stem = typeof skillName === 'string' && skillName.startsWith('gsd-') ? skillName.slice(4) : skillName; @@ -6456,33 +6456,53 @@ const RUNTIME_CONTENT_DISPATCH = { return content; }, }, + // qwen/hermes: brand VALUES are descriptor-driven (ADR-1239 / #2092) via + // _hostBehaviors(ctx.runtime).brandingRewrites — EXACT regexes/ordering + // preserved from the prior hardcoded-literal versions (including the + // qwen-specific `.claude/skills/` -> `.qwen/skills/` pre-rewrite, whose + // target is derived as `${b['.claude/']}skills/`). qwen: { - md: (content) => { - content = content.replace(/CLAUDE\.md/g, 'QWEN.md'); - content = content.replace(/\bClaude Code\b/g, 'Qwen Code'); - content = content.replace(/\.claude\//g, '.qwen/'); + md: (content, ctx) => { + // Guarded (post-review #2092): degrade closed to a no-op if the + // registry fails to load, instead of throwing on `b['CLAUDE.md']`. + 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; }, - js: (content) => { - content = content.replace(/\.claude\/skills\//g, '.qwen/skills/'); - content = content.replace(/\.claude\//g, '.qwen/'); - content = content.replace(/CLAUDE\.md/g, 'QWEN.md'); - content = content.replace(/\bClaude Code\b/g, 'Qwen Code'); + js: (content, ctx) => { + const b = _hostBehaviors(ctx.runtime).brandingRewrites; + if (b) { + content = content.replace(/\.claude\/skills\//g, `${b['.claude/']}skills/`); + content = content.replace(/\.claude\//g, b['.claude/']); + content = content.replace(/CLAUDE\.md/g, b['CLAUDE.md']); + content = content.replace(/\bClaude Code\b/g, b['Claude Code']); + } return content; }, }, hermes: { - md: (content) => { - content = content.replace(/CLAUDE\.md/g, 'HERMES.md'); - content = content.replace(/\bClaude Code\b/g, 'Hermes Agent'); - content = content.replace(/\.claude\//g, '.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; }, - js: (content) => { - content = content.replace(/\.claude\/skills\//g, '.hermes/skills/'); - content = content.replace(/\.claude\//g, '.hermes/'); - content = content.replace(/CLAUDE\.md/g, 'HERMES.md'); - content = content.replace(/\bClaude Code\b/g, 'Hermes Agent'); + js: (content, ctx) => { + const b = _hostBehaviors(ctx.runtime).brandingRewrites; + if (b) { + content = content.replace(/\.claude\/skills\//g, `${b['.claude/']}skills/`); + content = content.replace(/\.claude\//g, b['.claude/']); + content = content.replace(/CLAUDE\.md/g, b['CLAUDE.md']); + content = content.replace(/\bClaude Code\b/g, b['Claude Code']); + } return content; }, }, @@ -7067,7 +7087,7 @@ function uninstall(isGlobal, runtime = DEFAULT_RUNTIME) { // removes the directory; we must preserve/restore user artifacts before that path. // This block runs AFTER uninstallRuntimeArtifacts, so we check if the directory // was already removed and skip if so (idempotent). - if (isQwen || _hostBehaviors(runtime).legacyCommandsGsdCleanup === true) { + if (_hostBehaviors(runtime).legacyCommandsGsdCleanup === true) { // dev-preferences may have survived in skills/ as SKILL.md — nothing to do for // that case. If a stale commands/gsd/ still exists (e.g. legacy was not removed), // attempt migration. In practice _runLegacyUninstallCleanup removes it first, @@ -9125,9 +9145,15 @@ function install(isGlobal, runtime = DEFAULT_RUNTIME, options = {}) { // Trivial group (cursor/windsurf/augment/trae/codebuddy) cut over together. // #1575: copilot and antigravity cut over — copilot gets .agent.md filename // rename via _copyStaged(runtime); antigravity uses scope-aware converter. + // #2092 Phase B Upgrade 1: qwen cut over — native .qwen/agents/*.md subagent + // projection via convertClaudeAgentToQwenAgent. Without this exclusion the + // legacy inline loop below deletes+re-copies qwen's agents RAW (bypassing the + // new converter entirely, since qwen has no dedicated branch in the inline + // loop's if/else-if chain — it would silently fall through to the generic + // brandingRewrites-only branch). // cline remains excluded: rules-only local branch + local/global complication // that the descriptor-driven path does not handle correctly. - const _DESCRIPTOR_AGENTS_RUNTIMES = new Set(['cursor', 'windsurf', 'augment', 'trae', 'codebuddy', 'copilot', 'antigravity']); + const _DESCRIPTOR_AGENTS_RUNTIMES = new Set(['cursor', 'windsurf', 'augment', 'trae', 'codebuddy', 'copilot', 'antigravity', 'qwen']); // Always remove stale gsd-* agents first so re-installing with // `--minimal` actually shrinks a previously-full install. @@ -9230,14 +9256,15 @@ function install(isGlobal, runtime = DEFAULT_RUNTIME, options = {}) { // Descriptor-driven (ADR-1239 / #2090): folded from `isCline` into // hostBehaviors.frontmatterDialect === 'cline'. content = convertClaudeAgentToClineAgent(content); - } else if (isQwen) { - content = content.replace(/CLAUDE\.md/g, 'QWEN.md'); - content = content.replace(/\bClaude Code\b/g, 'Qwen Code'); - content = content.replace(/\.claude\//g, '.qwen/'); } else if (_hostBehaviors(runtime).brandingRewrites) { - content = content.replace(/CLAUDE\.md/g, 'HERMES.md'); - content = content.replace(/\bClaude Code\b/g, 'Hermes Agent'); - content = content.replace(/\.claude\//g, '.hermes/'); + // Descriptor-driven (ADR-1239 / #2092): folded from separate + // `isQwen` / hermes-hardcoded branches into a single read of + // runtime.hostBehaviors.brandingRewrites (qwen -> QWEN.md/Qwen + // Code/.qwen/, hermes -> HERMES.md/Hermes Agent/.hermes/). + const _b = _hostBehaviors(runtime).brandingRewrites; + 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/']); } // #443 — Inject `effort:` into the Claude .md frontmatter ONLY. // OpenCode/Qwen/Hermes also produce .md files but break on @@ -9329,13 +9356,15 @@ function install(isGlobal, runtime = DEFAULT_RUNTIME, options = {}) { content = content.replace(/'\.claude'/g, configDirReplacement); content = content.replace(/\/\.claude\//g, `/${getDirName(runtime)}/`); content = content.replace(/\.claude\//g, `${getDirName(runtime)}/`); - if (isQwen) { - content = content.replace(/CLAUDE\.md/g, 'QWEN.md'); - content = content.replace(/\bClaude Code\b/g, 'Qwen Code'); - } - if (_hostBehaviors(runtime).brandingRewrites) { - content = content.replace(/CLAUDE\.md/g, 'HERMES.md'); - content = content.replace(/\bClaude Code\b/g, 'Hermes Agent'); + // Descriptor-driven (ADR-1239 / #2092): folded from separate + // `isQwen` / hermes-hardcoded branches into a single read of + // runtime.hostBehaviors.brandingRewrites. This site only + // rewrites the two brand-name keys (no `.claude/` here — the + // config-dir replace above already handled path fragments). + const _b2 = _hostBehaviors(runtime).brandingRewrites; + if (_b2) { + content = content.replace(/CLAUDE\.md/g, _b2['CLAUDE.md']); + content = content.replace(/\bClaude Code\b/g, _b2['Claude Code']); } // #376: rewrite gsd: → gsd- for hyphen-namespace runtimes if (shouldNormalizeHyphenNamespaceInAgentBody(runtime)) { diff --git a/capabilities/antigravity/capability.json b/capabilities/antigravity/capability.json index 14ec22b53..362e829ed 100644 --- a/capabilities/antigravity/capability.json +++ b/capabilities/antigravity/capability.json @@ -89,6 +89,9 @@ "stateIO": "filesystem", "transport": "mcp", "runtime": "go" + }, + "hostBehaviors": { + "reviewerCli": true } } } diff --git a/capabilities/claude/capability.json b/capabilities/claude/capability.json index 4059a2dc5..e21c24d70 100644 --- a/capabilities/claude/capability.json +++ b/capabilities/claude/capability.json @@ -95,7 +95,11 @@ ], "ownsClaudePaths": true, "nativeModelAliases": true, - "skillsGlobalOnboarding": true + "skillsGlobalOnboarding": true, + "legacyCommandsGsdInstallMigration": true, + "legacyCommandsGsdUninstall": "global", + "hyphenNameAgentBody": true, + "reviewerCli": true } } } diff --git a/capabilities/codex/capability.json b/capabilities/codex/capability.json index d32fcdfce..5284ce383 100644 --- a/capabilities/codex/capability.json +++ b/capabilities/codex/capability.json @@ -78,7 +78,8 @@ "tomlConfigInstall": true, "cleanupSkillSidecars": true, "agentTomlFiles": true, - "frontmatterDialect": "codex" + "frontmatterDialect": "codex", + "reviewerCli": true } } } diff --git a/capabilities/cursor/capability.json b/capabilities/cursor/capability.json index cb1e3d6a5..7b62af25a 100644 --- a/capabilities/cursor/capability.json +++ b/capabilities/cursor/capability.json @@ -114,7 +114,8 @@ "stop", "subagentStart", "subagentStop" - ] + ], + "reviewerCli": true } } } diff --git a/capabilities/hermes/capability.json b/capabilities/hermes/capability.json index 85c3eff63..66a38aedf 100644 --- a/capabilities/hermes/capability.json +++ b/capabilities/hermes/capability.json @@ -63,7 +63,10 @@ "Claude Code": "Hermes Agent", ".claude/": ".hermes/" }, - "reapplyCommand": "gsd-update --reapply (mention the skill name)" + "reapplyCommand": "gsd-update --reapply (mention the skill name)", + "legacyCommandsGsdInstallMigration": true, + "legacyCommandsGsdUninstall": true, + "hyphenNameAgentBody": true }, "hostIntegration": { "embeddingMode": "imperative", diff --git a/capabilities/opencode/capability.json b/capabilities/opencode/capability.json index 4a37567ea..629d128cf 100644 --- a/capabilities/opencode/capability.json +++ b/capabilities/opencode/capability.json @@ -99,7 +99,8 @@ "skipHomePrefixSubstitution": true, "skipSettingsUi": true, "skipUpdateBannerCommand": true, - "skipCodexSkillsManifest": true + "skipCodexSkillsManifest": true, + "reviewerCli": true } } } diff --git a/capabilities/qwen/capability.json b/capabilities/qwen/capability.json index b6be414cd..6e8910404 100644 --- a/capabilities/qwen/capability.json +++ b/capabilities/qwen/capability.json @@ -28,6 +28,14 @@ "nesting": "nested", "recursive": false, "converter": "convertClaudeCommandToClaudeSkill" + }, + { + "kind": "agents", + "destSubpath": "agents", + "prefix": "gsd-", + "nesting": "flat", + "recursive": false, + "converter": "convertClaudeAgentToQwenAgent" } ], "local": [ @@ -38,6 +46,14 @@ "nesting": "nested", "recursive": false, "converter": "convertClaudeCommandToClaudeSkill" + }, + { + "kind": "agents", + "destSubpath": "agents", + "prefix": "gsd-", + "nesting": "flat", + "recursive": false, + "converter": "convertClaudeAgentToQwenAgent" } ] }, @@ -52,7 +68,8 @@ "extendedHookEvents": [ "SubagentStop", "Stop", - "PreCompact" + "PreCompact", + "SubagentStart" ], "hostIntegration": { "embeddingMode": "imperative", @@ -70,6 +87,15 @@ "stateIO": "filesystem", "transport": "mcp", "runtime": "node" + }, + "hostBehaviors": { + "skillPriorityFrontmatter": true, + "brandingRewrites": { "CLAUDE.md": "QWEN.md", "Claude Code": "Qwen Code", ".claude/": ".qwen/" }, + "legacyCommandsGsdCleanup": true, + "legacyCommandsGsdInstallMigration": true, + "legacyCommandsGsdUninstall": true, + "hyphenNameAgentBody": true, + "reviewerCli": true } } } diff --git a/docs/how-to/install-on-your-runtime.md b/docs/how-to/install-on-your-runtime.md index 90a6e7f15..d3b61ae20 100644 --- a/docs/how-to/install-on-your-runtime.md +++ b/docs/how-to/install-on-your-runtime.md @@ -351,6 +351,8 @@ Skills land in `~/.qwen/skills/gsd-*/SKILL.md`. GSD's main-loop skills are emitted with Qwen's optional numeric `priority` frontmatter field so the most-used workflows surface first in the `/skills` TUI list. Higher values sort earlier (per Qwen's skills spec), so core commands such as `/skills` for `new-project` (100), `plan-phase` (90), and `execute-phase` (85) appear above utility skills, which are left unset (default 0). This affects only the `/skills` list order — slash-command completion and `/help` remain alphabetical. +Subagents land in `~/.qwen/agents/gsd-*.md` as native Qwen subagents, converted to Qwen's own `name:`/`description:`/`tools:` (YAML block list) frontmatter schema rather than Claude Code's. + **Override the install directory:** ```bash @@ -367,6 +369,7 @@ Qwen Code supports 15 hook events. GSD registers the following events automatica | `PostToolUse` | `gsd-context-monitor.js`, `gsd-read-injection-scanner.js`, `gsd-phase-boundary.sh`, `gsd-graphify-update.sh` | Context monitoring, read-time scan, phase boundary detection | | `PreToolUse` | `gsd-prompt-guard.js`, `gsd-read-guard.js`, `gsd-workflow-guard.js`, `gsd-worktree-path-guard.js`, `gsd-validate-commit.sh` | Prompt guard, read-before-edit, workflow + worktree safety, commit validation | | `SubagentStop` | `gsd-context-monitor.js` | Context headroom tracking after subagent completion | +| `SubagentStart` | `gsd-context-monitor.js` | Context headroom tracking at subagent start | | `Stop` | `gsd-context-monitor.js` | Context headroom tracking before model stop | | `PreCompact` | `gsd-context-monitor.js` | Context awareness before conversation compaction | diff --git a/docs/reference/host-integration-capability-matrix.md b/docs/reference/host-integration-capability-matrix.md index d043085b9..f26e9382a 100644 --- a/docs/reference/host-integration-capability-matrix.md +++ b/docs/reference/host-integration-capability-matrix.md @@ -365,6 +365,8 @@ Documentation gaps: - dispatch.nested — docs only restrict fork-type sub-agents from nesting; whether named sub-agents can themselves spawn named sub-agents is not stated. - dispatch.maxDepth — depth=1 is documented only for fork sub-agents; depth for named sub-agent chains is undocumented. +**EoS migration status (#2092):** Migrated onto the imperative adapter. All `runtime === 'qwen'` branches in `bin/install.js`, `src/install-engine.cts`, `src/runtime-artifact-conversion.cts`, and `src/runtime-hooks-surface.cts` folded into descriptor-driven `runtime.hostBehaviors`. Two upgrades land: (1) **native subagent projection** — a new `agents` artifact-layout kind projects GSD's specialist agents into `~/.qwen/agents/gsd-*.md` as native Qwen subagents via `convertClaudeAgentToQwenAgent`, emitting Qwen's own `name:`/`description:`/`tools:` (YAML block list) frontmatter schema instead of Claude Code's; cite https://qwenlm.github.io/qwen-code-docs/en/users/features/sub-agents/. (2) **`SubagentStart` hook** — wired into `extendedHookEvents` alongside the existing `SubagentStop`/`Stop`/`PreCompact` events, firing the context-monitor hook symmetrically at subagent start and completion; cite https://qwenlm.github.io/qwen-code-docs/en/users/features/hooks. + --- ## codebuddy diff --git a/gsd-core/bin/lib/capability-registry.cjs b/gsd-core/bin/lib/capability-registry.cjs index cd840ba9b..958602af9 100644 --- a/gsd-core/bin/lib/capability-registry.cjs +++ b/gsd-core/bin/lib/capability-registry.cjs @@ -183,6 +183,9 @@ const capabilities = { "stateIO": "filesystem", "transport": "mcp", "runtime": "go" + }, + "hostBehaviors": { + "reviewerCli": true } } }, @@ -469,7 +472,11 @@ const capabilities = { ], "ownsClaudePaths": true, "nativeModelAliases": true, - "skillsGlobalOnboarding": true + "skillsGlobalOnboarding": true, + "legacyCommandsGsdInstallMigration": true, + "legacyCommandsGsdUninstall": "global", + "hyphenNameAgentBody": true, + "reviewerCli": true } } }, @@ -873,7 +880,8 @@ const capabilities = { "tomlConfigInstall": true, "cleanupSkillSidecars": true, "agentTomlFiles": true, - "frontmatterDialect": "codex" + "frontmatterDialect": "codex", + "reviewerCli": true } } }, @@ -1083,7 +1091,8 @@ const capabilities = { "stop", "subagentStart", "subagentStop" - ] + ], + "reviewerCli": true } } }, @@ -1395,7 +1404,10 @@ const capabilities = { "Claude Code": "Hermes Agent", ".claude/": ".hermes/" }, - "reapplyCommand": "gsd-update --reapply (mention the skill name)" + "reapplyCommand": "gsd-update --reapply (mention the skill name)", + "legacyCommandsGsdInstallMigration": true, + "legacyCommandsGsdUninstall": true, + "hyphenNameAgentBody": true }, "hostIntegration": { "embeddingMode": "imperative", @@ -1968,7 +1980,8 @@ const capabilities = { "skipHomePrefixSubstitution": true, "skipSettingsUi": true, "skipUpdateBannerCommand": true, - "skipCodexSkillsManifest": true + "skipCodexSkillsManifest": true, + "reviewerCli": true } } }, @@ -2133,6 +2146,14 @@ const capabilities = { "nesting": "nested", "recursive": false, "converter": "convertClaudeCommandToClaudeSkill" + }, + { + "kind": "agents", + "destSubpath": "agents", + "prefix": "gsd-", + "nesting": "flat", + "recursive": false, + "converter": "convertClaudeAgentToQwenAgent" } ], "local": [ @@ -2143,6 +2164,14 @@ const capabilities = { "nesting": "nested", "recursive": false, "converter": "convertClaudeCommandToClaudeSkill" + }, + { + "kind": "agents", + "destSubpath": "agents", + "prefix": "gsd-", + "nesting": "flat", + "recursive": false, + "converter": "convertClaudeAgentToQwenAgent" } ] }, @@ -2157,7 +2186,8 @@ const capabilities = { "extendedHookEvents": [ "SubagentStop", "Stop", - "PreCompact" + "PreCompact", + "SubagentStart" ], "hostIntegration": { "embeddingMode": "imperative", @@ -2175,6 +2205,19 @@ const capabilities = { "stateIO": "filesystem", "transport": "mcp", "runtime": "node" + }, + "hostBehaviors": { + "skillPriorityFrontmatter": true, + "brandingRewrites": { + "CLAUDE.md": "QWEN.md", + "Claude Code": "Qwen Code", + ".claude/": ".qwen/" + }, + "legacyCommandsGsdCleanup": true, + "legacyCommandsGsdInstallMigration": true, + "legacyCommandsGsdUninstall": true, + "hyphenNameAgentBody": true, + "reviewerCli": true } } }, @@ -3787,6 +3830,9 @@ const runtimes = { "stateIO": "filesystem", "transport": "mcp", "runtime": "go" + }, + "hostBehaviors": { + "reviewerCli": true } } }, @@ -3990,7 +4036,11 @@ const runtimes = { ], "ownsClaudePaths": true, "nativeModelAliases": true, - "skillsGlobalOnboarding": true + "skillsGlobalOnboarding": true, + "legacyCommandsGsdInstallMigration": true, + "legacyCommandsGsdUninstall": "global", + "hyphenNameAgentBody": true, + "reviewerCli": true } } }, @@ -4246,7 +4296,8 @@ const runtimes = { "tomlConfigInstall": true, "cleanupSkillSidecars": true, "agentTomlFiles": true, - "frontmatterDialect": "codex" + "frontmatterDialect": "codex", + "reviewerCli": true } } }, @@ -4456,7 +4507,8 @@ const runtimes = { "stop", "subagentStart", "subagentStop" - ] + ], + "reviewerCli": true } } }, @@ -4525,7 +4577,10 @@ const runtimes = { "Claude Code": "Hermes Agent", ".claude/": ".hermes/" }, - "reapplyCommand": "gsd-update --reapply (mention the skill name)" + "reapplyCommand": "gsd-update --reapply (mention the skill name)", + "legacyCommandsGsdInstallMigration": true, + "legacyCommandsGsdUninstall": true, + "hyphenNameAgentBody": true }, "hostIntegration": { "embeddingMode": "imperative", @@ -4822,7 +4877,8 @@ const runtimes = { "skipHomePrefixSubstitution": true, "skipSettingsUi": true, "skipUpdateBannerCommand": true, - "skipCodexSkillsManifest": true + "skipCodexSkillsManifest": true, + "reviewerCli": true } } }, @@ -4856,6 +4912,14 @@ const runtimes = { "nesting": "nested", "recursive": false, "converter": "convertClaudeCommandToClaudeSkill" + }, + { + "kind": "agents", + "destSubpath": "agents", + "prefix": "gsd-", + "nesting": "flat", + "recursive": false, + "converter": "convertClaudeAgentToQwenAgent" } ], "local": [ @@ -4866,6 +4930,14 @@ const runtimes = { "nesting": "nested", "recursive": false, "converter": "convertClaudeCommandToClaudeSkill" + }, + { + "kind": "agents", + "destSubpath": "agents", + "prefix": "gsd-", + "nesting": "flat", + "recursive": false, + "converter": "convertClaudeAgentToQwenAgent" } ] }, @@ -4880,7 +4952,8 @@ const runtimes = { "extendedHookEvents": [ "SubagentStop", "Stop", - "PreCompact" + "PreCompact", + "SubagentStart" ], "hostIntegration": { "embeddingMode": "imperative", @@ -4898,6 +4971,19 @@ const runtimes = { "stateIO": "filesystem", "transport": "mcp", "runtime": "node" + }, + "hostBehaviors": { + "skillPriorityFrontmatter": true, + "brandingRewrites": { + "CLAUDE.md": "QWEN.md", + "Claude Code": "Qwen Code", + ".claude/": ".qwen/" + }, + "legacyCommandsGsdCleanup": true, + "legacyCommandsGsdInstallMigration": true, + "legacyCommandsGsdUninstall": true, + "hyphenNameAgentBody": true, + "reviewerCli": true } } }, diff --git a/gsd-core/bin/lib/capability-validator.cjs b/gsd-core/bin/lib/capability-validator.cjs index bdf760d8b..1f4697f06 100644 --- a/gsd-core/bin/lib/capability-validator.cjs +++ b/gsd-core/bin/lib/capability-validator.cjs @@ -697,6 +697,8 @@ const VALID_CONVERTER_NAMES = new Set([ 'convertClaudeAgentToCodebuddyAgent', 'convertClaudeAgentToClineAgent', 'convertClaudeAgentToCodexAgent', + // ADR-1239 / #2092 Phase B Upgrade 1 — native .qwen/agents/*.md subagent projection. + 'convertClaudeAgentToQwenAgent', ]); // C3: Validate role:runtime body @@ -716,7 +718,9 @@ const VALID_ARTIFACT_NESTINGS = new Set(['flat', 'nested']); const FEATURE_FIELDS_FORBIDDEN_ON_RUNTIME = ['skills', 'agents', 'steps', 'contributions', 'gates', 'hooks', 'activationKey']; const VALID_INSTALL_SURFACES = new Set(['settings-json', 'codex-toml', 'copilot-instructions', 'cline-rules', 'cursor-hooks-json', 'profile-marker-only']); const VALID_PERMISSION_WRITERS = new Set(['opencode', 'kilo']); -const VALID_EXTENDED_HOOK_EVENTS = new Set(['SubagentStop', 'Stop', 'PreCompact', 'FileChanged', 'BeforeAgent', 'AfterAgent', 'BeforeModel']); +// SubagentStart added #2092 Phase B Upgrade 2 (qwen-only today — see +// capabilities/qwen/capability.json's extendedHookEvents). +const VALID_EXTENDED_HOOK_EVENTS = new Set(['SubagentStop', 'Stop', 'PreCompact', 'FileChanged', 'BeforeAgent', 'AfterAgent', 'BeforeModel', 'SubagentStart']); // ADR-1239 Phase A: hostIntegration axes (MUST stay parity-identical to HOST_INTEGRATION_AXES in src/host-integration.cts) const VALID_EMBEDDING_MODES = new Set(['imperative', 'declarative']); @@ -742,7 +746,9 @@ const INSTALL_SURFACE_TO_ALLOWED_HOOKS_SURFACES = new Map([ // GATE B: extended hook event families → required hookEvents value // Gemini agent-events require hookEvents='gemini'; Claude-family events require hookEvents='claude'. const GEMINI_AGENT_EVENTS = new Set(['BeforeAgent', 'AfterAgent', 'BeforeModel']); -const CLAUDE_FAMILY_EVENTS = new Set(['SubagentStop', 'Stop', 'PreCompact', 'FileChanged']); +// SubagentStart added #2092 Phase B Upgrade 2 — Claude hook-event dialect +// counterpart of SubagentStop (qwen-only today). +const CLAUDE_FAMILY_EVENTS = new Set(['SubagentStop', 'Stop', 'PreCompact', 'FileChanged', 'SubagentStart']); /** * Validate a runtime.configHome object per ADR-1016 Decision 1. diff --git a/src/install-engine.cts b/src/install-engine.cts index dcfbb6e18..5818c0eec 100644 --- a/src/install-engine.cts +++ b/src/install-engine.cts @@ -470,7 +470,7 @@ function _runLegacyInstallMigrations(runtime: string, configDir: string, scope: // that for Hermes the flat skills/gsd-*/ removal (below) does not delete the freshly // created skills/gsd-dev-preferences/ skill dir. let savedLegacyArtifacts: Map | null = null; - if (runtime === 'claude' || runtime === 'qwen' || runtime === 'hermes') { + if (_hostBehaviors(runtime).legacyCommandsGsdInstallMigration) { if (fs.existsSync(legacyCommandsGsd)) { savedLegacyArtifacts = preserveUserArtifacts(legacyCommandsGsd, ['dev-preferences.md']); fs.rmSync(legacyCommandsGsd, { recursive: true }); @@ -531,7 +531,8 @@ function _runLegacyUninstallCleanup(runtime: string, configDir: string, scope: s // commands/gsd/ for claude local, preserving dev-preferences.md by restoring it // to the same location (#1423). Using migrateLegacyDevPreferencesToSkill here // (which would redirect to skills/) conflicts with the test contract for local installs. - const isLegacyCommandsGsd = runtime === 'qwen' || runtime === 'hermes' || (runtime === 'claude' && scope === 'global'); + const _lu = _hostBehaviors(runtime).legacyCommandsGsdUninstall; + const isLegacyCommandsGsd = _lu === true || (_lu === 'global' && scope === 'global'); if (isLegacyCommandsGsd) { const legacyCommandsGsd = path.join(configDir, 'commands', 'gsd'); if (fs.existsSync(legacyCommandsGsd)) { diff --git a/src/review-reviewer-selection.cts b/src/review-reviewer-selection.cts index 9b71e0e1c..86f5f2b62 100644 --- a/src/review-reviewer-selection.cts +++ b/src/review-reviewer-selection.cts @@ -14,22 +14,45 @@ * per-instance CLI flags). An instance is available iff its base `cli` is * detected. The instance→cli mapping lives HERE (single source; see the parity * test in tests/review-reviewer-instances.test.cjs — DEFECT.GENERATIVE-FIX). + * + * KNOWN_REVIEWER_SLUGS (post-review #2092): registry-derived, not a flat + * hand-maintained array. Each capability-runtime descriptor that is a valid + * reviewer CLI declares `runtime.hostBehaviors.reviewerCli: true` + * (capabilities//capability.json); this module reads that flag off the + * generated capability-registry.cjs at require-time. A handful of reviewer + * CLIs are NOT install-time runtimes at all (no capabilities// descriptor + * exists) — those stay a small hardcoded tail: + * - `gemini` — hook-event dialect name only (see runtime-hooks-surface.cts); + * the Gemini CLI reviewer is not an installable runtime (#1928 folded + * gemini into antigravity's descriptor). + * - `coderabbit` / `ollama` / `lm_studio` / `llama_cpp` — third-party + * review/model CLIs with no GSD install surface at all. */ -export const KNOWN_REVIEWER_SLUGS: ReadonlyArray = [ +const NON_RUNTIME_REVIEWER_SLUGS: ReadonlyArray = [ 'gemini', - 'claude', - 'codex', 'coderabbit', - 'opencode', - 'qwen', - 'cursor', - 'antigravity', 'ollama', 'lm_studio', 'llama_cpp', ]; +function deriveRuntimeReviewerSlugs(): string[] { + // eslint-disable-next-line @typescript-eslint/no-require-imports + const registry = require('./capability-registry.cjs') as { + runtimes?: Record; + }; + const runtimes = registry.runtimes || {}; + return Object.keys(runtimes).filter( + (id) => runtimes[id]?.runtime?.hostBehaviors?.reviewerCli === true, + ); +} + +export const KNOWN_REVIEWER_SLUGS: ReadonlyArray = [ + ...deriveRuntimeReviewerSlugs(), + ...NON_RUNTIME_REVIEWER_SLUGS, +]; + /** Instance names are lowercase slugs that must not shadow a built-in slug. */ export const INSTANCE_NAME_PATTERN = /^[a-z0-9][a-z0-9-]*$/; diff --git a/src/runtime-artifact-conversion.cts b/src/runtime-artifact-conversion.cts index 261c06879..c14c17627 100644 --- a/src/runtime-artifact-conversion.cts +++ b/src/runtime-artifact-conversion.cts @@ -62,6 +62,27 @@ function gsdVersion(): string { return cachedVersion; } +/** + * Host-specific install behaviors declared on the runtime descriptor + * (capabilities//capability.json -> runtime.hostBehaviors). Mirrors + * bin/install.js's / install-engine.cts's `_hostBehaviors` (ADR-1239 / #2086 + * / #2092). Returns {} for runtimes that declare none, so every behavior + * branch degrades to the generic path by default. Unlike the bin/install.js + * and install-engine.cts variants, this module already imports + * `capabilityRegistry` statically (see NON_CLAUDE_RUNTIMES below), so this + * reads it directly rather than re-require()-ing inside a try/catch. + */ +function _hostBehaviors(runtime: string): Record { + return ( + (capabilityRegistry && + capabilityRegistry.runtimes && + capabilityRegistry.runtimes[runtime] && + capabilityRegistry.runtimes[runtime].runtime && + capabilityRegistry.runtimes[runtime].runtime.hostBehaviors) || + {} + ); +} + const colorNameToHex = { cyan: '#00FFFF', @@ -434,10 +455,12 @@ function convertClaudeCommandToClaudeSkill(content, skillName, runtime = null, c const version = gsdVersion(); if (version) fm += `version: ${yamlQuote(version)}\n`; } - // #778 (b) — Qwen-only numeric priority for /skills ordering. Scoped to qwen - // so Claude/Hermes skill frontmatter is unchanged (they ignore the field, but - // we keep their output byte-stable). skillName is the `gsd-` dir name. - if (runtime === 'qwen') { + // #778 (b) — numeric priority for /skills ordering, declared on the runtime + // descriptor (runtime.hostBehaviors.skillPriorityFrontmatter). Scoped to + // runtimes that declare the flag so Claude/Hermes skill frontmatter is + // unchanged (they ignore the field, but we keep their output byte-stable). + // skillName is the `gsd-` dir name. (ADR-1239 / #2092) + if (_hostBehaviors(runtime).skillPriorityFrontmatter) { const stem = typeof skillName === 'string' && skillName.startsWith('gsd-') ? skillName.slice(4) : skillName; @@ -2053,6 +2076,77 @@ function convertClaudeAgentToTraeAgent(content) { return `${cleanFrontmatter}\n${body}`; } +/** + * Convert a Claude agent (.md) to a native Qwen Code subagent file + * (`.qwen/agents/gsd-*.md` / `/agents/gsd-*.md`, ADR-1239 / #2092 + * Phase B Upgrade 1). Qwen Code is a Claude-dialect host: its docs' "Claude + * Code Compatibility Fields" section confirms CC agent files parse under + * `.qwen/agents/` (https://qwenlm.github.io/qwen-code-docs/en/users/features/sub-agents), + * so — unlike Cursor/Trae/Copilot/Antigravity — tool names pass through + * UNCHANGED (no remapping table). + * + * Emits DETERMINISTIC frontmatter: `name:` + `description:` (mirrors + * convertClaudeAgentToCursorAgent), plus `tools:` as a YAML block list when the + * source declares one. Qwen's documented `tools:` schema is a YAML array + * (`tools:\n- tool1\n- tool2`), not Claude's single-line comma-separated string + * — passing the raw single-line string through unchanged would parse as one + * malformed tool name and be silently dropped ("Optional fields with invalid + * values are silently dropped at parse time" — same docs page). Reuses + * `parseFrontmatterTools` (already relied on by the Kimi agent path), which + * tolerates BOTH source formats Claude's own agents/*.md files use — the + * single-line comma list (most agents) and the YAML block list (e.g. + * agents/gsd-nyquist-auditor.md, agents/gsd-security-auditor.md) — so no tools + * are lost regardless of which the source agent uses. + * + * `color` IS preserved: Qwen's docs list `color` under "Claude Code + * Compatibility Fields" as a supported optional field, so it is passed + * through as a plain scalar (unlike the cursor/trae/augment/windsurf + * reduced-frontmatter converters, which drop it — those hosts have no such + * compatibility field). `model:` and `approvalMode:` are intentionally NOT + * emitted: both are optional per the docs and out of scope for #2092 (model: + * would couple to the model catalog and introduce nondeterminism; + * approvalMode is a deliberate follow-on). + * + * Body: preserved verbatim after the qwen branding rewrite (CLAUDE.md / + * Claude Code / .claude/ literal-substring values — descriptor-driven via + * runtime.hostBehaviors.brandingRewrites, mirrors the qwen case in + * _applyRuntimeRewrites). The anchored `~/.claude/` / `$HOME/.claude/` forms + * are already rewritten upstream by applyAgentPathRewrites (agentCtx Step 1 in + * stageAgentsForRuntimeWithConverter) before this converter runs, so only the + * bare/non-anchored forms are handled here — mirrors how + * convertClaudeToTraeMarkdown orders its bare-form rewrites after the slash + * forms to avoid double-rewriting the same substring. + */ +function convertClaudeAgentToQwenAgent(content) { + const _b = _hostBehaviors('qwen').brandingRewrites || {}; + let converted = content; + if (_b['CLAUDE.md']) converted = converted.replace(/CLAUDE\.md/g, _b['CLAUDE.md']); + if (_b['Claude Code']) converted = converted.replace(/\bClaude Code\b/g, _b['Claude Code']); + if (_b['.claude/']) converted = converted.replace(/\.claude\//g, _b['.claude/']); + + const { frontmatter, body } = extractFrontmatterAndBody(converted); + if (!frontmatter) return converted; + + const name = extractFrontmatterField(frontmatter, 'name') || 'unknown'; + const description = extractFrontmatterField(frontmatter, 'description') || ''; + const tools = parseFrontmatterTools(frontmatter); + const color = extractFrontmatterField(frontmatter, 'color'); + + let fm = `---\nname: ${yamlIdentifier(name)}\ndescription: ${yamlQuote(toSingleLine(description))}\n`; + if (tools.length > 0) { + fm += 'tools:\n'; + for (const tool of tools) { + fm += ` - ${yamlIdentifier(tool)}\n`; + } + } + if (color) { + fm += `color: ${yamlIdentifier(color)}\n`; + } + fm += '---'; + + return `${fm}\n${body}`; +} + function convertClaudeAgentToCodebuddyAgent(content) { const converted = convertClaudeToCodebuddyMarkdown(content); @@ -2357,9 +2451,20 @@ function _applyRuntimeRewrites(content, runtime, pathPrefix, isGlobal = false, a content = processAttribution(content, attribution); break; - case 'qwen': - content = content.replace(/CLAUDE\.md/g, 'QWEN.md'); - content = content.replace(/\bClaude Code\b/g, 'Qwen Code'); + // Descriptor-driven brand literals (ADR-1239 / #2092): the qwen/hermes + // brand VALUES (CLAUDE.md/Claude Code/.claude/ replacements) now read from + // runtime.hostBehaviors.brandingRewrites instead of hardcoded literals. + // EXACT regexes/order preserved — only the replacement values changed. + case 'qwen': { + // Guarded (post-review #2092): brandingRewrites is undefined if the + // capability registry fails to load — degrade closed (skip the + // brand-literal replacements, still apply the non-branding path + // rewrites below) instead of throwing on `_b['CLAUDE.md']`. + const _b = _hostBehaviors(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, pathPrefix); content = content.replace(/\$HOME\/\.claude\//g, pathPrefix); content = content.replace(/~\/\.qwen\//g, pathPrefix); @@ -2368,15 +2473,23 @@ function _applyRuntimeRewrites(content, runtime, pathPrefix, isGlobal = false, a content = content.replace(/\$HOME\/\.claude(?![\w-])/g, normalizedPathPrefix); content = content.replace(/~\/\.qwen(?![\w-])/g, normalizedPathPrefix); content = content.replace(/\$HOME\/\.qwen(?![\w-])/g, normalizedPathPrefix); - content = content.replace(/\.claude\//g, '.qwen/'); + if (_b) { + content = content.replace(/\.claude\//g, _b['.claude/']); + } content = content.replace(/\.\/\.claude\//g, `./${dirName}/`); content = content.replace(/\.\/\.qwen\//g, `./${dirName}/`); content = processAttribution(content, attribution); break; + } - case 'hermes': - content = content.replace(/CLAUDE\.md/g, 'HERMES.md'); - content = content.replace(/\bClaude Code\b/g, 'Hermes Agent'); + case 'hermes': { + // Guarded (post-review #2092): see qwen case above — same degrade-closed + // rationale. + const _b = _hostBehaviors(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, pathPrefix); content = content.replace(/\$HOME\/\.claude\//g, pathPrefix); content = content.replace(/~\/\.hermes\//g, pathPrefix); @@ -2385,11 +2498,14 @@ function _applyRuntimeRewrites(content, runtime, pathPrefix, isGlobal = false, a content = content.replace(/\$HOME\/\.claude(?![\w-])/g, normalizedPathPrefix); content = content.replace(/~\/\.hermes(?![\w-])/g, normalizedPathPrefix); content = content.replace(/\$HOME\/\.hermes(?![\w-])/g, normalizedPathPrefix); - content = content.replace(/\.claude\//g, '.hermes/'); + if (_b) { + content = content.replace(/\.claude\//g, _b['.claude/']); + } content = content.replace(/\.\/\.claude\//g, `./${dirName}/`); content = content.replace(/\.\/\.hermes\//g, `./${dirName}/`); content = processAttribution(content, attribution); break; + } case 'kimi': content = content.replace(/~\/\.claude\//g, pathPrefix); @@ -2553,26 +2669,21 @@ function rewriteStagedCommandBodies(stagedDir, opts) { return applyRuntimeContentRewritesForCommandsInPlace(stagedDir, runtime, pathPrefix, isGlobal, attribution); } -/** - * Runtimes that use the hyphen-namespace form `/gsd-` in agent bodies. - * claude/qwen/hermes use hyphen-name:`...` frontmatter; cursor/windsurf/etc - * self-convert. Mirrors the `HYPHEN_NAME_AGENT_RUNTIMES` set in bin/install.js. - * - * @private — export normalizeAgentBodyForRuntime for callers. - */ -const HYPHEN_NAME_AGENT_RUNTIMES: ReadonlySet = new Set(['claude', 'qwen', 'hermes']); - /** * Normalize `/gsd:` colon refs in the agent body to `/gsd-` for - * hyphen-`name:` runtimes (claude / qwen / hermes). No-op for all other - * runtimes. Mirrors the per-file call in bin/install.js line 9400. + * runtimes that declare `runtime.hostBehaviors.hyphenNameAgentBody` on their + * descriptor (claude / qwen / hermes use hyphen-`name:` frontmatter; + * cursor/windsurf/etc self-convert and don't declare the flag). Descriptor- + * driven (ADR-1239 / #2092) — folded from the hardcoded + * `HYPHEN_NAME_AGENT_RUNTIMES` allow-list set. Mirrors the per-file call in + * bin/install.js line 9370 / `shouldNormalizeHyphenNamespaceInAgentBody`. * * @param content raw agent file content (post-converter) * @param runtime canonical runtime ID * @param cmdNames gsd command names from readGsdCommandNames() */ function normalizeAgentBodyForRuntime(content: string, runtime: string, cmdNames: string[]): string { - if (!HYPHEN_NAME_AGENT_RUNTIMES.has(runtime)) return content; + if (_hostBehaviors(runtime).hyphenNameAgentBody !== true) return content; return transformContentToHyphen(content, cmdNames); } @@ -2698,6 +2809,11 @@ export = { convertClaudeAgentToCodebuddyAgent, convertClaudeAgentToClineAgent, convertClaudeAgentToCodexAgent, + // ADR-1239 / #2092 Phase B Upgrade 1: native .qwen/agents/*.md subagent + // projection — registered by name so convertedAgentsKind's + // conversionExports[converterName] dispatch (runtime-artifact-layout.cts) + // can resolve it from capabilities/qwen/capability.json's agents kind. + convertClaudeAgentToQwenAgent, // #1511 ADR-1508 Phase 2: rewrite engine deep seam // Low-level walkers (pathPrefix + attribution pre-resolved by caller): applyRuntimeContentRewritesInPlace, diff --git a/src/runtime-artifact-layout.cts b/src/runtime-artifact-layout.cts index 4d8f34c57..f363c59e6 100644 --- a/src/runtime-artifact-layout.cts +++ b/src/runtime-artifact-layout.cts @@ -193,19 +193,27 @@ function agentsKind(destSubpath: string, prefix: string, configDir: string): Art * Agent filenames are preserved verbatim (the prefix is already embedded in the * agent stem — e.g. `gsd-planner.md`). * - * #1173 SCOPE — plumbing only (declarations deferred): this provides the - * converter dispatch + `isGlobal` scope threading for the descriptor's `agents` - * kind, but NO runtime currently declares a converted `agents` kind in its - * `capability.json`. The descriptor declarations for the 8 non-Claude runtimes - * (copilot/antigravity/cursor/windsurf/augment/trae/codebuddy/cline) are - * DEFERRED to a follow-up that first ships the ADR-1235 §0 byte-for-byte parity - * harness, because the second `layout.kinds` consumer — `applySurface` / - * `/gsd:surface` / `--materialize` (`src/surface.cts`) — does not yet mirror the - * legacy agent pipeline (Copilot's `.agent.md` filename rename, the cross-cutting - * path-prefix rewrite + attribution, stale-file cleanup, config-reading steps), - * so declaring the kind now would regress the surface path. Until then the legacy - * `bin/install.js` agent loop remains authoritative for the real install, and - * this `convertedAgentsKind` is exercised only by synthetic-descriptor seam tests. + * #1173 SCOPE — plumbing only (real install still elsewhere): this provides + * the converter dispatch + `isGlobal` scope threading for the descriptor's + * `agents` kind. As of #2092, 8 non-Claude runtimes DO declare a converted + * `agents` kind in their `capability.json` — qwen (`convertClaudeAgentToQwenAgent`) + * plus the 7 that already declared one before it (antigravity, augment, + * codebuddy, copilot, cursor, trae, windsurf) — so the descriptor-level + * declaration is no longer deferred. What IS still deferred is wiring + * `resolveRuntimeArtifactLayout`'s `agents` kind into the REAL install: + * `bin/install.js`'s agent-staging loop does not consume this module's + * `convertedAgentsKind` resolution at all — it dispatches the very same + * converter functions directly via `_hostBehaviors(runtime)` checks + * (`frontmatterDialect`, `brandingRewrites`, `isCopilot`/`isAntigravity`/…), + * duplicating the mapping declared here. That duplication is deliberate until + * the second `layout.kinds` consumer — `applySurface` / `/gsd:surface` / + * `--materialize` (`src/surface.cts`) — mirrors the legacy agent pipeline + * (Copilot's `.agent.md` filename rename, the cross-cutting path-prefix + * rewrite + attribution, stale-file cleanup, config-reading steps); declaring + * `bin/install.js` itself against this resolver before then would risk + * regressing the surface path. Until that follow-up lands, `bin/install.js` + * remains authoritative for the real install, and this `convertedAgentsKind` + * is exercised only by `/gsd:surface` and synthetic-descriptor seam tests. * * Mirrors the `convertedCommandsKind` pattern (#785). * diff --git a/src/runtime-hooks-surface.cts b/src/runtime-hooks-surface.cts index dbd1141ad..17462c3cc 100644 --- a/src/runtime-hooks-surface.cts +++ b/src/runtime-hooks-surface.cts @@ -107,6 +107,31 @@ const CURSOR_MANAGED_EVENTS = CURSOR_HOOK_EVENTS; const GSD_AGENTS_MD_MARKER = ''; const GSD_AGENTS_MD_CLOSE_MARKER = ''; +// --------------------------------------------------------------------------- +// Descriptor-driven runtime title lookup (ADR-1239 / #2092) +// --------------------------------------------------------------------------- + +/** + * Console-log label for a runtime, sourced from the capability registry's + * `title` field (capabilities//capability.json). Folded from a + * hardcoded `runtime === 'qwen' ? 'Qwen Code' : runtime === 'claude' ? + * 'Claude Code' : runtime` ternary — cosmetic (log text) only, but resolves + * to the same 'Qwen Code' / 'Claude Code' values for those two runtimes. + * Falls back to the raw runtime id if the registry can't be loaded or the + * runtime has no title. + */ +function _capabilityTitle(runtime: string): string { + try { + // eslint-disable-next-line @typescript-eslint/no-require-imports + const reg = require('./capability-registry.cjs') as { + runtimes?: Record; + }; + return reg?.runtimes?.[runtime]?.title || runtime; + } catch { + return runtime; + } +} + // --------------------------------------------------------------------------- // atomicWriteFileSync — shared canonical implementation. // @@ -1548,12 +1573,18 @@ function applySettingsJsonHooks(settings: any, opts: ApplySettingsJsonHooksOpts) console.warn(` ${yellow}⚠${reset} Skipped phase boundary hook — Bash executable path unavailable (#3393)`); } - // ── Extended hook events: SubagentStop / Stop / PreCompact (#788 + #770) ── - // Claude Code (since #770) and Qwen Code (since #788) both support these - // three lifecycle events. Wire gsd-context-monitor so agents get context- - // headroom warnings at subagent completion, model stop, and pre-compaction - // (the most critical moment to surface headroom info). + // ── Extended hook events: SubagentStop / Stop / PreCompact / SubagentStart + // (#788 + #770 + #2092) ──────────────────────────────────────────────── + // Claude Code (since #770) and Qwen Code (since #788) both support the + // SubagentStop / Stop / PreCompact lifecycle events. Qwen Code additionally + // supports SubagentStart (#2092 Phase B, Upgrade 2). Wire gsd-context- + // monitor so agents get context-headroom warnings at subagent start, + // subagent completion, model stop, and pre-compaction (the most critical + // moment to surface headroom info). // + // SubagentStart — subagent lifecycle start (context headroom tracking; + // qwen-only today — no other runtime declares it in + // extendedHookEvents) // SubagentStop — subagent lifecycle completion (context headroom tracking) // Stop — model stop / final-response moment (context headroom) // PreCompact — fires before conversation compaction (most critical @@ -1563,11 +1594,15 @@ function applySettingsJsonHooks(settings: any, opts: ApplySettingsJsonHooksOpts) // user prompt text, not a tool invocation, so gsd-prompt-guard (which // exits unless tool_name is Write/Edit) would be a silent no-op. A // dedicated handler for UserPromptSubmit is deferred to a follow-on issue. - // SubagentStop, Stop, PreCompact — route through the context monitor. - // Guard is now descriptor-driven: only events present in extendedEvents are wired. + // SubagentStart, SubagentStop, Stop, PreCompact — route through the context monitor. + // Guard is descriptor-driven: only events present in extendedEvents are wired, + // so this loop is a no-op for every runtime that doesn't list SubagentStart. { - const runtimeLabel = runtime === 'qwen' ? 'Qwen Code' : runtime === 'claude' ? 'Claude Code' : runtime; - for (const event of ['SubagentStop', 'Stop', 'PreCompact']) { + // Descriptor-driven (ADR-1239 / #2092): folded from a hardcoded + // `runtime === 'qwen' ? ... : ...` ternary into a capability-title + // lookup (see _capabilityTitle above). + const runtimeLabel = _capabilityTitle(runtime); + for (const event of ['SubagentStop', 'Stop', 'PreCompact', 'SubagentStart']) { if (!extendedEvents.includes(event)) continue; if (!settings.hooks[event]) { settings.hooks[event] = []; @@ -1591,7 +1626,7 @@ function applySettingsJsonHooks(settings: any, opts: ApplySettingsJsonHooksOpts) } } } - // ── end SubagentStop / Stop / PreCompact events ──────────────────────────── + // ── end SubagentStop / Stop / PreCompact / SubagentStart events ──────────── // ── Extended hook events (#776; Gemini runtime removed #1928) ────────────── // The Gemini-3-backend dialect exposes several hook events beyond diff --git a/tests/capability-registry.test.cjs b/tests/capability-registry.test.cjs index 012abceac..b963f3236 100644 --- a/tests/capability-registry.test.cjs +++ b/tests/capability-registry.test.cjs @@ -4001,9 +4001,9 @@ describe('ADR-1016 phase 5a: closed-vocab set exports', () => { // ─── 25. ADR-857 phase 5e: closed ConverterName enum (Part B) ───────────────── describe('ADR-857 phase 5e: VALID_CONVERTER_NAMES closed enum', () => { - test('VALID_CONVERTER_NAMES has exactly 25 entries (16 command/skill/workflow + 9 agent converters)', () => { + test('VALID_CONVERTER_NAMES has exactly 26 entries (16 command/skill/workflow + 10 agent converters)', () => { assert.ok(VALID_CONVERTER_NAMES instanceof Set, 'VALID_CONVERTER_NAMES must be a Set'); - assert.strictEqual(VALID_CONVERTER_NAMES.size, 25, 'VALID_CONVERTER_NAMES must have exactly 25 entries, got: ' + VALID_CONVERTER_NAMES.size); + assert.strictEqual(VALID_CONVERTER_NAMES.size, 26, 'VALID_CONVERTER_NAMES must have exactly 26 entries, got: ' + VALID_CONVERTER_NAMES.size); }); test('VALID_CONVERTER_NAMES contains all expected converter names', () => { @@ -4035,6 +4035,8 @@ describe('ADR-857 phase 5e: VALID_CONVERTER_NAMES closed enum', () => { 'convertClaudeAgentToCodebuddyAgent', 'convertClaudeAgentToClineAgent', 'convertClaudeAgentToCodexAgent', + // ADR-1239 / #2092 Phase B Upgrade 1 — native .qwen/agents/*.md subagent projection. + 'convertClaudeAgentToQwenAgent', ]; for (const name of expected) { assert.ok(VALID_CONVERTER_NAMES.has(name), 'VALID_CONVERTER_NAMES must contain "' + name + '"'); @@ -4424,10 +4426,11 @@ describe('ADR-857 phase 5f: cross-field consistency gate rejection tests (DEFECT } }); - test('VALID_EXTENDED_HOOK_EVENTS covers all 7 known extended events', () => { + test('VALID_EXTENDED_HOOK_EVENTS covers all 8 known extended events', () => { assert.ok(VALID_EXTENDED_HOOK_EVENTS instanceof Set, 'Must be a Set'); - assert.strictEqual(VALID_EXTENDED_HOOK_EVENTS.size, 7, 'Must cover 7 extended hook events'); - for (const ev of ['SubagentStop', 'Stop', 'PreCompact', 'FileChanged', 'BeforeAgent', 'AfterAgent', 'BeforeModel']) { + assert.strictEqual(VALID_EXTENDED_HOOK_EVENTS.size, 8, 'Must cover 8 extended hook events'); + // SubagentStart added #2092 Phase B Upgrade 2 (qwen-only today). + for (const ev of ['SubagentStop', 'Stop', 'PreCompact', 'FileChanged', 'BeforeAgent', 'AfterAgent', 'BeforeModel', 'SubagentStart']) { assert.ok(VALID_EXTENDED_HOOK_EVENTS.has(ev), 'Must include event "' + ev + '"'); } }); diff --git a/tests/fixtures/golden-install-parity/qwen.json b/tests/fixtures/golden-install-parity/qwen.json index 2472db607..8b56dc4ac 100644 --- a/tests/fixtures/golden-install-parity/qwen.json +++ b/tests/fixtures/golden-install-parity/qwen.json @@ -1,40 +1,40 @@ { ".gsd-profile": "0e716a5fef4e6dc1", ".gsd/defaults.json": "560664b045e645cb", - "agents/gsd-advisor-researcher.md": "f1c4a47d3d8cea72", - "agents/gsd-ai-researcher.md": "08847faf5494ff08", - "agents/gsd-assumptions-analyzer.md": "f11a80795163bb5d", - "agents/gsd-code-fixer.md": "5660b5d7a912dbac", - "agents/gsd-code-reviewer.md": "f70ee630a166fec8", - "agents/gsd-codebase-mapper.md": "b2885ffa8bb75d47", - "agents/gsd-debug-session-manager.md": "0b23e9c20c77f55c", - "agents/gsd-debugger.md": "7411b2f67a801c3e", - "agents/gsd-doc-classifier.md": "29b563a146c9d22c", - "agents/gsd-doc-synthesizer.md": "652beb928e93fa1d", - "agents/gsd-doc-verifier.md": "3767850df78f4a36", - "agents/gsd-doc-writer.md": "10962b6bd4212379", - "agents/gsd-domain-researcher.md": "412cdbb05ba252ea", - "agents/gsd-eval-auditor.md": "2d121dbec93fd4ce", - "agents/gsd-eval-planner.md": "03448fc9c5774b56", - "agents/gsd-executor.md": "0735dcf053be7c76", - "agents/gsd-framework-selector.md": "ea9981d65d6b3429", - "agents/gsd-integration-checker.md": "33fd537b862eb944", - "agents/gsd-intel-updater.md": "699dacdc0a1f2478", - "agents/gsd-mempalace-curator.md": "399e90e473b33814", - "agents/gsd-nyquist-auditor.md": "7fb0ecf639c1cdfe", - "agents/gsd-pattern-mapper.md": "9ab071c3ffc1cf46", - "agents/gsd-phase-researcher.md": "ecb365e5f62aaafc", - "agents/gsd-plan-checker.md": "ca47fdf6d4a36849", - "agents/gsd-planner.md": "7cb18e515bab3c05", - "agents/gsd-project-researcher.md": "d38509cab69224d9", - "agents/gsd-research-synthesizer.md": "6d64fce8b06b7a62", - "agents/gsd-roadmapper.md": "604b25c6687811f4", - "agents/gsd-security-auditor.md": "f85447e5b5300feb", - "agents/gsd-ui-auditor.md": "cc46d104cbc49079", - "agents/gsd-ui-checker.md": "702b224dede2618f", - "agents/gsd-ui-researcher.md": "3cab4101dd0a3e1d", - "agents/gsd-user-profiler.md": "ca3bf75581f211a0", - "agents/gsd-verifier.md": "45c450b6d7cc5f0d", + "agents/gsd-advisor-researcher.md": "692fbf5ed5e233d5", + "agents/gsd-ai-researcher.md": "f32dc6c6ed7e89fd", + "agents/gsd-assumptions-analyzer.md": "6a2e38d685ba0fce", + "agents/gsd-code-fixer.md": "3ed1a27ddc372ef8", + "agents/gsd-code-reviewer.md": "27eeeee6cb600e9b", + "agents/gsd-codebase-mapper.md": "032ff8ac55466a74", + "agents/gsd-debug-session-manager.md": "9f64af6513b8ec9e", + "agents/gsd-debugger.md": "d0a1e6a1b1cfd9e6", + "agents/gsd-doc-classifier.md": "bdf3d54082424e76", + "agents/gsd-doc-synthesizer.md": "96c383b74a60fbbe", + "agents/gsd-doc-verifier.md": "da9ab6f3ec27778a", + "agents/gsd-doc-writer.md": "7b247b42f674c9ba", + "agents/gsd-domain-researcher.md": "bd054bb27beed2a7", + "agents/gsd-eval-auditor.md": "57cc7458ab5de6b7", + "agents/gsd-eval-planner.md": "01b665728dde4ccf", + "agents/gsd-executor.md": "b50d53e45df6431c", + "agents/gsd-framework-selector.md": "82ba6abea84226b7", + "agents/gsd-integration-checker.md": "90835dbc7dfa1691", + "agents/gsd-intel-updater.md": "3cc4f6ddd04676ec", + "agents/gsd-mempalace-curator.md": "7a81acfbacc8bbd6", + "agents/gsd-nyquist-auditor.md": "2bf7126001ba3e70", + "agents/gsd-pattern-mapper.md": "83c66c7722e8b165", + "agents/gsd-phase-researcher.md": "284e55a86ae46d7f", + "agents/gsd-plan-checker.md": "c8a8fcc8ed38eff0", + "agents/gsd-planner.md": "a14e9e4981519580", + "agents/gsd-project-researcher.md": "b5baac64a15c85e2", + "agents/gsd-research-synthesizer.md": "6cd9b501dc97bd50", + "agents/gsd-roadmapper.md": "c357a77ab919e9e5", + "agents/gsd-security-auditor.md": "d6d8f82501f10b92", + "agents/gsd-ui-auditor.md": "47937e784c9ae541", + "agents/gsd-ui-checker.md": "7d708c53a106f748", + "agents/gsd-ui-researcher.md": "e3768304c1c77753", + "agents/gsd-user-profiler.md": "13388a80dc302a66", + "agents/gsd-verifier.md": "113d8dcff860b595", "gsd-core/VERSION": "ef0deccd81a6723c", "gsd-core/bin/check-latest-version.cjs": "e4a224058c8f4d74", "gsd-core/bin/ensure-runtime-build.cjs": "6e98d76e955e35a2", diff --git a/tests/qwen-imperative-reference.test.cjs b/tests/qwen-imperative-reference.test.cjs new file mode 100644 index 000000000..489dcc624 --- /dev/null +++ b/tests/qwen-imperative-reference.test.cjs @@ -0,0 +1,127 @@ +// allow-test-rule: structural-regression-guard — AC2 requires asserting no `runtime === 'qwen'` string-equality branch remains in bin/install.js, src/install-engine.cts, src/runtime-artifact-conversion.cts, and src/runtime-hooks-surface.cts — the descriptor-migration contract is a property of the source text, so a source-grep is the only faithful check (#2092) +'use strict'; + +/** + * qwen imperative reference host — ADR-1239 Phase D / #2092 (EoS/qwen). + * + * Proves qwen is driven through the PUBLIC Host-Integration Interface (the + * imperative adapter), that its negotiated axes classify + negotiate correctly, + * that negotiation fails CLOSED on a corrupted descriptor, and that the + * migration retired the hardcoded `runtime === 'qwen'` branches across the + * install engine, artifact conversion, and hooks-surface modules (folded into + * descriptor-driven `runtime.hostBehaviors`). + * + * Unlike hermes (#2091), qwen has NO `extensionEvents` dialect of its own — it + * still uses the borrowed `hookEvents: "claude"` 6-event surface plus + * `extendedHookEvents` for the SubagentStop/Stop/PreCompact/SubagentStart + * lifecycle events, so this file does not assert an extensionEvents dialect. + * Instead qwen contributes two real upgrades hermes lacked: a native `agents` + * artifact-layout kind (`.qwen/agents/*.md` subagent projection) and a + * `SubagentStart` hook — both covered in tests/qwen-upgrades.test.cjs. + */ + +const { test } = require('node:test'); +const assert = require('node:assert/strict'); +const fs = require('node:fs'); +const path = require('node:path'); + +const { createImperativeAdapter } = require('../gsd-core/bin/lib/adapter-imperative.cjs'); +const { + profileOf, + negotiateHostCapabilities, + PROFILE_BASELINES, + UNDOCUMENTED, +} = require('../gsd-core/bin/lib/host-integration.cjs'); + +const QWEN_CAP = JSON.parse( + fs.readFileSync(path.join(__dirname, '..', 'capabilities', 'qwen', 'capability.json'), 'utf8'), +); +const QWEN_AXES = QWEN_CAP.runtime.hostIntegration; + +// -- AC2: driven through the public interface (imperative adapter) ----------- + +test('createImperativeAdapter classifies qwen as imperative + composes the registry', () => { + const adapter = createImperativeAdapter({ runtime: 'qwen' }); + assert.equal(adapter.kind, 'imperative'); + assert.equal(adapter.runtime, 'qwen'); + assert.ok(adapter.registry && typeof adapter.registry === 'object'); + assert.equal(typeof adapter.install, 'function'); + assert.equal(typeof adapter.uninstall, 'function'); +}); + +test('qwen axes classify as the programmatic-cli reference profile', () => { + assert.equal(profileOf(QWEN_AXES), 'programmatic-cli'); +}); + +// -- AC3: all axes populated + validated ------------------------------------- + +test('qwen descriptor declares all 8 axes + 6 dispatch sub-axes with exact values', () => { + assert.equal(QWEN_AXES.embeddingMode, 'imperative'); + assert.equal(QWEN_AXES.commandSurface, 'slash-file'); + assert.equal(QWEN_AXES.modelMode, 'passive'); + assert.equal(QWEN_AXES.hookBus, 'host'); + assert.equal(QWEN_AXES.stateIO, 'filesystem'); + assert.equal(QWEN_AXES.transport, 'mcp'); + assert.equal(QWEN_AXES.runtime, 'node'); + const d = QWEN_AXES.dispatch; + assert.equal(d.namedDispatch, true); + assert.equal(d.nested, false); + assert.equal(d.maxDepth, 1); + assert.equal(d.background, true); + assert.equal(d.subagentToolkit, 'full'); + assert.equal(d.backgroundDispatch, false); +}); + +// -- AC5: negotiation fails CLOSED on a corrupted descriptor ------------------ + +test('negotiateHostCapabilities never throws for qwen, even fully corrupted', () => { + assert.doesNotThrow(() => negotiateHostCapabilities({})); + assert.doesNotThrow(() => negotiateHostCapabilities({ ...QWEN_AXES, embeddingMode: UNDOCUMENTED })); + assert.doesNotThrow(() => negotiateHostCapabilities({ ...QWEN_AXES, embeddingMode: 'future-unknown' })); +}); + +test('a partial/empty qwen descriptor degrades to the safe floor, not the programmatic-cli baseline', () => { + const result = negotiateHostCapabilities({}); + assert.equal(result.effective.embeddingMode, 'declarative', 'omitted embeddingMode degrades closed'); + assert.equal(result.effective.hookBus, 'none'); + assert.notDeepEqual(result.effective, PROFILE_BASELINES['programmatic-cli']); + assert.ok(result.warnings.length > 0); +}); + +// -- AC2: the folded-in behaviors --------------------------------------------- + +test('qwen descriptor declares runtime.hostBehaviors (the folded-in behaviors)', () => { + const hb = QWEN_CAP.runtime.hostBehaviors; + assert.ok(hb && typeof hb === 'object'); + assert.equal(hb.skillPriorityFrontmatter, true); + assert.ok(hb.brandingRewrites && typeof hb.brandingRewrites === 'object'); + assert.equal(hb.brandingRewrites['CLAUDE.md'], 'QWEN.md'); + assert.equal(hb.brandingRewrites['Claude Code'], 'Qwen Code'); + assert.equal(hb.brandingRewrites['.claude/'], '.qwen/'); + assert.equal(hb.legacyCommandsGsdCleanup, true); + assert.equal(hb.legacyCommandsGsdInstallMigration, true); + assert.equal(hb.legacyCommandsGsdUninstall, true); + assert.equal(hb.hyphenNameAgentBody, true); +}); + +// -- AC2: the hardcoded branches are retired across all folded modules ------- + +test('no `runtime === "qwen"` string-equality branch remains in the descriptor-migrated modules (AC2)', () => { + const strip = (src) => src + .replace(/\/\*[\s\S]*?\*\//g, '') + .replace(/\/\/[^\r\n]*/g, '') + .replace(/`[^`]*`/g, ''); + const repoRoot = path.join(__dirname, '..'); + const files = [ + path.join(repoRoot, 'bin', 'install.js'), + path.join(repoRoot, 'src', 'install-engine.cts'), + path.join(repoRoot, 'src', 'runtime-artifact-conversion.cts'), + path.join(repoRoot, 'src', 'runtime-hooks-surface.cts'), + ]; + for (const file of files) { + const src = fs.readFileSync(file, 'utf8'); + const offenders = strip(src).match(/runtime\s*[!=]==\s*'qwen'/g) || []; + assert.deepEqual(offenders, [], + `AC2: no hardcoded runtime==='qwen' branch may remain in ${path.relative(repoRoot, file)}; found: ${offenders.join(', ')}`); + } +}); diff --git a/tests/qwen-upgrades.test.cjs b/tests/qwen-upgrades.test.cjs new file mode 100644 index 000000000..9a4b431d5 --- /dev/null +++ b/tests/qwen-upgrades.test.cjs @@ -0,0 +1,152 @@ +'use strict'; + +/** + * qwen capability UPGRADES — ADR-1239 Phase D / #2092 (EoS/qwen). + * + * Drives the user-reachable surface (spawned `bin/install.js` via + * `runMinimalInstall`) to prove the two real upgrades qwen contributes beyond + * the hermes migration (#2091): + * + * UPGRADE 1 — native `agents` artifact-layout kind: GSD specialist agents + * project into `/agents/gsd-*.md` as native Qwen subagents + * (`name:`/`description:`/`tools:` YAML-block frontmatter — Qwen's own + * subagent schema, converted by `convertClaudeAgentToQwenAgent`). + * + * UPGRADE 2 — `SubagentStart` hook: wired symmetrically with the existing + * `SubagentStop` hook (same command + timeout), and proven to be + * descriptor-gated — a runtime whose `extendedHookEvents` omits + * `SubagentStart` (e.g. claude) must NOT get the hook, even though it does + * get `SubagentStop`. + */ + +const { test } = require('node:test'); +const assert = require('node:assert/strict'); +const fs = require('node:fs'); +const path = require('node:path'); + +const { runMinimalInstall } = require('./helpers/install-shared.cjs'); +const { cleanup } = require('./helpers.cjs'); +const { listAgentFiles } = require('./helpers/agent-roster.cjs'); + +const QWEN_CAP = JSON.parse( + fs.readFileSync(path.join(__dirname, '..', 'capabilities', 'qwen', 'capability.json'), 'utf8'), +); + +/** Extract the YAML frontmatter block (between the first pair of `---` lines), or null. */ +function parseFrontmatter(content) { + const m = content.match(/^---\r?\n([\s\S]*?)\r?\n---/); + return m ? m[1] : null; +} + +// -- UPGRADE 1: native agents artifact-layout kind --------------------------- + +for (const scope of ['global', 'local']) { + test(`qwen --${scope}: native .qwen/agents/*.md subagent projection (UPGRADE 1)`, (t) => { + const { configDir, root } = runMinimalInstall({ runtime: 'qwen', scope }); + t.after(() => cleanup(root)); + + const agentsDir = path.join(configDir, 'agents'); + assert.ok(fs.existsSync(agentsDir), `${agentsDir} must exist`); + + const expectedNames = listAgentFiles(); // dynamically derived source roster + assert.equal(expectedNames.length, 34, + 'sanity: shipped GSD agent roster is 34 files — update this boundary if the roster changes'); + + const installedFiles = fs.readdirSync(agentsDir) + .filter((f) => f.startsWith('gsd-') && f.endsWith('.md')); + // Boundary-safe: at least the full expected roster, AND every expected + // name present by NAME (not just count) — a mis-copy that drops one + // agent while adding a stray file would still satisfy a bare >= count. + assert.ok(installedFiles.length >= expectedNames.length, + `expected at least ${expectedNames.length} installed agents under ${agentsDir}, got ${installedFiles.length}`); + for (const name of expectedNames) { + assert.ok(installedFiles.includes(`${name}.md`), `${name}.md must be installed under ${agentsDir}`); + } + + // A couple of named agents, explicitly, spanning both source frontmatter + // styles (single-line `tools:` vs YAML block `tools:`) to exercise + // parseFrontmatterTools' dual-format tolerance end to end. + for (const known of ['gsd-code-reviewer', 'gsd-security-auditor', 'gsd-nyquist-auditor']) { + const filePath = path.join(agentsDir, `${known}.md`); + assert.ok(fs.existsSync(filePath), `${filePath} must exist`); + + const content = fs.readFileSync(filePath, 'utf8'); + const fm = parseFrontmatter(content); + assert.ok(fm, `${known}.md must have YAML frontmatter — resolvable by Qwen's subagent loader`); + + assert.match(fm, /^name:\s*\S+/m, `${known}.md frontmatter must declare name:`); + assert.match(fm, /^description:\s*\S/m, `${known}.md frontmatter must declare description:`); + + // tools:, when present, must be a YAML block list (Qwen's documented + // schema — https://qwenlm.github.io/qwen-code-docs — not Claude's + // single-line comma-separated string. + if (/^tools:/m.test(fm)) { + assert.match(fm, /^tools:\r?\n(?: {2}- .+\r?\n?)+/m, + `${known}.md tools: must be emitted as a YAML block list`); + } + + // color: is a documented Claude-Code-compatibility field in Qwen's + // native subagent schema — it must be preserved, not dropped. + assert.match(fm, /^color:\s*\S+/m, `${known}.md frontmatter must carry a preserved color: field`); + + // No branding residue from the Claude Code source. + assert.ok(!content.includes('CLAUDE.md'), `${known}.md must not contain residual "CLAUDE.md"`); + assert.ok(!content.includes('Claude Code'), `${known}.md must not contain residual "Claude Code"`); + assert.ok(!content.includes('.claude/'), `${known}.md must not contain residual ".claude/"`); + } + }); +} + +// -- UPGRADE 2: SubagentStart hook fires symmetrically with SubagentStop ---- + +test('qwen --global: settings.json SubagentStart mirrors SubagentStop (UPGRADE 2)', (t) => { + const { configDir, root } = runMinimalInstall({ runtime: 'qwen', scope: 'global' }); + t.after(() => cleanup(root)); + + const settingsPath = path.join(configDir, 'settings.json'); + assert.ok(fs.existsSync(settingsPath), `${settingsPath} must exist`); + const settings = JSON.parse(fs.readFileSync(settingsPath, 'utf8')); + + const subagentStart = settings.hooks && settings.hooks.SubagentStart; + const subagentStop = settings.hooks && settings.hooks.SubagentStop; + assert.ok(Array.isArray(subagentStart) && subagentStart.length > 0, + 'settings.hooks.SubagentStart must exist and be non-empty'); + assert.ok(Array.isArray(subagentStop) && subagentStop.length > 0, + 'settings.hooks.SubagentStop must exist and be non-empty'); + + const startEntry = subagentStart[0].hooks[0]; + const stopEntry = subagentStop[0].hooks[0]; + assert.equal(startEntry.command, stopEntry.command, 'SubagentStart must wire the same command as SubagentStop'); + assert.equal(startEntry.timeout, 10); + assert.equal(stopEntry.timeout, 10); +}); + +test('a non-qwen runtime that does not declare SubagentStart does NOT get a SubagentStart hook (descriptor-gated, not global)', (t) => { + const { configDir, root } = runMinimalInstall({ runtime: 'claude', scope: 'global' }); + t.after(() => cleanup(root)); + + const settingsPath = path.join(configDir, 'settings.json'); + assert.ok(fs.existsSync(settingsPath), `${settingsPath} must exist`); + const settings = JSON.parse(fs.readFileSync(settingsPath, 'utf8')); + + // claude DOES get SubagentStop (sanity — proves hooks are wired at all for + // this runtime) but must NOT get SubagentStart — claude's + // extendedHookEvents omits it, proving the loop extension is + // descriptor-gated, not a global default applied to every runtime. + assert.ok( + settings.hooks && Array.isArray(settings.hooks.SubagentStop) && settings.hooks.SubagentStop.length > 0, + 'claude must have SubagentStop wired (sanity — proves hooks ARE configured)', + ); + assert.ok( + !settings.hooks || settings.hooks.SubagentStart === undefined, + "claude must NOT have SubagentStart wired — it is not in claude's extendedHookEvents", + ); +}); + +// -- boundary/negative: extendedHookEvents is exactly the 4 documented events + +test('capabilities/qwen/capability.json extendedHookEvents contains exactly the 4 documented events', () => { + const events = QWEN_CAP.runtime.extendedHookEvents; + assert.deepEqual(events, ['SubagentStop', 'Stop', 'PreCompact', 'SubagentStart']); + assert.equal(events.length, 4); +}); diff --git a/tests/runtime-artifact-layout-descriptor-drive.test.cjs b/tests/runtime-artifact-layout-descriptor-drive.test.cjs index 686529649..e1034f0b1 100644 --- a/tests/runtime-artifact-layout-descriptor-drive.test.cjs +++ b/tests/runtime-artifact-layout-descriptor-drive.test.cjs @@ -143,11 +143,15 @@ const GOLDEN = { // ── qwen ───────────────────────────────────────────────────────────────────── // Old switch: no scope branch → local == global. 5b backfill restores this. + // ADR-1239 / #2092 Phase B Upgrade 1: agents kind added (native + // .qwen/agents/*.md subagent projection). 'qwen/global': [ { kind: 'skills', destSubpath: 'skills', prefix: 'gsd-' }, + { kind: 'agents', destSubpath: 'agents', prefix: 'gsd-' }, ], 'qwen/local': [ { kind: 'skills', destSubpath: 'skills', prefix: 'gsd-' }, + { kind: 'agents', destSubpath: 'agents', prefix: 'gsd-' }, ], // ── hermes ─────────────────────────────────────────────────────────────────── diff --git a/tests/runtime-artifact-layout.test.cjs b/tests/runtime-artifact-layout.test.cjs index e0df1ac38..dc6261ac0 100644 --- a/tests/runtime-artifact-layout.test.cjs +++ b/tests/runtime-artifact-layout.test.cjs @@ -222,15 +222,23 @@ describe('resolveRuntimeArtifactLayout — trae', () => { }); describe('resolveRuntimeArtifactLayout — qwen', () => { - test('returns correct layout for qwen', () => { + test('returns correct layout for qwen (skills + agents — #2092 Phase B Upgrade 1)', () => { const layout = resolveRuntimeArtifactLayout('qwen', FAKE_DIR); assert.strictEqual(layout.runtime, 'qwen'); assert.strictEqual(layout.configDir, FAKE_DIR); - assert.strictEqual(layout.kinds.length, 1); - assert.strictEqual(layout.kinds[0].kind, 'skills'); - assert.strictEqual(layout.kinds[0].destSubpath, 'skills'); - assert.strictEqual(layout.kinds[0].prefix, 'gsd-'); - assert.strictEqual(typeof layout.kinds[0].stage, 'function'); + assert.strictEqual(layout.kinds.length, 2); + + const skillsKind = layout.kinds.find(k => k.kind === 'skills'); + assert.ok(skillsKind, 'must have a skills kind'); + assert.strictEqual(skillsKind.destSubpath, 'skills'); + assert.strictEqual(skillsKind.prefix, 'gsd-'); + assert.strictEqual(typeof skillsKind.stage, 'function'); + + const agentsKind = layout.kinds.find(k => k.kind === 'agents'); + assert.ok(agentsKind, 'must have an agents kind (#2092 Phase B Upgrade 1 — native .qwen/agents/*.md subagent projection)'); + assert.strictEqual(agentsKind.destSubpath, 'agents'); + assert.strictEqual(agentsKind.prefix, 'gsd-'); + assert.strictEqual(typeof agentsKind.stage, 'function'); }); });