feat: implement preset wrap strategy (#2189)

* feat: implement strategy: wrap

* fix: resolve merge conflict for strategy wrap correctness

* feat: multi-preset composable wrapping with priority ordering

Implements comment #4 from PR review: multiple installed wrap presets
now compose in priority order rather than overwriting each other.

Key changes:
- PresetResolver.resolve() gains skip_presets flag; resolve_core() wraps
  it to skip tier 2, preventing accidental nesting during replay
- _replay_wraps_for_command() recomposed all enabled wrap presets for a
  command in ascending priority order (innermost-first) after any
  install or remove
- _replay_skill_override() keeps SKILL.md in sync with the recomposed
  command body for ai-skills-enabled projects
- install_from_directory() detects strategy: wrap commands, stores
  wrap_commands in the registry entry, and calls replay after install
- remove() reads wrap_commands before deletion, removes registry entry
  before rmtree so replay sees post-removal state, then replays
  remaining wraps or unregisters when none remain

Tests: TestResolveCore (5), TestReplayWrapsForCommand (5),
TestInstallRemoveWrapLifecycle (5), plus 2 skill/alias regression tests

* fix: resolve extension commands via manifest file mapping

PresetResolver.resolve_extension_command_via_manifest() consults each
installed extension.yml to find the actual file declared for a command
name, rather than assuming the file is named <cmd_name>.md.  This fixes
_substitute_core_template for extensions like selftest where the manifest
maps speckit.selftest.extension → commands/selftest.md.

Resolution order in _substitute_core_template is now:
  1. resolve_core(cmd_name) — project overrides win, then name-based lookup
  2. resolve_extension_command_via_manifest(cmd_name) — manifest fallback
  3. resolve_core(short_name) — core template short-name fallback

Path traversal guard mirrors the containment check already present in
ExtensionManager to reject absolute paths or paths escaping the extension
root.

* fix: add bundled core_pack as Priority 5 in PresetResolver.resolve()

resolve_core() was returning None for built-in commands (implement,
specify, etc.) because PresetResolver only checked .specify/templates/
commands/ (Priority 4), which is never populated for commands in a
normal project. strategy:wrap presets rely on resolve_core() to fetch
the {CORE_TEMPLATE} body, so the wrap was silently skipped and SKILL.md
was never updated.

Priority 5 now checks core_pack/commands/ (wheel install) or
repo_root/templates/commands/ (source checkout), mirroring the pattern
used by _locate_core_pack() elsewhere.

Updated two tests whose assertions assumed resolve_core() always
returned None when .specify/templates/commands/ was absent.

* fix: harden preset wrap replay removal

* fix: stabilize existing directory error output

* fix: track outermost_pack_id from contributing preset; use Path.parts in tests

- outermost_pack_id now updates alongside outermost_frontmatter inside
  the wrap loop, so it reflects the actual last contributing preset
  rather than always taking wrap_presets[0] (which may have been skipped)
- Replace str(path) substring checks in TestResolveCore with Path.parts
  tuple comparisons for correct behaviour on Windows (CI runs windows-latest)

* fix: guard against non-mapping YAML manifests; apply integration post-processing in replay

- ExtensionManifest._load raises ValidationError for non-dict YAML roots instead of TypeError
- PresetManager._replay_wraps_for_command calls integration.post_process_skill_content,
  matching _register_skills behaviour
- PresetResolver skips extensions that raise OSError/TypeError/AttributeError on manifest load
- Tests: non-mapping YAML, OSError manifest skip, and replay integration post-processing
This commit is contained in:
Kennedy
2026-04-21 16:02:31 -04:00
committed by GitHub
parent 569d18a59d
commit 22e76995c7
10 changed files with 1771 additions and 16 deletions

View File

@@ -1115,7 +1115,7 @@ def init(
console.print(f"[cyan]--force supplied: merging into existing directory '[cyan]{project_name}[/cyan]'[/cyan]")
else:
error_panel = Panel(
f"Directory '[cyan]{project_name}[/cyan]' already exists\n"
f"Directory already exists: '[cyan]{project_name}[/cyan]'\n"
"Please choose a different project name or remove the existing directory.\n"
"Use [bold]--force[/bold] to merge into the existing directory.",
title="[red]Directory Conflict[/red]",
@@ -1371,7 +1371,6 @@ def init(
"branch_numbering": branch_numbering or "sequential",
"context_file": resolved_integration.context_file,
"here": here,
"preset": preset,
"script": selected_script,
"speckit_version": get_speckit_version(),
}