Upward Directory Search for Project Config, Plus a Resolution Diagnostic
ADR-0005: Upward Directory Search for Project Config, Plus a Resolution Diagnostic
Section titled “ADR-0005: Upward Directory Search for Project Config, Plus a Resolution Diagnostic”Status
Section titled “Status”Accepted
Context
Section titled “Context”Background and Problem Statement
Section titled “Background and Problem Statement”Issue #106 reports that loadGdlcConfig (mcp-server/src/config.ts) and
readBoardConfig/readLegacyBoardConfig (hooks/lib/in-progress.mjs) both
resolve the project-config layer (ADR-0004) relative to the literal
process.cwd() of the running MCP server or hook, with no fallback and no
diagnostic when that exact directory doesn’t hold .config/gdlc/config.yml.
The reported repro: in this org’s own modeled-information-format workspace
(a non-git directory holding several repos under repos/*, per that
workspace’s own CLAUDE.md), Claude Code’s MCP server subprocess inherits
the workspace’s cwd, not any individual repo’s. lsof -p <pid> on a live
github-sdlc-planning MCP server confirmed cwd = /Users/AllenR1_1/Projects/modeled-information-format, while the actual
config file lives at repos/gdlc/.config/gdlc/config.yml – one level
below that cwd. get_session_context silently returned projectBoard: null, identical to “no board configured anywhere,” with no signal
distinguishing that from “a real config exists but wasn’t reachable.”
Issue #106 explicitly declined to prescribe a fix, asking that it be decided deliberately, “possibly via a new ADR” – naming climbing parent directories (git-style upward search) or documenting the cwd requirement as two candidate directions, or “something else.”
A geometric constraint the candidate options must be checked against
Section titled “A geometric constraint the candidate options must be checked against”.config/gdlc/config.yml in the reported repro is a descendant of the
MCP server’s cwd (repos/gdlc is one level below the workspace root), not an
ancestor of it. Git-style upward search – climbing from cwd toward the
filesystem root – only ever visits cwd’s ancestors. It provably cannot find
a file in a descendant directory it doesn’t already know about: climbing
further up moves strictly away from any child directory, never toward one.
This matters because upward search is the obvious, industry-precedented answer (git, npm, and tsconfig all resolve a project root this way) and it is one of the two directions issue #106 itself names – but checked against the exact topology in this issue’s own repro, it does not close the gap that motivated filing it. Any decision here has to be explicit about that, not just adopt the familiar pattern and assume it applies.
Decision Drivers
Section titled “Decision Drivers”Primary Decision Drivers
Section titled “Primary Decision Drivers”- Fix what upward search actually fixes, without overclaiming it fixes more. A cwd nested inside a project root (e.g. an MCP server or hook launched from a build subdirectory) is a real, common case upward search solves correctly and safely.
- Don’t silently guess across sibling repos. A workspace cwd that is an
ancestor of several repos (as in the reported case) has no signal in cwd
alone that identifies which descendant repo a given tool call concerns
– inventing a downward probe (e.g. scanning
repos/*) would encode one org’s directory convention into a supposedly general loader, contradicting issue #106’s own note that “any consumer running Claude Code from a parent/workspace directory… hits the same silent gap,” not just this org’srepos/*layout. - Replace silent failure with an observable diagnostic, per the issue’s
core complaint: a
null/empty resolution should be distinguishable from “nothing is configured” versus “something is configured but wasn’t reachable from here,” even where the underlying gap isn’t closed.
Secondary Decision Drivers
Section titled “Secondary Decision Drivers”- No behavior change for the common case. A project run from its own root (the majority case today) must resolve identically before and after.
- No new runtime dependency;
hooks/lib/in-progress.mjsstays dependency-free per its own documented constraint.
Considered Options
Section titled “Considered Options”Option 1: Git-style upward directory search only
Section titled “Option 1: Git-style upward directory search only”Description: Climb from cwd toward the filesystem root looking for
.config/gdlc/config.yml, stopping at the first ancestor (including cwd
itself) that has it.
Advantages: Matches git/npm/tsconfig precedent; fixes the nested-cwd case; fully backward-compatible (cwd-equals-root still resolves on the first check).
Disadvantages: Does not fix the issue’s own reported topology (workspace cwd is an ancestor of the project, not nested inside it) – adopting this alone and calling the issue resolved would misrepresent what changed.
Disqualifying Factor: none on its own merits, but insufficient as the entire decision given driver 3 (observability) is unmet for the reported case.
Risk Assessment:
- Technical Risk: Low. Bounded directory-depth climb, no new dependency.
- Schedule Risk: Low.
- Ecosystem Risk: Low. Additive; existing exact-cwd resolution keeps working identically.
Option 2: Document the cwd requirement prominently, no code change
Section titled “Option 2: Document the cwd requirement prominently, no code change”Description: Add documentation stating project config requires the MCP server’s cwd to equal the project root; no resolution logic changes.
Advantages: Zero risk; honest about the real constraint.
Disadvantages: Leaves the reported repro’s silent failure exactly as
found – get_session_context still returns projectBoard: null
indistinguishable from “nothing configured.” Fixes nothing a user or agent
can act on without already knowing to read this document first, which is
exactly the discoverability problem issue #106 raises.
Disqualifying Factor: doesn’t address driver 3 at all, and leaves driver 1’s real, fixable nested-cwd case unfixed for no reason.
Risk Assessment:
- Technical Risk: None. Documentation-only.
- Schedule Risk: Low.
- Ecosystem Risk: Low, but leaves the actual bug’s symptom (silent
null) live for every consumer that hits it.
Option 3: Downward probe of a known subdirectory convention (e.g. repos/*)
Section titled “Option 3: Downward probe of a known subdirectory convention (e.g. repos/*)”Description: When cwd has no .config/gdlc/config.yml of its own, scan
immediate child directories for one.
Advantages: Would resolve the exact reported repro.
Disadvantages: Encodes this org’s own repos/* workspace layout into a
supposedly general-purpose loader (issue #106 explicitly frames the gap as
affecting “any consumer,” not just this workspace’s convention); ambiguous
with multiple matching children (which sibling repo does an owner/repo-scoped
tool call concern?); unbounded-scan cost and surface-area risk scanning
directories a session may not even be about.
Disqualifying Factor: violates driver 2 outright – it’s exactly the “silently guess across sibling repos” this ADR rules out, and doesn’t generalize past this org’s own directory shape.
Risk Assessment:
- Technical Risk: Medium. Ambiguous-match handling and scan-depth bounding would need their own design.
- Schedule Risk: Medium.
- Ecosystem Risk: Medium. Hardcodes one org’s workspace convention into a general-purpose loader other consumers would inherit.
Option 4 (chosen): Upward search (Option 1) + an explicit resolution diagnostic
Section titled “Option 4 (chosen): Upward search (Option 1) + an explicit resolution diagnostic”Description: Adopt upward search for the real, fixable nested-cwd case
(findProjectConfigRoot in config.ts, findGdlcProjectRoot in
in-progress.mjs). Additionally, expose the resolved project-config path (or
null) as a new projectConfigPath field on get_session_context’s result
(resolveProjectConfigPath in config.ts), so the previously-invisible
resolution outcome becomes observable: a caller can now distinguish “no
project config found from cwd upward” from “project config was found but has
no board configured.” The multi-repo-workspace-ancestor gap (Option 3, ruled
out above) remains open and is the documented workaround path: configure the
global layer ($XDG_CONFIG_HOME/gdlc/config.yml) when running from a
workspace root above multiple repos, or invoke Claude Code with cwd set to
the specific repo.
Advantages: Fixes a real case (driver 1) without overclaiming; adds the observability the issue asks for (driver 3) even for the case that remains open; names the workaround explicitly instead of leaving it undiscoverable; no new dependency, no behavior change for the common case (drivers 2, secondary 1-2).
Disadvantages: Does not close the exact reported repro’s silent-null
symptom on its own – the caller must still notice projectConfigPath: null
and know to check the global layer or their cwd. A fully general fix for the
ancestor-workspace case (e.g. an explicit caller-supplied project-root
override, since only the caller invoking a specific owner/repo tool call
knows which repo it concerns) is named as follow-on work, not built here –
it’s a larger surface change (touching every config-consuming tool’s
signature) that deserves its own design pass rather than folding it into this
ADR’s scope.
Risk Assessment:
- Technical Risk: Low. Bounded directory-depth climb (typically single digits), the same pattern in both consuming files.
- Schedule Risk: Low.
- Ecosystem Risk: Low. Additive; the diagnostic field is new, existing fields and merge semantics are unchanged.
Decision
Section titled “Decision”We adopt Option 4: git-style upward search in both config readers, plus
a projectConfigPath diagnostic field on get_session_context, plus this
ADR’s explicit documentation of the residual multi-repo-workspace-ancestor
gap and its workaround (use the global config layer, or run with cwd set to
the specific repo).
Consequences
Section titled “Consequences”Positive
Section titled “Positive”- A cwd nested inside a project root (a real, common launch pattern) now resolves the project layer correctly where it silently didn’t before.
- The previously invisible “was project config even reachable from here”
question is now answerable from
get_session_context’s own output. - The genuinely unresolved case is named and has a documented workaround, rather than being an undocumented surprise.
Negative
Section titled “Negative”- The issue’s specific reported repro (workspace root above multiple repos) is not fully closed by this change alone – a caller must still notice the diagnostic and act on it.
hooks/lib/in-progress.mjsgains a small amount of duplicated logic (findGdlcProjectRoot) mirroringconfig.ts’sfindProjectConfigRoot, consistent with that module’s existing dependency-free-by-design duplication ofresolveGdlcConfigPath/resolveGlobalGdlcConfigRoot.
Neutral
Section titled “Neutral”- An explicit caller-supplied project-root override (per-tool-call or via an env var) remains a live option for fully closing the ancestor-workspace gap, deliberately left as follow-on work rather than folded into this ADR’s scope.
Decision Outcome
Section titled “Decision Outcome”The decision achieves its stated objective – fix the real, safe subset of
the reported gap and make the rest observable – measured by:
findProjectConfigRoot/findGdlcProjectRoot correctly resolve a project
root from a cwd nested inside it; get_session_context exposes
projectConfigPath; and this ADR records, rather than silently drops, the
finding that upward search alone cannot close the exact scenario issue #106
reported.
Related Decisions
Section titled “Related Decisions”- ADR-0004: One XDG-Mirrored Path for Global and Project Config –
established the
.config/gdlc/config.ymlcarrier and path convention this ADR resolves against; this ADR does not change that carrier or path, only how the search for it starts.
- Issue #106 – this ADR’s tracking issue.
docs/decisions/adr-0004-project-config-surface.md– the carrier and path-unification decision this ADR builds on.
More Information
Section titled “More Information”- Date: 2026-07-07
- Source: Issue #106.
- Related ADRs: ADR-0004.
2026-07-07
Section titled “2026-07-07”Status: Compliant
Findings:
| Finding | Files | Lines | Assessment |
|---|---|---|---|
| Drafted and implemented together; upward search + diagnostic field match the decision recorded here | mcp-server/src/config.ts, hooks/lib/in-progress.mjs, mcp-server/src/tools/session.ts |
- | compliant |
Summary: Decision and implementation land together; no open objections.
Action Required: None.
2026-07-07
Section titled “2026-07-07”Status: Compliant, implementation hardened (impartial review round)
Findings:
| Finding | Files | Lines | Assessment |
|---|---|---|---|
The upward search, unguarded, legitimately passes through the global config layer’s own default location (homedir()/.config) for any cwd under $HOME – risking a stray leftover file there silently outranking the real configured global layer |
mcp-server/src/config.ts, hooks/lib/in-progress.mjs |
findProjectConfigRoot, findGdlcProjectRoot |
fixed – added a homedir() ceiling the climb never checks past, plus a path-equality guard in resolveProjectConfigPath/resolveGdlcProjectConfigPath for a customized XDG_CONFIG_HOME that still happens to collide |
assertProjectScope’s own per-process-boolean cache (github-client.ts) has the identical staleness shape #105 was filed about: after #105’s fix, a gh auth switch correctly re-resolves the token, but this sibling cache still short-circuits on the stale flag |
mcp-server/src/github-client.ts |
assertProjectScope |
fixed – cache now keyed by the resolved token value, not a bare boolean |
resolveToken’s doc comment overclaimed “one subprocess spawn per tool call”; traced to 5-14 calls per single tool invocation |
mcp-server/src/github-client.ts |
resolveToken |
doc comment corrected; behavior unchanged – accepted tradeoff, per issue #105’s own text, over reintroducing a cache |
readLegacyBoardConfig’s cwd-only resolution doesn’t share the new upward search |
hooks/lib/in-progress.mjs |
readBoardConfig, readLegacyBoardConfig |
scope boundary documented, not implemented – the legacy carrier is already scheduled for removal, not worth parallel search machinery for code on its way out |
getSessionContext and its wrapper both independently walk the same directory tree in one invocation; the diagnostic field exists on only one of 10+ config-consuming tools |
mcp-server/src/tools/session.ts, src/tool-defaults.ts |
- | accepted as a known, minor inefficiency / narrower-than-ideal rollout – named as follow-on work, not blocking |
Summary: Independent review caught a real correctness gap (the global-root collision) that this ADR’s original text didn’t anticipate, plus a related staleness bug in a sibling cache this PR’s own doc comments had overclaimed past. Both are fixed, with regression tests. The remaining items are proportionate, explicitly-scoped deferrals, not silent gaps.
Action Required: None blocking. Follow-on candidates: extend the diagnostic field to the other config-driven tool wrappers; give the legacy carrier its own upward search if it outlives its planned removal window.
2026-07-11
Section titled “2026-07-11”Status: Compliant, extended by a later ADR
Findings:
| Finding | Files | Lines | Assessment |
|---|---|---|---|
| ADR-0008 extends this ADR’s upward search from “stop at the first ancestor with any config file” to “continue past a non-matching ancestor, per section” — a different, narrower bug (#227: ancestor shadowing) than this ADR’s own deferred Option 4 (caller-supplied project-root override, for cwd sibling-repo ambiguity), which remains open and unaddressed | mcp-server/src/config.ts, hooks/lib/in-progress.mjs, hooks/lib/settings.mjs, hooks/lib/pr-lifecycle-config.mjs |
findProjectConfigRoot, findGdlcProjectRoot |
compliant |
Summary: Cross-reference only — this ADR’s upward-search decision is not
changed or reversed, only extended to continue past a non-matching ancestor
instead of stopping at the first match. status: stays accepted. The
Option 4 gap this ADR itself deferred (caller-supplied override) is still
open; ADR-0008 explicitly does not close it, since #227 is a different bug.
Action Required: None for this ADR. See ADR-0008 for the extending decision and its implementing issue (#227).