diff --git a/.changeset/jolly-orcas-munch.md b/.changeset/jolly-orcas-munch.md new file mode 100644 index 000000000..5c8da0bf1 --- /dev/null +++ b/.changeset/jolly-orcas-munch.md @@ -0,0 +1,5 @@ +--- +type: Fixed +pr: 2623 +--- +**Project-local agents are detected across non-Claude runtimes** — GSD status and workflows now use a manifest-backed local installation before the global fallback. diff --git a/CONTEXT.md b/CONTEXT.md index 48af820be..d8c2cc846 100644 --- a/CONTEXT.md +++ b/CONTEXT.md @@ -176,7 +176,7 @@ Module owning ROADMAP.md parsing: shipped-milestone slicing, current-milestone e Module owning the shared low-level utility primitives extracted from Core: POSIX path normalization (`toPosixPath`), filesystem scanning (`detectSubRepos`, `readSubdirectories`, `getPhaseFileStats`, `pathExistsInternal`), and small pure helpers (`generateSlugInternal`, `extractOneLinerFromBody`, `filterPlanFiles`, `filterSummaryFiles`, `extractCanonicalPlanId`, `timeAgo`). Depends only on Node built-ins and already-leafed modules (`phase-id` for `comparePhaseNum`, `planning-workspace` for `findContextMdIn`) — no `loadConfig`, no other core dependency. Extracted from the Core module per ADR-857 rollout phase 2c (#877) as the shared leaf that unblocks the phase-locator fs-search extraction (2d); the `core.cjs` re-export spine was retired in epic #1267, so callers import this leaf directly. Source of truth: `gsd-core/bin/lib/core-utils.cjs` (generated from `src/core-utils.cts`). ### Agent Install Check Module -Module owning agent-presence resolution and verification, extracted from the Core module as the cleanup step that retired the `core.cjs` re-export spine (the final ADR-857 decomposition, epic #1267). Interface: `getAgentsDir(runtime?, env?)` — env-var-aware, runtime-aware agents-directory resolution (the `claude` runtime resolves `__dirname`-relative); `checkAgentsInstalled(...)` — multi-runtime agent-presence check that validates `gsd-file-manifest.json` completeness and confirms the declared agents exist on disk. Pure read/verify — no install-write side effects (writes remain the Installer Module's). Consumed by the Init Command Module, the verify workflow, and the docs workflow. Source of truth: `gsd-core/bin/lib/agent-install-check.cjs` (generated from `src/agent-install-check.cts`); replaced the two functions that squatted in `core.cts`. See Installer Module and ADR-857. +Module owning agent-presence resolution and verification, extracted from the Core module as the cleanup step that retired the `core.cjs` re-export spine (the final ADR-857 decomposition, epic #1267). Interface: `getAgentsDir(runtime?, projectRoot?)` — env-var-aware, runtime-aware agents-directory resolution; Claude resolves `__dirname`-relative, while other runtimes prefer a manifest-backed project-local agents directory before their global configuration home. The manifest gate is intentional: runtime-native project agents must not shadow a working global GSD install. `checkAgentsInstalled(...)` validates `gsd-file-manifest.json` completeness and confirms the declared agents exist on disk. Pure read/verify — no install-write side effects (writes remain the Installer Module's). Consumed by the Init Command Module, the verify workflow, and the docs workflow. Source of truth: `gsd-core/bin/lib/agent-install-check.cjs` (generated from `src/agent-install-check.cts`); replaced the two functions that squatted in `core.cts`. See Installer Module and ADR-857. ### Config Loader Module Module owning project configuration loading: reads `.planning/config.json`, merges built-in defaults (`CONFIG_DEFAULTS`/`CANONICAL_CONFIG_DEFAULTS`), normalizes legacy keys, applies the active-workstream overlay, validates against the config schema, and warns on unknown keys/profile overrides. Primary interface: `loadConfigResolved(cwd, options) → ConfigResolution { config, source, degraded }` (provenance-aware, ADR-1411 P2 / #1415) — `source` ∈ `'workstream' | 'root' | 'builtin-defaults' | 'global-defaults'`; `degraded:true` when a workstream was requested but its config.json was absent (fell back to root config). `loadConfig(cwd, options) → Record` is the back-compat thin wrapper over `loadConfigResolved` (byte-identical result). Resolution is **caller-anchored, not loader-anchored**: `loadConfigResolved` resolves `cwd` as-is (no walk-up), so `loadConfig` stays byte-identical for its callers; callers that need cwd-drift tolerance (e.g. `cmdAgentSkills`) anchor to the project root via `findProjectRoot` (Project-Root Resolution Module) *before* calling `loadConfigResolved`. Helper exports: `_deepMergeConfig`, `isGitIgnored`, `_warnUnknownProfileOverrides`. Depends only on leaf modules (`configuration`, `config-schema`, `planning-workspace`, `shell-command-projection`, `core-utils`, `model-catalog`) — no other core dependency. Extracted from the Core module per ADR-857 rollout phase 2e (#885) as the prerequisite for the model-resolver extraction (the resolvers call `loadConfig`); the `core.cjs` re-export spine was retired in epic #1267, so callers import this leaf directly. Source of truth: `gsd-core/bin/lib/config-loader.cjs` (generated from `src/config-loader.cts`). diff --git a/src/agent-install-check.cts b/src/agent-install-check.cts index 65c131323..e8878974d 100644 --- a/src/agent-install-check.cts +++ b/src/agent-install-check.cts @@ -2,8 +2,8 @@ * Agent Install Check — moved from core.cts (ADR-857 T0 #1268 phase rehome-core-squatters). * * Owns: - * - getAgentsDir(runtime?): string - * - checkAgentsInstalled(runtime?): AgentsInstalledResult + * - getAgentsDir(runtime?, projectRoot?): string + * - checkAgentsInstalled(runtime?, projectRoot?): AgentsInstalledResult * * The core.cjs re-export spine was retired in epic #1267; callers import * these symbols from agent-install-check.cjs directly. @@ -15,6 +15,7 @@ import path from 'node:path'; import modelProfiles = require('./model-profiles.cjs'); const { MODEL_PROFILES } = modelProfiles; import { getGlobalConfigDir } from './runtime-homes.cjs'; +import { getDirName, NO_LOCAL_CONFIG_DIR_SENTINEL } from './runtime-name-policy.cjs'; interface AgentsInstalledResult { agents_installed: boolean; @@ -33,11 +34,17 @@ interface AgentsInstalledResult { * 2. For claude runtime: __dirname-relative path (agents/ sibling of gsd-core/) * This is correct for both repo runs and real installs (the runtime config dir's * agents/ folder) because gsd-tools.cjs lives inside gsd-core/bin/ in both cases. - * 3. For non-claude runtimes: getGlobalConfigDir(runtime)/agents + * 3. For non-claude runtimes with a manifest-backed project-local install: + * //agents (or /agents when + * the runtime's local install targets the project root). Requiring the + * GSD manifest prevents runtime-native project agents from shadowing a + * working global GSD install. Symlinked local agent directories are ignored. + * 4. For non-claude runtimes: getGlobalConfigDir(runtime)/agents * * @param runtime - the active runtime name; defaults to GSD_RUNTIME env, then 'claude' + * @param projectRoot - canonical project root for local-install discovery */ -function getAgentsDir(runtime?: string): string { +function getAgentsDir(runtime?: string, projectRoot?: string): string { if (process.env['GSD_AGENTS_DIR']) { return process.env['GSD_AGENTS_DIR']; } @@ -45,6 +52,31 @@ function getAgentsDir(runtime?: string): string { if (resolved === 'claude') { return path.join(__dirname, '..', '..', '..', 'agents'); } + if (projectRoot) { + // eslint-disable-next-line @typescript-eslint/no-require-imports + const { runtimes } = require('./capability-registry.cjs') as { + runtimes: Record; + }; + const runtimeConfig = runtimes[resolved]?.runtime; + const localConfigDirName = getDirName(resolved); + const localConfigDir = localConfigDirName === NO_LOCAL_CONFIG_DIR_SENTINEL + ? undefined + : runtimeConfig?.hostBehaviors?.localTargetIsProjectRoot + ? projectRoot + : path.join(projectRoot, localConfigDirName); + if (!localConfigDir) { + return path.join(getGlobalConfigDir(resolved), 'agents'); + } + const localAgentsDir = path.join(localConfigDir, 'agents'); + const manifestPath = path.join(localConfigDir, 'gsd-file-manifest.json'); + try { + if (fs.lstatSync(localAgentsDir).isDirectory() && fs.lstatSync(manifestPath).isFile()) { + return localAgentsDir; + } + } catch { + // Local discovery is best-effort; any probe failure preserves global fallback. + } + } return path.join(getGlobalConfigDir(resolved), 'agents'); } @@ -52,10 +84,11 @@ function getAgentsDir(runtime?: string): string { * Check which GSD agents are installed on disk. * * @param runtime - the active runtime name; defaults to GSD_RUNTIME env, then 'claude' + * @param projectRoot - canonical project root for local-install discovery */ -function checkAgentsInstalled(runtime?: string): AgentsInstalledResult { +function checkAgentsInstalled(runtime?: string, projectRoot?: string): AgentsInstalledResult { const resolvedRuntime = runtime ?? (process.env['GSD_RUNTIME'] || 'claude'); - const agentsDir = getAgentsDir(resolvedRuntime); + const agentsDir = getAgentsDir(resolvedRuntime, projectRoot); const expectedAgents = Object.keys(MODEL_PROFILES); const installed: string[] = []; const missing: string[] = []; diff --git a/src/docs.cts b/src/docs.cts index b31a07a19..57251943a 100644 --- a/src/docs.cts +++ b/src/docs.cts @@ -27,6 +27,7 @@ const { pathExistsInternal, toPosixPath } = coreUtils; // eslint-disable-next-line @typescript-eslint/no-require-imports import agentInstallCheck = require('./agent-install-check.cjs'); const { checkAgentsInstalled } = agentInstallCheck; +import { resolveRuntime } from './runtime-slash.cjs'; import { platformReadSync } from './shell-command-projection.cjs'; // ─── Constants ──────────────────────────────────────────────────────────────── @@ -282,7 +283,7 @@ function cmdDocsInit(cwd: string, raw: boolean): void { }; // Inject project_root and agent installation status (mirrors withProjectRoot in init.cjs) result['project_root'] = cwd; - const agentStatus = checkAgentsInstalled(); + const agentStatus = checkAgentsInstalled(resolveRuntime(cwd), cwd); result['agents_installed'] = agentStatus.agents_installed; result['missing_agents'] = agentStatus.missing_agents; // #2402: withProjectRoot injects response_language when set; cmdDocsInit predates diff --git a/src/init.cts b/src/init.cts index 200993390..9d1d14aa7 100644 --- a/src/init.cts +++ b/src/init.cts @@ -226,7 +226,7 @@ function getLatestCompletedMilestone(cwd: string): { version: string; name: stri function withProjectRoot(cwd: string, result: Record): Record { result['project_root'] = cwd; const activeRuntime = resolveRuntime(cwd); - const agentStatus = checkAgentsInstalled(activeRuntime); + const agentStatus = checkAgentsInstalled(activeRuntime, cwd); result['agents_installed'] = agentStatus.agents_installed; result['missing_agents'] = agentStatus.missing_agents; result['agents_dir'] = agentStatus.agents_dir; @@ -2268,7 +2268,7 @@ function cmdAgentSkills( if (!block) { const runtime = (config && (config['runtime'] as string)) || process.env['GSD_RUNTIME'] || 'claude'; if (runtime !== 'claude') { - const agentCheck = checkAgentsInstalled(runtime) as unknown as { agents_dir?: string } | null; + const agentCheck = checkAgentsInstalled(runtime, projectRoot) as unknown as { agents_dir?: string } | null; const agentsDir = agentCheck?.agents_dir; if (typeof agentsDir === 'string' && agentsDir.length > 0) { const agentFile = path.join(agentsDir, `${agentType}.md`); diff --git a/src/verify.cts b/src/verify.cts index f64be88be..1a1a6ade9 100644 --- a/src/verify.cts +++ b/src/verify.cts @@ -1855,7 +1855,7 @@ function cmdValidateHealth( } try { - const agentStatus = checkAgentsInstalled(); + const agentStatus = checkAgentsInstalled(_slashRuntime, cwd); if (!agentStatus.agents_installed) { if ((agentStatus.installed_agents).length === 0) { addIssue( @@ -2396,7 +2396,7 @@ function cmdValidateHealth( } function cmdValidateAgents(cwd: string, raw: boolean): void { - const agentStatus = checkAgentsInstalled(); + const agentStatus = checkAgentsInstalled(resolveRuntime(cwd), cwd); const expected = Object.keys(MODEL_PROFILES); output( diff --git a/tests/agent-install-check.test.cjs b/tests/agent-install-check.test.cjs index 8cafa7cef..f6d4bdd47 100644 --- a/tests/agent-install-check.test.cjs +++ b/tests/agent-install-check.test.cjs @@ -31,6 +31,7 @@ const RUNTIME_HOMES_PATH = path.join( const agentInstallCheck = require(AGENT_INSTALL_CHECK_PATH); const { getGlobalConfigDir } = require(RUNTIME_HOMES_PATH); +const { getDirName } = require(path.join(__dirname, '..', 'gsd-core', 'bin', 'lib', 'runtime-name-policy.cjs')); // Get EXPECTED_AGENTS from model-profiles (same source of truth) const MODEL_PROFILES = require(path.join(__dirname, '..', 'gsd-core', 'bin', 'lib', 'model-profiles.cjs')).MODEL_PROFILES; @@ -40,12 +41,15 @@ const EXPECTED_AGENTS = Object.keys(MODEL_PROFILES); let savedAgentsDir; let savedRuntime; +let savedCodexHome; beforeEach(() => { savedAgentsDir = process.env['GSD_AGENTS_DIR']; savedRuntime = process.env['GSD_RUNTIME']; + savedCodexHome = process.env['CODEX_HOME']; delete process.env['GSD_AGENTS_DIR']; delete process.env['GSD_RUNTIME']; + delete process.env['CODEX_HOME']; }); afterEach(() => { @@ -59,8 +63,34 @@ afterEach(() => { } else { process.env['GSD_RUNTIME'] = savedRuntime; } + if (savedCodexHome === undefined) { + delete process.env['CODEX_HOME']; + } else { + process.env['CODEX_HOME'] = savedCodexHome; + } }); +function createCompleteAgents(agentsDir) { + fs.mkdirSync(agentsDir, { recursive: true }); + for (const agent of EXPECTED_AGENTS) { + fs.writeFileSync(path.join(agentsDir, `${agent}.toml`), `name = "${agent}"\n`); + } +} + +function markLocalGsdInstall(configDir) { + fs.writeFileSync( + path.join(configDir, 'gsd-file-manifest.json'), + JSON.stringify({ files: {} }), + ); +} + +function createCompleteLocalGsdInstall(configDir) { + const agentsDir = path.join(configDir, 'agents'); + createCompleteAgents(agentsDir); + markLocalGsdInstall(configDir); + return agentsDir; +} + // ─── 1. getAgentsDir behaviour ──────────────────────────────────────────────── describe('getAgentsDir', () => { @@ -94,6 +124,198 @@ describe('getAgentsDir', () => { const fromClaude = agentInstallCheck.getAgentsDir('claude'); assert.strictEqual(fromModule, fromClaude); }); + + test('a manifest-backed local runtime installation wins over global agents', (t) => { + const projectRoot = createTempDir('gsd-local-codex-'); + const globalHome = createTempDir('gsd-global-codex-'); + const localAgentsDir = createCompleteLocalGsdInstall(path.join(projectRoot, '.codex')); + t.after(() => cleanup(projectRoot)); + t.after(() => cleanup(globalHome)); + createCompleteAgents(path.join(globalHome, 'agents')); + process.env['CODEX_HOME'] = globalHome; + + assert.strictEqual(agentInstallCheck.getAgentsDir('codex', projectRoot), localAgentsDir); + assert.strictEqual(agentInstallCheck.checkAgentsInstalled('codex', projectRoot).agents_installed, true); + }); + + test('GSD_AGENTS_DIR remains terminal when a local Codex installation exists', (t) => { + const projectRoot = createTempDir('gsd-local-codex-'); + const overrideDir = path.join(projectRoot, 'override-agents'); + createCompleteLocalGsdInstall(path.join(projectRoot, '.codex')); + t.after(() => cleanup(projectRoot)); + fs.mkdirSync(overrideDir, { recursive: true }); + process.env['GSD_AGENTS_DIR'] = overrideDir; + + const result = agentInstallCheck.checkAgentsInstalled('codex', projectRoot); + assert.strictEqual(result.agents_dir, overrideDir); + assert.strictEqual(result.agents_installed, false); + assert.deepStrictEqual(result.missing_agents, EXPECTED_AGENTS); + }); + + test('a manifest-backed empty local directory is authoritative over complete global agents', (t) => { + const projectRoot = createTempDir('gsd-local-codex-'); + const globalHome = createTempDir('gsd-global-codex-'); + const localAgentsDir = path.join(projectRoot, '.codex', 'agents'); + t.after(() => cleanup(projectRoot)); + t.after(() => cleanup(globalHome)); + fs.mkdirSync(localAgentsDir, { recursive: true }); + markLocalGsdInstall(path.dirname(localAgentsDir)); + createCompleteAgents(path.join(globalHome, 'agents')); + process.env['CODEX_HOME'] = globalHome; + + const result = agentInstallCheck.checkAgentsInstalled('codex', projectRoot); + assert.strictEqual(result.agents_dir, localAgentsDir); + assert.strictEqual(result.agents_installed, false); + assert.deepStrictEqual(result.missing_agents, EXPECTED_AGENTS); + }); + + test('Codex falls back to global agents when no local directory exists', (t) => { + const projectRoot = createTempDir('gsd-local-codex-'); + const globalHome = createTempDir('gsd-global-codex-'); + t.after(() => cleanup(projectRoot)); + t.after(() => cleanup(globalHome)); + createCompleteAgents(path.join(globalHome, 'agents')); + process.env['CODEX_HOME'] = globalHome; + + const result = agentInstallCheck.checkAgentsInstalled('codex', projectRoot); + assert.strictEqual(result.agents_dir, path.join(globalHome, 'agents')); + assert.strictEqual(result.agents_installed, true); + }); + + test('Codex falls back to global agents when the local candidate is a regular file', (t) => { + const projectRoot = createTempDir('gsd-local-codex-'); + const globalHome = createTempDir('gsd-global-codex-'); + const localCandidate = path.join(projectRoot, '.codex', 'agents'); + t.after(() => cleanup(projectRoot)); + t.after(() => cleanup(globalHome)); + fs.mkdirSync(path.dirname(localCandidate), { recursive: true }); + fs.writeFileSync(localCandidate, 'not an agents directory\n'); + createCompleteAgents(path.join(globalHome, 'agents')); + process.env['CODEX_HOME'] = globalHome; + + const result = agentInstallCheck.checkAgentsInstalled('codex', projectRoot); + assert.strictEqual(result.agents_dir, path.join(globalHome, 'agents')); + assert.strictEqual(result.agents_installed, true); + }); + + test('Codex falls back to global agents when the local candidate cannot be inspected', (t) => { + const projectRoot = createTempDir('gsd-local-codex-'); + const globalHome = createTempDir('gsd-global-codex-'); + const localAgentsDir = path.join(projectRoot, '.codex', 'agents'); + const realLstatSync = fs.lstatSync; + t.after(() => cleanup(projectRoot)); + t.after(() => cleanup(globalHome)); + fs.mkdirSync(localAgentsDir, { recursive: true }); + markLocalGsdInstall(path.dirname(localAgentsDir)); + createCompleteAgents(path.join(globalHome, 'agents')); + process.env['CODEX_HOME'] = globalHome; + t.mock.method(fs, 'lstatSync', function injectedLocalProbeFailure(target, ...args) { + if (target === localAgentsDir) { + throw Object.assign(new Error('injected EACCES'), { code: 'EACCES' }); + } + return realLstatSync.call(fs, target, ...args); + }); + + const result = agentInstallCheck.checkAgentsInstalled('codex', projectRoot); + assert.strictEqual(result.agents_dir, path.join(globalHome, 'agents')); + assert.strictEqual(result.agents_installed, true); + }); + + test('Codex does not follow a symlinked local agents directory', (t) => { + const projectRoot = createTempDir('gsd-local-codex-'); + const globalHome = createTempDir('gsd-global-codex-'); + const localConfigDir = path.join(projectRoot, '.codex'); + const localAgentsDir = path.join(localConfigDir, 'agents'); + const symlinkTarget = path.join(projectRoot, 'shared-agents'); + t.after(() => cleanup(projectRoot)); + t.after(() => cleanup(globalHome)); + createCompleteAgents(symlinkTarget); + fs.mkdirSync(localConfigDir, { recursive: true }); + markLocalGsdInstall(localConfigDir); + createCompleteAgents(path.join(globalHome, 'agents')); + process.env['CODEX_HOME'] = globalHome; + try { + fs.symlinkSync(symlinkTarget, localAgentsDir, process.platform === 'win32' ? 'junction' : 'dir'); + } catch (error) { + if (error && ['EPERM', 'EACCES', 'ENOTSUP'].includes(error.code)) { + t.skip('symlink creation is not available on this platform'); + return; + } + throw error; + } + + const result = agentInstallCheck.checkAgentsInstalled('codex', projectRoot); + assert.strictEqual(result.agents_dir, path.join(globalHome, 'agents')); + assert.strictEqual(result.agents_installed, true); + }); + + test('a project-native agents directory without a GSD manifest does not override global agents', (t) => { + const projectRoot = createTempDir('gsd-local-codex-'); + const globalHome = createTempDir('gsd-global-codex-'); + const localAgentsDir = path.join(projectRoot, '.codex', 'agents'); + t.after(() => cleanup(projectRoot)); + t.after(() => cleanup(globalHome)); + createCompleteAgents(localAgentsDir); + createCompleteAgents(path.join(globalHome, 'agents')); + process.env['CODEX_HOME'] = globalHome; + + const result = agentInstallCheck.checkAgentsInstalled('codex', projectRoot); + assert.strictEqual(result.agents_dir, path.join(globalHome, 'agents')); + assert.strictEqual(result.agents_installed, true); + }); + + test('a manifest-backed Cursor installation resolves from the project root', (t) => { + const projectRoot = createTempDir('gsd-local-cursor-'); + const localAgentsDir = createCompleteLocalGsdInstall(path.join(projectRoot, getDirName('cursor'))); + t.after(() => cleanup(projectRoot)); + + assert.strictEqual(agentInstallCheck.getAgentsDir('cursor', projectRoot), localAgentsDir); + assert.strictEqual(agentInstallCheck.checkAgentsInstalled('cursor', projectRoot).agents_installed, true); + }); + + test('a manifest-backed Cline installation resolves from the project root', (t) => { + const projectRoot = createTempDir('gsd-local-cline-'); + const localAgentsDir = createCompleteLocalGsdInstall(projectRoot); + t.after(() => cleanup(projectRoot)); + + assert.strictEqual(agentInstallCheck.getAgentsDir('cline', projectRoot), localAgentsDir); + assert.strictEqual(agentInstallCheck.checkAgentsInstalled('cline', projectRoot).agents_installed, true); + }); + + test('a partial manifest-backed local installation remains selected and incomplete', (t) => { + const projectRoot = createTempDir('gsd-local-codex-'); + const localConfigDir = path.join(projectRoot, '.codex'); + const localAgentsDir = createCompleteLocalGsdInstall(localConfigDir); + const partialAgent = EXPECTED_AGENTS[0]; + t.after(() => cleanup(projectRoot)); + fs.writeFileSync(path.join(localAgentsDir, `${partialAgent}.md`), `# ${partialAgent}\n`); + fs.unlinkSync(path.join(localAgentsDir, `${partialAgent}.toml`)); + fs.writeFileSync( + path.join(localConfigDir, 'gsd-file-manifest.json'), + JSON.stringify({ files: { [`agents/${partialAgent}.md`]: {}, [`agents/${partialAgent}.toml`]: {} } }), + ); + + const result = agentInstallCheck.checkAgentsInstalled('codex', projectRoot); + assert.strictEqual(result.agents_dir, localAgentsDir); + assert.strictEqual(result.agents_installed, false); + assert.deepStrictEqual(result.missing_agents, []); + assert.deepStrictEqual(result.incomplete_agents, [partialAgent]); + }); + + test('Claude and other runtimes ignore a supplied Codex-local candidate', (t) => { + const projectRoot = createTempDir('gsd-local-codex-'); + t.after(() => cleanup(projectRoot)); + createCompleteLocalGsdInstall(path.join(projectRoot, '.codex')); + + assert.strictEqual( + agentInstallCheck.getAgentsDir('claude', projectRoot), + agentInstallCheck.getAgentsDir('claude'), + ); + assert.strictEqual( + agentInstallCheck.getAgentsDir('cursor', projectRoot), + path.join(getGlobalConfigDir('cursor'), 'agents'), + ); + }); }); // ─── 2. checkAgentsInstalled behaviour ─────────────────────────────────────── diff --git a/tests/agent-install-validation.test.cjs b/tests/agent-install-validation.test.cjs index 27b71b9f0..bff0e50c5 100644 --- a/tests/agent-install-validation.test.cjs +++ b/tests/agent-install-validation.test.cjs @@ -42,6 +42,23 @@ function _createAgentsDir(configDir, agentNames = []) { return agentsDir; } +function createCompleteCodexAgents(configDir) { + const agentsDir = path.join(configDir, '.codex', AGENTS_DIR_NAME); + fs.mkdirSync(agentsDir, { recursive: true }); + const files = {}; + for (const name of EXPECTED_AGENTS) { + fs.writeFileSync(path.join(agentsDir, `${name}.md`), `# ${name}\n`); + fs.writeFileSync(path.join(agentsDir, `${name}.toml`), `name = "${name}"\n`); + files[`agents/${name}.md`] = {}; + files[`agents/${name}.toml`] = {}; + } + fs.writeFileSync( + path.join(configDir, '.codex', 'gsd-file-manifest.json'), + JSON.stringify({ files }), + ); + return agentsDir; +} + // ─── Init command agent validation ────────────────────────────────────────── describe('init commands: agents_installed field (#1371)', () => { @@ -92,6 +109,30 @@ describe('init commands: agents_installed field (#1371)', () => { assert.strictEqual(output.agents_installed, true); }); + test('init plan-phase reports the complete project-local Codex installation', () => { + const phaseDir = path.join(tmpDir, '.planning', 'phases', '01-setup'); + const globalHome = path.join(tmpDir, 'global-codex'); + fs.mkdirSync(phaseDir, { recursive: true }); + fs.writeFileSync( + path.join(tmpDir, '.planning', 'config.json'), + JSON.stringify({ runtime: 'codex' }), + ); + createCompleteCodexAgents(tmpDir); + const canonicalRoot = fs.realpathSync(tmpDir); + const localAgentsDir = path.join(canonicalRoot, '.codex', AGENTS_DIR_NAME); + _createAgentsDir(globalHome, EXPECTED_AGENTS); + + const result = runGsdTools('init plan-phase 1 --raw', tmpDir, { CODEX_HOME: globalHome }); + assert.ok(result.success, `Command failed: ${result.error}`); + + const output = JSON.parse(result.output); + assert.strictEqual(output.project_root, canonicalRoot); + assert.strictEqual(output.agent_runtime, 'codex'); + assert.strictEqual(output.agents_dir, localAgentsDir); + assert.strictEqual(output.agents_installed, true); + assert.deepStrictEqual(output.missing_agents, []); + }); + test('init execute-phase includes missing_agents list when agents are missing', () => { const phaseDir = path.join(tmpDir, '.planning', 'phases', '01-setup'); fs.mkdirSync(phaseDir, { recursive: true }); @@ -162,6 +203,67 @@ describe('validate health: agent installation check W010 (#1371)', () => { }); }); +// ─── Codex project-local validation status ────────────────────────────────── + +describe('Codex project-local validation status', () => { + let tmpDir; + let globalHome; + + beforeEach(() => { + tmpDir = createTempProject(); + globalHome = path.join(tmpDir, 'global-codex'); + fs.writeFileSync( + path.join(tmpDir, '.planning', 'config.json'), + JSON.stringify({ runtime: 'codex' }), + ); + _createAgentsDir(globalHome, EXPECTED_AGENTS); + }); + + afterEach(() => { + cleanup(tmpDir); + }); + + test('validate agents and health use a complete project-local Codex installation', () => { + createCompleteCodexAgents(tmpDir); + const localAgentsDir = path.join(fs.realpathSync(tmpDir), '.codex', AGENTS_DIR_NAME); + const env = { CODEX_HOME: globalHome }; + + const validateResult = runGsdTools('validate agents --raw', tmpDir, env); + assert.ok(validateResult.success, `validate agents failed: ${validateResult.error}`); + const validateOutput = JSON.parse(validateResult.output); + assert.strictEqual(validateOutput.agents_dir, localAgentsDir); + assert.strictEqual(validateOutput.agents_found, true); + assert.deepStrictEqual(validateOutput.missing, []); + assert.deepStrictEqual(validateOutput.incomplete, []); + + const healthResult = runGsdTools('validate health --raw', tmpDir, env); + assert.ok(healthResult.success, `validate health failed: ${healthResult.error}`); + const healthOutput = JSON.parse(healthResult.output); + assert.ok(!(healthOutput.warnings || []).some(warning => warning.code === 'W010')); + }); + + test('an empty project-local Codex directory remains authoritative for validate agents and health', () => { + fs.mkdirSync(path.join(tmpDir, '.codex', AGENTS_DIR_NAME), { recursive: true }); + fs.writeFileSync(path.join(tmpDir, '.codex', 'gsd-file-manifest.json'), JSON.stringify({ files: {} })); + const localAgentsDir = path.join(fs.realpathSync(tmpDir), '.codex', AGENTS_DIR_NAME); + const env = { CODEX_HOME: globalHome }; + + const validateResult = runGsdTools('validate agents --raw', tmpDir, env); + assert.ok(validateResult.success, `validate agents failed: ${validateResult.error}`); + const validateOutput = JSON.parse(validateResult.output); + assert.strictEqual(validateOutput.agents_dir, localAgentsDir); + assert.strictEqual(validateOutput.agents_found, false); + assert.deepStrictEqual(validateOutput.installed, []); + assert.deepStrictEqual(validateOutput.incomplete, []); + assert.deepStrictEqual(validateOutput.missing, EXPECTED_AGENTS); + + const healthResult = runGsdTools('validate health --raw', tmpDir, env); + assert.ok(healthResult.success, `validate health failed: ${healthResult.error}`); + const healthOutput = JSON.parse(healthResult.output); + assert.ok((healthOutput.warnings || []).some(warning => warning.code === 'W010')); + }); +}); + // ─── Copilot .agent.md detection (#1512) ──────────────────────────────────── describe('checkAgentsInstalled: Copilot .agent.md format (#1512)', () => { diff --git a/tests/agent-skills.test.cjs b/tests/agent-skills.test.cjs index df8e01ae7..27ac96d82 100644 --- a/tests/agent-skills.test.cjs +++ b/tests/agent-skills.test.cjs @@ -30,6 +30,7 @@ const TEST_ENV_BASE = { WT_SESSION: '', TMUX_PANE: '', ZELLIJ_SESSION_NAME: '', + GSD_WORKSTREAM: '', TTY: '', SSH_TTY: '', }; @@ -71,6 +72,13 @@ function readConfig(tmpDir) { return JSON.parse(fs.readFileSync(configPath, 'utf-8')); } +function markLocalGsdInstall(tmpDir) { + fs.writeFileSync( + path.join(tmpDir, '.codex', 'gsd-file-manifest.json'), + JSON.stringify({ files: {} }), + ); +} + // Run agent-skills with --json for typed IR assertions function runAgentSkillsJson(args, tmpDir, env) { // Insert --json after 'agent-skills' subcommand @@ -128,6 +136,59 @@ describe('agent-skills command', () => { assert.strictEqual(r.ir.block, ''); }); + test('unconfigured Codex reads its local companion agent from a descendant cwd', () => { + const agentsDir = path.join(tmpDir, '.codex', 'agents'); + const descendant = path.join(tmpDir, 'src', 'feature'); + const localPersona = '# Local Codex executor\nUse the project-local agent.\n'; + fs.mkdirSync(agentsDir, { recursive: true }); + fs.mkdirSync(descendant, { recursive: true }); + fs.writeFileSync(path.join(agentsDir, 'gsd-executor.md'), localPersona); + markLocalGsdInstall(tmpDir); + writeConfig(tmpDir, { runtime: 'codex' }); + + const r = runAgentSkillsJson(['agent-skills', 'gsd-executor'], descendant, { + HOME: tmpDir, + USERPROFILE: tmpDir, + CODEX_HOME: path.join(tmpDir, 'global-codex'), + GSD_RUNTIME: '', + }); + assert.ok(r.success, `Command failed: ${r.error}`); + assert.strictEqual(r.ir.block, localPersona); + }); + + test('workstream runtime selects the local Codex companion when root config differs', () => { + const agentsDir = path.join(tmpDir, '.codex', 'agents'); + const localPersona = '# Local Codex executor\nUse the overridden runtime.\n'; + fs.mkdirSync(agentsDir, { recursive: true }); + fs.writeFileSync(path.join(agentsDir, 'gsd-executor.md'), localPersona); + markLocalGsdInstall(tmpDir); + writeConfig(tmpDir, { runtime: 'claude' }); + const workstreamDir = path.join(tmpDir, '.planning', 'workstreams', 'feature-x'); + fs.mkdirSync(workstreamDir, { recursive: true }); + fs.writeFileSync(path.join(workstreamDir, 'config.json'), JSON.stringify({ runtime: 'codex' })); + + const r = runAgentSkillsJson(['agent-skills', 'gsd-executor'], tmpDir, { + HOME: tmpDir, + USERPROFILE: tmpDir, + CODEX_HOME: path.join(tmpDir, 'global-codex'), + GSD_RUNTIME: '', + GSD_WORKSTREAM: 'feature-x', + }); + assert.ok(r.success, `Command failed: ${r.error}`); + assert.strictEqual(r.ir.block, localPersona); + }); + + test('unconfigured Claude remains empty when a local Codex companion exists', () => { + const agentsDir = path.join(tmpDir, '.codex', 'agents'); + fs.mkdirSync(agentsDir, { recursive: true }); + fs.writeFileSync(path.join(agentsDir, 'gsd-executor.md'), '# Local Codex executor\n'); + writeConfig(tmpDir, { runtime: 'claude' }); + + const r = runAgentSkillsJson(['agent-skills', 'gsd-executor'], tmpDir, { GSD_RUNTIME: 'claude' }); + assert.ok(r.success, `Command failed: ${r.error}`); + assert.strictEqual(r.ir.block, ''); + }); + test('returns block containing agent_skills XML for configured agent', () => { const skillDir = path.join(tmpDir, 'skills', 'test-skill'); fs.mkdirSync(skillDir, { recursive: true }); diff --git a/tests/docs-update.test.cjs b/tests/docs-update.test.cjs index c87326885..668731079 100644 --- a/tests/docs-update.test.cjs +++ b/tests/docs-update.test.cjs @@ -14,6 +14,22 @@ const fs = require('fs'); const path = require('path'); const { runGsdTools, createTempProject, cleanup } = require('./helpers.cjs'); +const MODEL_PROFILES = require('../gsd-core/bin/lib/model-profiles.cjs').MODEL_PROFILES; +const EXPECTED_AGENTS = Object.keys(MODEL_PROFILES); + +function createCompleteCodexAgents(projectRoot) { + const agentsDir = path.join(projectRoot, '.codex', 'agents'); + const files = {}; + fs.mkdirSync(agentsDir, { recursive: true }); + for (const name of EXPECTED_AGENTS) { + fs.writeFileSync(path.join(agentsDir, `${name}.md`), `# ${name}\n`); + fs.writeFileSync(path.join(agentsDir, `${name}.toml`), `name = "${name}"\n`); + files[`agents/${name}.md`] = {}; + files[`agents/${name}.toml`] = {}; + } + fs.writeFileSync(path.join(projectRoot, '.codex', 'gsd-file-manifest.json'), JSON.stringify({ files })); +} + // ─── JSON output shape ──────────────────────────────────────────────────────── describe('docs-init command', () => { @@ -91,6 +107,49 @@ describe('docs-init command', () => { }); }); +describe('docs-init Codex project-local agent status', () => { + let tmpDir; + let globalHome; + + beforeEach(() => { + tmpDir = createTempProject(); + globalHome = path.join(tmpDir, 'global-codex'); + fs.writeFileSync(path.join(tmpDir, '.planning', 'config.json'), JSON.stringify({ runtime: 'codex' })); + fs.mkdirSync(path.join(globalHome, 'agents'), { recursive: true }); + for (const name of EXPECTED_AGENTS) { + fs.writeFileSync(path.join(globalHome, 'agents', `${name}.md`), `# global-${name}\n`); + } + }); + + afterEach(() => { + cleanup(tmpDir); + }); + + test('reports a complete local Codex installation instead of a global fixture', () => { + createCompleteCodexAgents(tmpDir); + + const result = runGsdTools('docs-init --raw', tmpDir, { CODEX_HOME: globalHome }); + assert.ok(result.success, `docs-init failed: ${result.error}`); + const output = JSON.parse(result.output); + + assert.strictEqual(output.project_root, fs.realpathSync(tmpDir)); + assert.strictEqual(output.agents_installed, true); + assert.deepStrictEqual(output.missing_agents, []); + }); + + test('reports an empty local Codex directory as unhealthy even with global agents', () => { + fs.mkdirSync(path.join(tmpDir, '.codex', 'agents'), { recursive: true }); + fs.writeFileSync(path.join(tmpDir, '.codex', 'gsd-file-manifest.json'), JSON.stringify({ files: {} })); + + const result = runGsdTools('docs-init --raw', tmpDir, { CODEX_HOME: globalHome }); + assert.ok(result.success, `docs-init failed: ${result.error}`); + const output = JSON.parse(result.output); + + assert.strictEqual(output.agents_installed, false); + assert.deepStrictEqual(output.missing_agents, EXPECTED_AGENTS); + }); +}); + // ─── response_language wiring (#2402) ───────────────────────────────────────── // // cmdDocsInit predates withProjectRoot (init.cts) and never picked up