fix(3751): resolveAgentsDir falls back to repo-local .claude/agents (#3762)

* test(#3751): add RED test for resolveAgentsDir missing repo-local .claude/agents

Drive resolveAgentsDir() with a repo-local .claude/agents present and
~/.claude/agents absent. Structural contracts assert the function body
must reference projectDir when constructing the fallback path, and that
init-complex.ts must pass projectDir to the call site. Both fail
deterministically before the fix.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* fix(#3751): resolveAgentsDir() falls back to repo-local .claude/agents for --local installs

resolveAgentsDir() was probing only GSD_AGENTS_DIR or the runtime-global
config dir (~/.claude/agents for Claude). For Claude Code --local installs
where agents land in ./.claude/agents, the SDK reported agents_installed:
false even when the agent files were present.

Precedence (post-fix):
  1. GSD_AGENTS_DIR (explicit override, unchanged)
  2. <getRuntimeConfigDir(runtime)>/agents (when the dir exists)
  3. <projectDir>/.claude/agents (repo-local fallback for claude runtime)

Both init.ts:checkAgentsInstalled and init-complex.ts:initNewProject now
receive projectDir and thread it to resolveAgentsDir().

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* chore(3751): update changeset to reference PR #3762

* fix(3751): add allow-test-rule annotation to satisfy lint-no-source-grep

The structural-contract test reads TypeScript SDK source files to verify
resolveAgentsDir() signature shape, fallback ordering, and call-site
wiring. These are legitimate SDK-seam contracts (the TS source IS the
product artifact). The allow-test-rule annotation bypasses the
lint-no-source-grep check that flags readFileSync-bound variable usage.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* fix(3751): repair Windows config-set concurrent-write race

withPlanningLock threw on EPERM instead of retrying, causing the losing
process to crash silently and its config write to never land.

On Windows, when two processes race to create the same lock file via
O_EXCL (writeFileSync with flag:'wx'), the OS can return EPERM instead of
EEXIST while NTFS holds an internal exclusive handle on the newly-created
file during the write. The previous catch block only recognized EEXIST as
a contention signal — any other code (including EPERM) fell through to
`throw err`. The calling test used .catch(()=>{}) to suppress process
errors, so the losing process silently exited without writing its value;
the winning process then wrote from the stale pre-race config, producing
the observed 'balanced' instead of 'quality'. Fix: port the
PLANNING_LOCK_RETRY_ERRNOS Set from main (landed in #3777) into this
branch. The set treats EPERM, EBUSY, and six other transient filesystem
codes as retry signals rather than fatal errors.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Tom Boucher
2026-05-20 23:11:05 -04:00
committed by GitHub
parent 75e013191c
commit f27b10736e
5 changed files with 286 additions and 7 deletions

View File

@@ -109,14 +109,28 @@ export function detectRuntime(config?: { runtime?: unknown }): Runtime {
*
* Precedence:
* 1. `GSD_AGENTS_DIR` — explicit SDK override (wins over runtime selection)
* 2. `<getRuntimeConfigDir(runtime)>/agents` — installer-parity default
* 2. `<getRuntimeConfigDir(runtime)>/agents` — installer-parity default (when the dir exists)
* 3. `<projectDir>/.claude/agents` — repo-local fallback for `--local` Claude installs
* (only probed when the global runtime dir is absent or empty, and `projectDir` is given)
*
* Defaults to Claude when no runtime is passed, matching prior behavior
* (see `init-runner.ts`, which is Claude-only by design).
*
* The repo-local fallback was added for bug #3751: Claude Code `--local` installs
* place agent definitions under `./.claude/agents` rather than `~/.claude/agents`,
* but the SDK agent-detection path only probed the global directory.
*/
export function resolveAgentsDir(runtime: Runtime = 'claude'): string {
export function resolveAgentsDir(runtime: Runtime = 'claude', projectDir?: string): string {
if (process.env.GSD_AGENTS_DIR) return process.env.GSD_AGENTS_DIR;
return join(getRuntimeConfigDir(runtime), 'agents');
const globalDir = join(getRuntimeConfigDir(runtime), 'agents');
if (existsSync(globalDir)) return globalDir;
// Repo-local fallback: <projectDir>/.claude/agents for --local Claude installs (#3751).
// Only applicable when a projectDir is known; other runtimes don't use .claude/.
if (projectDir && runtime === 'claude') {
const localDir = join(projectDir, '.claude', 'agents');
if (existsSync(localDir)) return localDir;
}
return globalDir;
}
/**

View File

@@ -303,7 +303,7 @@ export const initNewProject: QueryHandler = async (_args, projectDir, workstream
getModelAlias('gsd-roadmapper', projectDir),
]);
const runtime = detectRuntime(config as { runtime?: unknown });
const agentsDir = resolveAgentsDir(runtime);
const agentsDir = resolveAgentsDir(runtime, projectDir);
const gitInfo = gitWorktreeInfo(projectDir);
const missingRequiredAgents = NEW_PROJECT_REQUIRED_AGENTS.filter(
agent => !hasAgentDefinition(agentsDir, agent),

View File

@@ -220,11 +220,14 @@ function getLatestCompletedMilestone(projectDir: string): { version: string; nam
* (`GSD_RUNTIME` → `config.runtime` → 'claude') and probes that runtime's
* canonical `agents/` directory. `GSD_AGENTS_DIR` still short-circuits.
*
* The optional `projectDir` parameter enables the repo-local `.claude/agents`
* fallback for Claude `--local` installs (bug #3751).
*
* Port of checkAgentsInstalled from core.cjs lines 1274-1306.
*/
function checkAgentsInstalled(config?: { runtime?: unknown }): { agents_installed: boolean; missing_agents: string[] } {
function checkAgentsInstalled(config?: { runtime?: unknown }, projectDir?: string): { agents_installed: boolean; missing_agents: string[] } {
const runtime = detectRuntime(config);
const agentsDir = resolveAgentsDir(runtime);
const agentsDir = resolveAgentsDir(runtime, projectDir);
const expectedAgents = Object.keys(MODEL_PROFILES);
if (!existsSync(agentsDir)) {
@@ -369,7 +372,7 @@ export function withProjectRoot(
): Record<string, unknown> {
result.project_root = projectDir;
const agentStatus = checkAgentsInstalled(config);
const agentStatus = checkAgentsInstalled(config, projectDir);
result.agents_installed = agentStatus.agents_installed;
result.missing_agents = agentStatus.missing_agents;