feat(#2092): migrate Qwen Code onto EoS imperative adapter + native subagents + SubagentStart (ADR-1239)

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 <noreply@anthropic.com>
This commit is contained in:
Tom Boucher
2026-07-10 14:46:59 -04:00
parent 91a68ae8e6
commit c6ce110efa
24 changed files with 819 additions and 172 deletions

View File

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

View File

@@ -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:<cmd>` colon refs leak into installed agent prose. Sibling fixes
* #3677 predicate — true when an agent body needs `/gsd:<cmd>` → `/gsd-<cmd>`
* normalization at install time. Descriptor-driven
* (capabilities/<runtime>/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:<cmd>` → `/gsd-<cmd>`
* 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-<stem>` 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-<stem>` 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)) {

View File

@@ -89,6 +89,9 @@
"stateIO": "filesystem",
"transport": "mcp",
"runtime": "go"
},
"hostBehaviors": {
"reviewerCli": true
}
}
}

View File

@@ -95,7 +95,11 @@
],
"ownsClaudePaths": true,
"nativeModelAliases": true,
"skillsGlobalOnboarding": true
"skillsGlobalOnboarding": true,
"legacyCommandsGsdInstallMigration": true,
"legacyCommandsGsdUninstall": "global",
"hyphenNameAgentBody": true,
"reviewerCli": true
}
}
}

View File

@@ -78,7 +78,8 @@
"tomlConfigInstall": true,
"cleanupSkillSidecars": true,
"agentTomlFiles": true,
"frontmatterDialect": "codex"
"frontmatterDialect": "codex",
"reviewerCli": true
}
}
}

View File

@@ -114,7 +114,8 @@
"stop",
"subagentStart",
"subagentStop"
]
],
"reviewerCli": true
}
}
}

View File

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

View File

@@ -99,7 +99,8 @@
"skipHomePrefixSubstitution": true,
"skipSettingsUi": true,
"skipUpdateBannerCommand": true,
"skipCodexSkillsManifest": true
"skipCodexSkillsManifest": true,
"reviewerCli": true
}
}
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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<string, string> | 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)) {

View File

@@ -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/<id>/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/<id>/ 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<string> = [
const NON_RUNTIME_REVIEWER_SLUGS: ReadonlyArray<string> = [
'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<string, { runtime?: { hostBehaviors?: { reviewerCli?: boolean } } }>;
};
const runtimes = registry.runtimes || {};
return Object.keys(runtimes).filter(
(id) => runtimes[id]?.runtime?.hostBehaviors?.reviewerCli === true,
);
}
export const KNOWN_REVIEWER_SLUGS: ReadonlyArray<string> = [
...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-]*$/;

View File

@@ -62,6 +62,27 @@ function gsdVersion(): string {
return cachedVersion;
}
/**
* Host-specific install behaviors declared on the runtime descriptor
* (capabilities/<runtime>/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<string, unknown> {
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-<stem>` 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-<stem>` 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` / `<qwenhome>/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-<cmd>` 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<string> = new Set(['claude', 'qwen', 'hermes']);
/**
* Normalize `/gsd:<cmd>` colon refs in the agent body to `/gsd-<cmd>` 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,

View File

@@ -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).
*

View File

@@ -107,6 +107,31 @@ const CURSOR_MANAGED_EVENTS = CURSOR_HOOK_EVENTS;
const GSD_AGENTS_MD_MARKER = '<!-- GSD Configuration — managed by gsd-core installer -->';
const GSD_AGENTS_MD_CLOSE_MARKER = '<!-- End GSD Configuration -->';
// ---------------------------------------------------------------------------
// Descriptor-driven runtime title lookup (ADR-1239 / #2092)
// ---------------------------------------------------------------------------
/**
* Console-log label for a runtime, sourced from the capability registry's
* `title` field (capabilities/<runtime>/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<string, { title?: string } | undefined>;
};
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

View File

@@ -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 + '"');
}
});

View File

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

View File

@@ -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(', ')}`);
}
});

View File

@@ -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 `<configDir>/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);
});

View File

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

View File

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