Summary
Relative paths in a non-root package's apm.yml are resolved against the root consumer's project directory rather than the package's own source directory. This makes it impossible to compose two local sibling packages where one declares the other as a dependency — the natural pattern for layering reusable building blocks inside a single repo.
The fix is small (~30 LOC), aligns APM with the semantics every other package manager (npm, pip, cargo, go modules, Bicep) uses for relative paths in manifests, and unlocks clean local-package composition without changing the common case.
Repro
A monorepo with two local packages — a base and a specialization that builds on it:
my-project/
├── apm.yml # consumer
└── packages/
├── editorial-pipeline/
│ └── apm.yml # base package
└── handbook-agents/
└── apm.yml # specialization, depends on the base
Consumer apm.yml:
dependencies:
apm:
- ./packages/handbook-agents
packages/handbook-agents/apm.yml — the natural way to declare the dep:
dependencies:
apm:
- ../editorial-pipeline
apm install fails: APM tries to resolve ../editorial-pipeline against the root consumer (my-project/), looking for my-project/../editorial-pipeline — i.e. one directory above the project root, which doesn't exist.
The only workaround that "works" today is to write ./packages/editorial-pipeline inside handbook-agents/apm.yml — but that bakes the consumer's filesystem layout into the inner package. Publish that package standalone tomorrow and it's broken for everyone else.
Root cause
In src/apm_cli/install/phases/resolve.py (around line 117), the root project's path is closure-captured once and reused for every dep, including transitive ones:
project_root = ctx.project_root # captured at the root
def download_callback(dep_ref, modules_dir, parent_chain=""):
...
if dep_ref.is_local and dep_ref.local_path:
result_path = _copy_local_package(
dep_ref, install_path, project_root, ... # always root, never the parent's location
)
_copy_local_package (src/apm_cli/install/phases/local_content.py:92-94) does the resolution:
local = Path(dep_ref.local_path).expanduser()
if not local.is_absolute():
local = (project_root / local).resolve()
So when a transitive dep declares ../editorial-pipeline, the project_root here is the root consumer, not the directory whose apm.yml actually contains the line. The breadcrumb already exists (parent_chain is threaded for error messages), but the parent package object isn't passed down, so the call site has no way to ask "where did the apm.yml that declared this dep actually live?"
Proposed fix
Make relative paths resolve against the source directory of the package whose apm.yml declared the dep.
Concretely:
-
APMPackage gains one field: source_path: Path — absolute on-disk directory holding this package's apm.yml.
- Root project:
project_root
- Local dep: the original path (before being copied into
apm_modules/_local/), which the resolver already computes
- Remote dep: the clone directory inside
apm_modules/
-
_try_load_dependency_package (apm_resolver.py:360-438) sets package.source_path when constructing APMPackage. For local deps the value is the same (project_root / local_path).resolve() expression _copy_local_package uses today.
-
download_callback accepts parent_pkg: Optional[APMPackage]. The resolver already knows the parent at every step (it must, to record graph edges); pass it down.
-
_copy_local_package signature changes from (dep_ref, install_path, project_root, ...) → (dep_ref, install_path, base_dir, ...) where the caller computes base_dir = parent_pkg.source_path if parent_pkg else project_root. The body is unchanged.
That's the entire change. ~30 lines touched, no new abstractions.
Why this is correct, not just convenient
| Behaviour |
Today |
With fix |
Root: ./packages/foo |
resolves vs project_root ✅ |
resolves vs project_root ✅ (root's source_path == project_root) |
Local-in-local: ../sibling |
breaks |
resolves vs the parent package's source dir ✅ |
Remote-in-remote: ./internal-helper |
meaningless |
resolves vs the clone dir ✅ |
| Absolute paths |
works |
works (handled by the absolute branch already) |
This semantic — "relative paths in a manifest are relative to that manifest's directory" — is what every other package manager uses (npm file:, pip editable installs, cargo path deps, go module replace directives, Bicep module paths). The current behaviour is the surprising one.
Wins
- Real local layering. A repo can ship reusable building blocks (
packages/base/) and consumers (packages/specialization/) that genuinely declare the relationship. Today this requires either flattening at the consumer root (so the dep is invisible to APM) or publishing the base as its own remote repo just to express a sibling-folder dependency.
- Portability survives extraction. A local package can be lifted into its own repo later without rewriting its
apm.yml. The relative path keeps the same meaning.
- Aligns with developer intuition. No one reading
../editorial-pipeline in packages/handbook-agents/apm.yml expects it to resolve from somewhere two directories up.
- Consistent semantic across local and remote deps. Both anchor on "the directory containing the apm.yml that declared me." Today, transitive local deps anchor on the root consumer, which has no equivalent in the remote case.
- Doesn't change the common case. Root-level relative paths behave identically (root's
source_path == project_root).
Risks & edge cases
| Concern |
Severity |
Notes |
| Backward compatibility |
Low |
The only behaviour that changes is local-relative paths in non-root apm.yml files. That case is broken today (resolves to nonsense), so realistically no one depends on the current resolution. The root case is byte-identical. |
| Install-path collisions |
Pre-existing |
install_path = apm_modules/_local/<dirname> (reference.py:335). Two parents declaring local deps with the same final dirname collide today and still would. Orthogonal to this issue; addressable later by hashing the source path or namespacing by parent. |
| Lockfile entries |
Low |
lockfile.py:198-215 serialises local_path as a string. For transitive local deps the string's anchor would change (relative-to-source rather than relative-to-root); a regenerate handles it. Direct local deps are unaffected. |
| Cycle detection |
None |
Graph walker already detects cycles by package identity, not by path. Unchanged. |
Out of scope
- Fixing the install-path collision risk (separate, longer-standing)
- Subpath-remote refs (
org/repo/sub/path) — already work today, this issue is purely about the local-path branch
- Any change to lockfile schema
Happy to send a PR if there's appetite. Analysis was done against awd-cli main (current).
Summary
Relative paths in a non-root package's
apm.ymlare resolved against the root consumer's project directory rather than the package's own source directory. This makes it impossible to compose two local sibling packages where one declares the other as a dependency — the natural pattern for layering reusable building blocks inside a single repo.The fix is small (~30 LOC), aligns APM with the semantics every other package manager (npm, pip, cargo, go modules, Bicep) uses for relative paths in manifests, and unlocks clean local-package composition without changing the common case.
Repro
A monorepo with two local packages — a base and a specialization that builds on it:
Consumer apm.yml:
packages/handbook-agents/apm.yml— the natural way to declare the dep:apm installfails: APM tries to resolve../editorial-pipelineagainst the root consumer (my-project/), looking formy-project/../editorial-pipeline— i.e. one directory above the project root, which doesn't exist.The only workaround that "works" today is to write
./packages/editorial-pipelineinsidehandbook-agents/apm.yml— but that bakes the consumer's filesystem layout into the inner package. Publish that package standalone tomorrow and it's broken for everyone else.Root cause
In
src/apm_cli/install/phases/resolve.py(around line 117), the root project's path is closure-captured once and reused for every dep, including transitive ones:_copy_local_package(src/apm_cli/install/phases/local_content.py:92-94) does the resolution:So when a transitive dep declares
../editorial-pipeline, theproject_roothere is the root consumer, not the directory whoseapm.ymlactually contains the line. The breadcrumb already exists (parent_chainis threaded for error messages), but the parent package object isn't passed down, so the call site has no way to ask "where did the apm.yml that declared this dep actually live?"Proposed fix
Make relative paths resolve against the source directory of the package whose apm.yml declared the dep.
Concretely:
APMPackagegains one field:source_path: Path— absolute on-disk directory holding this package'sapm.yml.project_rootapm_modules/_local/), which the resolver already computesapm_modules/_try_load_dependency_package(apm_resolver.py:360-438) setspackage.source_pathwhen constructingAPMPackage. For local deps the value is the same(project_root / local_path).resolve()expression_copy_local_packageuses today.download_callbackacceptsparent_pkg: Optional[APMPackage]. The resolver already knows the parent at every step (it must, to record graph edges); pass it down._copy_local_packagesignature changes from(dep_ref, install_path, project_root, ...)→(dep_ref, install_path, base_dir, ...)where the caller computesbase_dir = parent_pkg.source_path if parent_pkg else project_root. The body is unchanged.That's the entire change. ~30 lines touched, no new abstractions.
Why this is correct, not just convenient
./packages/fooproject_root✅project_root✅ (root'ssource_path == project_root)../sibling./internal-helperThis semantic — "relative paths in a manifest are relative to that manifest's directory" — is what every other package manager uses (npm
file:, pip editable installs, cargo path deps, go module replace directives, Bicep module paths). The current behaviour is the surprising one.Wins
packages/base/) and consumers (packages/specialization/) that genuinely declare the relationship. Today this requires either flattening at the consumer root (so the dep is invisible to APM) or publishing the base as its own remote repo just to express a sibling-folder dependency.apm.yml. The relative path keeps the same meaning.../editorial-pipelineinpackages/handbook-agents/apm.ymlexpects it to resolve from somewhere two directories up.source_path == project_root).Risks & edge cases
install_path = apm_modules/_local/<dirname>(reference.py:335). Two parents declaring local deps with the same final dirname collide today and still would. Orthogonal to this issue; addressable later by hashing the source path or namespacing by parent.lockfile.py:198-215serialiseslocal_pathas a string. For transitive local deps the string's anchor would change (relative-to-source rather than relative-to-root); a regenerate handles it. Direct local deps are unaffected.Out of scope
org/repo/sub/path) — already work today, this issue is purely about the local-path branchHappy to send a PR if there's appetite. Analysis was done against
awd-climain(current).