#4908·gsd-core

Epic: one resolved ProjectContext — which checkout a process acts in is decided once, not per call site

Author: trek-eCreated Sep 21, 2026Updated Sep 21, 2026
Labelsarea: coretype: choreepic

An approved epic does not approve its children — each child is its own issue before code.

Epic: one resolved ProjectContext — which checkout a process acts in is decided once, not per call site

The deliverable is the seam, not the individual fixes. The issues absorbed below are evidence that one piece of behavior has no single owner. They are the symptom list, not the work list. Repairing them where they were reported — N fixes for N issues — leaves the missing owner still missing, and the next occurrence is already being written somewhere else in the tree. An implementation that does that has not closed this epic, even with every symptom gone and CI green.

Closing this epic means three things land together: one resolved ProjectContext { repoRoot, worktreeRoot, planningRoot, gitCommonDir, isLinkedWorktree, provenance } that every git call, filesystem write and verify-command execution takes its cwd from; the duplicate resolvers are deleted rather than kept in sync, because two copies that agree today are the same defect as two that disagree; and enforcement is by construction — a lint rejecting a raw findProjectRoot / rev-parse --show-toplevel / resolveWorktreeLinkage at a consumer site, and a cwd parameter typed as ProjectContext rather than string, so a hand-resolved root does not typecheck. The first two without the third un-consolidate quietly as soon as someone who has not read this issue touches the area.

A defect that cannot be expressed through the seam is a finding about the seam's design and belongs in this issue as a comment — one special case is a caller that legitimately needs the main checkout while running inside a worktree.

This is the shape that worked in #1372 (markdown sectionizer), #2143 (table, bounded mutation, fail-loud) and #2121 (phase identifier): each closed its bug class with a seam, a migration and an anti-divergence guard, none of them by fixing the reported symptoms. Splitting delivery across several PRs is expected and welcome; splitting it so that the seam never lands is not.

Six open confirmed-bug issues are one defect: there is no single answer to "which checkout am I acting in," so each call site computes one — and they disagree. This is the highest-severity family on the tracker: one member silently commits into the wrong repository and returns {"committed": true}; another passes verification against code the change never touched.

The unification already happened once, and it did not hold

#1411 (Epic: Resolution Provenance) and #1414 (P1 — Unify the project-root walk-up) closed with exactly this shape: one findProjectRoot, one ancestor walk-up, a grep guard asserting "exactly one ancestor-.planning/ walk-up implementation in src/", and resolvePlanningCwd deleted. That guard still holds. The defect moved.

What #1414 unified was how far up to walk. What no one owns is which of several simultaneously-valid roots to act in — main checkout vs. linked worktree vs. symlinked planning store vs. an explicitly passed --project-dir — and that question is now answered by at least four independent mechanisms: findProjectRoot (src/project-root.cts), resolveMainWorktreeCwd (gsd-tools.cjs), resolveWorktreeLinkage (bin/lib/worktree-safety.cjs), and command -v against PATH. A grep guard scoped to src/ cannot see three of them.

Four arms, one remedy

(a) The resolver answers "main checkout" where the caller meant "here", and a write lands in the wrong repository.

#4885gsd-tools query commit invoked from a subdirectory of a linked worktree commits into the main checkout. Reproduced mechanically twice (probe_worktree_commit_root.py), clean success payload both times: {"committed": true, "hash": "5a80f27"}, worktree HEAD untouched.

The mechanism is precise and worth stating, because it is a correct-looking resolver used for the wrong question: resolveMainWorktreeCwd returns cwd unchanged only when cwd itself carries a .planning directory; otherwise it delegates to resolveWorktreeLinkage, which compares --git-dir against --git-common-dir for the current directory. A worktree subdirectory has its own --git-dir, so it classifies as linked_worktree and returns dirname(commonDirResolved) — the parent of the shared .git, which is the main checkout, never the worktree's own root. cmdCommit then runs every git add / git commit with that misresolved cwd.

The reporter has no code fix available — the resolver is outside their writable surface — and mitigates by never invoking a committing verb from a worktree subdirectory.

(b) An absolute path escapes the root check, and verification passes against the wrong tree.

#4767 — a worktree-isolated executor runs a plan's <automated> verify commands; when the planner wrote them with the orchestrator's absolute root, they cd into the main checkout and pass against code the worktree changed and the main tree did not. Three compounding sites: the verify-command-grounding probe exempts absolute targets from its outside_root check, quick mode never runs the probe at all, and the executor's absolute-path guard covers Edit/Write only.

This issue is unusually careful about what it is not, and the distinctions are load-bearing for scoping: not a duplicate of #2401 (there the command could not run; here it runs and passes), not #4254 (there the executor landed on the wrong checkout; here the executor is right and its verification is not), and not #4636 — assertWithinRoot and the local/no-unconfined-path-join lint shipped and drained this very file, but the absolute branch has no containment comparison at all, so there was nothing for the lint to find. The fix uses #4636's predicate at one of three sites; the other two are outside a path predicate's reach.

It also states explicitly that it falls outside all six existing epics, including #4630 — which concerns how the isolation decision is produced and transported, not which root a dispatched process resolves. That is this epic's charter.

(c) The walk-up stops at the wrong boundary for a layout GSD itself recommends.

#4815findProjectRoot's git-boundary-crossing guard (added for #2843) stops the walk at the wrong directory when .planning/ is a symlink whose target has its own .git. This is exactly the shape agents/gsd-planning-out-of-repo.md recommends (.planning symlinked to an external git-tracked store, commit_docs: false), so every project following the documented convention permanently fails verification.fingerprint / phase.complete's digest check, regardless of how solid the verification is. Confirmed unchanged at v1.14.0.

#4894 is the same fault line from the other side: --project-dir is validated and honored by the dispatcher (#3881) and ignored by verification.fingerprint and the staleness recompute, both of which re-derive from phaseDir. It is listed there as acceptance criterion (4) of the #4815 triage brief. It is absorbed by the verification epic (#4907) as an emitter/checker defect; it is named here because the flag it ignores is this seam's output.

(d) The binary itself is resolved by a different mechanism than everything else.

#4834_runtime-launcher.snippet.sh resolves gsd_run via command -v before it considers the runtime-local config-dir candidate. In a worktree (no gsd-core/ at the worktree root), candidate 1 misses and the global binary wins — a leftover npm i -g @opengsd/[email protected] against a 1.14.0 local install. The foreign tool predates query git.base-branch --is-protected, so the executor's mandatory pre-commit HEAD assertion treats the branch as protected and refuses the commit, even though git.allow_default_branch_commits is set.

#4799 (codex sequential phase worktree executor refuses commits on configured feature branch) and #4881 (worktree.base-check degrades on a premise an execute-phase run can never satisfy) are the same family: a decision about where is made from a signal that does not survive worktree isolation.

The seam this epic delivers

  1. resolveProjectContext(startDir, explicit?)ProjectContext — one resolver returning every root the process may need, each named, plus the provenance of how it was chosen (the #1411 Resolution<T> envelope, extended to worktree linkage). Symlink-aware, worktree-aware, --project-dir-aware.
  2. Callers name the root they want. ctx.worktreeRoot for "act where I am", ctx.repoRoot for "the shared history", ctx.planningRoot for "where .planning really lives". #4885 is a caller that asked a resolver named resolveMainWorktreeCwd for the answer to "here"; with named roots that call does not compile.
  3. One containment predicate for execution targets, not just for path joins. #4636's assertWithinRoot extended to cover a cd target — absolute or relative — so #4767's probe warns on an absolute target outside ctx.worktreeRoot, and the executor's guard covers <automated> execution and not only Edit/Write.
  4. Tool resolution is part of the context. gsd_run resolves against ctx — install-local first, PATH last — so a foreign global binary cannot shadow the install a worktree belongs to.

The ratchet

A lint rejects root resolution at a consumer site, extending #1414's grep guard past src/ to bin/, hooks/ and the shipped workflow shell. And the acceptance test is the reproducer #4885 already wrote: a committing verb invoked from a worktree subdirectory must commit in that worktree or refuse — never elsewhere, never silently. Every verb that takes a cwd gets a positive control run from a linked-worktree subdirectory; today that harness does not exist, which is why three of these shipped.

Absorbed issues — evidence of the missing seam, NOT a work list

Issue Defect Arm
#4885 commit verb from a worktree subdirectory resolves to and commits in the main checkout, reporting success wrong root
#4767 absolute cd targets skip the outside_root check, so a worktree executor's <automated> checks pass against the main checkout; quick mode never runs the probe containment
#4815 findProjectRoot stops at the wrong boundary when .planning is a symlink into an externally git-managed store — the layout GSD's own convention doc recommends walk-up
#4834 a globally-installed gsd_run on PATH shadows the local install, so git.allow_default_branch_commits is not honored tool resolution
#4799 codex sequential phase worktree executor refuses commits on the configured feature branch wrong root
#4881 worktree.base-check's fork-from-head observation requires a prior clean agent worktree at HEAD, which an execute-phase run never has wrong root

Related but housed elsewhere: #4894 (--project-dir ignored by verification) is absorbed by the verification epic (#4907); it consumes this seam's output and the two must land compatibly. #4630 owns how the isolation decision is produced and transported; this epic owns what the resolved roots are once it has been.

Closed prior art: #1411 / #1414 unified the walk-up and shipped the grep guard that still holds. #2843 added the git-boundary guard #4815 now trips over. #4636 delivered assertWithinRoot and drained no-unconfined-path-join through #4767's file without being able to see its defect.

Done when

  • One resolver; findProjectRoot, resolveMainWorktreeCwd and resolveWorktreeLinkage are consumers of it or deleted
  • Every git invocation and filesystem write takes its cwd from a named field of a ProjectContext, not a string
  • A committing verb invoked from any depth inside a linked worktree commits in that worktree or refuses — proven by #4885's reproducer
  • A .planning symlink into an externally git-managed store resolves correctly, proven against the layout gsd-planning-out-of-repo.md prescribes
  • --project-dir is honored uniformly by every path that resolves a root, or refused loudly where it cannot be
  • A cd target outside the resolved worktree root warns, absolute or relative, and the probe runs in quick mode
  • gsd_run resolves install-local before PATH
  • A lint rejects raw root resolution outside the seam, covering src/, bin/, hooks/ and shipped workflow shell
  • Every cwd-taking verb has a positive control executed from a linked-worktree subdirectory
  • Failing-first regression per absorbed issue

Non-goals

  • Repairing the absorbed issues individually at their existing call sites without building the seam. #1414 did the unification honestly and the defect reappeared in three resolvers a scoped guard could not see; a fourth point fix has the same future.
  • Making an absolute <automated> target a blocker. #4767 is explicit, quoting #3678's own commit message that a false blocker is "worse than the bug this feature fixes". The ask is the outside_root warning the relative form already gets.
  • Reverting #2401 review Finding 3 (a chained cd must reset on an absolute segment, not concatenate). That fix is correct and stays; its test asserts severity: 'none' for an absolute directory outside the fixture root, which is incidental to what it tests and is the one assertion this work flips — named here so it is not discovered in review.
  • Changing worktree lifecycle policy (creation, reuse, cleanup) or the isolation decision itself — #4630's territory.
  • Re-opening #2843's git-boundary rule as a policy. The requirement is that a symlinked planning store resolves correctly, not that boundary crossing becomes permitted.

Generated with Claude Code