diff --git a/src/specify_cli/presets/_manager_skills.py b/src/specify_cli/presets/_manager_skills.py index 5a4a4fb739..cc4d516be3 100644 --- a/src/specify_cli/presets/_manager_skills.py +++ b/src/specify_cli/presets/_manager_skills.py @@ -151,6 +151,9 @@ def _reconcile_skills( # directory-independent, so it's computed once and then applied to # every affected directory below. preset_cmds: Dict[str, List[str]] = {} + # Commands won by a composing preset whose stack no longer has any + # replace base: nothing resolves, so their owned output is removed. + unresolvable_cmds: Set[str] = set() non_preset_skills: List[tuple] = [] managed_skill_names: set = set() reconciled_skill_commands: set[str] = set() @@ -195,6 +198,11 @@ 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 ( + all(layer["strategy"] != "replace" for layer in layers) + and resolver.resolve_content(cmd_name, "command") is None + ): + unresolvable_cmds.add(cmd_name) found_preset = True break if not found_preset: @@ -327,6 +335,18 @@ def apply_to_dir( for name in self._skill_names_for_command(cmd) ) ] + stale_cmds = [cmd for cmd in dir_cmds if cmd in unresolvable_cmds] + if stale_cmds: + self._remove_owned_skills( + skills_dir, + [ + name + for cmd in stale_cmds + for name in self._skill_names_for_command(cmd) + if name in dir_managed_names + ], + ) + dir_cmds = [cmd for cmd in dir_cmds if cmd not in unresolvable_cmds] if not dir_cmds: continue pack_dir = self.presets_dir / pack_id @@ -362,11 +382,14 @@ 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) + written = self._register_skills( + filtered_manifest, pack_dir, only_skill_names=managed_names, + ) else: written = self._register_skills( filtered_manifest, pack_dir, target_dir=skills_dir, target_agent=dir_agent or "", + only_skill_names=managed_names, ) if target_agent is None: written_names = { @@ -391,7 +414,18 @@ def apply_to_dir( # reconciliation just wrote to on its behalf (#2948). self._merge_pack_registered_skills(pack_id, written) - extra_dirs = extra_skills_dirs or {} + extra_dirs = dict(extra_skills_dirs or {}) + if extra_skills_dirs is not None: + # _unregister_skills only restored what the removed preset owned. + # Skills a surviving preset rendered on top of it are its own, so + # refresh those recorded destinations from the remaining stack too. + for skills_dir, (agent, names) in self._surviving_owned_skills( + command_names, presets_by_priority, active_ai, active_skills_dir + ).items(): + prior_agent, prior_names = extra_dirs.get(skills_dir, (agent, [])) + extra_dirs[skills_dir] = ( + prior_agent, list(dict.fromkeys([*prior_names, *names])) + ) if active_skills_dir: active_provenance = extra_dirs.get(active_skills_dir) if extra_skills_dirs is None or active_provenance: @@ -418,6 +452,97 @@ def apply_to_dir( return reconciled_skill_commands + def _surviving_owned_skills( + self, + command_names: List[str], + presets_by_priority: List[Any], + active_ai: Optional[str], + active_skills_dir: Optional[Path], + ) -> Dict[Path, tuple[Optional[str], List[str]]]: + """Recorded skill destinations of ``command_names`` still owned by a survivor. + + Reads each remaining preset's ``registered_skills`` (inferring per-agent + provenance for legacy flat lists) and keeps the names whose ``SKILL.md`` + still carries that preset's ``preset:`` marker. + """ + registrar = self._command_registrar() + affected = { + name + for cmd_name in command_names + for name in self._skill_names_for_command(cmd_name) + } + found: Dict[Path, tuple[Optional[str], List[str]]] = {} + for pack_id, meta in presets_by_priority: + if not isinstance(meta, dict): + continue + recorded = meta.get("registered_skills") + if isinstance(recorded, list) and active_ai: + recorded = self._infer_legacy_skill_provenance( + [n for n in recorded if isinstance(n, str)], pack_id, active_ai + ) + if not isinstance(recorded, dict): + continue + for agent, names in recorded.items(): + if not isinstance(agent, str) or not isinstance(names, list): + continue + skills_dir = ( + active_skills_dir + if agent == active_ai + else self._safe_skills_dir_for_agent(agent) + ) + if skills_dir is None: + continue + for name in names: + if name not in affected: + continue + skill_file = skills_dir / 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}": + dir_agent, dir_names = found.get(skills_dir, (agent, [])) + found[skills_dir] = (dir_agent, [*dir_names, name]) + return found + + def _remove_owned_skills(self, skills_dir: Path, skill_names: List[str]) -> None: + """Delete preset-owned skills among ``skill_names`` (journaled). + + A skill is preset-owned when its ``SKILL.md`` source marker names an + installed preset, either as ``preset:`` (skill registration) or + ``:`` (command registration for native skill agents); + anything else is left untouched. + """ + from ..integrations._file_changes import after_file_change, before_file_change + + registrar = self._command_registrar() + pack_ids = list(self.registry.list()) + for name in dict.fromkeys(skill_names): + skill_dir = skills_dir / name + skill_file = skill_dir / "SKILL.md" + if not self._validate_skill_subdir( + skill_dir, 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") + source = metadata.get("source") if isinstance(metadata, dict) else None + if isinstance(source, str) and any( + source == f"preset:{pack_id}" or source.startswith(f"{pack_id}:") + for pack_id in pack_ids + ): + before_file_change(skill_dir, removal=True) + shutil.rmtree(skill_dir) + after_file_change(skill_dir) + 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 @@ -626,6 +751,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. @@ -651,6 +777,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 @@ -736,6 +865,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 @@ -759,9 +892,11 @@ def _register_skills( or frontmatter.get("strategy") or "replace" ) + # A cached composition can outlive its base (e.g. one removed + # while its composer was disabled), so the resolver decides even + # when the cache exists. if ( effective_strategy != "replace" - and not composed_file.exists() and resolver.resolve_content(cmd_name, "command") is None ): continue diff --git a/tests/specify_cli/presets/test_manager_skills.py b/tests/specify_cli/presets/test_manager_skills.py index 7ae833bfb9..647895ee45 100644 --- a/tests/specify_cli/presets/test_manager_skills.py +++ b/tests/specify_cli/presets/test_manager_skills.py @@ -1897,6 +1897,391 @@ 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 _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 layers or [ + ("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" + if layers is None: + 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 composer's skill is removed, and a later + rescaffold does not render its stale cached composition back.""" + manager, skill_file = self._install_unbacked_custom_stack(project_dir, temp_dir) + + with pytest.warns(UserWarning, match="no base layer"): + assert manager.remove("base-preset") is True + manager.register_enabled_presets_for_agent("claude") + + assert not skill_file.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_sole_base_under_two_composers_survives_rescaffold( + self, project_dir, temp_dir + ): + """With two composers and no base left, the skill is removed and a + rescaffold does not render either composer's stale cache back.""" + 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") + + assert not skill_file.exists() + + def test_stale_skill_removal_is_journaled(self, project_dir, temp_dir): + """Removing the composer's stale skill is reported to the lifecycle journal.""" + from specify_cli.integrations._file_changes import file_change_observer + + manager, _skill = self._install_unbacked_custom_stack(project_dir, temp_dir) + 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) + + skill_dir = project_dir / ".claude" / "skills" / "speckit-custom" + assert (skill_dir, True, True) in events + assert not skill_dir.exists() + + def _install_custom_layers(self, project_dir, temp_dir, *, core=False, alias=None): + """Base (replace) + append presets for ``speckit.custom`` under Claude.""" + self._write_init_options(project_dir, ai="claude", ai_skills=True) + self._create_skill(project_dir / ".claude" / "skills", "speckit-custom") + if core: + core_cmds = project_dir / ".specify" / "templates" / "commands" + core_cmds.mkdir(parents=True, exist_ok=True) + (core_cmds / "custom.md").write_text( + "---\ndescription: Core custom\n---\n\nCore custom body\n", + encoding="utf-8", + ) + 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 + ) + manifest_path = preset_dir / "preset.yml" + manifest = yaml.safe_load(manifest_path.read_text()) + manifest["provides"]["templates"][0]["strategy"] = strategy + if alias and strategy != "replace": + manifest["provides"]["templates"][0]["aliases"] = [alias] + manifest_path.write_text(yaml.dump(manifest)) + manager.install_from_directory(preset_dir, "0.1.5", priority=priority) + return manager + + @pytest.mark.parametrize("core", [False, True], ids=["no-fallback", "core-fallback"]) + def test_remove_base_reconciles_skill_owned_by_lower_survivor_in_historical_dir( + self, project_dir, temp_dir, core + ): + """Claude's skill was rendered by ``middle``; ``top`` was installed later + under Codex only. Removing the base must still refresh Claude's skill, + whose recorded owner is ``middle``, not the current winner ``top``.""" + self._write_init_options(project_dir, ai="claude", ai_skills=True) + claude_skill = project_dir / ".claude" / "skills" / "speckit-custom" / "SKILL.md" + self._create_skill(project_dir / ".claude" / "skills", "speckit-custom") + if core: + core_cmds = project_dir / ".specify" / "templates" / "commands" + core_cmds.mkdir(parents=True, exist_ok=True) + (core_cmds / "custom.md").write_text( + "---\ndescription: Core custom\n---\n\nCore custom body\n", + encoding="utf-8", + ) + manager = PresetManager(project_dir) + + def install(preset_id, body, strategy, priority): + preset_dir = self._create_command_preset( + temp_dir, preset_id, "speckit.custom", preset_id, body + ) + 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) + + install("base-preset", "Base preset body", "replace", 10) + install("middle-preset", "Middle body", "append", 7) + assert "preset:middle-preset" in claude_skill.read_text(encoding="utf-8") + self._write_init_options(project_dir, ai="codex", ai_skills=True) + manager.register_enabled_presets_for_agent("codex") + install("top-preset", "Top body", "append", 5) + + if core: + assert manager.remove("base-preset") is True + content = claude_skill.read_text(encoding="utf-8") + assert "Base preset body" not in content + assert "Core custom body" in content and "Middle body" in content + else: + with pytest.warns(UserWarning, match="no base layer"): + assert manager.remove("base-preset") is True + assert not claude_skill.exists() + + def test_remove_base_reconciles_survivor_with_legacy_flat_skill_record( + self, project_dir, temp_dir + ): + """A surviving composer whose ``registered_skills`` is still a legacy + flat list gets its inactive agent's skill refreshed too.""" + manager = self._install_custom_layers(project_dir, temp_dir, core=True) + claude_skill = project_dir / ".claude" / "skills" / "speckit-custom" / "SKILL.md" + self._write_init_options(project_dir, ai="codex", ai_skills=True) + manager.register_enabled_presets_for_agent("codex") + manager.registry.update("append-preset", {"registered_skills": ["speckit-custom"]}) + + assert manager.remove("base-preset") is True + + content = claude_skill.read_text(encoding="utf-8") + assert "Base preset body" not in content + assert "Core custom body" in content and "Appended body" in content + + def test_reenabled_sole_composer_does_not_render_removed_base( + self, project_dir, temp_dir + ): + """The only composer is disabled while its base is removed; re-enabling + and rescaffolding neither renders its stale cache nor keeps its old + skill, since nothing is left to compose onto.""" + manager = self._install_custom_layers(project_dir, temp_dir) + skill = project_dir / ".claude" / "skills" / "speckit-custom" / "SKILL.md" + manager.registry.update("append-preset", {"enabled": False}) + manager.remove("base-preset") + manager.registry.update("append-preset", {"enabled": True}) + + with pytest.warns(UserWarning, match="no base layer"): + manager.register_enabled_presets_for_agent("claude") + + assert not skill.exists() + + @pytest.mark.parametrize("core", [False, True], ids=["no-fallback", "core-fallback"]) + def test_remove_base_reconciles_historical_agent_skill( + self, project_dir, temp_dir, core + ): + """A composer's skill in a historical (inactive) agent directory is + recomposed onto the fallback, or retired when there is none.""" + manager = self._install_custom_layers(project_dir, temp_dir, core=core) + claude_skill = project_dir / ".claude" / "skills" / "speckit-custom" / "SKILL.md" + self._write_init_options(project_dir, ai="codex", ai_skills=True) + manager.register_enabled_presets_for_agent("codex") + + if core: + assert manager.remove("base-preset") is True + content = claude_skill.read_text(encoding="utf-8") + assert "Base preset body" not in content + assert "Core custom body" in content and "Appended body" in content + else: + with pytest.warns(UserWarning, match="no base layer"): + assert manager.remove("base-preset") is True + assert not claude_skill.exists() + + def test_remove_sole_base_with_disabled_composer_survives_rescaffold( + self, project_dir, temp_dir + ): + """A composer disabled during the removal can't bring the removed base + back through its stale cache once it is re-enabled and rescaffolded.""" + 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), + ], + ) + manager.registry.update("middle-preset", {"enabled": False}) + + with pytest.warns(UserWarning, match="no base layer"): + assert manager.remove("base-preset") is True + manager.registry.update("middle-preset", {"enabled": True}) + manager.register_enabled_presets_for_agent("claude") + + assert not skill_file.exists() + + def test_unreadable_surviving_base_keeps_composed_output( + self, project_dir, temp_dir + ): + """Resolution also fails when a surviving base can't be decoded; that + must not be treated as a missing base that retires the output.""" + manager, skill_file = self._install_unbacked_custom_stack( + project_dir, + temp_dir, + [ + ("base-preset", "Base preset body", "replace", 10), + ("other-base", "Other base body", "replace", 8), + ("append-preset", "Appended body", "append", 5), + ], + ) + cache = manager.presets_dir / "append-preset" / ".composed" / "speckit.custom.md" + skill_before = skill_file.read_text(encoding="utf-8") + assert cache.is_file() and "Other base body" in skill_before + manifest = yaml.safe_load( + (manager.presets_dir / "other-base" / "preset.yml").read_text() + ) + command_file = manager.presets_dir / "other-base" / manifest["provides"]["templates"][0]["file"] + command_file.write_bytes(b"---\ndescription: x\n---\n\xff\xfe broken\n") + + manager.remove("base-preset") + + assert cache.is_file() + assert skill_file.read_text(encoding="utf-8") == skill_before + + 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_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 ): @@ -2103,6 +2488,42 @@ def test_remove_reconciles_skill_for_every_historical_agent( "the currently active agent" ) + def test_remove_keeps_user_owned_legacy_skill_in_historical_dir( + self, project_dir, temp_dir + ): + """Reconciling an inactive agent's directory writes only the managed + skill name, not a user-owned legacy sibling beside it.""" + self._write_init_options(project_dir, ai="claude", ai_skills=True) + claude_skills_dir = project_dir / ".claude" / "skills" + core_cmds = project_dir / ".specify" / "templates" / "commands" + core_cmds.mkdir(parents=True, exist_ok=True) + (core_cmds / "specify.md").write_text( + "---\ndescription: Core specify command\n---\n\nCore specify body\n", + encoding="utf-8", + ) + preset_b_dir = self._create_command_preset( + temp_dir, "hist-legacy-b", "speckit.specify", "Preset B", "preset B body", + ) + preset_a_dir = self._create_command_preset( + temp_dir, "hist-legacy-a", "speckit.specify", "Preset A", "preset A body", + ) + manager = PresetManager(project_dir) + manager.install_from_directory(preset_b_dir, "0.1.5", priority=10) + manager.install_from_directory(preset_a_dir, "0.1.5", priority=1) + self._write_init_options(project_dir, ai="codex", ai_skills=True) + manager.register_enabled_presets_for_agent("codex") + legacy_file = claude_skills_dir / "speckit.specify" / "SKILL.md" + legacy_file.parent.mkdir(parents=True) + legacy = "---\nname: speckit.specify\n---\n\nUser-owned legacy body\n" + legacy_file.write_text(legacy, encoding="utf-8") + + assert manager.remove("hist-legacy-a") is True + + assert legacy_file.read_text(encoding="utf-8") == legacy + assert "preset:hist-legacy-b" in ( + claude_skills_dir / "speckit-specify" / "SKILL.md" + ).read_text() + def test_skill_reconciliation_preserves_per_directory_names( self, project_dir, temp_dir ):