feat(dev-tooling)!: resolve the project root per tool call - #186
Merged
Martin Bens (SpiGAndromeda) merged 20 commits intoSep 16, 2026
Merged
Conversation
Wrap run_mcp_server invocations in a subshell so the EXIT trap that config.sh installs for temp-file cleanup survives past the SDK's own signal handling, then vendor bash-mcp-sdk v5.1.0 and drop that subshell once the SDK itself captures and re-runs the caller's EXIT trap during teardown. The subshell-only approach left temp files behind on a signalled shutdown. The SDK-level trap capture fixes that directly. Co-Authored-By: Claude <noreply@anthropic.com>
The three MCP servers captured their project root once at launch, so a session working in a linked git worktree ran PHPStan, PHPUnit, ESLint and the builds against the main checkout while editing the worktree. Results looked valid and described the wrong tree. Every tool now takes an optional `project_root`, and `set_project_root` and `cwd` manage and report that state per server process. A target is validated against git's own worktree registry and its `.git` back-pointer, refused unless both the launch environment and the target's own configuration declare `native`, and refused when the dependencies the tool needs are missing under the resolved directory. Each result carries a leading banner line naming the effective root, so the tree a result describes is visible in the transcript. The native command wrappers no longer embed a `cd` in the command they emit. `exec_command` and `exec_npm_command` enter the working directory themselves. That keeps the project root out of shell program text, so a root containing a space or a shell metacharacter is usable, and it fixes `wrap_command`'s unscoped native branch, which emitted no `cd` at all and so ran in the server process's own directory regardless of `PROJECT_ROOT`. BREAKING CHANGE: most tool results now begin with a `Project root: <path> (launch|sticky|call)` banner line ahead of the tool's own output, which changes the shape consumers parse. `set_project_root` and `cwd` report their own state and carry no banner. Co-Authored-By: Claude <noreply@anthropic.com>
Six suites cover the new module: root precedence and validation against real `git worktree add` fixtures, the state file, the enter and exit hook, and a `project_root` argument reaching the resolved working directory for each tool on each server. `worktree_conformance.bats` enumerates every `tool_*` function the three servers define, from the live function set rather than from a list, and asserts each one calls `worktree_enter`. A tool added later that skips the wiring fails this suite instead of running against the launch root unnoticed. Four negative cases pin it: a function without the call, one delegating to a helper that lacks it, one naming it only in a comment, and one carrying it only inside a string literal. The ten existing suites that source a lib file now source the worktree module and initialise its state file. Assertions that pinned a tool's whole output were made exact where a partial match also accepted a wrong command. Co-Authored-By: Claude <noreply@anthropic.com>
…root `console_run` built the target for a relative `output_file` from `$PWD`. A call against the launch root returns from `worktree_resolve_root` without changing directory, so `$PWD` is still wherever the server process was started, which need not be the project root at all. The file landed outside the tree the caller named, and the parameter has documented the project root as its base since 3.19.0. The base is now `WORKTREE_EFFECTIVE_ROOT`, which `worktree_enter` has already set by the time the path is resolved. Co-Authored-By: Claude <noreply@anthropic.com>
`worktree_assert_paths_within_root` measured only the paths beginning with `/`, so `../../../src/Core` reached a worktree-targeted call untouched while the absolute spelling of the same directory was refused, and the refusal text recommended the relative form. A relative path carrying a `..` segment is now refused in any position. Relative paths are still not resolved. The Storefront tools accept a repo-root-relative, a tree-relative and a package-prefixed spelling of one file, and the tree-relative one does not exist under the project root, so resolving here would refuse a documented form. The configuration check covered only a config the worktree supplied itself. A launch config was exempt because it parsed when the server started, but the file is user-editable and a session outlives that parse, so a config edited into something unreadable mid-session reached the call and ran it with the environment, the scopes and every per-tool setting reading as absent. Whichever config is in force is now checked. `git rev-parse` answers an option it does not recognise by echoing it back at exit 0. Below git 2.31, the release that added `--path-format`, the flag became the first line read and shifted every value after it, so the refusal named `--path-format=absolute` as the worktree's git directory. `_worktree_git_dir_pair` now reports that case separately and the message names the version required. Co-Authored-By: Claude <noreply@anthropic.com>
…suites A trap collision in the worktree test suites was swallowing assertion failures, so non-discriminating assertions across those suites passed regardless of the actual command run. This fixes the trap collision, replaces the assertions with precise ones, and refactors worktree_conformance.bats to check the actual command execution directory instead of internal function calls. It also disambiguates five refusal error messages in worktree.sh that shared one phrase and were indistinguishable in test output, reconciles the tools.json enumeration in the conformance suite, cuts duplicate per-tool cases, and adds a check that temp files are released on subshell exit. Co-Authored-By: Claude <noreply@anthropic.com>
Document the worktree scope parameter, the git 2.31 floor required for worktree targeting, and the worktree-directives hook's behavior on EnterWorktree/ExitWorktree events. Correct claims about hook behavior and tool scoping that were accurate when written but went stale once the per-call project-root resolution landed. Remove numeric counts from comments, test names, and docs that go stale on ordinary work (tool counts, refusal counts), keeping only counts that are mechanically enforced elsewhere. Co-Authored-By: Claude <noreply@anthropic.com>
`lint_all`, `lint_twig`, `unit_setup`, `vite_build`, `webpack_build` and `phpunit_coverage_gaps` each declared a `scope` parameter in their server's `tools.json` and never called `resolve_scope`, so a caller's scope was accepted and the tool ran against the project root anyway. `phpunit_coverage_gaps` was the most visible of the six, since a scoped project's Clover report sits under the scope's own cwd, and the tool read the project root instead. The call goes between `worktree_enter` and `worktree_assert_dependencies`, in the order the header of `worktree_assert_dependencies` requires. The JS working directory carries `SCOPE_CWD` and `SCOPE_JS_SUBDIR`, so measuring dependencies before scope resolution examines the unscoped path and refuses a valid worktree. Each of the six gets the extraction and the error message its sibling tools on the same server already use, and one scope case per tool in `scope_js_tools.bats` and `scope_php_tools.bats`. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
No tool's `inputSchema` set `additionalProperties`, so a misspelled or unknown parameter was accepted and silently ignored. The call ran as if the parameter had never been passed, and nothing told the caller its value went nowhere. The vendored validator already implements the constraint and reports `Unknown parameter(s): <name>`; the schemas simply never asked for it. All 36 tools across the three servers now set it, `cwd` and `set_project_root` included. Every tool function's argument reads were compared against its declared properties first, so no tool is now refusing a parameter its own code still reads. `tool_schema.bats` drives `validate_tool_arguments` against each server's real `tools.json`, one case per server, the same function the server calls before it dispatches a tool. BREAKING CHANGE: a call carrying a parameter the tool does not declare is now refused instead of ignored. Remove the parameter, or correct its spelling to the declared name; the refusal names the offending parameter. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
All three MCP server entry points carried a byte-identical `run_mcp_server` call under a byte-identical seven-line rationale for why that call is not isolated in a subshell. `shared/server_run.sh` now holds both and each `server.sh` sources it. The `EXIT` trap stays where it is in each server. Moving it into the shared module would have placed `run_mcp_server` ahead of lib sourcing and environment detection, a behavior change rather than a deduplication. `config.sh` exposed its cleanup only as `_config_cleanup`, and six callers reached for that private name: three bats teardowns and the `EXIT` trap in each of the three servers. Renaming it would have left three traps calling an undefined function at process exit with nothing failing, so the function is now a documented `config_cleanup` and every caller moved with it. No occurrence of the old name remains. `config.sh` is templated, so the same rename lands in `shopware-env`'s copy and the template. The cleanup's behavior is unchanged. It removes the merged-config temp file if one exists. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Four entries for what the preceding commits changed: the six tools that accepted a `scope` and ignored it, the three refusals that shared one phrase, `additionalProperties` refusing an undeclared parameter, and the `config_cleanup` rename. The rename also lands in `shopware-env`, whose copy of the module is synced from `templates/mcp-shared/config.sh`, so that plugin's changelog carries it too. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Bump dev-tooling to 4.0.0, and its dependents test-writing to 5.1.2 and shopware-env to 1.3.1 for the vendored bash-mcp-sdk move. Document the worktree configuration rules in docs/configuration.md: native-only targeting, per-worktree config read timing, and config selection when a worktree carries none of its own. Exclude set_project_root from the dev-tooling-runner agent's tools, since its sticky value would otherwise redirect every later dev-tooling call in the session that spawned the agent, and document the same reasoning in the runner's README note. Update the SessionStart hook prompt with set_project_root and cwd on all three servers and the EnterWorktree/ExitWorktree reminder. Add worktree.sh to the templates README's list of dev-tooling-owned, non-templated shared files, and drop the executable bit from templates/mcp-shared/environment.sh to match its two consumers. Co-Authored-By: Claude <noreply@anthropic.com>
Martin Bens (SpiGAndromeda)
force-pushed
the
feat/dev-tooling-worktree-support
branch
from
September 16, 2026 00:58
48966cc to
52dc569
Compare
Agent Skills ExportBuilt 7 skill packages: |
The command wrappers could only run against the root the server was launched in. Every workdir was bound once at startup, so a call targeting another root (a git worktree) had no path the environment could reach it by. `resolve_env_workdir` now maps a host root onto the environment-side path: the configured workdir plus the root's suffix below the launch root for docker, vagrant and ddev, the longest matching bind-mount destination for docker-compose, identity for native, with both sides of every comparison canonicalized so a logical spelling (macOS `/var` vs `/private/var`) cannot refuse a reachable root. `_set_workdir_from_config` takes the config file and environment as arguments and assigns every scalar on every path, so a call that selected another configuration re-derives the values inside its dispatch subshell while the server process keeps its launch binding. A missing field now refuses on stderr instead of exiting inside a command substitution. `docker compose` invocations run from the launch root via `_compose_run`. Compose discovers its project from the working directory, and a worktree carrying a tracked compose file with a relative bind would otherwise resolve the worktree as the project: refused as not-running, or the container entered at the launch checkout's path while the result is attributed to the worktree. ddev calls name the resolved workdir with `exec -d` only when targeting another root; the `ddev composer` shortcut stays confined to launch-root calls because it re-execs at ddev's own project root and takes no `-d`. `_compose_wrap_npm_command` composes its JS workdir through `get_js_workdir` instead of a duplicate suffix, and the metacharacter set is promoted to `SHELL_HOSTILE_METACHARS` so the worktree root check and the caller-value guard read one source. `get_environment_info` and `_find_first_file` are deleted (no callers). Everything lands in `templates/mcp-shared/` and syncs byte-identical to dev-tooling and shopware-env. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The native-only gate is gone from `_worktree_validate_root`: the target-config allowlist and the launch-environment test are replaced by checks that hold in all five environments. Under docker, docker-compose, vagrant and ddev the effective root has to sit inside the launch project root (the only tree the container mounts), with both sides of the containment compare canonicalized, and the canonical pair is kept for the path mapping so containment and mapping measure the same two paths (a worktree reached through a symlink, or macOS's `/var` spelling, would otherwise pass one and be refused by the other). A worktree's `.git` file has to carry a relative gitdir pointer, in every environment including native: an absolute pointer names a host path that does not exist inside a container, which environment a root runs under is a property of a config file rather than of the pointer, and relinking a worktree is a repository-level operation this module never performs. The refusal names the remediation verbatim. `_worktree_bind_call_environment` does everything a differing-root call owes after validation, in a measured order: bind the environment (the startup binding when the selected config is the launch one, `_worktree_environment_in_force`, so a mid-session edit of the launch config stays inert until restart; a re-derivation inside the dispatch subshell when the worktree carries its own config), map the root through `resolve_env_workdir`, enter the root, then probe `test -d` of the mapped path through the call's own wrapper, after the cd, because the compose and ddev wrappers discover their project from the current directory. Probe verdicts are cached per environment and root in the state file, whose writes now go through `_worktree_state_update`, a jq filter over the current object, so the sticky-root writer and the probe writer preserve each other's keys. Roots that the wrappers cannot carry are refused per environment: whitespace and the shared `SHELL_HOSTILE_METACHARS` set under the four container environments, and the same two checks run a second time against the canonical spelling when it differs. The mapping embeds the resolved path, so a clean-named symlink to a directory carrying a metacharacter would otherwise reach the container's shell unquoted. `tool_set_project_root` runs the same binding and probe before committing a sticky root, so a set that would fail on the next call fails at the set, and both tools install the owned-temp cleanup trap. Messages and `cwd` name both the host path and the environment-side path when they differ. The SessionStart directive drops its native-only sentence and states the in-root and relative-linkage requirements. BREAKING CHANGE: a worktree whose `.git` file carries an absolute gitdir pointer is refused in every environment, and `git worktree add` writes an absolute pointer by default. Relink existing worktrees with `git -c worktree.useRelativePaths=true worktree repair <worktree-path>` (git 2.48 or newer), or create them with `git worktree add --relative-paths`. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The native-only statements are gone from the whole documentation surface. The three `tools.json` files' `project_root` descriptions (33 sites) state that the call runs against the named tree in the server's environment, and that under a container environment the worktree has to sit inside the launch project root. README, `docs/configuration.md` and `docs/reference.md` describe the in-root rule, the environment-side path derivation (configured workdir plus position below the root; for `docker-compose` a configured workdir or the longest matching bind mount), the relative-`gitdir` requirement with its repair command, the charset refusals, the existence probe, and per-worktree configuration. The unreleased `[4.0.0]` CHANGELOG entry absorbs the widened behavior instead of adding a version: five environments, the uniform absolute-pointer refusal with remediation, the probe, per-call honoring of a worktree's own config, the corrected `cwd` source list (`launch` or `sticky`), and the `get_environment_info` removal. AGENTS.md's structure tree, shared-module descriptions and test table are brought in line with the tree, including the previously unlisted `test_helper`, coverage fixtures and `lsp_proxy` suite. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The unreleased 1.3.1 section folds into 1.4.0: 1.3.1 never shipped, so the bash-mcp-sdk v5.1.0 vendoring releases together with this branch's template-synced changes. The minor bump carries the docker-compose ancestor-mount acceptance (longest-prefix matching in place of the exact-match refusal), compose CLI invocations anchored to the launch project root, `_get_docker_container` refusing with a message instead of exiting the process, and the environment-side path-mapping functions that back dev-tooling's worktree targeting and are inert for the lifecycle tools. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
`git worktree add --relative-paths` writes both links relative: the `.git` pointer and the `gitdir` back-pointer, the latter relative to the per-worktree directory that holds it. `_worktree_validate_root` canonicalized the back-pointer from the process working directory, so every worktree in the exact linkage state the module itself mandates was refused as hand-written, while absolute linkage was refused by the pointer classifier. No worktree passed validation in any environment. The back-pointer now resolves against the per-worktree directory, and the mismatch refusal names the resolved directory instead of a relative spelling that names nothing the reader can visit. The suites missed the defect because the `worktree_gitdir_relative()` fixture rewrote only the `.git` file and left the back-pointer absolute, a mixed state no git 2.48+ relink produces. The fixture now rewrites both links, and two regression tests pin the all-relative state (verified failing before the fix) and the still-accepted mixed state. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
A fresh worktree holds tracked files only, so `vendor/` and `node_modules` are missing and every composer- and npm-backed tool refuses until they exist. `worktree_prepare` on each server installs its own toolchain's dependencies through the environment wrappers (`composer install --no-interaction` on `php-tooling`, `npm ci` in the package directory on the JS servers), which under a container environment runs the install inside the container at the mapped path, something a session on the host cannot do. The tool skips `worktree_assert_dependencies`, since it installs exactly what that gate demands, and the gate's refusals now name it as the remedy. The descriptions rule out provisioning by symlink: composer's generated autoloaders resolve `__DIR__` through the symlink, so a worktree with a symlinked `vendor/` silently loads the launch tree's classes. A successful install returns a three-line completion summary instead of the per-package installer output, which would flood the calling session's context for no decision it has to make; a failed install returns the full output. The EnterWorktree directive in `worktree-directives.sh` now reads the worktree's `.git` file and names the repair command up front when the pointer is absolute (the linkage every worktree created by Claude Code's native EnterWorktree tool carries) and warns that such a worktree branches from the default remote branch unless the `worktree.baseRef` setting is `head`. The SessionStart directive's worktree guidance shrinks to the rules the refusals cannot carry themselves. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The admin `worktree_prepare` description claimed `unit_setup` completes jest provisioning. A validation run in a fresh worktree refuted that: jest still fails on the gitignored `test/_mocks_/entity-schema.json`, which `unit_setup` does not regenerate. The description and the reference now name the generator (`console_run` on `php-tooling` with env `prod`, running `framework:schema -s entity-schema <mock path>`), and the reference notes the flag trap. The option is `-s`/`--schema-format`. `--schema` does not exist. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The environment value in a worktree's own configuration selects which guards apply, and an unrecognized name (a "docker " typo, "podman") fell through to the permissive native path with containment, charset and probe skipped and the command running on the host while the launch wrappers stayed container-keyed. A worktree target now refuses unknown names, and the container set lives in one `_env_is_container_environment` predicate in the template-synced environment module, read by containment, charset and probe alike, so an environment cannot be added to the wrappers without the guards seeing it. The probe cache was keyed on environment and root while the mapped path derives from the worktree's own configuration, which is re-read every call. An edited workdir reused a pass recorded for the previous mapping. The mapped path is now part of the key. A scoped `worktree_prepare` on `php-tooling` installed into the scope's cwd while the dependency gate checks `vendor/autoload.php` at the effective root, so the refusal's named remedy never cleared the refusal. The PHP tool drops scope resolution and its schema parameter. The JS tools keep theirs, whose install and gate both derive the same package directory through `get_js_workdir`. `worktree_prepare` also joins `dev-tooling-runner`'s `disallowedTools` because the dependency refusals name it as the remedy, which steered a check-only dispatch into a `node_modules`-rewriting install. The EnterWorktree hook's read guard cleared the line `read` had populated whenever `.git` lacks a trailing newline, dropping the repair hint. Both the hook and the server refusal now quote the repair path, so a root carrying a space survives copy-paste as one argument. jest's `install_if_missing` tested an environment-side path with a host `[[ -d ]]`, so every scoped run under a container paid a full `npm ci`. It now measures the host package directory, the same binding the dependency gate uses. The docs state the effective git floor for worktree targeting as 2.48 rather than 2.31, because the mandatory relative linkage and its repair command are 2.48 features. `tool_cwd` and the call binding share one mapping prefix instead of forty duplicated lines. The three prepare bodies share `worktree_prepare_execute`, whose summary line no longer counts the three lines it shows as suppressed. Argument reads take one jq process instead of two. The probe test helpers replace three hand-rolled recording stubs. Regression tests pin the unknown-environment refusal, containment driven from a worktree's own configuration, probe rekeying, the hook's newline case and the jest host-path check, each verified failing before its fix. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Each of the three dev-tooling MCP servers captured its project root once at launch (
PROJECT_ROOT="${PROJECT_ROOT:-$(pwd)}"), so a session that entered a linked git worktree ran PHPStan, PHPUnit, ESLint and the builds against the main checkout while Claude edited the worktree. The results parsed as valid and described the wrong tree. Closes #181.Per-call project root
Every tool on all three servers takes an optional
project_root. Two new tools manage and report that state per server process:set_project_rootsets a sticky default for the process and resets to the launch root when called without an argument, andcwdreports the effective root, where it came from, the resolved working directory and the config files in force. Precedence is per-call argument, then sticky value, then launch root — a subagent or workflow script can pin the root per call and is not redirected by a sticky value set elsewhere in the session.set_project_rootruns the same validation, environment binding and reachability probe a per-call target gets, so a root that would fail on the next call fails at the set, with the same message.Worktree targeting in every environment
Targeting works under
native,docker,docker-compose,vagrantandddev. The environment layer maps a host root onto the path the environment reaches it by: the configured workdir (docker.workdir,vagrant.workdir,ddev.workdir) plus the worktree's position below the launch root, and underdocker-composea configureddocker-compose.workdiror the destination of the longest matching bind mount. Both sides of every containment and mapping comparison are canonicalized, so a worktree reached through a symlink, or macOS's/varspelling of/private/var, is measured by the directory it names rather than its spelling.A worktree may carry its own
.mcp-php-tooling.json/.mcp-js-tooling.json. Itsenvironmentand scalars are re-derived for that call inside the dispatch subshell; the server process keeps its launch binding, and a mid-session edit of the launch config stays inert until restart — for a launch-root call and a worktree call alike. An environment name outside the known set is refused for a worktree target rather than run as native — a"docker "typo or"podman"would otherwise skip containment, charset and probe and execute on the host. The container set lives in a single_env_is_container_environmentpredicate in the shared environment module, read by containment, charset and the probe alike, so an environment added there is picked up by all three guards in one edit.docker composeinvocations run from the launch root. Compose discovers its project from the working directory, and a worktree carrying the checkout's tracked compose file (with a relative.:/…bind and noname:) would otherwise resolve the worktree as the project — refused as not-running, or executed in the launch checkout while the banner names the worktree.shared/worktree.shholds the resolution and the validation. A target is accepted only when all of these hold:.gitback-pointer — a back-pointer written relative by--relative-pathsresolves against the per-worktree directory that holds itgitis 2.31 or newer for resolution — effectively 2.48 for the feature, since the mandatory relative linkage and its repair command are 2.48 features.gitfile carries a relativegitdirpointer — in every environment,nativeincludedgit -c worktree.useRelativePaths=true worktree repair "<path>", git 2.48+), with the path quoted so a root carrying a space survives copy-pastetest -dof the mapped path, through the call's own wrapper) reaches the root inside the environmentvendor/ornode_modules/and points atworktree_prepareProbe results are cached per environment, root and mapped path for the server process — the mapped path derives from the worktree's own per-call re-read configuration, so an edited workdir probes again instead of reusing a pass recorded for the old mapping. State-file writes update only the key they own, so the sticky root and the probe cache cannot erase each other. Diagnostics name both the host path and the environment-side path when they differ.
Most tool results begin with
Project root: <path> (launch|sticky|call), so the tree a result describes is visible in the transcript instead of inferred. APostToolUsehook onEnterWorktreeandExitWorktreeinjects a reminder naming all three servers, since each is a separate process carrying its own sticky value; on enter it also reads the worktree's.gitfile and names the repair command when the pointer is absolute — the linkage Claude Code's nativeEnterWorktreewrites unless the repository setsworktree.useRelativePaths, detected also when the file lacks a trailing newline — and warns that a native worktree branches from the default remote branch unless theworktree.baseRefsetting ishead.The native command wrappers no longer embed a
cdin the command they emit;exec_commandandexec_npm_commandenter the working directory themselves. That keeps the project root out of shell program text, so a root containing a space or a shell metacharacter is usable, and it fixeswrap_command's unscoped native branch, which emitted nocdat all and ran in the server process's own directory regardless ofPROJECT_ROOT.Dependency provisioning
A fresh worktree holds tracked files only, so
vendor/andnode_modulesare missing and every composer- and npm-backed tool refuses.worktree_prepareon each server installs its own toolchain's dependencies through the configured environment —composer install --no-interactiononphp-tooling,npm ciin the package directory on the JS servers — which under a container runs the install inside the container at the mapped path. It skips the dependency check it exists to satisfy, and the dependency refusals name it as the remedy. A successful install returns a three-line completion summary instead of the per-package installer output; a failed one returns everything.The PHP variant installs at the effective root and deliberately resolves no scope: the dependency gate checks
vendor/autoload.phpthere, and a scoped install would land where the gate never looks, so the refusal's named remedy could never clear the refusal. The JS variants stay scope-aware because their install and gate derive the same package directory throughget_js_workdir. Thedev-tooling-runneragent'sdisallowedToolsblocks all three — a check-only dispatch must not be steered into anode_modules-rewriting install by the refusal text.Provisioning by symlink is ruled out in the tool descriptions: composer's generated autoloaders resolve
__DIR__through the symlink, so a worktree with a symlinkedvendor/silently autoloads the launch tree's classes. Admin Jest needs two more generated artifacts after the install —unit_setup, plus the gitignoredtest/_mocks_/entity-schema.jsonviaconsole_runonphp-tooling(framework:schema -s entity-schema <mock path>); both are named in the tool description.Fixes riding along
lint_all,lint_twig,unit_setup,vite_build,webpack_buildandphpunit_coverage_gapseach declared ascopeparameter and never calledresolve_scope, so a caller's scope was accepted and the tool ran against the project root anyway. All six now resolve it, betweenworktree_enterandworktree_assert_dependencies.No tool's
inputSchemasetadditionalProperties, so a misspelled parameter was accepted and silently ignored. Every tool now sets it (39 across the three servers). Every tool function's argument reads were compared against its declared properties first, so no tool refuses a parameter its own code still reads.console_runresolved a relativeoutput_fileagainst$PWDrather than the project root the parameter has documented since 3.19.0, and$PWDis wherever the server process started. The base is now the effective root.worktree_assert_paths_within_rootmeasured only paths beginning with/, so../../../src/Corepassed while the absolute spelling of the same directory was refused — and the refusal text recommended the relative form. A relative path carrying a..segment is now refused in any position.jest'sinstall_if_missingtested an environment-side path with a host[[ -d ]], so every scoped run under a container paid a fullnpm cialthoughnode_modulesexisted; it now measures the host package directory, the same binding the dependency gate uses.docker-composeworkdir resolution required a bind mount whose source equals the project root exactly; an ancestor mount now resolves via the longest matching source._compose_wrap_npm_commandcomposes its JS working directory throughget_js_workdirinstead of a duplicate suffix.ddevcalls name the resolved workdir withexec -dwhen targeting a worktree; theddev composershortcut stays confined to launch-root calls because it re-execs at ddev's own project root.Refactor
The three
server.shfiles carried a byte-identicalrun_mcp_servercall under a byte-identical rationale;shared/server_run.shnow holds both. TheEXITtrap stays in each server, because moving it would placerun_mcp_serverahead of lib sourcing and environment detection.config.sh's cleanup is now a documentedconfig_cleanuprather than the private_config_cleanupthat six callers reached for, and every caller moved with it.get_environment_infois removed from the shared environment module (no callers).tool_cwdand the per-call binding share one mapping prefix (_worktree_map_call_environment) instead of forty duplicated lines, and the threeworktree_preparebodies share one install-and-summarize implementation.The environment-layer changes are template-synced, so shopware-env ships 1.4.0 (folding the unreleased 1.3.1): ancestor-mount acceptance, launch-root-anchored compose invocations, and path-mapping functions its lifecycle tools never call.
Breaking changes
Project root:banner line ahead of the tool's own output, which changes the shape consumers parse.set_project_rootandcwdcarry no banner..gitfile carries an absolutegitdirpointer is refused in every environment, andgit worktree addwrites an absolute pointer by default. Relink withgit -c worktree.useRelativePaths=true worktree repair <worktree-path>(git 2.48+), or create worktrees with--relative-paths. Settinggit config worktree.useRelativePaths trueonce makes every later worktree relative-linked from the start.