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

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