Skip to content
Open
13 changes: 13 additions & 0 deletions docs/reference/presets.md
Original file line number Diff line number Diff line change
Expand Up @@ -205,6 +205,19 @@ specify preset add team-workflow --priority 10

For any file that both provide, `compliance` wins (priority 5 < 10). For files only one provides, that one is used. For files neither provides, the core default is used.

## Always-on instructions

A preset can also contribute an always-on instruction block through `provides.instructions`. Unlike templates, commands, and scripts (which are resolved by the priority stack when Spec Kit needs them), an instruction block is composed into the coding agent's always-on context file so it reaches the agent's work generally, including outside a Spec Kit workflow.

```yaml
provides:
instructions:
- file: "instructions/best-practices.md"
description: "Always-on engineering rules"
```

This is opt-in and owned by the `agent-context` extension: nothing is written unless `agent-context` is installed and the preset is enabled. When both hold, `agent-context` composes each enabled preset's block into the routed context file (for example `.github/copilot-instructions.md`) inside a namespaced `<!-- SPECKIT PRESET:<id> START/END -->` block, and drops it again on `preset disable`/`remove` at the next refresh. Enabling the preset is the explicit opt-in; installing an extension does not by itself change the agent's context.

## FAQ

### Can I use multiple presets at the same time?
Expand Down
18 changes: 18 additions & 0 deletions extensions/agent-context/scripts/bash/update-agent-context.sh
Original file line number Diff line number Diff line change
Expand Up @@ -345,6 +345,21 @@ PY
fi

# Build the managed section
_SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"

# Always-on instruction blocks contributed by enabled presets (#4200).
# Delegated to the python twin's --emit-preset-blocks so all three twins emit
# byte-identical block text from a single implementation. Capture only stdout so
# the composer's warnings (oversized, marker-colliding, or skipped entries) still
# reach stderr. The assignment is the condition of an `if` so `set -e` does not
# abort on a nonzero composer status before this diagnostic runs; on failure we
# abort here so a composer failure never rewrites the section with previously
# composed preset blocks silently dropped.
if ! _PRESET_BLOCKS="$("$_python" "$_SCRIPT_DIR/../python/update_agent_context.py" --emit-preset-blocks --marker-start "$MARKER_START" --marker-end "$MARKER_END")"; then
echo "agent-context: preset instruction composer failed; aborting so the managed section is not rewritten with preset blocks dropped." >&2
exit 1
fi

TMP_SECTION="$(mktemp)"
trap 'rm -f "$TMP_SECTION"' EXIT
{
Expand All @@ -354,6 +369,9 @@ trap 'rm -f "$TMP_SECTION"' EXIT
if [[ -n "$PLAN_PATH" ]]; then
echo "at $PLAN_PATH"
fi
if [[ -n "$_PRESET_BLOCKS" ]]; then
printf '%s\n' "$_PRESET_BLOCKS"
fi
echo "$MARKER_END"
} > "$TMP_SECTION"

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -457,6 +457,73 @@ $lines = @($MarkerStart,
if ($PlanPath) {
$lines += "at $PlanPath"
}
# Always-on instruction blocks contributed by enabled presets (#4200): delegate
# to the python twin's --emit-preset-blocks so all three twins emit byte-identical
# block text from a single implementation.
$pyTwin = Join-Path (Join-Path (Join-Path $PSScriptRoot '..') 'python') 'update_agent_context.py'
$pyForBlocks = $null
foreach ($candidate in @($env:SPECKIT_PYTHON, 'python3', 'python')) {
if (-not $candidate) { continue }
if (-not (Get-Command $candidate -ErrorAction SilentlyContinue)) { continue }
# Require a real Python 3 that can import PyYAML (the composer imports yaml),
# skipping the Windows Store 'python3' alias stub.
try {
& $candidate -c "import sys, yaml; sys.exit(0 if sys.version_info[0] == 3 else 1)" 2>$null | Out-Null
if ($LASTEXITCODE -eq 0) { $pyForBlocks = $candidate; break }
} catch { }
}
if (-not $pyForBlocks) {
# The base section is written natively, but preset instruction blocks are
# composed by the Python twin only. If no Python 3 + PyYAML is available and
# presets are installed, warn instead of silently dropping their rules.
$presetReg = Join-Path $ProjectRoot '.specify/presets/.registry'
if (Test-Path -LiteralPath $presetReg) {
try {
$preg = Get-Content -LiteralPath $presetReg -Raw -Encoding UTF8 | ConvertFrom-Json
$enabled = @($preg.presets.PSObject.Properties | Where-Object { $_.Value.enabled -ne $false })
if ($enabled.Count -gt 0) {
[Console]::Error.WriteLine("agent-context: Python 3 with PyYAML not found; preset always-on instruction blocks (provides.instructions) were NOT composed. Base context section written.")
}
} catch { }
}
}
if ($pyForBlocks) {
if (-not (Test-Path -LiteralPath $pyTwin)) {
# Python is available but the sibling composer is gone: a partial or
# corrupt install. Treat it as a hard failure (like a nonzero composer
# exit) so the managed section is not rewritten with existing preset
# blocks silently dropped.
[Console]::Error.WriteLine("agent-context: preset instruction composer '$pyTwin' is missing (corrupt or partial install); aborting so the managed section is not rewritten with preset blocks dropped.")
exit 1
}
# Windows PowerShell decodes native-command stdout using the console code
# page; force UTF-8 so non-ASCII rule text (e.g. em-dashes) survives capture.
# Keep stderr (the composer's oversized/marker-colliding/skipped warnings) out
# of the captured stdout by routing it to a temp file, then surface it, and
# abort on a nonzero exit so a composer failure never rewrites the section
# with previously composed preset blocks silently dropped.
$errFile = [System.IO.Path]::GetTempFileName()
$prevOutEnc = [Console]::OutputEncoding
try {
[Console]::OutputEncoding = [System.Text.Encoding]::UTF8
$emitted = (& $pyForBlocks $pyTwin --emit-preset-blocks --marker-start $MarkerStart --marker-end $MarkerEnd 2>$errFile | Out-String)
} finally {
[Console]::OutputEncoding = $prevOutEnc
}
$emitRc = $LASTEXITCODE
$errText = if (Test-Path -LiteralPath $errFile) { Get-Content -LiteralPath $errFile -Raw } else { '' }
Remove-Item -LiteralPath $errFile -ErrorAction SilentlyContinue
if ($errText) { [Console]::Error.Write($errText) }
if ($emitRc -ne 0) {
[Console]::Error.WriteLine("agent-context: preset instruction composer failed (exit $emitRc); aborting so the managed section is not rewritten with preset blocks dropped.")
exit 1
}
if ($emitted) {
$emitted = ($emitted -replace "`r`n", "`n") -replace "`r", "`n"
$emitted = $emitted.TrimEnd("`n")
foreach ($bl in ($emitted -split "`n")) { $lines += $bl }
}
}
$lines += $MarkerEnd
$Section = ($lines -join "`n") + "`n"

Expand Down
213 changes: 211 additions & 2 deletions extensions/agent-context/scripts/python/update_agent_context.py
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,21 @@
DEFAULT_START = "<!-- SPECKIT START -->"
DEFAULT_END = "<!-- SPECKIT END -->"

# Any SPECKIT marker comment (the outer managed-section markers or the
# per-preset ``PRESET:<id> START/END`` sub-markers). Instruction payloads that
# embed one would collide with the find/replace in _upsert_section, so they are
# rejected.
_SPECKIT_MARKER_RE = re.compile(r"<!--\s*SPECKIT\b")

# Deliberately small budget for always-on instruction payloads. The composed
# managed section is re-sent as agent context on every request, so an oversized
# preset file (a bundled archive member or an unbounded ``--dev`` source) must
# not be allowed to bloat it. A single file over the per-file cap is skipped
# with a warning; once the aggregate cap across all presets is reached, the
# remaining entries are skipped too.
_MAX_INSTRUCTION_FILE_BYTES = 32 * 1024
_MAX_INSTRUCTION_TOTAL_BYTES = 64 * 1024


def _err(message: str) -> None:
print(message, file=sys.stderr)
Expand Down Expand Up @@ -201,18 +216,192 @@ def _resolved_rel(p: Path) -> Path | None:
return plan_path


def _build_section(marker_start: str, marker_end: str, plan_path: str) -> str:
def _build_section(
marker_start: str,
marker_end: str,
plan_path: str,
preset_blocks: list[str] | None = None,
) -> str:
lines = [
marker_start,
"For additional context about technologies to be used, project structure,",
"shell commands, and other important information, read the current plan",
]
if plan_path:
lines.append(f"at {plan_path}")
# Always-on instruction blocks contributed by explicitly-enabled presets,
# each in its own namespaced sub-block so multiple presets coexist and each
# can be regenerated or dropped independently on the next update.
lines.extend(preset_blocks or [])
lines.append(marker_end)
return "\n".join(lines) + "\n"


def _collect_preset_instruction_blocks(
project_root: str,
marker_start: str = DEFAULT_START,
marker_end: str = DEFAULT_END,
) -> list[tuple[str, str]]:
"""Collect always-on instruction blocks from installed + enabled presets.

A preset the user explicitly added (``specify preset add``) that declares
``provides.instructions`` gets its rule block composed into the managed
section. Reads ``.specify/presets/.registry`` and each preset's
``preset.yml`` directly, with no dependency on the Specify CLI (mirrors this
extension's by-design independence). Returns ``(preset_id, content)`` in
deterministic id order. Each referenced file must resolve inside its own
preset directory; path-unsafe, unreadable, non-UTF-8, oversized (per-file or
aggregate budget), or marker-colliding entries are skipped (fail closed).
Fails closed on an unreadable registry.
"""
presets_dir = Path(project_root) / ".specify" / "presets"
registry = presets_dir / ".registry"
if not registry.is_file():
return []
try:
import yaml
except ImportError:
return []
try:
with open(registry, "r", encoding="utf-8") as fh:
reg = json.load(fh)
except (json.JSONDecodeError, OSError, UnicodeDecodeError):
return []
if not isinstance(reg, dict) or not isinstance(reg.get("presets"), dict):
return []

presets_root = presets_dir.resolve()
blocks: list[tuple[str, str]] = []
total_bytes = 0
for preset_id in sorted(reg["presets"]):
# The registry lives on disk and is untrusted. Reject ids that are not
# simple names (no path separators, '..' traversal, or absolute/drive
# forms), then confirm the resolved directory stays inside
# .specify/presets, so a crafted key or a symlink cannot read a manifest
# or payload outside it.
if not isinstance(preset_id, str) or not re.match(r"^[a-z0-9][a-z0-9._-]*$", preset_id):
continue
preset_root = (presets_dir / preset_id).resolve()
try:
preset_root.relative_to(presets_root)
except ValueError:
continue
meta = reg["presets"][preset_id]
if not isinstance(meta, dict) or not meta.get("enabled", True):
continue
manifest = preset_root / "preset.yml"
if not manifest.is_file():
continue
try:
with open(manifest, "r", encoding="utf-8") as fh:
pdata = yaml.safe_load(fh)
except Exception:
continue
provides = pdata.get("provides") if isinstance(pdata, dict) else None
instructions = provides.get("instructions") if isinstance(provides, dict) else None
if not isinstance(instructions, list):
continue
parts: list[str] = []
for entry in instructions:
if not isinstance(entry, dict):
continue
rel = entry.get("file")
if not isinstance(rel, str) or not rel.strip():
continue
# Path-unsafe entries (absolute, backslash, parent traversal, or a
# target escaping the preset directory) are skipped silently: this is
# a security fail-closed decision, so no diagnostic is emitted.
if rel.startswith("/") or "\\" in rel or ".." in rel.split("/"):
continue
target = (preset_root / rel).resolve()
try:
target.relative_to(preset_root)
except ValueError:
continue
if not target.is_file():
_err(
f"agent-context: skipping instructions from preset '{preset_id}': "
f"file '{rel}' not found."
)
continue
# Reject an oversized file by its on-disk size before reading it, so
# a huge member never gets allocated into memory.
try:
size = target.stat().st_size
except OSError:
_err(
f"agent-context: skipping instructions from preset '{preset_id}': "
f"file '{rel}' is not readable."
)
continue
if size > _MAX_INSTRUCTION_FILE_BYTES:
_err(
f"agent-context: skipping instructions from preset '{preset_id}': "
f"file '{rel}' is {size} bytes (per-file limit "
f"{_MAX_INSTRUCTION_FILE_BYTES})."
)
continue
try:
text = target.read_text(encoding="utf-8").strip()

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Good call. The composed managed section is re-sent as agent context on every request, so an oversized instruction file (a bundled archive member or an unbounded --dev source) could bloat it without bound.

Fixed in a899821: added a deliberately small budget in the collector. Any single file over a per-file cap (32 KiB) is skipped with a warning, and the on-disk size is checked with stat() before the file is read so a huge member is never allocated into memory. A running aggregate cap (64 KiB across all presets) stops composition once reached, and each skip logs which preset and why.

Boundary coverage added: test_instruction_file_at_limit_included (a file exactly at the per-file cap is kept, since the check is strictly greater), test_oversized_instruction_file_skipped (over-cap file skipped, other presets still compose), and test_aggregate_instruction_budget_enforced (three under-cap presets where the third crosses the aggregate cap and is dropped in id order). The bash/ps1 twins delegate to this collector via --emit-preset-blocks, so the budget applies uniformly.

except (OSError, UnicodeDecodeError):
_err(
f"agent-context: skipping instructions from preset '{preset_id}': "
f"file '{rel}' is not readable UTF-8 text."
)
continue
if marker_start in text or marker_end in text or _SPECKIT_MARKER_RE.search(text):
_err(
f"agent-context: skipping instructions from preset '{preset_id}': "
"content contains a managed section marker."
)
continue
# Count the bytes this entry actually adds to the rendered section,
# not just its raw payload: the first entry of a preset materializes
# the surrounding marker block, and every later entry adds a blank-line
# separator. Without this, a flood of tiny entries would slip past the
# aggregate cap even though the composed section is far larger.
entry_bytes = len(text.encode("utf-8"))
if parts:
overhead = 2 # the "\n\n" joining this entry to the previous one
else:
overhead = (
len(f"<!-- SPECKIT PRESET:{preset_id} START -->")
+ len(f"<!-- SPECKIT PRESET:{preset_id} END -->")
+ 4 # surrounding newlines and the leading blank line
)
if total_bytes + entry_bytes + overhead > _MAX_INSTRUCTION_TOTAL_BYTES:
_err(
f"agent-context: skipping instructions from preset '{preset_id}': "
f"aggregate instruction budget ({_MAX_INSTRUCTION_TOTAL_BYTES} "
"bytes) exceeded."
)
continue
total_bytes += entry_bytes + overhead
parts.append(text)
if parts:
blocks.append((preset_id, "\n\n".join(parts)))
return blocks


def _render_preset_block_lines(
project_root: str,
marker_start: str = DEFAULT_START,
marker_end: str = DEFAULT_END,
) -> list[str]:
"""Render the namespaced sub-block lines for all enabled presets' instruction
blocks, to be embedded inside the managed section.
"""
lines: list[str] = []
for preset_id, content in _collect_preset_instruction_blocks(
project_root, marker_start, marker_end
):
lines.append("")
lines.append(f"<!-- SPECKIT PRESET:{preset_id} START -->")
lines.append(content)
lines.append(f"<!-- SPECKIT PRESET:{preset_id} END -->")
return lines


def ensure_mdc_frontmatter(content: str) -> str:
"""Ensure ``.mdc`` content has YAML frontmatter with ``alwaysApply: true``.

Expand Down Expand Up @@ -298,6 +487,25 @@ def _upsert_section(
def main(argv: list[str] | None = None) -> int:
args = sys.argv[1:] if argv is None else argv
project_root = os.getcwd()

# --emit-preset-blocks: print only the composed preset instruction sub-block
# lines and exit. Used by the bash/PowerShell twins so all three produce
# identical output from this single implementation. Does not require the
# agent-context config (the twin already validated it before calling).
if "--emit-preset-blocks" in args:
def _opt(name: str, default: str) -> str:
if name in args:
i = args.index(name)
if i + 1 < len(args):
return args[i + 1]
return default
marker_start = _opt("--marker-start", DEFAULT_START)
marker_end = _opt("--marker-end", DEFAULT_END)
block_lines = _render_preset_block_lines(project_root, marker_start, marker_end)
if block_lines:
sys.stdout.buffer.write("\n".join(block_lines).encode("utf-8"))
return 0

ext_config = (
f"{project_root}/.specify/extensions/agent-context/agent-context-config.yml"
)
Expand Down Expand Up @@ -353,7 +561,8 @@ def main(argv: list[str] | None = None) -> int:
if not plan_path:
plan_path = _resolve_plan_path(project_root)

section = _build_section(marker_start, marker_end, plan_path)
preset_blocks = _render_preset_block_lines(project_root, marker_start, marker_end)
section = _build_section(marker_start, marker_end, plan_path, preset_blocks)

for context_file in context_files:
ctx_path = os.path.join(project_root, context_file)
Expand Down
Loading