import { CommandScanner } from './command-scan'; /** * WHICH TREE does a Bash command actually act on? The ONE resolver every bash guard and the * force-to-root check share. * * WHY it has to exist at all: the shell cwd a PreToolUse hook is handed does not tell you which tree * the command acts on. `cd` behaves TWO different ways, and both break a cwd-based guard: * * - IN THE MAIN SESSION ONLY, a `cd` that stays INSIDE the session's working directory PERSISTS to * later calls. Claude Code documents this as a main-session property and states that "subagent * sessions never carry over working directory changes" — measured true here, and the reason this * rule must not be stated unconditionally to a reader who may be a subagent. So the cwd * can be a subdirectory of the governed root, left there by an unrelated command several turns * earlier — a relative path then resolves somewhere other than the root while still being in the * governed tree. * - A `cd` that LEAVES it is reset by the harness, which says so (`Shell cwd was reset to `). * So an agent working in a linked worktree is back in the primary clone by the next call and must * write self-contained `cd && …` commands — and the cwd the hook sees is the primary * clone, not the worktree the command targets. * * (Measured on 2026-08-02: `cd backlog && pwd` → `…/backlog`, then a bare `pwd` in a FRESH call → * still `…/backlog`. But `cd ../ && pwd` → the worktree, then a bare `pwd` → back at * the primary clone. An earlier version of this comment asserted `cd` never persists; that was the * worktree case generalized. The conclusion below is unchanged — only the reason was wrong.) * * Either way a guard that reasons from the raw cwd judges the wrong tree. Three field sightings in * one session: * an `ls` of a path outside every repo blocked as "this branch is merged"; a version-drift cure * (`pnpm install`) that could not be typed from the directory that needed it; and a command aimed at * `/private/tmp` blocked because the PRIMARY clone's main was behind — with a remedy (`git pull` in * the primary clone) the agent had been explicitly forbidden to run. * * Two separate copies of the cwd logic used to exist (the runner's foreign-repo/excludePaths check * and force-to-root). They are both this class now: two resolvers WILL disagree about which tree you * are in, and a guard that disagrees with the guard beside it is worse than either being wrong. * * Resolution, in order: * 1. `effectiveCwd` — the leading run of `cd`/`pushd` in the command itself, resolved left to right. * 2. SAME REPO? `git rev-parse --git-common-dir` is identical for every checkout of one repo, so * comparing it against the governed root's answer is the whole test. Different → FOREIGN (a * nested clone under `repositories/**`), out of scope, hands-off. Not a git repo at all * (`cd /tmp && …`) → OUTSIDE: the guards still run — an absolute path back into the repo must * still be judged — but nothing the command names relative to `/tmp` is workspace content, which * is what ContentReadScan uses `effectiveCwd` for. * 3. WHICH CHECKOUT? `--show-toplevel` from `effectiveCwd`. A LINKED WORKTREE of the governed repo is * MANAGED — it is the same project, just another checkout — so guards run, keyed on THAT tree's * branch and its own state. * 4. PRIMARY OR LINKED? `gitDir !== commonDir`, git's own canonical test — AND NOTHING ELSE. Where * `webpieces.config.json` happened to be found does not enter into it (see below). * 5. WHICH TREE GOVERNS THE INSTALL? `/..` — the PRIMARY clone, carried as `mainRoot`. * It is the tree whose `node_modules` supplies the binary judging the call, from any checkout. * * GOVERNANCE IS NOT IDENTITY EITHER — the second half of the same lesson, and the second bug. classify() * used to answer `primary` for ANY tree that also owned the `webpieces.config.json` it was judged * against (`sameDir(treeRoot, governedRoot)`). `governedRoot` is walked UP from the payload cwd, and a * linked worktree has its own TRACKED config, so for an agent whose cwd IS the worktree — the common * case, and the only one the harness creates for a worktree-isolated subagent — its own tree read as * `primary`. Row 8 (VersionSyncGuard) matches on `w`, so it could not fire for exactly the agents it * exists to protect: measured 2026-08-10, a worktree bumped its pin to 0.4.624 and ran its own * `pnpm install` while the main clone stayed on 0.4.616, and nothing said a word. In the SAME tool call * the `.webpieces/` log resolver — which asks git — correctly stamped `tree=agent-abfdc0aaf1f981f3f`. * Two resolvers in one process disagreeing at the same instant is the shape this module exists to make * impossible, so K is now git's answer and ONLY git's answer. * * That leaves `governedRoot` meaning what its name says (whose config and excludePaths apply) and adds * `mainRoot` for the question VersionSyncGuard actually asks (whose `node_modules` is judging this * call). Those were never the same value, and conflating them made the guard compare a worktree * against ITSELF, which is trivially in sync. * * PLACEMENT IS NOT IDENTITY, and assuming it was is the bug this shape exists to prevent. classify() * used to short-circuit on "is `effectiveCwd` inside the governed root?" and never ask git anything * else — so an agent worktree, which Claude Code checks out INSIDE the repo at * `/.claude/worktrees/agent-XXXX`, took that path, disagreed with `--show-toplevel`, and read as * FOREIGN. `foreign` is ALLOW_EXEMPT in runner.ts: every bash guard silently off, and * the worktree guard (which requires `kind === 'worktree'`) dead code, for exactly the worktrees * the harness creates. A common-dir comparison answers the same for both placements, so there is no * inside/outside case left to get wrong. * * WHY THE GIT DIRS AND NOT ONE OF THE OTHER RESOLVERS — state-dir.ts's own header makes this argument * in full ("Why `--git-dir` / `--git-common-dir`, and not one of the existing services"); the short * version is that `WorktreeService` is a repo-wide ENUMERATION that fails SOFT to `[]` (i.e. fails open * into `foreign`, this bug) and `webpieces.config.json` walk-up is GOVERNANCE, not identity — it * deliberately climbs past a nested clone's `.git` to the outer config (repo-root.spec.ts pins that), * so identity built on it would hand the guards someone else's repo. This class asks DotWebpieces, * whose `rev-parse` pair is memoized per directory per process — it is on the hook's blocking path. */ export type TreeKind = 'primary' | 'worktree' | 'foreign' | 'outside' | 'missing'; /** Data-only (per CLAUDE.md, classes for data). */ export declare class EffectiveTree { /** The pre-`cd` cwd the hook was handed. For an agent in a worktree this is the primary clone. */ readonly shellCwd: string; /** The directory the command really runs in, after its own leading `cd`/`pushd` run. */ readonly effectiveCwd: string; /** The tree root to JUDGE: the owning worktree root, the foreign repo root, or the governed root. */ readonly root: string; /** The root that owns webpieces.config.json — where config and excludePaths come from. */ readonly governedRoot: string; /** * The PRIMARY clone's root — git's `/..`, the same answer from every checkout of the * repo, and equal to `root` in the primary clone itself. * * NOT `governedRoot`. A linked worktree owns its own TRACKED `webpieces.config.json`, so for an agent * resident in one the governed root is the WORKTREE — while the `node_modules` (and therefore the * binary judging the call, the nx executors and the eslint plugin) still resolves by walking up to * the primary clone. This field is that walk-up, asked of git instead of inferred. */ readonly mainRoot: string; readonly kind: TreeKind; /** The command acts on a tree other than the shell's own — messages must steer with `cd &&`. */ readonly redirected: boolean; constructor(shellCwd: string, effectiveCwd: string, root: string, governedRoot: string, mainRoot: string, kind: TreeKind); } export declare class EffectiveTreeResolver { private readonly scanner; private readonly shell; constructor(scanner?: CommandScanner); resolve(command: string, shellCwd: string, governedRoot: string): EffectiveTree; /** * The cwd a command actually runs from, resolving a LEADING run of `cd`/`pushd` in the command. * * ONLY a leading run counts. Once a non-cd command appears it has ALREADY run in the current dir, * so a later `cd` must not retroactively pull it out of scope — otherwise a trailing * `… && cd ` would exempt the WHOLE line, smuggling a root-level `git push` past the * guards. `cd a && cd b && git …` resolves left to right, matching the shell. * * Segmentation is ShellSegmentScan's (over CommandScanner), so quoting is handled exactly as the * guards handle it: `echo "cd sub && git push"` is ONE opaque segment whose first word is `echo`, * so the quoted `cd` is never picked up and cannot be weaponised into a scope escape. */ effectiveCwd(command: string, shellCwd: string): string; /** * Is this command's location UNAMBIGUOUS — or should it be rejected outright? Returns the reason a * `cd` in it cannot be resolved, or null when there is nothing wrong. * * `cd && ` is the ONE shape that moves where a command is judged, because it * is the one shape effectiveCwd() can resolve. Everything else fell back to the shell cwd, which * is SAFE (fails closed, nothing smuggled) but silent, and silent is what cost the time: * * `cd "$DIR" && git push` — `path.resolve(cwd, '$DIR')` is a directory that does not * exist, not the tree that was meant. * `D=/x; cd "$D"; git push` — a bare `VAR=value` segment tokenizes to NO words, so the * leading run ends before the `cd` is reached. * `git fetch && cd /x && git push` — bash really does run the push in /x; the guard judged it * from the shell cwd and blocked it. * `git push && cd /x` — the push already ran at the root, whatever the trailing * `cd` says. * * Naming these in the block MESSAGE (the previous two PRs) helped, but left the agent to notice a * paragraph on an otherwise-normal block. Rejecting is the simpler contract and the one the human * asked for: ONE legal shape, everything else refused with the fix spelled out. No verdict changes * — every command this rejects was already being judged from the shell cwd — so this trades a * silent misdirect for a loud one-line rule, and deletes the near-miss taxonomy entirely. * * Expanding `$VAR` here is still the fix NOT taken. The resolver decides ONE location for a whole * line, so a `cd` that counts retroactively is exactly the `… && cd ` scope escape. * * A command containing a HEREDOC (`<<`) is exempt: CommandScanner does not model heredoc bodies, * so a commit message or doc that merely CONTAINS `cd /x && …` tokenizes as if it were code, and * rejecting that would block ordinary writing. Skipping the rejection cannot open a hole — the * location still falls back to the shell cwd exactly as before. */ misplacedCd(command: string): string | null; /** * "…and the `cd` you are looking at was FINE." * * Naming the offender is only half the cure. A command shaped `cd && … && cd sub && …` * begins with a perfectly compliant `cd`, so a reader told "a `cd` must come FIRST" audits the FRONT * of the line, finds a `cd` that IS first and IS literal, and concludes the guard is broken. That * happened: a human read this deny, checked the leading `cd`, and started filing a bug against the * rule (2026-08-11). Working out which of the two `cd`s was meant took a diff of the allowed retry * against the denied attempts. * * `consumed` already marks the boundary exactly — `segments[0..consumed-1]` are the `cd`s that * counted — so the accepted side costs nothing to state and is what stops the reader auditing the * wrong one. */ private acceptedNote; /** * The remedy for a command judged in the wrong directory — `cd '' && `, with the * command's OWN leading `cd` run REPLACED rather than prefixed. * * A block's remedy must not leave the block's condition true. `atRoot()` alone prefixes, and * `effectiveCwd()` resolves the leading run of `cd`s LEFT TO RIGHT — so prefixing `cd '' &&` * onto `cd && git status` still lands in ``, the identical block fires on * the remedy, and the retry prints it with the prefix doubled, then tripled. Structurally * non-convergent, and observed in the field against an agent worktree. * * Only the LEADING run is dropped, because only the leading run moved where the command was judged. * A mid-line `cd` is part of the work and is carried through untouched (it is separately rejected by * `misplacedCd`). */ remedyAtRoot(root: string, command: string): string; private withoutLeadingCds; private classify; /** Is `dir` the directory `root` itself, or somewhere beneath it? Pure path math, no filesystem. */ private isInside; } /** * The steering prefix every remedy needs, re-exported from @webpieces/rules-config so the guards, the * message builders and pr-gate's worktree notices all emit the IDENTICAL string — including the single * quotes that keep it runnable when the repo path contains a space. See atRoot's own header for why the * quotes are single and never double. */ export { atRoot } from '@webpieces/rules-config';