feat(install, compile): --root DIR to redirect writes while sources resolve from $PWD#928
feat(install, compile): --root DIR to redirect writes while sources resolve from $PWD#928srid wants to merge 5 commits intomicrosoft:mainfrom
--root DIR to redirect writes while sources resolve from $PWD#928Conversation
`apm install --root <dir>` writes apm_modules/, apm.lock.yaml, and runtime deployment dirs (.claude/, .codex/, .agents/, .opencode/) under <dir> while sources (apm.yml, .apm/, local-path packages) continue resolving from $PWD. Mirrors `pip install --target` and `npm install --prefix`. Useful for scratch-dir verification (microsoft#684), bootstrap scripts, and fixture generation -- closes microsoft#888. Implementation - core/scope.py: process-global deploy-root override (set_deploy_root_override) plus a separate get_source_root() that always resolves to $PWD. get_manifest_path is decoupled from get_apm_dir so the manifest stays in $PWD even when writes redirect. - install/context.py: InstallContext gains source_root, defaulting to project_root for back-compat. - install/pipeline.py: passes source_root into the context. _project_has_root_primitives runs against source_root. - install/services.py: integrate_local_content takes an optional source_root for the synthetic _local package's install_path. - install/phases/{resolve,integrate}.py: thread source_root through to local-package resolution and local-content integration. - commands/install.py: --root option, mutually exclusive with --global; sets the override at entry, clears it in finally so no global state leaks across invocations.
Replace the deploy-root override with a chdir-based redirect so every existing site that hardcodes Path.cwd() / os.getcwd() (notably the MCP adapters in apm_cli.adapters.client.*) automatically resolves to the deploy root. Sources keep reading from the original $PWD via set_source_root_override. Why chdir - The previous override only covered scope helpers (get_deploy_root/get_apm_dir). MCP adapters bypass those helpers and write directly to Path.cwd() / opencode.json, .vscode/mcp.json, .cursor/mcp.json, etc. Refactoring the long tail of cwd/getcwd call-sites is more invasive than the chdir trick and would block this feature on a wider cleanup. Implementation - new module: apm_cli.install.root_redirect.install_root_redirect context manager (chdir + source-root pin, restored on exit). - core/scope.py: replace deploy override with source-root override; get_manifest_path now derives from get_source_root. - commands/install.py: bracket the handler body with the context manager (enter at top, __exit__ in finally). The Click option block is compressed onto one line per option to recover budget. - tests/unit/install/test_architecture_invariants.py: bump LOC budget 1700 -> 1725 with rationale (mirrors the prior PR pattern); the pending --mcp extraction recovers this budget.
The dependency resolver loads ``project_root / "apm.yml"`` for the root manifest. Before this fix it received ``ctx.apm_dir`` -- which under ``apm install --root`` points at the (typically empty) deploy directory, causing the resolver to silently return zero direct deps. Pass ``ctx.source_root`` instead so the manifest resolves from $PWD even when writes redirect. Falls back to ``ctx.apm_dir`` when no override is active so the default path stays unchanged.
Sources continue resolving from $PWD; AGENTS.md / CLAUDE.md outputs land under DIR. Pairs with `apm install --root` for scratch-dir verification (microsoft#888) -- the install + compile combo needs no rsync, cd-gymnastics, or symlinks. Implementation - AgentsCompiler / DistributedAgentsCompiler take an optional source_dir parameter (defaults to base_dir for back-compat). base_dir continues to drive write paths and placement targets; source_dir is used for primitive discovery, project-tree scoring (ContextOptimizer), and constitution lookup. - DistributedAgentsCompiler._source_to_base translates the placement map keys from source-dir-rooted to base-dir-rooted so writes land at the deploy root. - template_builder.build_conditional_sections takes an optional source_dir to compute display-relative paths in `<!-- Source: -->` comments. Without this, scratch-compiled output renders absolute source paths and diverges from in-place compile output. - distributed_compiler's per-instruction source attribution renders paths relative to source_dir (was self.base_dir). - commands/compile/cli.py: --root option, brackets the handler with compile_root_redirect (alias for install_root_redirect; identical chdir + source-root pin). Source-root reads (apm.yml existence, .apm/ scan, find_constitution, target detection, AgentsCompiler) go through get_source_root. - install/root_redirect.py: re-exports the helper as compile_root_redirect; the two commands share one implementation.
Pre-merge structural review (Hickey + Lowy)I ran two structural-review passes on this branch — one Hickey-style ("Simple Made Easy", looking for complecting and missed deduplication) and one Lowy-style (volatility-based decomposition, after Parnas '72). Background on what each pass checks: kolu.dev/blog/hickey-lowy. Posting both before maintainer review since the two passes disagree on the central design choice and I'd rather hear your opinion than commit to one direction blindly. Where they convergeCorrectness bug under Missing cross-reference docstring on Where they disagreeThe headline: chdir + process-global override vs. just-the-override-and-refactor-cwd-callsites.
This is the tradeoff I called out in the PR description's "draft status" section; the two reviewers come down on opposite sides. I lean Hickey's way (smaller diff, contained surface) but Lowy's argument that the long tail is the volatility worth encapsulating is real. Maintainer call: which? Lowy's other architectural pushes
Hickey's other style pushes
Optional / defer
AskingThe MUST-FIX correctness bug + docstring will get fixed regardless. For the rest, especially the chdir-vs-refactor call, I'd rather have your direction before I rewrite. Happy to push either way; the kolu downstream consumer (juspay/kolu#716) is in draft pending this PR's resolution and isn't blocking. 🤖 Reviews generated with structural-analysis subagents via Claude Code (background: kolu.dev/blog/hickey-lowy). |
AgentsCompiler.validate_primitives and the verbose-output helper at the foot of agents_compiler.py rendered primitive / instruction file paths via `portable_relpath(file_path, self.base_dir)`. Under `apm compile --root`, source files live under `source_dir` while `base_dir` is the deploy root -- the two don't share a common parent, so the relpath either returned `../../...` chains or the absolute path. DistributedAgentsCompiler already does this right at distributed_compiler.py:594; this closes the asymmetry. Also documents the source-vs-deploy routing convention on `get_lockfile_dir` so a future maintainer adding a new metadata helper picks the right root without tracing callers. Found by a Hickey-style structural review of the --root branch: microsoft#928 (comment)
|
Pushed fix for the two MUST-FIX items (commit 19c20e1):
The should-fix / chdir-vs-refactor items still await your call. Draft stays draft until then. |
Closes #888.
Downstream consumer validating this PR end-to-end: juspay/kolu#716 — collapses a ~60-line
rsync+mkdir+cpscratch-staging workaround in their CI pipeline to a four-lineapm install --root+apm compile --rootsequence.Why
apm installandapm compilealways write relative to$PWD, which forces any scratch-directory workflow to physically stage every input file (manifest,.apm/, lockfile, project tree) beforecd-ing in. The juspay/kolu CI pipeline hit this in a sharp way (#684): runningapm installon the live worktree was briefly destroying.claude/while a Claude Code session had an open file handle there. The workaround exists purely because there was no--target-style flag.This PR adds that flag:
apm install --root DIRandapm compile --root DIR. Writes go underDIR; sources (apm.yml,.apm/, local-path packages, the project tree used for distributed compile placement scoring) continue reading from$PWD. Semantics deliberately mirrorpip install --targetandnpm install --prefix.With both flags in place the kolu workaround collapses to:
How
The implementation picks a chdir + source-root pin model over refactoring the long tail of
Path.cwd()/os.getcwd()call sites (notably the MCP adapters inapm_cli.adapters.client.*, the OpenCode/Cursor/VSCode adapters, andmcp_integrator.py). A new context managerapm_cli.install.root_redirect.install_root_redirectdoes two things:os.chdir(root)so every site that hardcodesPath.cwd()auto-resolves to the deploy root.set_source_root_override($PWD)(process-global) so helpers that need the user's source tree (get_source_root,get_manifest_path) still point at the original working directory.Both are restored in a
finallyso the context doesn't leak across invocations — important for test runners, REPL sessions, and embedded callers.compile_root_redirectis a one-line alias for the same implementation.get_manifest_pathis decoupled fromget_apm_dir: the manifest is a source, so it followsget_source_root, while the lockfile /apm_modules// deploy dirs followget_deploy_root. That distinction is the design crux — the issue body explicitly called out "sources continue resolving from $PWD" as the intended semantics.Install side
InstallContextgainssource_root(defaults toproject_rootfor back-compat via__post_init__).install/pipeline.pycomputessource_root = get_source_root(scope)alongsideproject_root = get_deploy_root(scope)._project_has_root_primitives(scans source root for.apm/),integrate_local_content's synthetic_localpackage (install_path = source_root), the dep resolver'sapm.ymllookup, and local-path package resolution inphases/resolve.py+sources.py.The resolver fix is its own commit because the tree-shaped symptom ("running
apm install --rootsilently resolved zero direct dependencies") is worth a standalone reviewable change.Compile side
AgentsCompilerandDistributedAgentsCompilergain an optionalsource_dirparameter (defaults tobase_dirfor back-compat).base_dirkeeps driving placement targets and write paths;source_dirdrives primitive discovery,ContextOptimizer, and constitution lookup.DistributedAgentsCompiler._source_to_basetranslates placement-map keys from source-rooted to base-rooted so the AGENTS.md writes land at the deploy root even though the placement decisions were scored against the source tree.template_builder.build_conditional_sectionspicks up a matchingsource_dirparameter so<!-- Source: ... -->display paths render relative to$PWD— without this, scratch-compiled output diverges from in-place output by embedding absolute scratch paths.What's included / what's not
Source resolution for
apm.ymlis tied toget_source_root, which currently means the original CWD. Adding a separate--manifest PATHflag for reading the manifest from an arbitrary location (called out as future work in the issue body) is deliberately not in this PR — the current design accommodates it cleanly by havingget_manifest_pathconsult an additional override, but that's a future increment.The compiler's
source_diris CLI-aware by necessity because distributed-compile placement scoring scans the project tree; a pre-resolved path from the caller would be fine except thatContextOptimizeris constructed insideDistributedAgentsCompiler.__init__and already took a directory. Threading two paths through is the minimal change.Verification
tests/unit/install/(63 tests) +tests/unit/compilation/+tests/unit/commands/(344 tests combined) all pass on this branch.tests/unit/suite passes (5430 / 5430 excluding 4 environment-dependent failures unrelated to this change:test_is_tool_available("python")and three_real_systemchecks — none reference any code I touched).install.pyLOC budget intest_architecture_invariants.pybumps 1700 → 1725 with rationale in the same comment-based tradition as prior PRs (Enforce apm-policy.yml atapm installtime, not only inapm audit --ci#827, feat(policy): enforce apm-policy.yml at install time #832, Azure DevOps authentication via Entra ID (AAD) bearer tokens #852, feat(auth): Azure DevOps authentication via Entra ID (AAD) bearer tokens #856); the pending--mcpextraction that's already tracked in that test will recover the budget.End-to-end validated against juspay/kolu#716:
apm audit --cipasses 7/7 and every AGENTS.md output (root +packages/+packages/client/src/+packages/tests/features/+packages/server/src/) is byte-identical between anapm install --root "$scratch" && apm compile --root "$scratch"run and the in-place equivalent.Draft status
Drafted so the design can be sanity-checked before it leaves draft. In particular, feedback welcome on:
Path.cwd()long tail is real and this PR sidesteps it; the alternative is a sweep through the adapters to use scope helpers, which is a larger and probably-separate change. Is that acceptable?source_dirleak into the compiler classes. Principled ("the compiler already knew about roots") or a smell ("CLI-level concept leaking into a pure library")? I picked principled-enough-for-now.🤖 Generated with Claude Code