From 1239cbeefcb52dc436daa37e3ccee57236581597 Mon Sep 17 00:00:00 2001 From: yxshee <84263676+yxshee@users.noreply.github.com> Date: Sun, 18 Oct 2026 00:20:17 +0530 Subject: [PATCH 1/4] fix(presets): recompose surviving skills when a lower preset layer is removed Removing a preset that is the base (or a middle layer) of another preset's prepend/append/wrap composition left the removed preset's content in the active agent's SKILL.md (Claude, Codex, Copilot with --skills, ...), while command-file agents such as Gemini were recomposed onto the new stack. The composed skill is owned by the surviving preset at the top of the chain, so _unregister_skills() restores nothing for the removed preset, and _reconcile_skills() only revisited the active skills directory when that restore had touched it. The recomposed .composed/.md that _reconcile_composed_commands() writes was therefore never rendered into the skill, and `specify preset resolve` disagreed with what the agent ran. After a removal, also reconcile active-directory skills for commands whose winning layer composes, as long as the skill still carries the winning preset's source marker. A hand-edited skill is left alone, matching the existing protection for non-owned skills during removal. Assisted-by: Claude Code (model: Claude Opus 5.5, autonomous) Co-Authored-By: Claude Opus 5.5 --- src/specify_cli/presets/_manager_skills.py | 45 +++++++-- .../presets/test_manager_skills.py | 91 +++++++++++++++++++ 2 files changed, 130 insertions(+), 6 deletions(-) diff --git a/src/specify_cli/presets/_manager_skills.py b/src/specify_cli/presets/_manager_skills.py index 5a4a4fb739..f58beef2af 100644 --- a/src/specify_cli/presets/_manager_skills.py +++ b/src/specify_cli/presets/_manager_skills.py @@ -151,6 +151,7 @@ def _reconcile_skills( # directory-independent, so it's computed once and then applied to # every affected directory below. preset_cmds: Dict[str, List[str]] = {} + composed_winners: Dict[str, str] = {} non_preset_skills: List[tuple] = [] managed_skill_names: set = set() reconciled_skill_commands: set[str] = set() @@ -195,6 +196,8 @@ def _reconcile_skills( pack_dir = self.presets_dir / pack_id if top_path.is_relative_to(pack_dir): preset_cmds.setdefault(pack_id, []).append(cmd_name) + if layers[0]["strategy"] != "replace": + composed_winners[cmd_name] = pack_id found_preset = True break if not found_preset: @@ -394,16 +397,21 @@ def apply_to_dir( extra_dirs = extra_skills_dirs or {} if active_skills_dir: active_provenance = extra_dirs.get(active_skills_dir) - if extra_skills_dirs is None or active_provenance: + active_names: Optional[Set[str]] = None + if extra_skills_dirs is not None: + # Removal restores only skills the removed preset still owned, + # but a surviving preset composing over a removed lower layer + # owns its skill: recompose it while it keeps that marker. + active_names = set(active_provenance[1]) if active_provenance else set() + active_names |= self._skills_owned_by_preset( + active_skills_dir, composed_winners + ) + if active_names is None or active_names: apply_to_dir( active_skills_dir, active_ai, is_active=True, - managed_names=( - set(active_provenance[1]) - if active_provenance - else None - ), + managed_names=active_names, ) for extra_dir, (extra_agent, extra_names) in extra_dirs.items(): @@ -418,6 +426,31 @@ def apply_to_dir( return reconciled_skill_commands + def _skills_owned_by_preset( + self, skills_dir: Path, owners: Dict[str, str] + ) -> Set[str]: + """Return skills in ``skills_dir`` whose source marker names their preset. + + ``owners`` maps command names to the expected owning preset id. + """ + registrar = self._command_registrar() + owned: Set[str] = set() + for cmd_name, pack_id in owners.items(): + for skill_name in self._skill_names_for_command(cmd_name): + skill_file = skills_dir / skill_name / "SKILL.md" + if not self._validate_skill_subdir( + skill_file.parent, create=False, skills_root=skills_dir + ) or not skill_file.is_file(): + continue + try: + content = skill_file.read_text(encoding="utf-8") + except (OSError, UnicodeDecodeError): + continue + metadata = registrar.parse_frontmatter(content)[0].get("metadata") + if isinstance(metadata, dict) and metadata.get("source") == f"preset:{pack_id}": + owned.add(skill_name) + return owned + def _resolve_agent_skills_dir(self, agent_name: str) -> Path: """Resolve the real skill output directory for an integration.""" from .. import _get_skills_dir as _project_skills_dir diff --git a/tests/specify_cli/presets/test_manager_skills.py b/tests/specify_cli/presets/test_manager_skills.py index 7ae833bfb9..31387b80bf 100644 --- a/tests/specify_cli/presets/test_manager_skills.py +++ b/tests/specify_cli/presets/test_manager_skills.py @@ -1897,6 +1897,97 @@ def test_remove_preserves_non_owned_skill_during_reconciliation( "---\nname: speckit-specify\n---\n\nUser-owned body\n" ) + def _install_composed_specify_stack(self, project_dir, temp_dir, layers): + """Install ``(preset_id, body, strategy, priority)`` layers for speckit.specify.""" + self._write_init_options(project_dir, ai="claude", ai_skills=True) + self._create_skill(project_dir / ".claude" / "skills", "speckit-specify") + (project_dir / ".specify" / "templates" / "commands" / "specify.md").write_text( + "---\ndescription: Core specify\n---\n\nCore specify body\n", + encoding="utf-8", + ) + manager = PresetManager(project_dir) + for preset_id, body, strategy, priority in layers: + preset_dir = self._create_command_preset( + temp_dir, preset_id, "speckit.specify", preset_id, body + ) + if strategy != "replace": + manifest_path = preset_dir / "preset.yml" + manifest = yaml.safe_load(manifest_path.read_text()) + manifest["provides"]["templates"][0]["strategy"] = strategy + manifest_path.write_text(yaml.dump(manifest)) + manager.install_from_directory(preset_dir, "0.1.5", priority=priority) + return manager, project_dir / ".claude" / "skills" / "speckit-specify" / "SKILL.md" + + def test_remove_base_preset_recomposes_surviving_composed_skill( + self, project_dir, temp_dir + ): + """Removing a composition's base layer must recompose the surviving skill. + + The appending preset owns the composed SKILL.md, so removing the + replace preset beneath it restored nothing in the active skills + directory and reconciliation skipped it: the skill kept the removed + preset's body while command-file agents were recomposed onto core. + """ + manager, skill_file = self._install_composed_specify_stack( + project_dir, + temp_dir, + [ + ("base-preset", "Base preset body", "replace", 10), + ("append-preset", "Appended body", "append", 5), + ], + ) + content = skill_file.read_text(encoding="utf-8") + assert "Base preset body" in content and "Appended body" in content + + assert manager.remove("base-preset") is True + + content = skill_file.read_text(encoding="utf-8") + assert "Base preset body" not in content + assert "Core specify body" in content + assert "Appended body" in content + assert "preset:append-preset" in content + + def test_remove_middle_preset_drops_its_layer_from_composed_skill( + self, project_dir, temp_dir + ): + """Removing a middle composing layer must drop its fragment from the skill.""" + manager, skill_file = self._install_composed_specify_stack( + project_dir, + temp_dir, + [ + ("base-preset", "Base preset body", "replace", 10), + ("middle-preset", "Middle body", "append", 7), + ("top-preset", "Top body", "append", 5), + ], + ) + assert "Middle body" in skill_file.read_text(encoding="utf-8") + + assert manager.remove("middle-preset") is True + + content = skill_file.read_text(encoding="utf-8") + assert "Middle body" not in content + assert "Base preset body" in content + assert "Top body" in content + + def test_remove_base_preset_preserves_hand_edited_composed_skill( + self, project_dir, temp_dir + ): + """Recomposition after removal must not overwrite a hand-edited skill.""" + manager, skill_file = self._install_composed_specify_stack( + project_dir, + temp_dir, + [ + ("base-preset", "Base preset body", "replace", 10), + ("append-preset", "Appended body", "append", 5), + ], + ) + edited = "---\nname: speckit-specify\n---\n\nUser-owned body\n" + skill_file.write_text(edited, encoding="utf-8") + + assert manager.remove("base-preset") is True + + assert skill_file.read_text(encoding="utf-8") == edited + def test_shared_skills_dir_restored_once_using_active_agent( self, project_dir, temp_dir ): From 8c0328f1132f275351c575e94f6cbeb35dac3485 Mon Sep 17 00:00:00 2001 From: yxshee <84263676+yxshee@users.noreply.github.com> Date: Mon, 19 Oct 2026 18:53:32 +0530 Subject: [PATCH 2/4] fix(presets): limit removal recomposition to owned skill names When both the modern (speckit-specify) and legacy (speckit.specify) skill directories exist, the active-directory recomposition added for lower-layer removal selected the command because the modern skill kept the winning preset's marker, and _register_skills() then rewrote both directories, overwriting a user-owned legacy skill. Give _register_skills() an optional only_skill_names filter and pass the directory's managed names on removal, so only names that are still preset-owned are written. Install and rescaffold calls pass None and keep writing every existing directory, as before. Assisted-by: Claude Code (model: Claude Opus 5.5, autonomous) Co-Authored-By: Claude Opus 5.5 --- src/specify_cli/presets/_manager_skills.py | 15 ++++++++++- .../presets/test_manager_skills.py | 25 +++++++++++++++++++ 2 files changed, 39 insertions(+), 1 deletion(-) diff --git a/src/specify_cli/presets/_manager_skills.py b/src/specify_cli/presets/_manager_skills.py index f58beef2af..b170f4ecdc 100644 --- a/src/specify_cli/presets/_manager_skills.py +++ b/src/specify_cli/presets/_manager_skills.py @@ -365,7 +365,12 @@ def apply_to_dir( # Preserve exact prior behaviour for the currently # active directory (including the ability to create # brand-new skill subdirectories when ai_skills is on). - written = self._register_skills(filtered_manifest, pack_dir) + # On removal, write only the skill names this directory + # may manage, so a user-owned modern/legacy sibling of a + # recomposed skill is left alone. + written = self._register_skills( + filtered_manifest, pack_dir, only_skill_names=managed_names, + ) else: written = self._register_skills( filtered_manifest, pack_dir, @@ -659,6 +664,7 @@ def _register_skills( *, target_dir: Optional[Path] = None, target_agent: Optional[str] = None, + only_skill_names: Optional[Set[str]] = None, ) -> Dict[str, List[str]]: """Generate SKILL.md files for preset command overrides. @@ -684,6 +690,9 @@ def _register_skills( subdirectories are never created for a non-active, explicitly targeted directory (that creation path is only meaningful for the currently active agent). + only_skill_names: When given, write only these skill + directory names; any other existing modern or legacy + skill for the same command is left untouched. Returns: ``{agent_name: [skill_name, ...]}`` for the single active @@ -769,6 +778,10 @@ def _register_skills( missing_skill_dir = skills_dir / skill_name if not missing_skill_dir.exists(): target_skill_names.append(skill_name) + if only_skill_names is not None: + target_skill_names = [ + name for name in target_skill_names if name in only_skill_names + ] if not target_skill_names: continue diff --git a/tests/specify_cli/presets/test_manager_skills.py b/tests/specify_cli/presets/test_manager_skills.py index 31387b80bf..9c89ade670 100644 --- a/tests/specify_cli/presets/test_manager_skills.py +++ b/tests/specify_cli/presets/test_manager_skills.py @@ -1988,6 +1988,31 @@ def test_remove_base_preset_preserves_hand_edited_composed_skill( assert skill_file.read_text(encoding="utf-8") == edited + def test_remove_base_preset_preserves_user_owned_legacy_skill( + self, project_dir, temp_dir + ): + """Recomposition writes only the skill name that still carries the + winning preset's marker, not a user-owned legacy sibling.""" + manager, skill_file = self._install_composed_specify_stack( + project_dir, + temp_dir, + [ + ("base-preset", "Base preset body", "replace", 10), + ("append-preset", "Appended body", "append", 5), + ], + ) + legacy_file = skill_file.parent.parent / "speckit.specify" / "SKILL.md" + legacy_file.parent.mkdir(parents=True, exist_ok=True) + legacy = "---\nname: speckit.specify\n---\n\nUser-owned legacy body\n" + legacy_file.write_text(legacy, encoding="utf-8") + + assert manager.remove("base-preset") is True + + assert legacy_file.read_text(encoding="utf-8") == legacy + content = skill_file.read_text(encoding="utf-8") + assert "Base preset body" not in content + assert "Appended body" in content + def test_shared_skills_dir_restored_once_using_active_agent( self, project_dir, temp_dir ): From 57bdfc9b9d8cd250c76569d6163af180957591f5 Mon Sep 17 00:00:00 2001 From: yxshee <84263676+yxshee@users.noreply.github.com> Date: Mon, 19 Oct 2026 19:48:40 +0530 Subject: [PATCH 3/4] fix(presets): retire a composed skill whose only base was removed When the removed preset was the only replace base of a composing command, with no core or extension fallback, resolution returns None. Command-file agents unregister that command, but the survivor's stale .composed/.md stayed behind, and the removal recomposition rendered it back into the skill, so the removed preset's instructions stayed active. Track composing winners whose stack no longer resolves, delete their stale composed cache, and on removal delete the active skill only while it still carries the winning preset's source marker. Hand-edited skills are kept, matching the existing ownership protection. Assisted-by: Claude Code (model: Claude Opus 5.5, autonomous) Co-Authored-By: Claude Opus 5.5 --- src/specify_cli/presets/_manager_skills.py | 23 ++++++++- .../presets/test_manager_skills.py | 49 +++++++++++++++++++ 2 files changed, 71 insertions(+), 1 deletion(-) diff --git a/src/specify_cli/presets/_manager_skills.py b/src/specify_cli/presets/_manager_skills.py index b170f4ecdc..ea4d23720a 100644 --- a/src/specify_cli/presets/_manager_skills.py +++ b/src/specify_cli/presets/_manager_skills.py @@ -152,6 +152,9 @@ def _reconcile_skills( # every affected directory below. preset_cmds: Dict[str, List[str]] = {} composed_winners: Dict[str, str] = {} + # Composing winners whose stack no longer has a base (resolution + # returns None): their .composed cache and skill are stale. + unresolved_winners: Dict[str, str] = {} non_preset_skills: List[tuple] = [] managed_skill_names: set = set() reconciled_skill_commands: set[str] = set() @@ -197,7 +200,15 @@ def _reconcile_skills( if top_path.is_relative_to(pack_dir): preset_cmds.setdefault(pack_id, []).append(cmd_name) if layers[0]["strategy"] != "replace": - composed_winners[cmd_name] = pack_id + if resolver.resolve_content(cmd_name, "command") is None: + # Nothing left to compose onto: drop the stale + # cache so it can't be rendered back into a skill. + unresolved_winners[cmd_name] = pack_id + stale = pack_dir / ".composed" / f"{cmd_name}.md" + if stale.is_file() and not stale.is_symlink(): + stale.unlink() + else: + composed_winners[cmd_name] = pack_id found_preset = True break if not found_preset: @@ -411,6 +422,16 @@ def apply_to_dir( active_names |= self._skills_owned_by_preset( active_skills_dir, composed_winners ) + # Command-file agents unregister an uncomposable command; + # retire its skill too while it still carries the marker. + for stale_skill in self._skills_owned_by_preset( + active_skills_dir, unresolved_winners + ): + stale_dir = active_skills_dir / stale_skill + if self._validate_skill_subdir( + stale_dir, create=False, skills_root=active_skills_dir + ): + shutil.rmtree(stale_dir) if active_names is None or active_names: apply_to_dir( active_skills_dir, diff --git a/tests/specify_cli/presets/test_manager_skills.py b/tests/specify_cli/presets/test_manager_skills.py index 9c89ade670..01601459c1 100644 --- a/tests/specify_cli/presets/test_manager_skills.py +++ b/tests/specify_cli/presets/test_manager_skills.py @@ -1918,6 +1918,55 @@ def _install_composed_specify_stack(self, project_dir, temp_dir, layers): manager.install_from_directory(preset_dir, "0.1.5", priority=priority) return manager, project_dir / ".claude" / "skills" / "speckit-specify" / "SKILL.md" + def _install_unbacked_custom_stack(self, project_dir, temp_dir): + """Base + append presets for ``speckit.custom``, which has no core template.""" + self._write_init_options(project_dir, ai="claude", ai_skills=True) + self._create_skill(project_dir / ".claude" / "skills", "speckit-custom") + manager = PresetManager(project_dir) + for preset_id, body, strategy, priority in [ + ("base-preset", "Base preset body", "replace", 10), + ("append-preset", "Appended body", "append", 5), + ]: + preset_dir = self._create_command_preset( + temp_dir, preset_id, "speckit.custom", preset_id, body + ) + if strategy != "replace": + manifest_path = preset_dir / "preset.yml" + manifest = yaml.safe_load(manifest_path.read_text()) + manifest["provides"]["templates"][0]["strategy"] = strategy + manifest_path.write_text(yaml.dump(manifest)) + manager.install_from_directory(preset_dir, "0.1.5", priority=priority) + skill_file = project_dir / ".claude" / "skills" / "speckit-custom" / "SKILL.md" + assert "Base preset body" in skill_file.read_text(encoding="utf-8") + return manager, skill_file + + def test_remove_sole_base_preset_retires_stale_composed_skill( + self, project_dir, temp_dir + ): + """With no core or extension fallback, removing the only base leaves + nothing to compose onto: the composed skill and its cache must not + keep serving the removed preset's instructions.""" + manager, skill_file = self._install_unbacked_custom_stack(project_dir, temp_dir) + cache = manager.presets_dir / "append-preset" / ".composed" / "speckit.custom.md" + + with pytest.warns(UserWarning, match="no base layer"): + assert manager.remove("base-preset") is True + + assert not skill_file.exists() + assert not cache.exists() + + def test_remove_sole_base_preset_keeps_hand_edited_skill( + self, project_dir, temp_dir + ): + manager, skill_file = self._install_unbacked_custom_stack(project_dir, temp_dir) + edited = "---\nname: speckit-custom\n---\n\nUser-owned body\n" + skill_file.write_text(edited, encoding="utf-8") + + with pytest.warns(UserWarning, match="no base layer"): + assert manager.remove("base-preset") is True + + assert skill_file.read_text(encoding="utf-8") == edited + def test_remove_base_preset_recomposes_surviving_composed_skill( self, project_dir, temp_dir ): From c184e1a5345f388e1a2204873ccd64cf49b1bd20 Mon Sep 17 00:00:00 2001 From: yxshee <84263676+yxshee@users.noreply.github.com> Date: Mon, 19 Oct 2026 20:28:29 +0530 Subject: [PATCH 4/4] fix(presets): guard, widen and journal stale composed-cache deletion Round-3 review of the no-base removal fix: - Delete a cache only when no path component below the project root is a symlink, so a symlinked .composed directory can't redirect the deletion to a file outside the project. - Drop every surviving preset's .composed/.md for an uncomposable command, not only the winner's: a middle preset's stale cache could be rendered back into the skill by a later rescaffold. - Delete through the journal-aware unlink helper, as the cache writes already do, so a failing integration lifecycle can restore the cache. Assisted-by: Claude Code (model: Claude Opus 5.5, autonomous) Co-Authored-By: Claude Opus 5.5 --- src/specify_cli/presets/_manager_skills.py | 38 +++++++++- .../presets/test_manager_skills.py | 70 ++++++++++++++++++- 2 files changed, 102 insertions(+), 6 deletions(-) diff --git a/src/specify_cli/presets/_manager_skills.py b/src/specify_cli/presets/_manager_skills.py index ea4d23720a..d7a2ec323f 100644 --- a/src/specify_cli/presets/_manager_skills.py +++ b/src/specify_cli/presets/_manager_skills.py @@ -204,9 +204,9 @@ def _reconcile_skills( # Nothing left to compose onto: drop the stale # cache so it can't be rendered back into a skill. unresolved_winners[cmd_name] = pack_id - stale = pack_dir / ".composed" / f"{cmd_name}.md" - if stale.is_file() and not stale.is_symlink(): - stale.unlink() + self._drop_stale_composed_caches( + cmd_name, presets_by_priority + ) else: composed_winners[cmd_name] = pack_id found_preset = True @@ -452,6 +452,38 @@ def apply_to_dir( return reconciled_skill_commands + def _drop_stale_composed_caches( + self, cmd_name: str, presets_by_priority: List[Any] + ) -> None: + """Delete every preset's ``.composed/.md`` for an uncomposable command. + + With no base left, any preset's cached composition of ``cmd_name`` + still holds the removed layer and could be rendered back by a later + registration. A cache reached through a symlinked ancestor below the + project root is skipped, so nothing outside the project is deleted; + the deletion is journaled like the cache writes. + """ + from ..integrations._file_changes import unlink as _unlink_file + + for pack_id, _meta in presets_by_priority: + stale = self.presets_dir / pack_id / ".composed" / f"{cmd_name}.md" + if not stale.is_file() or not self._has_no_symlink_below_project(stale): + continue + _unlink_file(stale) + + def _has_no_symlink_below_project(self, path: Path) -> bool: + """True if ``path`` is under the project root with no symlinked component.""" + try: + path.relative_to(self.project_root) + except ValueError: + return False + current = path + while current != self.project_root: + if current.is_symlink(): + return False + current = current.parent + return True + def _skills_owned_by_preset( self, skills_dir: Path, owners: Dict[str, str] ) -> Set[str]: diff --git a/tests/specify_cli/presets/test_manager_skills.py b/tests/specify_cli/presets/test_manager_skills.py index 01601459c1..86dea134ff 100644 --- a/tests/specify_cli/presets/test_manager_skills.py +++ b/tests/specify_cli/presets/test_manager_skills.py @@ -1918,12 +1918,12 @@ def _install_composed_specify_stack(self, project_dir, temp_dir, layers): manager.install_from_directory(preset_dir, "0.1.5", priority=priority) return manager, project_dir / ".claude" / "skills" / "speckit-specify" / "SKILL.md" - def _install_unbacked_custom_stack(self, project_dir, temp_dir): - """Base + append presets for ``speckit.custom``, which has no core template.""" + def _install_unbacked_custom_stack(self, project_dir, temp_dir, layers=None): + """Presets for ``speckit.custom``, which has no core template.""" self._write_init_options(project_dir, ai="claude", ai_skills=True) self._create_skill(project_dir / ".claude" / "skills", "speckit-custom") manager = PresetManager(project_dir) - for preset_id, body, strategy, priority in [ + for preset_id, body, strategy, priority in layers or [ ("base-preset", "Base preset body", "replace", 10), ("append-preset", "Appended body", "append", 5), ]: @@ -1967,6 +1967,70 @@ def test_remove_sole_base_preset_keeps_hand_edited_skill( assert skill_file.read_text(encoding="utf-8") == edited + def test_remove_sole_base_preset_keeps_cache_behind_symlinked_dir( + self, project_dir, temp_dir + ): + """A ``.composed`` directory that links outside the project is not + followed when stale caches are deleted.""" + manager, _skill = self._install_unbacked_custom_stack(project_dir, temp_dir) + composed = manager.presets_dir / "append-preset" / ".composed" + external = temp_dir / "external-composed" + shutil.move(str(composed), str(external)) + composed.symlink_to(external, target_is_directory=True) + target = external / "speckit.custom.md" + assert target.is_file() + + with pytest.warns(UserWarning, match="no base layer"): + assert manager.remove("base-preset") is True + + assert target.is_file() + + def test_remove_sole_base_under_two_composers_survives_rescaffold( + self, project_dir, temp_dir + ): + """Every surviving preset's stale cache is dropped, so a later + rescaffold cannot render the removed base back into the skill.""" + manager, skill_file = self._install_unbacked_custom_stack( + project_dir, + temp_dir, + [ + ("base-preset", "Base preset body", "replace", 10), + ("middle-preset", "Middle body", "append", 7), + ("top-preset", "Top body", "append", 5), + ], + ) + + with pytest.warns(UserWarning, match="no base layer"): + assert manager.remove("base-preset") is True + manager.register_enabled_presets_for_agent("claude") + + for preset_id in ("middle-preset", "top-preset"): + assert not ( + manager.presets_dir / preset_id / ".composed" / "speckit.custom.md" + ).exists() + assert not skill_file.exists() or "Base preset body" not in skill_file.read_text( + encoding="utf-8" + ) + + def test_stale_cache_deletion_is_journaled(self, project_dir, temp_dir): + """The cache deletion is reported to the lifecycle file-change journal.""" + from specify_cli.integrations._file_changes import file_change_observer + + manager, _skill = self._install_unbacked_custom_stack(project_dir, temp_dir) + cache = manager.presets_dir / "append-preset" / ".composed" / "speckit.custom.md" + events = [] + token = file_change_observer.set( + lambda path, before, removal: events.append((path, before, removal)) + ) + try: + with pytest.warns(UserWarning, match="no base layer"): + assert manager.remove("base-preset") is True + finally: + file_change_observer.reset(token) + + assert (cache, True, True) in events + assert not cache.exists() + def test_remove_base_preset_recomposes_surviving_composed_skill( self, project_dir, temp_dir ):