Skip to content

[BUG] apm compile --target claude never resolves .context.md links when folding instructions into CLAUDE.md #2881

Closed

Description

Describe the bug

apm compile --target claude folds instruction bodies (global + applyTo-scoped) directly
into CLAUDE.md, but never runs them through the markdown-link resolver. Any relative link
inside an instruction body — in particular a link to a .apm/context/*.context.md fragment —
is emitted byte-for-byte as written in the source file, with no adjustment for the fact that
CLAUDE.md may live in a different directory than the source instruction (e.g. when the
instruction comes from a dependency, or from a non-root placement).

The equivalent AGENTS.md pipeline (agents_compiler.py / distributed_compiler.py) does not
have this problem: it explicitly calls the link resolver
(UnifiedLinkResolver.resolve_links_for_compilation in distributed_compiler.py, or
resolve_markdown_links in agents_compiler.py) before returning the generated content.
claude_formatter.py has no equivalent call anywhere in the file — grepping the module for
resolve_links / link_resolver returns zero matches.

Practical effect: an instruction with no applyTo (global, e.g. a "signpost" file whose only
job is a short pointer to a .context.md file with the real content) will fold correctly into
AGENTS.md with a correctly rewritten link, but the same instruction, when it ends up folded
into CLAUDE.md, keeps the original unadjusted relative link and the link no longer resolves
from CLAUDE.md's location.

To Reproduce

  1. Create a package with one instruction and one context fragment it links to:

    my-pkg/
      apm.yml
      .apm/
        instructions/
          signpost.instructions.md
        context/
          conventions.context.md
    

    apm.yml:

    name: my-pkg
    version: 0.0.1
    targets: [claude]
    dependencies:
      apm: []

    .apm/instructions/signpost.instructions.md (no applyTo — global instruction):

    ---
    description: Signpost
    ---
    
    See [conventions](../context/conventions.context.md) for details.

    .apm/context/conventions.context.md:

    Real content lives here.
  2. Compile, forcing the instructions section into CLAUDE.md even though a
    .claude/rules/ directory may already exist (--force-instructions is the fastest way to
    force the code path; the same missing resolution also affects the natural, non-forced path
    any time .claude/rules/ is not yet populated, e.g. first apm compile run before
    apm install, or apm compile --root <scratch>):

    apm install --target claude
    apm compile --target claude --force-instructions
  3. Inspect CLAUDE.md:

    ## Project Standards
    
    ### Global Instructions
    
    See [conventions](../context/conventions.context.md) for details.

    The link ../context/conventions.context.md is relative to
    .apm/instructions/signpost.instructions.md's own directory (where it was authored), not to
    CLAUDE.md's directory (project root). Resolved from CLAUDE.md's location it points one
    directory above the project root and does not exist.

  4. Compile the same package for opencode (or any other AGENTS.md-based target) instead:

    apm compile --target opencode

    AGENTS.md contains the same link, correctly rewritten to
    .apm/context/conventions.context.md (relative to the project root) — because
    agents_compiler.py / distributed_compiler.py do call the link resolver.

Expected behavior

CLAUDE.md should resolve the same relative links to .context.md / .memory.md fragments
that AGENTS.md already resolves correctly, using the same anchor (the compiled output file's
own directory), regardless of whether the instruction section is emitted via the normal path or
via --force-instructions.

Environment:

  • OS: Linux
  • Python Version: 3.13
  • APM Version: 0.30.0 (commit 8c2e0d9c, tag v0.30.0)

Logs

No error is raised; the bug is a silently-wrong output (broken relative link), not a crash.

Additional context

Root cause, confirmed by reading the source at v0.30.0:

  • src/apm_cli/compilation/claude_formatter.py builds the CLAUDE.md body in
    _generate_claude_content() and returns "\n".join(sections) directly — the file contains
    zero references to resolve_links, resolve_markdown_links, link_resolver, or
    UnifiedLinkResolver anywhere.
  • Contrast with src/apm_cli/compilation/distributed_compiler.py:754, which — for the exact
    same kind of assembled AGENTS.md section content — explicitly calls:
    content = self.link_resolver.resolve_links_for_compilation(
        content=content,
        source_file=placement.agents_path.parent,
        compiled_output=placement.agents_path,
    )
    right before returning, using a UnifiedLinkResolver instance that had register_contexts()
    called on it earlier in the same compile pass.
  • src/apm_cli/compilation/agents_compiler.py additionally calls the (simpler,
    content-inlining) resolve_markdown_links() helper from link_resolver.py in its own
    non-distributed rendering path (generate_output(), lines ~1467 and ~1658).
  • claude_formatter.py has no equivalent of either call.

I verified the fix locally by adding the missing call, mirroring the distributed_compiler.py
pattern:

  1. In ClaudeFormatter.__init__, construct a resolver once: self.link_resolver = UnifiedLinkResolver(self.source_dir).
  2. In ClaudeFormatter.format_distributed, before generating any placement content, call
    self.link_resolver.register_contexts(primitives).
  3. In ClaudeFormatter._generate_claude_content, right before return, replace
    return "\n".join(sections) with:
    content = "\n".join(sections)
    content = self.link_resolver.resolve_links_for_compilation(
        content=content,
        source_file=placement.claude_path.parent,
        compiled_output=placement.claude_path,
    )
    return content

With this change, the reproduction above produces the correctly-resolved
.apm/context/conventions.context.md link in CLAUDE.md, identical to what AGENTS.md
already produces for the same source instruction. I'm happy to open a PR with this patch (plus
a regression test) if that's useful — flagging as an issue first in case there's a preferred
approach or a reason this was intentionally left out that I'm not aware of.

Separately (not part of this report, noting for completeness): I checked whether other
per-target root-context formatters have the same gap. gemini_formatter.py also has no
resolve_links call, but it turns out to be a 12-line stub that only ever emits a single
@./AGENTS.md import line and never assembles instruction content itself, so it isn't affected.
No other target has its own root-context formatter (cursor doesn't produce one; copilot,
codex, opencode, windsurf, antigravity all share the correctly-resolving AGENTS.md
pipeline).

Activity

  1. github-actions commented on Sep 9, 2026

    @github-actions

    Triage decision

    accept

    Proposed labels

    theme/portability
    area/multi-target
    type/bug
    status/accepted
    

    Milestone

    null -- open-milestone read failed (401 from milestones API); no milestone assigned this run.

    Suggested next action

    Call the existing markdown-link resolver (UnifiedLinkResolver.resolve_links_for_compilation or resolve_markdown_links) from claude_formatter.py before returning generated content, matching the AGENTS.md pipeline's existing behavior.

    Suggested issue comment

    Clear, well-localized gap -- claude_formatter.py has no equivalent link-resolution call that the AGENTS.md pipeline already has. Accepting as a bug: relative links (especially to .apm/context/*.context.md fragments) must resolve correctly when folded into CLAUDE.md regardless of the source instruction's original directory. No milestone assigned yet (milestone read failed this run).

    Per-lens notes (collapsed)

    DevX UX Expert -- User-Need Reviewer

    Broken relative links inside compiled CLAUDE.md content directly undermines the multi-target compile promise -- the same instruction source should compile correctly for every target, and Claude's formatter is the one falling short here.

    Supply Chain Security Expert -- Risk-Surface Reviewer

    No risk-surface implication; this is a content-correctness gap in link rewriting, not a trust boundary.

    OSS Growth Hacker -- Contributor-Tone Reviewer

    Not activated -- pavelvodrazka-etnetera has 3 prior issues on this repo and the report is a precise code-level diagnosis (grep evidence of the missing call), indicating an experienced contributor.

    Python Architect -- Architecture Reviewer

    Not activated -- the fix is a single missing call to an existing, already-used resolver function; no new interface or cross-cutting design is required.

    Doc Writer -- Documentation Reviewer

    Not activated -- no user-facing documentation change implied; this is an internal formatter parity fix.

    APM CEO -- Triage Arbiter

    Precise root cause with an existing precedent (AGENTS.md pipeline) to match. Accept as type/bug, theme/portability, area/multi-target.

    {
      "decision": "accept",
      "decision_detail": "",
      "theme": "theme/portability",
      "areas": ["area/multi-target"],
      "type": "type/bug",
      "status": "status/accepted",
      "priority": null,
      "preserved_labels": [],
      "milestone": null,
      "next_action": "Call the existing markdown-link resolver (UnifiedLinkResolver.resolve_links_for_compilation or resolve_markdown_links) from claude_formatter.py before returning generated content, matching the AGENTS.md pipeline's existing behavior.",
      "comment_markdown": "Clear, well-localized gap -- claude_formatter.py has no equivalent link-resolution call that the AGENTS.md pipeline already has. Accepting as a bug: relative links (especially to .apm/context/*.context.md fragments) must resolve correctly when folded into CLAUDE.md regardless of the source instruction's original directory. No milestone assigned yet (milestone read failed this run)."
    }

    Triage status: agentic proposal pending human ratification.
    Silence is approval. Maintainers can:

    • Override any label or milestone above by editing it directly --
      human edits are authoritative and will not be reverted on
      subsequent runs.
    • Re-trigger triage by applying the status/needs-triage label, or
      by removing status/triaged to enroll the issue in the next
      daily sweep.

    Posted by the Triage Panel workflow. See packages/apm-triage-panel for the panel skill.

    Generated by Triage Panel · copilot · auto · 165.9 AIC · ⌖ 21.2 AIC · ⊞ 14.1K · ◷

  2. added
    theme/portabilityOne manifest, every target. Multi-target deploy, marketplace, packaging, install.
    area/multi-targetMulti-target deploy spec, target directory creation, agent surface routing.
    status/acceptedHuman scope approval; verify the issue's approval record and review contact before work.
    on Sep 9, 2026
  3. github-actions commented on Sep 10, 2026

    @github-actions

    Triage decision

    accept

    Proposed labels

    theme/portability
    area/multi-target
    type/bug
    status/accepted
    

    Milestone

    null

    Suggested next action

    Call the existing markdown-link resolver (UnifiedLinkResolver.resolve_links_for_compilation or resolve_markdown_links) from claude_formatter.py before folding instruction bodies into CLAUDE.md, matching the AGENTS.md pipeline's existing behavior.

    Suggested issue comment

    Confirmed via code inspection -- `claude_formatter.py` has no call to the link resolver that `agents_compiler.py`/`distributed_compiler.py` already use, so relative links inside folded instructions (e.g. to `.apm/context/*.context.md` fragments) are emitted unadjusted and break once `CLAUDE.md` lives in a different directory than the source. This is accepted; the fix is to call the existing resolver from `claude_formatter.py` before returning generated content, mirroring the AGENTS.md pipeline exactly.

    Per-lens notes (collapsed)

    DevX UX Expert -- User-Need Reviewer

    Real, well-diagnosed inconsistency: the exact same instruction resolves correctly when compiled to AGENTS.md but breaks when compiled to CLAUDE.md, purely because one pipeline calls the link resolver and the other doesn't. This directly undermines the "signpost + context fragment" authoring pattern for Claude users.

    Supply Chain Security Expert -- Risk-Surface Reviewer

    No risk-surface implication -- this is a link-resolution correctness bug in the compile pipeline, not a trust or lockfile boundary.

    OSS Growth Hacker -- Contributor-Tone Reviewer

    Not activated -- author_association is NONE but the report pinpoints the exact missing function call via grep evidence; standard precise, technical tone is appropriate.

    Python Architect -- Architecture Reviewer

    Not activated -- this is a small, well-isolated fix (call an existing resolver function that another formatter already uses) rather than a new interface or cross-cutting design decision.

    Doc Writer -- Documentation Reviewer

    Not activated -- internal compile-pipeline parity fix; no new user-facing doc surface implied.

    APM CEO -- Triage Arbiter

    Precisely diagnosed pipeline-parity bug with a known-good pattern to copy from the sibling AGENTS.md compiler. Accepting as theme/portability, area/multi-target, type/bug.

    {
      "decision": "accept",
      "decision_detail": "",
      "theme": "theme/portability",
      "areas": ["area/multi-target"],
      "type": "type/bug",
      "status": "status/accepted",
      "priority": null,
      "preserved_labels": [],
      "milestone": null,
      "next_action": "Call the existing markdown-link resolver from claude_formatter.py before folding instruction bodies into CLAUDE.md, matching the AGENTS.md pipeline's existing behavior.",
      "comment_markdown": "Confirmed via code inspection -- claude_formatter.py has no call to the link resolver that agents_compiler.py/distributed_compiler.py already use, so relative links inside folded instructions are emitted unadjusted and break once CLAUDE.md lives in a different directory than the source. This is accepted; the fix is to call the existing resolver from claude_formatter.py before returning generated content, mirroring the AGENTS.md pipeline exactly."
    }

    Triage status: agentic proposal pending human ratification.
    Silence is approval. Maintainers can:

    • Override any label or milestone above by editing it directly -- human edits are authoritative and will not be reverted on subsequent runs.
    • Re-trigger triage by applying the status/needs-triage label, or by removing status/triaged to enroll the issue in the next daily sweep.

    Posted by the Triage Panel workflow. See packages/apm-triage-panel for the panel skill.

    Generated by Triage Panel · copilot · auto · 76.9 AIC · ⌖ 22.5 AIC · ⊞ 14.1K · ◷

  4. added
    status/triagedAutomated advice completed; deduplication only. Not human approval; silence is not approval.
    on Sep 10, 2026
  5. danielmeppiel commented on Sep 12, 2026

    @danielmeppiel
    Collaborator

    At the lead maintainer's direction, the current acceptance of this item is withdrawn as part of APM's roadmap and contribution reset. Previous acceptance labels or comments must not be treated as approval to start or continue substantive implementation for inclusion in APM.

    Thank you for the work and ideas already contributed. Our small maintainer team needs to agree supported scope and review capacity before inviting more implementation. This is not a rejection of the proposal or a judgment of your work, and it does not undo completed work or erase its history.

    Please follow the contribution rules: a responsible human maintainer needs to record the bounded scope, acceptance criteria and review contact on the issue before substantive work resumes. Existing issue discussion, assignments and contributions remain available for that decision. Trivial typo and broken-link corrections retain their standing preapproval.

  6. added
    triage/recommendedAutomated advice completed; not human scope approval.
    and removed
    status/triagedAutomated advice completed; deduplication only. Not human approval; silence is not approval.
    on Sep 12, 2026
  7. danielmeppiel commented on Sep 24, 2026

    @danielmeppiel
    Collaborator

    Decision: approve
    Area: project
    Scope: Approve the bounded Claude compilation link-resolution repair described here, replacing the September 12 withdrawal for this work only. Reuse the existing link resolver so relative context and memory links in instruction bodies folded into CLAUDE.md resolve to their intended files from each generated output location, including local and dependency-provided instructions under normal compilation and forced instruction folding.
    Done when: Regression coverage proves the affected links remain correct for local and dependency instructions, global and applyTo-scoped folding, and normal and forced instruction emission; repeated compilation does not reuse stale context locations; existing resolver containment and link-handling boundaries, unrelated content and other targets are preserved; directly affected user guidance remains accurate.
    Out of scope: New link formats or arbitrary file access; relaxation of existing resolver or security boundaries; a compiler redesign; changes to unrelated target behavior; broader symlink support or resource deployment changes.
    Review contact: Daniel Meppiel (@danielmeppiel)

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    area/multi-targetMulti-target deploy spec, target directory creation, agent surface routing.status/acceptedHuman scope approval; verify the issue's approval record and review contact before work.theme/portabilityOne manifest, every target. Multi-target deploy, marketplace, packaging, install.triage/recommendedAutomated advice completed; not human scope approval.type/bugSomething does not work as documented.

    Type

    No type

    Projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions