From 59c5136c9904e65650437a3f7227fca5528a5a52 Mon Sep 17 00:00:00 2001 From: WilliamK112 <164879897+WilliamK112@users.noreply.github.com> Date: Sat, 25 Apr 2026 01:37:00 -0500 Subject: [PATCH 1/4] fix(compile): emit and clean up copilot root instructions --- src/apm_cli/commands/compile/cli.py | 5 +- src/apm_cli/compilation/agents_compiler.py | 154 ++++++++++++++++- src/apm_cli/core/target_detection.py | 18 +- src/apm_cli/integration/targets.py | 9 + .../test_compile_copilot_root_instructions.py | 63 +++++++ .../compilation/test_compile_target_flag.py | 160 ++++++++++++++++++ tests/unit/core/test_target_detection.py | 20 +++ tests/unit/integration/test_targets.py | 4 + 8 files changed, 426 insertions(+), 7 deletions(-) create mode 100644 tests/integration/test_compile_copilot_root_instructions.py diff --git a/src/apm_cli/commands/compile/cli.py b/src/apm_cli/commands/compile/cli.py index e6fb5cace..3f8fb87c7 100644 --- a/src/apm_cli/commands/compile/cli.py +++ b/src/apm_cli/commands/compile/cli.py @@ -424,8 +424,9 @@ def compile( if isinstance(compile_config_target, str) else None, ) - # Map 'minimal' to 'vscode' for the compiler (AGENTS.md only, no folder integration) - effective_target = detected_target if detected_target != "minimal" else "vscode" + # Keep the detected target intact so the compiler can preserve + # minimal-mode semantics (AGENTS.md only, no .github side outputs). + effective_target = detected_target # Build config with distributed compilation flags (Task 7) config = CompilationConfig.from_apm_yml( diff --git a/src/apm_cli/compilation/agents_compiler.py b/src/apm_cli/compilation/agents_compiler.py index bdec805e2..44819130d 100644 --- a/src/apm_cli/compilation/agents_compiler.py +++ b/src/apm_cli/compilation/agents_compiler.py @@ -5,6 +5,7 @@ primitives & constitution are unchanged. """ +import hashlib import logging from dataclasses import dataclass from pathlib import Path @@ -14,6 +15,7 @@ CompileTargetType, should_compile_agents_md, should_compile_claude_md, + should_compile_copilot_instructions_md, should_compile_gemini_md, ) from ..primitives.discovery import discover_primitives @@ -21,6 +23,7 @@ from ..utils.paths import portable_relpath from ..version import get_version from .claude_formatter import ClaudeFormatter +from .constants import BUILD_ID_PLACEHOLDER from .link_resolver import resolve_markdown_links, validate_link_targets from .template_builder import ( TemplateData, @@ -45,6 +48,7 @@ "all", "minimal", ) + _VSCODE_TARGET_ALIASES +_COPILOT_ROOT_GENERATED_MARKER = "" # Compiler families allowed inside a multi-target frozenset (built by # _resolve_compile_target() from CLI-validated target names). Kept narrow @@ -336,10 +340,12 @@ def _compile_agents_md( """ # Handle distributed compilation (Task 7 - new default behavior) if config.strategy == "distributed" and not config.single_agents: - return self._compile_distributed(config, primitives) + result = self._compile_distributed(config, primitives) else: # Traditional single-file compilation (backward compatibility) - return self._compile_single_file(config, primitives) + result = self._compile_single_file(config, primitives) + + return self._maybe_emit_copilot_root_instructions(config, primitives, result) def _compile_distributed( self, config: CompilationConfig, primitives: PrimitiveCollection @@ -871,6 +877,150 @@ def _generate_template_data( chatmode_content=chatmode_content, ) + def _maybe_emit_copilot_root_instructions( + self, + config: CompilationConfig, + primitives: PrimitiveCollection, + result: CompilationResult, + ) -> CompilationResult: + """Generate .github/copilot-instructions.md for Copilot-capable targets.""" + routing_target = "vscode" if config.target in _VSCODE_TARGET_ALIASES else config.target + output_path = self.base_dir / ".github" / "copilot-instructions.md" + if not should_compile_copilot_instructions_md(routing_target): + if not config.dry_run: + self._cleanup_copilot_root_instructions(output_path, result) + result.stats.setdefault("copilot_root_instructions_generated", 0) + result.stats.setdefault("copilot_root_instructions_written", 0) + result.stats.setdefault("copilot_root_instructions_unchanged", 0) + result.stats.setdefault("copilot_root_instructions_removed", 0) + return result + + global_instructions = sorted( + [instruction for instruction in primitives.instructions if not instruction.apply_to], + key=lambda instruction: portable_relpath(instruction.file_path, self.base_dir), + ) + if not global_instructions: + if not config.dry_run: + self._cleanup_copilot_root_instructions(output_path, result) + result.stats.setdefault("copilot_root_instructions_generated", 0) + result.stats.setdefault("copilot_root_instructions_written", 0) + result.stats.setdefault("copilot_root_instructions_unchanged", 0) + result.stats.setdefault("copilot_root_instructions_removed", 0) + return result + + content = self._generate_copilot_root_instructions_content(global_instructions, config) + + result.stats["copilot_root_instructions_generated"] = 1 + result.stats.setdefault("copilot_root_instructions_removed", 0) + + if config.dry_run: + result.stats.setdefault("copilot_root_instructions_written", 0) + result.stats.setdefault("copilot_root_instructions_unchanged", 0) + return result + + from ..security.gate import WARN_POLICY, SecurityGate + + verdict = SecurityGate.scan_text(content, str(output_path), policy=WARN_POLICY) + actionable = verdict.critical_count + verdict.warning_count + if actionable: + if verdict.has_critical: + result.has_critical_security = True + result.warnings.append( + f"copilot-instructions.md contains {actionable} hidden character(s) " + f"-- run 'apm audit --file {output_path}' to inspect" + ) + + try: + output_path.parent.mkdir(parents=True, exist_ok=True) + existing = output_path.read_text(encoding="utf-8") if output_path.exists() else None + if existing == content: + result.stats["copilot_root_instructions_written"] = 0 + result.stats["copilot_root_instructions_unchanged"] = 1 + return result + + output_path.write_text(content, encoding="utf-8") + result.stats["copilot_root_instructions_written"] = 1 + result.stats["copilot_root_instructions_unchanged"] = 0 + return result + except OSError as exc: + message = f"Failed to write {output_path}: {exc}" + self.errors.append(message) + result.errors.append(message) + result.success = False + result.stats["copilot_root_instructions_written"] = 0 + result.stats.setdefault("copilot_root_instructions_unchanged", 0) + return result + + def _generate_copilot_root_instructions_content( + self, + instructions, + config: CompilationConfig, + ) -> str: + """Generate root Copilot instructions content from global instruction primitives.""" + sections = [ + _COPILOT_ROOT_GENERATED_MARKER, + BUILD_ID_PLACEHOLDER, + f"", + "", + ] + + for instruction in instructions: + rel_path = portable_relpath(instruction.file_path, self.base_dir) + sections.append(f"") + sections.append(instruction.content.strip()) + sections.append(f"") + sections.append("") + + sections.append("---") + sections.append("*This file was generated by APM CLI. Do not edit manually.*") + sections.append("*To regenerate: `specify apm compile`*") + sections.append("") + + content = "\n".join(sections) + if config.resolve_links: + content = resolve_markdown_links(content, self.base_dir) + return self._finalize_build_id(content) + + def _finalize_build_id(self, content: str) -> str: + """Replace the build-id placeholder with a deterministic content hash.""" + lines = content.splitlines() + try: + idx = lines.index(BUILD_ID_PLACEHOLDER) + except ValueError: + return content + + hash_input_lines = [line for i, line in enumerate(lines) if i != idx] + build_id = hashlib.sha256("\n".join(hash_input_lines).encode("utf-8")).hexdigest()[:12] + lines[idx] = f"" + return "\n".join(lines) + ("\n" if content.endswith("\n") else "") + + def _cleanup_copilot_root_instructions( + self, + output_path: Path, + result: CompilationResult, + ) -> CompilationResult: + """Remove stale generated Copilot root instructions when no longer applicable.""" + if not output_path.exists(): + result.stats.setdefault("copilot_root_instructions_removed", 0) + return result + + try: + existing = output_path.read_text(encoding="utf-8") + if _COPILOT_ROOT_GENERATED_MARKER not in existing: + result.stats.setdefault("copilot_root_instructions_removed", 0) + return result + + output_path.unlink() + result.stats["copilot_root_instructions_removed"] = 1 + return result + except OSError as exc: + message = f"Failed to remove stale {output_path}: {exc}" + self.errors.append(message) + result.errors.append(message) + result.success = False + result.stats.setdefault("copilot_root_instructions_removed", 0) + return result + def _write_output_file(self, output_path: str, content: str) -> None: """Write the generated content to the output file. diff --git a/src/apm_cli/core/target_detection.py b/src/apm_cli/core/target_detection.py index f6e8cf8e1..9474e281c 100644 --- a/src/apm_cli/core/target_detection.py +++ b/src/apm_cli/core/target_detection.py @@ -196,6 +196,18 @@ def should_compile_gemini_md(target: CompileTargetType) -> bool: return target in ("gemini", "all") +def should_compile_copilot_instructions_md(target: TargetType) -> bool: + """Check if .github/copilot-instructions.md should be compiled. + + Args: + target: The detected or configured target + + Returns: + bool: True if Copilot root instructions should be generated + """ + return target in ("vscode", "all") + + def get_target_description(target: UserTargetType) -> str: """Get a human-readable description of what will be generated for a target. @@ -210,14 +222,14 @@ def get_target_description(target: UserTargetType) -> str: # Normalize aliases to internal value for lookup normalized = "vscode" if target in ("copilot", "agents") else target descriptions = { - "vscode": "AGENTS.md + .github/prompts/ + .github/agents/", + "vscode": "AGENTS.md + .github/copilot-instructions.md + .github/prompts/ + .github/agents/", "claude": "CLAUDE.md + .claude/commands/ + .claude/agents/ + .claude/skills/", "cursor": ".cursor/agents/ + .cursor/skills/ + .cursor/rules/", "opencode": "AGENTS.md + .opencode/agents/ + .opencode/commands/ + .opencode/skills/", "codex": "AGENTS.md + .agents/skills/ + .codex/agents/ + .codex/hooks.json", "gemini": "GEMINI.md + .gemini/commands/ + .gemini/skills/ + .gemini/settings.json (MCP/hooks)", - "all": "AGENTS.md + CLAUDE.md + GEMINI.md + .github/ + .claude/ + .cursor/ + .opencode/ + .codex/ + .gemini/ + .agents/", - "minimal": "AGENTS.md only (create a target folder for full integration)", + "all": "AGENTS.md + CLAUDE.md + GEMINI.md + .github/copilot-instructions.md + .github/ + .claude/ + .cursor/ + .opencode/ + .codex/ + .gemini/ + .agents/", + "minimal": "AGENTS.md only (create .github/, .claude/, or .gemini/ for full integration)", } return descriptions.get(normalized, "unknown target") diff --git a/src/apm_cli/integration/targets.py b/src/apm_cli/integration/targets.py index afb1932f6..733c58987 100644 --- a/src/apm_cli/integration/targets.py +++ b/src/apm_cli/integration/targets.py @@ -123,6 +123,14 @@ class TargetProfile: in ``KNOWN_TARGETS`` for tooling introspection. """ + generated_files: tuple[str, ...] = () + """Additional generated files associated with this target. + + These are compile-time outputs that live at the target root but are not + deployed via primitive integrators, e.g. Copilot's root + ``copilot-instructions.md`` file. + """ + @property def prefix(self) -> str: """Return the path prefix for this target (e.g. ``".github/"``). @@ -285,6 +293,7 @@ def for_scope(self, user_scope: bool = False) -> TargetProfile | None: user_supported="partial", user_root_dir=".copilot", unsupported_user_primitives=("prompts", "instructions"), + generated_files=("copilot-instructions.md",), ), # Claude Code -- the user-level config directory is whatever # ``CLAUDE_CONFIG_DIR`` points to (default ``~/.claude``). The env diff --git a/tests/integration/test_compile_copilot_root_instructions.py b/tests/integration/test_compile_copilot_root_instructions.py new file mode 100644 index 000000000..6038e377e --- /dev/null +++ b/tests/integration/test_compile_copilot_root_instructions.py @@ -0,0 +1,63 @@ +from __future__ import annotations + +import subprocess +import sys +from pathlib import Path + +CLI = [sys.executable, "-m", "apm_cli.cli", "compile", "--target", "copilot", "--single-agents"] + + +def run_cli(cwd: Path) -> subprocess.CompletedProcess: + return subprocess.run(CLI, cwd=str(cwd), capture_output=True, text=True) + + +def test_compile_emits_copilot_root_instructions_and_is_idempotent(tmp_path: Path): + (tmp_path / "apm.yml").write_text("name: test-project\nversion: 0.1.0\n", encoding="utf-8") + instructions_dir = tmp_path / ".apm" / "instructions" + instructions_dir.mkdir(parents=True) + (instructions_dir / "contributing.instructions.md").write_text( + "---\ndescription: Contributing guide\n---\n\n# Contributing\n\nRun focused tests first.\n", + encoding="utf-8", + ) + + first = run_cli(tmp_path) + assert first.returncode == 0, first.stderr or first.stdout + + copilot_root = tmp_path / ".github" / "copilot-instructions.md" + assert copilot_root.exists() + first_content = copilot_root.read_text(encoding="utf-8") + assert " + + + + +# Linting (canonical contract) + +The CI `Lint` job is a hard gate. Mirror it locally before `git push` +and before producing any artifact (PR body, release note, audit +report) that claims CI is green. + +## CI-mirror commands + +The `Lint` job runs: + +- `uv run --extra dev ruff check src/ tests/` +- `uv run --extra dev ruff format --check src/ tests/` + +Both must be silent. + +## Local workflow + +- **Auto-fix style+imports:** `uv run --extra dev ruff check src/ tests/ --fix` +- **Apply formatter:** `uv run --extra dev ruff format src/ tests/` +- **Verify (must be silent):** `uv run --extra dev ruff check src/ tests/ && uv run --extra dev ruff format --check src/ tests/` + +Always run the verify pair before `git push` -- the CI Lint job +fails on any remaining diagnostic. + +## Common surprises + +- `RUF043` -- use `match=r"..."` for `pytest.raises` patterns with + regex metacharacters (`(`, `)`, `[`, etc.). +- `UP006` / `UP045` -- use `list` / `dict` / `X | None` instead of + `List` / `Dict` / `Optional`. +- `RUF100` -- drop stale `# noqa` directives. +- `F401` / `F841` -- remove unused imports / unused locals. +- `SIM103` -- inline negated returns where the body is one line. +- `I001` -- import sort order (auto-fixable). + +## Lifecycle binding + +This is the canonical lint contract for the repo. Skills that +produce artifacts asserting green CI -- notably `pr-description-skill` +(whose "Validation evidence" row covers CI checks) -- inherit this +gate transitively. Do NOT redefine ruff commands inside individual +skills; honor this instruction before invoking them. + + +--- +*This file was generated by APM CLI. Do not edit manually.* +*To regenerate: `specify apm compile`* diff --git a/.github/skills/pr-description-skill/SKILL.md b/.github/skills/pr-description-skill/SKILL.md index 2ab98c900..8426aeabf 100644 --- a/.github/skills/pr-description-skill/SKILL.md +++ b/.github/skills/pr-description-skill/SKILL.md @@ -216,8 +216,7 @@ Run these steps in order. Tick each before moving on. 1. [ ] Confirm every row of the activation contract is filled in. Defense-in-depth gate: before drafting the body, confirm the repo's lint contract is green (canonical commands and lifecycle - binding live in the project's `copilot-instructions.md` Linting - block - do NOT inline or restate them here). If lint is red, + binding live in `.apm/instructions/linting.instructions.md`). If lint is red, STOP, fix, re-run; a PR body claiming green CI while lint fails is a credibility tax we refuse to take on. 2. [ ] Read the diff in full. Identify per-file change summary, From a51921e467eaaeeb232b2a86308fdde5c212148e Mon Sep 17 00:00:00 2001 From: danielmeppiel Date: Thu, 30 Apr 2026 14:58:56 +0200 Subject: [PATCH 3/4] docs(contributing): mirror CI Lint job pre-PR (ruff check + format --check) Both commands must be silent before opening a PR. Points contributors at .apm/instructions/linting.instructions.md as the canonical lint contract (the same source CI, pr-description-skill, and the dogfood `apm compile -t copilot` mirror). Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- CONTRIBUTING.md | 23 ++++++++++++++++++----- 1 file changed, 18 insertions(+), 5 deletions(-) diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 3b0f23080..b7484d024 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -118,7 +118,12 @@ check, not a redesign. 2. Create a new branch for your feature/fix: `git checkout -b feature/your-feature-name` or `git checkout -b fix/issue-description`. 3. Make your changes. 4. Run tests: `uv run pytest tests/unit tests/test_console.py -x` -5. Ensure your code passes linting: `uv run ruff check src/ tests/` +5. Mirror the CI `Lint` job locally before pushing -- both commands must be silent: + ```bash + uv run --extra dev ruff check src/ tests/ + uv run --extra dev ruff format --check src/ tests/ + ``` + Auto-fix with `ruff check --fix` and `ruff format` (without `--check`). The full contract -- including common surprises like `RUF043`, `UP006`, `I001` -- lives in [`.apm/instructions/linting.instructions.md`](.apm/instructions/linting.instructions.md), the canonical source of truth that CI, the `pr-description-skill`, and the dogfood `apm compile -t copilot` all mirror. 6. Commit your changes with a descriptive message. 7. Push to your fork. 8. Submit a pull request. @@ -215,14 +220,22 @@ This project follows: - [PEP 8](https://pep8.org/) for Python style guidelines - We use [Ruff](https://docs.astral.sh/ruff/) for linting and formatting -CI enforces all lint and formatting rules automatically. You can run them locally: +CI enforces all lint and formatting rules automatically. The CI `Lint` job runs the following two commands -- both must be silent before you open a PR: + +```bash +uv run --extra dev ruff check src/ tests/ # lint (CI-mirror) +uv run --extra dev ruff format --check src/ tests/ # format check (CI-mirror) +``` + +Auto-fix locally with: ```bash -uv run ruff check src/ tests/ # lint -uv run ruff check --fix src/ tests/ # lint with auto-fix -uv run ruff format src/ tests/ # format +uv run --extra dev ruff check src/ tests/ --fix # lint with auto-fix +uv run --extra dev ruff format src/ tests/ # apply formatter ``` +The canonical lint contract (with common diagnostics and lifecycle binding for skills that claim green CI) lives in [`.apm/instructions/linting.instructions.md`](.apm/instructions/linting.instructions.md). Do not redefine these commands elsewhere -- honor that instruction. + ### Optional: local pre-commit hooks For instant feedback before pushing, install the pre-commit hooks: From 78fe23aba2a6f55cd7c7a9def84e77c9bce577cc Mon Sep 17 00:00:00 2001 From: danielmeppiel Date: Thu, 30 Apr 2026 17:22:37 +0200 Subject: [PATCH 4/4] chore(integration): regenerate .github/instructions/ after adding linting.instructions.md apm install --ci drift gate caught the new .apm/instructions/linting.instructions.md not yet mirrored under .github/instructions/. This is exactly the dogfood loop PR #1067 demonstrates: .apm/ is canonical, .github/ is regenerated. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .github/instructions/linting.instructions.md | 46 ++++++++++++++++++++ apm.lock.yaml | 4 +- 2 files changed, 49 insertions(+), 1 deletion(-) create mode 100644 .github/instructions/linting.instructions.md diff --git a/.github/instructions/linting.instructions.md b/.github/instructions/linting.instructions.md new file mode 100644 index 000000000..9654360c5 --- /dev/null +++ b/.github/instructions/linting.instructions.md @@ -0,0 +1,46 @@ +--- +description: "Lint contract: run BEFORE pushing or producing artifacts that claim green CI. Mirrors the CI Lint job." +--- + +# Linting (canonical contract) + +The CI `Lint` job is a hard gate. Mirror it locally before `git push` +and before producing any artifact (PR body, release note, audit +report) that claims CI is green. + +## CI-mirror commands + +The `Lint` job runs: + +- `uv run --extra dev ruff check src/ tests/` +- `uv run --extra dev ruff format --check src/ tests/` + +Both must be silent. + +## Local workflow + +- **Auto-fix style+imports:** `uv run --extra dev ruff check src/ tests/ --fix` +- **Apply formatter:** `uv run --extra dev ruff format src/ tests/` +- **Verify (must be silent):** `uv run --extra dev ruff check src/ tests/ && uv run --extra dev ruff format --check src/ tests/` + +Always run the verify pair before `git push` -- the CI Lint job +fails on any remaining diagnostic. + +## Common surprises + +- `RUF043` -- use `match=r"..."` for `pytest.raises` patterns with + regex metacharacters (`(`, `)`, `[`, etc.). +- `UP006` / `UP045` -- use `list` / `dict` / `X | None` instead of + `List` / `Dict` / `Optional`. +- `RUF100` -- drop stale `# noqa` directives. +- `F401` / `F841` -- remove unused imports / unused locals. +- `SIM103` -- inline negated returns where the body is one line. +- `I001` -- import sort order (auto-fixable). + +## Lifecycle binding + +This is the canonical lint contract for the repo. Skills that +produce artifacts asserting green CI -- notably `pr-description-skill` +(whose "Validation evidence" row covers CI checks) -- inherit this +gate transitively. Do NOT redefine ruff commands inside individual +skills; honor this instruction before invoking them. diff --git a/apm.lock.yaml b/apm.lock.yaml index eaa002865..8cffa989a 100644 --- a/apm.lock.yaml +++ b/apm.lock.yaml @@ -19,6 +19,7 @@ local_deployed_files: - .github/instructions/doc-sync.instructions.md - .github/instructions/encoding.instructions.md - .github/instructions/integrators.instructions.md +- .github/instructions/linting.instructions.md - .github/instructions/python.instructions.md - .github/instructions/tests.instructions.md - .github/skills/apm-review-panel @@ -49,5 +50,6 @@ local_deployed_file_hashes: .github/instructions/doc-sync.instructions.md: sha256:bb3816254f8df6bffc6faacd556871f36903e9d7f348982f1e2de0339384c696 .github/instructions/encoding.instructions.md: sha256:93db7377dc896f6efecf2c5d8c5d89255a555562f468d034d64c42edd5cf46d5 .github/instructions/integrators.instructions.md: sha256:b151e0438088d2c0b636dfc28532ecf43c3b51e5f1070a354b8d5b57c345e335 + .github/instructions/linting.instructions.md: sha256:312acd32353567834ec9f4f246710a47a991729a11c0380aa6a010b63de607eb .github/instructions/python.instructions.md: sha256:45173f778eddc126c37c7ace96acd0e17adb1895031eec134ec0754638d3ba37 - .github/instructions/tests.instructions.md: sha256:19a0d078417876ab3b758f8d404cf8266354e3412860eb88b849b620692657e4 + .github/instructions/tests.instructions.md: sha256:4c6335e3373f9735778a05913f2d8ef250d118f8c5305e70ba407e578a525ef7