diff --git a/AGENTS.md b/AGENTS.md index b6e3a28f5f..ca3f2a537e 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -177,7 +177,24 @@ def _register_builtins() -> None: Set `context_file` on the integration class. The base integration setup creates or updates the managed Spec Kit section in that file, and uninstall removes the managed section when appropriate. -Only add custom setup logic when the agent needs non-standard behavior. Most integrations do not need wrapper scripts or separate context-update dispatch code. +The managed section is owned by the bundled `agent-context` extension (`extensions/agent-context/`). All configuration flows through the extension's own config file at `.specify/extensions/agent-context/agent-context-config.yml`: + +```yaml +# Path to the coding agent context file managed by this extension +context_file: CLAUDE.md + +# Delimiters for the managed Spec Kit section +context_markers: + start: "" + end: "" +``` + +- `context_file` is written automatically from the integration's class attribute when `specify init` or `specify integration use` is run. +- `context_markers.{start,end}` defaults to `IntegrationBase.CONTEXT_MARKER_START` / `CONTEXT_MARKER_END`. Users who want custom markers edit `agent-context-config.yml` directly — both the Python layer (`upsert_context_section()` / `remove_context_section()`) and the bundled scripts (`extensions/agent-context/scripts/bash/update-agent-context.sh` and `.ps1`) read from this single source of truth. + +Users can opt out entirely with `specify extension disable agent-context`; while disabled, Spec Kit skips context-file creation, updates, and removal (the gates are inside `upsert_context_section()` and `remove_context_section()`). + +Only add custom setup logic when the agent needs non-standard behavior. Integrations no longer require per-agent thin wrapper scripts or shared context-update dispatcher scripts — the `agent-context` extension is fully generic. ### 5. Test it @@ -409,7 +426,7 @@ When an issue exists, include its number immediately after the prefix — this i ## Common Pitfalls 1. **Using shorthand keys for CLI-based integrations**: For CLI-based integrations (`requires_cli: True`), the `key` must match the executable name (e.g., `"cursor-agent"` not `"cursor"`). `shutil.which(key)` is used for CLI tool checks — mismatches require special-case mappings. IDE-based integrations (`requires_cli: False`) are not subject to this constraint. -2. **Forgetting update scripts**: Both bash and PowerShell thin wrappers and the shared context-update scripts must be updated. +2. **Forgetting context configuration**: The bundled `agent-context` extension reads from `.specify/extensions/agent-context/agent-context-config.yml`. New integrations only need to set `context_file` on the class — markers and dispatcher scripts are managed centrally. 3. **Incorrect `requires_cli` value**: Set to `True` only for agents that have a CLI tool; set to `False` for IDE-based agents. 4. **Wrong argument format**: Use `$ARGUMENTS` for Markdown agents, `{{args}}` for TOML agents. 5. **Skipping registration**: The import and `_register()` call in `_register_builtins()` must both be added. diff --git a/extensions/agent-context/README.md b/extensions/agent-context/README.md new file mode 100644 index 0000000000..dba004eb80 --- /dev/null +++ b/extensions/agent-context/README.md @@ -0,0 +1,57 @@ +# Coding Agent Context Extension + +This bundled extension manages the **coding agent context/instruction file** (e.g. `CLAUDE.md`, `.github/copilot-instructions.md`, `AGENTS.md`, `GEMINI.md`, …) for the active integration. + +It owns the lifecycle of the managed section delimited by the configurable start/end markers (defaults: `` / ``). + +## Why an extension? + +Not every Spec Kit user wants Spec Kit to write into the coding agent's context file. Extracting this behavior into a dedicated extension lets users: + +- **Opt out** entirely with `specify extension disable agent-context` — Spec Kit will then never create or modify the agent context file. +- **Customize the markers** by editing `.specify/extensions/agent-context/agent-context-config.yml` — both the Python layer and the bundled scripts honor the same `context_markers` value. +- **Refresh on demand** with `/speckit.agent-context.update`, or automatically through the hooks declared in `extension.yml` (`after_specify`, `after_plan`). + +## Commands + +| Command | Description | +|---------|-------------| +| `speckit.agent-context.update` | Refresh the managed section in the agent context file with the current plan path. | + +## Configuration + +All configuration flows through the extension's own config file at +`.specify/extensions/agent-context/agent-context-config.yml`: + +```yaml +# Path to the coding agent context file managed by this extension +context_file: CLAUDE.md + +# Delimiters for the managed Spec Kit section +context_markers: + start: "" + end: "" +``` + +- `context_file` — the project-relative path to the coding agent context file, written by `specify init` and `specify integration install`. +- `context_markers.start` / `.end` — the delimiters around the managed section. Edit these to use custom markers. + +## Requirements + +The bundled update scripts require **Python 3** with **PyYAML** for YAML/upsert processing (PowerShell can also use `ConvertFrom-Yaml` when available). + +PyYAML ships with the `specify` CLI and is normally available via the same `python3` interpreter. If a hook reports *"PyYAML is required … not available in the current Python environment"*, it means the system `python3` differs from the one used to install Spec Kit. To resolve, run: + +```bash +pip install pyyaml +# or target the specific interpreter Spec Kit uses: +/path/to/speckit-python -m pip install pyyaml +``` + +## Disable + +```bash +specify extension disable agent-context +``` + +When disabled, Spec Kit skips context file creation, updates, and removal (the gates are inside `upsert_context_section()` and `remove_context_section()`). diff --git a/extensions/agent-context/agent-context-config.yml b/extensions/agent-context/agent-context-config.yml new file mode 100644 index 0000000000..8c8d308b27 --- /dev/null +++ b/extensions/agent-context/agent-context-config.yml @@ -0,0 +1,15 @@ +# Coding Agent Context Extension Configuration +# These values are populated automatically by `specify init` and +# `specify integration use` / `specify integration install`. + +# Path (relative to the project root) to the coding agent context file +# managed by this extension (e.g. CLAUDE.md, AGENTS.md, +# .github/copilot-instructions.md). Set automatically from the active +# integration and regenerated during `specify init` or integration switches. +context_file: "" + +# Delimiters for the managed Spec Kit section. +# Edit these to use custom markers. +context_markers: + start: "" + end: "" diff --git a/extensions/agent-context/commands/speckit.agent-context.update.md b/extensions/agent-context/commands/speckit.agent-context.update.md new file mode 100644 index 0000000000..02f1706926 --- /dev/null +++ b/extensions/agent-context/commands/speckit.agent-context.update.md @@ -0,0 +1,26 @@ +--- +description: "Refresh the managed Spec Kit section in the coding agent context file" +--- + +# Update Coding Agent Context + +Refresh the managed Spec Kit section inside the active coding agent's context/instruction file (e.g. `CLAUDE.md`, `.github/copilot-instructions.md`, `AGENTS.md`). + +## Behavior + +The script reads the agent-context extension config at +`.specify/extensions/agent-context/agent-context-config.yml` to discover: + +- `context_file` — the path of the coding agent context file to manage. +- `context_markers.start` / `.end` — the delimiters surrounding the managed section. Defaults to `` and `` when the field is missing. + +It then creates, replaces, or appends the managed block so that the section points at the most recent plan path when one can be discovered (`specs//plan.md`). + +If `context_file` is empty or the file cannot be located, the command reports nothing to do and exits successfully. + +## Execution + +- **Bash**: `.specify/extensions/agent-context/scripts/bash/update-agent-context.sh [plan_path]` +- **PowerShell**: `.specify/extensions/agent-context/scripts/powershell/update-agent-context.ps1 [plan_path]` + +When `plan_path` is omitted, the script auto-detects the most recently modified `specs/*/plan.md`. diff --git a/extensions/agent-context/extension.yml b/extensions/agent-context/extension.yml new file mode 100644 index 0000000000..191069e32c --- /dev/null +++ b/extensions/agent-context/extension.yml @@ -0,0 +1,34 @@ +schema_version: "1.0" + +extension: + id: agent-context + name: "Coding Agent Context" + version: "1.0.0" + description: "Manages coding agent context/instruction files (e.g., CLAUDE.md, copilot-instructions.md) with project-specific plan references and configurable markers" + author: spec-kit-core + repository: https://github.com/github/spec-kit + license: MIT + +requires: + speckit_version: ">=0.2.0" + +provides: + commands: + - name: speckit.agent-context.update + file: commands/speckit.agent-context.update.md + description: "Refresh the managed Spec Kit section in the coding agent context file" + +hooks: + after_specify: + command: speckit.agent-context.update + optional: true + description: "Refresh agent context after specification" + after_plan: + command: speckit.agent-context.update + optional: true + description: "Refresh agent context after planning" + +tags: + - "agent" + - "context" + - "core" diff --git a/extensions/agent-context/scripts/bash/update-agent-context.sh b/extensions/agent-context/scripts/bash/update-agent-context.sh new file mode 100755 index 0000000000..42ce44df9a --- /dev/null +++ b/extensions/agent-context/scripts/bash/update-agent-context.sh @@ -0,0 +1,200 @@ +#!/usr/bin/env bash +# update-agent-context.sh +# +# Refresh the managed Spec Kit section in the coding agent's context file +# (e.g. CLAUDE.md, .github/copilot-instructions.md, AGENTS.md). +# +# Reads `context_file` and `context_markers.{start,end}` from the +# agent-context extension config: +# .specify/extensions/agent-context/agent-context-config.yml +# +# Usage: update-agent-context.sh [plan_path] +# +# When `plan_path` is omitted, the script picks the most recently modified +# `specs/*/plan.md` if any exist, otherwise emits the section without a +# concrete plan path. + +set -euo pipefail + +PROJECT_ROOT="$(pwd)" +EXT_CONFIG="$PROJECT_ROOT/.specify/extensions/agent-context/agent-context-config.yml" +DEFAULT_START="" +DEFAULT_END="" + +if [[ ! -f "$EXT_CONFIG" ]]; then + echo "agent-context: $EXT_CONFIG not found; nothing to do." >&2 + exit 0 +fi + +# Locate a suitable Python interpreter (python3, then python). +_python="" +if command -v python3 >/dev/null 2>&1; then + _python="python3" +elif command -v python >/dev/null 2>&1 && python --version 2>&1 | grep -q "^Python 3"; then + _python="python" +fi + +if [[ -z "$_python" ]]; then + echo "agent-context: Python 3 not found on PATH; skipping update." >&2 + exit 0 +fi + +# Parse extension config once; emit three newline-separated fields: +# context_file, context_markers.start, context_markers.end +if ! _raw_opts="$("$_python" - "$EXT_CONFIG" <<'PY' +import sys +try: + import yaml +except ImportError: + print( + "agent-context: PyYAML is required to parse extension config but is not available " + "in the current Python environment.\n" + " To resolve: pip install pyyaml (or install it into the environment used by python3).\n" + " Context file will not be updated until PyYAML is importable.", + file=sys.stderr, + ) + sys.exit(2) +try: + with open(sys.argv[1], "r", encoding="utf-8") as fh: + data = yaml.safe_load(fh) +except Exception as exc: + print( + f"agent-context: unable to parse {sys.argv[1]} ({exc}); cannot update context.", + file=sys.stderr, + ) + sys.exit(2) +if not isinstance(data, dict): + data = {} +def get_str(obj, *keys): + node = obj + for k in keys: + if isinstance(node, dict) and k in node: + node = node[k] + else: + return "" + return node if isinstance(node, str) else "" +print(get_str(data, "context_file")) +print(get_str(data, "context_markers", "start")) +print(get_str(data, "context_markers", "end")) +PY +)"; then + echo "agent-context: skipping update (see above for details)." >&2 + exit 0 +fi + +_opts_lines=() +while IFS= read -r _line || [[ -n "$_line" ]]; do + _opts_lines+=("$_line") +done < <(printf '%s\n' "$_raw_opts") +if (( ${#_opts_lines[@]} < 3 )); then + echo "agent-context: malformed config parser output; expected 3 lines (context_file, marker_start, marker_end), got ${#_opts_lines[@]}; skipping update." >&2 + exit 0 +fi +CONTEXT_FILE="${_opts_lines[0]}" +MARKER_START="${_opts_lines[1]}" +MARKER_END="${_opts_lines[2]}" + +if [[ -z "$CONTEXT_FILE" ]]; then + echo "agent-context: context_file not set in extension config; nothing to do." >&2 + exit 0 +fi + +# Reject absolute paths, backslash separators, and '..' path segments in context_file +if [[ "$CONTEXT_FILE" == /* ]] || [[ "$CONTEXT_FILE" =~ ^[A-Za-z]: ]]; then + echo "agent-context: context_file must be a project-relative path; got '$CONTEXT_FILE'." >&2 + exit 1 +fi +if [[ "$CONTEXT_FILE" == *\\* ]]; then + echo "agent-context: context_file must not contain backslash separators; got '$CONTEXT_FILE'." >&2 + exit 1 +fi +IFS='/' read -ra _cf_parts <<< "$CONTEXT_FILE" +for _seg in "${_cf_parts[@]}"; do + if [[ "$_seg" == ".." ]]; then + echo "agent-context: context_file must not contain '..' path segments; got '$CONTEXT_FILE'." >&2 + exit 1 + fi +done +unset _cf_parts _seg + +[[ -z "$MARKER_START" ]] && MARKER_START="$DEFAULT_START" +[[ -z "$MARKER_END" ]] && MARKER_END="$DEFAULT_END" + +PLAN_PATH="${1:-}" +if [[ -z "$PLAN_PATH" ]]; then + # Pick the most recently modified plan.md one level deep (specs//plan.md). + # Use find + sort by modification time to avoid ls/head fragility with + # spaces in paths or SIGPIPE from pipefail. + _plan_abs="$("$_python" - "$PROJECT_ROOT" <<'PY' +import sys, os +from pathlib import Path +specs = Path(sys.argv[1]) / "specs" +plans = sorted( + specs.glob("*/plan.md"), + key=lambda p: p.stat().st_mtime, + reverse=True, +) +print(plans[0] if plans else "") +PY +)" + if [[ -n "$_plan_abs" ]]; then + PLAN_PATH="${_plan_abs#"$PROJECT_ROOT/"}" + fi +fi + +CTX_PATH="$PROJECT_ROOT/$CONTEXT_FILE" +mkdir -p "$(dirname "$CTX_PATH")" + +# Build the managed section +TMP_SECTION="$(mktemp)" +trap 'rm -f "$TMP_SECTION"' EXIT +{ + echo "$MARKER_START" + echo "For additional context about technologies to be used, project structure," + echo "shell commands, and other important information, read the current plan" + if [[ -n "$PLAN_PATH" ]]; then + echo "at $PLAN_PATH" + fi + echo "$MARKER_END" +} > "$TMP_SECTION" + +"$_python" - "$CTX_PATH" "$MARKER_START" "$MARKER_END" "$TMP_SECTION" <<'PY' +import sys, os +ctx_path, start, end, section_path = sys.argv[1:5] +with open(section_path, "r", encoding="utf-8") as fh: + section = fh.read().rstrip("\n") + "\n" + +if os.path.exists(ctx_path): + with open(ctx_path, "r", encoding="utf-8-sig") as fh: + content = fh.read() + s = content.find(start) + e = content.find(end, s if s != -1 else 0) + if s != -1 and e != -1 and e > s: + end_of_marker = e + len(end) + if end_of_marker < len(content) and content[end_of_marker] == "\r": + end_of_marker += 1 + if end_of_marker < len(content) and content[end_of_marker] == "\n": + end_of_marker += 1 + new_content = content[:s] + section + content[end_of_marker:] + elif s != -1: + new_content = content[:s] + section + elif e != -1: + end_of_marker = e + len(end) + if end_of_marker < len(content) and content[end_of_marker] == "\r": + end_of_marker += 1 + if end_of_marker < len(content) and content[end_of_marker] == "\n": + end_of_marker += 1 + new_content = section + content[end_of_marker:] + else: + if content and not content.endswith("\n"): + content += "\n" + new_content = (content + "\n" + section) if content else section +else: + new_content = section + +new_content = new_content.replace("\r\n", "\n").replace("\r", "\n") +with open(ctx_path, "wb") as fh: + fh.write(new_content.encode("utf-8")) +PY + +echo "agent-context: updated $CONTEXT_FILE" diff --git a/extensions/agent-context/scripts/powershell/update-agent-context.ps1 b/extensions/agent-context/scripts/powershell/update-agent-context.ps1 new file mode 100644 index 0000000000..dad309c03a --- /dev/null +++ b/extensions/agent-context/scripts/powershell/update-agent-context.ps1 @@ -0,0 +1,237 @@ +#!/usr/bin/env pwsh +# update-agent-context.ps1 +# +# Refresh the managed Spec Kit section in the coding agent's context file +# (e.g. CLAUDE.md, .github/copilot-instructions.md, AGENTS.md). +# +# Reads `context_file` and `context_markers.{start,end}` from the +# agent-context extension config: +# .specify/extensions/agent-context/agent-context-config.yml +# +# Usage: update-agent-context.ps1 [plan_path] + +[CmdletBinding()] +param( + [Parameter(Position = 0)] + [string]$PlanPath +) + +function Get-ConfigValue { + param( + [AllowNull()][object]$Object, + [Parameter(Mandatory = $true)][string]$Key + ) + + if ($null -eq $Object) { + return $null + } + if ($Object -is [System.Collections.IDictionary]) { + return $Object[$Key] + } + $prop = $Object.PSObject.Properties[$Key] + if ($prop) { + return $prop.Value + } + return $null +} + +function Test-ConfigObject { + param( + [AllowNull()][object]$Object + ) + + if ($null -eq $Object) { + return $false + } + if ($Object -is [System.Collections.IDictionary]) { + return $true + } + if ($Object -is [System.Management.Automation.PSCustomObject]) { + return $true + } + return $false +} + +$ErrorActionPreference = 'Stop' +$DefaultStart = '' +$DefaultEnd = '' +$ProjectRoot = (Get-Location).Path +$ExtConfig = Join-Path $ProjectRoot '.specify/extensions/agent-context/agent-context-config.yml' + +if (-not (Test-Path -LiteralPath $ExtConfig)) { + Write-Warning "agent-context: $ExtConfig not found; nothing to do." + exit 0 +} + +$Options = $null +if (Get-Command ConvertFrom-Yaml -ErrorAction SilentlyContinue) { + try { + $Options = Get-Content -LiteralPath $ExtConfig -Raw | ConvertFrom-Yaml -ErrorAction Stop + } catch { + # fall through to Python fallback + } +} + +if ($null -eq $Options) { + # ConvertFrom-Yaml unavailable or failed; fall back to Python+PyYAML. + $pythonCmd = $null + foreach ($candidate in @('python3', 'python')) { + if (Get-Command $candidate -ErrorAction SilentlyContinue) { + # Verify it is Python 3 + $verOut = & $candidate --version 2>&1 + if ($verOut -match 'Python 3') { + $pythonCmd = $candidate + break + } + } + } + + if ($pythonCmd) { + try { + $jsonOut = & $pythonCmd -c @' +import json +import sys +try: + import yaml +except ImportError: + print( + "agent-context: PyYAML is required to parse extension config; cannot update context.", + file=sys.stderr, + ) + sys.exit(2) + +try: + with open(sys.argv[1], "r", encoding="utf-8") as fh: + data = yaml.safe_load(fh) +except Exception as exc: + print( + f"agent-context: unable to parse {sys.argv[1]} ({exc}); cannot update context.", + file=sys.stderr, + ) + sys.exit(2) + +if not isinstance(data, dict): + data = {} + +print(json.dumps(data)) +'@ $ExtConfig + if ($LASTEXITCODE -eq 0 -and $jsonOut) { + $Options = $jsonOut | ConvertFrom-Json -ErrorAction Stop + } + } catch { + $Options = $null + } + } + + if (-not $Options) { + Write-Warning "agent-context: unable to parse $ExtConfig; skipping update." + exit 0 + } +} + +if (-not (Test-ConfigObject -Object $Options)) { + Write-Warning "agent-context: $ExtConfig must contain a YAML mapping; skipping update." + exit 0 +} + +$ContextFile = Get-ConfigValue -Object $Options -Key 'context_file' +if (-not $ContextFile) { + Write-Warning 'agent-context: context_file not set in extension config; nothing to do.' + exit 0 +} + +# Reject absolute paths and '..' path segments in context_file +if ([System.IO.Path]::IsPathRooted($ContextFile)) { + Write-Warning "agent-context: context_file must be a project-relative path; got '$ContextFile'." + exit 1 +} +$cfSegments = $ContextFile -split '[/\\]' +if ($cfSegments -contains '..') { + Write-Warning "agent-context: context_file must not contain '..' path segments; got '$ContextFile'." + exit 1 +} + +$MarkerStart = $DefaultStart +$MarkerEnd = $DefaultEnd +$cm = Get-ConfigValue -Object $Options -Key 'context_markers' +if ($cm) { + $cmStart = Get-ConfigValue -Object $cm -Key 'start' + if ($cmStart -is [string] -and $cmStart) { + $MarkerStart = $cmStart + } + $cmEnd = Get-ConfigValue -Object $cm -Key 'end' + if ($cmEnd -is [string] -and $cmEnd) { + $MarkerEnd = $cmEnd + } +} + +if (-not $PlanPath) { + # Discover plan.md exactly one level deep (specs//plan.md), + # matching the bash glob specs/*/plan.md. Wrap in try/catch so access errors under + # $ErrorActionPreference = 'Stop' don't abort the script. + try { + $specsDir = Join-Path $ProjectRoot 'specs' + $candidate = Get-ChildItem -Path $specsDir -Directory -ErrorAction SilentlyContinue | + ForEach-Object { Get-Item -LiteralPath (Join-Path $_.FullName 'plan.md') -ErrorAction SilentlyContinue } | + Where-Object { $_ } | + Sort-Object LastWriteTime -Descending | + Select-Object -First 1 + if ($candidate) { + $PlanPath = [System.IO.Path]::GetRelativePath($ProjectRoot, $candidate.FullName).Replace('\','/') + } + } catch { + # Non-fatal: continue without a plan path. + } +} + +$CtxPath = Join-Path $ProjectRoot $ContextFile +$CtxDir = Split-Path -Parent $CtxPath +if ($CtxDir -and -not (Test-Path -LiteralPath $CtxDir)) { + New-Item -ItemType Directory -Path $CtxDir -Force | Out-Null +} + +$lines = @($MarkerStart, + 'For additional context about technologies to be used, project structure,', + 'shell commands, and other important information, read the current plan') +if ($PlanPath) { + $lines += "at $PlanPath" +} +$lines += $MarkerEnd +$Section = ($lines -join "`n") + "`n" + +if (Test-Path -LiteralPath $CtxPath) { + $rawBytes = [System.IO.File]::ReadAllBytes($CtxPath) + # Strip UTF-8 BOM if present + if ($rawBytes.Length -ge 3 -and $rawBytes[0] -eq 0xEF -and $rawBytes[1] -eq 0xBB -and $rawBytes[2] -eq 0xBF) { + $content = [System.Text.Encoding]::UTF8.GetString($rawBytes, 3, $rawBytes.Length - 3) + } else { + $content = [System.Text.Encoding]::UTF8.GetString($rawBytes) + } + + $s = $content.IndexOf($MarkerStart) + $e = if ($s -ge 0) { $content.IndexOf($MarkerEnd, $s) } else { $content.IndexOf($MarkerEnd) } + + if ($s -ge 0 -and $e -ge 0 -and $e -gt $s) { + $endOfMarker = $e + $MarkerEnd.Length + if ($endOfMarker -lt $content.Length -and $content[$endOfMarker] -eq "`r") { $endOfMarker++ } + if ($endOfMarker -lt $content.Length -and $content[$endOfMarker] -eq "`n") { $endOfMarker++ } + $newContent = $content.Substring(0, $s) + $Section + $content.Substring($endOfMarker) + } elseif ($s -ge 0) { + $newContent = $content.Substring(0, $s) + $Section + } elseif ($e -ge 0) { + $endOfMarker = $e + $MarkerEnd.Length + if ($endOfMarker -lt $content.Length -and $content[$endOfMarker] -eq "`r") { $endOfMarker++ } + if ($endOfMarker -lt $content.Length -and $content[$endOfMarker] -eq "`n") { $endOfMarker++ } + $newContent = $Section + $content.Substring($endOfMarker) + } else { + if ($content -and -not $content.EndsWith("`n")) { $content += "`n" } + if ($content) { $newContent = $content + "`n" + $Section } else { $newContent = $Section } + } +} else { + $newContent = $Section +} + +$newContent = $newContent.Replace("`r`n", "`n").Replace("`r", "`n") +[System.IO.File]::WriteAllText($CtxPath, $newContent, (New-Object System.Text.UTF8Encoding($false))) + +Write-Host "agent-context: updated $ContextFile" diff --git a/extensions/catalog.json b/extensions/catalog.json index de9372e2bc..284e9abe75 100644 --- a/extensions/catalog.json +++ b/extensions/catalog.json @@ -3,6 +3,20 @@ "updated_at": "2026-04-10T00:00:00Z", "catalog_url": "https://raw.githubusercontent.com/github/spec-kit/main/extensions/catalog.json", "extensions": { + "agent-context": { + "name": "Coding Agent Context", + "id": "agent-context", + "version": "1.0.0", + "description": "Manages coding agent context/instruction files (e.g., CLAUDE.md, copilot-instructions.md) with project-specific plan references and configurable markers", + "author": "spec-kit-core", + "repository": "https://github.com/github/spec-kit", + "bundled": true, + "tags": [ + "agent", + "context", + "core" + ] + }, "git": { "name": "Git Branching Workflow", "id": "git", diff --git a/pyproject.toml b/pyproject.toml index 6c9170f54e..55a233c8db 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -40,6 +40,7 @@ packages = ["src/specify_cli"] "scripts/powershell" = "specify_cli/core_pack/scripts/powershell" # Bundled extensions (installable via `specify extension add `) "extensions/git" = "specify_cli/core_pack/extensions/git" +"extensions/agent-context" = "specify_cli/core_pack/extensions/agent-context" # Bundled workflows (auto-installed during `specify init`) "workflows/speckit" = "specify_cli/core_pack/workflows/speckit" # Bundled presets (installable via `specify preset add ` or `specify init --preset `) diff --git a/src/specify_cli/__init__.py b/src/specify_cli/__init__.py index 595b38f1e9..bff911def0 100644 --- a/src/specify_cli/__init__.py +++ b/src/specify_cli/__init__.py @@ -304,6 +304,72 @@ def load_init_options(project_path: Path) -> dict[str, Any]: return {} +# --------------------------------------------------------------------------- +# Agent-context extension config helpers +# --------------------------------------------------------------------------- + +_AGENT_CTX_EXT_CONFIG = ( + Path(".specify") / "extensions" / "agent-context" / "agent-context-config.yml" +) + + +def _load_agent_context_config(project_root: Path) -> dict[str, Any]: + """Load the agent-context extension config, returning defaults on failure.""" + from .integrations.base import IntegrationBase + + defaults: dict[str, Any] = { + "context_file": "", + "context_markers": { + "start": IntegrationBase.CONTEXT_MARKER_START, + "end": IntegrationBase.CONTEXT_MARKER_END, + }, + } + path = project_root / _AGENT_CTX_EXT_CONFIG + if not path.exists(): + return defaults + try: + raw = yaml.safe_load(path.read_text(encoding="utf-8")) + except (OSError, UnicodeError, yaml.YAMLError): + return defaults + if not isinstance(raw, dict): + return defaults + return raw + + +def _save_agent_context_config( + project_root: Path, config: dict[str, Any] +) -> None: + """Persist *config* to the agent-context extension config file.""" + path = project_root / _AGENT_CTX_EXT_CONFIG + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text(yaml.safe_dump(config, default_flow_style=False, sort_keys=False), encoding="utf-8") + + +def _update_agent_context_config_file( + project_root: Path, + context_file: str | None, + *, + preserve_markers: bool = True, +) -> None: + """Update the agent-context extension config with *context_file*. + + When *preserve_markers* is True (default), any existing + ``context_markers`` values are kept unchanged so user customisations + survive integration changes and reinit. When False, the default + markers are written unconditionally. + """ + from .integrations.base import IntegrationBase + + cfg = _load_agent_context_config(project_root) + cfg["context_file"] = context_file or "" + if not preserve_markers or not isinstance(cfg.get("context_markers"), dict): + cfg["context_markers"] = { + "start": IntegrationBase.CONTEXT_MARKER_START, + "end": IntegrationBase.CONTEXT_MARKER_END, + } + _save_agent_context_config(project_root, cfg) + + def _get_skills_dir(project_path: Path, selected_ai: str) -> Path: """Resolve the agent-specific skills directory. @@ -649,13 +715,31 @@ def _refresh_init_options_speckit_version(project_root: Path) -> None: def _clear_init_options_for_integration(project_root: Path, integration_key: str) -> None: - """Clear active integration keys from init-options.json when they match.""" + """Clear active integration keys from init-options.json when they match. + + Also clears ``context_file`` from the agent-context extension config so + no stale path is left behind when the integration is uninstalled. + """ opts = load_init_options(project_root) + has_legacy_context_keys = ("context_file" in opts) or ("context_markers" in opts) + # Remove legacy fields that older versions may have written. + opts.pop("context_file", None) + opts.pop("context_markers", None) + if opts.get("integration") == integration_key or opts.get("ai") == integration_key: opts.pop("integration", None) opts.pop("ai", None) opts.pop("ai_skills", None) - opts.pop("context_file", None) + save_init_options(project_root, opts) + # Clear context_file in the extension config if it already exists. + # Avoid creating the config (and parent dirs) in projects where the + # agent-context extension was never installed. + ext_cfg_path = project_root / _AGENT_CTX_EXT_CONFIG + if ext_cfg_path.exists(): + _update_agent_context_config_file( + project_root, "", preserve_markers=True + ) + elif has_legacy_context_keys: save_init_options(project_root, opts) @@ -1100,12 +1184,23 @@ def _update_init_options_for_integration( integration: Any, script_type: str | None = None, ) -> None: - """Update ``init-options.json`` to reflect *integration* as the active one.""" + """Update init-options.json and the agent-context extension config to + reflect *integration* as the active one. + + ``context_file`` and ``context_markers`` are stored in the agent-context + extension config (``.specify/extensions/agent-context/agent-context-config.yml``), + not in ``init-options.json``. Existing user-customised markers are + always preserved when the config already exists; invalid marker values + are silently ignored at runtime by ``_resolve_context_markers()`` which + falls back to the class-level defaults. + """ from .integrations.base import SkillsIntegration opts = load_init_options(project_root) opts["integration"] = integration.key opts["ai"] = integration.key - opts["context_file"] = integration.context_file + # Remove legacy fields if they were written by an older version. + opts.pop("context_file", None) + opts.pop("context_markers", None) opts["speckit_version"] = get_speckit_version() if script_type: opts["script"] = script_type @@ -1115,6 +1210,24 @@ def _update_init_options_for_integration( opts.pop("ai_skills", None) save_init_options(project_root, opts) + # Update the agent-context extension config with the new context_file, + # preserving any user-customised markers. + ext_cfg_path = project_root / _AGENT_CTX_EXT_CONFIG + if ext_cfg_path.exists(): + _update_agent_context_config_file( + project_root, + integration.context_file, + preserve_markers=True, + ) + elif integration.context_file: + # Extension config doesn't exist yet (extension not installed). + # Write defaults so scripts have something to read. + _update_agent_context_config_file( + project_root, + integration.context_file, + preserve_markers=False, + ) + @integration_app.command("use") def integration_use( diff --git a/src/specify_cli/agents.py b/src/specify_cli/agents.py index a01ed4102f..404a3a0a0e 100644 --- a/src/specify_cli/agents.py +++ b/src/specify_cli/agents.py @@ -374,8 +374,15 @@ def resolve_skill_placeholders( body = body.replace("{ARGS}", "$ARGUMENTS").replace("__AGENT__", agent_name) - # Resolve __CONTEXT_FILE__ from init-options - context_file = init_opts.get("context_file") or "" + # Resolve __CONTEXT_FILE__ from the agent-context extension config. + # Fall back to init-options.json for projects that haven't migrated. + # Local import: _load_agent_context_config lives in __init__.py which + # imports agents.py, so a top-level import would be circular. + from . import _load_agent_context_config + ac_cfg = _load_agent_context_config(project_root) + context_file = ac_cfg.get("context_file") or "" + if not context_file: + context_file = init_opts.get("context_file") or "" body = body.replace("__CONTEXT_FILE__", context_file) return CommandRegistrar.rewrite_project_relative_paths(body) diff --git a/src/specify_cli/commands/init.py b/src/specify_cli/commands/init.py index f04feb4057..e5dc47e98c 100644 --- a/src/specify_cli/commands/init.py +++ b/src/specify_cli/commands/init.py @@ -153,6 +153,7 @@ def init( _install_shared_infra_or_exit, _parse_integration_options, _print_cli_warning, + _update_agent_context_config_file, _write_integration_json, ensure_executable_scripts, save_init_options, @@ -394,6 +395,7 @@ def init( ("constitution", "Constitution setup"), ("git", "Install git extension"), ("workflow", "Install bundled workflow"), + ("agent-context", "Install agent-context extension"), ("final", "Finalize"), ]: tracker.add(key, label) @@ -535,13 +537,10 @@ def init( sanitized_wf = str(wf_err).replace('\n', ' ').strip() tracker.error("workflow", f"install failed: {sanitized_wf[:120]}") - ensure_executable_scripts(project_path, tracker=tracker) - init_opts = { "ai": selected_ai, "integration": resolved_integration.key, "branch_numbering": branch_numbering or "sequential", - "context_file": resolved_integration.context_file, "here": here, "script": selected_script, "speckit_version": get_speckit_version(), @@ -551,6 +550,47 @@ def init( init_opts["ai_skills"] = True save_init_options(project_path, init_opts) + # --- agent-context extension (bundled, auto-installed) --- + # Installed after init-options.json is written so that skill + # registration can read ai_skills + integration key. + try: + from ..extensions import ExtensionManager as _ExtMgr + bundled_ac = _locate_bundled_extension("agent-context") + if bundled_ac: + ac_mgr = _ExtMgr(project_path) + if ac_mgr.registry.is_installed("agent-context"): + tracker.complete("agent-context", "already installed") + else: + ac_mgr.install_from_directory( + bundled_ac, get_speckit_version() + ) + tracker.complete("agent-context", "extension installed") + else: + from ..extensions import REINSTALL_COMMAND as _ac_reinstall + tracker.error( + "agent-context", + f"bundled extension not found — installation may be " + f"incomplete. Run: {_ac_reinstall}", + ) + except Exception as ac_err: + sanitized_ac = str(ac_err).replace('\n', ' ').strip() + tracker.error( + "agent-context", + f"extension install failed: {sanitized_ac[:120]}", + ) + + # Write context_file to the agent-context extension config + # AFTER the extension install (which copies the template config + # with an empty context_file). + if resolved_integration.context_file: + _update_agent_context_config_file( + project_path, + resolved_integration.context_file, + preserve_markers=True, + ) + + ensure_executable_scripts(project_path, tracker=tracker) + if preset: try: from ..presets import PresetManager, PresetCatalog, PresetError diff --git a/src/specify_cli/integrations/base.py b/src/specify_cli/integrations/base.py index 5889ed08ea..4742a37cab 100644 --- a/src/specify_cli/integrations/base.py +++ b/src/specify_cli/integrations/base.py @@ -13,6 +13,7 @@ from __future__ import annotations +import json import os import re import shlex @@ -549,6 +550,91 @@ def _build_context_section(plan_path: str = "") -> str: lines.append(f"at {plan_path}") return "\n".join(lines) + @staticmethod + def _agent_context_extension_enabled(project_root: Path) -> bool: + """Return whether the bundled ``agent-context`` extension is enabled. + + The extension is the single source of truth for managing coding + agent context/instruction files (e.g. ``CLAUDE.md``, + ``.github/copilot-instructions.md``). + + Returns ``True`` (enabled) when: + - the extension registry does not exist (legacy project, backwards + compatibility), or + - the registry has no ``agent-context`` entry (older project layout + predating the extension), or + - the entry is present and not explicitly disabled. + + Returns ``False`` only when an entry exists with ``enabled: false``. + """ + registry_path = ( + project_root / ".specify" / "extensions" / ".registry" + ) + if not registry_path.exists(): + return True + try: + data = json.loads(registry_path.read_text(encoding="utf-8")) + except (OSError, ValueError, UnicodeError): + return True + if not isinstance(data, dict): + return True + extensions = data.get("extensions") + if not isinstance(extensions, dict): + return True + entry = extensions.get("agent-context") + if not isinstance(entry, dict): + return True + return entry.get("enabled", True) is not False + + def _resolve_context_markers(self, project_root: Path) -> tuple[str, str]: + """Return the (start, end) context markers to use for *project_root*. + + Reads ``context_markers.start`` / ``context_markers.end`` from the + agent-context extension config + (``.specify/extensions/agent-context/agent-context-config.yml``) + when present. Falls back to the class-level constants + ``CONTEXT_MARKER_START`` / ``CONTEXT_MARKER_END`` when the file is + missing, the section is absent, or the values are not non-empty + strings. + """ + from .._console import console # local import to avoid cycles + + start = self.CONTEXT_MARKER_START + end = self.CONTEXT_MARKER_END + config_path = ( + project_root + / ".specify" + / "extensions" + / "agent-context" + / "agent-context-config.yml" + ) + try: + raw = config_path.read_text(encoding="utf-8") + cfg = yaml.safe_load(raw) + except (OSError, UnicodeError, ValueError, yaml.YAMLError): + return start, end + markers = cfg.get("context_markers") if isinstance(cfg, dict) else None + if isinstance(markers, dict): + cm_start = markers.get("start") + cm_end = markers.get("end") + s_valid = isinstance(cm_start, str) and cm_start + e_valid = isinstance(cm_end, str) and cm_end + if not s_valid and cm_start is not None: + console.print( + f"[yellow]agent-context: ignoring invalid context_markers.start " + f"({cm_start!r}), using default[/yellow]" + ) + if not e_valid and cm_end is not None: + console.print( + f"[yellow]agent-context: ignoring invalid context_markers.end " + f"({cm_end!r}), using default[/yellow]" + ) + if s_valid: + start = cm_start # type: ignore[assignment] + if e_valid: + end = cm_end # type: ignore[assignment] + return start, end + def upsert_context_section( self, project_root: Path, @@ -557,34 +643,54 @@ def upsert_context_section( """Create or update the managed section in the agent context file. If the context file does not exist it is created with just the - managed section. If it exists, the content between - ```` and ```` markers - is replaced (or appended when no markers are found). + managed section. If it exists, the content between the configured + start/end markers (default ```` / + ````) is replaced, or appended when no markers + are found. Markers are read from the agent-context extension config + (``.specify/extensions/agent-context/agent-context-config.yml``) + when present, falling back to the class-level constants. Returns the path to the context file, or ``None`` when - ``context_file`` is not set. + ``context_file`` is not set or the ``agent-context`` extension is + disabled. """ if not self.context_file: return None + if not self._agent_context_extension_enabled(project_root): + return None + + from .._console import console # local import to avoid cycles + + console.print( + "[yellow]Deprecation:[/yellow] Inline agent-context updates during " + "integration setup will be disabled in v0.12.0. Context file " + "management has moved to the bundled [bold]agent-context[/bold] " + "extension. Run [cyan]specify extension disable agent-context[/cyan] " + "to opt out early.", + highlight=False, + ) + + marker_start, marker_end = self._resolve_context_markers(project_root) + ctx_path = project_root / self.context_file section = ( - f"{self.CONTEXT_MARKER_START}\n" + f"{marker_start}\n" f"{self._build_context_section(plan_path)}\n" - f"{self.CONTEXT_MARKER_END}\n" + f"{marker_end}\n" ) if ctx_path.exists(): content = ctx_path.read_text(encoding="utf-8-sig") - start_idx = content.find(self.CONTEXT_MARKER_START) + start_idx = content.find(marker_start) end_idx = content.find( - self.CONTEXT_MARKER_END, + marker_end, start_idx if start_idx != -1 else 0, ) if start_idx != -1 and end_idx != -1 and end_idx > start_idx: # Replace existing section (include the end marker + newline) - end_of_marker = end_idx + len(self.CONTEXT_MARKER_END) + end_of_marker = end_idx + len(marker_end) # Consume trailing line ending (CRLF or LF) if end_of_marker < len(content) and content[end_of_marker] == "\r": end_of_marker += 1 @@ -596,7 +702,7 @@ def upsert_context_section( new_content = content[:start_idx] + section elif end_idx != -1: # Corrupted: end marker without start — replace BOF through end marker - end_of_marker = end_idx + len(self.CONTEXT_MARKER_END) + end_of_marker = end_idx + len(marker_end) if end_of_marker < len(content) and content[end_of_marker] == "\r": end_of_marker += 1 if end_of_marker < len(content) and content[end_of_marker] == "\n": @@ -630,20 +736,27 @@ def remove_context_section(self, project_root: Path) -> bool: """Remove the managed section from the agent context file. Returns ``True`` if the section was found and removed. If the - file becomes empty (or whitespace-only) after removal it is - deleted. + file becomes empty (or whitespace-only) after removal it is deleted. + Markers are read from the agent-context extension config + (``.specify/extensions/agent-context/agent-context-config.yml``) + when present, falling back to the class-level constants. """ if not self.context_file: return False + if not self._agent_context_extension_enabled(project_root): + return False + ctx_path = project_root / self.context_file if not ctx_path.exists(): return False + marker_start, marker_end = self._resolve_context_markers(project_root) + content = ctx_path.read_text(encoding="utf-8-sig") - start_idx = content.find(self.CONTEXT_MARKER_START) + start_idx = content.find(marker_start) end_idx = content.find( - self.CONTEXT_MARKER_END, + marker_end, start_idx if start_idx != -1 else 0, ) @@ -654,7 +767,7 @@ def remove_context_section(self, project_root: Path) -> bool: return False removal_start = start_idx - removal_end = end_idx + len(self.CONTEXT_MARKER_END) + removal_end = end_idx + len(marker_end) # Consume trailing line ending (CRLF or LF) if removal_end < len(content) and content[removal_end] == "\r": diff --git a/templates/commands/checklist.md b/templates/commands/checklist.md index 3e400d86e3..2e1b1040af 100644 --- a/templates/commands/checklist.md +++ b/templates/commands/checklist.md @@ -74,7 +74,9 @@ You **MUST** consider the user input before proceeding (if not empty). - All file paths must be absolute. - For single quotes in args like "I'm Groot", use escape syntax: e.g 'I'\''m Groot' (or double-quote if possible: "I'm Groot"). -2. **Clarify intent (dynamic)**: Derive up to THREE initial contextual clarifying questions (no pre-baked catalog). They MUST: +2. **IF EXISTS**: Load `/memory/constitution.md` for project principles and governance constraints. + +3. **Clarify intent (dynamic)**: Derive up to THREE initial contextual clarifying questions (no pre-baked catalog). They MUST: - Be generated from the user's phrasing + extracted signals from spec/plan/tasks - Only ask about information that materially changes checklist content - Be skipped individually if already unambiguous in `$ARGUMENTS` @@ -106,13 +108,13 @@ You **MUST** consider the user input before proceeding (if not empty). Output the questions (label Q1/Q2/Q3). After answers: if ≥2 scenario classes (Alternate / Exception / Recovery / Non-Functional domain) remain unclear, you MAY ask up to TWO more targeted follow‑ups (Q4/Q5) with a one-line justification each (e.g., "Unresolved recovery path risk"). Do not exceed five total questions. Skip escalation if user explicitly declines more. -3. **Understand user request**: Combine `$ARGUMENTS` + clarifying answers: +4. **Understand user request**: Combine `$ARGUMENTS` + clarifying answers: - Derive checklist theme (e.g., security, review, deploy, ux) - Consolidate explicit must-have items mentioned by user - Map focus selections to category scaffolding - Infer any missing context from spec/plan/tasks (do NOT hallucinate) -4. **Load feature context**: Read from FEATURE_DIR: +5. **Load feature context**: Read from FEATURE_DIR: - spec.md: Feature requirements and scope - plan.md (if exists): Technical details, dependencies - tasks.md (if exists): Implementation tasks @@ -123,7 +125,7 @@ You **MUST** consider the user input before proceeding (if not empty). - Use progressive disclosure: add follow-on retrieval only if gaps detected - If source docs are large, generate interim summary items instead of embedding raw text -5. **Generate checklist** - Create "Unit Tests for Requirements": +6. **Generate checklist** - Create "Unit Tests for Requirements": - Create `FEATURE_DIR/checklists/` directory if it doesn't exist - Generate unique checklist filename: - Use short, descriptive name based on domain (e.g., `ux.md`, `api.md`, `security.md`) @@ -241,9 +243,9 @@ You **MUST** consider the user input before proceeding (if not empty). - ✅ "Are [edge cases/scenarios] addressed in requirements?" - ✅ "Does the spec define [missing aspect]?" -6. **Structure Reference**: Generate the checklist following the canonical template in `templates/checklist-template.md` for title, meta section, category headings, and ID formatting. If template is unavailable, use: H1 title, purpose/created meta lines, `##` category sections containing `- [ ] CHK### ` lines with globally incrementing IDs starting at CHK001. +7. **Structure Reference**: Generate the checklist following the canonical template in `templates/checklist-template.md` for title, meta section, category headings, and ID formatting. If template is unavailable, use: H1 title, purpose/created meta lines, `##` category sections containing `- [ ] CHK### ` lines with globally incrementing IDs starting at CHK001. -7. **Report**: Output full path to checklist file, item count, and summarize whether the run created a new file or appended to an existing one. Summarize: +8. **Report**: Output full path to checklist file, item count, and summarize whether the run created a new file or appended to an existing one. Summarize: - Focus areas selected - Depth level - Actor/timing diff --git a/templates/commands/clarify.md b/templates/commands/clarify.md index 8488557e74..a83d52f026 100644 --- a/templates/commands/clarify.md +++ b/templates/commands/clarify.md @@ -66,7 +66,9 @@ Execution steps: - If JSON parsing fails, abort and instruct user to re-run `__SPECKIT_COMMAND_SPECIFY__` or verify feature branch environment. - For single quotes in args like "I'm Groot", use escape syntax: e.g 'I'\''m Groot' (or double-quote if possible: "I'm Groot"). -2. Load the current spec file. Perform a structured ambiguity & coverage scan using this taxonomy. For each category, mark status: Clear / Partial / Missing. Produce an internal coverage map used for prioritization (do not output raw map unless no questions will be asked). +2. **IF EXISTS**: Load `/memory/constitution.md` for project principles and governance constraints. + +3. Load the current spec file. Perform a structured ambiguity & coverage scan using this taxonomy. For each category, mark status: Clear / Partial / Missing. Produce an internal coverage map used for prioritization (do not output raw map unless no questions will be asked). Functional Scope & Behavior: - Core user goals & success criteria @@ -122,7 +124,7 @@ Execution steps: - Clarification would not materially change implementation or validation strategy - Information is better deferred to planning phase (note internally) -3. Generate (internally) a prioritized queue of candidate clarification questions (maximum 5). Do NOT output them all at once. Apply these constraints: +4. Generate (internally) a prioritized queue of candidate clarification questions (maximum 5). Do NOT output them all at once. Apply these constraints: - Maximum of 5 total questions across the whole session. - Each question must be answerable with EITHER: - A short multiple‑choice selection (2–5 distinct, mutually exclusive options), OR @@ -133,7 +135,7 @@ Execution steps: - Favor clarifications that reduce downstream rework risk or prevent misaligned acceptance tests. - If more than 5 categories remain unresolved, select the top 5 by (Impact * Uncertainty) heuristic. -4. Sequential questioning loop (interactive): +5. Sequential questioning loop (interactive): - Present EXACTLY ONE question at a time. - For multiple‑choice questions: - **Analyze all options** and determine the **most suitable option** based on: @@ -169,7 +171,7 @@ Execution steps: - Never reveal future queued questions in advance. - If no valid questions exist at start, immediately report no critical ambiguities. -5. Integration after EACH accepted answer (incremental update approach): +6. Integration after EACH accepted answer (incremental update approach): - Maintain in-memory representation of the spec (loaded once at start) plus the raw file contents. - For the first integrated answer in this session: - Ensure a `## Clarifications` section exists (create it just after the highest-level contextual/overview section per the spec template if missing). @@ -187,7 +189,7 @@ Execution steps: - Preserve formatting: do not reorder unrelated sections; keep heading hierarchy intact. - Keep each inserted clarification minimal and testable (avoid narrative drift). -6. Validation (performed after EACH write plus final pass): +7. Validation (performed after EACH write plus final pass): - Clarifications session contains exactly one bullet per accepted answer (no duplicates). - Total asked (accepted) questions ≤ 5. - Updated sections contain no lingering vague placeholders the new answer was meant to resolve. @@ -195,9 +197,9 @@ Execution steps: - Markdown structure valid; only allowed new headings: `## Clarifications`, `### Session YYYY-MM-DD`. - Terminology consistency: same canonical term used across all updated sections. -7. Write the updated spec back to `FEATURE_SPEC`. +8. Write the updated spec back to `FEATURE_SPEC`. -8. **Re-validate Spec Quality Checklist** (if it exists): +9. **Re-validate Spec Quality Checklist** (if it exists): - Check if `FEATURE_DIR/checklists/requirements.md` exists. - If it does NOT exist, skip this step silently. - If it exists: diff --git a/templates/commands/specify.md b/templates/commands/specify.md index 76c548676b..2fa192fea1 100644 --- a/templates/commands/specify.md +++ b/templates/commands/specify.md @@ -109,7 +109,9 @@ Given that feature description, do this: 4. Load `templates/spec-template.md` to understand required sections. -5. Follow this execution flow: +5. **IF EXISTS**: Load `/memory/constitution.md` for project principles and governance constraints. + +6. Follow this execution flow: 1. Parse user description from arguments If empty: ERROR "No feature description provided" 2. Extract key concepts from description diff --git a/templates/commands/tasks.md b/templates/commands/tasks.md index 43ff37d760..f863e7787f 100644 --- a/templates/commands/tasks.md +++ b/templates/commands/tasks.md @@ -63,6 +63,7 @@ You **MUST** consider the user input before proceeding (if not empty). 2. **Load design documents**: Read from FEATURE_DIR: - **Required**: plan.md (tech stack, libraries, structure), spec.md (user stories with priorities) - **Optional**: data-model.md (entities), contracts/ (interface contracts), research.md (decisions), quickstart.md (test scenarios) + - **IF EXISTS**: Load `/memory/constitution.md` for project principles and governance constraints - Note: Not all projects have all documents. Generate tasks based on what's available. 3. **Execute task generation workflow**: diff --git a/templates/commands/taskstoissues.md b/templates/commands/taskstoissues.md index 77db7be130..b24e84ee14 100644 --- a/templates/commands/taskstoissues.md +++ b/templates/commands/taskstoissues.md @@ -51,6 +51,7 @@ You **MUST** consider the user input before proceeding (if not empty). ## Outline 1. Run `{SCRIPT}` from repo root and parse FEATURE_DIR and AVAILABLE_DOCS list. All paths must be absolute. For single quotes in args like "I'm Groot", use escape syntax: e.g 'I'\''m Groot' (or double-quote if possible: "I'm Groot"). +1. **IF EXISTS**: Load `/memory/constitution.md` for project principles and governance constraints. 1. From the executed script, extract the path to **tasks**. 1. Get the Git remote by running: diff --git a/tests/extensions/test_extension_agent_context.py b/tests/extensions/test_extension_agent_context.py new file mode 100644 index 0000000000..61ecab91af --- /dev/null +++ b/tests/extensions/test_extension_agent_context.py @@ -0,0 +1,455 @@ +"""Tests for the bundled ``agent-context`` extension and related plumbing.""" + +from __future__ import annotations + +import json +from pathlib import Path + +import yaml + +from specify_cli import ( + _load_agent_context_config, + _save_agent_context_config, + load_init_options, + save_init_options, +) +from specify_cli.integrations.base import IntegrationBase +from specify_cli.integrations.claude import ClaudeIntegration + + +PROJECT_ROOT = Path(__file__).resolve().parent.parent.parent +EXT_DIR = PROJECT_ROOT / "extensions" / "agent-context" + + +def _write_ext_config(project_root: Path, **overrides: object) -> None: + """Write a minimal agent-context extension config.""" + cfg: dict = { + "context_file": overrides.get("context_file", ""), + "context_markers": overrides.get( + "context_markers", + { + "start": IntegrationBase.CONTEXT_MARKER_START, + "end": IntegrationBase.CONTEXT_MARKER_END, + }, + ), + } + _save_agent_context_config(project_root, cfg) + + +# ── Bundled extension layout ───────────────────────────────────────────────── + + +class TestExtensionLayout: + """The bundled agent-context extension ships a complete package.""" + + def test_extension_yml_exists(self): + assert (EXT_DIR / "extension.yml").is_file() + + def test_extension_yml_has_required_fields(self): + manifest = yaml.safe_load((EXT_DIR / "extension.yml").read_text()) + assert manifest["extension"]["id"] == "agent-context" + assert manifest["extension"]["name"] == "Coding Agent Context" + assert manifest["extension"]["author"] == "spec-kit-core" + # Provides at least the manual update command + commands = {c["name"] for c in manifest["provides"]["commands"]} + assert "speckit.agent-context.update" in commands + + def test_readme_exists(self): + readme = EXT_DIR / "README.md" + assert readme.is_file() + text = readme.read_text(encoding="utf-8") + assert "Coding Agent Context Extension" in text + + def test_config_template_exists(self): + cfg = EXT_DIR / "agent-context-config.yml" + assert cfg.is_file() + parsed = yaml.safe_load(cfg.read_text(encoding="utf-8")) + assert "context_file" in parsed + assert "context_markers" in parsed + + def test_command_file_exists(self): + cmd = EXT_DIR / "commands" / "speckit.agent-context.update.md" + assert cmd.is_file() + assert "agent-context-config.yml" in cmd.read_text(encoding="utf-8") + + def test_bundled_scripts_exist(self): + assert (EXT_DIR / "scripts" / "bash" / "update-agent-context.sh").is_file() + assert (EXT_DIR / "scripts" / "powershell" / "update-agent-context.ps1").is_file() + + def test_bash_script_reads_extension_config(self): + text = (EXT_DIR / "scripts" / "bash" / "update-agent-context.sh").read_text( + encoding="utf-8" + ) + # The script must consult the extension config, not init-options.json + assert "agent-context-config.yml" in text + assert "context_file" in text + assert "context_markers" in text + + +# ── Catalog registration ───────────────────────────────────────────────────── + + +class TestCatalogEntry: + def test_catalog_lists_agent_context_as_bundled(self): + catalog = json.loads( + (PROJECT_ROOT / "extensions" / "catalog.json").read_text(encoding="utf-8") + ) + entry = catalog["extensions"]["agent-context"] + assert entry["bundled"] is True + assert entry["id"] == "agent-context" + assert entry["author"] == "spec-kit-core" + + +# ── Marker resolution from extension config ────────────────────────────────── + + +class _CtxIntegration(ClaudeIntegration): + """Use Claude as a concrete integration with a context_file.""" + + +class TestContextMarkerResolution: + def test_defaults_when_ext_config_missing(self, tmp_path): + i = _CtxIntegration() + start, end = i._resolve_context_markers(tmp_path) + assert start == IntegrationBase.CONTEXT_MARKER_START + assert end == IntegrationBase.CONTEXT_MARKER_END + + def test_defaults_when_markers_field_missing(self, tmp_path): + """Config file exists with context_file but no context_markers key.""" + cfg_path = ( + tmp_path / ".specify" / "extensions" / "agent-context" + / "agent-context-config.yml" + ) + cfg_path.parent.mkdir(parents=True, exist_ok=True) + cfg_path.write_text("context_file: CLAUDE.md\n", encoding="utf-8") + i = _CtxIntegration() + start, end = i._resolve_context_markers(tmp_path) + assert start == IntegrationBase.CONTEXT_MARKER_START + assert end == IntegrationBase.CONTEXT_MARKER_END + + def test_custom_markers_respected(self, tmp_path): + _write_ext_config( + tmp_path, + context_markers={"start": "", "end": ""}, + ) + i = _CtxIntegration() + start, end = i._resolve_context_markers(tmp_path) + assert start == "" + assert end == "" + + def test_partial_override_falls_back_for_missing_side(self, tmp_path): + _write_ext_config(tmp_path, context_markers={"start": ""}) + i = _CtxIntegration() + start, end = i._resolve_context_markers(tmp_path) + assert start == "" + assert end == IntegrationBase.CONTEXT_MARKER_END + + def test_invalid_markers_fall_back(self, tmp_path): + _write_ext_config(tmp_path, context_markers={"start": 42, "end": ""}) + i = _CtxIntegration() + start, end = i._resolve_context_markers(tmp_path) + assert start == IntegrationBase.CONTEXT_MARKER_START + assert end == IntegrationBase.CONTEXT_MARKER_END + + +# ── upsert_context_section / remove_context_section honor markers ─────────── + + +class TestUpsertWithCustomMarkers: + def _setup(self, tmp_path: Path, markers: dict | None = None) -> _CtxIntegration: + _write_ext_config( + tmp_path, + context_file="CLAUDE.md", + **({"context_markers": markers} if markers is not None else {}), + ) + return _CtxIntegration() + + def test_upsert_uses_default_markers(self, tmp_path): + i = self._setup(tmp_path) + result = i.upsert_context_section(tmp_path) + assert result is not None + text = (tmp_path / "CLAUDE.md").read_text(encoding="utf-8") + assert IntegrationBase.CONTEXT_MARKER_START in text + assert IntegrationBase.CONTEXT_MARKER_END in text + + def test_upsert_uses_custom_markers(self, tmp_path): + i = self._setup( + tmp_path, {"start": "", "end": ""} + ) + i.upsert_context_section(tmp_path) + text = (tmp_path / "CLAUDE.md").read_text(encoding="utf-8") + assert "" in text + assert "" in text + # Defaults must not appear + assert IntegrationBase.CONTEXT_MARKER_START not in text + assert IntegrationBase.CONTEXT_MARKER_END not in text + + def test_upsert_replaces_existing_custom_section(self, tmp_path): + i = self._setup( + tmp_path, {"start": "", "end": ""} + ) + ctx = tmp_path / "CLAUDE.md" + ctx.write_text( + "# header\n\n\nold body\n\n\nfooter\n", + encoding="utf-8", + ) + i.upsert_context_section(tmp_path, plan_path="specs/001-foo/plan.md") + text = ctx.read_text(encoding="utf-8") + assert "old body" not in text + assert "specs/001-foo/plan.md" in text + assert text.startswith("# header\n") + assert "footer" in text + + def test_remove_uses_custom_markers(self, tmp_path): + i = self._setup( + tmp_path, {"start": "", "end": ""} + ) + ctx = tmp_path / "CLAUDE.md" + ctx.write_text( + "preamble\n\n\nbody\n\nepilogue\n", + encoding="utf-8", + ) + removed = i.remove_context_section(tmp_path) + assert removed is True + remaining = ctx.read_text(encoding="utf-8") + assert "" not in remaining + assert "" not in remaining + assert "body" not in remaining + assert "preamble" in remaining + assert "epilogue" in remaining + + def test_remove_with_default_markers_unchanged_when_custom_in_file(self, tmp_path): + # Extension config absent → default markers used. File contains only + # custom markers — nothing should be removed. + i = _CtxIntegration() + ctx = tmp_path / "CLAUDE.md" + original = "x\n\nbody\n\n" + ctx.write_text(original, encoding="utf-8") + assert i.remove_context_section(tmp_path) is False + assert ctx.read_text(encoding="utf-8") == original + + +# ── Extension disabled gates setup/teardown ────────────────────────────────── + + +def _write_registry(project_root: Path, *, enabled: bool) -> None: + registry = project_root / ".specify" / "extensions" / ".registry" + registry.parent.mkdir(parents=True, exist_ok=True) + registry.write_text( + json.dumps( + { + "schema_version": "1.0", + "extensions": { + "agent-context": { + "version": "1.0.0", + "enabled": enabled, + } + }, + } + ), + encoding="utf-8", + ) + + +class TestExtensionEnabledGate: + def test_enabled_helper_default_when_no_registry(self, tmp_path): + assert IntegrationBase._agent_context_extension_enabled(tmp_path) is True + + def test_enabled_helper_when_entry_present(self, tmp_path): + _write_registry(tmp_path, enabled=True) + assert IntegrationBase._agent_context_extension_enabled(tmp_path) is True + + def test_disabled_helper_when_entry_disabled(self, tmp_path): + _write_registry(tmp_path, enabled=False) + assert IntegrationBase._agent_context_extension_enabled(tmp_path) is False + + def test_upsert_skipped_when_disabled(self, tmp_path): + _write_registry(tmp_path, enabled=False) + i = _CtxIntegration() + result = i.upsert_context_section(tmp_path) + assert result is None + assert not (tmp_path / "CLAUDE.md").exists() + + def test_remove_skipped_when_disabled(self, tmp_path): + _write_registry(tmp_path, enabled=False) + i = _CtxIntegration() + ctx = tmp_path / "CLAUDE.md" + original = ( + f"head\n{IntegrationBase.CONTEXT_MARKER_START}\nbody\n" + f"{IntegrationBase.CONTEXT_MARKER_END}\ntail\n" + ) + ctx.write_text(original, encoding="utf-8") + assert i.remove_context_section(tmp_path) is False + # File must be unchanged when extension is disabled + assert ctx.read_text(encoding="utf-8") == original + + +# ── Extension config writers ───────────────────────────────────────────────── + + +class TestExtensionConfigWriters: + def test_clear_init_options_clears_ext_config_context_file(self, tmp_path): + from specify_cli import _clear_init_options_for_integration + + save_init_options( + tmp_path, + {"integration": "claude", "ai": "claude"}, + ) + _write_ext_config(tmp_path, context_file="CLAUDE.md") + _clear_init_options_for_integration(tmp_path, "claude") + cfg = _load_agent_context_config(tmp_path) + assert cfg.get("context_file") == "" + + def test_clear_init_options_creates_ext_config_when_missing(self, tmp_path): + from specify_cli import _clear_init_options_for_integration + + save_init_options( + tmp_path, + {"integration": "claude", "ai": "claude"}, + ) + _clear_init_options_for_integration(tmp_path, "claude") + cfg = _load_agent_context_config(tmp_path) + assert cfg.get("context_file") == "" + + def test_clear_init_options_removes_legacy_context_keys_even_when_not_active( + self, tmp_path + ): + from specify_cli import _clear_init_options_for_integration + + save_init_options( + tmp_path, + { + "integration": "copilot", + "ai": "copilot", + "context_file": "CLAUDE.md", + "context_markers": {"start": "", "end": ""}, + }, + ) + _clear_init_options_for_integration(tmp_path, "claude") + opts = load_init_options(tmp_path) + assert opts["integration"] == "copilot" + assert opts["ai"] == "copilot" + assert "context_file" not in opts + assert "context_markers" not in opts + + def test_update_init_options_writes_context_file_to_ext_config(self, tmp_path): + from specify_cli import _update_init_options_for_integration + + # Pre-create the extension config so _update_init_options_for_integration + # updates it (rather than skipping it when ext config doesn't exist yet). + _write_ext_config(tmp_path, context_file="") + i = _CtxIntegration() + _update_init_options_for_integration(tmp_path, i, script_type="sh") + # init-options.json must NOT have context_file or context_markers + opts = load_init_options(tmp_path) + assert "context_file" not in opts + assert "context_markers" not in opts + # Extension config must have them + cfg = _load_agent_context_config(tmp_path) + assert cfg["context_file"] == i.context_file + assert "context_markers" in cfg + + def test_update_init_options_preserves_custom_markers(self, tmp_path): + from specify_cli import _update_init_options_for_integration + + _write_ext_config( + tmp_path, + context_file="", + context_markers={"start": "", "end": ""}, + ) + i = _CtxIntegration() + _update_init_options_for_integration(tmp_path, i) + cfg = _load_agent_context_config(tmp_path) + assert cfg["context_markers"] == {"start": "", "end": ""} + + def test_reinit_preserves_custom_markers(self, tmp_path): + """specify init (reinit) must not overwrite user-customised markers.""" + from specify_cli import _update_agent_context_config_file + + # Simulate existing project with custom markers + _write_ext_config( + tmp_path, + context_file="CLAUDE.md", + context_markers={"start": "", "end": ""}, + ) + # Re-running init updates context_file but must preserve markers + _update_agent_context_config_file( + tmp_path, "CLAUDE.md", preserve_markers=True + ) + cfg = _load_agent_context_config(tmp_path) + assert cfg["context_markers"] == { + "start": "", + "end": "", + } + + +# ── Deprecation warning on upsert ──────────────────────────────────────────── + + +class TestDeprecationWarning: + def test_upsert_emits_deprecation_warning(self, tmp_path, capsys): + """upsert_context_section must emit a deprecation notice on stdout.""" + from tests.conftest import strip_ansi + + i = _CtxIntegration() + _write_ext_config(tmp_path, context_file="CLAUDE.md") + i.upsert_context_section(tmp_path) + captured = capsys.readouterr() + plain = strip_ansi(captured.out) + assert "Deprecation" in plain + assert "v0.12.0" in plain + assert "agent-context" in plain + + def test_upsert_no_warning_when_disabled(self, tmp_path, capsys): + """No deprecation warning when agent-context extension is disabled.""" + _write_registry(tmp_path, enabled=False) + i = _CtxIntegration() + i.upsert_context_section(tmp_path) + captured = capsys.readouterr() + assert "Deprecation" not in captured.out + + +# ── Corrupt / invalid extension config ─────────────────────────────────────── + + +class TestCorruptExtensionConfig: + def test_marker_resolution_with_corrupt_yaml(self, tmp_path): + """Corrupt YAML in agent-context-config.yml falls back to defaults.""" + cfg_path = ( + tmp_path / ".specify" / "extensions" / "agent-context" + / "agent-context-config.yml" + ) + cfg_path.parent.mkdir(parents=True, exist_ok=True) + cfg_path.write_text(": invalid: yaml: {{{\n", encoding="utf-8") + i = _CtxIntegration() + start, end = i._resolve_context_markers(tmp_path) + assert start == IntegrationBase.CONTEXT_MARKER_START + assert end == IntegrationBase.CONTEXT_MARKER_END + + def test_upsert_with_corrupt_config_uses_defaults(self, tmp_path): + """upsert_context_section still works when config YAML is corrupt.""" + cfg_path = ( + tmp_path / ".specify" / "extensions" / "agent-context" + / "agent-context-config.yml" + ) + cfg_path.parent.mkdir(parents=True, exist_ok=True) + cfg_path.write_text("not valid yaml: {{{\n", encoding="utf-8") + i = _CtxIntegration() + result = i.upsert_context_section(tmp_path) + assert result is not None + text = (tmp_path / "CLAUDE.md").read_text(encoding="utf-8") + assert IntegrationBase.CONTEXT_MARKER_START in text + assert IntegrationBase.CONTEXT_MARKER_END in text + + def test_marker_resolution_with_non_dict_yaml(self, tmp_path): + """Config file containing a scalar (not a dict) falls back to defaults.""" + cfg_path = ( + tmp_path / ".specify" / "extensions" / "agent-context" + / "agent-context-config.yml" + ) + cfg_path.parent.mkdir(parents=True, exist_ok=True) + cfg_path.write_text("just a string\n", encoding="utf-8") + i = _CtxIntegration() + start, end = i._resolve_context_markers(tmp_path) + assert start == IntegrationBase.CONTEXT_MARKER_START + assert end == IntegrationBase.CONTEXT_MARKER_END diff --git a/tests/integrations/test_cli.py b/tests/integrations/test_cli.py index b7a328402e..0c793cd7fa 100644 --- a/tests/integrations/test_cli.py +++ b/tests/integrations/test_cli.py @@ -87,7 +87,14 @@ def test_integration_copilot_creates_files(self, tmp_path): opts = json.loads((project / ".specify" / "init-options.json").read_text(encoding="utf-8")) assert opts["integration"] == "copilot" - assert opts["context_file"] == ".github/copilot-instructions.md" + # context_file lives in the agent-context extension config, not init-options.json + assert "context_file" not in opts + + import yaml as _yaml + ext_cfg_path = project / ".specify" / "extensions" / "agent-context" / "agent-context-config.yml" + assert ext_cfg_path.exists(), "agent-context extension config must be created on init" + ext_cfg = _yaml.safe_load(ext_cfg_path.read_text(encoding="utf-8")) + assert ext_cfg["context_file"] == ".github/copilot-instructions.md" assert (project / ".specify" / "integrations" / "copilot.manifest.json").exists() diff --git a/tests/integrations/test_integration_base_markdown.py b/tests/integrations/test_integration_base_markdown.py index 0b74a6f1a9..48af5bd33b 100644 --- a/tests/integrations/test_integration_base_markdown.py +++ b/tests/integrations/test_integration_base_markdown.py @@ -226,8 +226,8 @@ def test_integration_flag_creates_files(self, tmp_path): assert len(commands) > 0, f"No command files in {cmd_dir}" def test_init_options_includes_context_file(self, tmp_path): - """init-options.json must include context_file for the active integration.""" - import json + """agent-context extension config must include context_file for the active integration.""" + import yaml from typer.testing import CliRunner from specify_cli import app @@ -243,15 +243,17 @@ def test_init_options_includes_context_file(self, tmp_path): finally: os.chdir(old_cwd) assert result.exit_code == 0 - opts = json.loads((project / ".specify" / "init-options.json").read_text()) + ext_cfg_path = project / ".specify" / "extensions" / "agent-context" / "agent-context-config.yml" + ext_cfg = yaml.safe_load(ext_cfg_path.read_text(encoding="utf-8")) if ext_cfg_path.exists() else {} i = get_integration(self.KEY) - assert opts.get("context_file") == i.context_file, ( - f"Expected context_file={i.context_file!r}, got {opts.get('context_file')!r}" + assert ext_cfg.get("context_file") == i.context_file, ( + f"Expected context_file={i.context_file!r}, got {ext_cfg.get('context_file')!r}" ) # -- Complete file inventory ------------------------------------------ COMMAND_STEMS = [ + "agent-context.update", "analyze", "checklist", "clarify", "constitution", "implement", "plan", "specify", "tasks", "taskstoissues", ] @@ -291,6 +293,16 @@ def _expected_files(self, script_variant: str) -> list[str]: files.append(".specify/workflows/speckit/workflow.yml") files.append(".specify/workflows/workflow-registry.json") + # Bundled agent-context extension + files.append(".specify/extensions.yml") + files.append(".specify/extensions/.registry") + files.append(".specify/extensions/agent-context/README.md") + files.append(".specify/extensions/agent-context/agent-context-config.yml") + files.append(".specify/extensions/agent-context/commands/speckit.agent-context.update.md") + files.append(".specify/extensions/agent-context/extension.yml") + files.append(".specify/extensions/agent-context/scripts/bash/update-agent-context.sh") + files.append(".specify/extensions/agent-context/scripts/powershell/update-agent-context.ps1") + # Agent context file (if set) if i.context_file: files.append(i.context_file) diff --git a/tests/integrations/test_integration_base_skills.py b/tests/integrations/test_integration_base_skills.py index 4cbf1774c4..d6c913b70c 100644 --- a/tests/integrations/test_integration_base_skills.py +++ b/tests/integrations/test_integration_base_skills.py @@ -357,8 +357,8 @@ def test_integration_flag_creates_files(self, tmp_path): assert skills_dir.is_dir(), f"Skills directory {skills_dir} not created" def test_init_options_includes_context_file(self, tmp_path): - """init-options.json must include context_file for the active integration.""" - import json + """agent-context extension config must include context_file for the active integration.""" + import yaml from typer.testing import CliRunner from specify_cli import app @@ -374,10 +374,11 @@ def test_init_options_includes_context_file(self, tmp_path): finally: os.chdir(old_cwd) assert result.exit_code == 0 - opts = json.loads((project / ".specify" / "init-options.json").read_text()) + ext_cfg_path = project / ".specify" / "extensions" / "agent-context" / "agent-context-config.yml" + ext_cfg = yaml.safe_load(ext_cfg_path.read_text(encoding="utf-8")) if ext_cfg_path.exists() else {} i = get_integration(self.KEY) - assert opts.get("context_file") == i.context_file, ( - f"Expected context_file={i.context_file!r}, got {opts.get('context_file')!r}" + assert ext_cfg.get("context_file") == i.context_file, ( + f"Expected context_file={i.context_file!r}, got {ext_cfg.get('context_file')!r}" ) # -- IntegrationOption ------------------------------------------------ @@ -402,9 +403,11 @@ def _expected_files(self, script_variant: str) -> list[str]: skills_prefix = i.config["folder"].rstrip("/") + "/" + i.config.get("commands_subdir", "skills") files = [] - # Skill files + # Skill files (core commands) for cmd in self._SKILL_COMMANDS: files.append(f"{skills_prefix}/speckit-{cmd}/SKILL.md") + # Extension-installed skill (agent-context) + files.append(f"{skills_prefix}/speckit-agent-context-update/SKILL.md") # Integration metadata files += [ ".specify/init-options.json", @@ -443,6 +446,15 @@ def _expected_files(self, script_variant: str) -> list[str]: ".specify/workflows/speckit/workflow.yml", ".specify/workflows/workflow-registry.json", ] + # Bundled agent-context extension + files.append(".specify/extensions.yml") + files.append(".specify/extensions/.registry") + files.append(".specify/extensions/agent-context/README.md") + files.append(".specify/extensions/agent-context/agent-context-config.yml") + files.append(".specify/extensions/agent-context/commands/speckit.agent-context.update.md") + files.append(".specify/extensions/agent-context/extension.yml") + files.append(".specify/extensions/agent-context/scripts/bash/update-agent-context.sh") + files.append(".specify/extensions/agent-context/scripts/powershell/update-agent-context.ps1") # Agent context file (if set) if i.context_file: files.append(i.context_file) diff --git a/tests/integrations/test_integration_base_toml.py b/tests/integrations/test_integration_base_toml.py index 56862e534c..c0970f3271 100644 --- a/tests/integrations/test_integration_base_toml.py +++ b/tests/integrations/test_integration_base_toml.py @@ -457,8 +457,8 @@ def test_integration_flag_creates_files(self, tmp_path): assert len(commands) > 0, f"No command files in {cmd_dir}" def test_init_options_includes_context_file(self, tmp_path): - """init-options.json must include context_file for the active integration.""" - import json + """agent-context extension config must include context_file for the active integration.""" + import yaml from typer.testing import CliRunner from specify_cli import app @@ -474,15 +474,17 @@ def test_init_options_includes_context_file(self, tmp_path): finally: os.chdir(old_cwd) assert result.exit_code == 0 - opts = json.loads((project / ".specify" / "init-options.json").read_text()) + ext_cfg_path = project / ".specify" / "extensions" / "agent-context" / "agent-context-config.yml" + ext_cfg = yaml.safe_load(ext_cfg_path.read_text(encoding="utf-8")) if ext_cfg_path.exists() else {} i = get_integration(self.KEY) - assert opts.get("context_file") == i.context_file, ( - f"Expected context_file={i.context_file!r}, got {opts.get('context_file')!r}" + assert ext_cfg.get("context_file") == i.context_file, ( + f"Expected context_file={i.context_file!r}, got {ext_cfg.get('context_file')!r}" ) # -- Complete file inventory ------------------------------------------ COMMAND_STEMS = [ + "agent-context.update", "analyze", "checklist", "clarify", @@ -543,6 +545,16 @@ def _expected_files(self, script_variant: str) -> list[str]: files.append(".specify/workflows/speckit/workflow.yml") files.append(".specify/workflows/workflow-registry.json") + # Bundled agent-context extension + files.append(".specify/extensions.yml") + files.append(".specify/extensions/.registry") + files.append(".specify/extensions/agent-context/README.md") + files.append(".specify/extensions/agent-context/agent-context-config.yml") + files.append(".specify/extensions/agent-context/commands/speckit.agent-context.update.md") + files.append(".specify/extensions/agent-context/extension.yml") + files.append(".specify/extensions/agent-context/scripts/bash/update-agent-context.sh") + files.append(".specify/extensions/agent-context/scripts/powershell/update-agent-context.ps1") + # Agent context file (if set) if i.context_file: files.append(i.context_file) diff --git a/tests/integrations/test_integration_base_yaml.py b/tests/integrations/test_integration_base_yaml.py index 956c7a796f..bed0c66570 100644 --- a/tests/integrations/test_integration_base_yaml.py +++ b/tests/integrations/test_integration_base_yaml.py @@ -336,8 +336,8 @@ def test_integration_flag_creates_files(self, tmp_path): assert len(commands) > 0, f"No command files in {cmd_dir}" def test_init_options_includes_context_file(self, tmp_path): - """init-options.json must include context_file for the active integration.""" - import json + """agent-context extension config must include context_file for the active integration.""" + import yaml from typer.testing import CliRunner from specify_cli import app @@ -353,15 +353,17 @@ def test_init_options_includes_context_file(self, tmp_path): finally: os.chdir(old_cwd) assert result.exit_code == 0 - opts = json.loads((project / ".specify" / "init-options.json").read_text()) + ext_cfg_path = project / ".specify" / "extensions" / "agent-context" / "agent-context-config.yml" + ext_cfg = yaml.safe_load(ext_cfg_path.read_text(encoding="utf-8")) if ext_cfg_path.exists() else {} i = get_integration(self.KEY) - assert opts.get("context_file") == i.context_file, ( - f"Expected context_file={i.context_file!r}, got {opts.get('context_file')!r}" + assert ext_cfg.get("context_file") == i.context_file, ( + f"Expected context_file={i.context_file!r}, got {ext_cfg.get('context_file')!r}" ) # -- Complete file inventory ------------------------------------------ COMMAND_STEMS = [ + "agent-context.update", "analyze", "checklist", "clarify", @@ -422,6 +424,16 @@ def _expected_files(self, script_variant: str) -> list[str]: files.append(".specify/workflows/speckit/workflow.yml") files.append(".specify/workflows/workflow-registry.json") + # Bundled agent-context extension + files.append(".specify/extensions.yml") + files.append(".specify/extensions/.registry") + files.append(".specify/extensions/agent-context/README.md") + files.append(".specify/extensions/agent-context/agent-context-config.yml") + files.append(".specify/extensions/agent-context/commands/speckit.agent-context.update.md") + files.append(".specify/extensions/agent-context/extension.yml") + files.append(".specify/extensions/agent-context/scripts/bash/update-agent-context.sh") + files.append(".specify/extensions/agent-context/scripts/powershell/update-agent-context.ps1") + # Agent context file (if set) if i.context_file: files.append(i.context_file) diff --git a/tests/integrations/test_integration_copilot.py b/tests/integrations/test_integration_copilot.py index 1c69a54bd1..5793583adb 100644 --- a/tests/integrations/test_integration_copilot.py +++ b/tests/integrations/test_integration_copilot.py @@ -178,6 +178,7 @@ def test_complete_file_inventory_sh(self, tmp_path): assert result.exit_code == 0 actual = sorted(p.relative_to(project).as_posix() for p in project.rglob("*") if p.is_file()) expected = sorted([ + ".github/agents/speckit.agent-context.update.agent.md", ".github/agents/speckit.analyze.agent.md", ".github/agents/speckit.checklist.agent.md", ".github/agents/speckit.clarify.agent.md", @@ -187,6 +188,7 @@ def test_complete_file_inventory_sh(self, tmp_path): ".github/agents/speckit.specify.agent.md", ".github/agents/speckit.tasks.agent.md", ".github/agents/speckit.taskstoissues.agent.md", + ".github/prompts/speckit.agent-context.update.prompt.md", ".github/prompts/speckit.analyze.prompt.md", ".github/prompts/speckit.checklist.prompt.md", ".github/prompts/speckit.clarify.prompt.md", @@ -198,6 +200,14 @@ def test_complete_file_inventory_sh(self, tmp_path): ".github/prompts/speckit.taskstoissues.prompt.md", ".vscode/settings.json", ".github/copilot-instructions.md", + ".specify/extensions.yml", + ".specify/extensions/.registry", + ".specify/extensions/agent-context/README.md", + ".specify/extensions/agent-context/agent-context-config.yml", + ".specify/extensions/agent-context/commands/speckit.agent-context.update.md", + ".specify/extensions/agent-context/extension.yml", + ".specify/extensions/agent-context/scripts/bash/update-agent-context.sh", + ".specify/extensions/agent-context/scripts/powershell/update-agent-context.ps1", ".specify/integration.json", ".specify/init-options.json", ".specify/integrations/copilot.manifest.json", @@ -238,6 +248,7 @@ def test_complete_file_inventory_ps(self, tmp_path): assert result.exit_code == 0 actual = sorted(p.relative_to(project).as_posix() for p in project.rglob("*") if p.is_file()) expected = sorted([ + ".github/agents/speckit.agent-context.update.agent.md", ".github/agents/speckit.analyze.agent.md", ".github/agents/speckit.checklist.agent.md", ".github/agents/speckit.clarify.agent.md", @@ -247,6 +258,7 @@ def test_complete_file_inventory_ps(self, tmp_path): ".github/agents/speckit.specify.agent.md", ".github/agents/speckit.tasks.agent.md", ".github/agents/speckit.taskstoissues.agent.md", + ".github/prompts/speckit.agent-context.update.prompt.md", ".github/prompts/speckit.analyze.prompt.md", ".github/prompts/speckit.checklist.prompt.md", ".github/prompts/speckit.clarify.prompt.md", @@ -258,6 +270,14 @@ def test_complete_file_inventory_ps(self, tmp_path): ".github/prompts/speckit.taskstoissues.prompt.md", ".vscode/settings.json", ".github/copilot-instructions.md", + ".specify/extensions.yml", + ".specify/extensions/.registry", + ".specify/extensions/agent-context/README.md", + ".specify/extensions/agent-context/agent-context-config.yml", + ".specify/extensions/agent-context/commands/speckit.agent-context.update.md", + ".specify/extensions/agent-context/extension.yml", + ".specify/extensions/agent-context/scripts/bash/update-agent-context.sh", + ".specify/extensions/agent-context/scripts/powershell/update-agent-context.ps1", ".specify/integration.json", ".specify/init-options.json", ".specify/integrations/copilot.manifest.json", @@ -624,10 +644,20 @@ def test_complete_file_inventory_skills_sh(self, tmp_path): assert result.exit_code == 0, f"init failed: {result.output}" actual = sorted(p.relative_to(project).as_posix() for p in project.rglob("*") if p.is_file()) expected = sorted([ - # Skill files + # Skill files (core + extension-installed agent-context command) *[f".github/skills/speckit-{cmd}/SKILL.md" for cmd in self._SKILL_COMMANDS], + ".github/skills/speckit-agent-context-update/SKILL.md", # Context file ".github/copilot-instructions.md", + # Bundled agent-context extension + ".specify/extensions.yml", + ".specify/extensions/.registry", + ".specify/extensions/agent-context/README.md", + ".specify/extensions/agent-context/agent-context-config.yml", + ".specify/extensions/agent-context/commands/speckit.agent-context.update.md", + ".specify/extensions/agent-context/extension.yml", + ".specify/extensions/agent-context/scripts/bash/update-agent-context.sh", + ".specify/extensions/agent-context/scripts/powershell/update-agent-context.ps1", # Integration metadata ".specify/init-options.json", ".specify/integration.json", diff --git a/tests/integrations/test_integration_generic.py b/tests/integrations/test_integration_generic.py index 4f515a01d2..856b34e0f8 100644 --- a/tests/integrations/test_integration_generic.py +++ b/tests/integrations/test_integration_generic.py @@ -195,6 +195,39 @@ def test_implement_loads_constitution_context(self, tmp_path): content = implement_file.read_text(encoding="utf-8") assert ".specify/memory/constitution.md" in content + @pytest.mark.parametrize( + "command_stem", + [ + "analyze", + "checklist", + "clarify", + "implement", + "plan", + "specify", + "tasks", + "taskstoissues", + ], + ) + def test_command_loads_constitution_context(self, tmp_path, command_stem): + """Every command except constitution must reference constitution.md.""" + i = get_integration("generic") + m = IntegrationManifest("generic", tmp_path) + i.setup(tmp_path, m, parsed_options={"commands_dir": ".custom/cmds"}) + cmd_file = tmp_path / ".custom" / "cmds" / f"speckit.{command_stem}.md" + assert cmd_file.exists(), f"Command file missing: {cmd_file.name}" + content = cmd_file.read_text(encoding="utf-8") + assert "constitution.md" in content, ( + f"speckit.{command_stem}.md must reference constitution.md" + ) + + def test_constitution_command_exists(self, tmp_path): + """The constitution command itself must exist but is not required to load itself.""" + i = get_integration("generic") + m = IntegrationManifest("generic", tmp_path) + i.setup(tmp_path, m, parsed_options={"commands_dir": ".custom/cmds"}) + cmd_file = tmp_path / ".custom" / "cmds" / "speckit.constitution.md" + assert cmd_file.exists() + # -- CLI -------------------------------------------------------------- def test_cli_generic_without_commands_dir_fails(self, tmp_path): @@ -211,8 +244,8 @@ def test_cli_generic_without_commands_dir_fails(self, tmp_path): assert result.exit_code != 0 def test_init_options_includes_context_file(self, tmp_path): - """init-options.json must include context_file for the generic integration.""" - import json + """agent-context extension config must include context_file for the generic integration.""" + import yaml from typer.testing import CliRunner from specify_cli import app @@ -229,8 +262,9 @@ def test_init_options_includes_context_file(self, tmp_path): finally: os.chdir(old_cwd) assert result.exit_code == 0 - opts = json.loads((project / ".specify" / "init-options.json").read_text()) - assert opts.get("context_file") == "AGENTS.md" + ext_cfg_path = project / ".specify" / "extensions" / "agent-context" / "agent-context-config.yml" + ext_cfg = yaml.safe_load(ext_cfg_path.read_text(encoding="utf-8")) if ext_cfg_path.exists() else {} + assert ext_cfg.get("context_file") == "AGENTS.md" def test_complete_file_inventory_sh(self, tmp_path): """Every file produced by specify init --integration generic --ai-commands-dir ... --script sh.""" @@ -265,6 +299,14 @@ def test_complete_file_inventory_sh(self, tmp_path): ".myagent/commands/speckit.specify.md", ".myagent/commands/speckit.tasks.md", ".myagent/commands/speckit.taskstoissues.md", + ".specify/extensions.yml", + ".specify/extensions/.registry", + ".specify/extensions/agent-context/README.md", + ".specify/extensions/agent-context/agent-context-config.yml", + ".specify/extensions/agent-context/commands/speckit.agent-context.update.md", + ".specify/extensions/agent-context/extension.yml", + ".specify/extensions/agent-context/scripts/bash/update-agent-context.sh", + ".specify/extensions/agent-context/scripts/powershell/update-agent-context.ps1", ".specify/init-options.json", ".specify/integration.json", ".specify/integrations/generic.manifest.json", @@ -321,6 +363,14 @@ def test_complete_file_inventory_ps(self, tmp_path): ".myagent/commands/speckit.specify.md", ".myagent/commands/speckit.tasks.md", ".myagent/commands/speckit.taskstoissues.md", + ".specify/extensions.yml", + ".specify/extensions/.registry", + ".specify/extensions/agent-context/README.md", + ".specify/extensions/agent-context/agent-context-config.yml", + ".specify/extensions/agent-context/commands/speckit.agent-context.update.md", + ".specify/extensions/agent-context/extension.yml", + ".specify/extensions/agent-context/scripts/bash/update-agent-context.sh", + ".specify/extensions/agent-context/scripts/powershell/update-agent-context.ps1", ".specify/init-options.json", ".specify/integration.json", ".specify/integrations/generic.manifest.json", diff --git a/tests/integrations/test_integration_hermes.py b/tests/integrations/test_integration_hermes.py index 6b05a4c791..8c896c9bb6 100644 --- a/tests/integrations/test_integration_hermes.py +++ b/tests/integrations/test_integration_hermes.py @@ -241,10 +241,15 @@ def test_complete_file_inventory_sh(self, tmp_path, monkeypatch): p.relative_to(project).as_posix() for p in project.rglob("*") if p.is_file() ) - # Ensure no .hermes/skills/speckit-*/SKILL.md in project dir - hermes_skill_files = [f for f in actual if f.startswith(".hermes/skills/speckit-")] + # Ensure no core .hermes/skills/speckit-*/SKILL.md in project dir + # (extension-installed skills like agent-context-update may appear) + hermes_skill_files = [ + f for f in actual + if f.startswith(".hermes/skills/speckit-") + and "agent-context" not in f + ] assert hermes_skill_files == [], ( - f"Expected no local SKILL.md files, found: {hermes_skill_files}" + f"Expected no local core SKILL.md files, found: {hermes_skill_files}" ) # Ensure the marker exists (empty dir won't appear in file listing) assert (project / ".hermes" / "skills").is_dir() @@ -274,9 +279,15 @@ def test_complete_file_inventory_ps(self, tmp_path, monkeypatch): p.relative_to(project).as_posix() for p in project.rglob("*") if p.is_file() ) - hermes_skill_files = [f for f in actual if f.startswith(".hermes/skills/speckit-")] + # Ensure no core .hermes/skills/speckit-*/SKILL.md in project dir + # (extension-installed skills like agent-context-update may appear) + hermes_skill_files = [ + f for f in actual + if f.startswith(".hermes/skills/speckit-") + and "agent-context" not in f + ] assert hermes_skill_files == [], ( - f"Expected no local SKILL.md files, found: {hermes_skill_files}" + f"Expected no local core SKILL.md files, found: {hermes_skill_files}" ) assert (project / ".hermes" / "skills").is_dir() @@ -342,6 +353,10 @@ def test_ai_hermes_without_ai_skills_auto_promotes(self, tmp_path, monkeypatch): assert (home / ".hermes" / "skills" / "speckit-plan" / "SKILL.md").exists() # Local marker should exist assert (target / ".hermes" / "skills").is_dir() - # No SKILL.md files in project-local dir - local_skills = list((target / ".hermes" / "skills").iterdir()) + # No core SKILL.md files in project-local dir + # (extension-installed skills like agent-context-update may appear) + local_skills = [ + d for d in (target / ".hermes" / "skills").iterdir() + if "agent-context" not in d.name + ] assert local_skills == [], f"Local skills dir should be empty, got: {local_skills}" diff --git a/tests/integrations/test_integration_subcommand.py b/tests/integrations/test_integration_subcommand.py index e9bb50f7d2..f40adb7ae9 100644 --- a/tests/integrations/test_integration_subcommand.py +++ b/tests/integrations/test_integration_subcommand.py @@ -255,7 +255,7 @@ def test_install_non_default_refreshes_init_options_version_only(self, tmp_path, assert updated["speckit_version"] == "0.8.11" assert updated["integration"] == "claude" assert updated["ai"] == "claude" - assert updated["context_file"] == "CLAUDE.md" + assert "context_file" not in updated def test_install_additional_preserves_shared_manifest(self, tmp_path): project = _init_project(tmp_path, "claude") @@ -1250,7 +1250,7 @@ def test_upgrade_non_default_refreshes_init_options_version_only(self, tmp_path, assert updated["speckit_version"] == "0.8.11" assert updated["integration"] == "gemini" assert updated["ai"] == "gemini" - assert updated["context_file"] == "GEMINI.md" + assert "context_file" not in updated def test_upgrade_does_not_persist_state_when_shared_infra_refresh_fails(self, tmp_path, monkeypatch): project = _init_project(tmp_path, "claude") @@ -1376,11 +1376,16 @@ def test_upgrade_migrates_opencode_legacy_dir(self, tmp_path): new_commands = sorted(canonical.glob("speckit.*.md")) assert len(new_commands) > 0, "Commands should exist in .opencode/commands/" - # Stale files removed from legacy dir - remaining = list(legacy.glob("speckit.*.md")) - assert len(remaining) == 0, ( - f"Legacy .opencode/command/ should have no speckit files after upgrade, " - f"found: {[f.name for f in remaining]}" + # Stale files removed from legacy dir (extension-installed commands + # like agent-context.update may still appear — only check the original + # core command stems that should have been migrated). + core_remaining = [ + f for f in legacy.glob("speckit.*.md") + if "agent-context" not in f.name + ] + assert len(core_remaining) == 0, ( + f"Legacy .opencode/command/ should have no core speckit files after upgrade, " + f"found: {[f.name for f in core_remaining]}" )