@@ -64,7 +64,9 @@ def _substitute_core_template(
6464 # (e.g. speckit.git.feature -> extensions/git/commands/speckit.git.feature.md)
6565 # are found before falling back to the short name used by core commands
6666 # (e.g. specify -> templates/commands/specify.md).
67- core_file = resolver .resolve (cmd_name , "command" ) or resolver .resolve (short_name , "command" )
67+ # Use resolve_core() to skip installed presets (tier 2), preventing accidental
68+ # nesting where another preset's wrap output is mistaken for the real core.
69+ core_file = resolver .resolve_core (cmd_name , "command" ) or resolver .resolve_core (short_name , "command" )
6870 if core_file is None :
6971 return body , {}
7072
@@ -600,6 +602,202 @@ def _unregister_commands(self, registered_commands: Dict[str, List[str]]) -> Non
600602 registrar = CommandRegistrar ()
601603 registrar .unregister_commands (registered_commands , self .project_root )
602604
605+ def _replay_wraps_for_command (self , cmd_name : str ) -> None :
606+ """Recompose and rewrite agent files for a wrap-strategy command.
607+
608+ Collects all installed presets that declare cmd_name in their
609+ wrap_commands registry field, sorts them so the highest-precedence
610+ preset (lowest priority number) wraps outermost, then writes the
611+ fully composed output to every agent directory.
612+
613+ Called after every install and remove to keep agent files correct
614+ regardless of installation order.
615+
616+ Args:
617+ cmd_name: Full command name (e.g. "speckit.specify")
618+ """
619+ try :
620+ from .agents import CommandRegistrar
621+ except ImportError :
622+ return
623+
624+ # Collect enabled presets that wrap this command, sorted ascending
625+ # (lowest priority number = highest precedence = outermost).
626+ wrap_presets = []
627+ for pack_id , metadata in self .registry .list_by_priority (include_disabled = False ):
628+ if cmd_name not in metadata .get ("wrap_commands" , []):
629+ continue
630+ pack_dir = self .presets_dir / pack_id
631+ if not pack_dir .is_dir ():
632+ continue # corrupted state — skip
633+ wrap_presets .append ((pack_id , pack_dir ))
634+
635+ if not wrap_presets :
636+ return
637+
638+ # Derive short name for core resolution fallback.
639+ short_name = cmd_name
640+ if short_name .startswith ("speckit." ):
641+ short_name = short_name [len ("speckit." ):]
642+
643+ resolver = PresetResolver (self .project_root )
644+ core_file = (
645+ resolver .resolve_core (cmd_name , "command" )
646+ or resolver .resolve_core (short_name , "command" )
647+ )
648+ if core_file is None :
649+ return
650+
651+ registrar = CommandRegistrar ()
652+ core_frontmatter , core_body = registrar .parse_frontmatter (
653+ core_file .read_text (encoding = "utf-8" )
654+ )
655+ replay_aliases : List [str ] = []
656+ seen_aliases : set [str ] = set ()
657+
658+ # Apply wraps innermost-first (reverse of ascending list).
659+ accumulated_body = core_body
660+ outermost_frontmatter = {}
661+ for pack_id , pack_dir in reversed (wrap_presets ):
662+ cmd_file = pack_dir / "commands" / f"{ cmd_name } .md"
663+ if not cmd_file .exists ():
664+ continue
665+ manifest_path = pack_dir / "preset.yml"
666+ if manifest_path .exists ():
667+ manifest = PresetManifest (manifest_path )
668+ for template in manifest .templates :
669+ if template .get ("type" ) != "command" or template .get ("name" ) != cmd_name :
670+ continue
671+ aliases = template .get ("aliases" , [])
672+ if not isinstance (aliases , list ):
673+ aliases = []
674+ for alias in aliases :
675+ if isinstance (alias , str ) and alias not in seen_aliases :
676+ replay_aliases .append (alias )
677+ seen_aliases .add (alias )
678+ wrap_fm , wrap_body = registrar .parse_frontmatter (
679+ cmd_file .read_text (encoding = "utf-8" )
680+ )
681+ accumulated_body = wrap_body .replace ("{CORE_TEMPLATE}" , accumulated_body )
682+ outermost_frontmatter = wrap_fm # last iteration = outermost preset
683+
684+ # Build final frontmatter: outermost preset wins; fall back to core for
685+ # scripts/agent_scripts if the outermost preset does not define them.
686+ final_frontmatter = dict (outermost_frontmatter )
687+ final_frontmatter .pop ("strategy" , None )
688+ for key in ("scripts" , "agent_scripts" ):
689+ if key not in final_frontmatter and key in core_frontmatter :
690+ final_frontmatter [key ] = core_frontmatter [key ]
691+
692+ outermost_pack_id = wrap_presets [- 1 ][0 ]
693+ composed_content = (
694+ registrar .render_frontmatter (final_frontmatter ) + "\n " + accumulated_body
695+ )
696+
697+ self ._replay_skill_override (cmd_name , composed_content , outermost_pack_id )
698+
699+ with tempfile .TemporaryDirectory () as tmpdir :
700+ tmp_path = Path (tmpdir )
701+ cmd_dir = tmp_path / "commands"
702+ cmd_dir .mkdir ()
703+ (cmd_dir / f"{ cmd_name } .md" ).write_text (composed_content , encoding = "utf-8" )
704+ registrar ._ensure_configs ()
705+ for agent_name , agent_config in registrar .AGENT_CONFIGS .items ():
706+ if agent_config .get ("extension" ) == "/SKILL.md" :
707+ continue
708+ agent_dir = self .project_root / agent_config ["dir" ]
709+ if not agent_dir .exists ():
710+ continue
711+ try :
712+ registrar .register_commands (
713+ agent_name ,
714+ [{
715+ "name" : cmd_name ,
716+ "file" : f"commands/{ cmd_name } .md" ,
717+ "aliases" : replay_aliases ,
718+ }],
719+ f"preset:{ outermost_pack_id } " ,
720+ tmp_path ,
721+ self .project_root ,
722+ )
723+ except ValueError :
724+ continue
725+
726+ def _replay_skill_override (
727+ self ,
728+ cmd_name : str ,
729+ composed_content : str ,
730+ outermost_pack_id : str ,
731+ ) -> None :
732+ """Rewrite any active SKILL.md override for a replayed wrap command."""
733+ skills_dir = self ._get_skills_dir ()
734+ if not skills_dir :
735+ return
736+
737+ from . import SKILL_DESCRIPTIONS , load_init_options
738+ from .agents import CommandRegistrar
739+
740+ init_opts = load_init_options (self .project_root )
741+ if not isinstance (init_opts , dict ):
742+ init_opts = {}
743+ selected_ai = init_opts .get ("ai" )
744+ if not isinstance (selected_ai , str ):
745+ return
746+
747+ registrar = CommandRegistrar ()
748+ agent_config = registrar .AGENT_CONFIGS .get (selected_ai , {})
749+ create_missing_skills = bool (init_opts .get ("ai_skills" )) and agent_config .get ("extension" ) != "/SKILL.md"
750+
751+ skill_name , legacy_skill_name = self ._skill_names_for_command (cmd_name )
752+ target_skill_names : List [str ] = []
753+ if (skills_dir / skill_name ).is_dir ():
754+ target_skill_names .append (skill_name )
755+ if legacy_skill_name != skill_name and (skills_dir / legacy_skill_name ).is_dir ():
756+ target_skill_names .append (legacy_skill_name )
757+ if not target_skill_names and create_missing_skills :
758+ missing_skill_dir = skills_dir / skill_name
759+ if not missing_skill_dir .exists ():
760+ target_skill_names .append (skill_name )
761+ if not target_skill_names :
762+ return
763+
764+ raw_short_name = cmd_name
765+ if raw_short_name .startswith ("speckit." ):
766+ raw_short_name = raw_short_name [len ("speckit." ):]
767+ short_name = raw_short_name .replace ("." , "-" )
768+ skill_title = self ._skill_title_from_command (cmd_name )
769+
770+ frontmatter , body = registrar .parse_frontmatter (composed_content )
771+ original_desc = frontmatter .get ("description" , "" )
772+ enhanced_desc = SKILL_DESCRIPTIONS .get (
773+ short_name ,
774+ original_desc or f"Spec-kit workflow command: { short_name } " ,
775+ )
776+ body = registrar .resolve_skill_placeholders (
777+ selected_ai , dict (frontmatter ), body , self .project_root
778+ )
779+
780+ for target_skill_name in target_skill_names :
781+ skill_subdir = skills_dir / target_skill_name
782+ if skill_subdir .exists () and not skill_subdir .is_dir ():
783+ continue
784+ skill_subdir .mkdir (parents = True , exist_ok = True )
785+ frontmatter_data = registrar .build_skill_frontmatter (
786+ selected_ai ,
787+ target_skill_name ,
788+ enhanced_desc ,
789+ f"preset:{ outermost_pack_id } " ,
790+ )
791+ frontmatter_text = yaml .safe_dump (frontmatter_data , sort_keys = False ).strip ()
792+ skill_content = (
793+ f"---\n "
794+ f"{ frontmatter_text } \n "
795+ f"---\n \n "
796+ f"# Speckit { skill_title } Skill\n \n "
797+ f"{ body } \n "
798+ )
799+ (skill_subdir / "SKILL.md" ).write_text (skill_content , encoding = "utf-8" )
800+
603801 def _get_skills_dir (self ) -> Optional [Path ]:
604802 """Return the active skills directory for preset skill overrides.
605803
@@ -1010,6 +1208,24 @@ def install_from_directory(
10101208 # Update corresponding skills when --ai-skills was previously used
10111209 registered_skills = self ._register_skills (manifest , dest_dir )
10121210
1211+ # Detect wrap commands before registry.add() so a read failure doesn't
1212+ # leave a partially-committed registry entry.
1213+ wrap_commands = []
1214+ try :
1215+ from .agents import CommandRegistrar as _CR
1216+ _registrar = _CR ()
1217+ for cmd_tmpl in manifest .templates :
1218+ if cmd_tmpl .get ("type" ) != "command" :
1219+ continue
1220+ cmd_file = dest_dir / cmd_tmpl ["file" ]
1221+ if not cmd_file .exists ():
1222+ continue
1223+ cmd_fm , _ = _registrar .parse_frontmatter (cmd_file .read_text (encoding = "utf-8" ))
1224+ if cmd_fm .get ("strategy" ) == "wrap" :
1225+ wrap_commands .append (cmd_tmpl ["name" ])
1226+ except ImportError :
1227+ pass
1228+
10131229 self .registry .add (manifest .id , {
10141230 "version" : manifest .version ,
10151231 "source" : "local" ,
@@ -1018,8 +1234,12 @@ def install_from_directory(
10181234 "priority" : priority ,
10191235 "registered_commands" : registered_commands ,
10201236 "registered_skills" : registered_skills ,
1237+ "wrap_commands" : wrap_commands ,
10211238 })
10221239
1240+ for cmd_name in wrap_commands :
1241+ self ._replay_wraps_for_command (cmd_name )
1242+
10231243 return manifest
10241244
10251245 def install_from_zip (
@@ -1094,9 +1314,16 @@ def remove(self, pack_id: str) -> bool:
10941314 # Restore original skills when preset is removed
10951315 registered_skills = metadata .get ("registered_skills" , []) if metadata else []
10961316 registered_commands = metadata .get ("registered_commands" , {}) if metadata else {}
1317+ wrap_commands = metadata .get ("wrap_commands" , []) if metadata else []
10971318 pack_dir = self .presets_dir / pack_id
1319+
1320+ # _unregister_skills must run before directory deletion (reads preset files)
10981321 if registered_skills :
10991322 self ._unregister_skills (registered_skills , pack_dir )
1323+ # When _unregister_skills has already handled skill-agent files, strip
1324+ # those entries from registered_commands to avoid double-deletion.
1325+ # (When registered_skills is empty, skill-agent entries in
1326+ # registered_commands are the only deletion path for those files.)
11001327 try :
11011328 from .agents import CommandRegistrar
11021329 except ImportError :
@@ -1108,14 +1335,42 @@ def remove(self, pack_id: str) -> bool:
11081335 if CommandRegistrar .AGENT_CONFIGS .get (agent_name , {}).get ("extension" ) != "/SKILL.md"
11091336 }
11101337
1111- # Unregister non-skill command files from AI agents.
1112- if registered_commands :
1113- self ._unregister_commands ( registered_commands )
1338+ # Remove from registry BEFORE deleting the directory so that
1339+ # _replay_wraps_for_command sees the post-removal registry state.
1340+ self .registry . remove ( pack_id )
11141341
11151342 if pack_dir .exists ():
11161343 shutil .rmtree (pack_dir )
11171344
1118- self .registry .remove (pack_id )
1345+ # Separate wrap commands from non-wrap commands in registered_commands.
1346+ non_wrap_commands = {
1347+ agent_name : [c for c in cmd_names if c not in wrap_commands ]
1348+ for agent_name , cmd_names in registered_commands .items ()
1349+ }
1350+ non_wrap_commands = {k : v for k , v in non_wrap_commands .items () if v }
1351+
1352+ # Unregister non-wrap command files from AI agents.
1353+ if non_wrap_commands :
1354+ self ._unregister_commands (non_wrap_commands )
1355+
1356+ # For each wrapped command, either re-compose remaining wraps or delete.
1357+ for cmd_name in wrap_commands :
1358+ remaining = [
1359+ pid for pid , meta in self .registry .list ().items ()
1360+ if cmd_name in meta .get ("wrap_commands" , [])
1361+ ]
1362+ if remaining :
1363+ self ._replay_wraps_for_command (cmd_name )
1364+ else :
1365+ # No wrap presets remain — delete the agent file entirely.
1366+ wrap_agent_commands = {
1367+ agent_name : [c for c in cmd_names if c == cmd_name ]
1368+ for agent_name , cmd_names in registered_commands .items ()
1369+ }
1370+ wrap_agent_commands = {k : v for k , v in wrap_agent_commands .items () if v }
1371+ if wrap_agent_commands :
1372+ self ._unregister_commands (wrap_agent_commands )
1373+
11191374 return True
11201375
11211376 def list_installed (self ) -> List [Dict [str , Any ]]:
@@ -1771,6 +2026,7 @@ def resolve(
17712026 self ,
17722027 template_name : str ,
17732028 template_type : str = "template" ,
2029+ skip_presets : bool = False ,
17742030 ) -> Optional [Path ]:
17752031 """Resolve a template name to its file path.
17762032
@@ -1779,6 +2035,8 @@ def resolve(
17792035 Args:
17802036 template_name: Template name (e.g., "spec-template")
17812037 template_type: Template type ("template", "command", or "script")
2038+ skip_presets: When True, skip tier 2 (installed presets). Use
2039+ resolve_core() as the preferred caller-facing API for this.
17822040
17832041 Returns:
17842042 Path to the resolved template file, or None if not found
@@ -1807,7 +2065,7 @@ def resolve(
18072065 return override
18082066
18092067 # Priority 2: Installed presets (sorted by priority — lower number wins)
1810- if self .presets_dir .exists ():
2068+ if not skip_presets and self .presets_dir .exists ():
18112069 registry = PresetRegistry (self .presets_dir )
18122070 for pack_id , _metadata in registry .list_by_priority ():
18132071 pack_dir = self .presets_dir / pack_id
@@ -1848,6 +2106,18 @@ def resolve(
18482106
18492107 return None
18502108
2109+ def resolve_core (
2110+ self ,
2111+ template_name : str ,
2112+ template_type : str = "template" ,
2113+ ) -> Optional [Path ]:
2114+ """Resolve against tiers 1, 3, and 4 only — skipping installed presets.
2115+
2116+ Use when resolving {CORE_TEMPLATE} to guarantee the result is actual
2117+ base content, never another preset's wrap output.
2118+ """
2119+ return self .resolve (template_name , template_type , skip_presets = True )
2120+
18512121 def resolve_with_source (
18522122 self ,
18532123 template_name : str ,
0 commit comments