Skip to content

Commit a7f547d

Browse files
Copilotmnriem
andcommitted
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 59aad55 commit a7f547d

File tree

8 files changed

+1170
-12
lines changed

8 files changed

+1170
-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
@@ -373,3 +373,146 @@ except Exception:
373373
return 1
374374
}
375375

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

0 commit comments

Comments
 (0)