Skip to content

Commit fc15fdc

Browse files
committed
fix: rebase onto upstream/main, resolve conflicts with PR #2189
upstream/main merged PR #2189 (wrap-only strategy) which overlaps with our comprehensive composition strategies (prepend/append/wrap). Resolved conflicts keeping our implementation as source of truth: - README: keep our future considerations (composition is now fully implemented, not a future item) - presets.py: keep our composition architecture (_reconcile_composed_commands, collect_all_layers, resolve_content) while preserving #2189's _substitute_core_template which is used by agents.py for skill generation - tests: keep both test sets (our composition tests + #2189's wrap tests), removed TestReplayWrapsForCommand and TestInstallRemoveWrapLifecycle which test the superseded _replay_wraps_for_command API; our composition tests cover equivalent scenarios - Restored missing _unregister_commands call in remove() that was lost during #2189 merge
1 parent 22e7699 commit fc15fdc

9 files changed

Lines changed: 1893 additions & 856 deletions

File tree

presets/ARCHITECTURE.md

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -41,6 +41,24 @@ The resolution is implemented three times to ensure consistency:
4141
- **Bash**: `resolve_template()` in `scripts/bash/common.sh`
4242
- **PowerShell**: `Resolve-Template` in `scripts/powershell/common.ps1`
4343

44+
### Composition Strategies
45+
46+
Templates, commands, and scripts support a `strategy` field that controls how a preset's content is combined with lower-priority content instead of fully replacing it:
47+
48+
| Strategy | Description | Templates | Commands | Scripts |
49+
|----------|-------------|-----------|----------|---------|
50+
| `replace` (default) | Fully replaces lower-priority content ||||
51+
| `prepend` | Places content before lower-priority content (separated by a blank line) ||||
52+
| `append` | Places content after lower-priority content (separated by a blank line) ||||
53+
| `wrap` | Content contains `{CORE_TEMPLATE}` (templates/commands) or `$CORE_SCRIPT` (scripts) placeholder replaced with lower-priority content ||||
54+
55+
Composition is recursive — multiple composing presets chain. The `PresetResolver.resolve_content()` method walks the full priority stack bottom-up and applies each layer's strategy.
56+
57+
Content resolution functions for composition:
58+
- **Python**: `PresetResolver.resolve_content()` in `src/specify_cli/presets.py`
59+
- **Bash**: `resolve_template_content()` in `scripts/bash/common.sh`
60+
- **PowerShell**: `Resolve-TemplateContent` in `scripts/powershell/common.ps1`
61+
4462
## Command Registration
4563

4664
When a preset is installed with `type: "command"` entries, the `PresetManager` registers them into all detected agent directories using the shared `CommandRegistrar` from `src/specify_cli/agents.py`.

presets/README.md

Lines changed: 33 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -61,7 +61,37 @@ specify preset add healthcare-compliance --priority 5 # overrides enterprise-sa
6161
specify preset add pm-workflow --priority 1 # overrides everything
6262
```
6363

64-
Presets **override**, they don't merge. If two presets both provide `spec-template`, the one with the lowest priority number wins entirely.
64+
Presets **override by default**, they don't merge. If two presets both provide `spec-template` with the default `replace` strategy, the one with the lowest priority number wins entirely. However, presets can use **composition strategies** to augment rather than replace content.
65+
66+
### Composition Strategies
67+
68+
Presets can declare a `strategy` per template to control how content is combined. The `name` field identifies which template to compose with in the priority stack, while `file` points to the actual content file (which can differ from the convention path `templates/<name>.md`):
69+
70+
```yaml
71+
provides:
72+
templates:
73+
- type: "template"
74+
name: "spec-template"
75+
file: "templates/spec-addendum.md"
76+
strategy: "append" # adds content after the core template
77+
```
78+
79+
| Strategy | Description |
80+
|----------|-------------|
81+
| `replace` (default) | Fully replaces the lower-priority template |
82+
| `prepend` | Places content **before** the resolved lower-priority template, separated by a blank line |
83+
| `append` | Places content **after** the resolved lower-priority template, separated by a blank line |
84+
| `wrap` | Content contains `{CORE_TEMPLATE}` placeholder (or `$CORE_SCRIPT` for scripts) replaced with the lower-priority content |
85+
86+
**Supported combinations:**
87+
88+
| Type | `replace` | `prepend` | `append` | `wrap` |
89+
|------|-----------|-----------|----------|--------|
90+
| **template** | ✓ (default) | ✓ | ✓ | ✓ |
91+
| **command** | ✓ (default) | ✓ | ✓ | ✓ |
92+
| **script** | ✓ (default) | — | — | ✓ |
93+
94+
Multiple composing presets chain recursively. For example, a security preset with `prepend` and a compliance preset with `append` will produce: security header + core content + compliance footer.
6595

6696
## Catalog Management
6797

@@ -108,13 +138,5 @@ See [scaffold/](scaffold/) for a scaffold you can copy to create your own preset
108138

109139
The following enhancements are under consideration for future releases:
110140

111-
- **Composition strategies** — Allow presets to declare a `strategy` per template instead of the default `replace`:
112-
113-
| Type | `replace` | `prepend` | `append` | `wrap` |
114-
|------|-----------|-----------|----------|--------|
115-
| **template** | ✓ (default) ||||
116-
| **command** | ✓ (default) ||||
117-
| **script** | ✓ (default) ||||
118-
119-
For artifacts and commands (which are LLM directives), `wrap` injects preset content before and after the core template using a `{CORE_TEMPLATE}` placeholder (implemented). For scripts, `wrap` would run custom logic before/after the core script via a `$CORE_SCRIPT` variable (not yet implemented).
120-
- **Script overrides** — Enable presets to provide alternative versions of core scripts (e.g. `create-new-feature.sh`) for workflow customization. A `strategy: "wrap"` option could allow presets to run custom logic before/after the core script without fully replacing it.
141+
- **Structural merge strategies** — Parsing Markdown sections for per-section granularity (e.g., "replace only ## Security").
142+
- **Conflict detection** — `specify preset lint` / `specify preset doctor` for detecting composition conflicts.

presets/scaffold/preset.yml

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,14 @@ provides:
3232
templates:
3333
# CUSTOMIZE: Define your template overrides
3434
# Templates are document scaffolds (spec-template.md, plan-template.md, etc.)
35+
#
36+
# Strategy options (optional, defaults to "replace"):
37+
# replace - Fully replaces the lower-priority template (default)
38+
# prepend - Places this content BEFORE the lower-priority template
39+
# append - Places this content AFTER the lower-priority template
40+
# wrap - Uses {CORE_TEMPLATE} placeholder, replaced with lower-priority content
41+
#
42+
# Note: Scripts only support "replace" and "wrap" strategies.
3543
- type: "template"
3644
name: "spec-template"
3745
file: "templates/spec-template.md"
@@ -45,6 +53,26 @@ provides:
4553
# description: "Custom plan template"
4654
# replaces: "plan-template"
4755

56+
# COMPOSITION EXAMPLES:
57+
# The `file` field points to the content file (can differ from the
58+
# convention path `templates/<name>.md`). The `name` field identifies
59+
# which template to compose with in the priority stack.
60+
#
61+
# Append additional sections to an existing template:
62+
# - type: "template"
63+
# name: "spec-template"
64+
# file: "templates/spec-addendum.md"
65+
# description: "Add compliance section to spec template"
66+
# strategy: "append"
67+
#
68+
# Wrap a command with preamble/sign-off:
69+
# - type: "command"
70+
# name: "speckit.specify"
71+
# file: "commands/specify-wrapper.md"
72+
# description: "Wrap specify command with compliance checks"
73+
# strategy: "wrap"
74+
# # In the wrapper file, use {CORE_TEMPLATE} where the original content goes
75+
4876
# OVERRIDE EXTENSION TEMPLATES:
4977
# Presets sit above extensions in the resolution stack, so you can
5078
# override templates provided by any installed extension.

scripts/bash/common.sh

Lines changed: 232 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -321,7 +321,8 @@ try:
321321
data = json.load(f)
322322
presets = data.get('presets', {})
323323
for pid, meta in sorted(presets.items(), key=lambda x: x[1].get('priority', 10)):
324-
print(pid)
324+
if meta.get('enabled', True) is not False:
325+
print(pid)
325326
except Exception:
326327
sys.exit(1)
327328
" 2>/dev/null); then
@@ -373,3 +374,233 @@ except Exception:
373374
return 1
374375
}
375376

377+
# Resolve a template name to composed content using composition strategies.
378+
# Reads strategy metadata from preset manifests and composes content
379+
# from multiple layers using prepend, append, or wrap strategies.
380+
#
381+
# Usage: CONTENT=$(resolve_template_content "template-name" "$REPO_ROOT")
382+
# Returns composed content string on stdout; exit code 1 if not found.
383+
resolve_template_content() {
384+
local template_name="$1"
385+
local repo_root="$2"
386+
local base="$repo_root/.specify/templates"
387+
388+
# Collect all layers (highest priority first)
389+
local -a layer_paths=()
390+
local -a layer_strategies=()
391+
392+
# Priority 1: Project overrides (always "replace")
393+
local override="$base/overrides/${template_name}.md"
394+
if [ -f "$override" ]; then
395+
layer_paths+=("$override")
396+
layer_strategies+=("replace")
397+
fi
398+
399+
# Priority 2: Installed presets (sorted by priority from .registry)
400+
local presets_dir="$repo_root/.specify/presets"
401+
if [ -d "$presets_dir" ]; then
402+
local registry_file="$presets_dir/.registry"
403+
local sorted_presets=""
404+
if [ -f "$registry_file" ] && command -v python3 >/dev/null 2>&1; then
405+
if sorted_presets=$(SPECKIT_REGISTRY="$registry_file" python3 -c "
406+
import json, sys, os
407+
try:
408+
with open(os.environ['SPECKIT_REGISTRY']) as f:
409+
data = json.load(f)
410+
presets = data.get('presets', {})
411+
for pid, meta in sorted(presets.items(), key=lambda x: x[1].get('priority', 10)):
412+
if meta.get('enabled', True) is not False:
413+
print(pid)
414+
except Exception:
415+
sys.exit(1)
416+
" 2>/dev/null); then
417+
if [ -n "$sorted_presets" ]; then
418+
while IFS= read -r preset_id; do
419+
# Read strategy and file path from preset manifest
420+
local strategy="replace"
421+
local manifest_file=""
422+
local manifest="$presets_dir/$preset_id/preset.yml"
423+
if [ -f "$manifest" ] && command -v python3 >/dev/null 2>&1; then
424+
# Requires PyYAML; falls back to replace/convention if unavailable
425+
local result
426+
local py_stderr
427+
py_stderr=$(mktemp)
428+
result=$(SPECKIT_MANIFEST="$manifest" SPECKIT_TMPL="$template_name" python3 -c "
429+
import sys, os
430+
try:
431+
import yaml
432+
except ImportError:
433+
print('yaml_missing', file=sys.stderr)
434+
print('replace\t')
435+
sys.exit(0)
436+
try:
437+
with open(os.environ['SPECKIT_MANIFEST']) as f:
438+
data = yaml.safe_load(f)
439+
for t in data.get('provides', {}).get('templates', []):
440+
if t.get('name') == os.environ['SPECKIT_TMPL'] and t.get('type', 'template') == 'template':
441+
print(t.get('strategy', 'replace') + '\t' + t.get('file', ''))
442+
sys.exit(0)
443+
print('replace\t')
444+
except Exception:
445+
print('replace\t')
446+
" 2>"$py_stderr")
447+
local parse_status=$?
448+
if [ $parse_status -eq 0 ] && [ -n "$result" ]; then
449+
IFS=$'\t' read -r strategy manifest_file <<< "$result"
450+
strategy=$(printf '%s' "$strategy" | tr '[:upper:]' '[:lower:]')
451+
fi
452+
# Warn only when PyYAML is explicitly missing
453+
if grep -q 'yaml_missing' "$py_stderr" 2>/dev/null; then
454+
echo "Warning: PyYAML not available; composition strategies in $manifest may be ignored" >&2
455+
fi
456+
rm -f "$py_stderr"
457+
fi
458+
# Try manifest file path first, then convention path
459+
local candidate=""
460+
if [ -n "$manifest_file" ]; then
461+
local mf="$presets_dir/$preset_id/$manifest_file"
462+
[ -f "$mf" ] && candidate="$mf"
463+
fi
464+
if [ -z "$candidate" ]; then
465+
local cf="$presets_dir/$preset_id/templates/${template_name}.md"
466+
[ -f "$cf" ] && candidate="$cf"
467+
fi
468+
if [ -n "$candidate" ]; then
469+
layer_paths+=("$candidate")
470+
layer_strategies+=("$strategy")
471+
fi
472+
done <<< "$sorted_presets"
473+
fi
474+
else
475+
# python3 failed — fall back to unordered directory scan (replace only)
476+
for preset in "$presets_dir"/*/; do
477+
[ -d "$preset" ] || continue
478+
local candidate="$preset/templates/${template_name}.md"
479+
if [ -f "$candidate" ]; then
480+
layer_paths+=("$candidate")
481+
layer_strategies+=("replace")
482+
fi
483+
done
484+
fi
485+
else
486+
# No python3 or registry — fall back to unordered directory scan (replace only)
487+
for preset in "$presets_dir"/*/; do
488+
[ -d "$preset" ] || continue
489+
local candidate="$preset/templates/${template_name}.md"
490+
if [ -f "$candidate" ]; then
491+
layer_paths+=("$candidate")
492+
layer_strategies+=("replace")
493+
fi
494+
done
495+
fi
496+
fi
497+
498+
# Priority 3: Extension-provided templates (always "replace")
499+
local ext_dir="$repo_root/.specify/extensions"
500+
if [ -d "$ext_dir" ]; then
501+
for ext in "$ext_dir"/*/; do
502+
[ -d "$ext" ] || continue
503+
case "$(basename "$ext")" in .*) continue;; esac
504+
local candidate="$ext/templates/${template_name}.md"
505+
if [ -f "$candidate" ]; then
506+
layer_paths+=("$candidate")
507+
layer_strategies+=("replace")
508+
fi
509+
done
510+
fi
511+
512+
# Priority 4: Core templates (always "replace")
513+
local core="$base/${template_name}.md"
514+
if [ -f "$core" ]; then
515+
layer_paths+=("$core")
516+
layer_strategies+=("replace")
517+
fi
518+
519+
local count=${#layer_paths[@]}
520+
[ "$count" -eq 0 ] && return 1
521+
522+
# Check if any layer uses a non-replace strategy
523+
local has_composition=false
524+
for s in "${layer_strategies[@]}"; do
525+
[ "$s" != "replace" ] && has_composition=true && break
526+
done
527+
528+
# If the top (highest-priority) layer is replace, it wins entirely —
529+
# lower layers are irrelevant regardless of their strategies.
530+
if [ "${layer_strategies[0]}" = "replace" ]; then
531+
cat "${layer_paths[0]}"
532+
return 0
533+
fi
534+
535+
if [ "$has_composition" = false ]; then
536+
cat "${layer_paths[0]}"
537+
return 0
538+
fi
539+
540+
# Compose bottom-up: start from lowest priority
541+
local content=""
542+
local has_base=false
543+
local started=false
544+
local i
545+
for (( i=count-1; i>=0; i-- )); do
546+
local path="${layer_paths[$i]}"
547+
local strat="${layer_strategies[$i]}"
548+
local layer_content
549+
# Preserve trailing newlines: append sentinel, then strip it
550+
layer_content=$(cat "$path"; printf x)
551+
layer_content="${layer_content%x}"
552+
553+
if [ "$started" = false ]; then
554+
if [ "$strat" = "replace" ]; then
555+
content="$layer_content"
556+
has_base=true
557+
fi
558+
# Keep consuming replace layers from the bottom until we hit a non-replace
559+
if [ "$strat" != "replace" ]; then
560+
# No base content to compose onto
561+
[ "$has_base" = false ] && return 1
562+
started=true
563+
case "$strat" in
564+
prepend) content="$(printf '%s\n\n%s' "$layer_content" "$content")" ;;
565+
append) content="$(printf '%s\n\n%s' "$content" "$layer_content")" ;;
566+
wrap)
567+
# Validate placeholder exists
568+
case "$layer_content" in
569+
*'{CORE_TEMPLATE}'*) ;;
570+
*) echo "Error: wrap strategy missing {CORE_TEMPLATE} placeholder" >&2; return 1 ;;
571+
esac
572+
# Replace all occurrences to match Python/PowerShell behavior
573+
while [[ "$layer_content" == *'{CORE_TEMPLATE}'* ]]; do
574+
local before="${layer_content%%\{CORE_TEMPLATE\}*}"
575+
local after="${layer_content#*\{CORE_TEMPLATE\}}"
576+
layer_content="${before}${content}${after}"
577+
done
578+
content="$layer_content"
579+
;;
580+
esac
581+
fi
582+
else
583+
case "$strat" in
584+
replace) content="$layer_content" ;;
585+
prepend) content="$(printf '%s\n\n%s' "$layer_content" "$content")" ;;
586+
append) content="$(printf '%s\n\n%s' "$content" "$layer_content")" ;;
587+
wrap)
588+
case "$layer_content" in
589+
*'{CORE_TEMPLATE}'*) ;;
590+
*) echo "Error: wrap strategy missing {CORE_TEMPLATE} placeholder" >&2; return 1 ;;
591+
esac
592+
while [[ "$layer_content" == *'{CORE_TEMPLATE}'* ]]; do
593+
local before="${layer_content%%\{CORE_TEMPLATE\}*}"
594+
local after="${layer_content#*\{CORE_TEMPLATE\}}"
595+
layer_content="${before}${content}${after}"
596+
done
597+
content="$layer_content"
598+
;;
599+
esac
600+
fi
601+
done
602+
603+
printf '%s' "$content"
604+
return 0
605+
}
606+

0 commit comments

Comments
 (0)