feat(#2099): drive GitHub Copilot through the EoS descriptor + multi-event hook bus (ADR-1239)
Fold Copilot's residual runtime-literal branches onto descriptor-driven hostBehaviors. Several issue premises were inaccurate (verified via research) and deliberately NOT followed: writesSharedSettings/"legacy exclusion list" (per-runtime descriptor data, copilot's false is correct); RUNTIME_CONTENT_DISPATCH.copilot + installSurface==='copilot-instructions' (already descriptor-driven); extendedHookEvents (closed Claude/Gemini enum — hooks extended in code instead); reapply ternary (already folded, kimi #2095). Real folds (all byte-parity — golden byte-identical for every runtime): - src/install-engine.cts + src/surface.cts: the two `.agent.md` filename cutovers (_copyStaged + _syncGsdDir) unified onto hostBehaviors.agentFileExtension via a new exported agentFileExtensionFor() accessor (kills the two-mechanism divergence). - src/runtime-artifact-conversion.cts: applyAgentPathRewrites' copilot skip → hostBehaviors.noPathRewrite:true (antigravity #2096 precedent). - bin/install.js uninstall: the two isCopilot cleanup branches → installSurface=== 'copilot-instructions' gate (symmetric with install-time). - bin/install.js: `!isCopilot` in the two skipSharedHooksInstall checks → hostBehaviors.skipSharedHooksInstall:true (copilot has no shared gsd-*.js hooks). - bin/install.js: three dead legacy inline-agent-loop isCopilot refs removed (copilot ∈ _DESCRIPTOR_AGENTS_RUNTIMES → unreachable; byte-parity proven by clean golden + real reachable-runtime install diffs). isCopilot dropped from 4 destructures. Zero live `runtime==='copilot'`/`isCopilot` branches remain in bin/install.js, install-engine.cts, surface.cts, or runtime-artifact-conversion.cts (AC2 guard scans all four). UPGRADE 1 (multi-event hook bus): buildCopilotHookConfig() now emits preToolUse/ postToolUse/userPromptSubmitted/sessionEnd advisory handlers alongside sessionStart (static inline bash/powershell — deterministic, golden-trackable). Only copilot.json's gsd-session.json hash changes. UPGRADE 2 (background dispatch): surfaced via the negotiated contract only — dispatch.background:true exceeds the declarative-cli baseline and survives negotiation with no downgrade warning. NO .agent.md frontmatter field (copilot has none). MCP companion out of scope (AC4 names only 2 upgrades). Tests: declarative-reference-copilot (adapter/axes/fail-closed + AC2 4-file source-grep guard) + copilot-upgrades (live 5-event hook wiring; dispatch.background negotiation). Matrix EoS note + how-to; changeset (Changed). capability-registry regenerated. Incidental flaky-test RE-ARCHITECTURE (no-defer, maintainer-directed): tests/opencode-review-reconstruction.property.test.cjs spawned ~600 synchronous execFileSync('jq') subprocesses (numRuns:200 × 3 fast-check properties, one jq per generated stream); a single jq freezing on a contended macos-22 CI runner hung the whole unit-test chunk to its 600s kill (this PR's CI). --test-force-exit can't interrupt a synchronous execFileSync, so the cure is to stop spawning per case, not just time-bound it. Re-architected to run the SHIPPED jq program over the whole fast-check corpus in ONE jq process: each generated stream is one compact-JSON array per line in a temp file, `jq -c <PROGRAM>` (no -s) applies PROGRAM to each array (`.` == the array, exactly what production's `jq -rs <file>` sees after slurping) and emits one result per line — empirically byte-identical to the per-stream form across embedded-newline/empty/quote/ unicode/null-drop cases, and file-input (like production) so there's no stdin pipe to deadlock on large I/O. ~600 spawns → 6; coverage unchanged (200-case corpus per property, deterministic seeds) plus explicit boundary/diagnostic example batches. Still property- tests the real shipped jq (no JS reimplementation). Per-call jq timeout retained as a belt-and-suspenders bound. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
6
.changeset/2099-eos-copilot.md
Normal file
6
.changeset/2099-eos-copilot.md
Normal file
@@ -0,0 +1,6 @@
|
||||
---
|
||||
type: Changed
|
||||
pr: 2172
|
||||
---
|
||||
|
||||
**GitHub Copilot now wires GSD's full lifecycle hook bus and is driven by its capability descriptor** — installing GSD into Copilot registers `preToolUse`, `postToolUse`, `userPromptSubmitted`, and `sessionEnd` handlers in its `hooks/gsd-session.json` (beyond today's `sessionStart`-only advisory), and Copilot's residual hardcoded runtime branches are folded onto descriptor-driven `hostBehaviors`. (#2099)
|
||||
@@ -6851,7 +6851,9 @@ function uninstall(isGlobal, runtime = DEFAULT_RUNTIME) {
|
||||
// consumer, so its former `&& !isKimi` uninstall guards were removed.
|
||||
// #2096: isAntigravity dropped — unused in this function.
|
||||
// #2098: isCodebuddy dropped — unused in this function.
|
||||
const { isOpencode, isCodex, isCopilot, isCursor, isWindsurf, isAugment, isQwen, isHermes, isCline } = runtimeFlags(runtime);
|
||||
// #2099: isCopilot dropped — both Copilot side-effect branches below are now
|
||||
// gated on resolveInstallPlan(runtime).installSurface === 'copilot-instructions'.
|
||||
const { isOpencode, isCodex, isCursor, isWindsurf, isAugment, isQwen, isHermes, isCline } = runtimeFlags(runtime);
|
||||
const dirName = getDirName(runtime);
|
||||
|
||||
// Get the target directory based on runtime and install type. Cline local
|
||||
@@ -6880,7 +6882,13 @@ function uninstall(isGlobal, runtime = DEFAULT_RUNTIME) {
|
||||
// #786: AGENTS.md lives at the repo root (outside targetDir) for local Copilot
|
||||
// installs, so its cleanup must run even when .github (targetDir) was already
|
||||
// removed — i.e. BEFORE the "target directory missing" early-return below.
|
||||
if (isCopilot && !isGlobal) {
|
||||
// #2099: descriptor-driven via resolveInstallPlan(runtime).installSurface ===
|
||||
// 'copilot-instructions' (was hardcoded `isCopilot`). Mirrors the install-time
|
||||
// gate at the 'copilot-instructions' branch below (~line 10471 equivalent),
|
||||
// which writes this same repo-root AGENTS.md only for local ('!isGlobal')
|
||||
// installs — 'copilot-instructions' is unique to copilot's descriptor, so
|
||||
// this is byte-parity.
|
||||
if (resolveInstallPlan(runtime).installSurface === 'copilot-instructions' && !isGlobal) {
|
||||
const agentsMdPath = path.join(process.cwd(), 'AGENTS.md');
|
||||
if (fs.existsSync(agentsMdPath)) {
|
||||
const content = fs.readFileSync(agentsMdPath, 'utf8');
|
||||
@@ -7064,7 +7072,10 @@ function uninstall(isGlobal, runtime = DEFAULT_RUNTIME) {
|
||||
}
|
||||
|
||||
// 1b. Non-layout Copilot side-effect: copilot-instructions.md cleanup
|
||||
if (isCopilot) {
|
||||
// #2099: descriptor-driven via resolveInstallPlan(runtime).installSurface ===
|
||||
// 'copilot-instructions' (was hardcoded `isCopilot`), mirroring the same
|
||||
// gate used at the install-time 'copilot-instructions' branch.
|
||||
if (resolveInstallPlan(runtime).installSurface === 'copilot-instructions') {
|
||||
const instructionsPath = path.join(targetDir, 'copilot-instructions.md');
|
||||
if (fs.existsSync(instructionsPath)) {
|
||||
const content = fs.readFileSync(instructionsPath, 'utf8');
|
||||
@@ -8223,7 +8234,9 @@ function writeManifest(configDir, runtime = DEFAULT_RUNTIME, options = {}) {
|
||||
// settings-json-adjacent runtime, so the `&& !isKimi` term below was removed.
|
||||
// #2096: isAntigravity dropped — unused in this function.
|
||||
// #2098: isCodebuddy dropped — unused in this function.
|
||||
const { isOpencode, isCodex, isCopilot, isCursor, isWindsurf, isAugment, isQwen, isHermes, isCline } = runtimeFlags(runtime);
|
||||
// #2099: isCopilot dropped — was only used in the hooks-tracking conditional
|
||||
// above, now covered by hostBehaviors.skipSharedHooksInstall.
|
||||
const { isOpencode, isCodex, isCursor, isWindsurf, isAugment, isQwen, isHermes, isCline } = 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.
|
||||
@@ -8335,7 +8348,9 @@ function writeManifest(configDir, runtime = DEFAULT_RUNTIME, options = {}) {
|
||||
// skipSharedHooksInstall:true) — the redundant `&& !isTrae` was removed.
|
||||
// #2095: kimi is now a hooks/ consumer (native config.toml [[hooks]] bus) —
|
||||
// the redundant `&& !isKimi` was removed so its hook files are tracked too.
|
||||
if (!isCodex && !isCopilot && _hostBehaviors(runtime).skipSharedHooksInstall !== true && !isWindsurf) {
|
||||
// #2099: Copilot's exclusion is likewise descriptor-driven (copilot declares
|
||||
// skipSharedHooksInstall:true) — the redundant `&& !isCopilot` was removed.
|
||||
if (!isCodex && _hostBehaviors(runtime).skipSharedHooksInstall !== true && !isWindsurf) {
|
||||
const hooksDir = path.join(configDir, 'hooks');
|
||||
if (fs.existsSync(hooksDir)) {
|
||||
// Drive from INSTALLED_HOOK_FILES (the canonical HOOKS_TO_COPY set from
|
||||
@@ -8738,7 +8753,14 @@ function install(isGlobal, runtime = DEFAULT_RUNTIME, options = {}) {
|
||||
// _DESCRIPTOR_AGENTS_RUNTIMES below, so its legacy converter-dispatch branch
|
||||
// (the `isCodebuddy` arm calling convertClaudeAgentToCodebuddyAgent) was
|
||||
// unreachable dead code and was removed rather than re-gated.
|
||||
const { isOpencode, isZcode, isCodex, isCopilot, isCursor, isWindsurf, isAugment, isTrae, isQwen, isHermes, isCline } = runtimeFlags(runtime);
|
||||
// #2099: isCopilot dropped — copilot is also in _DESCRIPTOR_AGENTS_RUNTIMES
|
||||
// below, so its three legacy-agent-loop branches (the path-rewrite skip,
|
||||
// the converter dispatch, and the .agent.md destName ternary) were
|
||||
// unreachable dead code and were removed rather than re-gated; the
|
||||
// .agent.md suffix now lives on hostBehaviors.agentFileExtension in
|
||||
// src/install-engine.cts, and the skipSharedHooksInstall check above no
|
||||
// longer needs `&& !isCopilot`.
|
||||
const { isOpencode, isZcode, isCodex, isCursor, isWindsurf, isAugment, isTrae, isQwen, isHermes, isCline } = runtimeFlags(runtime);
|
||||
const plan = resolveInstallPlan(runtime);
|
||||
const dirName = getDirName(runtime);
|
||||
const src = path.join(__dirname, '..');
|
||||
@@ -9608,12 +9630,14 @@ function install(isGlobal, runtime = DEFAULT_RUNTIME, options = {}) {
|
||||
// _DESCRIPTOR_AGENTS_RUNTIMES above, so this whole branch is already
|
||||
// unreachable for it; the path-rewrite skip for antigravity now lives
|
||||
// in the descriptor-driven `applyAgentPathRewrites` (hostBehaviors.noPathRewrite).
|
||||
if (!isCopilot) {
|
||||
content = content.replace(dirRegex, pathPrefix);
|
||||
content = content.replace(homeDirRegex, pathPrefix);
|
||||
content = content.replace(bareDirRegex, normalizedPathPrefix);
|
||||
content = content.replace(bareHomeDirRegex, normalizedPathPrefix);
|
||||
}
|
||||
// #2099: `if (!isCopilot)` guard dropped — copilot is ALSO in
|
||||
// _DESCRIPTOR_AGENTS_RUNTIMES (line ~9564 above), so this whole
|
||||
// `else if (fs.existsSync(agentsSrc))` branch is unreachable for it;
|
||||
// isCopilot was therefore always false here, making the guard a no-op.
|
||||
content = content.replace(dirRegex, pathPrefix);
|
||||
content = content.replace(homeDirRegex, pathPrefix);
|
||||
content = content.replace(bareDirRegex, normalizedPathPrefix);
|
||||
content = content.replace(bareHomeDirRegex, normalizedPathPrefix);
|
||||
content = processAttribution(content, getCommitAttribution(runtime));
|
||||
// Convert frontmatter for runtime compatibility (agents need different handling)
|
||||
if (_hostBehaviors(runtime).frontmatterDialect === 'opencode') {
|
||||
@@ -9657,8 +9681,11 @@ function install(isGlobal, runtime = DEFAULT_RUNTIME, options = {}) {
|
||||
content = convertClaudeToKiloFrontmatter(content, { isAgent: true, modelOverride: _kiloModelOverride });
|
||||
} else if (_hostBehaviors(runtime).frontmatterDialect === 'codex') {
|
||||
content = convertClaudeAgentToCodexAgent(content);
|
||||
} else if (isCopilot) {
|
||||
content = convertClaudeAgentToCopilotAgent(content, isGlobal);
|
||||
// #2099: `else if (isCopilot)` arm dropped — copilot is unreachable
|
||||
// here (see the isCopilot-guard-drop comment above); its content
|
||||
// conversion is applied pre-staging via the descriptor's
|
||||
// artifactLayout.converter (runtime-artifact-layout.cts), independent
|
||||
// of this legacy loop.
|
||||
} else if (isWindsurf) {
|
||||
content = convertClaudeAgentToWindsurfAgent(content);
|
||||
} else if (_hostBehaviors(runtime).frontmatterDialect === 'cline') {
|
||||
@@ -9698,7 +9725,12 @@ function install(isGlobal, runtime = DEFAULT_RUNTIME, options = {}) {
|
||||
// shouldNormalizeHyphenNamespaceInAgentBody above. Mirrors the
|
||||
// SKILL.md-body fix shipped via #3629.
|
||||
content = normalizeAgentBodyForRuntime(content, runtime, readGsdCommandNames());
|
||||
const destName = isCopilot ? entry.name.replace('.md', '.agent.md') : entry.name;
|
||||
// #2099: `isCopilot ? ... : entry.name` ternary dropped — copilot is
|
||||
// unreachable here (see the isCopilot-guard-drop comment above), so
|
||||
// the ternary always evaluated to entry.name in practice; its
|
||||
// .agent.md suffix is applied by the descriptor-driven fold in
|
||||
// src/install-engine.cts (hostBehaviors.agentFileExtension).
|
||||
const destName = entry.name;
|
||||
fs.writeFileSync(path.join(agentsDest, destName), content);
|
||||
}
|
||||
}
|
||||
@@ -9886,7 +9918,9 @@ function install(isGlobal, runtime = DEFAULT_RUNTIME, options = {}) {
|
||||
// resolveKimiHooksTomlDir) via installSharedHooksBundle, at the
|
||||
// kimi-hooks-toml branch further below — never under the generic
|
||||
// Agent-Skills configDir GSD installs skills/agents into for kimi.
|
||||
if (!isCodex && !isCopilot && _hostBehaviors(runtime).skipSharedHooksInstall !== true && !isWindsurf && !isZcode) {
|
||||
// #2099: Copilot's exclusion is likewise descriptor-driven (copilot declares
|
||||
// skipSharedHooksInstall:true) — the redundant `&& !isCopilot` was removed.
|
||||
if (!isCodex && _hostBehaviors(runtime).skipSharedHooksInstall !== true && !isWindsurf && !isZcode) {
|
||||
if (!installSharedHooksBundle(targetDir)) {
|
||||
failures.push('hooks');
|
||||
}
|
||||
@@ -10869,7 +10903,8 @@ function finishInstall(settingsPath, settings, statuslineCommand, shouldInstallS
|
||||
// _hostBehaviors(runtime).doneBannerStyle === 'kimi-agent-file' (descriptor-driven), not this flag.
|
||||
// #2096: isAntigravity dropped — unused in this function.
|
||||
// #2098: isCodebuddy dropped — unused in this function.
|
||||
const { isOpencode, isCodex, isCopilot, isCursor, isWindsurf, isAugment, isQwen, isHermes, isCline } = runtimeFlags(runtime);
|
||||
// #2099: isCopilot dropped — unused in this function.
|
||||
const { isOpencode, isCodex, isCursor, isWindsurf, isAugment, isQwen, isHermes, isCline } = runtimeFlags(runtime);
|
||||
const plan = resolveInstallPlan(runtime);
|
||||
|
||||
if (shouldInstallStatusline && plan.writesSharedSettings && !_hostBehaviors(runtime).skipSettingsUi) {
|
||||
|
||||
@@ -84,7 +84,10 @@
|
||||
"runtime": "undocumented"
|
||||
},
|
||||
"hostBehaviors": {
|
||||
"reapplyCommand": "/gsd-update --reapply"
|
||||
"reapplyCommand": "/gsd-update --reapply",
|
||||
"agentFileExtension": ".agent.md",
|
||||
"skipSharedHooksInstall": true,
|
||||
"noPathRewrite": true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -147,6 +147,19 @@ yields the same truth value**, so behavior is unchanged and only the brittle cou
|
||||
the host correctly, negotiation fails closed on a corrupted descriptor, and — with a source-grep behind
|
||||
an `// allow-test-rule:` exemption — that **no `runtime === '<id>'` branch remains** in `bin/install.js`.
|
||||
|
||||
**Another completed worked example: `copilot` (#2099).** Copilot was already installing through the
|
||||
declarative artifactLayout (not the direct `installRuntimeArtifacts` calls step 4 describes), so its
|
||||
migration folded the *residual* hardcoded branches rather than the whole install path: the `.agent.md`
|
||||
destination-suffix rename in `src/install-engine.cts` (→ `hostBehaviors.agentFileExtension`), two
|
||||
uninstall side-effect branches in `bin/install.js` (→
|
||||
`resolveInstallPlan(runtime).installSurface === 'copilot-instructions'`, already a live descriptor field
|
||||
elsewhere in the same file), and two `skipSharedHooksInstall` gates (→
|
||||
`hostBehaviors.skipSharedHooksInstall: true`). A dead legacy agent-converter dispatch arm — unreachable
|
||||
because copilot is a member of `_DESCRIPTOR_AGENTS_RUNTIMES` — was deleted outright rather than re-gated,
|
||||
mirroring step 6's guard: `tests/declarative-reference-copilot.test.cjs` source-greps both files for the
|
||||
retired `isCopilot` reads. See the `copilot` section of the reference matrix for the full EoS migration
|
||||
note, including the two upgrades (multi-event hook bus; negotiated `dispatch.background`) this PR adds.
|
||||
|
||||
---
|
||||
|
||||
## Related
|
||||
|
||||
@@ -461,6 +461,8 @@ Documentation gaps:
|
||||
- runtime — docs describe the CLI binary and the SDK (Node.js/Go/Python/Rust) but do not state what runtime the CLI host itself or its plugin/extension loader executes in.
|
||||
- dispatch.nested exact authoritative source is awesome-copilot.github.com (community docs) not docs.github.com.
|
||||
|
||||
**EoS migration status (#2099):** Migrated onto the declarative adapter (dogfooded in `tests/declarative-reference-copilot.test.cjs`). The residual `isCopilot` branches were folded onto descriptor-driven `runtime.hostBehaviors`: the `.agent.md` destination-suffix rename in `src/install-engine.cts` now reads `hostBehaviors.agentFileExtension`; `bin/install.js`'s two uninstall side-effect branches (repo-root `AGENTS.md` cleanup, `copilot-instructions.md`/hook cleanup) now gate on `resolveInstallPlan(runtime).installSurface === 'copilot-instructions'` (unique to copilot, so byte-identical); and the two `skipSharedHooksInstall` checks now read `hostBehaviors.skipSharedHooksInstall:true` (copilot's golden has only `hooks/gsd-session.json`, no shared `gsd-*.js` scripts). A dead legacy agent-converter dispatch arm in the inline agent-copy loop — unreachable since copilot is a member of `_DESCRIPTOR_AGENTS_RUNTIMES` — was removed outright; `isCopilot` no longer appears as a live read anywhere in `bin/install.js` or `src/install-engine.cts`. Two upgrades land: (1) **multi-event hook bus** — `buildCopilotHookConfig()` previously emitted only `sessionStart`; this PR wires four additional events — `preToolUse`/`postToolUse`/`userPromptSubmitted`/`sessionEnd` — each a static, deterministic advisory command (no node-runner invocation), so an install's `hooks/gsd-session.json` now registers all five events. (2) **`dispatch.background`** — the descriptor already declared `true`, exceeding the `declarative-cli` profile baseline of `false`; the negotiation contract (`negotiateHostCapabilities`) surfaces that value with no downgrade warning, documenting the legitimate deviation. Note: Copilot's `.agent.md` frontmatter has no background-dispatch field (fields are `description`/`infer`/`mcp-servers`/`model`/`name`/`tools`) — background dispatch remains a negotiated-contract-only axis, not a field GSD's agent artifacts emit. MCP companion tooling is out of scope for this migration (AC4 names only the two upgrades above).
|
||||
|
||||
---
|
||||
|
||||
## kilo
|
||||
|
||||
@@ -987,7 +987,10 @@ const capabilities = {
|
||||
"runtime": "undocumented"
|
||||
},
|
||||
"hostBehaviors": {
|
||||
"reapplyCommand": "/gsd-update --reapply"
|
||||
"reapplyCommand": "/gsd-update --reapply",
|
||||
"agentFileExtension": ".agent.md",
|
||||
"skipSharedHooksInstall": true,
|
||||
"noPathRewrite": true
|
||||
}
|
||||
}
|
||||
},
|
||||
@@ -4444,7 +4447,10 @@ const runtimes = {
|
||||
"runtime": "undocumented"
|
||||
},
|
||||
"hostBehaviors": {
|
||||
"reapplyCommand": "/gsd-update --reapply"
|
||||
"reapplyCommand": "/gsd-update --reapply",
|
||||
"agentFileExtension": ".agent.md",
|
||||
"skipSharedHooksInstall": true,
|
||||
"noPathRewrite": true
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
@@ -342,9 +342,13 @@ function _copyStaged(stagedDir: string, destDir: string, kind: any, configDir: s
|
||||
let destName: string;
|
||||
if (kind.kind === 'agents') {
|
||||
// Agent files already carry the gsd- prefix in the source dir.
|
||||
// #1575: copilot agents get .agent.md suffix (mirrors inline loop line ~9118).
|
||||
destName = runtime === 'copilot'
|
||||
? entry.name.replace(/\.md$/, '.agent.md')
|
||||
// #2099: descriptor-driven via hostBehaviors.agentFileExtension (was
|
||||
// hardcoded `runtime === 'copilot'`). copilot declares '.agent.md';
|
||||
// every other runtime's descriptor leaves this unset, so destName falls
|
||||
// back to entry.name unchanged (byte-parity, #1575 origin comment).
|
||||
const _agentExt = runtime ? _hostBehaviors(runtime).agentFileExtension : undefined;
|
||||
destName = _agentExt
|
||||
? entry.name.replace(/\.md$/, _agentExt)
|
||||
: entry.name;
|
||||
} else if (namespacedByDir) {
|
||||
// Directory is the namespace; don't double-prefix the filename
|
||||
|
||||
@@ -83,6 +83,21 @@ function _hostBehaviors(runtime: string): Record<string, unknown> {
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Public accessor for the `hostBehaviors.agentFileExtension` descriptor field
|
||||
* (ADR-1239 / #2099 / #2103). Returns the runtime's declared agent-file
|
||||
* destination-suffix rename target (e.g. copilot's `.agent.md`), or
|
||||
* `undefined` when the runtime declares none (the generic no-rename
|
||||
* default). Exported so callers outside this module (surface.cts's
|
||||
* `_syncGsdDir`) can derive the SAME rename decision as
|
||||
* install-engine.cts's staged-copy loop from ONE descriptor read, instead of
|
||||
* duplicating a hardcoded `runtime === 'copilot'` check (#2103 fold).
|
||||
*/
|
||||
function agentFileExtensionFor(runtime: string): string | undefined {
|
||||
const ext = _hostBehaviors(runtime).agentFileExtension;
|
||||
return typeof ext === 'string' ? ext : undefined;
|
||||
}
|
||||
|
||||
|
||||
const colorNameToHex = {
|
||||
cyan: '#00FFFF',
|
||||
@@ -2756,10 +2771,12 @@ function normalizeAgentBodyForRuntime(content: string, runtime: string, cmdNames
|
||||
* ~/\.claude\b → normalizedPathPrefix
|
||||
* $HOME/\.claude\b → normalizedPathPrefix
|
||||
*
|
||||
* Skipped for copilot (hardcoded — #2099 will fold it) and for any runtime
|
||||
* that declares `hostBehaviors.noPathRewrite` (descriptor-driven, ADR-1239 /
|
||||
* #2096 — folds the prior hardcoded `runtime === 'antigravity'` literal;
|
||||
* Antigravity does NOT do path rewrites in the inline loop). NO stamp
|
||||
* Skipped for any runtime that declares `hostBehaviors.noPathRewrite`
|
||||
* (descriptor-driven, ADR-1239 / #2096 — folds the prior hardcoded
|
||||
* `runtime === 'antigravity'` literal; Antigravity does NOT do path rewrites
|
||||
* in the inline loop / #2103 — folds the prior hardcoded
|
||||
* `runtime === 'copilot'` literal onto the same descriptor field, since
|
||||
* copilot also skips these rewrites). NO stamp
|
||||
* (_stampNonClaudeRuntimeDefaults) — agents are NOT stamped in the inline loop.
|
||||
*
|
||||
* ADR-1235 §1: pre-converter cross-cutting for descriptor-driven agent pipeline.
|
||||
@@ -2769,10 +2786,10 @@ function normalizeAgentBodyForRuntime(content: string, runtime: string, cmdNames
|
||||
* @param content raw agent file content
|
||||
* @param runtime canonical runtime ID
|
||||
* @param pathPrefix trailing-slash path prefix (e.g. '$HOME/.cursor/')
|
||||
* @returns content with path-prefix rewrites applied (or unchanged for copilot / noPathRewrite runtimes)
|
||||
* @returns content with path-prefix rewrites applied (or unchanged for noPathRewrite runtimes, e.g. copilot)
|
||||
*/
|
||||
function applyAgentPathRewrites(content: string, runtime: string, pathPrefix: string): string {
|
||||
if (runtime === 'copilot' || _hostBehaviors(runtime).noPathRewrite === true) return content;
|
||||
if (_hostBehaviors(runtime).noPathRewrite === true) return content;
|
||||
const normalizedPathPrefix = pathPrefix.replace(/\/$/, '');
|
||||
content = content.replace(/~\/\.claude\//g, pathPrefix);
|
||||
content = content.replace(/\$HOME\/\.claude\//g, pathPrefix);
|
||||
@@ -2813,6 +2830,11 @@ function processAttribution(
|
||||
|
||||
export = {
|
||||
processAttribution,
|
||||
// #2103: public accessor for hostBehaviors.agentFileExtension, exported so
|
||||
// surface.cts's _syncGsdDir can derive the .agent.md rename from the SAME
|
||||
// descriptor read as install-engine.cts (folds a duplicated hardcoded
|
||||
// `runtime === 'copilot'` literal).
|
||||
agentFileExtensionFor,
|
||||
yamlIdentifier,
|
||||
yamlQuote,
|
||||
toSingleLine,
|
||||
|
||||
@@ -84,6 +84,39 @@ const GSD_COPILOT_SESSION_HOOK_PWSH =
|
||||
`{ '{"additionalContext":"${GSD_COPILOT_SESSION_MSG_PRESENT}"}' } ` +
|
||||
`else { '{"additionalContext":"${GSD_COPILOT_SESSION_MSG_ABSENT}"}' }`;
|
||||
|
||||
// #2099 UPGRADE 1: multi-event hook bus. Each additional event is a static,
|
||||
// deterministic advisory (no branching/no-op-style, matching sessionStart's
|
||||
// tone) so the emitted hooks/gsd-session.json stays golden-trackable — no
|
||||
// node-runner invocation, no filesystem probing beyond what sessionStart
|
||||
// already does.
|
||||
const GSD_COPILOT_PRE_TOOL_MSG =
|
||||
'GSD: confirm this tool use is in scope for the active phase before proceeding.';
|
||||
const GSD_COPILOT_PRE_TOOL_HOOK_BASH =
|
||||
`printf '%s' '{"additionalContext":"${GSD_COPILOT_PRE_TOOL_MSG}"}'`;
|
||||
const GSD_COPILOT_PRE_TOOL_HOOK_PWSH =
|
||||
`'{"additionalContext":"${GSD_COPILOT_PRE_TOOL_MSG}"}'`;
|
||||
|
||||
const GSD_COPILOT_POST_TOOL_MSG =
|
||||
'GSD: review the tool result against the active phase before continuing.';
|
||||
const GSD_COPILOT_POST_TOOL_HOOK_BASH =
|
||||
`printf '%s' '{"additionalContext":"${GSD_COPILOT_POST_TOOL_MSG}"}'`;
|
||||
const GSD_COPILOT_POST_TOOL_HOOK_PWSH =
|
||||
`'{"additionalContext":"${GSD_COPILOT_POST_TOOL_MSG}"}'`;
|
||||
|
||||
const GSD_COPILOT_PROMPT_SUBMIT_MSG =
|
||||
'GSD: check this request against .planning/STATE.md scope before acting.';
|
||||
const GSD_COPILOT_PROMPT_SUBMIT_HOOK_BASH =
|
||||
`printf '%s' '{"additionalContext":"${GSD_COPILOT_PROMPT_SUBMIT_MSG}"}'`;
|
||||
const GSD_COPILOT_PROMPT_SUBMIT_HOOK_PWSH =
|
||||
`'{"additionalContext":"${GSD_COPILOT_PROMPT_SUBMIT_MSG}"}'`;
|
||||
|
||||
const GSD_COPILOT_SESSION_END_MSG =
|
||||
'GSD: update .planning/STATE.md with the session outcome before ending.';
|
||||
const GSD_COPILOT_SESSION_END_HOOK_BASH =
|
||||
`printf '%s' '{"additionalContext":"${GSD_COPILOT_SESSION_END_MSG}"}'`;
|
||||
const GSD_COPILOT_SESSION_END_HOOK_PWSH =
|
||||
`'{"additionalContext":"${GSD_COPILOT_SESSION_END_MSG}"}'`;
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Cursor hook constants
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -1147,6 +1180,41 @@ function buildCopilotHookConfig(): Record<string, unknown> {
|
||||
timeoutSec: 10,
|
||||
},
|
||||
],
|
||||
// #2099 UPGRADE 1: multi-event hook bus — preToolUse (worktree/read-safety
|
||||
// advisory), postToolUse (context-monitor advisory), userPromptSubmitted
|
||||
// (prompt-guard advisory), sessionEnd (session-finalize advisory).
|
||||
preToolUse: [
|
||||
{
|
||||
type: 'command',
|
||||
bash: GSD_COPILOT_PRE_TOOL_HOOK_BASH,
|
||||
powershell: GSD_COPILOT_PRE_TOOL_HOOK_PWSH,
|
||||
timeoutSec: 10,
|
||||
},
|
||||
],
|
||||
postToolUse: [
|
||||
{
|
||||
type: 'command',
|
||||
bash: GSD_COPILOT_POST_TOOL_HOOK_BASH,
|
||||
powershell: GSD_COPILOT_POST_TOOL_HOOK_PWSH,
|
||||
timeoutSec: 10,
|
||||
},
|
||||
],
|
||||
userPromptSubmitted: [
|
||||
{
|
||||
type: 'command',
|
||||
bash: GSD_COPILOT_PROMPT_SUBMIT_HOOK_BASH,
|
||||
powershell: GSD_COPILOT_PROMPT_SUBMIT_HOOK_PWSH,
|
||||
timeoutSec: 10,
|
||||
},
|
||||
],
|
||||
sessionEnd: [
|
||||
{
|
||||
type: 'command',
|
||||
bash: GSD_COPILOT_SESSION_END_HOOK_BASH,
|
||||
powershell: GSD_COPILOT_SESSION_END_HOOK_PWSH,
|
||||
timeoutSec: 10,
|
||||
},
|
||||
],
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
@@ -521,10 +521,14 @@ function _syncGsdDir(stagedDir: string, destDir: string, kind: ArtifactKind | st
|
||||
const kindName = (typeof kind === 'string') ? kind : kind.kind;
|
||||
const kindPrefix = (typeof kind === 'object' && kind !== null) ? kind.prefix : 'gsd-';
|
||||
|
||||
// #1575: copilot agents are renamed .md -> .agent.md at copy time, mirroring
|
||||
// the inline agent loop in bin/install.js (line ~9118). Other runtimes keep
|
||||
// the staged filename verbatim.
|
||||
const isCopilotAgents = runtime === 'copilot' && kindName === 'agents';
|
||||
// #1575 / #2103: agent files are renamed .md -> <agentFileExtension> at copy
|
||||
// time when the runtime's descriptor declares hostBehaviors.agentFileExtension
|
||||
// (e.g. copilot's '.agent.md'), mirroring install-engine.cts's staged-copy
|
||||
// loop (`_copyStaged`) — ONE descriptor read shared by both surfaces instead
|
||||
// of a duplicated hardcoded `runtime === 'copilot'` literal. Other runtimes
|
||||
// (no agentFileExtension declared) keep the staged filename verbatim.
|
||||
const _agentExt = runtime ? runtimeArtifactConversion.agentFileExtensionFor(runtime) : undefined;
|
||||
const isRenamedAgents = !!_agentExt && kindName === 'agents';
|
||||
|
||||
if (kindName === 'skills') {
|
||||
// Skills kind: work with directories, not files.
|
||||
@@ -564,8 +568,8 @@ function _syncGsdDir(stagedDir: string, destDir: string, kind: ArtifactKind | st
|
||||
const stagedFiles = fs.readdirSync(stagedDir).filter(f => f.endsWith('.md'));
|
||||
const stagedDestNames = new Set<string>();
|
||||
for (const file of stagedFiles) {
|
||||
const destName = isCopilotAgents
|
||||
? file.replace(/\.md$/, '.agent.md')
|
||||
const destName = isRenamedAgents
|
||||
? file.replace(/\.md$/, _agentExt)
|
||||
: (kindName === 'agents' || namespacedByDir)
|
||||
? file
|
||||
: `${kindPrefix}${file.slice(0, -3)}.md`;
|
||||
|
||||
149
tests/copilot-upgrades.test.cjs
Normal file
149
tests/copilot-upgrades.test.cjs
Normal file
@@ -0,0 +1,149 @@
|
||||
'use strict';
|
||||
|
||||
/**
|
||||
* Copilot capability UPGRADES — ADR-1239 / #2099 (EoS/copilot).
|
||||
*
|
||||
* Drives the user-reachable surface (spawned `bin/install.js` via
|
||||
* `runMinimalInstall`) plus a direct negotiation-contract check to prove the
|
||||
* two real upgrades Copilot contributes as part of the EoS migration:
|
||||
*
|
||||
* UPGRADE 1 — multi-event hook bus: buildCopilotHookConfig() previously
|
||||
* emitted only `sessionStart`. This PR wires four additional events —
|
||||
* `preToolUse`, `postToolUse`, `userPromptSubmitted`, `sessionEnd` — each a
|
||||
* static, deterministic advisory command (no node-runner invocation), so a
|
||||
* live install's hooks/gsd-session.json registers all five events.
|
||||
*
|
||||
* UPGRADE 2 — dispatch.background: Copilot's capability.json already
|
||||
* declares `dispatch.background: true`, which legitimately EXCEEDS the
|
||||
* `declarative-cli` profile baseline (`false`, per
|
||||
* `PROFILE_BASELINES['declarative-cli']` in src/host-integration.cts).
|
||||
* NO agent-file/frontmatter change is involved — Copilot's .agent.md
|
||||
* frontmatter has no background-dispatch field (fields are
|
||||
* description/infer/mcp-servers/model/name/tools). This is surfaced
|
||||
* purely through the negotiated contract: a documented `true` value must
|
||||
* survive negotiation without a downgrade warning.
|
||||
*/
|
||||
|
||||
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 {
|
||||
profileOf,
|
||||
negotiateHostCapabilities,
|
||||
PROFILE_BASELINES,
|
||||
} = require('../gsd-core/bin/lib/host-integration.cjs');
|
||||
|
||||
const COPILOT_CAP = JSON.parse(
|
||||
fs.readFileSync(path.join(__dirname, '..', 'capabilities', 'copilot', 'capability.json'), 'utf8'),
|
||||
);
|
||||
const COPILOT_AXES = COPILOT_CAP.runtime.hostIntegration;
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// UPGRADE 1: multi-event hook bus — all 4 newly-wired events' live-install
|
||||
// coverage, on top of the pre-existing sessionStart.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const NEWLY_WIRED_EVENTS = ['preToolUse', 'postToolUse', 'userPromptSubmitted', 'sessionEnd'];
|
||||
const ALL_EXPECTED_EVENTS = ['sessionStart', ...NEWLY_WIRED_EVENTS];
|
||||
|
||||
test('copilot --global: hooks/gsd-session.json wires sessionStart plus all 4 newly-added events (UPGRADE 1)', (t) => {
|
||||
const { configDir, root } = runMinimalInstall({ runtime: 'copilot', scope: 'global' });
|
||||
t.after(() => cleanup(root));
|
||||
|
||||
const hookPath = path.join(configDir, 'hooks', 'gsd-session.json');
|
||||
assert.ok(fs.existsSync(hookPath), `${hookPath} must exist`);
|
||||
const parsed = JSON.parse(fs.readFileSync(hookPath, 'utf8'));
|
||||
|
||||
assert.strictEqual(parsed.version, 1, 'hook config version must be 1');
|
||||
assert.ok(parsed.hooks && typeof parsed.hooks === 'object', 'has hooks object');
|
||||
|
||||
for (const eventName of ALL_EXPECTED_EVENTS) {
|
||||
const eventHooks = parsed.hooks[eventName];
|
||||
assert.ok(Array.isArray(eventHooks) && eventHooks.length > 0,
|
||||
`hooks.${eventName} must exist and be non-empty`);
|
||||
const entry = eventHooks[0];
|
||||
assert.strictEqual(entry.type, 'command', `${eventName} entry type must be 'command'`);
|
||||
assert.ok(typeof entry.bash === 'string' && entry.bash.length > 0,
|
||||
`${eventName} entry must have a non-empty inline bash body`);
|
||||
assert.ok(typeof entry.powershell === 'string' && entry.powershell.length > 0,
|
||||
`${eventName} entry must have a non-empty inline powershell body`);
|
||||
assert.strictEqual(entry.timeoutSec, 10, `${eventName} entry must use timeoutSec 10`);
|
||||
}
|
||||
});
|
||||
|
||||
test('each newly-wired event emits a distinct static advisory (no shared boilerplate, no node-runner invocation)', (t) => {
|
||||
const { configDir, root } = runMinimalInstall({ runtime: 'copilot', scope: 'global' });
|
||||
t.after(() => cleanup(root));
|
||||
|
||||
const hookPath = path.join(configDir, 'hooks', 'gsd-session.json');
|
||||
const parsed = JSON.parse(fs.readFileSync(hookPath, 'utf8'));
|
||||
|
||||
const bashBodies = new Set();
|
||||
for (const eventName of ALL_EXPECTED_EVENTS) {
|
||||
const entry = parsed.hooks[eventName][0];
|
||||
assert.ok(entry.bash.includes('"additionalContext"'),
|
||||
`${eventName} bash body must emit the additionalContext JSON envelope`);
|
||||
assert.ok(!/hooks\/gsd-[\w-]+\.(js|cjs|sh)/.test(entry.bash),
|
||||
`${eventName} bash body must not reference an external hook script (cannot dangle)`);
|
||||
bashBodies.add(entry.bash);
|
||||
}
|
||||
assert.strictEqual(bashBodies.size, ALL_EXPECTED_EVENTS.length,
|
||||
'each event must emit its own distinct advisory command, not a copy-pasted duplicate');
|
||||
});
|
||||
|
||||
test('a runtime whose hook config omits the new events does NOT get them (descriptor-gated, not a global default)', (t) => {
|
||||
const { configDir, root } = runMinimalInstall({ runtime: 'claude', scope: 'global' });
|
||||
t.after(() => cleanup(root));
|
||||
|
||||
// Claude uses settings.json, not hooks/gsd-session.json — the Copilot
|
||||
// multi-event bus additions are self-contained to buildCopilotHookConfig
|
||||
// and must not leak into another runtime's hook surface.
|
||||
const copilotHookPath = path.join(configDir, 'hooks', 'gsd-session.json');
|
||||
assert.ok(!fs.existsSync(copilotHookPath),
|
||||
'claude must not have a Copilot-style hooks/gsd-session.json file');
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// UPGRADE 2: dispatch.background negotiation — documented true survives,
|
||||
// exceeding the declarative-cli profile baseline. No agent-file/frontmatter
|
||||
// change; negotiated-contract only (Copilot .agent.md has no background field).
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
test("copilot classifies as the 'declarative-cli' profile, whose baseline dispatch.background is false", () => {
|
||||
assert.equal(profileOf(COPILOT_AXES), 'declarative-cli');
|
||||
assert.equal(PROFILE_BASELINES['declarative-cli'].dispatch.background, false,
|
||||
'sanity: the declarative-cli baseline is false — copilot legitimately exceeds it');
|
||||
});
|
||||
|
||||
test('negotiateHostCapabilities surfaces copilot\'s documented dispatch.background:true with no downgrade warning (UPGRADE 2)', () => {
|
||||
assert.equal(COPILOT_AXES.dispatch.background, true,
|
||||
'sanity: the descriptor declares dispatch.background: true (documented, not undocumented)');
|
||||
|
||||
const { effective, warnings } = negotiateHostCapabilities(COPILOT_AXES);
|
||||
|
||||
assert.equal(effective.dispatch.background, true,
|
||||
'a documented true value must survive negotiation, exceeding the declarative-cli baseline of false');
|
||||
assert.ok(
|
||||
!warnings.some((w) => w.includes('dispatch.background')),
|
||||
`no warning may be raised for the documented dispatch.background axis, got: ${JSON.stringify(warnings)}`,
|
||||
);
|
||||
});
|
||||
|
||||
test('copilot .agent.md frontmatter has no background-dispatch field (negotiated contract only, sanity)', (t) => {
|
||||
const { configDir, root } = runMinimalInstall({ runtime: 'copilot', scope: 'global' });
|
||||
t.after(() => cleanup(root));
|
||||
|
||||
const agentsDir = path.join(configDir, 'agents');
|
||||
const agentFiles = fs.readdirSync(agentsDir).filter((f) => f.endsWith('.agent.md'));
|
||||
assert.ok(agentFiles.length > 0, 'at least one .agent.md must be installed');
|
||||
|
||||
const sample = fs.readFileSync(path.join(agentsDir, agentFiles[0]), 'utf8');
|
||||
const frontmatterMatch = sample.match(/^---\r?\n([\s\S]*?)\r?\n---/);
|
||||
assert.ok(frontmatterMatch, 'agent file must have a frontmatter block');
|
||||
assert.ok(!/^background:/m.test(frontmatterMatch[1]),
|
||||
'UPGRADE 2 must not add a background: frontmatter field — Copilot .agent.md has no such field');
|
||||
});
|
||||
164
tests/declarative-reference-copilot.test.cjs
Normal file
164
tests/declarative-reference-copilot.test.cjs
Normal file
@@ -0,0 +1,164 @@
|
||||
// allow-test-rule: structural-regression-guard — AC2: assert no `runtime === 'copilot'` string-equality branch, no live `isCopilot` 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 (#2099, expanded #2103)
|
||||
'use strict';
|
||||
|
||||
/**
|
||||
* Declarative reference host — GitHub Copilot (#2099 / ADR-1239 EoS).
|
||||
*
|
||||
* Copilot already installs through the descriptor-driven artifactLayout
|
||||
* (skills/agents, each with a named `converter`), and its capability.json
|
||||
* already declares `hostIntegration` + `dispatch` axes. Issue #2099's
|
||||
* literal premises about a "legacy exclusion list" / writesSharedSettings
|
||||
* mismatch and a hardcoded RUNTIME_CONTENT_DISPATCH branch were WRONG (see
|
||||
* the deep-research writeup on the issue) — this migration instead folds
|
||||
* the real residual `isCopilot` branches:
|
||||
* 1. src/install-engine.cts's `.agent.md` destination-suffix rename —
|
||||
* folded onto `hostBehaviors.agentFileExtension`.
|
||||
* 2. bin/install.js uninstall's two Copilot side-effect branches
|
||||
* (repo-root AGENTS.md cleanup + copilot-instructions.md/hook
|
||||
* cleanup) — folded onto `resolveInstallPlan(runtime).installSurface
|
||||
* === 'copilot-instructions'` (unique to copilot, so byte-parity).
|
||||
* 3. bin/install.js's two `skipSharedHooksInstall` checks — folded onto
|
||||
* `hostBehaviors.skipSharedHooksInstall:true` (copilot's golden has
|
||||
* only hooks/gsd-session.json, no shared gsd-*.js scripts).
|
||||
* 4. bin/install.js's dead legacy agent-converter dispatch arm in the
|
||||
* inline agent-copy loop — unreachable because copilot is a member of
|
||||
* `_DESCRIPTOR_AGENTS_RUNTIMES` (installRuntimeArtifacts already wrote
|
||||
* the agents before that loop runs) — deleted outright.
|
||||
*
|
||||
* This test is the reference-host dogfood mirroring
|
||||
* tests/declarative-reference-codebuddy.test.cjs: it (1) classifies
|
||||
* Copilot's profile via profileOf, (2) confirms the public declarative
|
||||
* adapter classifies it as declarative, (3) round-trips a real install
|
||||
* proving a gsd agent/skill 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
|
||||
* `isCopilot` branches (AC2).
|
||||
*
|
||||
* UPGRADE 1 (multi-event hook bus) + UPGRADE 2 (dispatch.background
|
||||
* negotiation) live-install coverage is in tests/copilot-upgrades.test.cjs
|
||||
* — not duplicated here.
|
||||
*/
|
||||
|
||||
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', 'copilot', 'capability.json');
|
||||
const COPILOT_CAP = JSON.parse(fs.readFileSync(DESC, 'utf8'));
|
||||
const COPILOT_AXES = COPILOT_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('Copilot 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, 'copilot descriptor declares hostIntegration axes');
|
||||
assert.equal(profileOf(axes), 'declarative-cli',
|
||||
'Copilot is a Declarative-CLI host');
|
||||
});
|
||||
|
||||
test('the public declarative adapter classifies Copilot as a declarative host', () => {
|
||||
const adapter = createDeclarativeAdapter({ runtime: 'copilot' });
|
||||
assert.equal(adapter.kind, 'declarative');
|
||||
assert.equal(adapter.runtime, 'copilot');
|
||||
assert.equal(typeof adapter.install, 'function');
|
||||
assert.equal(typeof adapter.uninstall, 'function');
|
||||
});
|
||||
|
||||
test('a real Copilot install emits a gsd agent/skill surface (invocable)', () => {
|
||||
const { configDir, root } = runMinimalInstall({ runtime: 'copilot', 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 agent/skill surface (declarative reference)');
|
||||
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('.agent.md'));
|
||||
assert.ok(agentFiles.length > 0, 'agents/ must contain gsd-*.agent.md files');
|
||||
const skillsDir = path.join(configDir, 'skills');
|
||||
assert.ok(fs.existsSync(skillsDir), 'skills/ directory must exist');
|
||||
} finally {
|
||||
cleanup(root);
|
||||
}
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// #2099 EoS/copilot — fail-closed negotiation + validator acceptance +
|
||||
// the folded descriptor (mirrors codebuddy/antigravity/qwen reference tests).
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
test('negotiateHostCapabilities never throws for copilot, even fully corrupted', () => {
|
||||
assert.doesNotThrow(() => negotiateHostCapabilities({}));
|
||||
assert.doesNotThrow(() => negotiateHostCapabilities({ ...COPILOT_AXES, embeddingMode: UNDOCUMENTED }));
|
||||
assert.doesNotThrow(() => negotiateHostCapabilities({ ...COPILOT_AXES, embeddingMode: 'future-unknown' }));
|
||||
assert.doesNotThrow(() => negotiateHostCapabilities({ ...COPILOT_AXES, dispatch: 'corrupted-not-an-object' }));
|
||||
assert.doesNotThrow(() => negotiateHostCapabilities({ ...COPILOT_AXES, dispatch: { ...COPILOT_AXES.dispatch, maxDepth: 'not-a-number' } }));
|
||||
});
|
||||
|
||||
test('a partial/empty copilot 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);
|
||||
});
|
||||
|
||||
test('capabilities/copilot/capability.json validates — no errors', () => {
|
||||
const errors = validateCapability(COPILOT_CAP, 'copilot');
|
||||
assert.deepEqual(errors, [], `validateCapability must return no errors, got: ${JSON.stringify(errors)}`);
|
||||
});
|
||||
|
||||
// -- AC2: the hardcoded branches are retired across all folded modules ------
|
||||
|
||||
test('no `runtime === "copilot"` string-equality branch (nor live `isCopilot` 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);
|
||||
|
||||
// NIT-1: catch both quote styles (single- and double-quoted 'copilot').
|
||||
const eqOffenders = stripped.match(/runtime\s*[!=]==\s*["']copilot["']/g) || [];
|
||||
assert.deepEqual(eqOffenders, [],
|
||||
`AC2: no hardcoded runtime==='copilot' branch may remain in ${path.relative(repoRoot, file)}; found: ${eqOffenders.join(', ')}`);
|
||||
|
||||
// Excludes legit enumeration sites: --copilot CLI flag parsing, the
|
||||
// '7':'copilot' numbered-menu map, allRuntimes/_DESCRIPTOR_AGENTS_RUNTIMES
|
||||
// set literals, config-dir lists, the copilot conversion FUNCTION names
|
||||
// (convertClaudeCommandToCopilotSkill / convertClaudeAgentToCopilotAgent /
|
||||
// convertCopilotToolName), and installSurface==='copilot-instructions'
|
||||
// (a descriptor-field string, not a runtime literal) — none of those
|
||||
// contain the token `isCopilot`, so a literal-word match is precise here.
|
||||
const isCopilotHits = stripped.match(/\bisCopilot\b/g) || [];
|
||||
assert.deepEqual(isCopilotHits, [],
|
||||
`AC2: no live isCopilot read may remain in ${path.relative(repoRoot, file)}; found ${isCopilotHits.length} occurrence(s)`);
|
||||
}
|
||||
});
|
||||
@@ -312,7 +312,7 @@
|
||||
"gsd-core/workflows/validate-phase.md": "2f705775a4b76d42",
|
||||
"gsd-core/workflows/verify-phase.md": "5c72780e34214e27",
|
||||
"gsd-core/workflows/verify-work.md": "c966971a3cbd1d71",
|
||||
"hooks/gsd-session.json": "0a462834f2a28fee",
|
||||
"hooks/gsd-session.json": "3382597f61e3a559",
|
||||
"scripts/changeset/README.md": "86ff89331dfd94b2",
|
||||
"scripts/changeset/cli.cjs": "68f92a344b199271",
|
||||
"scripts/changeset/github-release-notes.cjs": "795677f0c009b132",
|
||||
|
||||
@@ -4,14 +4,30 @@
|
||||
// Those programs ARE the runtime contract; this test extracts them verbatim from
|
||||
// the workflow and exercises the real jq (not a reimplementation) so the shipped
|
||||
// reconstruction logic is what gets property-tested.
|
||||
//
|
||||
// ARCHITECTURE (#2099): the shipped jq program is run over a WHOLE fast-check corpus
|
||||
// in ONE jq process, not once per generated case. Each generated event stream is
|
||||
// written as one compact-JSON array per line to a temp file, then `jq -c <PROGRAM>`
|
||||
// (no `-s`) applies PROGRAM to each array — `.` is that array, exactly what
|
||||
// production's `jq -rs` sees after slurping opencode's one-value-per-line stream —
|
||||
// emitting one compact-JSON result per line. This is empirically identical to the
|
||||
// per-stream `-rs` form (verified across embedded-newline/empty/quote/unicode/
|
||||
// null-drop cases) AND reads from a file like production (`jq -rs '…' <file>`), so
|
||||
// there is no stdin pipe to deadlock on large I/O. The prior design spawned ~600
|
||||
// synchronous jq subprocesses (numRuns × 3 properties); a single one freezing on a
|
||||
// contended CI runner hung the whole unit-test chunk to its 600s kill (macOS CI,
|
||||
// #2099). `node --test`'s --test-force-exit cannot interrupt a synchronous
|
||||
// execFileSync, so the cure is to stop spawning per case — not just to time-bound it.
|
||||
'use strict';
|
||||
|
||||
const { describe, test } = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
const { execFileSync } = require('node:child_process');
|
||||
const fs = require('node:fs');
|
||||
const os = require('node:os');
|
||||
const path = require('node:path');
|
||||
const fc = require('./helpers/fast-check-setup.cjs');
|
||||
const { cleanup } = require('./helpers.cjs');
|
||||
|
||||
const reviewPath = path.resolve(__dirname, '..', 'gsd-core', 'workflows', 'review.md');
|
||||
const workflow = fs.readFileSync(reviewPath, 'utf-8');
|
||||
@@ -34,20 +50,48 @@ const DIAG_PROGRAM = extractJqProgram('OPENCODE_DIAG'); // empty-output diagn
|
||||
// the suite to jq-present non-Windows hosts, mirroring golden-install-parity's win32
|
||||
// skip. The assertions run in full on every macOS/Linux CI leg.
|
||||
let jqAvailable = false;
|
||||
try { execFileSync('jq', ['--version'], { stdio: 'ignore' }); jqAvailable = true; } catch { /* no jq on PATH */ }
|
||||
try { execFileSync('jq', ['--version'], { stdio: 'ignore', timeout: 10000, killSignal: 'SIGKILL' }); jqAvailable = true; } catch { /* no jq on PATH */ }
|
||||
const skipReason = process.platform === 'win32'
|
||||
? 'jq invocation is not portable under Node child_process arg-quoting on Windows; logic is platform-independent and asserted on macOS/Linux'
|
||||
: (jqAvailable ? false : 'jq not on PATH');
|
||||
const opts = { skip: skipReason };
|
||||
|
||||
// Run a shipped jq program against a stream of events serialized exactly as
|
||||
// opencode emits them: one JSON value per line (jq -s slurps them into an array).
|
||||
// jq -r appends a single trailing newline to the (single) string result; strip it
|
||||
// to recover the value the workflow's `$(…)` capture would see.
|
||||
function runJq(program, events) {
|
||||
const jsonl = events.map((e) => JSON.stringify(e)).join('\n');
|
||||
const out = execFileSync('jq', ['-rs', program], { input: jsonl, encoding: 'utf8' });
|
||||
return out.endsWith('\n') ? out.slice(0, -1) : out;
|
||||
// Bound each jq subprocess so a frozen spawn on a contended runner fails fast +
|
||||
// diagnosably (ETIMEDOUT) rather than hanging the chunk. jq over this corpus
|
||||
// completes in ~10ms, so 30s is an enormous margin that never trips on a healthy run.
|
||||
const JQ_EXEC_OPTS = { encoding: 'utf8', timeout: 30000, killSignal: 'SIGKILL', maxBuffer: 64 * 1024 * 1024 };
|
||||
|
||||
// Run a shipped jq program over an array of event streams in a SINGLE jq process.
|
||||
// Returns one result per input stream (order preserved), decoded from jq's compact
|
||||
// (`-c`) JSON output back to the raw string production's `-r` would have captured.
|
||||
// Both shipped programs yield exactly one value per stream (join(...) / last|"...");
|
||||
// the length assertion pins that invariant so a future program change that broke it
|
||||
// (0 or >1 outputs) fails loudly instead of silently misaligning results.
|
||||
function runJqBatch(program, streams) {
|
||||
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-jq-batch-'));
|
||||
try {
|
||||
const file = path.join(dir, 'streams.jsonl');
|
||||
fs.writeFileSync(file, streams.map((events) => JSON.stringify(events)).join('\n') + '\n');
|
||||
const out = execFileSync('jq', ['-c', program, file], JQ_EXEC_OPTS);
|
||||
const lines = out.split('\n').filter((line) => line.length > 0);
|
||||
assert.equal(
|
||||
lines.length,
|
||||
streams.length,
|
||||
`jq must emit exactly one result per stream (got ${lines.length} for ${streams.length})`,
|
||||
);
|
||||
return lines.map((line) => JSON.parse(line));
|
||||
} finally {
|
||||
cleanup(dir);
|
||||
}
|
||||
}
|
||||
|
||||
// The intended reconstruction, computed independently of jq. jq is the unit under
|
||||
// test; this JS is the spec it must match on every generated case.
|
||||
function expectedReview(events) {
|
||||
return events
|
||||
.filter((e) => e.type === 'text' && e.part && typeof e.part.text === 'string')
|
||||
.map((e) => e.part.text)
|
||||
.join('\n');
|
||||
}
|
||||
|
||||
// Text values safe to round-trip through JSON → jq (utf8) → string. Excludes lone
|
||||
@@ -85,69 +129,82 @@ const eventStream = fc.array(fc.oneof(textEvent, textEvent, nonTextEvent), {
|
||||
maxLength: 30,
|
||||
});
|
||||
|
||||
// Corpus size per property. Matches the prior fast-check-setup numRuns:200 so
|
||||
// coverage is unchanged; distinct seeds give each property an independent corpus,
|
||||
// and fixed seeds keep the corpus deterministic across CI runs/OS legs.
|
||||
const CORPUS = 200;
|
||||
|
||||
describe('#1936 OpenCode review reconstruction — jq properties', () => {
|
||||
test('review == the newline-join of every assistant text part (order preserved)', opts, () => {
|
||||
fc.assert(
|
||||
fc.property(eventStream, (events) => {
|
||||
const expected = events
|
||||
.filter((e) => e.type === 'text' && e.part && typeof e.part.text === 'string')
|
||||
.map((e) => e.part.text)
|
||||
.join('\n');
|
||||
assert.equal(runJq(TEXT_PROGRAM, events), expected);
|
||||
}),
|
||||
);
|
||||
test('review == the newline-join of every assistant text part, over a generated corpus (order preserved)', opts, () => {
|
||||
const streams = fc.sample(eventStream, { numRuns: CORPUS, seed: 42 });
|
||||
const actual = runJqBatch(TEXT_PROGRAM, streams); // one jq process for the whole corpus
|
||||
streams.forEach((events, i) => {
|
||||
assert.equal(
|
||||
actual[i],
|
||||
expectedReview(events),
|
||||
`case ${i}: shipped jq review must equal the spec for events=${JSON.stringify(events)}`,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
test('a stream with no assistant text part reconstructs to empty (drives the #1936 stub)', opts, () => {
|
||||
fc.assert(
|
||||
fc.property(fc.array(nonTextEvent, { minLength: 1, maxLength: 20 }), (events) => {
|
||||
// This is the exact failure the bug describes: the agent runs tool calls
|
||||
// and ends with step_finish, emitting no text. Reconstruction must be empty
|
||||
// so the content-gate (`[ -n "$OPENCODE_REVIEW" ]`) falls through to the stub.
|
||||
assert.equal(runJq(TEXT_PROGRAM, events), '');
|
||||
}),
|
||||
);
|
||||
test('a stream with no assistant text part reconstructs to empty (drives the #1936 stub), over a corpus', opts, () => {
|
||||
// The exact failure the bug describes: the agent runs tool calls and ends with
|
||||
// step_finish, emitting no text. Reconstruction must be empty so the content-gate
|
||||
// (`[ -n "$OPENCODE_REVIEW" ]`) falls through to the stub.
|
||||
const streams = fc.sample(fc.array(nonTextEvent, { minLength: 1, maxLength: 20 }), { numRuns: CORPUS, seed: 43 });
|
||||
const actual = runJqBatch(TEXT_PROGRAM, streams);
|
||||
streams.forEach((events, i) => {
|
||||
assert.equal(actual[i], '', `case ${i}: text-free stream must reconstruct to '' for ${JSON.stringify(events)}`);
|
||||
});
|
||||
});
|
||||
|
||||
test('text parts that are null/absent are dropped, never rendered as "null"', opts, () => {
|
||||
fc.assert(
|
||||
fc.property(
|
||||
fc.array(
|
||||
fc.oneof(
|
||||
fc.record({ type: fc.constant('text'), part: fc.record({ text: fc.constant(null) }) }),
|
||||
fc.record({ type: fc.constant('text'), part: fc.record({}) }),
|
||||
),
|
||||
{ minLength: 1, maxLength: 10 },
|
||||
),
|
||||
(events) => {
|
||||
const out = runJq(TEXT_PROGRAM, events);
|
||||
assert.equal(out, '');
|
||||
assert.doesNotMatch(out, /null/);
|
||||
},
|
||||
test('text parts that are null/absent are dropped, never rendered as "null", over a corpus', opts, () => {
|
||||
const nullish = fc.array(
|
||||
fc.oneof(
|
||||
fc.record({ type: fc.constant('text'), part: fc.record({ text: fc.constant(null) }) }),
|
||||
fc.record({ type: fc.constant('text'), part: fc.record({}) }),
|
||||
),
|
||||
{ minLength: 1, maxLength: 10 },
|
||||
);
|
||||
const streams = fc.sample(nullish, { numRuns: CORPUS, seed: 44 });
|
||||
const actual = runJqBatch(TEXT_PROGRAM, streams);
|
||||
streams.forEach((events, i) => {
|
||||
assert.equal(actual[i], '', `case ${i}: null/absent text must drop to ''`);
|
||||
assert.doesNotMatch(actual[i], /null/, `case ${i}: must never render "null"`);
|
||||
});
|
||||
});
|
||||
|
||||
// Explicit boundary + happy examples (deterministic, not sampled) — all in one
|
||||
// batched jq spawn. Pins the exact contract the corpus only covers probabilistically.
|
||||
test('boundary + happy example streams reconstruct exactly (batched)', opts, () => {
|
||||
const cases = [
|
||||
{ events: [{ type: 'text', part: { text: 'only' } }], expect: 'only' }, // single text part
|
||||
{ events: [{ type: 'text', part: { text: '' } }], expect: '' }, // empty-string text is kept
|
||||
{ events: [{ type: 'text', part: { text: 'a' } }, { type: 'tool_use', part: { tool: 'r' } }, { type: 'text', part: { text: 'b' } }], expect: 'a\nb' }, // text interleaved with noise
|
||||
{ events: [{ type: 'text', part: { text: 'x\ny' } }], expect: 'x\ny' }, // embedded newline preserved
|
||||
{ events: [{ type: 'tool_use', part: { tool: 'read' } }, { type: 'step_finish', part: { reason: 'stop', tokens: { output: 3 } } }], expect: '' }, // no text at all
|
||||
{ events: Array.from({ length: 30 }, (_v, i) => ({ type: 'text', part: { text: `p${i}` } })), expect: Array.from({ length: 30 }, (_v, i) => `p${i}`).join('\n') }, // max-size all-text stream
|
||||
];
|
||||
const actual = runJqBatch(TEXT_PROGRAM, cases.map((c) => c.events));
|
||||
cases.forEach((c, i) => assert.equal(actual[i], c.expect, `example ${i}: ${JSON.stringify(c.events)}`));
|
||||
});
|
||||
|
||||
// Diagnostic path (empty-output stub). The finding calls out `missing .tokens.output`
|
||||
// and no-step_finish as real edges — pin them with examples against the shipped jq.
|
||||
// and no-step_finish as real edges — pin them with examples against the shipped jq,
|
||||
// all in one batched spawn.
|
||||
describe('diagnostic reconstruction (stop reason + output tokens)', () => {
|
||||
test('reports reason and output tokens from the LAST step_finish', opts, () => {
|
||||
const events = [
|
||||
{ type: 'step_finish', part: { reason: 'tool_calls', tokens: { output: 5 } } },
|
||||
{ type: 'tool_use', part: {} },
|
||||
{ type: 'step_finish', part: { reason: 'stop', tokens: { output: 0 } } },
|
||||
test('reports reason/tokens from the LAST step_finish and degrades missing fields to "?"', opts, () => {
|
||||
const cases = [
|
||||
{ events: [
|
||||
{ type: 'step_finish', part: { reason: 'tool_calls', tokens: { output: 5 } } },
|
||||
{ type: 'tool_use', part: {} },
|
||||
{ type: 'step_finish', part: { reason: 'stop', tokens: { output: 0 } } },
|
||||
], expect: 'stop reason=stop, output tokens=0' }, // LAST step_finish wins
|
||||
{ events: [{ type: 'step_finish', part: { reason: 'stop', tokens: {} } }], expect: 'stop reason=stop, output tokens=?' }, // missing .tokens.output → "?"
|
||||
{ events: [{ type: 'tool_use', part: { tool: 'read' } }], expect: 'stop reason=?, output tokens=?' }, // no step_finish at all → both "?"
|
||||
];
|
||||
assert.equal(runJq(DIAG_PROGRAM, events), 'stop reason=stop, output tokens=0');
|
||||
});
|
||||
|
||||
test('missing .tokens.output degrades to "?" rather than null/garbage', opts, () => {
|
||||
const events = [{ type: 'step_finish', part: { reason: 'stop', tokens: {} } }];
|
||||
assert.equal(runJq(DIAG_PROGRAM, events), 'stop reason=stop, output tokens=?');
|
||||
});
|
||||
|
||||
test('no step_finish at all degrades both fields to "?"', opts, () => {
|
||||
const events = [{ type: 'tool_use', part: { tool: 'read' } }];
|
||||
assert.equal(runJq(DIAG_PROGRAM, events), 'stop reason=?, output tokens=?');
|
||||
const actual = runJqBatch(DIAG_PROGRAM, cases.map((c) => c.events));
|
||||
cases.forEach((c, i) => assert.equal(actual[i], c.expect, `diag ${i}: ${JSON.stringify(c.events)}`));
|
||||
});
|
||||
});
|
||||
|
||||
@@ -158,7 +215,7 @@ describe('#1936 OpenCode review reconstruction — jq properties', () => {
|
||||
test('non-JSON stdout does not masquerade as a reconstructed review', opts, () => {
|
||||
let threw = false;
|
||||
try {
|
||||
execFileSync('jq', ['-rs', TEXT_PROGRAM], { input: 'auth token expired\n', encoding: 'utf8' });
|
||||
execFileSync('jq', ['-rs', TEXT_PROGRAM], { ...JQ_EXEC_OPTS, input: 'auth token expired\n' });
|
||||
} catch {
|
||||
threw = true;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user