Skip to content

Commit 24be746

Browse files
fix(compile): honor compilation.strategy=single-file for CLAUDE.md (closes #1445) (#1514)
* fix(compile): honor compilation.strategy=single-file for CLAUDE.md _compile_claude_md ignored config.strategy and config.single_agents, unconditionally building a DistributedAgentsCompiler placement map and emitting per-subdirectory CLAUDE.md files. This made compilation.strategy single-file silently no-op for the Claude target while AGENTS.md correctly collapsed to a single root file. Mirror the gate from _compile_agents_md: when strategy != distributed or single_agents is True, collapse the placement_map to {base_dir: all instructions} so ClaudeFormatter emits only the root CLAUDE.md. Distributed behavior is preserved for the default path. Closes #1445 Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * fix(compile): address wave-1 advisory follow-ups for #1514 Folds the convergent CHANGELOG ask, the python-architect placement nit, the cli-logging single-file success-log nit, the devx-ux doc wording nit, and the test-coverage parametrization nit from the apm-review-panel CEO ship_with_followups verdict on PR #1514. - CHANGELOG.md: add Unreleased/Fixed bullet for #1445 (closes the doc-writer + oss-growth-hacker convergence; required by .apm/instructions/changelog.instructions.md). - src/apm_cli/compilation/agents_compiler.py: move DistributedAgentsCompiler import and instantiation into the distributed else-branch so single-file mode does not construct an unused analyzer; guard the later display block on distributed_compiler is not None. Add a minimal success progress log on the single-file CLAUDE.md path so users get a confirmation that single-file strategy took effect (mirrors the display gap the panel flagged on this new code path). - docs/src/content/docs/reference/cli/compile.md: replace the AGENTS.md-centric wording of --single-agents with target-neutral phrasing now that the flag truly applies to CLAUDE.md too. - tests/unit/compilation/test_compile_target_flag.py: collapse the two single-file positive-case tests into one parametrized test (ids=strategy-single-file, single-agents-flag). Mutation-break gate confirmed: replacing the gate with 'if False' makes both parametrized cases fail; restoring the gate makes them pass. Full tests/unit/compilation/ suite: 1047 passed. Lint chain (ruff check, ruff format --check, pylint R0801, auth-signals) all silent. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * docs+log: address iteration-2 advisory nits for #1514 Folds the five small wording-and-drift nits surfaced by the iteration-2 apm-review-panel pass on PR #1514: - src/apm_cli/compilation/agents_compiler.py: drop the ', single-file strategy' qualifier from the single-file CLAUDE.md success log so the message stays user-facing (cli-logging nit). - CHANGELOG.md: rephrase the trailing maintainer-internal phrase ('Restores multi-harness parity with the AGENTS.md gate.') into a user-outcome sentence (oss-growth nit). - docs/src/content/docs/reference/cli/compile.md: drop the explicit '(AGENTS.md, CLAUDE.md)' parenthetical so the help row does not need updating when a future target adopts distributed placement (devx-ux nit). - docs/src/content/docs/reference/manifest-schema.md: update the 'strategy' row description to target-neutral wording now that the distributed branch genuinely produces per-directory files for both AGENTS.md and CLAUDE.md (doc-writer nit; doc drift caused by #1514). - docs/src/content/docs/producer/compile.md: update the --dry-run description on the same target-neutral axis (doc-writer nit; doc drift caused by #1514). Full tests/unit/compilation/ suite: 1047 passed. Lint chain (ruff check, ruff format --check, pylint R0801, auth-signals): silent. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * docs: target-neutral wording for Strategy modes prose (#1514) Folds the iteration-3 doc-writer nit on cli/compile.md:205 -- the 'Distributed (default)' bullet still said 'a tree of focused AGENTS.md files', the same target-naming drift class as the manifest-schema and producer/compile.md rows already folded. Mirrors the wording across the three pages so the single source of truth on the strategy/target axis stays consistent now that the distributed branch genuinely produces per-directory CLAUDE.md files too (PR scope). Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --------- Co-authored-by: danielmeppiel <danielmeppiel@users.noreply.github.com> Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
1 parent e3950be commit 24be746

6 files changed

Lines changed: 131 additions & 23 deletions

File tree

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
2424

2525
### Fixed
2626

27+
- `apm compile --target claude` (and the `claude_md` target generally) now honors `compilation.strategy: single-file` and `--single-agents`, collapsing into a single root `CLAUDE.md` instead of silently emitting per-subdirectory `CLAUDE.md` files. Single-file compilation now behaves consistently across all supported targets. (closes #1445) (#1514)
2728
- `apm install git@gitlab.com:owner/repo.git#ref` now succeeds for users with an SSH key and no `GITLAB_APM_PAT` / `GITLAB_TOKEN`. The validator previously ignored the explicit SSH transport on GitLab refs and demanded an HTTPS-token probe, which raised `Authentication failed for gitlab.com / No token available` even though the matching `dependencies.apm` entry in `apm.yml` installed cleanly via SSH. The validation path now mirrors the clone path (and the existing generic-host explicit-ssh arm) and honors `APM_ALLOW_PROTOCOL_FALLBACK=1` with SSH-first ordering. GitLab SSH-key users get the same frictionless install experience GitHub SSH users already had, matching `apm.yml` end-to-end. (closes #1501)
2829
- `apm install -g` now correctly integrates hook JSON files authored in the "naked" Claude settings-slice format (event names at top-level, no outer `hooks:` wrap) into `.claude/settings.json`, `.cursor/hooks.json`, and the copilot per-event layout. Previously the file parsed cleanly but produced an empty merge while the user-facing summary still reported `1 hook(s) integrated`. The integrated-hook counter now only increments for files that actually contributed entries, malformed shapes where `hooks` is not a dict fail closed with a warning, and files that contribute zero entries log a warning instead of silently skipping. (closes #1499) (#1516)
2930
- `apm install` against a registry proxy now works for GitLab nested-group repos (3+ path segments, e.g. `group/subgroup/project`). Previously the proxy resolver guessed `owner/repo` from the first two path segments and treated the rest as an in-repo virtual sub-path, so the downloader requested the wrong archive URL and the install failed with HTTP 404. The new install-time probe HEAD-walks candidate splits against the proxy and locks in the first one whose archive responds, so nested shorthand (`apm install <host>/artifactory/<key>/<group>/<subgroup>/<project>` or the bare-shorthand form under `PROXY_REGISTRY_URL` + `PROXY_REGISTRY_ONLY=1`) just works. The probe distinguishes auth (401/403) from missing-repo (4xx) so a misconfigured token surfaces as an auth problem instead of a "missing repo", and runs with `allow_redirects=False` so the bearer token cannot follow a redirect off the proxy host. When the proxy is unreachable, the `//` notation can mark the repo/virtual boundary explicitly as an escape hatch. (#1472)

docs/src/content/docs/producer/compile.md

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -65,8 +65,9 @@ apm compile --dry-run # print placement decisions without writing fil
6565
```
6666

6767
`--validate` is the fastest signal that an instruction parses.
68-
`--dry-run` shows you exactly which AGENTS.md tree would be written
69-
where. `--watch` is the tight inner loop while you edit prose.
68+
`--dry-run` shows you exactly which root-context tree (`AGENTS.md`,
69+
`CLAUDE.md`, ...) would be written where. `--watch` is the tight inner
70+
loop while you edit prose.
7071

7172
To preview a script that wraps a `.prompt.md` file, use
7273
[`apm preview`](../preview-and-validate/) instead. `apm compile` builds

docs/src/content/docs/reference/cli/compile.md

Lines changed: 5 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -75,7 +75,7 @@ include it in `--target` lists when you also want shared
7575
| Flag | Description |
7676
|------|-------------|
7777
| `-o, --output PATH` | Output file path. Only applies in single-file mode (`--single-agents`). Default: `AGENTS.md`. |
78-
| `--single-agents` | Force single-file compilation (legacy). Writes one combined file at `--output` instead of distributed AGENTS.md tree. |
78+
| `--single-agents` | Force single-file compilation (legacy). Writes one combined file at `--output` instead of a distributed per-directory target-file tree. Applies to every target that uses distributed placement. |
7979
| `--clean` | Remove orphaned AGENTS.md files no longer produced by the current primitive set. |
8080

8181
### Content
@@ -202,9 +202,10 @@ file) and re-run `apm compile`.
202202

203203
There is no `--strategy` flag. Compilation runs in one of two modes:
204204

205-
- **Distributed (default)** -- writes a tree of focused AGENTS.md files
206-
next to the code they apply to, plus per-target subdirectories. This
207-
is the recommended mode and follows the Minimal Context Principle.
205+
- **Distributed (default)** -- writes a tree of focused target files
206+
(e.g. `AGENTS.md`, `CLAUDE.md`) next to the code they apply to, plus
207+
per-target subdirectories. This is the recommended mode and follows
208+
the Minimal Context Principle.
208209
- **Single-file (`--single-agents`)** -- writes one combined file at
209210
`--output` (default `AGENTS.md`). Use when a harness or workflow
210211
requires a single context file.

docs/src/content/docs/reference/manifest-schema.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -574,7 +574,7 @@ The `compilation` key is OPTIONAL. It controls [`apm compile`](../cli/compile/)
574574
| Field | Type | Default | Constraint | Description |
575575
|---|---|---|---|---|
576576
| `target` | `enum<string>` | `all` | Same values as Section 3.6 | Output target. Defaults to `all` when set explicitly in compilation config. |
577-
| `strategy` | `enum<string>` | `distributed` | `distributed`, `single-file` | `distributed` generates per-directory `AGENTS.md` files. `single-file` generates one monolithic file. |
577+
| `strategy` | `enum<string>` | `distributed` | `distributed`, `single-file` | `distributed` generates per-directory target files (e.g. `AGENTS.md`, `CLAUDE.md`). `single-file` generates one monolithic file at `output`. |
578578
| `single_file` | `bool` | `false` | | Legacy alias. When `true`, overrides `strategy` to `single-file`. |
579579
| `output` | `string` | `AGENTS.md` | File path | Custom output path for the compiled file. |
580580
| `chatmode` | `string` | unset | | Chatmode filter for compilation. |

src/apm_cli/compilation/agents_compiler.py

Lines changed: 41 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -536,21 +536,34 @@ def _compile_claude_md(
536536
# Create Claude formatter
537537
claude_formatter = ClaudeFormatter(str(self.base_dir))
538538

539-
# Get placement map from distributed compiler for consistency
540-
from .distributed_compiler import DistributedAgentsCompiler
541-
542-
distributed_compiler = DistributedAgentsCompiler(
543-
str(self.base_dir), exclude_patterns=config.exclude
544-
)
539+
# Honor compilation.strategy=single-file (and the --single-agents flag)
540+
# by collapsing all instructions into a single root CLAUDE.md, mirroring
541+
# the gate in _compile_agents_md. Without this, single-file mode is
542+
# silently ignored for the Claude target and per-subdirectory CLAUDE.md
543+
# files are emitted via the distributed placement path (issue #1445).
544+
#
545+
# DistributedAgentsCompiler is only constructed on the distributed
546+
# branch -- single-file mode does not use its placement analysis and
547+
# the later display block guards on `distributed_compiler is not None`.
548+
distributed_compiler = None
549+
if config.strategy != "distributed" or config.single_agents:
550+
placement_map = {self.base_dir: list(primitives.instructions)}
551+
else:
552+
from .distributed_compiler import DistributedAgentsCompiler
545553

546-
# Analyze directory structure and determine placement
547-
directory_map = distributed_compiler.analyze_directory_structure(primitives.instructions)
548-
placement_map = distributed_compiler.determine_agents_placement(
549-
primitives.instructions,
550-
directory_map,
551-
min_instructions=config.min_instructions_per_file,
552-
debug=config.debug,
553-
)
554+
distributed_compiler = DistributedAgentsCompiler(
555+
str(self.base_dir), exclude_patterns=config.exclude
556+
)
557+
# Analyze directory structure and determine placement
558+
directory_map = distributed_compiler.analyze_directory_structure(
559+
primitives.instructions
560+
)
561+
placement_map = distributed_compiler.determine_agents_placement(
562+
primitives.instructions,
563+
directory_map,
564+
min_instructions=config.min_instructions_per_file,
565+
debug=config.debug,
566+
)
554567

555568
# Skip instructions in CLAUDE.md when they are already deployed to
556569
# .claude/rules/ by `apm install` (avoids duplicate context in Claude Code).
@@ -676,6 +689,16 @@ def _compile_claude_md(
676689
" no further action needed",
677690
symbol="info",
678691
)
692+
elif distributed_compiler is None and files_written > 0 and not config.dry_run:
693+
# Single-file strategy bypasses the distributed display formatter
694+
# (which has no analysis to render). Emit a minimal progress line
695+
# so users get a confirmation that single-file mode took effect.
696+
noun = "file" if files_written == 1 else "files"
697+
self._log(
698+
"progress",
699+
f"CLAUDE.md compiled ({files_written} {noun})",
700+
symbol="success",
701+
)
679702

680703
# Display CLAUDE.md compilation output using standard formatter
681704
# Get proper compilation results from distributed compiler (has optimization decisions)
@@ -684,8 +707,10 @@ def _compile_claude_md(
684707
from ..output.formatters import CompilationFormatter
685708
from ..output.models import CompilationResults
686709

687-
compilation_results = distributed_compiler.get_compilation_results_for_display(
688-
is_dry_run=config.dry_run
710+
compilation_results = (
711+
distributed_compiler.get_compilation_results_for_display(is_dry_run=config.dry_run)
712+
if distributed_compiler is not None
713+
else None
689714
)
690715
if compilation_results and not (skip_instructions and files_written == 0):
691716
# Update target name for CLAUDE.md output

tests/unit/compilation/test_compile_target_flag.py

Lines changed: 80 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1750,3 +1750,83 @@ def test_config_multi_target_log_message_does_not_say_unknown(self, runner, empt
17501750
assert "AGENTS.md" in result.output and "CLAUDE.md" in result.output
17511751
finally:
17521752
os.chdir(original_dir)
1753+
1754+
1755+
class TestClaudeMdHonorsSingleFileStrategy:
1756+
"""Regression for issue #1445.
1757+
1758+
compilation.strategy=single-file (or single_agents=True) must produce a
1759+
single root CLAUDE.md, mirroring the AGENTS.md behavior. Previously
1760+
_compile_claude_md ignored the strategy gate and always built a
1761+
per-subdirectory placement map, emitting e.g. scripts/CLAUDE.md.
1762+
"""
1763+
1764+
@pytest.fixture
1765+
def project_with_subdir_instruction(self):
1766+
temp_dir = tempfile.mkdtemp()
1767+
temp_path = Path(temp_dir)
1768+
1769+
(temp_path / "apm.yml").write_text("name: test-project\nversion: 0.1.0\n")
1770+
1771+
apm_dir = temp_path / ".apm" / "instructions"
1772+
apm_dir.mkdir(parents=True)
1773+
(apm_dir / "shell.instructions.md").write_text(
1774+
"---\napplyTo: '**/*.sh'\n---\nShell scripts must set -euo pipefail.\n"
1775+
)
1776+
1777+
scripts_dir = temp_path / "scripts"
1778+
scripts_dir.mkdir()
1779+
(scripts_dir / "build.sh").write_text("#!/bin/bash\necho hi\n")
1780+
1781+
yield temp_path
1782+
shutil.rmtree(temp_dir, ignore_errors=True)
1783+
1784+
@pytest.mark.parametrize(
1785+
"config_kwargs",
1786+
[
1787+
{"strategy": "single-file"},
1788+
{"single_agents": True},
1789+
],
1790+
ids=["strategy-single-file", "single-agents-flag"],
1791+
)
1792+
def test_single_file_mode_emits_only_root_claude_md(
1793+
self, project_with_subdir_instruction, config_kwargs
1794+
):
1795+
# Both strategy='single-file' and single_agents=True must collapse the
1796+
# CLAUDE.md placement map to a single root file; per-subdir CLAUDE.md
1797+
# files must not leak when single-file mode is requested via either
1798+
# surface.
1799+
config = CompilationConfig(
1800+
target="claude",
1801+
dry_run=False,
1802+
with_constitution=False,
1803+
**config_kwargs,
1804+
)
1805+
compiler = AgentsCompiler(str(project_with_subdir_instruction))
1806+
result = compiler.compile(config)
1807+
1808+
assert result.success, f"compile failed: {result.errors}"
1809+
root_claude = project_with_subdir_instruction / "CLAUDE.md"
1810+
subdir_claude = project_with_subdir_instruction / "scripts" / "CLAUDE.md"
1811+
assert root_claude.exists(), "root CLAUDE.md should be generated"
1812+
assert not subdir_claude.exists(), (
1813+
"scripts/CLAUDE.md must NOT be generated in single-file mode"
1814+
)
1815+
1816+
def test_distributed_strategy_unchanged_behavior(self, project_with_subdir_instruction):
1817+
# Default distributed strategy must still place per-subdirectory.
1818+
config = CompilationConfig(
1819+
target="claude",
1820+
dry_run=False,
1821+
with_constitution=False,
1822+
)
1823+
compiler = AgentsCompiler(str(project_with_subdir_instruction))
1824+
result = compiler.compile(config)
1825+
1826+
assert result.success, f"compile failed: {result.errors}"
1827+
# At least the root CLAUDE.md should exist; distributed may also emit
1828+
# scripts/CLAUDE.md depending on placement -- we don't assert on that.
1829+
# The point is this test documents that distributed behavior is intact.
1830+
assert (project_with_subdir_instruction / "CLAUDE.md").exists() or (
1831+
project_with_subdir_instruction / "scripts" / "CLAUDE.md"
1832+
).exists()

0 commit comments

Comments
 (0)