Skip to content

Commit fd29a72

Browse files
Copilotmnriem
andauthored
feat(presets): Add composition strategies (prepend, append, wrap) for templates, commands, and scripts
- Add strategy field validation in PresetManifest._validate() - Add VALID_PRESET_STRATEGIES and VALID_SCRIPT_STRATEGIES constants - Add PresetResolver.resolve_content() for composed content resolution - Add PresetResolver._collect_all_layers() for full priority stack - Update _register_commands() to compose before writing - Update CLI preset resolve command to show composition chain - Add resolve_template_content() to bash common.sh - Add Resolve-TemplateContent to PowerShell common.ps1 - Update scaffold preset.yml with strategy documentation - Update presets/README.md and ARCHITECTURE.md - Add 26 unit tests covering validation, composition, and chaining Agent-Logs-Url: https://github.com/github/spec-kit/sessions/c285c51b-f00b-480a-9eb2-ae70e3cbcb72 Co-authored-by: mnriem <15701806+mnriem@users.noreply.github.com>
1 parent a74ba68 commit fd29a72

File tree

8 files changed

+1172
-12
lines changed

8 files changed

+1172
-12
lines changed

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:
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` would inject preset content before and after the core template using a `{CORE_TEMPLATE}` placeholder. For scripts, `wrap` would run custom logic before/after the core script via a `$CORE_SCRIPT` variable.
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: 24 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,22 @@ provides:
4553
# description: "Custom plan template"
4654
# replaces: "plan-template"
4755

56+
# COMPOSITION EXAMPLES:
57+
# Append additional sections to an existing template:
58+
# - type: "template"
59+
# name: "spec-template"
60+
# file: "templates/spec-addendum.md"
61+
# description: "Add compliance section to spec template"
62+
# strategy: "append"
63+
#
64+
# Wrap a command with preamble/sign-off:
65+
# - type: "command"
66+
# name: "speckit.specify"
67+
# file: "commands/specify-wrapper.md"
68+
# description: "Wrap specify command with compliance checks"
69+
# strategy: "wrap"
70+
# # In the wrapper file, use {CORE_TEMPLATE} where the original content goes
71+
4872
# OVERRIDE EXTENSION TEMPLATES:
4973
# Presets sit above extensions in the resolution stack, so you can
5074
# override templates provided by any installed extension.

scripts/bash/common.sh

Lines changed: 143 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -360,3 +360,146 @@ except Exception:
360360
return 1
361361
}
362362

363+
# Resolve a template name to composed content using composition strategies.
364+
# Reads strategy metadata from preset manifests and composes content
365+
# from multiple layers using prepend, append, or wrap strategies.
366+
#
367+
# Usage: CONTENT=$(resolve_template_content "template-name" "$REPO_ROOT")
368+
# Returns composed content string on stdout; exit code 1 if not found.
369+
resolve_template_content() {
370+
local template_name="$1"
371+
local repo_root="$2"
372+
local base="$repo_root/.specify/templates"
373+
374+
# Collect all layers (highest priority first)
375+
local -a layer_paths=()
376+
local -a layer_strategies=()
377+
378+
# Priority 1: Project overrides (always "replace")
379+
local override="$base/overrides/${template_name}.md"
380+
if [ -f "$override" ]; then
381+
layer_paths+=("$override")
382+
layer_strategies+=("replace")
383+
fi
384+
385+
# Priority 2: Installed presets (sorted by priority from .registry)
386+
local presets_dir="$repo_root/.specify/presets"
387+
if [ -d "$presets_dir" ]; then
388+
local registry_file="$presets_dir/.registry"
389+
local sorted_presets=""
390+
if [ -f "$registry_file" ] && command -v python3 >/dev/null 2>&1; then
391+
if sorted_presets=$(SPECKIT_REGISTRY="$registry_file" python3 -c "
392+
import json, sys, os
393+
try:
394+
with open(os.environ['SPECKIT_REGISTRY']) as f:
395+
data = json.load(f)
396+
presets = data.get('presets', {})
397+
for pid, meta in sorted(presets.items(), key=lambda x: x[1].get('priority', 10)):
398+
print(pid)
399+
except Exception:
400+
sys.exit(1)
401+
" 2>/dev/null); then
402+
if [ -n "$sorted_presets" ]; then
403+
while IFS= read -r preset_id; do
404+
local candidate="$presets_dir/$preset_id/templates/${template_name}.md"
405+
if [ -f "$candidate" ]; then
406+
# Read strategy from preset manifest
407+
local strategy="replace"
408+
local manifest="$presets_dir/$preset_id/preset.yml"
409+
if [ -f "$manifest" ] && command -v python3 >/dev/null 2>&1; then
410+
local s
411+
s=$(SPECKIT_MANIFEST="$manifest" SPECKIT_TMPL="$template_name" python3 -c "
412+
import yaml, sys, os
413+
try:
414+
with open(os.environ['SPECKIT_MANIFEST']) as f:
415+
data = yaml.safe_load(f)
416+
for t in data.get('provides', {}).get('templates', []):
417+
if t.get('name') == os.environ['SPECKIT_TMPL'] and t.get('type', 'template') == 'template':
418+
print(t.get('strategy', 'replace'))
419+
sys.exit(0)
420+
print('replace')
421+
except Exception:
422+
print('replace')
423+
" 2>/dev/null) && strategy="$s"
424+
fi
425+
layer_paths+=("$candidate")
426+
layer_strategies+=("$strategy")
427+
fi
428+
done <<< "$sorted_presets"
429+
fi
430+
fi
431+
fi
432+
fi
433+
434+
# Priority 3: Extension-provided templates (always "replace")
435+
local ext_dir="$repo_root/.specify/extensions"
436+
if [ -d "$ext_dir" ]; then
437+
for ext in "$ext_dir"/*/; do
438+
[ -d "$ext" ] || continue
439+
case "$(basename "$ext")" in .*) continue;; esac
440+
local candidate="$ext/templates/${template_name}.md"
441+
if [ -f "$candidate" ]; then
442+
layer_paths+=("$candidate")
443+
layer_strategies+=("replace")
444+
fi
445+
done
446+
fi
447+
448+
# Priority 4: Core templates (always "replace")
449+
local core="$base/${template_name}.md"
450+
if [ -f "$core" ]; then
451+
layer_paths+=("$core")
452+
layer_strategies+=("replace")
453+
fi
454+
455+
local count=${#layer_paths[@]}
456+
[ "$count" -eq 0 ] && return 1
457+
458+
# Check if any layer uses a non-replace strategy
459+
local has_composition=false
460+
for s in "${layer_strategies[@]}"; do
461+
[ "$s" != "replace" ] && has_composition=true && break
462+
done
463+
464+
if [ "$has_composition" = false ]; then
465+
cat "${layer_paths[0]}"
466+
return 0
467+
fi
468+
469+
# Compose bottom-up: start from lowest priority
470+
local content=""
471+
local started=false
472+
local i
473+
for (( i=count-1; i>=0; i-- )); do
474+
local path="${layer_paths[$i]}"
475+
local strat="${layer_strategies[$i]}"
476+
local layer_content
477+
layer_content=$(cat "$path")
478+
479+
if [ "$started" = false ]; then
480+
if [ "$strat" = "replace" ]; then
481+
content="$layer_content"
482+
fi
483+
# Keep consuming replace layers from the bottom until we hit a non-replace
484+
if [ "$strat" != "replace" ]; then
485+
started=true
486+
case "$strat" in
487+
prepend) content="${layer_content}\n\n${content}" ;;
488+
append) content="${content}\n\n${layer_content}" ;;
489+
wrap) content="${layer_content//\{CORE_TEMPLATE\}/$content}" ;;
490+
esac
491+
fi
492+
else
493+
case "$strat" in
494+
replace) content="$layer_content" ;;
495+
prepend) content="${layer_content}\n\n${content}" ;;
496+
append) content="${content}\n\n${layer_content}" ;;
497+
wrap) content="${layer_content//\{CORE_TEMPLATE\}/$content}" ;;
498+
esac
499+
fi
500+
done
501+
502+
printf '%s' "$content"
503+
return 0
504+
}
505+

0 commit comments

Comments
 (0)