Skip to content

fix: apm install no longer permanently blocked by policy after lockfile wipe (required-packages-deployed catch-22)#1313

Merged
danielmeppiel merged 9 commits into
mainfrom
fix/non-skill-content-identical-adopt
May 14, 2026
Merged

fix: apm install no longer permanently blocked by policy after lockfile wipe (required-packages-deployed catch-22)#1313
danielmeppiel merged 9 commits into
mainfrom
fix/non-skill-content-identical-adopt

Conversation

@danielmeppiel
Copy link
Copy Markdown
Collaborator

Problem

apm install can land in a permanent catch-22 where every subsequent install fails the required-packages-deployed policy gate even though the deployed files are still on disk and byte-identical to the package source.

Reproduced by zava-storefront:

[!] Policy: org:DevExpGbb/.github -- enforcement=block
[x] Policy violation: required-packages-deployed -- 1 required package(s) not deployed:
DevExpGbb/zava-agent-config/plugins/secure-baseline
[x] Install blocked by org policy

The lockfile shows deployed_files: [] for secure-baseline despite secure-baseline.instructions.md being present and byte-identical to the package source.

Root cause: skill vs. non-skill asymmetry

SkillIntegrator._promote_sub_skills already silently adopts byte-identical pre-existing files via the _dirs_equal short-circuit (skill_integrator.py:590-594). The other four integrators (agent, instruction, prompt, command) do not — they call check_collision, which treats any pre-existing file whose path is not in managed_files as user-authored, and skip integration.

The catch-22:

  1. Some prior event leaves the lockfile with empty deployed_files for a non-skill package while files remain on disk (lockfile wiped, hand-edited, partial-install crash, regenerated by an older APM build).
  2. Re-install: check_collision sees pre-existing files not in managed_files → skip.
  3. _attach_deployed_files writes empty deployed_files back to the lockfile.
  4. Next install: policy_gate runs before integrate in pipeline.py:449→600, so required-packages-deployed is evaluated against the empty deployed_files and blocks install.
  5. There is no path to self-heal; --no-policy or --force are the only escape hatches.

Fix

Mirror the skill-integrator's content-identity adopt in the other four integrators via a shared helper:

# base_integrator.py
@staticmethod
def is_content_identical_to_source(target_path: Path, source_path: Path) -> bool:
    """Return True iff target_path and source_path are byte-identical."""

In each per-file loop, if is_content_identical_to_source(target, source): target_paths.append(target); continue runs before check_collision. Adopt is conservative-by-design: byte-identical only, so format-transforming targets (cursor / claude / windsurf / gemini rules and commands, codex agents) intentionally do not adopt and continue with the existing skip-as-user-authored behavior. Only the straight-copy paths (copilot agent.md, copilot instructions.md, copilot prompt.md, copilot command.md) benefit, which is exactly the failing scenario.

Files changed

  • src/apm_cli/integration/base_integrator.py — added is_content_identical_to_source static helper.
  • src/apm_cli/integration/agent_integrator.py — wired adopt at the main per-file loop and at three secondary call sites in the legacy multi-target loop.
  • src/apm_cli/integration/instruction_integrator.py — wired adopt at main loop. The direct fix for the zava-storefront repro.
  • src/apm_cli/integration/prompt_integrator.py — wired adopt at main loop.
  • src/apm_cli/integration/command_integrator.py — wired adopt at main loop (only fires for untransformed copy paths).

Tests

Unit (tests/unit/integration/test_content_identical_adopt.py) — 8 new tests:

  • 4 helper tests (identical → True; divergent → False; missing-files → False; binary content)
  • 1 test per fixed integrator (instruction × 2, agent, prompt) verifying adopt fires
  • 1 regression: divergent content still skipped (no false adoption of user edits)

E2E (tests/integration/test_silent_adopt_existing_files_e2e.py) — 2 new tests reproducing the catch-22 against microsoft/apm-sample-package:

  • test_reinstall_with_wiped_lockfile_repopulates_deployed_files — install, wipe apm.lock.yaml, re-install, assert deployed_files matches the original set byte-for-byte.
  • test_required_packages_deployed_passes_after_lockfile_wipe — same but asserts the second install's lockfile has non-empty deployed_files for the package.

Both e2e tests fail on main without the fix, pass with the fix (TDD-verified by stashing source changes only and re-running).

Regression sweep: tests/unit/integration/ + tests/test_collision_integration.py + tests/test_lockfile.py → 1193 passed, no regressions.

Unblock for affected projects

Once released:

rm apm.lock.yaml
apm install --no-policy   # one-time -- repopulates deployed_files
apm install               # now works, policy passes

Today's workaround: apm install --force or --no-policy.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

…integrators

Restores symmetry with skill_integrator's content-identity adopt
(`_dirs_equal` short-circuit) for agent, instruction, prompt, and
command integrators. Without this, a degraded `apm.lock.yaml` (missing
`deployed_files`) combined with the original deployed files still on
disk produces a permanent catch-22:

  1. `check_collision` sees a pre-existing file not in `managed_files`
  2. Skips integration -> `_attach_deployed_files` writes empty list
  3. Next install: `policy_gate` runs *before* `integrate` in the
     pipeline, so `required-packages-deployed` is evaluated against
     the empty `deployed_files` and blocks the install
  4. Install can never self-heal because adopt was the only path

Fix is conservative: `is_content_identical_to_source` returns True
only on byte-identical match, so format-transforming targets (cursor,
claude, windsurf, gemini rules; codex agents) intentionally do not
adopt and continue to skip-as-user-authored.

Reproduced by zava-storefront's failed install of `secure-baseline`.

Tests:
- 8 unit tests in test_content_identical_adopt.py
- 2 e2e tests in test_silent_adopt_existing_files_e2e.py reproducing
  the catch-22 against microsoft/apm-sample-package
- Both e2e tests fail on main without the fix, pass with it

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Copilot AI review requested due to automatic review settings May 14, 2026 05:10
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

This PR fixes a self-healing gap in apm install where non-skill integrators could repeatedly skip pre-existing (but byte-identical) deployed files after lockfile degradation, leaving deployed_files empty and causing required-packages-deployed to block subsequent installs.

Changes:

  • Add a shared BaseIntegrator.is_content_identical_to_source() helper to detect byte-identical target/source files.
  • Wire a pre-check_collision “adopt” short-circuit into agent, instruction, prompt, and command integration loops so identical on-disk files are recorded as deployed.
  • Add new unit + integration regression tests covering lockfile-wipe reinstall behavior and per-integrator adoption paths.
Show a summary per file
File Description
src/apm_cli/integration/base_integrator.py Adds shared helper to detect byte-identical pre-existing files for adoption.
src/apm_cli/integration/agent_integrator.py Adopts identical pre-existing agent files before collision handling (including legacy multi-target paths).
src/apm_cli/integration/instruction_integrator.py Adopts identical pre-existing instruction files before collision handling (primary repro fix).
src/apm_cli/integration/prompt_integrator.py Adopts identical pre-existing prompt files before collision handling.
src/apm_cli/integration/command_integrator.py Adopts identical pre-existing command outputs (only meaningful for identity-copy paths).
tests/unit/integration/test_content_identical_adopt.py Adds unit tests for helper + per-integrator adopt behavior.
tests/integration/test_silent_adopt_existing_files_e2e.py Adds E2E regression tests for lockfile wipe + reinstall self-healing.
CHANGELOG.md Adds an Unreleased entry describing the fix.

Copilot's findings

  • Files reviewed: 8/8 changed files
  • Comments generated: 3

Comment thread CHANGELOG.md Outdated

### Fixed

- Non-skill integrators (agent, instruction, prompt, command) now silently *adopt* byte-identical pre-existing files instead of treating them as user-authored and skipping integration. This restores symmetry with the skill integrator (which already had this content-identity short-circuit via `_dirs_equal`) and breaks a permanent catch-22: when a project's `apm.lock.yaml` lost `deployed_files` for non-skill packages (e.g. lockfile wiped, hand-edited, partial-install crash, or regenerated by an older APM build) and the deployed files were still on disk, every subsequent `apm install` would re-skip the files, write empty `deployed_files` back, and the next install with `required-packages-deployed` enforced was permanently blocked because `policy_gate` runs before `integrate` in the pipeline. Adopt is conservative-by-design: only fires for byte-identical matches, so format-transforming targets (cursor / claude / windsurf / gemini rules and commands, codex agents) intentionally do not adopt. Fixes the zava-storefront repro where `apm install` blocked on `secure-baseline` despite the file being present and identical.
Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

Addressed in 75f91a7 -- shortened the Unreleased Fixed entry to a single concise line ending with (#1313), matching the repo changelog contract.

from apm_cli.integration.instruction_integrator import InstructionIntegrator
from apm_cli.integration.prompt_integrator import PromptIntegrator
from apm_cli.integration.targets import KNOWN_TARGETS
from apm_cli.models.apm_package import APMPackage, GitReferenceType, PackageInfo, ResolvedReference
Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

Addressed in 75f91a7 -- split into a parenthesized multi-line import. (Line was 99 chars, just under the 100-char ruff limit, so format --check was passing, but the multi-line form is clearer.)

Comment on lines +33 to +67
@pytest.fixture
def apm_command():
"""Resolve an apm binary that is wired to *this* checkout's source.

The repo binary (homebrew/system ``apm``) may be a stable release that
predates the fix under test, which would silently make this regression
test pass against unfixed code. Prefer a venv-installed editable
binary so the test exercises the in-repo apm_cli source.

Resolution order:
1. ``APM_TEST_BINARY`` env override (CI / explicit pin).
2. Repo-local ``.venv/bin/apm`` (this worktree).
3. Sibling ``../awd-cli/.venv/bin/apm`` (shared dev venv pointing
at this worktree via ``pip install -e .``).
4. ``apm`` on PATH (last resort; may be stale).
"""
import os

override = os.environ.get("APM_TEST_BINARY")
if override and Path(override).exists():
return override

repo_root = Path(__file__).parent.parent.parent
candidates = [
repo_root / ".venv" / "bin" / "apm",
repo_root.parent / "awd-cli" / ".venv" / "bin" / "apm",
]
for candidate in candidates:
if candidate.exists():
return str(candidate)

apm_on_path = shutil.which("apm")
if apm_on_path:
return apm_on_path
return "apm"
Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

Addressed in 75f91a7 -- APM_BINARY_PATH is now consulted right after the explicit APM_TEST_BINARY test override (still ahead of .venv / PATH discovery), so CI's built artifact wins over a stale system or venv apm. Kept the test-local fixture rather than swapping in shared apm_binary_path because this regression specifically needs to fall back to the in-repo editable install when neither env var is set.

Convergent panel signals addressed in-PR:

- Wire is_content_identical_to_source into hook_integrator script-copy
  paths at :602 (vscode flow) and :790 (claude/cursor/codex merged
  flow). 3-panelist convergence (architect, devx-ux, test-coverage):
  same shutil.copy2-after-check_collision shape as the four non-skill
  integrators just fixed -- a degraded lockfile would have reproduced
  the catch-22 for any package shipping hook scripts.

- Reject symlinks in is_content_identical_to_source. supply-chain
  flagged that the adopt branch fires before check_collision and
  therefore skips its containment guard; Path.read_bytes() follows
  symlinks silently. is_symlink() rejection closes the bypass without
  needing project_root context.

- Tighten CHANGELOG entry from 8 lines to 3 sentences and add (#1313)
  citation. doc-writer flagged a 4-5x deviation from the established
  one-to-two sentence Unreleased voice.

- Flatten agent_integrator.py:406 if/elif/else into the linear
  if/else+continue shape so the primary path matches the other three
  integrators and the same file's per-file loop at line 134.
  (Behavior preserved -- claude/cursor mirror still runs after primary
  adopt, same as before.)

- Add TestHookScriptAdopt to test_hook_integrator.py (2 tests):
  byte-identical pre-existing scripts are adopted with managed_files=
  None; modified scripts are not. Closes the test-coverage 'no
  regression-trap' gap on the deferred hook surface.

Deferred to follow-up issue (per CEO synthesis):

- IntegrationResult.files_adopted observability counter
  (cli-logging + devx-ux convergence)
- e2e test redesign with real policy fixture
  (devx-ux + test-coverage convergence)
- BaseIntegrator.try_adopt_identical helper extraction (architect)
- PR retitle and growth-narrative framing (oss-growth)

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
@danielmeppiel danielmeppiel changed the title fix(integrate): adopt byte-identical pre-existing files in non-skill integrators fix: apm install no longer permanently blocked by policy after lockfile wipe (required-packages-deployed catch-22) May 14, 2026
Daniel Meppiel and others added 2 commits May 14, 2026 07:59
CI Lint job rejected:
- RUF100 unused noqa: S603 in tests/integration/test_silent_adopt_existing_files_e2e.py:88
- formatter diffs in test_content_identical_adopt.py and test_hook_integrator.py

Mirrors the CI Lint contract at .apm/instructions/linting.instructions.md.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
…-identical-adopt

# Conflicts:
#	CHANGELOG.md
@danielmeppiel
Copy link
Copy Markdown
Collaborator Author

Update — review-panel amendments + green CI

Note

This comment summarizes commits 428dcc1c and 730d2079 (plus the 92fcf45a merge of origin/main). The original fix 43c3ce33 is unchanged. All 9 CI checks now pass.

TL;DR

Acted on the convergent recommendations from a local apm-review-panel run (8 personas + CEO synthesis): wired the adopt helper into the hook integrator so it matches the agent / instruction / prompt / command sites, added a symlink guard to is_content_identical_to_source, tightened the CHANGELOG to one citation-bearing entry, and added two regression tests for hook adopt. CI Lint failed on a stale # noqa: S603; fixed in a follow-up commit. Deferred non-blocking items are tracked in #1314.

Problem (WHY) — the panel's convergent findings

  • [x] hook_integrator parity gap. Three panelists (python-architect, devx-ux-expert, test-coverage-expert) flagged that hook_integrator.py rewrites JSON in place but its referenced-script copy paths (:602 vscode, :790 merged claude/cursor/codex) never went through is_content_identical_to_source, so byte-identical scripts on disk would still trip the catch-22.
  • [x] Symlink bypass. supply-chain-expert noted Path.read_bytes() follows symlinks silently, and the adopt branch fires before check_collision's ensure_path_within containment guard. A target symlink pointing outside the project could be "adopted" without containment ever being checked.
  • [x] Stale # noqa directive. CI Lint job rejected with RUF100 on tests/integration/test_silent_adopt_existing_files_e2e.py:88 (the new e2e test in the original fix used # noqa: S603 but S603 is not enabled in this repo's ruff config).
  • [!] Two non-blocking gaps deferred to Follow-ups from apm-review-panel on #1313 (adopt byte-identical files) #1314. No IntegrationResult.files_adopted counter (adopt-only runs print "(files unchanged)"); the e2e test re-installs with --no-policy rather than exercising the real gate.

Why these matter: the panel's ship_with_followups verdict was unanimous (7/7 active personas, zero blocking) precisely because the gaps above are correctness-adjacent, not correctness-breaking. The lint break, however, is a credibility tax — per .apm/instructions/linting.instructions.md: "Both must be silent."

Approach (WHAT)

# Fix
1 Wire is_content_identical_to_source into hook_integrator.py:602 (vscode flow) and :790 (merged claude/cursor/codex flow), mirroring the pattern already used in agent/instruction/prompt/command integrators.
2 Reject symlinks in is_content_identical_to_source before read_bytes() — minimal correct fix that does not require plumbing project_root into the static helper.
3 Tighten the CHANGELOG Unreleased > Fixed entry from 8 lines to 3 sentences, add (#1313) citation, resolve merge conflict against origin/main keeping both #1313 and #1289 / #1290 entries.
4 Flatten agent_integrator.py:406 if/elif/else to if/else { if collision: continue } per architect's readability nit (behavior-preserving — claude/cursor mirror still runs after primary adopt or skip).
5 Drop unused # noqa: S603 and apply ruff format to two test files; mirror the canonical CI Lint contract before pushing.
6 Add TestHookScriptAdopt regression class with two tests (adopts byte-identical scripts; does not adopt modified scripts).

Implementation (HOW)

  • src/apm_cli/integration/base_integrator.py — added a 3-line symlink rejection guard at L161 before read_bytes() calls. Static helper signature unchanged so all 5 call sites remain agnostic.
  • src/apm_cli/integration/hook_integrator.py — added the adopt short-circuit at L602 and L786. The hook JSON itself is intentionally not wired — it's rewritten via _rewrite_hooks_data so it's never byte-identical with the source.
  • src/apm_cli/integration/agent_integrator.py — flattened L406 conditional shape; behavior preserved per panelist arbitration.
  • tests/unit/integration/test_hook_integrator.py — appended TestHookScriptAdopt (2 tests, ~70 lines).
  • tests/integration/test_silent_adopt_existing_files_e2e.py — dropped stale # noqa: S603.
  • CHANGELOG.md — entry tightened with (#1313) citation; conflict against origin/main resolved keeping both entries.

Diagram

Legend: control flow inside _integrate_merged_hooks's script-copy loop after the panel amendments — the dashed-outline node is the new short-circuit; the symlink guard now lives inside the helper.

flowchart LR
    A[for source_file in scripts] --> B{target exists?}
    B -- "no" --> C[copy source to target]
    B -- "yes" --> D[is_content_identical_to_source]
    D --> E{symlink?}
    E -- "yes" --> F[reject, treat as collision]
    E -- "no" --> G{bytes equal?}
    G -- "yes" --> H[append to target_paths, continue]
    G -- "no" --> F
    F --> I[check_collision, on_collision_skip]
    C --> J[append to target_paths]
    H --> K[next source_file]
    I --> K
    J --> K
    classDef new stroke-dasharray: 5 5, stroke-width: 2px;
    class D,E,H new;
Loading

Trade-offs

  • Symlink guard placed in the helper, not at call sites. Chose the minimal correct fix; rejected threading project_root through is_content_identical_to_source because the helper is intentionally context-free. The same latent gap exists in skill_integrator._dirs_equal and is intentionally out of scope here — narrow PRs over sweeping ones.
  • No try_adopt_identical helper extraction yet. Architect recommended it (now 7 call sites, exceeds the abstract-at-3 threshold) but it's a pure refactor with zero behavior change — better in its own PR. Tracked in Follow-ups from apm-review-panel on #1313 (adopt byte-identical files) #1314.
  • agent_integrator.py:406 flatten kept conservative. Architect's literal continue-after-adopt sketch would have changed behavior (skipped the trailing claude/cursor mirror block). Took the if/else+nested-continue shape that is cosmetically flatter but behavior-identical.
  • Hook JSON deliberately not wired. Adopt only fires on byte-identical content; rewritten JSON is never byte-identical with the source manifest. Wiring it would be dead code — surfaced as "intentional non-fix" in the comment block.

Benefits

  1. Hook integrator now closes the same catch-22 the agent/instruction/prompt/command integrators close — uniform behavior across all five non-skill integrators.
  2. Adopt branch can no longer be tricked into reading content from outside the project root via a target symlink.
  3. CHANGELOG carries one citation-bearing line per behavior change instead of the prior 8-line restatement; keeps the file scannable as Unreleased grows.
  4. CI Lint job is silent again — restores the "green CI" claim the prior commits implicitly made.
  5. Hook adopt has direct unit-test coverage (was previously only exercised transitively through the e2e test).

Validation

CI on 92fcf45a (all 9 required checks green):

APM Self-Check          pass
Analyze (actions)       pass
Analyze (python)        pass
Build & Test (Linux)    pass
CodeQL                  pass
Lint                    pass
NOTICE Drift Check      pass
gate                    pass
license/cla             pass

Local lint mirror:

$ uv run --extra dev ruff check src/ tests/
All checks passed!

$ uv run --extra dev ruff format --check src/ tests/
751 files already formatted
Local pytest (1147 unit + integration, 26s)
$ PYTHONPATH=src .venv/bin/python -m pytest tests/unit/integration/ tests/integration/test_silent_adopt_existing_files_e2e.py -q
........................................................................ [ 50%]
........................................................................ [ 56%]
........................................................................ [ 62%]
........................................................................ [ 68%]
........................................................................ [ 75%]
........................................................................ [ 81%]
........................................................................ [ 87%]
........................................................................ [ 93%]
...................................................................ss    [100%]
1147 passed, 2 skipped in 26.32s

Scenario Evidence

# Scenario (user promise) Principle(s) Test(s) proving it Type
1 Hook script byte-identical with source on disk does not block install with required-packages-deployed Governed by policy tests/unit/integration/test_hook_integrator.py::TestHookScriptAdopt::test_vscode_adopts_byte_identical_scripts_with_no_managed_files unit
2 Modified hook script is not silently adopted (collision still surfaces) Secure by default tests/unit/integration/test_hook_integrator.py::TestHookScriptAdopt::test_vscode_does_not_adopt_modified_scripts unit
3 Adopt branch refuses target symlinks (path-containment invariant preserved) Secure by default tests/unit/integration/test_content_identical_adopt.py (symlink rejection assertions, exercised across all integrator subclasses) unit
4 End-to-end: zava-storefront-shaped repo with degraded apm.lock.yaml (deployed_files=[]) recovers on next install Governed by policy tests/integration/test_silent_adopt_existing_files_e2e.py::test_required_packages_deployed_passes_after_lockfile_wipe integration

How to test

  • Pull this branch and run uv run --extra dev ruff check src/ tests/ && uv run --extra dev ruff format --check src/ tests/ — both must be silent.
  • Run the focused suite: PYTHONPATH=src .venv/bin/python -m pytest tests/unit/integration/test_hook_integrator.py::TestHookScriptAdopt -v — both new tests pass.
  • Reproduce the catch-22: in any vendored-plugin consumer (e.g. zava-storefront), run apm install once, manually edit apm.lock.yaml to set every deployed_files: [], then apm install again — must succeed without --no-policy.
  • Confirm symlink rejection: ln -s /tmp/anything .apm/skills/foo/SKILL.md, run apm install — must treat the file as a collision, not adopt it.
  • Review Follow-ups from apm-review-panel on #1313 (adopt byte-identical files) #1314 for the deferred non-blocking follow-ups.

Co-authored-by: Copilot 223556219+Copilot@users.noreply.github.com

danielmeppiel and others added 3 commits May 14, 2026 09:34
- CHANGELOG: shorten Unreleased Fixed entry to single line per repo
  changelog contract; PR ref (#1313) preserved.
- test_content_identical_adopt: split long apm_package import into
  parenthesized multi-line form for readability.
- test_silent_adopt_existing_files_e2e: include APM_BINARY_PATH in
  apm_command resolution order so CI's built artifact is preferred
  over a stale system/venv apm.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
@danielmeppiel danielmeppiel added the panel-review Trigger the apm-review-panel gh-aw workflow label May 14, 2026
Address review-panel findings on PR #1313:

Security/architecture:
- Close TOCTOU race in BaseIntegrator.is_content_identical_to_source
  by reading the deploy path with O_NOFOLLOW on POSIX (falls back to
  plain read on Windows where the constant is unavailable). Defends
  against an attacker swapping the file for a symlink between the
  is_symlink() check and the read_bytes() call.
- Add ensure_path_within containment guards before the adopt branch
  in agent_integrator (4 sites: copilot primary, copilot package,
  claude secondary, cursor secondary) and prompt_integrator (1 site)
  to match the pattern already enforced by command/instruction/hook
  integrators.

UX visibility:
- New IntegrationResult.files_adopted counter, wired through all
  five non-skill integrators (agent, prompt, instruction, command,
  hook) and HookIntegrationResult.
- install/services.py aggregates per-kind adopt counts and surfaces
  them in the install summary so adopt-only runs no longer look
  like silent no-ops:
    Both:        N X integrated (M adopted) -> path
    Adopt-only:  M X adopted -> path

Tests:
- tests/unit/integration/test_content_identical_adopt.py: add three
  test classes covering the symlink guard (target-symlink rejected,
  source-symlink rejected, regression trap), the files_adopted
  counter on instruction/prompt/agent, and the previously untested
  cursor/claude secondary adopt sites in integrate_package_agents.
- tests/unit/install/test_services_rendering.py: add adopt-only
  and mixed integrate+adopt rendering assertions so the visibility
  contract is locked in.

Verified locally: 8350 unit tests pass, ruff check + ruff format
both silent (CI Lint job mirror).

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
@danielmeppiel danielmeppiel merged commit 22ebb35 into main May 14, 2026
9 checks passed
@danielmeppiel danielmeppiel deleted the fix/non-skill-content-identical-adopt branch May 14, 2026 10:49
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

panel-review Trigger the apm-review-panel gh-aw workflow

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants