From a2c7b879d04239a1f35f4af687fef484c9f2fc0e Mon Sep 17 00:00:00 2001 From: Tom Boucher Date: Sat, 11 Jul 2026 16:30:52 -0400 Subject: [PATCH] feat(#2101): dogfood ZCode through the EoS declarative adapter + fold shared-hooks exclusion (ADR-1239) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The issue's "0 conditional branches" premise missed a live one: the `!isZcode` shared-hooks exclusion (bin/install.js). Fold it onto descriptor-driven hostBehaviors.skipSharedHooksInstall:true (zcode's golden has zero hook files — byte-parity verified) and drop the now-unused isZcode destructure. Zero live runtime==='zcode'/isZcode branches remain (AC2 source-grep guard over bin/install.js + install-engine.cts + surface.cts + runtime-artifact-conversion.cts). The 6 CLI-bookkeeping zcode mentions (--zcode flag, menu, roster, help) stay. Reference test (declarative-reference-zcode.test.cjs): profileOf → declarative-cli; createDeclarativeAdapter({runtime:'zcode'}).kind → declarative; a real install emits the invocable nested-skills/commands/agents surface (no hooks); negotiateHostCapabilities fail-closes (empty/corrupt descriptor; the nested/maxDepth undocumented sub-axes degrade to most-restrictive); validateCapability clean. UPGRADES documented as BLOCKED (verified doc gaps — NOT guessed, to avoid a non-functional false-green): both of ZCode's documented capabilities lack a published on-disk config format. - Hook automation: zcode.z.ai/en/docs/plugin documents the Hook component only as "automation hooks triggered on specific events" (capability detected from directory layout) — no config file format/location/event schema. Cannot faithfully wire. - MCP registration: zcode.z.ai/en/docs/mcp-services says servers are "stored in the .zcode configuration file" (UI-only) with no documented on-disk filename/path/schema — exactly the settings-filename gap the issue AC anticipated. Both are documented (with the search trail) in the capability matrix + how-to, per AC4's block-documentation clause; hookBus/transport stay declared for when ZCode publishes the formats. No hook scripts or MCP artifacts added → no golden change, no other-runtime impact. Golden: byte-identical for all 16 runtimes (the fold is byte-parity; no upgrade artifacts). Matrix ## zcode EoS note + how-to; changeset (Changed). capability-registry regenerated. Co-Authored-By: Claude Opus 4.8 --- .changeset/2101-eos-zcode.md | 5 + bin/install.js | 8 +- capabilities/zcode/capability.json | 3 + docs/how-to/install-on-your-runtime.md | 2 + .../host-integration-capability-matrix.md | 4 + gsd-core/bin/lib/capability-registry.cjs | 6 + tests/declarative-reference-zcode.test.cjs | 211 ++++++++++++++++++ 7 files changed, 236 insertions(+), 3 deletions(-) create mode 100644 .changeset/2101-eos-zcode.md create mode 100644 tests/declarative-reference-zcode.test.cjs diff --git a/.changeset/2101-eos-zcode.md b/.changeset/2101-eos-zcode.md new file mode 100644 index 000000000..e3770c283 --- /dev/null +++ b/.changeset/2101-eos-zcode.md @@ -0,0 +1,5 @@ +--- +type: Changed +pr: 2195 +--- +**ZCode's install is now driven and regression-tested through its capability descriptor** — ZCode joins the dogfooded declarative-adapter reference hosts with a byte-identical install, and its shared-hooks exclusion is folded onto `hostBehaviors` instead of a hardcoded runtime branch. (Hook-automation and MCP upgrades remain blocked on ZCode publishing its on-disk config formats.) (#2101) diff --git a/bin/install.js b/bin/install.js index b2ca32c59..e0aae3554 100755 --- a/bin/install.js +++ b/bin/install.js @@ -8859,7 +8859,8 @@ function install(isGlobal, runtime = DEFAULT_RUNTIME, options = {}) { // hostBehaviors.skipSharedHooksInstall respectively; its legacy-agent-loop // converter arm was likewise unreachable dead code (windsurf is in // _DESCRIPTOR_AGENTS_RUNTIMES) and was removed above. - const { isOpencode, isZcode, isCodex, isCursor, isAugment, isTrae, isQwen, isHermes, isCline } = runtimeFlags(runtime); + // #2101: isZcode dropped — folded onto hostBehaviors.skipSharedHooksInstall. + const { isOpencode, isCodex, isCursor, isAugment, isTrae, isQwen, isHermes, isCline } = runtimeFlags(runtime); const plan = resolveInstallPlan(runtime); const dirName = getDirName(runtime); const src = path.join(__dirname, '..'); @@ -10026,7 +10027,8 @@ function install(isGlobal, runtime = DEFAULT_RUNTIME, options = {}) { // 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. + // #2101: ZCode's exclusion is likewise descriptor-driven (zcode declares + // skipSharedHooksInstall:true) — the redundant `&& !isZcode` was removed. // #2095: Kimi's exclusion is likewise descriptor-driven (kimi declares // skipSharedHooksInstall:true) — kimi's shared hooks/ + package.json marker // are instead installed into its OWN native hook root (~/.kimi, resolved by @@ -10037,7 +10039,7 @@ function install(isGlobal, runtime = DEFAULT_RUNTIME, options = {}) { // skipSharedHooksInstall:true) — the redundant `&& !isCopilot` was removed. // #2100: Windsurf's exclusion is likewise descriptor-driven (windsurf declares // skipSharedHooksInstall:true) — the redundant `&& !isWindsurf` was removed. - if (!isCodex && _hostBehaviors(runtime).skipSharedHooksInstall !== true && !isZcode) { + if (!isCodex && _hostBehaviors(runtime).skipSharedHooksInstall !== true) { if (!installSharedHooksBundle(targetDir)) { failures.push('hooks'); } diff --git a/capabilities/zcode/capability.json b/capabilities/zcode/capability.json index a01f80f4b..59555b7a4 100644 --- a/capabilities/zcode/capability.json +++ b/capabilities/zcode/capability.json @@ -97,6 +97,9 @@ "stateIO": "filesystem", "transport": "mcp", "runtime": "electron" + }, + "hostBehaviors": { + "skipSharedHooksInstall": true } } } diff --git a/docs/how-to/install-on-your-runtime.md b/docs/how-to/install-on-your-runtime.md index 5db6c9f9a..3adc7a61c 100644 --- a/docs/how-to/install-on-your-runtime.md +++ b/docs/how-to/install-on-your-runtime.md @@ -457,6 +457,8 @@ npx @opengsd/gsd-core@latest --zcode --global ZCode's skill format is identical to Claude Code's, so no runtime-specific converter is required — GSD lands as a pure declarative descriptor with no hardcoded installer branches. ZCode also natively imports skills and MCP config from `~/.claude`; if you install GSD for **both** Claude and ZCode, you may see duplicate GSD skills inside ZCode, which is expected. To connect ZCode's MCP servers to GSD's companion server, see [how to connect the GSD MCP server](connect-gsd-mcp-server.md). +GSD's hook-automation and native-MCP-registration integrations are not yet wired for ZCode — both are blocked on ZCode not yet publishing the on-disk config format for its plugin `Hook` component or the settings filename/schema for its MCP store. See the [`## zcode`](host-integration-capability-matrix.md#zcode) section of the host-integration capability matrix for the cited source URLs. + --- ## Local vs global install diff --git a/docs/reference/host-integration-capability-matrix.md b/docs/reference/host-integration-capability-matrix.md index 1ac5a4ca2..0263727b9 100644 --- a/docs/reference/host-integration-capability-matrix.md +++ b/docs/reference/host-integration-capability-matrix.md @@ -649,3 +649,7 @@ Documentation gaps: - configHome — skills/commands/agents homes are documented (`~/.zcode/skills`, `~/.zcode/commands`, `~/.zcode/agents`); the exact settings filename under `~/.zcode` (where MCP server config is stored) is not fully documented at time of writing. - Maintenance note — ZCode is a young, fast-moving app (observed at v3.2.x); these axes may need revision as its on-disk config layout stabilizes. Because ZCode also natively imports skills/MCP from `~/.claude`, installing GSD to BOTH `claude` and `zcode` can surface duplicated skills inside ZCode; this overlap is expected and documented. +EoS migration status (#2101, ADR-1239): ZCode's install is fully dogfooded through the declarative adapter — its shared-hooks exclusion (previously a hardcoded `!isZcode` branch in `bin/install.js`) is now folded onto `hostBehaviors.skipSharedHooksInstall`, byte-parity with the prior install (ZCode's golden install tree has zero hook files). The two capability upgrades anticipated for ZCode both remain **blocked** on undocumented on-disk formats — `hookBus` and `transport` above stay documented-but-unimplemented pending ZCode publishing those formats, and implementing a guessed format risks a false-green descriptor, so neither upgrade is wired: +- **Hook automation** (the plugin `Hook` component, `hookBus: host` above) — https://zcode.z.ai/en/docs/plugin documents the capability only at a high level ("Automation hooks triggered on specific events"; components are "detected from directory layout, shown as badges"). No config file format, on-disk location, event-name vocabulary, or payload schema is published, so GSD cannot faithfully wire hook events into a plugin bundle. BLOCKED (undocumented on-disk hook-config format). +- **MCP registration** (`transport: mcp` above) — https://zcode.z.ai/en/docs/mcp-services confirms servers are "stored in the .zcode configuration file of the chosen scope" and accepts both a bare `{"server-name":{...}}` map and an `{"mcpServers":{...}}` wrapper shape, but does not document the exact settings filename/path or full schema (the docs describe the UI flow, not the on-disk contract) — this is the same gap already noted under `configHome` above. BLOCKED (undocumented settings-filename/schema gap). + diff --git a/gsd-core/bin/lib/capability-registry.cjs b/gsd-core/bin/lib/capability-registry.cjs index 569fa235d..e94456718 100644 --- a/gsd-core/bin/lib/capability-registry.cjs +++ b/gsd-core/bin/lib/capability-registry.cjs @@ -2880,6 +2880,9 @@ const capabilities = { "stateIO": "filesystem", "transport": "mcp", "runtime": "electron" + }, + "hostBehaviors": { + "skipSharedHooksInstall": true } } } @@ -5351,6 +5354,9 @@ const runtimes = { "stateIO": "filesystem", "transport": "mcp", "runtime": "electron" + }, + "hostBehaviors": { + "skipSharedHooksInstall": true } } } diff --git a/tests/declarative-reference-zcode.test.cjs b/tests/declarative-reference-zcode.test.cjs new file mode 100644 index 000000000..20e7dbf08 --- /dev/null +++ b/tests/declarative-reference-zcode.test.cjs @@ -0,0 +1,211 @@ +// allow-test-rule: structural-regression-guard — AC2: assert no `runtime === 'zcode'` string-equality branch, no live `isZcode` read remains in bin/install.js, src/install-engine.cts, src/surface.cts, or src/runtime-artifact-conversion.cts — a source-text property, so source-grep is the faithful check (#2101) +'use strict'; + +/** + * Declarative reference host — ZCode (#2101 / ADR-1239 EoS). + * + * ZCode already installs through the descriptor-driven artifactLayout + * (nested skills/, flat commands/, flat agents/, each with a named + * `converter`), and its capability.json already declared `hostIntegration` + * axes. Issue #2101 found ZCode was already descriptor-driven except for one + * residual `isZcode` branch in bin/install.js: the shared-hooks-install + * exclusion (`&& !isZcode`), kept hardcoded because zcode's `hostBehaviors` + * block was previously empty. ZCode's golden install tree has ZERO hook + * files (verified), so folding this onto `hostBehaviors.skipSharedHooksInstall` + * is byte-parity — the same fold already done for + * windsurf/copilot/cursor/cline/kilo/trae (#2089/#2090/#2093/#2094/#2099/#2100). + * + * This test is the reference-host dogfood mirroring + * tests/declarative-reference-windsurf.test.cjs: it (1) classifies ZCode's + * profile via profileOf, (2) confirms the public declarative adapter + * classifies it as declarative, (3) round-trips a real install proving a + * gsd surface is emitted, (4) proves negotiation fails CLOSED on a corrupted + * descriptor, (5) proves the validator accepts the descriptor, and (6) + * source-greps the folded modules for the retired `isZcode` branch (AC2). + * + * Both capability upgrades anticipated by the issue (hook automation via + * ZCode's plugin Hook component; native MCP registration) remain BLOCKED — + * ZCode's docs do not publish the on-disk config format/location/schema for + * either surface (see docs/reference/host-integration-capability-matrix.md + * ## zcode for the cited doc URLs and rationale). No upgrade code is added + * here; implementing a guessed format would risk a false-green descriptor. + */ + +const { test, before } = require('node:test'); +const assert = require('node:assert/strict'); +const fs = require('node:fs'); +const path = require('node:path'); +const { execFileSync } = require('node:child_process'); + +const { + profileOf, + negotiateHostCapabilities, + PROFILE_BASELINES, + UNDOCUMENTED, +} = require('../gsd-core/bin/lib/host-integration.cjs'); +const { validateCapability } = require('../gsd-core/bin/lib/capability-validator.cjs'); +const { createDeclarativeAdapter } = require('../gsd-core/bin/lib/adapter-declarative.cjs'); +const { cleanup } = require('./helpers.cjs'); +const { walk, runMinimalInstall, BUILD_SCRIPT } = require('./helpers/install-shared.cjs'); + +const DESC = path.join(__dirname, '..', 'capabilities', 'zcode', 'capability.json'); +const ZCODE_CAP = JSON.parse(fs.readFileSync(DESC, 'utf8')); +const ZCODE_AXES = ZCODE_CAP.runtime.hostIntegration; + +// hooks/dist is gitignored and built (mirrors golden-install-parity harness). +before(() => { + execFileSync(process.execPath, [BUILD_SCRIPT], { encoding: 'utf-8', stdio: 'pipe' }); +}); + +test('ZCode classifies as the declarative-cli reference profile (profileOf)', () => { + const desc = JSON.parse(fs.readFileSync(DESC, 'utf8')); + const axes = desc.runtime.hostIntegration; + assert.ok(axes && axes.embeddingMode, 'zcode descriptor declares hostIntegration axes'); + assert.equal(profileOf(axes), 'declarative-cli', 'ZCode is a Declarative-CLI host'); +}); + +test('the public declarative adapter classifies ZCode as a declarative host', () => { + const adapter = createDeclarativeAdapter({ runtime: 'zcode' }); + assert.equal(adapter.kind, 'declarative'); + assert.equal(adapter.runtime, 'zcode'); + assert.equal(typeof adapter.install, 'function'); + assert.equal(typeof adapter.uninstall, 'function'); +}); + +test('a real ZCode install emits an invocable gsd skill/command/agent surface', () => { + const { configDir, root } = runMinimalInstall({ runtime: 'zcode', scope: 'global' }); + try { + const files = walk(configDir); + assert.ok(files.length > 0, 'install must emit artifacts'); + const gsdSurface = files.filter((f) => /gsd/i.test(path.relative(configDir, f))); + assert.ok(gsdSurface.length > 0, 'install must emit a gsd surface (declarative reference)'); + + // ZCode's artifactLayout (capabilities/zcode/capability.json) declares + // nested skills/, flat commands/, and flat agents/ — verify all three. + const skillsDir = path.join(configDir, 'skills'); + assert.ok(fs.existsSync(skillsDir), 'skills/ directory must exist'); + const skillDirs = fs.readdirSync(skillsDir, { withFileTypes: true }) + .filter((e) => e.isDirectory() && e.name.startsWith('gsd-')); + assert.ok(skillDirs.length > 0, 'skills/ must contain nested gsd-* skill directories'); + const firstSkillFiles = fs.readdirSync(path.join(skillsDir, skillDirs[0].name)); + assert.ok(firstSkillFiles.includes('SKILL.md'), 'each nested skill dir must contain SKILL.md'); + + const commandsDir = path.join(configDir, 'commands'); + assert.ok(fs.existsSync(commandsDir), 'commands/ directory must exist'); + const cmdFiles = fs.readdirSync(commandsDir).filter((f) => f.startsWith('gsd-') && f.endsWith('.md')); + assert.ok(cmdFiles.length > 0, 'commands/ must contain flat gsd-*.md slash commands'); + + const agentsDir = path.join(configDir, 'agents'); + assert.ok(fs.existsSync(agentsDir), 'agents/ directory must exist'); + const agentFiles = fs.readdirSync(agentsDir).filter((f) => f.startsWith('gsd-') && f.endsWith('.md')); + assert.ok(agentFiles.length > 0, 'agents/ must contain flat gsd-*.md agent files'); + + // #2101: zcode's shared-hooks exclusion is now descriptor-driven + // (hostBehaviors.skipSharedHooksInstall:true) — golden has zero hook + // files, so no hooks/ directory should be installed. + assert.ok(!fs.existsSync(path.join(configDir, 'hooks')), 'zcode install must not emit a hooks/ directory'); + } finally { + cleanup(root); + } +}); + +// --------------------------------------------------------------------------- +// #2101 EoS/zcode — fail-closed negotiation + validator acceptance + +// the folded descriptor (mirrors codebuddy/windsurf/augment reference tests). +// --------------------------------------------------------------------------- + +test('negotiateHostCapabilities never throws for zcode, even fully corrupted', () => { + assert.doesNotThrow(() => negotiateHostCapabilities({})); + assert.doesNotThrow(() => negotiateHostCapabilities({ ...ZCODE_AXES, embeddingMode: UNDOCUMENTED })); + assert.doesNotThrow(() => negotiateHostCapabilities({ ...ZCODE_AXES, embeddingMode: 'future-unknown' })); + assert.doesNotThrow(() => negotiateHostCapabilities({ ...ZCODE_AXES, dispatch: 'corrupted-not-an-object' })); + assert.doesNotThrow(() => negotiateHostCapabilities({ ...ZCODE_AXES, dispatch: { ...ZCODE_AXES.dispatch, maxDepth: 'not-a-number' } })); +}); + +test('a partial/empty zcode descriptor degrades to the safe floor, not the declarative-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['declarative-cli']); + assert.ok(result.warnings.length > 0); +}); + +// AC-style proof: the 2 still-`undocumented` dispatch sub-axes (nested/ +// maxDepth) must degrade to the most-restrictive KNOWN value, never their +// optimistic value. Unlike augment (3 undocumented sub-axes) or antigravity +// (4), zcode documents namedDispatch/background/subagentToolkit/ +// backgroundDispatch, leaving only nested + maxDepth undocumented. Real +// values confirmed via: +// node -e "const {negotiateHostCapabilities}=require('./gsd-core/bin/lib/host-integration.cjs'); +// const cap=require('./capabilities/zcode/capability.json'); +// console.log(negotiateHostCapabilities(cap.runtime.hostIntegration).effective.dispatch)" +// -> { namedDispatch:true, nested:false, maxDepth:0, background:false, subagentToolkit:'full', backgroundDispatch:false } +test("zcode's 2 still-undocumented dispatch sub-axes (nested/maxDepth) degrade to the most-restrictive known value, not their optimistic value", () => { + // Sanity: the descriptor itself still declares these 2 as the undocumented + // sentinel, while namedDispatch/background/subagentToolkit/backgroundDispatch + // are documented. + assert.equal(ZCODE_AXES.dispatch.nested, 'undocumented'); + assert.equal(ZCODE_AXES.dispatch.maxDepth, 'undocumented'); + assert.equal(ZCODE_AXES.dispatch.namedDispatch, true, 'sanity: namedDispatch is documented, not part of the undocumented set'); + assert.equal(ZCODE_AXES.dispatch.background, false, 'sanity: background is documented, not part of the undocumented set'); + assert.equal(ZCODE_AXES.dispatch.subagentToolkit, 'full', 'sanity: subagentToolkit is documented, not part of the undocumented set'); + assert.equal(ZCODE_AXES.dispatch.backgroundDispatch, false, 'sanity: backgroundDispatch is documented, not part of the undocumented set'); + + const { effective, warnings } = negotiateHostCapabilities(ZCODE_AXES); + + assert.equal(effective.dispatch.nested, false, 'undocumented nested must degrade to false, never true'); + assert.equal(effective.dispatch.maxDepth, 0, 'undocumented maxDepth must degrade to 0, never -1/unbounded'); + + // namedDispatch/background/subagentToolkit/backgroundDispatch are documented + // — they are trusted and survive negotiation unchanged. + assert.equal(effective.dispatch.namedDispatch, true, "documented 'true' namedDispatch is trusted, unlike the undocumented sub-axes"); + assert.equal(effective.dispatch.background, false, "documented 'false' background is trusted"); + assert.equal(effective.dispatch.subagentToolkit, 'full', "documented 'full' subagentToolkit is trusted, unlike the undocumented sub-axes"); + assert.equal(effective.dispatch.backgroundDispatch, false, "documented 'false' backgroundDispatch is trusted"); + + assert.ok( + warnings.some((w) => w.includes('dispatch.nested') && w.includes('undocumented')), + 'a warning must be raised for the undocumented dispatch.nested axis', + ); + assert.ok( + warnings.some((w) => w.includes('dispatch.maxDepth')), + 'a warning must be raised for the undocumented dispatch.maxDepth axis (reported as missing/non-number)', + ); +}); + +test('capabilities/zcode/capability.json validates — no errors', () => { + const errors = validateCapability(ZCODE_CAP, 'zcode'); + assert.deepEqual(errors, [], `validateCapability must return no errors, got: ${JSON.stringify(errors)}`); +}); + +// -- AC2: the hardcoded branch is retired across all folded modules --------- + +test('no `runtime === "zcode"` string-equality branch (nor live `isZcode` read) remains in bin/install.js, src/install-engine.cts, src/surface.cts, or src/runtime-artifact-conversion.cts (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', 'surface.cts'), + path.join(repoRoot, 'src', 'runtime-artifact-conversion.cts'), + ]; + for (const file of files) { + const src = fs.readFileSync(file, 'utf8'); + const stripped = strip(src); + + const eqOffenders = stripped.match(/runtime\s*[!=]==\s*["']zcode["']/g) || []; + assert.deepEqual(eqOffenders, [], + `AC2: no hardcoded runtime==='zcode' branch may remain in ${path.relative(repoRoot, file)}; found: ${eqOffenders.join(', ')}`); + + // Excludes legit enumeration sites: --zcode CLI flag parsing, the numbered + // menu map ('16': 'zcode'), the allRuntimes set literal, help/usage text, + // and the `// #2101: isZcode dropped` comment (stripped above) — none of + // those contain the token `isZcode`, so a literal-word match is precise. + const isZcodeHits = stripped.match(/\bisZcode\b/g) || []; + assert.deepEqual(isZcodeHits, [], + `AC2: no live isZcode read may remain in ${path.relative(repoRoot, file)}; found ${isZcodeHits.length} occurrence(s)`); + } +});