Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
18 changes: 18 additions & 0 deletions presets/ARCHITECTURE.md
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,24 @@ The resolution is implemented three times to ensure consistency:
- **Bash**: `resolve_template()` in `scripts/bash/common.sh`
- **PowerShell**: `Resolve-Template` in `scripts/powershell/common.ps1`

### Composition Strategies

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:

| Strategy | Description | Templates | Commands | Scripts |
|----------|-------------|-----------|----------|---------|
| `replace` (default) | Fully replaces lower-priority content | ✓ | ✓ | ✓ |
| `prepend` | Places content before lower-priority content (separated by a blank line) | ✓ | ✓ | — |
| `append` | Places content after lower-priority content (separated by a blank line) | ✓ | ✓ | — |
| `wrap` | Content contains `{CORE_TEMPLATE}` (templates/commands) or `$CORE_SCRIPT` (scripts) placeholder replaced with lower-priority content | ✓ | ✓ | ✓ |

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.

Content resolution functions for composition:
- **Python**: `PresetResolver.resolve_content()` in `src/specify_cli/presets.py`
- **Bash**: `resolve_template_content()` in `scripts/bash/common.sh`
- **PowerShell**: `Resolve-TemplateContent` in `scripts/powershell/common.ps1`

## Command Registration

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`.
Expand Down
44 changes: 33 additions & 11 deletions presets/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -61,7 +61,37 @@ specify preset add healthcare-compliance --priority 5 # overrides enterprise-sa
specify preset add pm-workflow --priority 1 # overrides everything
```

Presets **override**, they don't merge. If two presets both provide `spec-template`, the one with the lowest priority number wins entirely.
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.

### Composition Strategies

Presets can declare a `strategy` per template to control how content is combined:

```yaml
provides:
templates:
- type: "template"
name: "spec-template"
file: "templates/spec-addendum.md"
strategy: "append" # adds content after the core template
```

| Strategy | Description |
|----------|-------------|
| `replace` (default) | Fully replaces the lower-priority template |
| `prepend` | Places content **before** the resolved lower-priority template, separated by a blank line |
| `append` | Places content **after** the resolved lower-priority template, separated by a blank line |
| `wrap` | Content contains `{CORE_TEMPLATE}` placeholder (or `$CORE_SCRIPT` for scripts) replaced with the lower-priority content |

**Supported combinations:**

| Type | `replace` | `prepend` | `append` | `wrap` |
|------|-----------|-----------|----------|--------|
| **template** | ✓ (default) | ✓ | ✓ | ✓ |
| **command** | ✓ (default) | ✓ | ✓ | ✓ |
| **script** | ✓ (default) | — | — | ✓ |

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.

## Catalog Management

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

The following enhancements are under consideration for future releases:

- **Composition strategies** — Allow presets to declare a `strategy` per template instead of the default `replace`:

| Type | `replace` | `prepend` | `append` | `wrap` |
|------|-----------|-----------|----------|--------|
| **template** | ✓ (default) | ✓ | ✓ | ✓ |
| **command** | ✓ (default) | ✓ | ✓ | ✓ |
| **script** | ✓ (default) | — | — | ✓ |

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.
- **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.
- **Structural merge strategies** — Parsing Markdown sections for per-section granularity (e.g., "replace only ## Security").
- **Conflict detection** — `specify preset lint` / `specify preset doctor` for detecting composition conflicts.
24 changes: 24 additions & 0 deletions presets/scaffold/preset.yml
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,14 @@ provides:
templates:
# CUSTOMIZE: Define your template overrides
# Templates are document scaffolds (spec-template.md, plan-template.md, etc.)
#
# Strategy options (optional, defaults to "replace"):
# replace - Fully replaces the lower-priority template (default)
# prepend - Places this content BEFORE the lower-priority template
# append - Places this content AFTER the lower-priority template
# wrap - Uses {CORE_TEMPLATE} placeholder, replaced with lower-priority content
#
# Note: Scripts only support "replace" and "wrap" strategies.
- type: "template"
name: "spec-template"
file: "templates/spec-template.md"
Expand All @@ -45,6 +53,22 @@ provides:
# description: "Custom plan template"
# replaces: "plan-template"

# COMPOSITION EXAMPLES:
# Append additional sections to an existing template:
# - type: "template"
# name: "spec-template"
# file: "templates/spec-addendum.md"
# description: "Add compliance section to spec template"
# strategy: "append"
#
# Wrap a command with preamble/sign-off:
# - type: "command"
# name: "speckit.specify"
# file: "commands/specify-wrapper.md"
# description: "Wrap specify command with compliance checks"
# strategy: "wrap"
# # In the wrapper file, use {CORE_TEMPLATE} where the original content goes

# OVERRIDE EXTENSION TEMPLATES:
# Presets sit above extensions in the resolution stack, so you can
# override templates provided by any installed extension.
Expand Down
147 changes: 147 additions & 0 deletions scripts/bash/common.sh
Original file line number Diff line number Diff line change
Expand Up @@ -360,3 +360,150 @@ except Exception:
return 1
}

# Resolve a template name to composed content using composition strategies.
# Reads strategy metadata from preset manifests and composes content
# from multiple layers using prepend, append, or wrap strategies.
#
# Usage: CONTENT=$(resolve_template_content "template-name" "$REPO_ROOT")
# Returns composed content string on stdout; exit code 1 if not found.
resolve_template_content() {
local template_name="$1"
local repo_root="$2"
local base="$repo_root/.specify/templates"

# Collect all layers (highest priority first)
local -a layer_paths=()
local -a layer_strategies=()

# Priority 1: Project overrides (always "replace")
local override="$base/overrides/${template_name}.md"
if [ -f "$override" ]; then
layer_paths+=("$override")
layer_strategies+=("replace")
fi

# Priority 2: Installed presets (sorted by priority from .registry)
local presets_dir="$repo_root/.specify/presets"
if [ -d "$presets_dir" ]; then
local registry_file="$presets_dir/.registry"
local sorted_presets=""
if [ -f "$registry_file" ] && command -v python3 >/dev/null 2>&1; then
if sorted_presets=$(SPECKIT_REGISTRY="$registry_file" python3 -c "
import json, sys, os
try:
with open(os.environ['SPECKIT_REGISTRY']) as f:
data = json.load(f)
presets = data.get('presets', {})
for pid, meta in sorted(presets.items(), key=lambda x: x[1].get('priority', 10)):
print(pid)
except Exception:
sys.exit(1)
" 2>/dev/null); then
if [ -n "$sorted_presets" ]; then
while IFS= read -r preset_id; do
local candidate="$presets_dir/$preset_id/templates/${template_name}.md"
if [ -f "$candidate" ]; then
# Read strategy from preset manifest
local strategy="replace"
local manifest="$presets_dir/$preset_id/preset.yml"
if [ -f "$manifest" ] && command -v python3 >/dev/null 2>&1; then
local s
s=$(SPECKIT_MANIFEST="$manifest" SPECKIT_TMPL="$template_name" python3 -c "
import yaml, sys, os
try:
with open(os.environ['SPECKIT_MANIFEST']) as f:
data = yaml.safe_load(f)
for t in data.get('provides', {}).get('templates', []):
if t.get('name') == os.environ['SPECKIT_TMPL'] and t.get('type', 'template') == 'template':
print(t.get('strategy', 'replace'))
sys.exit(0)
print('replace')
except Exception:
print('replace')
" 2>/dev/null) && strategy="$s"
fi
layer_paths+=("$candidate")
layer_strategies+=("$strategy")
fi
done <<< "$sorted_presets"
fi
fi
fi
fi

# Priority 3: Extension-provided templates (always "replace")
local ext_dir="$repo_root/.specify/extensions"
if [ -d "$ext_dir" ]; then
for ext in "$ext_dir"/*/; do
[ -d "$ext" ] || continue
case "$(basename "$ext")" in .*) continue;; esac
local candidate="$ext/templates/${template_name}.md"
if [ -f "$candidate" ]; then
layer_paths+=("$candidate")
layer_strategies+=("replace")
fi
done
fi

# Priority 4: Core templates (always "replace")
local core="$base/${template_name}.md"
if [ -f "$core" ]; then
layer_paths+=("$core")
layer_strategies+=("replace")
fi

local count=${#layer_paths[@]}
[ "$count" -eq 0 ] && return 1

# Check if any layer uses a non-replace strategy
local has_composition=false
for s in "${layer_strategies[@]}"; do
[ "$s" != "replace" ] && has_composition=true && break
done

if [ "$has_composition" = false ]; then
cat "${layer_paths[0]}"
return 0
fi

# Compose bottom-up: start from lowest priority
local content=""
local has_base=false
local started=false
local i
for (( i=count-1; i>=0; i-- )); do
local path="${layer_paths[$i]}"
local strat="${layer_strategies[$i]}"
local layer_content
layer_content=$(cat "$path")

if [ "$started" = false ]; then
if [ "$strat" = "replace" ]; then
content="$layer_content"
has_base=true
fi
# Keep consuming replace layers from the bottom until we hit a non-replace
if [ "$strat" != "replace" ]; then
# No base content to compose onto
[ "$has_base" = false ] && return 1
started=true
case "$strat" in
prepend) content="${layer_content}\n\n${content}" ;;
append) content="${content}\n\n${layer_content}" ;;
wrap) content="${layer_content//\{CORE_TEMPLATE\}/$content}" ;;
esac
fi
else
case "$strat" in
replace) content="$layer_content" ;;
prepend) content="${layer_content}\n\n${content}" ;;
append) content="${content}\n\n${layer_content}" ;;
wrap) content="${layer_content//\{CORE_TEMPLATE\}/$content}" ;;
esac
fi
done

printf '%s' "$content"
return 0
}

Loading
Loading