* fix(#2875): stage user artifacts durably across install wipes (#1874-F19) preserveUserArtifacts held user files only in an in-memory Map across the wipe, so any process death between preserve and restore lost them outright. Seven call sites, not the four the issue records. Three of them never called the helper at all - they open-coded the same read/wipe/write - so searching for callers under-counted by construction; the extra sites were found by sweeping for the pattern instead. The worst is the mainline install path, where the crash window spans the entire gsd-core tree copy rather than a single rmSync. Adds src/user-artifact-staging.cts: durable on-disk staging with a record written after the copies land as the commit point, plus recovery of orphaned batches on the next run - without recovery the staged bytes survive but the user's file is still gone, which would pass its own test while delivering nothing. Routes copyPreservingSymlink through installFs() so staging cannot bypass the install fs seam, and reunites its symlink-safety docblock with the function it documents. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * docs(#2875): amend ADR-3574 with four claims disproved by implementation Implementing Phase 6 disproved four statements the ADR rests on. The central decision - no single materializer - is unaffected and stands. Corrected: decision 3 was already satisfied, so nothing was extracted; the agents-bypass runtime set omitted claude, kilo and opencode, and closing it needed three new pieces of descriptor contract rather than proceeding on its own terms; three of the four blockers the layout comment names were already stale; and F19 is seven call sites, not four. Records the generalizable lesson: the defect is the pattern of holding user data in memory across a wipe, not the helper, so searching for callers of the helper under-counts by construction. Also resolves the ADR's open question on USER_OWNED_ARTIFACTS membership, and notes that copyPreservingSymlink needed routing through the install fs seam before it could be reused. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * fix(#2875): close dangling-symlink blind spot and harden staging recovery An adversarial review found the F19 staging work shipped red and unsafe. Root cause, shared by two arbitrary-write findings: hasExistingSymlinkBetween missed dangling symlinks in both its root check and its per-segment walk, because it probed with existsSync, which is false for a link whose target does not exist. Fixing only the new module would have reused a guard that was itself blind. This guard protects the whole install tree. Recovery no longer throws: it degrades per entry and per file, so one bad batch cannot block the others. Previously an unrecoverable entry propagated out of the first statement of install and uninstall, before the cleanup that would have removed it - wedging the installer permanently. Partial fs adapters now throw on any omitted method instead of silently reaching the real filesystem, closing the trap that let a test poison list pass while real IO happened. Staged names must be flat, recovery refuses a dangling destination symlink, and a batch whose recovery genuinely failed is no longer swept - it was discarding the only durable copy of the file it had just failed to restore. Replaces three tests that could not fail, including the one labelled negative proof. Known limitation, documented not closed: concurrent installs sharing a staging key can still lose a batch. A real fix needs a cross-process lock. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * enh(#2875): make the descriptor authoritative for the agents kind Deletes the inline agent-staging loop in bin/install.js and the _DESCRIPTOR_AGENTS_RUNTIMES set, so every runtime materializes agents from its capability descriptor instead of an inline hostBehaviors dispatch. Closing it needed three pieces of contract the descriptor pipeline never had, all reducible to one missing input - per-agent resolution context: a frontmatter-extensions step for claude's effort and disallowedTools, per-agent model-override resolution for kilo and opencode, and a named branding converter for hermes, whose rewrite data was already declared. Seven runtimes were on the loop, not the six the design recorded - kimi-code was found by a golden fixture, not by analysis. claude-local and kimi-code both silently lost their agents mid-change; the fixtures caught both and the cause was fixed rather than the fixtures regenerated. A parity harness gates the migration: both pipelines over identical inputs, byte-identical output including filenames, per runtime. It is demonstrated red before being trusted. Surface and install paths converge for all seven, which also fixes surface previously writing no agents for these runtimes. Codex's config.toml strip stays put - it mutates host config, which no descriptor kind models. Also routes install-model-override-resolver and install-effort-resolver through the install fs seam. Both leaked real filesystem IO from the install call tree; the stricter adapter is what exposed them. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * docs(#2875): record the agents-descriptor migration and correct the ADR count The _DESCRIPTOR_AGENTS_RUNTIMES allow-list no longer exists, so the host integration guide told readers to join a set that is gone. Replaces that with what is now true - declare an agents entry and it installs, on the surface path as well as install - and points anyone needing a per-agent transform at the three extension points rather than at a new inline branch. Corrects the ADR amendment: seven runtimes were on the inline loop, not six. kimi-code was found by a golden fixture going red, not by reading. That is the third short count this phase, all from enumerating by symbol or set membership when the thing that matters is a behavior. Adds the Changed changeset for the surface-path convergence. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * docs(#2875): amend ADR-2866 - claude global always wrote agents on disk The claude row's global=[skills] described what capability.json declared, not what the installer wrote. bin/install.js's inline agent-staging loop was never scope-gated and never consulted the descriptor, so a claude --global install has always written agents/gsd-*.md. Phase 6 closes the gap by deleting that loop and declaring agents on claude's descriptor at global scope. On-disk bytes are unchanged - the golden fixtures did not move, which is the evidence that the descriptor, not the installer, was incomplete. #2218 is unaffected: agents are not trigger-bearing, so the wider row does not introduce a new shadowing case. Records the warning that an incomplete descriptor is invisible while a second code path silently does its work, and only surfaces when the two are forced into agreement. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * fix(#2875): close review findings across staging, agents and the parity harness Two independent reviews of this branch found defects the local gates missed. Security: a dangling symlink at a migration destination allowed writing outside configDir - the same class this change claimed to close, missed at the terminal write of the flow being added. The staging-root resolver threw as the first statement of install and uninstall, so a hostile symlink bricked both, and symlinked-configDir users lost uninstall as well as install; it now degrades instead of aborting. Recovery gained a source-side symlink check and now refuses a relative destDir, which resolved against cwd. Converter dispatch gained a runtime allowlist - lint-time validation stopped mattering once this branch promoted that dispatch from the surface path to real installs. Correctness: claude --local --minimal exited 1 because the minimal profile legitimately yields zero agents and the new path treated that as a failure. cline --local silently lost its agents - its descriptor declared none while the deleted loop wrote them unconditionally. The agents prune was widened to any gsd-* entry and destroyed user files it never owned. The parity harness, on which the migration's safety argument rested, drove a synthetic registry and never byte-compared the shipped descriptors; two of its trap rows could not fail. It now drives the real registry across 13 runtime-scope rows including kimi-code and cline-local, and its red-proof is demonstrated by corrupting a live capability.json. Three goldens that had encoded the cline regression as expected behavior were corrected. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * fix(#2875): close findings from both mandated review engines /security-review found the staging source-side walk honouring GSD_ALLOW_SYMLINKED_DEST, an opt-in documented as relaxing only the write destination. A symlinked files/ component dereferenced because copyPreservingSymlink lstats the leaf only, so an intermediate link is followed. The source walk no longer honours the opt-in; the destination check still does. /code-review spec axis found this branch had reintroduced its own bug: migrateLegacyDevPreferencesToSkill's new symlink refusal threw unguarded after the legacy dir was wiped and before the staged batch was restored, so a planted symlink bricked uninstall permanently and orphaned the batch. Refusal kept, abort removed. kimi-code local silently lost its agents, the same class as the cline bug, and the parity harness recorded that exclusion as intentional - the third test in this branch to pin a regression as correct. --minimal now creates an empty agents/ dir that never existed. Behaviour restored rather than softening the changeset, so its byte-identical claim stays true. Standards axis: try/finally removed from twelve test bodies, fast-check properties added for parseOwnerPid, boundary coverage at the grace window and the ancestor-probe depth, a parity assertion for the staging-root helper duplicated across two files, and the 8-deep config walk deduplicated. Records 60-review.json with every finding and disposition from five passes, including the smells left unfixed and why. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * fix(#2875): prune stale agents unconditionally in minimal mode The previous round stopped an empty agents/ directory being created when the resolved profile yields no agents. That was implemented by skipping the agents kind entirely, which also skipped its stale-agent prune - so a full to minimal downgrade left stale gsd-* agents behind. The deleted inline loop pruned unconditionally and only skipped writing. Those are three separate conditions, not one: prune always, write only when there is something to write, create the directory only when writing. Both call sites now run _removeGsdEntries before the empty-staged early exit. The symlink-escape guard moved with it, since the prune also touches dest. Codex .toml agents and the config.toml stanzas are cleaned again, and user-owned agents are still preserved. The agents/ directory is left in place after a prune empties it, matching every sibling kind - none of them remove the destination directory itself. Golden fixtures confirmed byte-identical: the prune is a no-op on a fresh install, so fixture generation is unaffected. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * docs(#2875): document interrupted-install recovery for user-owned files The durable-staging fix is invisible to the user it protects. Someone whose install died mid-flight has no way to know USER-PROFILE.md was staged before the delete, that the next run restores it, or that recovery happens at the start of that run rather than in the background. Written as the task the user has - finish the interrupted command - rather than as a description of the mechanism, and states what it will not do: overwrite a file already present, or touch staging belonging to another install still running. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * chore(#2875): backfill changeset pr number Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * test(#2875): assert the J8 model override without building a regex CodeQL flagged incomplete string escaping: the assertion interpolated the override value into a RegExp while escaping only forward slashes, which is meaningless in a constructor, leaving real metacharacters unescaped. The failure direction was the dangerous one - a metacharacter would have made the match more permissive, so the row would pass when it should fail. That matters here because J8 exists precisely because an earlier revision was a tautology; the rewrite reintroduced a different way for the same assertion to stop discriminating. Replaced with a line-wise exact match, so no regex is constructed at all. Swept the other test files this branch adds; no sibling instances. lint:ci passed on the original - lint-no-adhoc-regex-escape matches a full metachar-escape copy, so a single slash replace slipped under it. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> --------- Co-authored-by: sim <sim@local> Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
647 lines
35 KiB
JavaScript
647 lines
35 KiB
JavaScript
'use strict';
|
||
|
||
// allow-test-rule: source-text-is-the-product #2875 — the J-row assertions
|
||
// pattern-match rendered agent-.md frontmatter (`effort:`, `model:`,
|
||
// disallowedTools, branding text). That frontmatter IS the deployed artifact
|
||
// each runtime loads at dispatch time (no typed IR exists between
|
||
// applyAgentFrontmatterExtensions/injectEffortFrontmatter and the file a
|
||
// runtime reads) — matching CONTRIBUTING.md's `source-text-is-the-product`
|
||
// exemption, not a workaroundable "hide the grep in a parser" case.
|
||
|
||
/**
|
||
* agent-descriptor-parity.install.test.cjs — #2875 Part 2 (the agents-bypass
|
||
* closure), 50-test-matrix.md sections H, I, J (rows H1-H8, I1-I3, J1-J10).
|
||
*
|
||
* HONEST BASELINE (rewritten — a prior revision of this file was found to
|
||
* test the wrong thing on every axis; see the fixed defects below):
|
||
*
|
||
* bin/install.js's inline agent-staging loop is GONE (deleted in the same
|
||
* commit that made every runtime descriptor-driven for agents). There is no
|
||
* second, independently-maintained agent-staging pipeline left in this
|
||
* codebase to diff against — an "old pipeline vs new pipeline" comparison is
|
||
* therefore IMPOSSIBLE post-deletion, and a prior revision of this file's
|
||
* header claiming to compare against "bin/install.js's inline agent-staging
|
||
* loop" was false the moment that loop was deleted.
|
||
*
|
||
* What THIS revision actually proves instead, and how:
|
||
*
|
||
* 1. The REAL production entry point (`installAgentsKindStandalone`,
|
||
* `install-engine.cjs`) is driven directly, against the REAL, BUILT
|
||
* `capability-registry.cjs` — no synthetic registry override. Every H
|
||
* row therefore byte-compares actual output written by a real
|
||
* `capabilities/<runtime>/capability.json` edit, not a hand-rolled
|
||
* stand-in for one. A wrong-but-syntactically-valid `converter` name
|
||
* landing in a real descriptor changes the ACTUAL side's output and is
|
||
* caught (see H8's red-proof, which demonstrates this directly).
|
||
*
|
||
* 2. `computeExpectedOutput` (the "oracle") independently assembles the
|
||
* EXPECTED bytes by calling the individual conversion PRIMITIVES
|
||
* directly: `composeWorkflow`, `applyAgentPathRewrites`,
|
||
* `processAttribution`, the runtime's converter function (dispatched
|
||
* off `EXPECTED_CONVERTER_NAME_BY_RUNTIME` — a hand-verified,
|
||
* independent map, NOT read from the descriptor under test),
|
||
* `applyAgentFrontmatterExtensions`, `normalizeAgentBodyForRuntime`.
|
||
* These primitives are — by construction, not accident —
|
||
* single-sourced: there is no live duplicate of `composeWorkflow` or
|
||
* `applyAgentPathRewrites` to diff against either, because #2875 Part 2
|
||
* collapsed the duplication into these shared functions. Reusing them
|
||
* here does not defeat the test: the property under test in every H row
|
||
* is "does capability.json's declared `converter` name resolve to the
|
||
* CORRECT converter and get invoked in the correct position of the
|
||
* pipeline" — which the independent `EXPECTED_CONVERTER_NAME_BY_RUNTIME`
|
||
* map exists specifically to keep decoupled from the descriptor.
|
||
*
|
||
* 3. Both scopes are exercised for every runtime that declares a
|
||
* per-scope `agents` kind (claude, cline, codex, hermes, kilo, opencode,
|
||
* kimi-code × global+local). The prior revision was global-only, which
|
||
* is exactly the class of gap that let two separate agents-drop
|
||
* regressions reach `next` undetected: cline-local (fixed alongside
|
||
* this rewrite — capabilities/cline/capability.json's `local`
|
||
* artifactLayout now declares an `agents` kind) and kimi-code-local
|
||
* (fixed the same way — its `local` artifactLayout previously declared
|
||
* no `agents` kind at all, so the deleted inline loop's implicit
|
||
* scope-gate was silently replaced with NO gate, dropping every
|
||
* kimi-code local install's agents/gsd-*.md entirely).
|
||
*
|
||
* H8 is mandatory, not optional: a parity harness never demonstrated failing
|
||
* is decoration. It feeds the oracle a DELIBERATELY WRONG (but real,
|
||
* allowlisted) converter name and asserts the comparison goes red against
|
||
* the REAL (correctly-configured) production output — proving that if
|
||
* capability.json's declared converter ever regressed, this exact harness
|
||
* would catch it.
|
||
*
|
||
* WHAT THIS FILE DOES NOT PROVE: H8's red-proof demonstrates exactly one
|
||
* failure class — the oracle and the real descriptor path resolving to a
|
||
* DIFFERENT converter for the same runtime. It says nothing about a bug
|
||
* INSIDE a shared primitive (`composeWorkflow`, `applyAgentPathRewrites`,
|
||
* `processAttribution`, `applyAgentFrontmatterExtensions`,
|
||
* `normalizeAgentBodyForRuntime`, or a named converter itself): both the
|
||
* oracle (point 2 above) and the real descriptor path call the identical
|
||
* function, so a regression inside one of those functions changes BOTH
|
||
* sides identically and every H row stays green. That is a deliberate,
|
||
* unavoidable consequence of point 2's single-sourcing (there is no second,
|
||
* independently-implemented copy of those primitives left to diff against
|
||
* post-#2875-Part-2) — this file is a converter-WIRING parity gate, not a
|
||
* substitute for direct unit coverage of the primitives themselves (which
|
||
* live in their own owning test files, e.g. `runtime-artifact-conversion`'s
|
||
* suite).
|
||
*/
|
||
|
||
const { test } = require('node:test');
|
||
const assert = require('node:assert/strict');
|
||
const fs = require('node:fs');
|
||
const os = require('node:os');
|
||
const path = require('node:path');
|
||
const { cleanup } = require('./helpers.cjs');
|
||
|
||
const REPO_ROOT = path.join(__dirname, '..');
|
||
const LIB_DIR = path.join(REPO_ROOT, 'gsd-core', 'bin', 'lib');
|
||
|
||
const runtimeArtifactConversion = require(path.join(LIB_DIR, 'runtime-artifact-conversion.cjs'));
|
||
const installModelOverrideResolver = require(path.join(LIB_DIR, 'install-model-override-resolver.cjs'));
|
||
const installEngine = require(path.join(LIB_DIR, 'install-engine.cjs'));
|
||
const capabilityRegistry = require(path.join(LIB_DIR, 'capability-registry.cjs'));
|
||
const { composeWorkflow } = require(path.join(LIB_DIR, 'workflow-fragments.cjs'));
|
||
const installBin = require(path.join(REPO_ROOT, 'bin', 'install.js'));
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// Fixture builders
|
||
// ---------------------------------------------------------------------------
|
||
|
||
/** Deterministic sample agent sources — NOT the real agents/ tree (the real
|
||
* tree's exact roster can change independently of this suite). Covers:
|
||
* ~/.claude/ + $HOME/.claude/ (anchored + bare) path forms, a Co-Authored-By
|
||
* trailer, and one row-J4 "disallowedTools hit" agent name plus one "miss". */
|
||
const SAMPLE_AGENTS = {
|
||
'gsd-planner.md': [
|
||
'---',
|
||
'name: gsd-planner',
|
||
'description: Plans phases for GSD workflows.',
|
||
'tools: Read, Write, Edit, Bash',
|
||
'---',
|
||
'',
|
||
'Reads @~/.claude/gsd-core/commands/gsd/plan-phase.md and $HOME/.claude/CLAUDE.md.',
|
||
'Bare forms too: ~/.claude and $HOME/.claude.',
|
||
'References Claude Code and CLAUDE.md and .claude/settings.json.',
|
||
'',
|
||
'Co-Authored-By: Claude <noreply@anthropic.com>',
|
||
'',
|
||
].join('\n'),
|
||
'gsd-plan-checker.md': [
|
||
'---',
|
||
'name: gsd-plan-checker',
|
||
'description: Checks plans for GSD workflows.',
|
||
'tools: Read, Grep, Glob',
|
||
'---',
|
||
'',
|
||
'A read-only checker agent (row J4 "hit" — declared in READONLY_AGENT_DISALLOWED_TOOLS).',
|
||
'',
|
||
].join('\n'),
|
||
};
|
||
|
||
function buildSourceTree(agentFiles) {
|
||
const root = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-agent-parity-src-'));
|
||
const commandsGsd = path.join(root, 'commands', 'gsd');
|
||
fs.mkdirSync(commandsGsd, { recursive: true });
|
||
const agentsDir = path.join(root, 'agents');
|
||
fs.mkdirSync(agentsDir, { recursive: true });
|
||
for (const [name, content] of Object.entries(agentFiles)) {
|
||
fs.writeFileSync(path.join(agentsDir, name), content);
|
||
}
|
||
return { root, commandsGsd, agentsDir };
|
||
}
|
||
|
||
/** A fresh "install destination" dir with a `.gsd-source` marker pointing at
|
||
* `commandsGsd` — the same marker findInstallSourceRoot/findAgentsSourceRoot
|
||
* read (runtime-artifact-layout.cts). */
|
||
function buildTargetDir(commandsGsd) {
|
||
const targetDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-agent-parity-dest-'));
|
||
fs.writeFileSync(path.join(targetDir, '.gsd-source'), commandsGsd);
|
||
return targetDir;
|
||
}
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// Oracle — independently-assembled EXPECTED output (see header doc)
|
||
// ---------------------------------------------------------------------------
|
||
|
||
/** Hand-verified, independent of any capability.json — this is the thing
|
||
* every H row's ACTUAL side (a real capability.json) is checked against.
|
||
* `null` means converter:null (identity — claude, kimi-code). */
|
||
const EXPECTED_CONVERTER_NAME_BY_RUNTIME = {
|
||
claude: null,
|
||
'kimi-code': null,
|
||
cline: 'convertClaudeAgentToClineAgent',
|
||
codex: 'convertClaudeAgentToCodexAgent',
|
||
hermes: 'convertClaudeAgentToHermesAgent',
|
||
kilo: 'convertClaudeToKiloFrontmatter',
|
||
opencode: 'convertClaudeToOpencodeFrontmatter',
|
||
};
|
||
|
||
/** Converter functions callable by name — bin/install.js still owns
|
||
* cline/codex/kilo/opencode's (never migrated to runtime-artifact-conversion.cjs);
|
||
* hermes's is descriptor-native (#2875 Part 2 / J9-J10). */
|
||
const NAMED_CONVERTERS = {
|
||
convertClaudeAgentToClineAgent: installBin.convertClaudeAgentToClineAgent,
|
||
convertClaudeAgentToCodexAgent: installBin.convertClaudeAgentToCodexAgent,
|
||
convertClaudeAgentToHermesAgent: runtimeArtifactConversion.convertClaudeAgentToHermesAgent,
|
||
convertClaudeToKiloFrontmatter: installBin.convertClaudeToKiloFrontmatter,
|
||
convertClaudeToOpencodeFrontmatter: installBin.convertClaudeToOpencodeFrontmatter,
|
||
};
|
||
|
||
/** kilo/opencode take an options bag (`{isAgent, modelOverride}`), resolved
|
||
* ONCE per call via the single shared precedence resolver (J5-J8) — every
|
||
* other converter here takes only `content`. */
|
||
const MODEL_OVERRIDE_CONVERTER_NAMES = new Set(['convertClaudeToKiloFrontmatter', 'convertClaudeToOpencodeFrontmatter']);
|
||
|
||
/**
|
||
* Assemble the EXPECTED per-file output for `runtime` from `agentsDir`,
|
||
* calling the shared conversion primitives directly (see header doc for why
|
||
* this is not circular). `converterNameOverride`, when passed, replaces
|
||
* `EXPECTED_CONVERTER_NAME_BY_RUNTIME[runtime]` — used ONLY by H8's
|
||
* red-proof to inject a deliberately wrong converter.
|
||
*/
|
||
function computeExpectedOutput(runtime, agentsDir, ctx, converterNameOverride) {
|
||
const converterName = converterNameOverride !== undefined ? converterNameOverride : EXPECTED_CONVERTER_NAME_BY_RUNTIME[runtime];
|
||
const { pathPrefix, attribution, targetDir } = ctx;
|
||
const out = new Map();
|
||
const entries = fs.readdirSync(agentsDir, { withFileTypes: true });
|
||
for (const entry of entries) {
|
||
if (!entry.isFile() || !entry.name.endsWith('.md')) continue;
|
||
const agentSourcePath = path.join(agentsDir, entry.name);
|
||
let content = fs.readFileSync(agentSourcePath, 'utf8');
|
||
// Step 0 (#2995): strip gsd:section markers — same order the real
|
||
// pipeline uses (stageAgentsForRuntimeWithConverter, install-profiles.cts).
|
||
content = composeWorkflow(content, { sourcePath: agentSourcePath });
|
||
const agentName = runtimeArtifactConversion.deriveAgentName(entry.name);
|
||
// Step 1: path rewrites
|
||
content = runtimeArtifactConversion.applyAgentPathRewrites(content, runtime, pathPrefix);
|
||
// Step 2: attribution
|
||
content = runtimeArtifactConversion.processAttribution(content, attribution);
|
||
// Step 3: converter — dispatched off the INDEPENDENT expected-name map,
|
||
// never off the descriptor under test.
|
||
if (converterName) {
|
||
const fn = NAMED_CONVERTERS[converterName];
|
||
assert.ok(typeof fn === 'function', `oracle: no converter function registered for "${converterName}"`);
|
||
if (MODEL_OVERRIDE_CONVERTER_NAMES.has(converterName)) {
|
||
const modelOverride = installModelOverrideResolver.resolveAgentModelOverride(
|
||
agentName,
|
||
installModelOverrideResolver.readGsdEffectiveModelOverrides(targetDir),
|
||
installModelOverrideResolver.readGsdRuntimeProfileResolver(targetDir),
|
||
);
|
||
content = fn(content, { isAgent: true, modelOverride });
|
||
} else {
|
||
content = fn(content);
|
||
}
|
||
}
|
||
// converter:null (claude, kimi-code) — content unchanged by this step.
|
||
// Step 4: frontmatter extensions (effort/disallowedTools)
|
||
content = runtimeArtifactConversion.applyAgentFrontmatterExtensions(content, { runtime, agentName, targetDir });
|
||
// Step 5: normalize colon->hyphen refs
|
||
content = runtimeArtifactConversion.normalizeAgentBodyForRuntime(
|
||
content,
|
||
runtime,
|
||
runtimeArtifactConversion.readGsdCommandNames(),
|
||
);
|
||
out.set(entry.name, content);
|
||
}
|
||
return out;
|
||
}
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// Real descriptor path — the REAL production entry point, REAL registry
|
||
// ---------------------------------------------------------------------------
|
||
|
||
/**
|
||
* Drive the ACTUAL production entry point (`installAgentsKindStandalone`)
|
||
* against the REAL, built `capability-registry.cjs` — no override. This is
|
||
* exactly what `bin/install.js`'s `install()` calls for every runtime whose
|
||
* layout is not otherwise reached by the generic `installRuntimeArtifacts`
|
||
* loop (and, for the runtimes reached by that loop, produces identical
|
||
* output to it — both route through the SAME `convertedAgentsKind` /
|
||
* `_resolveNamedConverter` dispatch in runtime-artifact-layout.cts).
|
||
*/
|
||
function runRealDescriptorPath(runtime, scope, targetDir, ctx) {
|
||
const resolvedProfile = { name: 'full', skills: '*', agents: new Set() };
|
||
const result = installEngine.installAgentsKindStandalone(
|
||
runtime,
|
||
targetDir,
|
||
scope,
|
||
resolvedProfile,
|
||
ctx.pathPrefix,
|
||
() => ctx.attribution,
|
||
);
|
||
const out = new Map();
|
||
if (!result) return out;
|
||
for (const entry of fs.readdirSync(result.destDir, { withFileTypes: true })) {
|
||
if (entry.isFile()) out.set(entry.name, fs.readFileSync(path.join(result.destDir, entry.name), 'utf8'));
|
||
}
|
||
return out;
|
||
}
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// Comparison helper
|
||
// ---------------------------------------------------------------------------
|
||
|
||
/** Every (runtime, scope) pair the REAL capabilities/<runtime>/capability.json
|
||
* today declares an `agents` kind for (measured 2026-08-17). K1 below is the
|
||
* machine-checked guarantee that this list cannot silently go stale — it
|
||
* sweeps the real registry and fails if a declarant is missing here. */
|
||
const RUNTIME_SCOPE_PAIRS = [
|
||
['claude', 'global'], ['claude', 'local'],
|
||
['cline', 'global'], ['cline', 'local'],
|
||
['codex', 'global'], ['codex', 'local'],
|
||
['hermes', 'global'], ['hermes', 'local'],
|
||
['kilo', 'global'], ['kilo', 'local'],
|
||
['opencode', 'global'], ['opencode', 'local'],
|
||
['kimi-code', 'global'], ['kimi-code', 'local'],
|
||
];
|
||
|
||
/** Model override literal shared by J8's two `_stageWithModelOverride` calls. */
|
||
const J8_OVERRIDE_MODEL = 'shared/explicit-model';
|
||
|
||
/**
|
||
* Standalone helper (module scope, no test-context access — the
|
||
* CONTRIBUTING.md "Never use try/finally inside test bodies" exemption) for
|
||
* J8: stage a single-agent source tree with a real `.planning/config.json`
|
||
* model_overrides block for `runtime` through the real descriptor path.
|
||
*/
|
||
function _stageWithModelOverride(runtime, overrideModel) {
|
||
const agentFiles = { 'gsd-planner.md': SAMPLE_AGENTS['gsd-planner.md'] };
|
||
const { commandsGsd, root } = buildSourceTree(agentFiles);
|
||
const targetDir = buildTargetDir(commandsGsd);
|
||
fs.mkdirSync(path.join(targetDir, '.planning'), { recursive: true });
|
||
fs.writeFileSync(
|
||
path.join(targetDir, '.planning', 'config.json'),
|
||
JSON.stringify({ model_overrides: { 'gsd-planner': overrideModel } }),
|
||
);
|
||
try {
|
||
const ctx = { pathPrefix: `${targetDir}/`, attribution: undefined, targetDir };
|
||
return runRealDescriptorPath(runtime, 'global', targetDir, ctx);
|
||
} finally {
|
||
cleanup(root);
|
||
cleanup(targetDir);
|
||
}
|
||
}
|
||
|
||
function comparePipelines(runtime, scope, converterNameOverride) {
|
||
const { commandsGsd, agentsDir, root } = buildSourceTree(SAMPLE_AGENTS);
|
||
const targetDir = buildTargetDir(commandsGsd);
|
||
try {
|
||
const ctx = { pathPrefix: `${targetDir}/`, attribution: undefined, targetDir };
|
||
const expected = computeExpectedOutput(runtime, agentsDir, ctx, converterNameOverride);
|
||
const actual = runRealDescriptorPath(runtime, scope, targetDir, ctx);
|
||
return { expected, actual };
|
||
} finally {
|
||
cleanup(root);
|
||
cleanup(targetDir);
|
||
}
|
||
}
|
||
|
||
/** Asserts H1-H6 + H7 in one shot: same filenames (Set equality, order-free)
|
||
* AND byte-identical content per filename. */
|
||
function assertMapsIdentical(expected, actual) {
|
||
assert.deepEqual(
|
||
[...expected.keys()].sort(),
|
||
[...actual.keys()].sort(),
|
||
'filenames diverged between the oracle and the real descriptor path (row H7)',
|
||
);
|
||
for (const [name, expectedContent] of expected) {
|
||
assert.equal(
|
||
actual.get(name),
|
||
expectedContent,
|
||
`content diverged for ${name} between the oracle and the real descriptor path`,
|
||
);
|
||
}
|
||
}
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// H rows — the parity gate, one row per (runtime, scope)
|
||
// ---------------------------------------------------------------------------
|
||
|
||
for (const [runtime, scope] of RUNTIME_SCOPE_PAIRS) {
|
||
test(`agent-descriptor-parity: H row — ${runtime} (${scope}) real descriptor output matches the independent oracle`, () => {
|
||
const { expected, actual } = comparePipelines(runtime, scope);
|
||
assert.ok(expected.size > 0, 'fixture produced no oracle output — test is vacuous');
|
||
assertMapsIdentical(expected, actual);
|
||
});
|
||
}
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// H7 — the harness compares filenames, not only content (meta)
|
||
// ---------------------------------------------------------------------------
|
||
|
||
test('agent-descriptor-parity: H7 — a filename-only divergence fails the harness', (t) => {
|
||
const { commandsGsd, agentsDir, root } = buildSourceTree(SAMPLE_AGENTS);
|
||
const targetDir = buildTargetDir(commandsGsd);
|
||
t.after(() => {
|
||
cleanup(root);
|
||
cleanup(targetDir);
|
||
});
|
||
const ctx = { pathPrefix: `${targetDir}/`, attribution: undefined, targetDir };
|
||
const expected = computeExpectedOutput('claude', agentsDir, ctx);
|
||
const actual = runRealDescriptorPath('claude', 'global', targetDir, ctx);
|
||
// Deliberately rename one actual-side entry — same bytes, different name.
|
||
const [firstName, firstContent] = [...actual.entries()][0];
|
||
actual.delete(firstName);
|
||
actual.set(`RENAMED-${firstName}`, firstContent);
|
||
assert.throws(
|
||
() => assertMapsIdentical(expected, actual),
|
||
/filenames diverged/,
|
||
'a renamed output file must fail the harness',
|
||
);
|
||
});
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// H8 — the harness can actually FAIL (mandatory, not optional)
|
||
// ---------------------------------------------------------------------------
|
||
|
||
test('agent-descriptor-parity: H8 — a deliberately-wrong (but real, allowlisted) expected converter turns the harness RED', (t) => {
|
||
// Hermes's REAL capability.json declares convertClaudeAgentToHermesAgent.
|
||
// Feed the ORACLE a different, real, allowlisted converter name
|
||
// (convertClaudeAgentToCodexAgent) instead — simulating exactly the failure
|
||
// mode row H exists to catch: capability.json's declared converter silently
|
||
// diverging from the correct one. The REAL descriptor path is untouched and
|
||
// still uses hermes's real (correct) converter, so this proves: IF
|
||
// capability.json ever regressed to the wrong name, THIS harness's H row
|
||
// for hermes would go red exactly like this.
|
||
const { commandsGsd, agentsDir, root } = buildSourceTree(SAMPLE_AGENTS);
|
||
const targetDir = buildTargetDir(commandsGsd);
|
||
t.after(() => {
|
||
cleanup(root);
|
||
cleanup(targetDir);
|
||
});
|
||
const ctx = { pathPrefix: `${targetDir}/`, attribution: undefined, targetDir };
|
||
const wrongExpected = computeExpectedOutput('hermes', agentsDir, ctx, 'convertClaudeAgentToCodexAgent');
|
||
const realActual = runRealDescriptorPath('hermes', 'global', targetDir, ctx);
|
||
let threw = false;
|
||
let observedDiff = null;
|
||
try {
|
||
assertMapsIdentical(wrongExpected, realActual);
|
||
} catch (err) {
|
||
threw = true;
|
||
observedDiff = err.message;
|
||
}
|
||
assert.equal(threw, true, 'H8 FAILED: the harness did not go red for a deliberately-wrong expected converter');
|
||
assert.match(observedDiff, /content diverged for gsd-(planner|plan-checker)\.md/, 'expected the content-diverged assertion to name the mismatched file');
|
||
// Verbatim red-proof output for the record (see CHANGES report):
|
||
console.log(`H8 red-proof observed: ${observedDiff}`);
|
||
});
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// I1-I3 — per-agent resolution context
|
||
// ---------------------------------------------------------------------------
|
||
|
||
test('agent-descriptor-parity: I3 — deriveAgentName matches the pipeline exactly, including a no-.md-suffix boundary', () => {
|
||
assert.equal(runtimeArtifactConversion.deriveAgentName('gsd-planner.md'), 'gsd-planner');
|
||
// Boundary: a filename with no trailing .md is returned unchanged (the
|
||
// regex has nothing to match) — matches `entry.name.replace(/\.md$/, '')`.
|
||
assert.equal(runtimeArtifactConversion.deriveAgentName('gsd-planner'), 'gsd-planner');
|
||
assert.equal(runtimeArtifactConversion.deriveAgentName('gsd-planner.MD'), 'gsd-planner.MD');
|
||
});
|
||
|
||
test('agent-descriptor-parity: I2 — real descriptor path with no agentCtx is unaffected (converter-only)', (t) => {
|
||
const { commandsGsd, agentsDir, root } = buildSourceTree(SAMPLE_AGENTS);
|
||
const targetDir = buildTargetDir(commandsGsd);
|
||
t.after(() => {
|
||
cleanup(root);
|
||
cleanup(targetDir);
|
||
});
|
||
const runtimeArtifactLayout = require(path.join(LIB_DIR, 'runtime-artifact-layout.cjs'));
|
||
const realLayout = runtimeArtifactLayout.resolveRuntimeArtifactLayout('claude', targetDir, 'global');
|
||
const agentsKindEntry = realLayout.kinds.find((k) => k.kind === 'agents');
|
||
const stagedDir = agentsKindEntry.stage({ name: 'full', skills: '*', agents: new Set() }); // no agentCtx
|
||
t.after(() => cleanup(stagedDir));
|
||
const planner = fs.readFileSync(path.join(stagedDir, 'gsd-planner.md'), 'utf8');
|
||
const original = fs.readFileSync(path.join(agentsDir, 'gsd-planner.md'), 'utf8');
|
||
assert.equal(planner, original, 'no agentCtx must leave content byte-identical to source (converter:null == identity)');
|
||
});
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// J1-J4 — frontmatter extensions
|
||
// ---------------------------------------------------------------------------
|
||
|
||
test('agent-descriptor-parity: J1 — claude effort is injected via applyAgentFrontmatterExtensions', () => {
|
||
const content = '---\nname: gsd-planner\ndescription: x\n---\n\nBody.\n';
|
||
const viaShared = runtimeArtifactConversion.applyAgentFrontmatterExtensions(content, { runtime: 'claude', agentName: 'gsd-planner', targetDir: null });
|
||
assert.match(viaShared, /^effort: /m, 'expected an effort: key to be injected for claude');
|
||
});
|
||
|
||
test('agent-descriptor-parity: J2 — effort resolving to inherit writes NO effort: key at all, exercised via the REAL guard in applyAgentFrontmatterExtensions (#3533 trap row)', (t) => {
|
||
// #2875 Part 2 defect fix: a prior revision of this row asserted the DUMB
|
||
// half (injectEffortFrontmatter DOES emit the literal 'inherit' if called
|
||
// with it) and never called applyAgentFrontmatterExtensions at all — so
|
||
// deleting the `universalEffort !== 'inherit'` guard at
|
||
// runtime-artifact-conversion.cts:3504 left this row green. This revision
|
||
// writes a REAL .planning/config.json under targetDir (readGsdEffectiveEffortConfig
|
||
// walks up from targetDir looking for it — install-effort-resolver.cts)
|
||
// and calls the REAL applyAgentFrontmatterExtensions end to end, so removing
|
||
// that guard makes THIS assertion fail (verified: red with the guard
|
||
// removed, green with it restored — see CHANGES report).
|
||
const targetDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-agent-parity-j2-'));
|
||
t.after(() => cleanup(targetDir));
|
||
fs.mkdirSync(path.join(targetDir, '.planning'), { recursive: true });
|
||
fs.writeFileSync(
|
||
path.join(targetDir, '.planning', 'config.json'),
|
||
JSON.stringify({ effort: { agent_overrides: { 'gsd-inherit-agent': 'inherit' } } }),
|
||
);
|
||
const content = '---\nname: gsd-inherit-agent\ndescription: x\n---\n\nBody.\n';
|
||
const out = runtimeArtifactConversion.applyAgentFrontmatterExtensions(content, { runtime: 'claude', agentName: 'gsd-inherit-agent', targetDir });
|
||
assert.doesNotMatch(out, /^effort:/m, 'an agent resolving to "inherit" must get no effort: key at all — not even effort: inherit');
|
||
// Sanity: injectEffortFrontmatter itself is dumb and WOULD write the
|
||
// literal if called with it — the guard is applyAgentFrontmatterExtensions
|
||
// never calling it for 'inherit', which is exactly what the assertion above proves.
|
||
assert.match(runtimeArtifactConversion.injectEffortFrontmatter(content, 'inherit'), /^effort: inherit$/m);
|
||
});
|
||
|
||
test('agent-descriptor-parity: J3 — a runtime NOT declaring agentFrontmatterExtensions gets nothing injected', () => {
|
||
const content = '---\nname: gsd-plan-checker\ndescription: x\n---\n\nBody.\n';
|
||
const out = runtimeArtifactConversion.applyAgentFrontmatterExtensions(content, { runtime: 'opencode', agentName: 'gsd-plan-checker', targetDir: null });
|
||
assert.equal(out, content, 'opencode declares no agentFrontmatterExtensions — output must be byte-identical to input');
|
||
});
|
||
|
||
test('agent-descriptor-parity: J4 — disallowedTools injected only on a READONLY_AGENT_DISALLOWED_TOOLS hit', () => {
|
||
const content = '---\nname: gsd-plan-checker\ndescription: x\n---\n\nBody.\n';
|
||
const hit = runtimeArtifactConversion.applyAgentFrontmatterExtensions(content, { runtime: 'claude', agentName: 'gsd-plan-checker', targetDir: null });
|
||
assert.match(hit, /^disallowedTools: /m, 'gsd-plan-checker is a declared read-only agent — expected a disallowedTools hit');
|
||
|
||
const missContent = '---\nname: gsd-not-a-readonly-agent\ndescription: x\n---\n\nBody.\n';
|
||
const miss = runtimeArtifactConversion.applyAgentFrontmatterExtensions(missContent, { runtime: 'claude', agentName: 'gsd-not-a-readonly-agent', targetDir: null });
|
||
assert.doesNotMatch(miss, /^disallowedTools:/m, 'an agent absent from READONLY_AGENT_DISALLOWED_TOOLS must get no disallowedTools key');
|
||
});
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// J5-J8 — model-override resolution (kilo/opencode), single-sourced
|
||
// ---------------------------------------------------------------------------
|
||
|
||
test('agent-descriptor-parity: J5 — explicit model_overrides[agent] wins (highest precedence)', () => {
|
||
const modelOverrides = { 'gsd-planner': 'anthropic/explicit-model' };
|
||
const runtimeResolver = { resolve: () => ({ model: 'anthropic/tier-model' }) }; // would win if precedence were wrong
|
||
const result = installModelOverrideResolver.resolveAgentModelOverride('gsd-planner', modelOverrides, runtimeResolver);
|
||
assert.equal(result, 'anthropic/explicit-model');
|
||
});
|
||
|
||
test('agent-descriptor-parity: J6 — falls back to the runtime tier resolver when no explicit override exists', () => {
|
||
const runtimeResolver = { resolve: (agentName) => (agentName === 'gsd-planner' ? { model: 'anthropic/tier-model' } : null) };
|
||
const result = installModelOverrideResolver.resolveAgentModelOverride('gsd-planner', null, runtimeResolver);
|
||
assert.equal(result, 'anthropic/tier-model');
|
||
});
|
||
|
||
test('agent-descriptor-parity: J7 — neither configured resolves to null (omit), never "" or the string "null"', () => {
|
||
const result = installModelOverrideResolver.resolveAgentModelOverride('gsd-planner', null, null);
|
||
assert.equal(result, null);
|
||
assert.notEqual(result, '');
|
||
const contentWithoutModelOverride = installBin.convertClaudeToOpencodeFrontmatter(
|
||
'---\nname: gsd-planner\ndescription: x\ntools: Read\n---\n\nBody.\n',
|
||
{ isAgent: true, modelOverride: result },
|
||
);
|
||
assert.doesNotMatch(contentWithoutModelOverride, /^model:/m, 'an omitted override must not appear as a model: key at all');
|
||
});
|
||
|
||
test('agent-descriptor-parity: J8 — kilo and opencode both resolve model overrides through the REAL descriptor path (installAgentsKindStandalone), proving ONE shared resolution path, not a tautology', () => {
|
||
// #2875 Part 2 defect fix: a prior revision of this row called
|
||
// resolveAgentModelOverride TWICE with identical arguments and asserted
|
||
// equality — true of ANY pure function regardless of whether kilo/opencode
|
||
// are actually wired to it. This revision drives BOTH runtimes through the
|
||
// REAL production entry point with a REAL .planning/config.json
|
||
// model_overrides block and asserts BOTH staged outputs carry the SAME
|
||
// resolved model — which only happens if both are genuinely wired to the
|
||
// one shared installModelOverrideResolver.resolveAgentModelOverride.
|
||
const kiloOut = _stageWithModelOverride('kilo', J8_OVERRIDE_MODEL);
|
||
const opencodeOut = _stageWithModelOverride('opencode', J8_OVERRIDE_MODEL);
|
||
// J8_OVERRIDE_MODEL is a fixed constant: assert the exact expected
|
||
// frontmatter line, matched line-wise, rather than building a RegExp from
|
||
// an interpolated value (CodeQL js/incomplete-sanitization — a
|
||
// metachar-bearing value would silently widen the match).
|
||
const expectedModelLine = `model: ${J8_OVERRIDE_MODEL}`;
|
||
assert.ok(
|
||
kiloOut.get('gsd-planner.md').split('\n').some((line) => line.trim() === expectedModelLine),
|
||
'kilo must apply the shared model override via the real descriptor path',
|
||
);
|
||
assert.ok(
|
||
opencodeOut.get('gsd-planner.md').split('\n').some((line) => line.trim() === expectedModelLine),
|
||
'opencode must apply the SAME shared model override via the real descriptor path',
|
||
);
|
||
});
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// J9-J10 — hermes branding converter
|
||
// ---------------------------------------------------------------------------
|
||
|
||
test('agent-descriptor-parity: J9 — hermes branding converter is byte-identical to the shared generic branding-rewrite function, including \\bClaude Code\\b word-boundary semantics', () => {
|
||
const content = 'Claude Code and ClaudeCodeExtra and CLAUDE.md and .claude/foo and reClaude Code.\n';
|
||
const viaSharedFn = runtimeArtifactConversion.applyAgentBrandingRewrites(content, 'hermes');
|
||
const viaNamedConverter = runtimeArtifactConversion.convertClaudeAgentToHermesAgent(content);
|
||
assert.equal(viaSharedFn, viaNamedConverter, 'the named converter must be a pure delegate to the generic branding-rewrite function');
|
||
// \bClaude Code\b: neither "ClaudeCodeExtra" (no space/boundary between
|
||
// "Claude" and "Code") nor "reClaude Code" (no boundary between the 'e' of
|
||
// "re" and the 'C' of "Claude" — both word chars) satisfy the word-boundary
|
||
// requirement, so BOTH are left untouched; only the standalone occurrence is
|
||
// rewritten.
|
||
assert.match(viaSharedFn, /Hermes Agent and ClaudeCodeExtra and HERMES\.md and \.hermes\/foo and reClaude Code\./);
|
||
});
|
||
|
||
test('agent-descriptor-parity: J10 — the branding converter is descriptor-data-driven, not hardcoded to hermes strings', () => {
|
||
const content = 'Claude Code uses CLAUDE.md under .claude/.\n';
|
||
// A runtime with NO brandingRewrites declared gets nothing rewritten.
|
||
assert.equal(runtimeArtifactConversion.applyAgentBrandingRewrites(content, 'claude'), content);
|
||
// hermes (the only runtime with brandingRewrites AND no dedicated converter
|
||
// pre-#2875) gets ITS OWN declared rewrite table applied — proving the
|
||
// function reads the runtime's descriptor rather than a hermes-hardcoded literal.
|
||
const hermesOut = runtimeArtifactConversion.applyAgentBrandingRewrites(content, 'hermes');
|
||
assert.notEqual(hermesOut, content);
|
||
assert.match(hermesOut, /Hermes Agent uses HERMES\.md under \.hermes\/\./);
|
||
});
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// K1 — migration completeness: every registry runtime with an `agents` kind
|
||
// is reachable from the REAL production entry point, not the (now-deleted)
|
||
// inline loop.
|
||
// ---------------------------------------------------------------------------
|
||
|
||
/**
|
||
* bin/install.js's inline agent-staging loop and its `_DESCRIPTOR_AGENTS_RUNTIMES`
|
||
* gate were DELETED in #2875 Part 2 Task C — there is no longer a symbol to
|
||
* assert absent (a source-text check would violate `local/no-source-grep` and
|
||
* would prove nothing about runtime behavior anyway, per CLAUDE.md's
|
||
* "Behavioral tests are required"). Row K1 is instead proven the only way
|
||
* that is actually meaningful once the code is gone: for EVERY `role:
|
||
* "runtime"` capability in the REAL capability-registry that declares an
|
||
* `agents` kind (either scope), the REAL production entry point
|
||
* (`installRuntimeArtifacts` — which internally routes combinedFamilyInstall
|
||
* runtimes like kilo/opencode through `installOpencodeFamilyAgents`, #2875
|
||
* Part 2 Task A) actually materializes agents/ on disk. If any runtime were
|
||
* still silently depending on the deleted inline loop, this call would write
|
||
* nothing to agents/ for it (the deleted code was the ONLY thing that used to
|
||
* write it for the seven runtimes migrated in this change) and the assertion
|
||
* below would fail.
|
||
*/
|
||
test('agent-descriptor-parity: K1 — every registry runtime declaring an agents kind is reachable from installRuntimeArtifacts (the inline loop is gone)', (t) => {
|
||
const runtimesWithAgentsKind = Object.entries(capabilityRegistry.runtimes || {})
|
||
.filter(([, cap]) => {
|
||
const layout = cap.runtime && cap.runtime.artifactLayout;
|
||
if (!layout) return false;
|
||
const entries = [...(layout.global || []), ...(layout.local || [])];
|
||
return entries.some((e) => e.kind === 'agents');
|
||
})
|
||
.map(([id]) => id);
|
||
|
||
assert.ok(runtimesWithAgentsKind.length >= 7, 'sanity: expected at least the seven #2875 Part 2 runtimes to declare an agents kind');
|
||
assert.ok(runtimesWithAgentsKind.includes('kimi-code'), 'kimi-code (found via golden fixture, not analysis) must be covered here');
|
||
assert.ok(runtimesWithAgentsKind.includes('cline'), 'cline must be covered here');
|
||
|
||
for (const runtime of runtimesWithAgentsKind) {
|
||
const { commandsGsd, root } = buildSourceTree(SAMPLE_AGENTS);
|
||
const targetDir = buildTargetDir(commandsGsd);
|
||
t.after(() => {
|
||
cleanup(root);
|
||
cleanup(targetDir);
|
||
});
|
||
const resolvedProfile = { name: 'full', skills: '*', agents: new Set() };
|
||
const result = installEngine.installRuntimeArtifacts(runtime, targetDir, 'global', resolvedProfile, () => undefined, undefined);
|
||
const agentsKindEntry = result.kinds.find((k) => k.kind === 'agents');
|
||
assert.ok(agentsKindEntry, `${runtime}: installRuntimeArtifacts reported no agents kind in the executed plan — it did not go through the descriptor path`);
|
||
const writtenFiles = fs.readdirSync(agentsKindEntry.destDir).filter((f) => f.endsWith('.md'));
|
||
assert.ok(writtenFiles.length > 0, `${runtime}: agents kind reported but nothing was actually written to ${agentsKindEntry.destDir}`);
|
||
}
|
||
});
|