From abcaecc5606450132ff22ae3ada47630192fde76 Mon Sep 17 00:00:00 2001 From: AustinZ21 Date: Sat, 13 Jun 2026 11:34:10 -0700 Subject: [PATCH] fix(agent-context): support multiple context files safely --- extensions/agent-context/README.md | 9 + .../agent-context/agent-context-config.yml | 7 +- .../commands/speckit.agent-context.update.md | 3 +- .../scripts/bash/update-agent-context.sh | 90 ++++-- .../powershell/update-agent-context.ps1 | 134 ++++---- src/specify_cli/__init__.py | 14 + src/specify_cli/agents.py | 29 +- src/specify_cli/integrations/_helpers.py | 12 +- src/specify_cli/integrations/base.py | 292 ++++++++++++------ .../integrations/copilot/__init__.py | 2 +- .../integrations/forge/__init__.py | 2 +- .../integrations/generic/__init__.py | 2 +- .../integrations/hermes/__init__.py | 2 +- .../test_extension_agent_context.py | 159 ++++++++++ tests/integrations/test_integration_codex.py | 74 +++++ 15 files changed, 644 insertions(+), 187 deletions(-) diff --git a/extensions/agent-context/README.md b/extensions/agent-context/README.md index dba004eb80..091e2b4802 100644 --- a/extensions/agent-context/README.md +++ b/extensions/agent-context/README.md @@ -10,6 +10,7 @@ Not every Spec Kit user wants Spec Kit to write into the coding agent's context - **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. +- **Synchronize multiple agent anchors** by setting `context_files` when a project intentionally uses more than one coding agent context file, such as `AGENTS.md` and `CLAUDE.md`. - **Refresh on demand** with `/speckit.agent-context.update`, or automatically through the hooks declared in `extension.yml` (`after_specify`, `after_plan`). ## Commands @@ -27,6 +28,12 @@ All configuration flows through the extension's own config file at # Path to the coding agent context file managed by this extension context_file: CLAUDE.md +# Optional list of coding agent context files to manage together. +# When non-empty, this takes precedence over context_file. +context_files: + - AGENTS.md + - CLAUDE.md + # Delimiters for the managed Spec Kit section context_markers: start: "" @@ -34,6 +41,7 @@ context_markers: ``` - `context_file` — the project-relative path to the coding agent context file, written by `specify init` and `specify integration install`. +- `context_files` — optional project-relative paths to multiple coding agent context files. When non-empty, the list takes precedence over `context_file`. Absolute paths, backslash separators, and `..` path segments are rejected. - `context_markers.start` / `.end` — the delimiters around the managed section. Edit these to use custom markers. ## Requirements @@ -55,3 +63,4 @@ 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()`). +Disabled projects also ignore stale `context_files` values during command rendering so disabling the extension remains a complete opt-out. diff --git a/extensions/agent-context/agent-context-config.yml b/extensions/agent-context/agent-context-config.yml index 8c8d308b27..e73f8c7c50 100644 --- a/extensions/agent-context/agent-context-config.yml +++ b/extensions/agent-context/agent-context-config.yml @@ -2,12 +2,17 @@ # 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 +# Path (relative to the project root) to the default 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: "" +# Optional list of project-relative coding agent context files managed by this +# extension. When non-empty, this list takes precedence over `context_file`. +# Use this for projects that intentionally keep multiple agent anchors in sync. +context_files: [] + # Delimiters for the managed Spec Kit section. # Edit these to use custom markers. context_markers: diff --git a/extensions/agent-context/commands/speckit.agent-context.update.md b/extensions/agent-context/commands/speckit.agent-context.update.md index 02f1706926..7569adf3aa 100644 --- a/extensions/agent-context/commands/speckit.agent-context.update.md +++ b/extensions/agent-context/commands/speckit.agent-context.update.md @@ -12,11 +12,12 @@ 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_files` — optional project-relative paths for multiple coding agent context files. When non-empty, the script updates each listed file and the list takes precedence over `context_file`. - `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. +If `context_files` and `context_file` are empty, the command reports nothing to do and exits successfully. Context file paths must stay project-relative; absolute paths and `..` path segments are rejected. ## Execution diff --git a/extensions/agent-context/scripts/bash/update-agent-context.sh b/extensions/agent-context/scripts/bash/update-agent-context.sh index 42ce44df9a..2747b79ab0 100755 --- a/extensions/agent-context/scripts/bash/update-agent-context.sh +++ b/extensions/agent-context/scripts/bash/update-agent-context.sh @@ -1,10 +1,10 @@ #!/usr/bin/env bash # update-agent-context.sh # -# Refresh the managed Spec Kit section in the coding agent's context file +# Refresh the managed Spec Kit section in the coding agent's context file(s) # (e.g. CLAUDE.md, .github/copilot-instructions.md, AGENTS.md). # -# Reads `context_file` and `context_markers.{start,end}` from the +# Reads `context_files` or `context_file`, plus `context_markers.{start,end}`, from the # agent-context extension config: # .specify/extensions/agent-context/agent-context-config.yml # @@ -39,9 +39,10 @@ if [[ -z "$_python" ]]; then exit 0 fi -# Parse extension config once; emit three newline-separated fields: -# context_file, context_markers.start, context_markers.end +# Parse extension config once; emit three JSON lines: +# context files array, context_markers.start, context_markers.end if ! _raw_opts="$("$_python" - "$EXT_CONFIG" <<'PY' +import json import sys try: import yaml @@ -73,7 +74,17 @@ def get_str(obj, *keys): else: return "" return node if isinstance(node, str) else "" -print(get_str(data, "context_file")) +context_files = [] +raw_files = data.get("context_files") +if isinstance(raw_files, list): + for value in raw_files: + if isinstance(value, str) and value.strip() and value.strip() not in context_files: + context_files.append(value.strip()) +if not context_files: + raw_file = get_str(data, "context_file") + if raw_file: + context_files.append(raw_file) +print(json.dumps(context_files)) print(get_str(data, "context_markers", "start")) print(get_str(data, "context_markers", "end")) PY @@ -87,33 +98,58 @@ 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 + echo "agent-context: malformed config parser output; expected 3 lines (context_files, marker_start, marker_end), got ${#_opts_lines[@]}; skipping update." >&2 exit 0 fi -CONTEXT_FILE="${_opts_lines[0]}" +CONTEXT_FILES_JSON="${_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 +if ! _context_files_raw="$("$_python" - "$CONTEXT_FILES_JSON" <<'PY' +import json +import sys +try: + data = json.loads(sys.argv[1]) +except Exception: + data = [] +if not isinstance(data, list): + data = [] +for value in data: + if isinstance(value, str) and value: + print(value) +PY +)"; then + echo "agent-context: malformed context_files parser output; skipping update." >&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 +CONTEXT_FILES=() +while IFS= read -r _line || [[ -n "$_line" ]]; do + [[ -n "$_line" ]] && CONTEXT_FILES+=("$_line") +done < <(printf '%s\n' "$_context_files_raw") + +if (( ${#CONTEXT_FILES[@]} == 0 )); then + echo "agent-context: context_files/context_file not set in extension config; nothing to do." >&2 + exit 0 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 + +for CONTEXT_FILE in "${CONTEXT_FILES[@]}"; do + # Reject absolute paths, backslash separators, and '..' path segments in context files + if [[ "$CONTEXT_FILE" == /* ]] || [[ "$CONTEXT_FILE" =~ ^[A-Za-z]: ]]; then + echo "agent-context: context files must be project-relative paths; got '$CONTEXT_FILE'." >&2 + exit 1 + fi + if [[ "$CONTEXT_FILE" == *\\* ]]; then + echo "agent-context: context files 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 files must not contain '..' path segments; got '$CONTEXT_FILE'." >&2 + exit 1 + fi + done done unset _cf_parts _seg @@ -142,9 +178,6 @@ PY 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 @@ -158,7 +191,11 @@ trap 'rm -f "$TMP_SECTION"' EXIT echo "$MARKER_END" } > "$TMP_SECTION" -"$_python" - "$CTX_PATH" "$MARKER_START" "$MARKER_END" "$TMP_SECTION" <<'PY' +for CONTEXT_FILE in "${CONTEXT_FILES[@]}"; do + CTX_PATH="$PROJECT_ROOT/$CONTEXT_FILE" + mkdir -p "$(dirname "$CTX_PATH")" + + "$_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: @@ -197,4 +234,5 @@ with open(ctx_path, "wb") as fh: fh.write(new_content.encode("utf-8")) PY -echo "agent-context: updated $CONTEXT_FILE" + echo "agent-context: updated $CONTEXT_FILE" +done diff --git a/extensions/agent-context/scripts/powershell/update-agent-context.ps1 b/extensions/agent-context/scripts/powershell/update-agent-context.ps1 index dad309c03a..10323885a5 100644 --- a/extensions/agent-context/scripts/powershell/update-agent-context.ps1 +++ b/extensions/agent-context/scripts/powershell/update-agent-context.ps1 @@ -1,10 +1,10 @@ #!/usr/bin/env pwsh # update-agent-context.ps1 # -# Refresh the managed Spec Kit section in the coding agent's context file +# Refresh the managed Spec Kit section in the coding agent's context file(s) # (e.g. CLAUDE.md, .github/copilot-instructions.md, AGENTS.md). # -# Reads `context_file` and `context_markers.{start,end}` from the +# Reads `context_files` or `context_file`, plus `context_markers.{start,end}`, from the # agent-context extension config: # .specify/extensions/agent-context/agent-context-config.yml # @@ -87,8 +87,10 @@ if ($null -eq $Options) { } if ($pythonCmd) { + $pyScript = $null try { - $jsonOut = & $pythonCmd -c @' + $pyScript = [System.IO.Path]::GetTempFileName() + Set-Content -LiteralPath $pyScript -Encoding UTF8 -Value @' import json import sys try: @@ -114,12 +116,17 @@ if not isinstance(data, dict): data = {} print(json.dumps(data)) -'@ $ExtConfig +'@ + $jsonOut = & $pythonCmd $pyScript $ExtConfig if ($LASTEXITCODE -eq 0 -and $jsonOut) { $Options = $jsonOut | ConvertFrom-Json -ErrorAction Stop } } catch { $Options = $null + } finally { + if ($pyScript -and (Test-Path -LiteralPath $pyScript)) { + Remove-Item -LiteralPath $pyScript -Force -ErrorAction SilentlyContinue + } } } @@ -134,21 +141,38 @@ if (-not (Test-ConfigObject -Object $Options)) { 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.' +$ConfiguredContextFiles = Get-ConfigValue -Object $Options -Key 'context_files' +$ContextFiles = @() +if ($ConfiguredContextFiles -is [System.Array]) { + foreach ($item in $ConfiguredContextFiles) { + if ($item -is [string] -and -not [string]::IsNullOrWhiteSpace($item)) { + $ContextFiles += $item.Trim() + } + } +} +if ($ContextFiles.Count -eq 0) { + $ContextFile = Get-ConfigValue -Object $Options -Key 'context_file' + if ($ContextFile -is [string] -and -not [string]::IsNullOrWhiteSpace($ContextFile)) { + $ContextFiles += $ContextFile.Trim() + } +} +$ContextFiles = @($ContextFiles | Select-Object -Unique) +if ($ContextFiles.Count -eq 0) { + Write-Warning 'agent-context: context_files/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 +foreach ($ContextFile in $ContextFiles) { + # Reject absolute paths and '..' path segments in context files + if ([System.IO.Path]::IsPathRooted($ContextFile)) { + Write-Warning "agent-context: context files must be project-relative paths; got '$ContextFile'." + exit 1 + } + $cfSegments = $ContextFile -split '[/\\]' + if ($cfSegments -contains '..') { + Write-Warning "agent-context: context files must not contain '..' path segments; got '$ContextFile'." + exit 1 + } } $MarkerStart = $DefaultStart @@ -184,12 +208,6 @@ if (-not $PlanPath) { } } -$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') @@ -199,39 +217,47 @@ if ($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) +foreach ($ContextFile in $ContextFiles) { + $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 + } + + 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 { - if ($content -and -not $content.EndsWith("`n")) { $content += "`n" } - if ($content) { $newContent = $content + "`n" + $Section } else { $newContent = $Section } + $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))) + $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" + Write-Host "agent-context: updated $ContextFile" +} diff --git a/src/specify_cli/__init__.py b/src/specify_cli/__init__.py index 186593000c..60a229932c 100644 --- a/src/specify_cli/__init__.py +++ b/src/specify_cli/__init__.py @@ -277,6 +277,7 @@ def _load_agent_context_config(project_root: Path) -> dict[str, Any]: defaults: dict[str, Any] = { "context_file": "", + "context_files": [], "context_markers": { "start": IntegrationBase.CONTEXT_MARKER_START, "end": IntegrationBase.CONTEXT_MARKER_END, @@ -308,6 +309,7 @@ def _update_agent_context_config_file( context_file: str | None, *, preserve_markers: bool = True, + preserve_context_files: bool = True, ) -> None: """Update the agent-context extension config with *context_file*. @@ -315,11 +317,23 @@ def _update_agent_context_config_file( ``context_markers`` values are kept unchanged so user customisations survive integration changes and reinit. When False, the default markers are written unconditionally. + + When *preserve_context_files* is True (default), an existing non-empty + ``context_files`` list is kept unchanged. This lets projects opt into + updating multiple agent context files while still preserving the legacy + singular ``context_file`` value for compatibility. """ from .integrations.base import IntegrationBase cfg = _load_agent_context_config(project_root) cfg["context_file"] = context_file or "" + existing_context_files = cfg.get("context_files") + has_context_files = ( + isinstance(existing_context_files, list) + and any(isinstance(item, str) and item.strip() for item in existing_context_files) + ) + if not preserve_context_files or not has_context_files: + cfg.pop("context_files", None) if not preserve_markers or not isinstance(cfg.get("context_markers"), dict): cfg["context_markers"] = { "start": IntegrationBase.CONTEXT_MARKER_START, diff --git a/src/specify_cli/agents.py b/src/specify_cli/agents.py index 3c06418014..f882ddc7ed 100644 --- a/src/specify_cli/agents.py +++ b/src/specify_cli/agents.py @@ -398,13 +398,28 @@ def resolve_skill_placeholders( body = body.replace("{ARGS}", "$ARGUMENTS").replace("__AGENT__", agent_name) - # 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 "" + # Resolve __CONTEXT_FILE__ from the agent-context extension config + # when enabled. Fall back to init-options.json for projects that + # haven't migrated or that explicitly disabled the extension. + context_file = "" + from .integrations.base import IntegrationBase + + if IntegrationBase._agent_context_extension_enabled(project_root): + # 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_files = ac_cfg.get("context_files") + if isinstance(context_files, list): + context_file_values = [ + value.strip() + for value in context_files + if isinstance(value, str) and value.strip() + ] + context_file = ", ".join(dict.fromkeys(context_file_values)) + if not context_file: + 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) diff --git a/src/specify_cli/integrations/_helpers.py b/src/specify_cli/integrations/_helpers.py index a95f36563a..aa9cf19fc2 100644 --- a/src/specify_cli/integrations/_helpers.py +++ b/src/specify_cli/integrations/_helpers.py @@ -131,7 +131,7 @@ def _clear_init_options_for_integration(project_root: Path, integration_key: str ext_cfg_path = project_root / _AGENT_CTX_EXT_CONFIG if ext_cfg_path.exists(): _update_agent_context_config_file( - project_root, "", preserve_markers=True + project_root, "", preserve_markers=True, preserve_context_files=False ) elif has_legacy_context_keys: save_init_options(project_root, opts) @@ -277,12 +277,14 @@ def _update_init_options_for_integration( """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 + ``context_file``, ``context_files``, 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. + always preserved when the config already exists. Existing non-empty + ``context_files`` lists are also preserved so projects can keep multi-agent + context anchors during integration switches. Invalid marker values are + silently ignored at runtime by ``_resolve_context_markers()`` which falls + back to the class-level defaults. """ from .. import ( _AGENT_CTX_EXT_CONFIG, diff --git a/src/specify_cli/integrations/base.py b/src/specify_cli/integrations/base.py index def5ad20ba..56e1d8372d 100644 --- a/src/specify_cli/integrations/base.py +++ b/src/specify_cli/integrations/base.py @@ -20,7 +20,7 @@ import shutil from abc import ABC from dataclasses import dataclass -from pathlib import Path +from pathlib import Path, PureWindowsPath from typing import TYPE_CHECKING, Any import yaml @@ -92,6 +92,11 @@ class IntegrationBase(ABC): * ``context_file`` — path (relative to project root) of the agent context/instructions file (e.g. ``"CLAUDE.md"``) + + Projects may additionally opt into managing multiple context files by + setting ``context_files`` in the agent-context extension config. The + integration class still declares one default ``context_file`` for backwards + compatibility and command-template rendering. """ # -- Must be set by every subclass ------------------------------------ @@ -668,51 +673,103 @@ def _resolve_context_markers(self, project_root: Path) -> tuple[str, str]: end = cm_end # type: ignore[assignment] return start, end - def upsert_context_section( - self, - project_root: Path, - plan_path: str = "", - ) -> Path | None: - """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 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. + @staticmethod + def _validate_context_file_path(project_root: Path, context_file: str) -> str: + """Return a safe project-relative context file path. - Returns the path to the context file, or ``None`` when - ``context_file`` is not set or the ``agent-context`` extension is - disabled. + The agent-context scripts reject paths that can escape the project + root; the Python integration path must apply the same guard before + setup or teardown touches context files. """ - if not self.context_file: - return None + candidate = context_file.strip() + if not candidate: + raise ValueError("agent-context: context file path must not be empty") - if not self._agent_context_extension_enabled(project_root): - return None + win_path = PureWindowsPath(candidate) + if Path(candidate).is_absolute() or win_path.drive or win_path.root: + raise ValueError( + "agent-context: context files must be project-relative paths; " + f"got {candidate!r}" + ) + if "\\" in candidate: + raise ValueError( + "agent-context: context files must not contain backslash " + f"separators; got {candidate!r}" + ) - from .._console import console # local import to avoid cycles + parts = [part for part in re.split(r"[\\/]+", candidate) if part] + if ".." in parts: + raise ValueError( + "agent-context: context files must not contain '..' path " + f"segments; got {candidate!r}" + ) - 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, - ) + root = project_root.resolve() + target = (root / candidate).resolve(strict=False) + try: + target.relative_to(root) + except ValueError as exc: + raise ValueError( + "agent-context: context file path resolves outside the project " + f"root; got {candidate!r}" + ) from exc - marker_start, marker_end = self._resolve_context_markers(project_root) + return candidate - ctx_path = project_root / self.context_file - section = ( - f"{marker_start}\n" - f"{self._build_context_section(plan_path)}\n" - f"{marker_end}\n" + def _resolve_context_files(self, project_root: Path) -> list[str]: + """Return project-relative context files managed for *project_root*. + + ``context_files`` in the agent-context extension config, when present + and non-empty, takes precedence over the integration's singular + ``context_file``. This preserves existing behavior while allowing + projects to update multiple agent anchors from one Spec Kit run. + Raises ``ValueError`` when a configured path can escape the project + root. + """ + 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): + cfg = None + configured = cfg.get("context_files") if isinstance(cfg, dict) else None + if isinstance(configured, list): + files: list[str] = [] + seen: set[str] = set() + for value in configured: + if not isinstance(value, str): + continue + candidate = value.strip() + if not candidate or candidate in seen: + continue + files.append(self._validate_context_file_path(project_root, candidate)) + seen.add(candidate) + if files: + return files + if self.context_file: + return [self._validate_context_file_path(project_root, self.context_file)] + return [] + + def _context_file_display(self, project_root: Path) -> str: + """Return human-readable context file target(s) for templates.""" + if not self._agent_context_extension_enabled(project_root): + return self.context_file + return ", ".join(self._resolve_context_files(project_root)) + @staticmethod + def _upsert_context_file( + ctx_path: Path, + section: str, + marker_start: str, + marker_end: str, + ) -> None: + """Create or update one managed context section.""" if ctx_path.exists(): content = ctx_path.read_text(encoding="utf-8-sig") start_idx = content.find(marker_start) @@ -752,18 +809,70 @@ def upsert_context_section( # Ensure .mdc files have required YAML frontmatter if ctx_path.suffix == ".mdc": - new_content = self._ensure_mdc_frontmatter(new_content) + new_content = IntegrationBase._ensure_mdc_frontmatter(new_content) else: ctx_path.parent.mkdir(parents=True, exist_ok=True) # Cursor .mdc files require YAML frontmatter to be loaded if ctx_path.suffix == ".mdc": - new_content = self._ensure_mdc_frontmatter(section) + new_content = IntegrationBase._ensure_mdc_frontmatter(section) else: new_content = section normalized = new_content.replace("\r\n", "\n").replace("\r", "\n") ctx_path.write_bytes(normalized.encode("utf-8")) - return ctx_path + + def upsert_context_section( + self, + project_root: Path, + plan_path: str = "", + ) -> Path | None: + """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 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 first context file, or ``None`` when no context + files are configured or the ``agent-context`` extension is + disabled. + """ + if not self._agent_context_extension_enabled(project_root): + return None + + context_files = self._resolve_context_files(project_root) + if not context_files: + 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) + + section = ( + f"{marker_start}\n" + f"{self._build_context_section(plan_path)}\n" + f"{marker_end}\n" + ) + + first_path: Path | None = None + for context_file in context_files: + ctx_path = project_root / context_file + self._upsert_context_file(ctx_path, section, marker_start, marker_end) + if first_path is None: + first_path = ctx_path + return first_path def remove_context_section(self, project_root: Path) -> bool: """Remove the managed section from the agent context file. @@ -774,68 +883,73 @@ def remove_context_section(self, project_root: Path) -> bool: (``.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(): + context_files = self._resolve_context_files(project_root) + if not context_files: return False marker_start, marker_end = self._resolve_context_markers(project_root) + removed_any = False - content = ctx_path.read_text(encoding="utf-8-sig") - start_idx = content.find(marker_start) - end_idx = content.find( - marker_end, - start_idx if start_idx != -1 else 0, - ) + for context_file in context_files: + ctx_path = project_root / context_file + if not ctx_path.exists(): + continue - # Only remove a complete, well-ordered managed section. If either - # marker is missing, leave the file unchanged to avoid deleting - # unrelated user-authored content. - if start_idx == -1 or end_idx == -1 or end_idx <= start_idx: - return False + content = ctx_path.read_text(encoding="utf-8-sig") + start_idx = content.find(marker_start) + end_idx = content.find( + marker_end, + start_idx if start_idx != -1 else 0, + ) - removal_start = start_idx - removal_end = end_idx + len(marker_end) + # Only remove a complete, well-ordered managed section. If either + # marker is missing, leave the file unchanged to avoid deleting + # unrelated user-authored content. + if start_idx == -1 or end_idx == -1 or end_idx <= start_idx: + continue - # Consume trailing line ending (CRLF or LF) - if removal_end < len(content) and content[removal_end] == "\r": - removal_end += 1 - if removal_end < len(content) and content[removal_end] == "\n": - removal_end += 1 + removal_start = start_idx + removal_end = end_idx + len(marker_end) - # Also strip a blank line before the section if present - if removal_start > 0 and content[removal_start - 1] == "\n": - if removal_start > 1 and content[removal_start - 2] == "\n": - removal_start -= 1 + # Consume trailing line ending (CRLF or LF) + if removal_end < len(content) and content[removal_end] == "\r": + removal_end += 1 + if removal_end < len(content) and content[removal_end] == "\n": + removal_end += 1 - new_content = content[:removal_start] + content[removal_end:] + # Also strip a blank line before the section if present + if removal_start > 0 and content[removal_start - 1] == "\n": + if removal_start > 1 and content[removal_start - 2] == "\n": + removal_start -= 1 - # Normalize line endings before comparisons - normalized = new_content.replace("\r\n", "\n").replace("\r", "\n") + new_content = content[:removal_start] + content[removal_end:] - # For .mdc files, treat Speckit-generated frontmatter-only content as empty - if ctx_path.suffix == ".mdc": - import re + # Normalize line endings before comparisons + normalized = new_content.replace("\r\n", "\n").replace("\r", "\n") - # Delete the file if only YAML frontmatter remains (no body content) - frontmatter_only = re.match( - r"^---\n.*?\n---\s*$", normalized, re.DOTALL - ) - if not normalized.strip() or frontmatter_only: - ctx_path.unlink() - return True + # For .mdc files, treat Speckit-generated frontmatter-only content as empty + if ctx_path.suffix == ".mdc": + import re - if not normalized.strip(): - ctx_path.unlink() - else: - ctx_path.write_bytes(normalized.encode("utf-8")) + # Delete the file if only YAML frontmatter remains (no body content) + frontmatter_only = re.match( + r"^---\n.*?\n---\s*$", normalized, re.DOTALL + ) + if not normalized.strip() or frontmatter_only: + ctx_path.unlink() + removed_any = True + continue + + if not normalized.strip(): + ctx_path.unlink() + else: + ctx_path.write_bytes(normalized.encode("utf-8")) + removed_any = True - return True + return removed_any @staticmethod def resolve_command_refs(content: str, separator: str = ".") -> str: @@ -1111,7 +1225,7 @@ def setup( raw = src_file.read_text(encoding="utf-8") processed = self.process_template( raw, self.key, script_type, arg_placeholder, - context_file=self.context_file or "", + context_file=self._context_file_display(project_root), ) dst_name = self.command_filename(src_file.stem) dst_file = self.write_file_and_record( @@ -1317,7 +1431,7 @@ def setup( description = self._extract_description(raw) processed = self.process_template( raw, self.key, script_type, arg_placeholder, - context_file=self.context_file or "", + context_file=self._context_file_display(project_root), ) _, body = self._split_frontmatter(processed) toml_content = self._render_toml(description, body) @@ -1521,7 +1635,7 @@ def setup( processed = self.process_template( raw, self.key, script_type, arg_placeholder, - context_file=self.context_file or "", + context_file=self._context_file_display(project_root), ) _, body = self._split_frontmatter(processed) yaml_content = self._render_yaml( @@ -1719,7 +1833,7 @@ def setup( # Process body through the standard template pipeline processed_body = self.process_template( raw, self.key, script_type, arg_placeholder, - context_file=self.context_file or "", + context_file=self._context_file_display(project_root), invoke_separator=self.invoke_separator, ) # Strip the processed frontmatter — we rebuild it for skills. diff --git a/src/specify_cli/integrations/copilot/__init__.py b/src/specify_cli/integrations/copilot/__init__.py index 6e3daeefeb..6c83bb2e82 100644 --- a/src/specify_cli/integrations/copilot/__init__.py +++ b/src/specify_cli/integrations/copilot/__init__.py @@ -349,7 +349,7 @@ def _setup_default( raw = src_file.read_text(encoding="utf-8") processed = self.process_template( raw, self.key, script_type, arg_placeholder, - context_file=self.context_file or "", + context_file=self._context_file_display(project_root), ) dst_name = self.command_filename(src_file.stem) dst_file = self.write_file_and_record( diff --git a/src/specify_cli/integrations/forge/__init__.py b/src/specify_cli/integrations/forge/__init__.py index 47a90687dc..b327ad0e64 100644 --- a/src/specify_cli/integrations/forge/__init__.py +++ b/src/specify_cli/integrations/forge/__init__.py @@ -134,7 +134,7 @@ def setup( # Process template with standard MarkdownIntegration logic processed = self.process_template( raw, self.key, script_type, arg_placeholder, - context_file=self.context_file or "", + context_file=self._context_file_display(project_root), invoke_separator=self.invoke_separator, ) diff --git a/src/specify_cli/integrations/generic/__init__.py b/src/specify_cli/integrations/generic/__init__.py index fdaee4ed04..01114d80a1 100644 --- a/src/specify_cli/integrations/generic/__init__.py +++ b/src/specify_cli/integrations/generic/__init__.py @@ -124,7 +124,7 @@ def setup( raw = src_file.read_text(encoding="utf-8") processed = self.process_template( raw, self.key, script_type, arg_placeholder, - context_file=self.context_file or "", + context_file=self._context_file_display(project_root), ) dst_name = self.command_filename(src_file.stem) dst_file = self.write_file_and_record( diff --git a/src/specify_cli/integrations/hermes/__init__.py b/src/specify_cli/integrations/hermes/__init__.py index 1e739002fc..f9c7fec1d4 100644 --- a/src/specify_cli/integrations/hermes/__init__.py +++ b/src/specify_cli/integrations/hermes/__init__.py @@ -140,7 +140,7 @@ def setup( self.key, script_type, arg_placeholder, - context_file=self.context_file or "", + context_file=self._context_file_display(project_root), invoke_separator=self.invoke_separator, ) # Strip the processed frontmatter — we rebuild it for skills. diff --git a/tests/extensions/test_extension_agent_context.py b/tests/extensions/test_extension_agent_context.py index 61ecab91af..1e0b2d1fc5 100644 --- a/tests/extensions/test_extension_agent_context.py +++ b/tests/extensions/test_extension_agent_context.py @@ -5,6 +5,7 @@ import json from pathlib import Path +import pytest import yaml from specify_cli import ( @@ -25,6 +26,7 @@ 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_files": overrides.get("context_files", []), "context_markers": overrides.get( "context_markers", { @@ -200,6 +202,92 @@ def test_upsert_replaces_existing_custom_section(self, tmp_path): assert text.startswith("# header\n") assert "footer" in text + def test_upsert_uses_configured_context_files(self, tmp_path): + _write_ext_config( + tmp_path, + context_file="CLAUDE.md", + context_files=["AGENTS.md", "CLAUDE.md"], + ) + i = _CtxIntegration() + result = i.upsert_context_section( + tmp_path, plan_path="specs/001-foo/plan.md" + ) + assert result == tmp_path / "AGENTS.md" + for name in ("AGENTS.md", "CLAUDE.md"): + text = (tmp_path / name).read_text(encoding="utf-8") + assert IntegrationBase.CONTEXT_MARKER_START in text + assert "specs/001-foo/plan.md" in text + + def test_remove_uses_configured_context_files(self, tmp_path): + _write_ext_config( + tmp_path, + context_file="CLAUDE.md", + context_files=["AGENTS.md", "CLAUDE.md"], + ) + i = _CtxIntegration() + for name in ("AGENTS.md", "CLAUDE.md"): + (tmp_path / name).write_text( + f"head\n{IntegrationBase.CONTEXT_MARKER_START}\nbody\n" + f"{IntegrationBase.CONTEXT_MARKER_END}\ntail\n", + encoding="utf-8", + ) + assert i.remove_context_section(tmp_path) is True + for name in ("AGENTS.md", "CLAUDE.md"): + text = (tmp_path / name).read_text(encoding="utf-8") + assert "body" not in text + assert "head" in text + assert "tail" in text + + @pytest.mark.parametrize( + "bad_path", + [ + "../outside.md", + "nested/../../outside.md", + "nested\\outside.md", + str(Path("/tmp/outside.md")), + "C:/tmp/outside.md", + ], + ) + def test_upsert_rejects_context_files_outside_project(self, tmp_path, bad_path): + _write_ext_config( + tmp_path, + context_file="CLAUDE.md", + context_files=["AGENTS.md", bad_path], + ) + i = _CtxIntegration() + with pytest.raises(ValueError, match="project-relative|must not contain"): + i.upsert_context_section(tmp_path) + + assert not (tmp_path / "AGENTS.md").exists() + assert not (tmp_path.parent / "outside.md").exists() + + @pytest.mark.parametrize( + "bad_path", + [ + "../outside.md", + "nested\\outside.md", + str(Path("/tmp/outside.md")), + "C:/tmp/outside.md", + ], + ) + def test_remove_rejects_context_files_outside_project(self, tmp_path, bad_path): + _write_ext_config( + tmp_path, + context_file="CLAUDE.md", + context_files=["AGENTS.md", bad_path], + ) + outside = tmp_path.parent / "outside.md" + outside.write_text( + f"{IntegrationBase.CONTEXT_MARKER_START}\nbody\n" + f"{IntegrationBase.CONTEXT_MARKER_END}\n", + encoding="utf-8", + ) + i = _CtxIntegration() + with pytest.raises(ValueError, match="project-relative|must not contain"): + i.remove_context_section(tmp_path) + + assert "body" in outside.read_text(encoding="utf-8") + def test_remove_uses_custom_markers(self, tmp_path): i = self._setup( tmp_path, {"start": "", "end": ""} @@ -270,6 +358,17 @@ def test_upsert_skipped_when_disabled(self, tmp_path): assert result is None assert not (tmp_path / "CLAUDE.md").exists() + def test_upsert_disabled_ignores_bad_context_files_config(self, tmp_path): + _write_registry(tmp_path, enabled=False) + _write_ext_config( + tmp_path, + context_file="CLAUDE.md", + context_files=["../disabled-upsert-outside.md"], + ) + i = _CtxIntegration() + assert i.upsert_context_section(tmp_path) is None + assert not (tmp_path.parent / "disabled-upsert-outside.md").exists() + def test_remove_skipped_when_disabled(self, tmp_path): _write_registry(tmp_path, enabled=False) i = _CtxIntegration() @@ -283,6 +382,35 @@ def test_remove_skipped_when_disabled(self, tmp_path): # File must be unchanged when extension is disabled assert ctx.read_text(encoding="utf-8") == original + def test_remove_disabled_ignores_bad_context_files_config(self, tmp_path): + _write_registry(tmp_path, enabled=False) + _write_ext_config( + tmp_path, + context_file="CLAUDE.md", + context_files=["../disabled-remove-outside.md"], + ) + outside = tmp_path.parent / "disabled-remove-outside.md" + outside.write_text( + f"{IntegrationBase.CONTEXT_MARKER_START}\nbody\n" + f"{IntegrationBase.CONTEXT_MARKER_END}\n", + encoding="utf-8", + ) + i = _CtxIntegration() + assert i.remove_context_section(tmp_path) is False + assert "body" in outside.read_text(encoding="utf-8") + + def test_context_file_display_disabled_ignores_bad_context_files_config( + self, tmp_path + ): + _write_registry(tmp_path, enabled=False) + _write_ext_config( + tmp_path, + context_file="CLAUDE.md", + context_files=["../outside.md"], + ) + i = _CtxIntegration() + assert i._context_file_display(tmp_path) == i.context_file + # ── Extension config writers ───────────────────────────────────────────────── @@ -349,6 +477,37 @@ def test_update_init_options_writes_context_file_to_ext_config(self, tmp_path): assert cfg["context_file"] == i.context_file assert "context_markers" in cfg + def test_update_init_options_preserves_context_files(self, tmp_path): + from specify_cli import _update_init_options_for_integration + + _write_ext_config( + tmp_path, + context_file="AGENTS.md", + context_files=["AGENTS.md", "CLAUDE.md"], + ) + i = _CtxIntegration() + _update_init_options_for_integration(tmp_path, i, script_type="sh") + cfg = _load_agent_context_config(tmp_path) + assert cfg["context_file"] == i.context_file + assert cfg["context_files"] == ["AGENTS.md", "CLAUDE.md"] + + def test_clear_init_options_clears_context_files(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", + context_files=["AGENTS.md", "CLAUDE.md"], + ) + _clear_init_options_for_integration(tmp_path, "claude") + cfg = _load_agent_context_config(tmp_path) + assert cfg.get("context_file") == "" + assert "context_files" not in cfg + def test_update_init_options_preserves_custom_markers(self, tmp_path): from specify_cli import _update_init_options_for_integration diff --git a/tests/integrations/test_integration_codex.py b/tests/integrations/test_integration_codex.py index bb3b477fcc..6766375871 100644 --- a/tests/integrations/test_integration_codex.py +++ b/tests/integrations/test_integration_codex.py @@ -29,6 +29,80 @@ def test_integration_codex_creates_skills(self, tmp_path): assert result.exit_code == 0, f"init --integration codex failed: {result.output}" assert (target / ".agents" / "skills" / "speckit-plan" / "SKILL.md").exists() + def test_plan_skill_references_configured_context_files(self, tmp_path): + """Plan skill should render all configured agent context files.""" + from specify_cli import _save_agent_context_config + + target = tmp_path / "test-proj" + target.mkdir() + _save_agent_context_config( + target, + { + "context_file": "AGENTS.md", + "context_files": ["AGENTS.md", "CLAUDE.md"], + "context_markers": { + "start": "", + "end": "", + }, + }, + ) + + integration = get_integration("codex") + manifest = IntegrationManifest("codex", target) + integration.setup(target, manifest, script_type="sh") + + plan_skill = target / ".agents" / "skills" / "speckit-plan" / "SKILL.md" + content = plan_skill.read_text(encoding="utf-8") + assert "AGENTS.md, CLAUDE.md" in content + assert "__CONTEXT_FILE__" not in content + + def test_plan_skill_ignores_context_files_when_agent_context_disabled( + self, tmp_path + ): + """Disabled agent-context must not leak stale context_files into commands.""" + from specify_cli import _save_agent_context_config + + target = tmp_path / "test-proj" + target.mkdir() + registry = target / ".specify" / "extensions" / ".registry" + registry.parent.mkdir(parents=True, exist_ok=True) + registry.write_text( + """ +{ + "schema_version": "1.0", + "extensions": { + "agent-context": { + "version": "1.0.0", + "enabled": false + } + } +} +""".strip(), + encoding="utf-8", + ) + _save_agent_context_config( + target, + { + "context_file": "AGENTS.md", + "context_files": ["../outside.md", "CLAUDE.md"], + "context_markers": { + "start": "", + "end": "", + }, + }, + ) + + integration = get_integration("codex") + manifest = IntegrationManifest("codex", target) + integration.setup(target, manifest, script_type="sh") + + plan_skill = target / ".agents" / "skills" / "speckit-plan" / "SKILL.md" + content = plan_skill.read_text(encoding="utf-8") + assert "AGENTS.md, CLAUDE.md" not in content + assert "../outside.md" not in content + assert "AGENTS.md" in content + assert "__CONTEXT_FILE__" not in content + class TestCodexHookCommandNote: """Verify dot-to-hyphen normalization note is injected in hook sections.