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:
5
.changeset/2092-eos-qwen-imperative-adapter.md
Normal file
5
.changeset/2092-eos-qwen-imperative-adapter.md
Normal 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)
|
||||
131
bin/install.js
131
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:<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)) {
|
||||
|
||||
@@ -89,6 +89,9 @@
|
||||
"stateIO": "filesystem",
|
||||
"transport": "mcp",
|
||||
"runtime": "go"
|
||||
},
|
||||
"hostBehaviors": {
|
||||
"reviewerCli": true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -95,7 +95,11 @@
|
||||
],
|
||||
"ownsClaudePaths": true,
|
||||
"nativeModelAliases": true,
|
||||
"skillsGlobalOnboarding": true
|
||||
"skillsGlobalOnboarding": true,
|
||||
"legacyCommandsGsdInstallMigration": true,
|
||||
"legacyCommandsGsdUninstall": "global",
|
||||
"hyphenNameAgentBody": true,
|
||||
"reviewerCli": true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -78,7 +78,8 @@
|
||||
"tomlConfigInstall": true,
|
||||
"cleanupSkillSidecars": true,
|
||||
"agentTomlFiles": true,
|
||||
"frontmatterDialect": "codex"
|
||||
"frontmatterDialect": "codex",
|
||||
"reviewerCli": true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -114,7 +114,8 @@
|
||||
"stop",
|
||||
"subagentStart",
|
||||
"subagentStop"
|
||||
]
|
||||
],
|
||||
"reviewerCli": true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -99,7 +99,8 @@
|
||||
"skipHomePrefixSubstitution": true,
|
||||
"skipSettingsUi": true,
|
||||
"skipUpdateBannerCommand": true,
|
||||
"skipCodexSkillsManifest": true
|
||||
"skipCodexSkillsManifest": true,
|
||||
"reviewerCli": true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 |
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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)) {
|
||||
|
||||
@@ -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-]*$/;
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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).
|
||||
*
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 + '"');
|
||||
}
|
||||
});
|
||||
|
||||
68
tests/fixtures/golden-install-parity/qwen.json
vendored
68
tests/fixtures/golden-install-parity/qwen.json
vendored
@@ -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",
|
||||
|
||||
127
tests/qwen-imperative-reference.test.cjs
Normal file
127
tests/qwen-imperative-reference.test.cjs
Normal 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(', ')}`);
|
||||
}
|
||||
});
|
||||
152
tests/qwen-upgrades.test.cjs
Normal file
152
tests/qwen-upgrades.test.cjs
Normal 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);
|
||||
});
|
||||
@@ -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 ───────────────────────────────────────────────────────────────────
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
Reference in New Issue
Block a user