Skip to content
Open
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
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
Original file line number Diff line number Diff line change
Expand Up @@ -345,6 +345,7 @@ PY
fi

# Build the managed section
_SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
TMP_SECTION="$(mktemp)"
trap 'rm -f "$TMP_SECTION"' EXIT
{
Expand All @@ -354,6 +355,13 @@ trap 'rm -f "$TMP_SECTION"' EXIT
if [[ -n "$PLAN_PATH" ]]; then
echo "at $PLAN_PATH"
fi
# 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.
_PRESET_BLOCKS="$("$_python" "$_SCRIPT_DIR/../python/update_agent_context.py" --emit-preset-blocks --marker-start "$MARKER_START" --marker-end "$MARKER_END" 2>/dev/null || true)"
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,52 @@ $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 -and (Test-Path -LiteralPath $pyTwin)) {
# 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.
$prevOutEnc = [Console]::OutputEncoding
try {
[Console]::OutputEncoding = [System.Text.Encoding]::UTF8
$emitted = (& $pyForBlocks $pyTwin --emit-preset-blocks --marker-start $MarkerStart --marker-end $MarkerEnd 2>$null | Out-String)
} finally {
[Console]::OutputEncoding = $prevOutEnc
}
if ($emitted) {
$emitted = ($emitted -replace "`r`n", "`n") -replace "`r", "`n"
$emitted = $emitted.TrimEnd("`n")
foreach ($bl in ($emitted -split "`n")) { $lines += $bl }
}
Comment on lines +493 to +504
}
$lines += $MarkerEnd
$Section = ($lines -join "`n") + "`n"

Expand Down
185 changes: 183 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,164 @@ 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
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():
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:
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):
continue
entry_bytes = len(text.encode("utf-8"))
if total_bytes + entry_bytes > _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
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
total_bytes += entry_bytes
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 +459,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 +533,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
3 changes: 3 additions & 0 deletions presets/PUBLISHING.md
Original file line number Diff line number Diff line change
Expand Up @@ -76,6 +76,9 @@ provides:
file: "templates/spec-template.md"
description: "Custom spec template"
replaces: "spec-template"
instructions: # Optional: always-on rule blocks composed
- file: "instructions/best-practices.md" # by the opt-in agent-context extension
description: "Always-on engineering rules"
Comment on lines +79 to +81

tags: # 2-5 relevant tags
- "category"
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
## Example project rules

These are always-on rules contributed by an explicitly-enabled preset. The
`agent-context` extension composes this block into the coding agent's context
file so it applies to the agent's work, including outside a Spec Kit workflow.

- Prefer small, well-named functions over large ones.
- Validate inputs at system boundaries, not deep in the call stack.
- Write a test for each behavior change.
20 changes: 20 additions & 0 deletions presets/example-always-on-rules/preset.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
schema_version: "1.0"

preset:
id: "example-always-on-rules"
name: "Example Always-On Rules"
version: "1.0.0"
description: "Example preset that contributes an always-on instruction block. When the preset is enabled and the opt-in agent-context extension is installed, the block is composed into the coding agent's context file (e.g. .github/copilot-instructions.md)."
author: "spec-kit"

requires:
speckit_version: ">=0.6.0"

provides:
instructions:
- file: "instructions/best-practices.md"
description: "Example always-on engineering rules"

tags:
- "example"
- "agent-context"
Loading