Skip to content

fix(compile): forward target to watch-mode recompile (closes #1345)#1349

Open
danielmeppiel wants to merge 1 commit into
mainfrom
fix/1345-watch-target-resolution
Open

fix(compile): forward target to watch-mode recompile (closes #1345)#1349
danielmeppiel wants to merge 1 commit into
mainfrom
fix/1345-watch-target-resolution

Conversation

@danielmeppiel
Copy link
Copy Markdown
Collaborator

TL;DR

apm compile --watch silently regenerated GEMINI.md even when apm.yml declared targets: [claude, cursor]. The watch path bypassed the target-resolver the one-shot path uses, so every recompile fell back to the all-families fanout. This PR factors the resolver into a shared helper, hoists it above the if watch: branch, and forwards target= into every CompilationConfig.from_apm_yml(...) call the watcher makes. Adds a 3-test regression suite that pins the contract via an outcome assertion (should_compile_gemini_md is False for the captured target). Closes #1345.

Problem (WHY)

From #1345:

apm compile --watch regenerates GEMINI.md even when apm.yml targets is [claude, cursor]. The non-watch path correctly omits GEMINI.md (#1019/#1074 fix). The watch path regresses because:

  1. src/apm_cli/commands/compile/cli.py line ~457-458 calls _watch_mode(output, chatmode, no_links, dry_run, verbose=verbose) -- does not pass target or config_target.
  2. src/apm_cli/commands/compile/watcher.py does not accept target/config_target and never passes a target= to CompilationConfig.from_apm_yml(...). Without target=, defaults expand to all families.

The one-shot path computes an effective_target (lines 506-531 of cli.py) by resolving the CLI flag, then the apm.yml target: / targets: value, then auto-detection. The watch path skipped this entire block, so users on a Claude+Cursor stack saw GEMINI.md reappear on every save.

Approach (WHAT)

Two narrow surface changes inside src/apm_cli/commands/compile/:

  1. cli.py -- Extract _resolve_effective_target(target) that returns (effective_target, detection_reason, config_target). The watch branch calls it before delegating to _watch_mode and forwards the resolved value plus the user-facing labels.
  2. watcher.py -- Promote APMFileHandler to module scope (so unit tests can instantiate it without spinning up watchdog Observer). Accept effective_target in __init__ and forward it as target= into both the initial-compile and per-edit-recompile calls to CompilationConfig.from_apm_yml(...). Render a one-shot-parity "Compiling for AGENTS.md + CLAUDE.md (apm.yml target: [claude, cursor])" progress line so users see the same string in watch mode.

The one-shot inline resolution at lines 463-531 of cli.py is left untouched on purpose: identical behavior, single round-trip with the helper in the watch branch only. Less restructuring, fully reversible.

Implementation (HOW)

sequenceDiagram
    participant User
    participant CLI as cli.py (compile)
    participant Resolver as _resolve_effective_target
    participant Watcher as _watch_mode
    participant Handler as APMFileHandler
    participant Config as CompilationConfig.from_apm_yml
    participant Compiler as AgentsCompiler

    User->>CLI: apm compile --watch (apm.yml targets: [claude, cursor])
    CLI->>Resolver: target=None, reads apm.yml
    Resolver-->>CLI: effective_target=frozenset({"claude","agents"})
    CLI->>Watcher: _watch_mode(..., effective_target, target_label_config=[claude,cursor])
    Watcher->>Handler: APMFileHandler(..., effective_target)
    Watcher->>Config: from_apm_yml(target=effective_target, ...)
    Config-->>Watcher: config (target=frozenset)
    Watcher->>Compiler: compile(config) -> CLAUDE.md + AGENTS.md (no GEMINI.md)

    Note over User,Handler: User edits .apm/instructions/foo.md
    Handler->>Handler: on_modified -> _recompile
    Handler->>Config: from_apm_yml(target=self.effective_target, ...)
    Config-->>Handler: config (target=frozenset, same as initial)
    Handler->>Compiler: compile(config) -> CLAUDE.md + AGENTS.md (still no GEMINI.md)
Loading

Files touched

  • src/apm_cli/commands/compile/cli.py -- new helper _resolve_effective_target(); watch branch hoisted to call it.
  • src/apm_cli/commands/compile/watcher.py -- module-scope APMFileHandler, new effective_target parameter, forwards target= into both from_apm_yml calls, family-aware progress label.
  • tests/unit/commands/compile/test_watch_target_forwarding.py -- 3 regression tests (frozenset target, None, single-string target) with an outcome assertion (should_compile_gemini_md(captured_target) is False).
  • CHANGELOG.md -- one line under ### Fixed.

Trade-offs

  • The watcher loads apm.yml once per session; the resolver runs once before the watch loop starts. Re-running it on every recompile would honor live apm.yml edits, but the existing on_modified filter watches apm.yml and the dataclass default-merge in from_apm_yml already picks up most config drift on each call. Adding live re-resolution is a follow-up if requested.
  • We do not refactor compilation/agents_compiler.py. The bug lives entirely in the watch-path call site.
  • A maintainer may want to refactor the inline one-shot resolution at lines 463-531 of cli.py to also call the new _resolve_effective_target helper for a true single-source-of-truth pass -- intentionally deferred here to keep the diff small.

Validation evidence

Lint (canonical contract per .github/instructions/linting.instructions.md):

$ uv run --extra dev ruff check src/ tests/ && uv run --extra dev ruff format --check src/ tests/
All checks passed!
766 files already formatted

Regression test suite (new):

$ uv run --extra dev pytest tests/unit/commands/compile/test_watch_target_forwarding.py -x -v
collected 3 items

tests/unit/commands/compile/test_watch_target_forwarding.py::test_recompile_forwards_frozenset_target PASSED [ 33%]
tests/unit/commands/compile/test_watch_target_forwarding.py::test_recompile_forwards_none_when_no_target_configured PASSED [ 66%]
tests/unit/commands/compile/test_watch_target_forwarding.py::test_recompile_forwards_single_string_target PASSED [100%]

============================== 3 passed in 0.47s ===============================

Broader sweep (no collateral damage):

$ uv run --extra dev pytest tests/ -k "compile or watch or target_detection or target_resolution" -x
========= 362 passed, 6 skipped, 9567 deselected, 2 xfailed in 17.34s ==========

How to test

Reproducing the original report:

  1. Create a fresh project directory and a minimal apm.yml declaring targets: [claude, cursor]:

    name: repro-1345
    version: 0.1.0
    targets: [claude, cursor]
  2. Add a primitive at .apm/instructions/foo.instructions.md with applyTo: "**".

  3. Run apm compile --watch in that directory.

  4. In another terminal, append a newline to .apm/instructions/foo.instructions.md to trigger a recompile.

  5. Stop the watcher (Ctrl-C).

  6. List the directory.

Expected after fix: AGENTS.md and CLAUDE.md present, GEMINI.md NOT present. Before the fix, GEMINI.md was regenerated on every recompile.

The new regression test asserts the same outcome programmatically:
uv run --extra dev pytest tests/unit/commands/compile/test_watch_target_forwarding.py -x -v

Ship recommendation (CEO synthesis)

Ship as-is. The fix is a minimal, reversible plumbing change scoped to two files in commands/compile/ plus a focused regression test that asserts the outcome (no GEMINI.md in the targets-claude-cursor case) rather than just the plumbing. The watch-mode UX now matches one-shot parity, including the family-aware "Compiling for AGENTS.md + CLAUDE.md (apm.yml target: [claude, cursor])" progress label. Deferred follow-up: collapsing the inline one-shot resolution into the new helper for a true single source of truth.

apm compile --watch silently regenerated GEMINI.md even when apm.yml
declared targets: [claude, cursor]. The non-watch path correctly omits
GEMINI.md (#1019/#1074) because it resolves the effective target and
forwards it as target= into CompilationConfig.from_apm_yml. The watch
path bypassed that resolution and let the dataclass default fall back
to the all-families fanout on every recompile.

Fix:
- Extract _resolve_effective_target() in commands/compile/cli.py so
  the resolution (apm.yml target/targets load + _resolve_compile_target
  + detect_target fallback) lives in one place.
- Hoist the call above the if watch: branch and pass effective_target
  (plus the user-facing target / config_target labels) into _watch_mode.
- Promote APMFileHandler to module scope, accept effective_target, and
  forward it as target= into both the initial compile and every
  debounced recompile.
- Surface the same family-aware 'Compiling for AGENTS.md + CLAUDE.md
  (...)' label the one-shot path emits, so users see parity.

Tests: tests/unit/commands/compile/test_watch_target_forwarding.py
pins the regression with an outcome assertion (should_compile_gemini_md
is False for the captured target) so the all-families fanout cannot
silently come back.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Copilot AI review requested due to automatic review settings May 15, 2026 14:44
Copy link
Copy Markdown
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Fixes a regression where apm compile --watch did not forward the resolved compile target into CompilationConfig.from_apm_yml(...), causing watch-mode recompiles to fan out to all compiler families and regenerate GEMINI.md even when the project’s configured targets excluded Gemini.

Changes:

  • Added a shared _resolve_effective_target(...) helper in the compile CLI and used it in the watch-mode branch to compute/forward the same effective target used by one-shot compile.
  • Refactored the watch implementation to accept and forward effective_target into both initial compile and debounced recompiles, plus added a target-aware “Compiling for …” progress label.
  • Added unit regression tests that capture the forwarded target= and assert the “no GEMINI.md” outcome for targets: [claude, cursor].
Show a summary per file
File Description
src/apm_cli/commands/compile/cli.py Adds _resolve_effective_target() and uses it to pass the resolved target + labels into watch mode.
src/apm_cli/commands/compile/watcher.py Promotes APMFileHandler for testability, threads effective_target through initial and incremental compiles, and adds a watch-mode target label formatter.
tests/unit/commands/compile/test_watch_target_forwarding.py Adds regression tests ensuring watch-mode recompiles forward target= and don’t imply Gemini output for claude+cursor.
CHANGELOG.md Documents the watch-mode target forwarding fix under “Fixed”.

Copilot's findings

  • Files reviewed: 4/5 changed files
  • Comments generated: 2

return target # single string pass-through


def _resolve_effective_target(target):
Comment on lines +10 to +15
def _format_target_label(effective_target, target_label_user, target_label_config):
"""Render a one-shot-parity 'Compiling for ...' label for the watch path.

Mirrors the family-aware label the one-shot compile path emits so the
user sees the same string in watch mode (#1345).
"""
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[BUG] apm compile --watch regenerates GEMINI.md when targets is [claude, cursor] — regression of #1019 in watch code path

2 participants