diff --git a/CHANGELOG.md b/CHANGELOG.md index f9fea1855..015ae3dd3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -25,6 +25,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 closes #1650) (#1770) - Two additive, default-off policy keys under the existing `security:` namespace: `security.integrity.require_hashes` makes `apm install` fail closed when any non-local lockfile entry lacks a content hash, and `security.audit.fail_on_drift` makes `apm audit` exit non-zero when the workspace drifts from the lockfile. Both only tighten through policy inheritance. (#1794) +### Changed + +- Windsurf skills now deploy to the cross-tool `.agents/skills//SKILL.md` path (was `.windsurf/skills/`), converging with Copilot, Cursor, Codex, Gemini, and OpenCode. Pass `--legacy-skill-paths` or set `APM_LEGACY_SKILL_PATHS=1` to restore the per-client `.windsurf/skills/` layout. The lockfile pack-time cross-target skill map for Windsurf is swept separately in #1805. (closes #1520) (#1802) + ### Removed - `apm marketplace publish` command and consumer-repo fan-out workflow; consumers should run `apm install --update` instead. (#1134) diff --git a/docs/src/content/docs/producer/author-primitives/skills.md b/docs/src/content/docs/producer/author-primitives/skills.md index d159e94c1..573121b4b 100644 --- a/docs/src/content/docs/producer/author-primitives/skills.md +++ b/docs/src/content/docs/producer/author-primitives/skills.md @@ -99,22 +99,23 @@ in `references/`; keep `SKILL.md` to the always-relevant flow. | Target | Deploy directory | |-------------------|----------------------------------------------| | `claude` | `.claude/skills//SKILL.md` | -| `windsurf` | `.windsurf/skills//SKILL.md` | | `kiro` | `.kiro/skills//SKILL.md` | | `copilot` | `.agents/skills//SKILL.md` | | `cursor` | `.agents/skills//SKILL.md` | | `codex` | `.agents/skills//SKILL.md` | | `gemini` | `.agents/skills//SKILL.md` | | `opencode` | `.agents/skills//SKILL.md` | +| `windsurf` | `.agents/skills//SKILL.md` | | `agent-skills` | `.agents/skills//SKILL.md` (explicit) | -Five harnesses converge on the cross-tool `.agents/skills/` -directory. Claude keeps its harness-native path because Claude Code's -default scan is `.claude/skills/`; Windsurf and Kiro currently use -`.windsurf/skills/` and `.kiro/skills/` for the same reason. Windsurf's Cascade also +Six harnesses converge on the cross-tool `.agents/skills/` +directory. Claude and Kiro keep their harness-native paths +(`.claude/skills/`, `.kiro/skills/`) because those clients' default +scan is the per-tool directory. Windsurf (now Devin Desktop) converged +onto `.agents/skills/` in +[#1520](https://github.com/microsoft/apm/issues/1520): Cascade [discovers `.agents/skills/`](https://docs.windsurf.com/windsurf/cascade/skills#skill-scopes) -natively for cross-agent compatibility (convergence tracked in -[#1520](https://github.com/microsoft/apm/issues/1520)). The whole +natively, and Devin's own docs use `.agents/skills/`. The whole skill folder is copied (`shutil.copytree`), so `scripts/`, `references/`, `assets/`, and `examples/` ride along. Symlinks and the `.apm-pin` cache marker are filtered out diff --git a/docs/src/content/docs/reference/manifest-schema.md b/docs/src/content/docs/reference/manifest-schema.md index fca59da5b..1dc5d4e32 100644 --- a/docs/src/content/docs/reference/manifest-schema.md +++ b/docs/src/content/docs/reference/manifest-schema.md @@ -170,7 +170,7 @@ A plural alias `targets:` (YAML list only) is also accepted and takes precedence | `codex` | Emits `AGENTS.md` and deploys skills to `.agents/skills/`, agents to `.codex/agents/`. | | `gemini` | Emits `GEMINI.md` and deploys to `.gemini/commands/`, `.gemini/skills/`, `.gemini/settings.json`. | | `antigravity` | Emits `AGENTS.md` and deploys rules to `.agents/rules/`, skills to `.agents/skills/`, hooks to `.agents/hooks.json`, MCP to `.agents/mcp_config.json`. Explicit-only (not auto-detected; not part of `--target all`). | -| `windsurf` | Emits `AGENTS.md` and deploys to `.windsurf/rules/`, `.windsurf/skills/`, `.windsurf/workflows/`, `.windsurf/hooks.json`. | +| `windsurf` | Emits `AGENTS.md` and deploys to `.windsurf/rules/`, `.agents/skills/`, `.windsurf/workflows/`, `.windsurf/hooks.json`. | | `kiro` | Emits `AGENTS.md` and deploys to `.kiro/steering/`, `.kiro/skills/`, `.kiro/hooks/`, `.kiro/settings/mcp.json`. | | `all` | All targets. Cannot be combined with other values in a list. | | `minimal` | `AGENTS.md` only at project root. **Auto-detected only**: this value MUST NOT be set explicitly in manifests; it is an internal fallback when no target folder is detected. | diff --git a/docs/src/content/docs/reference/targets-matrix.md b/docs/src/content/docs/reference/targets-matrix.md index 8e8f36072..f9e529cb7 100644 --- a/docs/src/content/docs/reference/targets-matrix.md +++ b/docs/src/content/docs/reference/targets-matrix.md @@ -26,13 +26,13 @@ see [Primitive types](./primitive-types/). | gemini | `.gemini/` | [ ] | [ ] | [ ] | [x] | [x] | [x] | [x] | | antigravity | `.agents/` | [x] | [ ] | [ ] | [x] | [ ] | [x] | [x] | | opencode | `.opencode/` | [ ] | [ ] | [x] | [x] | [x] | [ ] | [x] | -| windsurf | `.windsurf/` | [x] | [ ] | [ ] | [x] | [x] | [x] | [x] | +| windsurf | `.windsurf/` + `.agents/` | [x] | [ ] | [ ] | [x] | [x] | [x] | [x] | | kiro | `.kiro/` | [x] | [ ] | [ ] | [x] | [ ] | [x] | [x] | | agent-skills | `.agents/` | [ ] | [ ] | [ ] | [x] | [ ] | [ ] | [ ] | Skills deploy to `.agents/skills/` for Copilot, Cursor, OpenCode, -Gemini, Antigravity, and Codex by default (see [Skills convergence](#skills-convergence) -below). Claude, Windsurf, and Kiro keep target-native skill directories. +Gemini, Antigravity, Codex, and Windsurf by default (see [Skills convergence](#skills-convergence) +below). Claude and Kiro keep target-native skill directories. `copilot-cowork` (Microsoft 365 Copilot), `copilot-app` (GitHub Copilot desktop App), and `openclaw` (OpenClaw agent runtime) are @@ -180,11 +180,11 @@ OpenCode. Windsurf / Cascade. - **Detection.** `.windsurf/` directory. -- **Deploy directory.** `.windsurf/` at project scope; `~/.codeium/windsurf/` at user scope. +- **Deploy directory.** Native primitives deploy under `.windsurf/` at project scope and `~/.codeium/windsurf/` at user scope; skills converge on `.agents/skills/` at both scopes (`~/.agents/skills/` at user scope). - **Supported primitives.** instructions, skills, commands, hooks, mcp. - **File conventions.** - instructions: `.windsurf/rules/.md` - - skills: `.windsurf/skills//SKILL.md` + - skills: `.agents/skills//SKILL.md` - commands: `.windsurf/workflows/.md` - hooks: `.windsurf/hooks.json` - **Agents.** Not deployed. Cascade auto-invokes any `SKILL.md` by its `description:` frontmatter, so a separate agents primitive would collide with skills on the same path. Ship personas as skills under `.apm/skills//SKILL.md` instead. diff --git a/src/apm_cli/core/target_detection.py b/src/apm_cli/core/target_detection.py index 25f14029c..7c913437d 100644 --- a/src/apm_cli/core/target_detection.py +++ b/src/apm_cli/core/target_detection.py @@ -356,7 +356,7 @@ def get_target_description(target: UserTargetType) -> str: "codex": "AGENTS.md + .agents/skills/ + .codex/agents/ + .codex/hooks.json", "gemini": "GEMINI.md + .gemini/commands/ + .gemini/skills/ + .gemini/settings.json (MCP/hooks)", "antigravity": "AGENTS.md + .agents/rules/ + .agents/skills/ + .agents/hooks.json + .agents/mcp_config.json (explicit --target only)", - "windsurf": "AGENTS.md + .windsurf/rules/ + .windsurf/skills/ + .windsurf/workflows/ + .windsurf/hooks.json", + "windsurf": "AGENTS.md + .windsurf/rules/ + .agents/skills/ + .windsurf/workflows/ + .windsurf/hooks.json", "kiro": "AGENTS.md + .kiro/steering/ + .kiro/skills/ + .kiro/hooks/ + .kiro/settings/mcp.json", "agent-skills": ".agents/skills/ only (cross-client shared skills -- no agents, hooks, or commands)", "openclaw": ".agents/skills/ (project) or ~/.openclaw/skills/ (--global) -- experimental", diff --git a/src/apm_cli/integration/targets.py b/src/apm_cli/integration/targets.py index c89ab7b28..1fc420323 100644 --- a/src/apm_cli/integration/targets.py +++ b/src/apm_cli/integration/targets.py @@ -708,10 +708,14 @@ def for_scope(self, user_scope: bool = False) -> TargetProfile | None: compile_family="agents", hooks_config_display=".codex/hooks.json", ), - # Windsurf/Cascade -- .windsurf/ is the workspace config directory. + # Windsurf/Cascade (now Devin Desktop) -- .windsurf/ is the workspace + # config directory. # Rules are markdown files with trigger/globs frontmatter under .windsurf/rules/. - # Skills use the standard SKILL.md format under .windsurf/skills/. - # Cascade auto-invokes them when the description frontmatter matches the + # Skills converge onto the cross-tool .agents/skills//SKILL.md path + # (deploy_root=".agents"), matching copilot/cursor/codex/gemini/opencode; + # Devin's own docs also use .agents/skills/. Rules, workflows, and hooks + # stay under .windsurf/. + # Cascade auto-invokes skills when the description frontmatter matches the # task -- this is the universal invocation mechanism, so windsurf does # NOT expose a separate ``agents`` primitive. Package authors who want # their content to deploy to windsurf must declare it under @@ -734,7 +738,12 @@ def for_scope(self, user_scope: bool = False) -> TargetProfile | None: "windsurf_rules", output_compare=True, ), - "skills": PrimitiveMapping("skills", "/SKILL.md", "skill_standard"), + "skills": PrimitiveMapping( + "skills", + "/SKILL.md", + "skill_standard", + deploy_root=".agents", + ), "commands": PrimitiveMapping("workflows", ".md", "windsurf_workflow"), "hooks": PrimitiveMapping("", "hooks.json", "windsurf_hooks"), }, diff --git a/tests/unit/integration/test_data_driven_dispatch.py b/tests/unit/integration/test_data_driven_dispatch.py index 7d72cc6cb..f0a5cbb48 100644 --- a/tests/unit/integration/test_data_driven_dispatch.py +++ b/tests/unit/integration/test_data_driven_dispatch.py @@ -310,7 +310,7 @@ def test_partition_parity_with_old_buckets(self): "agents_opencode", "agents_codex", # NOTE: windsurf no longer exposes an 'agents' primitive - # (its content deploys as skills under .windsurf/skills/). + # (its content deploys as skills under .agents/skills/). "commands", # was commands_claude, aliased "commands_cursor", "commands_gemini", diff --git a/tests/unit/integration/test_scope_integration.py b/tests/unit/integration/test_scope_integration.py index fbe4fb911..4955c8932 100644 --- a/tests/unit/integration/test_scope_integration.py +++ b/tests/unit/integration/test_scope_integration.py @@ -357,7 +357,7 @@ def test_project_scope_uses_windsurf_root(self): assert "instructions" in resolved.primitives assert "skills" in resolved.primitives # windsurf intentionally does not expose an 'agents' primitive: - # Cascade discovers SKILL.md uniformly under .windsurf/skills/. + # Cascade discovers SKILL.md uniformly under .agents/skills/. assert "agents" not in resolved.primitives def test_user_scope_uses_codeium_windsurf_root(self): diff --git a/tests/unit/integration/test_targets.py b/tests/unit/integration/test_targets.py index 34a53847c..8fa37bc8b 100644 --- a/tests/unit/integration/test_targets.py +++ b/tests/unit/integration/test_targets.py @@ -305,21 +305,22 @@ def test_copilot_profile_lists_root_generated_file(self): # --------------------------------------------------------------------------- -# Skill routing convergence (convergence ยง1) +# Skill routing convergence (apm#1520) # --------------------------------------------------------------------------- class TestDefaultSkillRouting: - """Assert that the 4 documented clients route skills to .agents/ by default.""" + """Assert that the documented clients route skills to .agents/ by default.""" def test_default_skill_routing_uses_agents_dir_for_documented_clients(self): - """copilot, cursor, opencode, codex, gemini all have deploy_root='.agents' on skills.""" + """copilot, cursor, opencode, codex, gemini, windsurf all have deploy_root='.agents' on skills.""" expected = { "copilot": ".agents", "cursor": ".agents", "opencode": ".agents", "codex": ".agents", "gemini": ".agents", + "windsurf": ".agents", "claude": None, # not documented as .agents/-aware } for name, want_root in expected.items(): @@ -335,11 +336,12 @@ def test_legacy_skill_paths_flag_restores_per_client_routing(self): from apm_cli.integration.targets import apply_legacy_skill_paths profiles = [ - KNOWN_TARGETS[n] for n in ("copilot", "cursor", "opencode", "codex", "claude", "gemini") + KNOWN_TARGETS[n] + for n in ("copilot", "cursor", "opencode", "codex", "claude", "gemini", "windsurf") ] restored = apply_legacy_skill_paths(profiles) - # All 6 should have deploy_root=None after legacy restore + # All 7 should have deploy_root=None after legacy restore for profile in restored: skills_pm = profile.primitives.get("skills") assert skills_pm is not None, f"{profile.name} should have skills" @@ -374,6 +376,38 @@ def test_gemini_legacy_skill_paths_restores_per_client_routing(self): f"gemini: expected deploy_root=None (legacy), got {skills_pm.deploy_root!r}" ) + def test_windsurf_skill_routing_uses_agents_dir_by_default(self): + """Windsurf (now Devin Desktop) converges skills onto .agents/skills/ (apm#1520).""" + profile = KNOWN_TARGETS["windsurf"] + skills_pm = profile.primitives["skills"] + assert skills_pm.deploy_root == ".agents", ( + f"windsurf: expected deploy_root='.agents', got {skills_pm.deploy_root!r}" + ) + # The deploy directory is /skills/; with the + # override that resolves to .agents/skills/ (parts, not substring). + effective_root = skills_pm.deploy_root or profile.root_dir + resolved = Path(effective_root) / "skills" + assert resolved.parts == (".agents", "skills"), ( + f"windsurf: expected .agents/skills/, got {resolved.parts!r}" + ) + + def test_windsurf_legacy_skill_paths_restores_per_client_routing(self): + """With apply_legacy_skill_paths(), windsurf skills return to .windsurf/skills/ (apm#1520).""" + from apm_cli.integration.targets import apply_legacy_skill_paths + + profiles = [KNOWN_TARGETS["windsurf"]] + restored = apply_legacy_skill_paths(profiles) + profile = restored[0] + skills_pm = profile.primitives["skills"] + assert skills_pm.deploy_root is None, ( + f"windsurf: expected deploy_root=None (legacy), got {skills_pm.deploy_root!r}" + ) + effective_root = skills_pm.deploy_root or profile.root_dir + resolved = Path(effective_root) / "skills" + assert resolved.parts == (".windsurf", "skills"), ( + f"windsurf legacy: expected .windsurf/skills/, got {resolved.parts!r}" + ) + def test_apply_legacy_does_not_mutate_known_targets(self): """apply_legacy_skill_paths must not mutate the global KNOWN_TARGETS.""" from apm_cli.integration.targets import apply_legacy_skill_paths diff --git a/tests/unit/integration/test_windsurf_uninstall_skills.py b/tests/unit/integration/test_windsurf_uninstall_skills.py index 9c235ac85..a6955bf28 100644 --- a/tests/unit/integration/test_windsurf_uninstall_skills.py +++ b/tests/unit/integration/test_windsurf_uninstall_skills.py @@ -1,12 +1,14 @@ """Regression tests for the windsurf uninstall-cleanup bug (#1481): ``apm uninstall`` silently failed to remove deployed skill directories -under ``.windsurf/skills/``. - -The fix dropped the ``agents`` primitive from the windsurf -``TargetProfile`` so that the deploy path ``.windsurf/skills//`` -is owned exclusively by the ``skills`` primitive. These tests pin the -post-fix shape of the windsurf profile and the directory-aware cleanup -path so a future regression -- e.g. re-introducing an ``agents`` +under the windsurf skills deploy path. + +The #1481 fix dropped the ``agents`` primitive from the windsurf +``TargetProfile`` so that the skills deploy path is owned exclusively +by the ``skills`` primitive. Since #1520 windsurf skills converge onto +the cross-tool ``.agents/skills//`` path (``deploy_root=".agents"``), +matching copilot/cursor/codex/gemini/opencode; these tests pin the +post-convergence shape of the windsurf profile and the directory-aware +cleanup path so a future regression -- e.g. re-introducing an ``agents`` primitive that aliases the same deploy path -- is caught here instead of silently corrupting an end-user workspace. """ @@ -24,41 +26,43 @@ class TestWindsurfTargetProfileShape: def test_windsurf_does_not_expose_agents_primitive(self): """windsurf intentionally has no 'agents' primitive: Cascade reads SKILL.md uniformly, so a separate agents primitive would re-create - the .windsurf/skills/ path collision.""" + the skills deploy-path collision.""" windsurf = KNOWN_TARGETS["windsurf"] assert "agents" not in windsurf.primitives, ( "windsurf must not declare an 'agents' primitive: it shares the " - "deploy path '.windsurf/skills/' with the 'skills' primitive and " - "would re-introduce the silent uninstall-cleanup bug." + "skills deploy path ('.agents/skills/') with the 'skills' " + "primitive and would re-introduce the silent uninstall-cleanup bug." ) def test_windsurf_skills_primitive_uses_standard_format(self): """windsurf 'skills' primitive uses the standard skill_standard - format (deployed as SKILL.md under .windsurf/skills/).""" + format (deployed as SKILL.md under .agents/skills/ since #1520).""" windsurf = KNOWN_TARGETS["windsurf"] skills = windsurf.primitives["skills"] assert skills.subdir == "skills" assert skills.extension == "/SKILL.md" assert skills.format_id == "skill_standard" + assert skills.deploy_root == ".agents" class TestWindsurfPartitionRouting: - """partition_managed_files must route .windsurf/skills/ paths to the - cross-target 'skills' bucket -- not to a windsurf-specific agents bucket.""" + """partition_managed_files must route windsurf's converged skills deploy + path (.agents/skills/) to the cross-target 'skills' bucket -- not to a + windsurf-specific agents bucket.""" def test_windsurf_skill_path_routes_to_skills_bucket(self): - """The lockfile path '.windsurf/skills/' must land in the + """The lockfile path '.agents/skills/' must land in the 'skills' bucket so SkillIntegrator (directory-aware) handles it.""" managed = { - ".windsurf/skills/code-review", - ".windsurf/skills/grill-me", + ".agents/skills/code-review", + ".agents/skills/grill-me", } buckets = BaseIntegrator.partition_managed_files(managed) - assert ".windsurf/skills/code-review" in buckets["skills"], ( + assert ".agents/skills/code-review" in buckets["skills"], ( "windsurf skill path must be in the cross-target 'skills' bucket" ) - assert ".windsurf/skills/grill-me" in buckets["skills"] + assert ".agents/skills/grill-me" in buckets["skills"] def test_no_agents_windsurf_bucket_is_created(self): """The 'agents_windsurf' bucket must not exist: windsurf no longer @@ -70,25 +74,25 @@ def test_windsurf_skill_path_not_routed_to_other_buckets(self): """A windsurf skill path must NOT leak into instructions/commands/ hooks buckets (which would mean the prefix trie matched the wrong primitive).""" - managed = {".windsurf/skills/my-skill"} + managed = {".agents/skills/my-skill"} buckets = BaseIntegrator.partition_managed_files(managed) for bucket_name, paths in buckets.items(): if bucket_name == "skills": continue - assert ".windsurf/skills/my-skill" not in paths, ( + assert ".agents/skills/my-skill" not in paths, ( f"windsurf skill path leaked into bucket '{bucket_name}'" ) class TestWindsurfSkillUninstallCleanup: """End-to-end: SkillIntegrator.sync_integration must remove the - .windsurf/skills// directories that install deployed.""" + .agents/skills// directories that install deployed.""" def test_sync_removes_windsurf_skill_directories(self, tmp_path: Path): - """Regression: skill dirs under .windsurf/skills/ created at install + """Regression: skill dirs under .agents/skills/ created at install time must be removed when listed in managed_files.""" - skills_root = tmp_path / ".windsurf" / "skills" + skills_root = tmp_path / ".agents" / "skills" skills_root.mkdir(parents=True) managed_a = skills_root / "code-review" @@ -105,8 +109,8 @@ def test_sync_removes_windsurf_skill_directories(self, tmp_path: Path): (user_skill / "SKILL.md").write_text("authored by user\n") managed = { - ".windsurf/skills/code-review", - ".windsurf/skills/grill-me", + ".agents/skills/code-review", + ".agents/skills/grill-me", } stats = SkillIntegrator().sync_integration(None, tmp_path, managed_files=managed) @@ -125,13 +129,13 @@ def test_sync_removes_windsurf_skill_directories(self, tmp_path: Path): def test_sync_handles_trailing_slash_in_managed_path(self, tmp_path: Path): """Lockfile entries may carry a trailing slash on directory paths; cleanup must work either way.""" - skills_root = tmp_path / ".windsurf" / "skills" + skills_root = tmp_path / ".agents" / "skills" skills_root.mkdir(parents=True) skill = skills_root / "code-review" skill.mkdir() (skill / "SKILL.md").write_text("managed") - managed = {".windsurf/skills/code-review/"} + managed = {".agents/skills/code-review/"} stats = SkillIntegrator().sync_integration(None, tmp_path, managed_files=managed) # Primary assertion: the directory is gone regardless of how the