From f744635b3dd0f19e7c81c6e395d3d840fdc1a8de Mon Sep 17 00:00:00 2001 From: Tom Boucher Date: Fri, 10 Jul 2026 20:59:31 -0400 Subject: [PATCH] feat(#2094): migrate Trae onto EoS imperative adapter + SOLO stage-metadata upgrade (ADR-1239) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Fold trae logic branches into descriptor-driven reads: skipSharedHooksInstall gates (dropped && !isTrae), and the case 'trae' path-rewrite arm now computes the self-alias from the descriptor-driven dirName (.trae). Dead isTrae bindings removed from uninstall/writeManifest/finishInstall. trae's skills dispatch was already descriptor-driven (converter-by-name). RUNTIME_CONTENT_DISPATCH.trae is left as a runtime-keyed table registration (its regex/callback rewrites can't be a byte-identical descriptor map — matches cursor/windsurf/cline). trae stays in RUNTIME_FLAG_IDS: isTrae still gates the agents-converter selection (agents out of scope; removal gated on the cross-runtime agents-dispatch migration). Byte-identical golden parity for all 16 runtimes. UPGRADE: SOLO stage/trigger metadata — emitted Trae SKILL.md now carries stage: workflow (descriptor-gated via hostBehaviors.soloStageMetadata) so Trae's SOLO Agent can auto-invoke GSD skills at the corresponding stage. Field shape is best-effort/inferred (Trae publishes no formal schema). trae.json golden regenerated. Tests: trae-imperative-reference (adapter/axes/fail-closed shouldFlattenDispatch + no runtime==='trae' source-grep, isTrae exempted for agents) + trae-upgrades (stage: workflow on installed SKILL.md, descriptor-gated). Matrix note + changeset added. Co-Authored-By: Claude Opus 4.8 --- .../2094-eos-trae-imperative-adapter.md | 5 + bin/install.js | 56 +++++-- capabilities/trae/capability.json | 4 + .../host-integration-capability-matrix.md | 2 + gsd-core/bin/lib/capability-registry.cjs | 8 + src/runtime-artifact-conversion.cts | 22 ++- src/runtime-name-policy.cts | 5 + .../fixtures/golden-install-parity/trae.json | 144 ++++++++--------- tests/runtime-converters.test.cjs | 43 +++++ tests/trae-imperative-reference.test.cjs | 153 ++++++++++++++++++ tests/trae-upgrades.test.cjs | 112 +++++++++++++ 11 files changed, 466 insertions(+), 88 deletions(-) create mode 100644 .changeset/2094-eos-trae-imperative-adapter.md create mode 100644 tests/trae-imperative-reference.test.cjs create mode 100644 tests/trae-upgrades.test.cjs diff --git a/.changeset/2094-eos-trae-imperative-adapter.md b/.changeset/2094-eos-trae-imperative-adapter.md new file mode 100644 index 000000000..8139f82bd --- /dev/null +++ b/.changeset/2094-eos-trae-imperative-adapter.md @@ -0,0 +1,5 @@ +--- +type: Changed +pr: 2157 +--- +**GSD skills installed for Trae now carry SOLO stage metadata** — Trae's SOLO Agent can recognize GSD skills as workflow-stage skills for auto-invocation instead of requiring manual triggering. Several of Trae's install branches (shared-hooks gating, path rewrites) also move onto its capability descriptor. Note: the stage-metadata field is a best-effort/inferred shape — Trae publishes no formal schema. (#2094) diff --git a/bin/install.js b/bin/install.js index ee10477b4..7ae843e70 100755 --- a/bin/install.js +++ b/bin/install.js @@ -2618,6 +2618,14 @@ function convertClaudeToTraeMarkdown(content) { return converted; } +// DEFECT.GENERATIVE-FIX: this body is mirrored in +// src/runtime-artifact-conversion.cts's convertClaudeCommandToTraeSkill (used +// by src/install-engine.cts's skills-install path via +// SKILLS_CONVERTER_REGISTRY). This bin/install.js copy is dead for the live +// skills-install path — kept for this file's own module-level export/test +// surface. Neither copy re-exports the other — mirror any behavior change +// into both. Guarded by the output-parity test in +// tests/runtime-converters.test.cjs (#2094). function convertClaudeCommandToTraeSkill(content, skillName) { const converted = convertClaudeToTraeMarkdown(content); const { frontmatter, body } = extractFrontmatterAndBody(converted); @@ -2632,7 +2640,16 @@ function convertClaudeCommandToTraeSkill(content, skillName) { const shortDescription = description.length > 180 ? `${description.slice(0, 177)}...` : description; // #2876: quote so YAML flow indicators (`[BETA] …`) don't break Trae's // frontmatter parser. - return `---\nname: ${yamlIdentifier(skillName)}\ndescription: ${yamlQuote(shortDescription)}\n---\n${body}`; + let fm = `---\nname: ${yamlIdentifier(skillName)}\ndescription: ${yamlQuote(shortDescription)}\n`; + // #2094: emit `stage:` so Trae's SOLO agent can auto-invoke GSD skills at + // the corresponding stage (docs.trae.ai/ide/agent). The field name/schema + // is not formally documented (thin SPA docs) — descriptor-driven, single + // fixed GSD-side value (runtime.hostBehaviors.soloStageMetadata), inferred/ + // best-effort. + const soloStage = _hostBehaviors('trae').soloStageMetadata; + if (soloStage) fm += `stage: ${soloStage}\n`; + fm += '---'; + return `${fm}\n${body}`; } function convertClaudeAgentToTraeAgent(content) { @@ -6803,7 +6820,10 @@ function uninstall(isGlobal, runtime = DEFAULT_RUNTIME) { // #2093: isKilo dropped — the Kilo permission-cleanup branch below is // descriptor-driven (resolveInstallPlan(runtime).finishPermissionWriter), // not gated on this flag. - const { isOpencode, isCodex, isCopilot, isAntigravity, isCursor, isWindsurf, isAugment, isTrae, isQwen, isHermes, isCodebuddy, isCline, isKimi } = runtimeFlags(runtime); + // #2094: isTrae dropped — unused in this function after the + // skipSharedHooksInstall fold (was never referenced here besides the + // destructure). + const { isOpencode, isCodex, isCopilot, isAntigravity, isCursor, isWindsurf, isAugment, isQwen, isHermes, isCodebuddy, isCline, isKimi } = runtimeFlags(runtime); const dirName = getDirName(runtime); // Get the target directory based on runtime and install type. Cline local @@ -7845,7 +7865,9 @@ function resolveInstallRelativePath(baseDir, relPath) { */ function writeManifest(configDir, runtime = DEFAULT_RUNTIME, options = {}) { // #2093: isKilo dropped — unused in this function. - const { isOpencode, isCodex, isCopilot, isAntigravity, isCursor, isWindsurf, isAugment, isTrae, isQwen, isHermes, isCodebuddy, isCline, isKimi } = runtimeFlags(runtime); + // #2094: isTrae dropped — was only used in the hooks-tracking conditional + // above, now covered by hostBehaviors.skipSharedHooksInstall. + const { isOpencode, isCodex, isCopilot, isAntigravity, isCursor, isWindsurf, isAugment, isQwen, isHermes, isCodebuddy, isCline, isKimi } = runtimeFlags(runtime); const gsdDir = path.join(configDir, 'gsd-core'); // #1367: Claude local now writes flat gsd-*.md files at commands/ (not commands/gsd/). // Claude local uses flatCommandsDir instead for manifest recording. @@ -7953,7 +7975,9 @@ function writeManifest(configDir, runtime = DEFAULT_RUNTIME, options = {}) { // Hooks are only installed for runtimes that use settings.json (not Codex/Copilot/Cline) // Descriptor-driven (ADR-1239 / #2089+#2090): cline's exclusion is via // hostBehaviors.skipSharedHooksInstall (was hardcoded !isCline). - if (!isCodex && !isCopilot && _hostBehaviors(runtime).skipSharedHooksInstall !== true && !isWindsurf && !isTrae && !isKimi) { + // #2094: Trae's exclusion is likewise descriptor-driven (trae declares + // skipSharedHooksInstall:true) — the redundant `&& !isTrae` was removed. + if (!isCodex && !isCopilot && _hostBehaviors(runtime).skipSharedHooksInstall !== true && !isWindsurf && !isKimi) { const hooksDir = path.join(configDir, 'hooks'); if (fs.existsSync(hooksDir)) { // Drive from INSTALLED_HOOK_FILES (the canonical HOOKS_TO_COPY set from @@ -9373,8 +9397,10 @@ function install(isGlobal, runtime = DEFAULT_RUNTIME, options = {}) { // skipSharedHooksInstall:true) — the redundant `&& !isCline` was removed. // #2093: Kilo's exclusion is likewise descriptor-driven (kilo declares // skipSharedHooksInstall:true) — the redundant `&& !isKilo` was removed. + // #2094: Trae's exclusion is likewise descriptor-driven (trae declares + // skipSharedHooksInstall:true) — the redundant `&& !isTrae` was removed. // ZCode still has an empty hostBehaviors, so `&& !isZcode` stays. - if (!isCodex && !isCopilot && _hostBehaviors(runtime).skipSharedHooksInstall !== true && !isWindsurf && !isTrae && !isKimi && !isZcode) { + if (!isCodex && !isCopilot && _hostBehaviors(runtime).skipSharedHooksInstall !== true && !isWindsurf && !isKimi && !isZcode) { // Write package.json to force CommonJS mode for GSD scripts // Prevents "require is not defined" errors when project has "type": "module" // Node.js walks up looking for package.json - this stops inheritance from project @@ -9469,15 +9495,16 @@ function install(isGlobal, runtime = DEFAULT_RUNTIME, options = {}) { // Codex/Copilot/Cursor/Windsurf/Trae/Cline/Kilo do not use the shared hooks/lib/ // helpers (Cursor uses standalone .js hook scripts registered via hooks.json — gated // descriptor-driven via hostBehaviors.skipSharedHooksInstall, #2089; Cline likewise - // #2090; Kilo likewise #2093; Codex uses hooks.json directly; the others skip hooks - // entirely); Kilo and ZCode also skip hooks entirely (hooksSurface:'none' with no - // plugin surface — #1821). ZCode's hostBehaviors is still empty, so `&& !isZcode` - // stays hardcoded. OpenCode is NOT excluded: its #1914 plugin adapter spawns the - // staged hooks and requires hooks/lib/ helpers. None of the excluded runtimes must - // receive the hooks/lib/ helpers — otherwise the Codex comment downstream ("we - // deliberately do *not* copy hooks/lib/ for Codex") is contradicted in practice. + // #2090; Kilo likewise #2093; Trae likewise #2094; Codex uses hooks.json directly; + // the others skip hooks entirely); Kilo and ZCode also skip hooks entirely + // (hooksSurface:'none' with no plugin surface — #1821). ZCode's hostBehaviors is + // still empty, so `&& !isZcode` stays hardcoded. OpenCode is NOT excluded: its + // #1914 plugin adapter spawns the staged hooks and requires hooks/lib/ helpers. + // None of the excluded runtimes must receive the hooks/lib/ helpers — otherwise + // the Codex comment downstream ("we deliberately do *not* copy hooks/lib/ for + // Codex") is contradicted in practice. const hooksLibSrc = path.join(src, 'hooks', 'lib'); - if (!isCodex && !isCopilot && _hostBehaviors(runtime).skipSharedHooksInstall !== true && !isWindsurf && !isTrae && !isKimi && !isZcode && fs.existsSync(hooksLibSrc)) { + if (!isCodex && !isCopilot && _hostBehaviors(runtime).skipSharedHooksInstall !== true && !isWindsurf && !isKimi && !isZcode && fs.existsSync(hooksLibSrc)) { const hooksLibDest = path.join(targetDir, 'hooks', 'lib'); fs.mkdirSync(hooksLibDest, { recursive: true }); copyLibDir(hooksLibSrc, hooksLibDest, GSD_HOOK_LIB_FILES); @@ -10413,7 +10440,8 @@ function install(isGlobal, runtime = DEFAULT_RUNTIME, options = {}) { function finishInstall(settingsPath, settings, statuslineCommand, shouldInstallStatusline, runtime = DEFAULT_RUNTIME, isGlobal = true, configDir = null, bannerOpts = {}) { // #2093: isKilo dropped — the Kilo permissions-writer call below is gated // on plan.finishPermissionWriter === 'kilo' (descriptor-driven), not this flag. - const { isOpencode, isCodex, isCopilot, isAntigravity, isCursor, isWindsurf, isAugment, isTrae, isQwen, isHermes, isCodebuddy, isCline, isKimi } = runtimeFlags(runtime); + // #2094: isTrae dropped — unused in this function. + const { isOpencode, isCodex, isCopilot, isAntigravity, isCursor, isWindsurf, isAugment, isQwen, isHermes, isCodebuddy, isCline, isKimi } = runtimeFlags(runtime); const plan = resolveInstallPlan(runtime); if (shouldInstallStatusline && plan.writesSharedSettings && !_hostBehaviors(runtime).skipSettingsUi) { diff --git a/capabilities/trae/capability.json b/capabilities/trae/capability.json index 9acf76b57..35cf1aa42 100644 --- a/capabilities/trae/capability.json +++ b/capabilities/trae/capability.json @@ -81,6 +81,10 @@ "stateIO": "filesystem", "transport": "mcp", "runtime": "node" + }, + "hostBehaviors": { + "skipSharedHooksInstall": true, + "soloStageMetadata": "workflow" } } } diff --git a/docs/reference/host-integration-capability-matrix.md b/docs/reference/host-integration-capability-matrix.md index 5a8e0d695..f9648f948 100644 --- a/docs/reference/host-integration-capability-matrix.md +++ b/docs/reference/host-integration-capability-matrix.md @@ -538,6 +538,8 @@ Documentation gaps: - dispatch.maxDepth — no integer depth limit documented beyond one orchestrator level. - dispatch.subagentToolkit — docs say agents can be configured with 'callable MCP services and other capabilities' but do not state whether sub-agents receive a full vs. restricted tool set. +**EoS migration status (#2094):** Migrated onto the imperative adapter — partially. Two `runtime === 'trae'` string-equality branches folded into descriptor-driven `runtime.hostBehaviors`: `skipSharedHooksInstall:true` gates the shared-hooks install (Trae has no hook surface: `hooksSurface: "none"`), and the `case 'trae'` global-config-dir path-rewrite's self-alias regex is now built off the descriptor's `dirName` rather than a hardcoded `~/.trae/` literal (byte-identical output). Skills dispatch was already descriptor-driven before this migration (`artifactLayout.skills.converter: "convertClaudeCommandToTraeSkill"`, resolved by converter name, not a runtime check). **Still runtime-keyed** (not folded by #2094, matching the same posture as cursor/windsurf/cline, pending a future cross-runtime content-dispatch consolidation): `RUNTIME_CONTENT_DISPATCH.trae` in `bin/install.js` — its `md`/`js` bodies are regex-callback rewrites that cannot be reduced to a byte-identical descriptor map; and the `case 'trae':` switch arm itself in `src/runtime-artifact-conversion.cts` — the arm's *structure* (not just its self-alias regex) is boilerplate shared verbatim across 7 runtimes (`codex`, `cline`, `cursor`, `windsurf`, `augment`, `trae`, `codebuddy`) and remains a runtime-keyed `switch`. `trae` also remains in `RUNTIME_FLAG_IDS` (and `isTrae` remains in `bin/install.js`, gating only the agents-converter dispatch) pending the cross-runtime agents-converter dispatch migration — agents conversion is out of scope for #2094. One upgrade lands: **SOLO stage/trigger metadata** — every emitted `SKILL.md` now carries a `stage: workflow` frontmatter line (`runtime.hostBehaviors.soloStageMetadata`), so Trae's SOLO Agent can recognize GSD skills as workflow-stage skills for auto-invocation instead of requiring manual triggering; cite https://docs.trae.ai/ide/agent ("Agents in Trae can be called individually, or automatically called by SOLO Agent at the corresponding stage"). The field is a single fixed, best-effort/inferred GSD-side value — Trae's thin SPA docs don't publish a formal stage-metadata schema. The four `undocumented` dispatch sub-axes (`nested`, `maxDepth`, `subagentToolkit`, `backgroundDispatch`) keep dispatch flattened (`shouldFlattenDispatch` fails closed to inline) — fail-closed negotiation, not a regression. + --- ## kimi diff --git a/gsd-core/bin/lib/capability-registry.cjs b/gsd-core/bin/lib/capability-registry.cjs index 8f7c13618..bed3603f3 100644 --- a/gsd-core/bin/lib/capability-registry.cjs +++ b/gsd-core/bin/lib/capability-registry.cjs @@ -2561,6 +2561,10 @@ const capabilities = { "stateIO": "filesystem", "transport": "mcp", "runtime": "node" + }, + "hostBehaviors": { + "skipSharedHooksInstall": true, + "soloStageMetadata": "workflow" } } }, @@ -5084,6 +5088,10 @@ const runtimes = { "stateIO": "filesystem", "transport": "mcp", "runtime": "node" + }, + "hostBehaviors": { + "skipSharedHooksInstall": true, + "soloStageMetadata": "workflow" } } }, diff --git a/src/runtime-artifact-conversion.cts b/src/runtime-artifact-conversion.cts index 73bb330ac..1c9a66b0f 100644 --- a/src/runtime-artifact-conversion.cts +++ b/src/runtime-artifact-conversion.cts @@ -1169,6 +1169,12 @@ function convertClaudeToTraeMarkdown(content) { return converted; } +// DEFECT.GENERATIVE-FIX: this body is mirrored in bin/install.js's +// convertClaudeCommandToTraeSkill (dead for the live skills-install path, +// which routes here via install-engine.cts's SKILLS_CONVERTER_REGISTRY; kept +// for bin/install.js's own module-level export/test surface). Neither copy +// re-exports the other — mirror any behavior change into both. Guarded by +// the output-parity test in tests/runtime-converters.test.cjs (#2094). function convertClaudeCommandToTraeSkill(content, skillName) { const converted = convertClaudeToTraeMarkdown(content); const { frontmatter, body } = extractFrontmatterAndBody(converted); @@ -1183,7 +1189,16 @@ function convertClaudeCommandToTraeSkill(content, skillName) { const shortDescription = description.length > 180 ? `${description.slice(0, 177)}...` : description; // #2876: quote so YAML flow indicators (`[BETA] …`) don't break Trae's // frontmatter parser. - return `---\nname: ${yamlIdentifier(skillName)}\ndescription: ${yamlQuote(shortDescription)}\n---\n${body}`; + let fm = `---\nname: ${yamlIdentifier(skillName)}\ndescription: ${yamlQuote(shortDescription)}\n`; + // #2094: emit `stage:` so Trae's SOLO agent can auto-invoke GSD skills at + // the corresponding stage (docs.trae.ai/ide/agent). The field name/schema + // is not formally documented (thin SPA docs) — descriptor-driven, single + // fixed GSD-side value (runtime.hostBehaviors.soloStageMetadata), inferred/ + // best-effort. + const soloStage = _hostBehaviors('trae').soloStageMetadata as string | undefined; + if (soloStage) fm += `stage: ${soloStage}\n`; + fm += '---'; + return `${fm}\n${body}`; } function convertSlashCommandsToCodebuddySkillMentions(content) { @@ -2431,7 +2446,10 @@ function _applyRuntimeRewrites(content, runtime, pathPrefix, isGlobal = false, a content = content.replace(/~\/\.claude\b/g, normalizedPathPrefix); content = content.replace(/\$HOME\/\.claude\b/g, normalizedPathPrefix); content = content.replace(/\.\/\.claude\b/g, `./${dirName}`); - content = content.replace(/~\/\.trae\//g, pathPrefix); + // #2094: descriptor-driven — dirName resolves to '.trae' via + // getDirName()/localConfigDir, so this regex is built rather than + // hardcoded as `/~\/\.trae\//g` (byte-identical output for trae). + content = content.replace(new RegExp('~/' + escapeRegExp(dirName) + '/', 'g'), pathPrefix); content = processAttribution(content, attribution); break; diff --git a/src/runtime-name-policy.cts b/src/runtime-name-policy.cts index 685e4b5aa..824d75919 100644 --- a/src/runtime-name-policy.cts +++ b/src/runtime-name-policy.cts @@ -266,6 +266,11 @@ export function getGlobalConfigHomeFragment(runtime: string): string { * function declaration block (the add-a-host tax ADR-1239 Phase B / #1679 AC2 * removes). */ +// #2094: 'trae' stays here — bin/install.js's agents-converter dispatch +// (convertClaudeAgentToTraeAgent selection) still reads isTrae directly. +// Removing it is gated on migrating that runtime-keyed `else if` chain to a +// cross-runtime agents-dispatch table (out of scope for #2094, which only +// folds the shared-hooks-install skip). const RUNTIME_FLAG_IDS = Object.freeze([ 'opencode', 'kilo', 'codex', 'copilot', 'antigravity', 'cursor', 'windsurf', 'augment', 'trae', 'qwen', 'hermes', 'codebuddy', 'cline', 'kimi', 'zcode', diff --git a/tests/fixtures/golden-install-parity/trae.json b/tests/fixtures/golden-install-parity/trae.json index 3374172bb..3109e4263 100644 --- a/tests/fixtures/golden-install-parity/trae.json +++ b/tests/fixtures/golden-install-parity/trae.json @@ -324,76 +324,76 @@ "scripts/gen-loop-host-contract.cjs": "c7f15237234811a0", "scripts/lib/allowlist-ratchet.cjs": "ffaceaac3efc2660", "scripts/lib/cli-exit.cjs": "612d0c372c75b7e7", - "skills/gsd-ns-context/SKILL.md": "e278a50f3ecb8f56", - "skills/gsd-ns-context/skills/docs-update/SKILL.md": "52755343585b987b", - "skills/gsd-ns-context/skills/extract-learnings/SKILL.md": "d39269bea995fabc", - "skills/gsd-ns-context/skills/graphify/SKILL.md": "e0253aef14693a7e", - "skills/gsd-ns-context/skills/map-codebase/SKILL.md": "2982436d8652f172", - "skills/gsd-ns-context/skills/mempalace-capture/SKILL.md": "36c486f14abe869a", - "skills/gsd-ns-context/skills/mempalace-recall/SKILL.md": "2d95e385fee26dee", - "skills/gsd-ns-ideate/SKILL.md": "c10342345c01c91f", - "skills/gsd-ns-ideate/skills/capture/SKILL.md": "90aee26970fa7639", - "skills/gsd-ns-ideate/skills/explore/SKILL.md": "d8e26a5ed95a4ecc", - "skills/gsd-ns-ideate/skills/sketch/SKILL.md": "ede2d3ab60a3c613", - "skills/gsd-ns-ideate/skills/spec-phase/SKILL.md": "f773e17e1ae7fb35", - "skills/gsd-ns-ideate/skills/spike/SKILL.md": "da215c1fb96a70e5", - "skills/gsd-ns-manage/SKILL.md": "0f4fb6d2f96ed7e9", - "skills/gsd-ns-manage/skills/cleanup/SKILL.md": "a578d6536b9cabc3", - "skills/gsd-ns-manage/skills/config/SKILL.md": "becac686746b68a4", - "skills/gsd-ns-manage/skills/health/SKILL.md": "74a68e1c1b310bef", - "skills/gsd-ns-manage/skills/help/SKILL.md": "0f0ae57a446b4601", - "skills/gsd-ns-manage/skills/inbox/SKILL.md": "34370c6138415209", - "skills/gsd-ns-manage/skills/manager/SKILL.md": "fba2aa10f8b29d84", - "skills/gsd-ns-manage/skills/pause-work/SKILL.md": "42924a70d6a4b96f", - "skills/gsd-ns-manage/skills/pr-branch/SKILL.md": "c25b5a3e31ab6f74", - "skills/gsd-ns-manage/skills/resume-work/SKILL.md": "5f0d4d0e86b99180", - "skills/gsd-ns-manage/skills/settings/SKILL.md": "67eff2d16e17403c", - "skills/gsd-ns-manage/skills/ship/SKILL.md": "4ad9695934e069ee", - "skills/gsd-ns-manage/skills/stats/SKILL.md": "25898070fb2a4b20", - "skills/gsd-ns-manage/skills/surface/SKILL.md": "c30399ea5d11cc5d", - "skills/gsd-ns-manage/skills/thread/SKILL.md": "67b5a0451b58c50c", - "skills/gsd-ns-manage/skills/undo/SKILL.md": "2a37b9c270d4c785", - "skills/gsd-ns-manage/skills/update/SKILL.md": "2fc47bf059aa7b38", - "skills/gsd-ns-manage/skills/workspace/SKILL.md": "257d17b9d20274d8", - "skills/gsd-ns-manage/skills/workstreams/SKILL.md": "737841783c7fdd4f", - "skills/gsd-ns-project/SKILL.md": "fac77a9e770fd655", - "skills/gsd-ns-project/skills/audit-milestone/SKILL.md": "0ccabc5556d65d56", - "skills/gsd-ns-project/skills/complete-milestone/SKILL.md": "a69d534b385d6aaf", - "skills/gsd-ns-project/skills/import/SKILL.md": "a11e630bfd8ae4de", - "skills/gsd-ns-project/skills/ingest-docs/SKILL.md": "d26637cdafd1a629", - "skills/gsd-ns-project/skills/milestone-summary/SKILL.md": "9d408f56712ba948", - "skills/gsd-ns-project/skills/new-milestone/SKILL.md": "7cbc011130e8590c", - "skills/gsd-ns-project/skills/new-project/SKILL.md": "ff0d57491df30624", - "skills/gsd-ns-project/skills/onboard/SKILL.md": "ae4c7b799d9ffb09", - "skills/gsd-ns-project/skills/profile-user/SKILL.md": "4f526120cda2f6cc", - "skills/gsd-ns-project/skills/review-backlog/SKILL.md": "d8a150558cc9326a", - "skills/gsd-ns-review/SKILL.md": "c5afe33c212947dd", - "skills/gsd-ns-review/skills/audit-fix/SKILL.md": "2c1f601f2fb52585", - "skills/gsd-ns-review/skills/audit-uat/SKILL.md": "2c450432b6fd1c3e", - "skills/gsd-ns-review/skills/code-review/SKILL.md": "71f717d78fd4954d", - "skills/gsd-ns-review/skills/debug/SKILL.md": "9d814fba870c538e", - "skills/gsd-ns-review/skills/eval-review/SKILL.md": "d42b504c0ca2dc69", - "skills/gsd-ns-review/skills/forensics/SKILL.md": "250f476c4c547a0f", - "skills/gsd-ns-review/skills/review/SKILL.md": "c89722cb096066d4", - "skills/gsd-ns-review/skills/secure-phase/SKILL.md": "b35ac9da51635368", - "skills/gsd-ns-review/skills/ui-phase/SKILL.md": "89a29a2fa62b177e", - "skills/gsd-ns-review/skills/ui-review/SKILL.md": "3e23efe03dafd691", - "skills/gsd-ns-review/skills/validate-phase/SKILL.md": "fa1ab5c9a869f6cd", - "skills/gsd-ns-workflow/SKILL.md": "0cb9c23c065fe448", - "skills/gsd-ns-workflow/skills/add-tests/SKILL.md": "6d64ae85b590274f", - "skills/gsd-ns-workflow/skills/ai-integration-phase/SKILL.md": "c458b792bdf5b1f2", - "skills/gsd-ns-workflow/skills/autonomous/SKILL.md": "37dc7b78ceb74803", - "skills/gsd-ns-workflow/skills/discuss-phase/SKILL.md": "c41cf9bf50dc6be6", - "skills/gsd-ns-workflow/skills/execute-phase/SKILL.md": "eb0158de64f22a55", - "skills/gsd-ns-workflow/skills/fast/SKILL.md": "3eae6536d09c2532", - "skills/gsd-ns-workflow/skills/mvp-phase/SKILL.md": "81c98f4436888cb8", - "skills/gsd-ns-workflow/skills/next/SKILL.md": "02993a5aa046eadd", - "skills/gsd-ns-workflow/skills/phase/SKILL.md": "50f6cc4df79a26b7", - "skills/gsd-ns-workflow/skills/plan-phase/SKILL.md": "44d42fa41a094ad9", - "skills/gsd-ns-workflow/skills/plan-review-convergence/SKILL.md": "fee26bc1daedbff5", - "skills/gsd-ns-workflow/skills/progress/SKILL.md": "de48301875978833", - "skills/gsd-ns-workflow/skills/quick/SKILL.md": "d81670625c884d2c", - "skills/gsd-ns-workflow/skills/spec-phase/SKILL.md": "f773e17e1ae7fb35", - "skills/gsd-ns-workflow/skills/ultraplan-phase/SKILL.md": "1cc863a090cdcc6b", - "skills/gsd-ns-workflow/skills/verify-work/SKILL.md": "aae57a48a1b966ac" + "skills/gsd-ns-context/SKILL.md": "9353c36c249db204", + "skills/gsd-ns-context/skills/docs-update/SKILL.md": "26ec00ef228c2bdb", + "skills/gsd-ns-context/skills/extract-learnings/SKILL.md": "1fc6ba92fc18eb86", + "skills/gsd-ns-context/skills/graphify/SKILL.md": "371ce22f2fe319bc", + "skills/gsd-ns-context/skills/map-codebase/SKILL.md": "cfc5310b903d594a", + "skills/gsd-ns-context/skills/mempalace-capture/SKILL.md": "8524838c851ee44f", + "skills/gsd-ns-context/skills/mempalace-recall/SKILL.md": "73b0b7a6301cb214", + "skills/gsd-ns-ideate/SKILL.md": "aa399385b735c7c1", + "skills/gsd-ns-ideate/skills/capture/SKILL.md": "3b7fc696b2107bcf", + "skills/gsd-ns-ideate/skills/explore/SKILL.md": "34495849e42be247", + "skills/gsd-ns-ideate/skills/sketch/SKILL.md": "318e76d151f6c3c1", + "skills/gsd-ns-ideate/skills/spec-phase/SKILL.md": "9291f835233fa8da", + "skills/gsd-ns-ideate/skills/spike/SKILL.md": "b84100b5c869880f", + "skills/gsd-ns-manage/SKILL.md": "d44c39486fd6b83d", + "skills/gsd-ns-manage/skills/cleanup/SKILL.md": "1aa3427198e6ee04", + "skills/gsd-ns-manage/skills/config/SKILL.md": "f67d9051f635a030", + "skills/gsd-ns-manage/skills/health/SKILL.md": "2a4f959f80995d1d", + "skills/gsd-ns-manage/skills/help/SKILL.md": "52a981e1a34dd41d", + "skills/gsd-ns-manage/skills/inbox/SKILL.md": "88b260e6166d7b8d", + "skills/gsd-ns-manage/skills/manager/SKILL.md": "ac5c418cee66ee19", + "skills/gsd-ns-manage/skills/pause-work/SKILL.md": "ba8390423acccdeb", + "skills/gsd-ns-manage/skills/pr-branch/SKILL.md": "28d58cf0bb655bbc", + "skills/gsd-ns-manage/skills/resume-work/SKILL.md": "7e1f9d4d1fc60bca", + "skills/gsd-ns-manage/skills/settings/SKILL.md": "e3590bb5e3d0fdf2", + "skills/gsd-ns-manage/skills/ship/SKILL.md": "456609cf127b36dc", + "skills/gsd-ns-manage/skills/stats/SKILL.md": "f5f5f1fbfe65cee8", + "skills/gsd-ns-manage/skills/surface/SKILL.md": "025a48d0b1129e47", + "skills/gsd-ns-manage/skills/thread/SKILL.md": "48894eaf985265b3", + "skills/gsd-ns-manage/skills/undo/SKILL.md": "0deb95bc44edf37c", + "skills/gsd-ns-manage/skills/update/SKILL.md": "ffcd813ad256769b", + "skills/gsd-ns-manage/skills/workspace/SKILL.md": "e942e86d9e284d0f", + "skills/gsd-ns-manage/skills/workstreams/SKILL.md": "2374d16c8b5653d5", + "skills/gsd-ns-project/SKILL.md": "7c5ed3164048d2fe", + "skills/gsd-ns-project/skills/audit-milestone/SKILL.md": "6d9e265675fd6440", + "skills/gsd-ns-project/skills/complete-milestone/SKILL.md": "9a36fb672ddb2334", + "skills/gsd-ns-project/skills/import/SKILL.md": "46f9c44249ddc9e3", + "skills/gsd-ns-project/skills/ingest-docs/SKILL.md": "9254dae5d5104a80", + "skills/gsd-ns-project/skills/milestone-summary/SKILL.md": "a5aa590c17da3075", + "skills/gsd-ns-project/skills/new-milestone/SKILL.md": "827a2fd21878df0b", + "skills/gsd-ns-project/skills/new-project/SKILL.md": "cbbd2ed15bd72d39", + "skills/gsd-ns-project/skills/onboard/SKILL.md": "28a81722a1083234", + "skills/gsd-ns-project/skills/profile-user/SKILL.md": "7ed27739a950999f", + "skills/gsd-ns-project/skills/review-backlog/SKILL.md": "0285f773cc4e3bfe", + "skills/gsd-ns-review/SKILL.md": "a89fa5e8afbb7c56", + "skills/gsd-ns-review/skills/audit-fix/SKILL.md": "c07aea2676e00c7b", + "skills/gsd-ns-review/skills/audit-uat/SKILL.md": "fc192f6cba1a99fd", + "skills/gsd-ns-review/skills/code-review/SKILL.md": "467fee86ac8af71d", + "skills/gsd-ns-review/skills/debug/SKILL.md": "1b9030f4a138bedc", + "skills/gsd-ns-review/skills/eval-review/SKILL.md": "919f1aaad7d9a46a", + "skills/gsd-ns-review/skills/forensics/SKILL.md": "95f2f0a901b7b31a", + "skills/gsd-ns-review/skills/review/SKILL.md": "c35628771471201e", + "skills/gsd-ns-review/skills/secure-phase/SKILL.md": "875a24903352fc0f", + "skills/gsd-ns-review/skills/ui-phase/SKILL.md": "33b2394c085b93e8", + "skills/gsd-ns-review/skills/ui-review/SKILL.md": "6d6b47e51e60afc6", + "skills/gsd-ns-review/skills/validate-phase/SKILL.md": "536619be8c40ff5e", + "skills/gsd-ns-workflow/SKILL.md": "2c85abe6d78c7cf7", + "skills/gsd-ns-workflow/skills/add-tests/SKILL.md": "6e10735c8b18de8a", + "skills/gsd-ns-workflow/skills/ai-integration-phase/SKILL.md": "b5cce2e6e801420d", + "skills/gsd-ns-workflow/skills/autonomous/SKILL.md": "a9d7374b631ae7b3", + "skills/gsd-ns-workflow/skills/discuss-phase/SKILL.md": "d2f1554b9faab0a1", + "skills/gsd-ns-workflow/skills/execute-phase/SKILL.md": "e05fc375d4b5f7b5", + "skills/gsd-ns-workflow/skills/fast/SKILL.md": "08f8ce2ba96921cc", + "skills/gsd-ns-workflow/skills/mvp-phase/SKILL.md": "fef8178c9920c2dd", + "skills/gsd-ns-workflow/skills/next/SKILL.md": "faadd9e2817e7324", + "skills/gsd-ns-workflow/skills/phase/SKILL.md": "df3efcd61f7cc796", + "skills/gsd-ns-workflow/skills/plan-phase/SKILL.md": "b6f6395312983c31", + "skills/gsd-ns-workflow/skills/plan-review-convergence/SKILL.md": "d67e16a8e28b780d", + "skills/gsd-ns-workflow/skills/progress/SKILL.md": "37a37d2cdfba46ea", + "skills/gsd-ns-workflow/skills/quick/SKILL.md": "6fd1b96274b23a9f", + "skills/gsd-ns-workflow/skills/spec-phase/SKILL.md": "9291f835233fa8da", + "skills/gsd-ns-workflow/skills/ultraplan-phase/SKILL.md": "a3cd6f98acc738f0", + "skills/gsd-ns-workflow/skills/verify-work/SKILL.md": "25dc4ab4e4a060bc" } diff --git a/tests/runtime-converters.test.cjs b/tests/runtime-converters.test.cjs index 593a3104f..b5547bfe9 100644 --- a/tests/runtime-converters.test.cjs +++ b/tests/runtime-converters.test.cjs @@ -19,6 +19,7 @@ const { convertClaudeAgentToAntigravityAgent, convertClaudeCommandToOpencodeSkill, convertClaudeCommandToKiloSkill, + convertClaudeCommandToTraeSkill, neutralizeAgentReferences, } = require('../bin/install.js'); @@ -267,6 +268,48 @@ describe('convertClaudeToKiloFrontmatter output parity: bin/install.js vs runtim }); }); +// ───────────────────────────────────────────────────────────────────────────── +// DEFECT.GENERATIVE-FIX output-parity guard: convertClaudeCommandToTraeSkill is +// defined TWICE — once in bin/install.js (dead for the live skills-install +// path; kept for this file's own module-level export/test surface) and once in +// src/runtime-artifact-conversion.cts, compiled to +// gsd-core/bin/lib/runtime-artifact-conversion.cjs (used by +// src/install-engine.cts's skills-install path via SKILLS_CONVERTER_REGISTRY, +// see install-engine.cts ~L754). Both copies are LIVE call surfaces — neither +// re-exports the other — so #2094's `stage:` emission had to be applied to +// bin/install.js's copy by hand to keep parity. Source-text identity can't be +// asserted (they live in different module systems: plain CJS vs a +// tsc-compiled .cts output with different surrounding comments), so this +// instead proves the two implementations still produce IDENTICAL output for +// representative command input. If a future edit changes one copy's behavior +// without mirroring it into the other, this test is the guard that catches +// the divergence. +// ───────────────────────────────────────────────────────────────────────────── +describe('convertClaudeCommandToTraeSkill output parity: bin/install.js vs runtime-artifact-conversion.cjs (#2094)', () => { + const { convertClaudeCommandToTraeSkill: convertViaConversionModule } = + require('../gsd-core/bin/lib/runtime-artifact-conversion.cjs'); + + test('identical output for a representative command, including the #2094 stage: field', () => { + const viaInstall = convertClaudeCommandToTraeSkill(SAMPLE_COMMAND, 'gsd-execute-phase'); + const viaModule = convertViaConversionModule(SAMPLE_COMMAND, 'gsd-execute-phase'); + assert.equal(viaInstall, viaModule, 'bin/install.js and runtime-artifact-conversion.cjs must emit identical command output'); + assert.match(viaInstall, /\nstage: workflow\n/, 'both copies must emit the #2094 stage: field'); + }); + + test('identical output when the source has no description (falls back to generic description)', () => { + const noDescriptionCommand = `--- +name: gsd-noop +allowed-tools: + - Read +--- + +Do nothing.`; + const viaInstall = convertClaudeCommandToTraeSkill(noDescriptionCommand, 'gsd-noop'); + const viaModule = convertViaConversionModule(noDescriptionCommand, 'gsd-noop'); + assert.equal(viaInstall, viaModule, 'bin/install.js and runtime-artifact-conversion.cjs must emit identical output when description is absent'); + }); +}); + // ───────────────────────────────────────────────────────────────────────────── // Antigravity agent conversion — shared Gemini-backend tool mapping (#1394 / #1928) // ───────────────────────────────────────────────────────────────────────────── diff --git a/tests/trae-imperative-reference.test.cjs b/tests/trae-imperative-reference.test.cjs new file mode 100644 index 000000000..0b004652b --- /dev/null +++ b/tests/trae-imperative-reference.test.cjs @@ -0,0 +1,153 @@ +// allow-test-rule: structural-regression-guard — AC2 requires asserting no `runtime === 'trae'` string-equality branch remains in bin/install.js, src/install-engine.cts, and src/runtime-artifact-conversion.cts — the descriptor-migration contract is a property of the source text, so a source-grep is the only faithful check (#2094) +'use strict'; + +/** + * trae imperative reference host — ADR-1239 Phase D / #2094 (EoS/trae). + * + * Proves Trae IDE 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 === 'trae'` string- + * equality branches across the install engine and artifact conversion + * modules (folded into descriptor-driven `runtime.hostBehaviors`). + * + * Trae has NO hook surface at all (`hooksSurface: "none"`, + * `extendedHookEvents: []`, `installSurface: "profile-marker-only"`) — its + * `hookBus` axis is `'engine'` (VSCode-fork extension-host lifecycle, not a + * GSD-managed hook dialect), so unlike qwen/kilo this file does not assert an + * extendedHookEvents surface. Four of Trae's six dispatch sub-axes + * (`nested`, `maxDepth`, `subagentToolkit`, `backgroundDispatch`) are + * `'undocumented'` — no authoritative Trae doc states them — which is what + * drives the fail-closed `shouldFlattenDispatch` assertion below. The real + * upgrade (SOLO stage/trigger metadata on emitted skills) is covered in + * tests/trae-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, + shouldFlattenDispatch, + PROFILE_BASELINES, + UNDOCUMENTED, +} = require('../gsd-core/bin/lib/host-integration.cjs'); + +const TRAE_CAP = JSON.parse( + fs.readFileSync(path.join(__dirname, '..', 'capabilities', 'trae', 'capability.json'), 'utf8'), +); +const TRAE_AXES = TRAE_CAP.runtime.hostIntegration; + +// -- AC2: driven through the public interface (imperative adapter) ----------- + +test('createImperativeAdapter classifies trae as imperative + composes the registry', () => { + const adapter = createImperativeAdapter({ runtime: 'trae' }); + assert.equal(adapter.kind, 'imperative'); + assert.equal(adapter.runtime, 'trae'); + assert.ok(adapter.registry && typeof adapter.registry === 'object'); + assert.equal(typeof adapter.install, 'function'); + assert.equal(typeof adapter.uninstall, 'function'); +}); + +test('trae axes classify as the programmatic-cli reference profile', () => { + // Confirmed via `node -e` against the real descriptor before asserting: + // profileOf(TRAE_AXES) === 'programmatic-cli' (embeddingMode: 'imperative'). + assert.equal(profileOf(TRAE_AXES), 'programmatic-cli'); +}); + +// -- AC3: all axes populated + validated ------------------------------------- + +test('trae descriptor declares all 8 axes + 6 dispatch sub-axes with exact values', () => { + assert.equal(TRAE_AXES.embeddingMode, 'imperative'); + assert.equal(TRAE_AXES.commandSurface, 'slash-file'); + assert.equal(TRAE_AXES.modelMode, 'passive'); + assert.equal(TRAE_AXES.hookBus, 'engine'); + assert.equal(TRAE_AXES.stateIO, 'filesystem'); + assert.equal(TRAE_AXES.transport, 'mcp'); + assert.equal(TRAE_AXES.runtime, 'node'); + const d = TRAE_AXES.dispatch; + assert.equal(d.namedDispatch, true); + assert.equal(d.nested, 'undocumented'); + assert.equal(d.maxDepth, 'undocumented'); + assert.equal(d.background, true); + assert.equal(d.subagentToolkit, 'undocumented'); + assert.equal(d.backgroundDispatch, 'undocumented'); +}); + +// -- AC5: negotiation fails CLOSED on a corrupted descriptor ------------------ + +test('negotiateHostCapabilities never throws for trae, even fully corrupted', () => { + assert.doesNotThrow(() => negotiateHostCapabilities({})); + assert.doesNotThrow(() => negotiateHostCapabilities({ ...TRAE_AXES, embeddingMode: UNDOCUMENTED })); + assert.doesNotThrow(() => negotiateHostCapabilities({ ...TRAE_AXES, embeddingMode: 'future-unknown' })); +}); + +test('AC-SPECIFIC: trae real dispatch axes fail CLOSED to inline (shouldFlattenDispatch === true)', () => { + // Confirmed via `node -e` against the real descriptor before asserting: + // shouldFlattenDispatch(TRAE_AXES.dispatch) === true. + // + // shouldFlattenDispatch only permits backgrounding when BOTH `background` + // AND `backgroundDispatch` are explicitly `true` (src/host-integration.cts + // shouldFlattenDispatch, `canBackground = background === true && + // backgroundDispatch === true`). Trae's `background` is `true` but + // `backgroundDispatch` is `'undocumented'` (no authoritative Trae doc states + // whether a spawned agent can itself be backgrounded) — so canBackground is + // false and the orchestrator must run inline. Pinned here so a future + // doc-sourcing pass that fills in `backgroundDispatch` can't silently flip + // this fail-closed default without a deliberate test update. + assert.equal(shouldFlattenDispatch(TRAE_AXES.dispatch), true); + + // Sanity: if backgroundDispatch WERE true (all else equal), the same shape + // would NOT flatten — proving backgroundDispatch is what flips the result. + assert.equal(shouldFlattenDispatch({ ...TRAE_AXES.dispatch, backgroundDispatch: true }), false); +}); + +test('a partial/empty trae 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('trae descriptor declares runtime.hostBehaviors (the folded-in behaviors)', () => { + const hb = TRAE_CAP.runtime.hostBehaviors; + assert.ok(hb && typeof hb === 'object'); + assert.equal(hb.skipSharedHooksInstall, true); + assert.equal(hb.soloStageMetadata, 'workflow'); +}); + +// -- AC2: the hardcoded branches are retired across all folded modules ------- + +test('no `runtime === "trae"` string-equality branch remains in the descriptor-migrated modules (AC2)', () => { + // NOTE: this deliberately does NOT grep for `isTrae` — `isTrae` legitimately + // remains in bin/install.js as a destructured `runtimeFlags(runtime)` + // binding used ONLY by the agents-converter dispatch chain (`else if + // (isTrae) { content = convertClaudeAgentToTraeAgent(content); }`). That + // cross-runtime agents-converter dispatch is out of scope for #2094 (a + // separate migration tracked elsewhere) — trae stays in RUNTIME_FLAG_IDS by + // design until that follow-up lands. Only a `runtime === 'trae'` / + // `runtime !== 'trae'` STRING-EQUALITY comparison is a regression here. + 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'), + ]; + for (const file of files) { + const src = fs.readFileSync(file, 'utf8'); + const offenders = strip(src).match(/runtime\s*[!=]==\s*'trae'/g) || []; + assert.deepEqual(offenders, [], + `AC2: no hardcoded runtime==='trae' branch may remain in ${path.relative(repoRoot, file)}; found: ${offenders.join(', ')}`); + } +}); diff --git a/tests/trae-upgrades.test.cjs b/tests/trae-upgrades.test.cjs new file mode 100644 index 000000000..a4c8f2967 --- /dev/null +++ b/tests/trae-upgrades.test.cjs @@ -0,0 +1,112 @@ +'use strict'; + +/** + * trae capability UPGRADE — ADR-1239 Phase D / #2094 (EoS/trae). + * + * Drives the user-reachable surface (spawned `bin/install.js` via + * `runMinimalInstall`) to prove the one real upgrade Trae contributes beyond + * the base imperative migration (tests/trae-imperative-reference.test.cjs): + * + * UPGRADE — SOLO stage/trigger metadata: every emitted `SKILL.md` carries a + * `stage: workflow` frontmatter line (`runtime.hostBehaviors. + * soloStageMetadata`), sourced from https://docs.trae.ai/ide/agent ("Agents + * in Trae can be called individually, or automatically called by SOLO Agent + * at the corresponding stage"). This is a single fixed GSD-side value — + * best-effort/inferred, since Trae's docs don't publish a formal + * stage-metadata schema — not per-skill differentiated. It lets Trae's SOLO + * Agent auto-invoke GSD skills at the corresponding stage instead of + * requiring the user to manually trigger them. + */ + +const { test } = require('node:test'); +const assert = require('node:assert/strict'); +const fs = require('node:fs'); +const path = require('node:path'); + +const { runMinimalInstall, walk } = require('./helpers/install-shared.cjs'); +const { cleanup } = require('./helpers.cjs'); + +const TRAE_CAP = JSON.parse( + fs.readFileSync(path.join(__dirname, '..', 'capabilities', 'trae', '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: SOLO stage/trigger metadata on every emitted SKILL.md --------- + +for (const scope of ['global', 'local']) { + test(`trae --${scope}: emitted SKILL.md frontmatter carries stage: workflow (UPGRADE)`, (t) => { + const { configDir, root } = runMinimalInstall({ runtime: 'trae', scope }); + t.after(() => cleanup(root)); + + const skillsDir = path.join(configDir, 'skills'); + assert.ok(fs.existsSync(skillsDir), `${skillsDir} must exist`); + + // Trae uses NESTED skills (capabilities/trae/capability.json + // artifactLayout.skills.nesting === "nested"): a router SKILL.md at + // skills/gsd-ns-/SKILL.md, and concrete skills nested under + // skills/gsd-ns-/skills//SKILL.md. Discover ALL of them + // by recursive walk rather than hand-listing paths, so the test doesn't + // silently stop covering skills added/renamed later. + const skillFiles = walk(skillsDir).filter((f) => f.endsWith('SKILL.md')); + assert.ok(skillFiles.length > 0, `expected at least one SKILL.md under ${skillsDir}`); + + for (const filePath of skillFiles) { + const content = fs.readFileSync(filePath, 'utf8'); + const fm = parseFrontmatter(content); + assert.ok(fm, `${filePath} must have YAML frontmatter`); + assert.match(fm, /^stage:\s*workflow\s*$/m, + `${filePath} frontmatter must declare stage: workflow (UPGRADE — SOLO stage metadata)`); + } + + // A couple of named skills, explicitly, spanning both the top-level + // router shape and the nested concrete-skill shape — confirmed via + // `node -e` against a real install before asserting these exact paths. + const samples = [ + path.join(skillsDir, 'gsd-ns-manage', 'SKILL.md'), + path.join(skillsDir, 'gsd-ns-manage', 'skills', 'cleanup', 'SKILL.md'), + path.join(skillsDir, 'gsd-ns-ideate', 'skills', 'capture', 'SKILL.md'), + ]; + for (const sample of samples) { + assert.ok(fs.existsSync(sample), `${sample} must exist`); + const fm = parseFrontmatter(fs.readFileSync(sample, 'utf8')); + assert.ok(fm, `${sample} must have YAML frontmatter`); + assert.match(fm, /^stage:\s*workflow\s*$/m, `${sample} frontmatter must declare stage: workflow`); + } + }); +} + +// -- boundary: capability.json is the single source of truth ---------------- + +test('capabilities/trae/capability.json runtime.hostBehaviors.soloStageMetadata === "workflow"', () => { + assert.equal(TRAE_CAP.runtime.hostBehaviors.soloStageMetadata, 'workflow'); +}); + +// -- descriptor-gated, not global: a runtime lacking soloStageMetadata gets +// no stage: line at all ------------------------------------------------- + +test('qwen (no soloStageMetadata declared) does NOT get a stage: line — proves the field is descriptor-gated, not global', (t) => { + const { configDir, root } = runMinimalInstall({ runtime: 'qwen', scope: 'global' }); + t.after(() => cleanup(root)); + + const skillsDir = path.join(configDir, 'skills'); + assert.ok(fs.existsSync(skillsDir), `${skillsDir} must exist`); + // Qwen also uses the nested skill layout (router SKILL.md + + // skills//SKILL.md) — confirmed via `node -e` against a real + // install before writing this — so walk recursively, same as the trae + // assertions above, rather than assuming flat gsd-*.md files. + const skillFiles = walk(skillsDir).filter((f) => f.endsWith('SKILL.md')); + assert.ok(skillFiles.length > 0, `expected at least one SKILL.md under ${skillsDir}`); + + for (const filePath of skillFiles) { + const content = fs.readFileSync(filePath, 'utf8'); + const fm = parseFrontmatter(content); + assert.ok(fm, `${filePath} must have YAML frontmatter`); + assert.doesNotMatch(fm, /^stage:/m, + `${filePath} must NOT declare stage: — qwen's descriptor has no hostBehaviors.soloStageMetadata`); + } +});