Skip to content
Merged
Show file tree
Hide file tree
Changes from 21 commits
Commits
Show all changes
33 commits
Select commit Hold shift + click to select a range
fc15fdc
fix: rebase onto upstream/main, resolve conflicts with PR #2189
mnriem Apr 21, 2026
60f8d5b
fix: re-create skill directory in _reconcile_skills after removal
mnriem Apr 21, 2026
0d2dd42
fix: address twenty-third round of Copilot PR review feedback
mnriem Apr 21, 2026
a677787
fix: address twenty-fourth round of Copilot PR review feedback
mnriem Apr 21, 2026
9d0e95b
fix: address twenty-fifth round of Copilot PR review feedback
mnriem Apr 22, 2026
b968fc1
fix: add explanatory comment to empty except in legacy frontmatter pa…
mnriem Apr 22, 2026
df56d7a
fix: address twenty-sixth round of Copilot PR review feedback
mnriem Apr 22, 2026
3799a5e
fix: address twenty-seventh round of Copilot PR review feedback
mnriem Apr 22, 2026
a7b1660
fix: address twenty-eighth round of Copilot PR review feedback
mnriem Apr 22, 2026
0a70488
fix: address twenty-ninth round of Copilot PR review feedback
mnriem Apr 22, 2026
0ec81eb
fix: address thirtieth round of Copilot PR review feedback
mnriem Apr 22, 2026
655476b
fix: handle project override skills and extension context in reconcil…
mnriem Apr 22, 2026
95c6e62
fix: add comment to empty except in extension registration fallback
mnriem Apr 22, 2026
98698fa
fix: filter extension commands in reconciliation and fix type annotation
mnriem Apr 22, 2026
0a562ac
fix: filter extension commands from post-install reconciliation
mnriem Apr 22, 2026
2437d7a
fix: skip convention fallback for explicit file paths and add stem fa…
mnriem Apr 22, 2026
697f056
fix: scan past non-replace layers to find base in resolve_content
mnriem Apr 22, 2026
a1a0094
fix: add context_note to non-skill agent registration for extensions
mnriem Apr 22, 2026
75f64d9
fix: Optional type, rollback safety, and override skill restoration
mnriem Apr 22, 2026
d5875f7
fix: align bash/PS1 base-finding with Python resolver
mnriem Apr 22, 2026
7217df4
fix: PS1 no-python warning, integration hook for override skills, ali…
mnriem Apr 22, 2026
06b1694
fix: include aliases in removed_cmd_names during preset removal
mnriem Apr 22, 2026
4ba13ae
fix: add comment to empty except in alias extraction during removal
mnriem Apr 22, 2026
9caf29c
fix: scan top-down for effective base in all resolvers
mnriem Apr 22, 2026
d43011c
fix: align CLI composition chain display with top-down base-finding
mnriem Apr 22, 2026
f6a7cd8
fix: guard corrupted registry entries and make manifest authoritative
mnriem Apr 22, 2026
d912227
fix: align resolve() with manifest file paths and match extension con…
mnriem Apr 23, 2026
fa5626b
revert: restore resolve() convention-based behavior for backwards com…
mnriem Apr 23, 2026
38382cb
fix: only pre-compose when this preset is the top composing layer
mnriem Apr 23, 2026
7bb154d
fix: deduplicate PyYAML warnings and use self.registry in reconciliation
mnriem Apr 23, 2026
6636549
fix: document strategy handling consistency between layers and registrar
mnriem Apr 23, 2026
3456fad
fix: correct stale comments for alias tracking and base-finding algor…
mnriem Apr 23, 2026
dc09a93
security: validate manifest file paths in bash/PowerShell resolvers
mnriem Apr 23, 2026
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` (templates, commands, and scripts)
- **Bash**: `resolve_template_content()` in `scripts/bash/common.sh` (templates only; command/script composition is handled by the Python resolver)
- **PowerShell**: `Resolve-TemplateContent` in `scripts/powershell/common.ps1` (templates only; command/script composition is handled by the Python resolver)

## 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. 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`):

```yaml
provides:
templates:
- type: "template"
name: "spec-template"
file: "templates/spec-addendum.md"
Comment thread
mnriem marked this conversation as resolved.
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` 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).
- **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.
29 changes: 29 additions & 0 deletions presets/scaffold/preset.yml
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,15 @@ 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 (templates/commands) or
# $CORE_SCRIPT placeholder (scripts), 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 +54,26 @@ provides:
# description: "Custom plan template"
# replaces: "plan-template"

# COMPOSITION EXAMPLES:
# The `file` field points to the content file (can differ from the
# convention path `templates/<name>.md`). The `name` field identifies
# which template to compose with in the priority stack.
#
# 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"
Comment thread
mnriem marked this conversation as resolved.
# 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
223 changes: 222 additions & 1 deletion scripts/bash/common.sh
Original file line number Diff line number Diff line change
Expand Up @@ -321,7 +321,8 @@ try:
data = json.load(f)
presets = data.get('presets', {})
for pid, meta in sorted(presets.items(), key=lambda x: x[1].get('priority', 10)):
Comment thread
mnriem marked this conversation as resolved.
Outdated
print(pid)
if meta.get('enabled', True) is not False:
Comment thread
mnriem marked this conversation as resolved.
Outdated
print(pid)
except Exception:
sys.exit(1)
" 2>/dev/null); then
Expand Down Expand Up @@ -373,3 +374,223 @@ 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)):
if meta.get('enabled', True) is not False:
print(pid)
except Exception:
sys.exit(1)
" 2>/dev/null); then
if [ -n "$sorted_presets" ]; then
while IFS= read -r preset_id; do
Comment thread
mnriem marked this conversation as resolved.
# Read strategy and file path from preset manifest
local strategy="replace"
local manifest_file=""
local manifest="$presets_dir/$preset_id/preset.yml"
if [ -f "$manifest" ] && command -v python3 >/dev/null 2>&1; then
# Requires PyYAML; falls back to replace/convention if unavailable
local result
local py_stderr
py_stderr=$(mktemp)
result=$(SPECKIT_MANIFEST="$manifest" SPECKIT_TMPL="$template_name" python3 -c "
import sys, os
try:
import yaml
except ImportError:
print('yaml_missing', file=sys.stderr)
print('replace\t')
sys.exit(0)
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') + '\t' + t.get('file', ''))
sys.exit(0)
print('replace\t')
except Exception:
print('replace\t')
" 2>"$py_stderr")
local parse_status=$?
if [ $parse_status -eq 0 ] && [ -n "$result" ]; then
IFS=$'\t' read -r strategy manifest_file <<< "$result"
strategy=$(printf '%s' "$strategy" | tr '[:upper:]' '[:lower:]')
fi
# Warn only when PyYAML is explicitly missing
if grep -q 'yaml_missing' "$py_stderr" 2>/dev/null; then
echo "Warning: PyYAML not available; composition strategies in $manifest may be ignored" >&2
fi
rm -f "$py_stderr"
Comment thread
mnriem marked this conversation as resolved.
Outdated
fi
# Try manifest file path first, then convention path
local candidate=""
if [ -n "$manifest_file" ]; then
local mf="$presets_dir/$preset_id/$manifest_file"
[ -f "$mf" ] && candidate="$mf"
fi
if [ -z "$candidate" ]; then
local cf="$presets_dir/$preset_id/templates/${template_name}.md"
[ -f "$cf" ] && candidate="$cf"
fi
Comment thread
mnriem marked this conversation as resolved.
if [ -n "$candidate" ]; then
layer_paths+=("$candidate")
layer_strategies+=("$strategy")
fi
done <<< "$sorted_presets"
fi
else
# python3 failed — fall back to unordered directory scan (replace only)
for preset in "$presets_dir"/*/; do
[ -d "$preset" ] || continue
local candidate="$preset/templates/${template_name}.md"
if [ -f "$candidate" ]; then
layer_paths+=("$candidate")
layer_strategies+=("replace")
fi
done
fi
else
# No python3 or registry — fall back to unordered directory scan (replace only)
for preset in "$presets_dir"/*/; do
[ -d "$preset" ] || continue
local candidate="$preset/templates/${template_name}.md"
if [ -f "$candidate" ]; then
layer_paths+=("$candidate")
layer_strategies+=("replace")
fi
done
fi
fi
Comment thread
mnriem marked this conversation as resolved.

# 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 the top (highest-priority) layer is replace, it wins entirely —
# lower layers are irrelevant regardless of their strategies.
if [ "${layer_strategies[0]}" = "replace" ]; then
cat "${layer_paths[0]}"
return 0
fi

if [ "$has_composition" = false ]; then
Comment thread
mnriem marked this conversation as resolved.
cat "${layer_paths[0]}"
return 0
fi

# Compose bottom-up: start from the effective base.
# Find the highest-priority replace layer that sits below composing layers.
# Skip non-replace layers below any replace (they have no base to compose onto).
local content=""
local has_base=false
local base_idx=-1
local i
for (( i=count-1; i>=0; i-- )); do
local strat="${layer_strategies[$i]}"
if [ "$strat" = "replace" ]; then
base_idx=$i
elif [ $base_idx -ge 0 ]; then
# Found a non-replace above a replace — this is where composition starts
Comment thread
mnriem marked this conversation as resolved.
Outdated
break
fi
done

if [ $base_idx -lt 0 ]; then
return 1 # no base layer found
fi

# Read the base content; start composing from the layer above the base
content=$(cat "${layer_paths[$base_idx]}"; printf x)
content="${content%x}"

for (( i=base_idx-1; i>=0; i-- )); do
local path="${layer_paths[$i]}"
local strat="${layer_strategies[$i]}"
local layer_content
# Preserve trailing newlines
layer_content=$(cat "$path"; printf x)
layer_content="${layer_content%x}"

case "$strat" in
replace) content="$layer_content" ;;
prepend) content="$(printf '%s\n\n%s' "$layer_content" "$content")" ;;
append) content="$(printf '%s\n\n%s' "$content" "$layer_content")" ;;
wrap)
case "$layer_content" in
*'{CORE_TEMPLATE}'*) ;;
*) echo "Error: wrap strategy missing {CORE_TEMPLATE} placeholder" >&2; return 1 ;;
esac
while [[ "$layer_content" == *'{CORE_TEMPLATE}'* ]]; do
local before="${layer_content%%\{CORE_TEMPLATE\}*}"
local after="${layer_content#*\{CORE_TEMPLATE\}}"
layer_content="${before}${content}${after}"
done
content="$layer_content"
;;
*) echo "Error: unknown strategy '$strat'" >&2; return 1 ;;
esac
done

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

Loading
Loading