* fix(#3203): stop npm-global installs validating bundled agents against themselves getAgentsDir's claude branch derived the agents directory from __dirname, which is correct for repo runs and runtime-config-dir installs (where <root>/../agents IS the user's agents dir) but on an npm-global install resolves to the package's own bundled agents/, so checkAgentsInstalled validated the package against itself and agents_installed could never be false. The new-project and new-milestone halt/warn gates were silently dead for npm-global users. Keep the install-relative path for the shapes where it is correct, but when it lies inside a node_modules tree — the provably self-validating case — resolve getGlobalConfigDir('claude')/agents like every other runtime, honouring CLAUDE_CONFIG_DIR. GSD_AGENTS_DIR stays priority 1. Repair the doc comment that asserted the __dirname form was correct for both install shapes. Regression test mirrors the published npm-global layout (package under node_modules with a complete bundled agents/) and pins the resolved directory plus the issue's negative control (one agent missing from the config dir → agents_installed:false). Verified red against pre-fix code, green post-fix; the repo-layout W010 health test stays green. * chore(#3203): set changeset fragment pr to 3229 * docs(#3203): describe the node_modules guard as lexical, in CONTEXT.md and at the call site The Agent Install Check Module glossary entry asserted that Claude resolves the agents directory `__dirname`-relative unconditionally. That is the premise this PR falsified: on an npm-global install the install-relative path resolves to the package's own bundled `agents/`, so the check validated the package against itself and `agents_installed` could never be false. The inline doc comment above `getAgentsDir` was repaired with the fix; this external predicate was left behind and has been false since. CONTRIBUTING.md's `Fixed`-fragment docs exemption names this case explicitly — "Edit the docs anyway if a fix corrects something the docs got wrong." Both surfaces now describe the guard as what it is: an exact, case-sensitive path-segment test that TARGETS those layouts rather than detecting them, so neither claims more certainty than the predicate has. The call-site comment carried the same conflation the glossary did. A path merely carrying a directory of that name resolves the same way — the edge already disclosed on this PR — and a non-empty GSD_AGENTS_DIR overrides it. Comment-only in `src/`; no behaviour change. `CONFIG.LOCATION.SEAM.two-families` needs no change: `GSD_AGENTS_DIR -> getAgentsDir priority 1` is still accurate. --------- Co-authored-by: Tom Boucher <trekkie@nomorestars.com>
This commit is contained in:
5
.changeset/witty-herons-march.md
Normal file
5
.changeset/witty-herons-march.md
Normal file
@@ -0,0 +1,5 @@
|
||||
---
|
||||
type: Fixed
|
||||
pr: 3229
|
||||
---
|
||||
**npm-global installs can now actually fail the agents-installed gate** — `checkAgentsInstalled` resolved the claude agents directory relative to its own install location, so an npm-global install validated the package's bundled `agents/` against itself and `agents_installed` could never be `false`, silently disabling the halt/warn gates in `new-project` and `new-milestone`. When the install-relative path lies inside a `node_modules` tree the claude runtime now resolves `getGlobalConfigDir('claude')/agents` like every other runtime, honouring `CLAUDE_CONFIG_DIR`; repo runs and runtime-config-dir installs are unchanged, and the `GSD_AGENTS_DIR` override stays priority 1. (#3203)
|
||||
@@ -195,7 +195,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`), plan/summary pairing helpers (`countMatchedSummaries`, `findUnsummarizedPlans`, `findOrphanSummaries`), and small pure helpers (`generateSlugInternal`, `extractOneLinerFromBody`, `extractCanonicalPlanId`, `timeAgo`). `filterPlanFiles`/`filterSummaryFiles` were retired by #3183 (ADR-3180 Decision 2) — `getPhaseFileStats` no longer re-derives plan/summary filename matching locally; it now sources `plans`/`summaries` (plus a `scope` field, `COMPLETE`/`TRUNCATED`/`UNREADABLE`) directly from `scanPhasePlans` (`src/plan-scan.cts`), the single owner of live-plan counting. 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?, 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.
|
||||
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 unless that path contains a `node_modules` path segment, in which case it falls back to `getGlobalConfigDir('claude')/agents` (#3203); that segment test is lexical and case-sensitive rather than an install-shape guarantee — it targets the layouts where the sibling `agents/` is the package's own bundled copy and the check would otherwise validate the package against itself, and any path merely carrying a directory of that name resolves the same way. 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`).
|
||||
|
||||
@@ -81,9 +81,17 @@ function truncatePostureValue(value: string): string {
|
||||
*
|
||||
* Priority:
|
||||
* 1. GSD_AGENTS_DIR env var (explicit override, any runtime)
|
||||
* 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.
|
||||
* 2. For claude runtime: __dirname-relative path (agents/ sibling of
|
||||
* gsd-core/) — correct for repo runs and runtime-config-dir installs,
|
||||
* where the sibling agents/ IS the user's agents dir — UNLESS that path
|
||||
* carries an exact node_modules segment. gsd-tools.cjs lives inside
|
||||
* gsd-core/bin/ in every install shape, but on an npm-global install
|
||||
* gsd-core/ sits inside the package (not the runtime config dir) and the
|
||||
* package ships its own agents/, so the install-relative path resolves
|
||||
* to the bundled copy and the check validates the package against
|
||||
* itself — agents_installed can never be false. In that case resolve
|
||||
* getGlobalConfigDir('claude')/agents (honours CLAUDE_CONFIG_DIR) like
|
||||
* every other runtime (#3203).
|
||||
* 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
|
||||
@@ -100,7 +108,16 @@ function getAgentsDir(runtime?: string, projectRoot?: string): string {
|
||||
}
|
||||
const resolved = runtime ?? (process.env['GSD_RUNTIME'] || 'claude');
|
||||
if (resolved === 'claude') {
|
||||
return path.join(__dirname, '..', '..', '..', 'agents');
|
||||
const installRelative = path.join(__dirname, '..', '..', '..', 'agents');
|
||||
// #3203: a lexical guard, not an install-shape test. It targets the
|
||||
// layouts where the sibling agents/ is the package's own bundled copy and
|
||||
// the check would otherwise validate the package against itself; a path
|
||||
// merely carrying a directory of that name resolves the same way, and a
|
||||
// non-empty GSD_AGENTS_DIR overrides both.
|
||||
if (installRelative.split(path.sep).includes('node_modules')) {
|
||||
return path.join(getGlobalConfigDir('claude'), 'agents');
|
||||
}
|
||||
return installRelative;
|
||||
}
|
||||
if (projectRoot) {
|
||||
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
||||
|
||||
@@ -107,11 +107,67 @@ describe('getAgentsDir', () => {
|
||||
assert.strictEqual(agentInstallCheck.getAgentsDir('cursor'), '/tmp/x');
|
||||
});
|
||||
|
||||
test('claude runtime returns __dirname-relative path', () => {
|
||||
const fromModule = agentInstallCheck.getAgentsDir('claude');
|
||||
// Should end with /agents
|
||||
assert.ok(fromModule.endsWith(path.sep + 'agents') || fromModule.endsWith('/agents'),
|
||||
`Expected path to end with /agents, got: ${fromModule}`);
|
||||
test('claude runtime outside node_modules returns the install-relative path', () => {
|
||||
// Repo runs and runtime-config-dir installs: the sibling agents/ IS the
|
||||
// user's agents dir, so install-relative resolution is correct there.
|
||||
const expected = path.resolve(
|
||||
path.dirname(AGENT_INSTALL_CHECK_PATH), '..', '..', '..', 'agents'
|
||||
);
|
||||
assert.strictEqual(agentInstallCheck.getAgentsDir('claude'), expected);
|
||||
});
|
||||
|
||||
test('npm-global install resolves the config dir, never the bundled agents (#3203)', (t) => {
|
||||
// Mirror the published npm-global layout: the package sits inside a
|
||||
// node_modules tree and ships its own agents/. Pre-fix, getAgentsDir
|
||||
// resolved that bundled copy, so checkAgentsInstalled validated the
|
||||
// package against itself and agents_installed could never be false.
|
||||
const tmp = createTempDir('gsd-npm-global-');
|
||||
const savedClaudeDir = process.env['CLAUDE_CONFIG_DIR'];
|
||||
t.after(() => cleanup(tmp));
|
||||
t.after(() => {
|
||||
if (savedClaudeDir === undefined) {
|
||||
delete process.env['CLAUDE_CONFIG_DIR'];
|
||||
} else {
|
||||
process.env['CLAUDE_CONFIG_DIR'] = savedClaudeDir;
|
||||
}
|
||||
});
|
||||
|
||||
const pkgRoot = path.join(tmp, 'node_modules', '@opengsd', 'gsd-core');
|
||||
fs.cpSync(
|
||||
path.join(__dirname, '..', 'gsd-core', 'bin'),
|
||||
path.join(pkgRoot, 'gsd-core', 'bin'),
|
||||
{ recursive: true }
|
||||
);
|
||||
// Bundled agents/ is always complete — that is exactly why the pre-fix
|
||||
// self-validation could never report a missing agent.
|
||||
const bundledAgents = path.join(pkgRoot, 'agents');
|
||||
fs.mkdirSync(bundledAgents, { recursive: true });
|
||||
for (const agent of EXPECTED_AGENTS) {
|
||||
fs.writeFileSync(path.join(bundledAgents, `${agent}.md`), `# ${agent}\n`);
|
||||
}
|
||||
|
||||
// Config dir carries every expected agent except the first — the issue
|
||||
// repro's negative control (gsd-verifier removed from ~/.claude/agents).
|
||||
const configDir = path.join(tmp, 'claude-config');
|
||||
const agentsDir = path.join(configDir, 'agents');
|
||||
fs.mkdirSync(agentsDir, { recursive: true });
|
||||
const [removedAgent, ...presentAgents] = EXPECTED_AGENTS;
|
||||
for (const agent of presentAgents) {
|
||||
fs.writeFileSync(path.join(agentsDir, `${agent}.md`), `# ${agent}\n`);
|
||||
}
|
||||
process.env['CLAUDE_CONFIG_DIR'] = configDir;
|
||||
|
||||
const globalInstallCheck = require(
|
||||
path.join(pkgRoot, 'gsd-core', 'bin', 'lib', 'agent-install-check.cjs')
|
||||
);
|
||||
// Pin the resolved directory, not just an /agents suffix — the pre-fix
|
||||
// resolver also ended with /agents, which is how the bug survived.
|
||||
assert.strictEqual(globalInstallCheck.getAgentsDir('claude'), agentsDir);
|
||||
|
||||
const result = globalInstallCheck.checkAgentsInstalled('claude');
|
||||
assert.strictEqual(result.agents_dir, agentsDir);
|
||||
assert.strictEqual(result.agents_installed, false);
|
||||
assert.deepStrictEqual(result.missing_agents, [removedAgent]);
|
||||
});
|
||||
|
||||
test('non-claude runtime returns getGlobalConfigDir(runtime)/agents', () => {
|
||||
|
||||
Reference in New Issue
Block a user