Summary
Add composition strategies to the preset system so that presets can augment core templates instead of only replacing them. Today, PresetResolver returns the first match in the priority stack — a pure "replace" strategy. This proposal adds prepend, append, and wrap strategies that let presets inject content before, after, or around the lower-priority template.
Listed as a future consideration in the presets README and explicitly deferred as out of scope in #1708.
Problem Statement
The current preset system only supports full replacement. If two presets both provide spec-template, the highest-priority one wins entirely. This forces preset authors to duplicate the full content of the template they want to extend, even for a single added section.
| Scenario |
Current behavior |
Desired behavior |
| Healthcare preset adds a "PHI Considerations" section to spec template |
Must copy entire core spec-template.md and add the section |
strategy: "append" with only the new section |
| Enterprise preset wraps the plan command with compliance preamble/sign-off |
Must copy entire plan.md command and add wrapper |
strategy: "wrap" with {CORE_TEMPLATE} placeholder |
CI preset runs validation before create-new-feature.sh |
Not possible — scripts can only be replaced |
strategy: "wrap" with $CORE_SCRIPT variable |
This is especially problematic when core templates evolve (replaced presets don't get upstream improvements) and when multiple additive presets need to stack.
Proposed strategy field in preset.yml
provides:
templates:
- type: "template"
name: "spec-template"
file: "templates/spec-addendum.md"
strategy: "append" # new optional field, default: "replace"
- type: "command"
name: "plan"
file: "commands/plan-wrapper.md"
strategy: "wrap"
- type: "script"
name: "create-new-feature"
file: "scripts/create-new-feature-wrapper.sh"
strategy: "wrap"
Supported combinations:
| Type |
replace |
prepend |
append |
wrap |
| template |
✓ (default) |
✓ |
✓ |
✓ |
| command |
✓ (default) |
✓ |
✓ |
✓ |
| script |
✓ (default) |
— |
— |
✓ |
Scripts only support replace and wrap (prepend/append don't make semantic sense for executable code).
Strategy semantics
replace (default): No change — preset's template fully replaces lower-priority one.
prepend: Preset content placed before resolved lower-priority template, separated by a blank line.
append: Preset content placed after resolved lower-priority template, separated by a blank line.
wrap: For templates/commands — preset content contains {CORE_TEMPLATE} placeholder replaced with resolved lower-priority content. For scripts — $CORE_SCRIPT variable points to the lower-priority script path.
Resolution changes
Current PresetResolver.resolve() returns Optional[Path] — just a file path. With composition, the result is synthesized content from multiple files. New API:
class PresetResolver:
def resolve(self, template_name, template_type="template") -> Optional[Path]: ... # existing
def resolve_content(self, template_name, template_type="template") -> Optional[str]: ... # new
Composition is recursive — multiple composing presets chain:
Priority 1: preset-a (strategy: "prepend", security header)
Priority 2: preset-b (strategy: "append", compliance footer)
Priority 4: core spec-template
→ Result: <security header> + <core content> + <compliance footer>
Impact areas
| Implementation |
File |
Changes needed |
| Python resolver |
src/specify_cli/presets.py (PresetResolver, ~line 1710) |
Add resolve_content(), manifest-aware strategy lookup |
| Bash resolver |
scripts/bash/common.sh (resolve_template()) |
Add resolve_template_content() |
| PowerShell resolver |
scripts/powershell/common.ps1 (Resolve-Template) |
Add Resolve-TemplateContent |
| Manifest validation |
src/specify_cli/presets.py (PresetManifest._validate(), ~line 96) |
Validate strategy values; reject prepend/append for scripts |
| Command registration |
src/specify_cli/presets.py (_register_commands(), ~line 691) |
Compose before writing to agent directories |
| Scaffold |
presets/scaffold/preset.yml |
Document strategy field |
Acceptance Criteria
Out of Scope
- Structural merge strategies (parsing Markdown sections)
- Per-section granularity (e.g., "replace only ## Security")
- Conflict detection UI (
specify preset lint / specify preset doctor)
References
Summary
Add composition strategies to the preset system so that presets can augment core templates instead of only replacing them. Today,
PresetResolverreturns the first match in the priority stack — a pure "replace" strategy. This proposal addsprepend,append, andwrapstrategies that let presets inject content before, after, or around the lower-priority template.Listed as a future consideration in the presets README and explicitly deferred as out of scope in #1708.
Problem Statement
The current preset system only supports full replacement. If two presets both provide
spec-template, the highest-priority one wins entirely. This forces preset authors to duplicate the full content of the template they want to extend, even for a single added section.spec-template.mdand add the sectionstrategy: "append"with only the new sectionplan.mdcommand and add wrapperstrategy: "wrap"with{CORE_TEMPLATE}placeholdercreate-new-feature.shstrategy: "wrap"with$CORE_SCRIPTvariableThis is especially problematic when core templates evolve (replaced presets don't get upstream improvements) and when multiple additive presets need to stack.
Proposed
strategyfield inpreset.ymlSupported combinations:
replaceprependappendwrapScripts only support
replaceandwrap(prepend/append don't make semantic sense for executable code).Strategy semantics
replace(default): No change — preset's template fully replaces lower-priority one.prepend: Preset content placed before resolved lower-priority template, separated by a blank line.append: Preset content placed after resolved lower-priority template, separated by a blank line.wrap: For templates/commands — preset content contains{CORE_TEMPLATE}placeholder replaced with resolved lower-priority content. For scripts —$CORE_SCRIPTvariable points to the lower-priority script path.Resolution changes
Current
PresetResolver.resolve()returnsOptional[Path]— just a file path. With composition, the result is synthesized content from multiple files. New API:Composition is recursive — multiple composing presets chain:
→ Result:
<security header> + <core content> + <compliance footer>Impact areas
src/specify_cli/presets.py(PresetResolver, ~line 1710)resolve_content(), manifest-aware strategy lookupscripts/bash/common.sh(resolve_template())resolve_template_content()scripts/powershell/common.ps1(Resolve-Template)Resolve-TemplateContentsrc/specify_cli/presets.py(PresetManifest._validate(), ~line 96)src/specify_cli/presets.py(_register_commands(), ~line 691)presets/scaffold/preset.ymlstrategyfieldAcceptance Criteria
strategyfield accepted inpreset.ymlwith values:replace(default),prepend,append,wrapPresetManifest._validate()validates strategy; rejectsprepend/appendfortype: "script"PresetResolver.resolve_content()returns composed contentresolve_template_content()added to bash and PowerShell scriptswrapreplaces{CORE_TEMPLATE}for templates/commands, sets$CORE_SCRIPTfor scriptsreplaceremains default; existing presets work without changesspecify preset resolve <name>shows composition chainpresets/README.md,presets/ARCHITECTURE.md, scaffoldOut of Scope
specify preset lint/specify preset doctor)References
presets/ARCHITECTURE.md