* fix(#2602): discover project-local Codex agents - Select an existing local Codex agents directory before global fallback - Prove init reports the canonical local installation through compiled CJS * test(#2602): lock Codex agent precedence - Cover override, local authority, global fallback, and runtime compatibility - Exercise installed state through the compiled resolver * fix(#2602): resolve local Codex agent skills - Pass the canonical project root to the non-Claude persona fallback - Cover nested-Codex fallback and Claude compatibility through the CLI * test(#2602): cover local Codex validation status - Assert emitted validate and health commands use the project-local install - Preserve empty local-directory authority beside complete global agents * fix(#2602): align validation with local Codex discovery - Pass the resolved runtime and project root to health W010 - Resolve the validate-agents runtime before checking installation status * test(#2602): cover local Codex docs status - Assert docs-init reports an authoritative empty local install as unhealthy * fix(#2602): align docs with local Codex discovery - Pass the resolved runtime and canonical project root to the shared agent checker * fix(#2602): honor agent-skills runtime override - Resolve agent-skills fallback runtime through the canonical project resolver - Cover conflicting config and GSD_RUNTIME values through the emitted CLI * fix(#2602): ignore non-directory local agents paths - Treat only a local Codex agents directory as authoritative - Cover regular-file fallback through the emitted install checker * chore(#2602): add changelog fragment - record the user-visible local Codex agent discovery fix for PR #2623 * fix(#2602): align local agent discovery with runtime policy - Resolve Codex's local config directory through the canonical runtime policy - Use test-managed cleanup for local-agent discovery coverage * fix(#2602): discover local agents across runtimes - Prefer manifest-backed project-local installs for non-Claude runtimes - Respect runtime-specific local install roots and preserve global fallback behavior - Cover native, partial, cross-runtime, and project-root local discovery * fix(#2602): preserve agent discovery fallback - Fall back globally when local-install probes fail - Document and test symlink rejection - Align the changeset with repository format * fix(#2602): reuse local directory policy - Resolve runtimes without local config through the canonical sentinel - Document the manifest gate and refresh the context index --------- Co-authored-by: Daniel E. <daniel.e@teachingstrategies.com> Co-authored-by: Rezolv <dave@sienkowski.com>
This commit is contained in:
committed by
GitHub
parent
7b204ad2ac
commit
f0ff23635e
5
.changeset/jolly-orcas-munch.md
Normal file
5
.changeset/jolly-orcas-munch.md
Normal file
@@ -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.
|
||||
@@ -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<string,unknown>` 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`).
|
||||
|
||||
@@ -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:
|
||||
* <projectRoot>/<localConfigDir>/agents (or <projectRoot>/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<string, { runtime?: { hostBehaviors?: { localTargetIsProjectRoot?: boolean } } }>;
|
||||
};
|
||||
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[] = [];
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -226,7 +226,7 @@ function getLatestCompletedMilestone(cwd: string): { version: string; name: stri
|
||||
function withProjectRoot(cwd: string, result: Record<string, unknown>): Record<string, unknown> {
|
||||
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`);
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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 ───────────────────────────────────────
|
||||
|
||||
@@ -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)', () => {
|
||||
|
||||
@@ -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 });
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user