diff --git a/extensions/agent-context/commands/speckit.agent-context.update.md b/extensions/agent-context/commands/speckit.agent-context.update.md index 02f1706926..c45a8893f1 100644 --- a/extensions/agent-context/commands/speckit.agent-context.update.md +++ b/extensions/agent-context/commands/speckit.agent-context.update.md @@ -14,7 +14,7 @@ The script reads the agent-context extension config at - `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`). +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. @@ -23,4 +23,4 @@ If `context_file` is empty or the file cannot be located, the command reports no - **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`. +When `plan_path` is omitted, the script auto-detects the most recently modified `plan.md` anywhere under `specs/`. diff --git a/extensions/agent-context/scripts/bash/update-agent-context.sh b/extensions/agent-context/scripts/bash/update-agent-context.sh index 42ce44df9a..3ef4326703 100755 --- a/extensions/agent-context/scripts/bash/update-agent-context.sh +++ b/extensions/agent-context/scripts/bash/update-agent-context.sh @@ -11,7 +11,7 @@ # 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 +# `specs/**/plan.md` if any exist, otherwise emits the section without a # concrete plan path. set -euo pipefail @@ -122,7 +122,7 @@ unset _cf_parts _seg PLAN_PATH="${1:-}" if [[ -z "$PLAN_PATH" ]]; then - # Pick the most recently modified plan.md one level deep (specs//plan.md). + # Pick the most recently modified plan.md anywhere under specs/. # 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' @@ -130,7 +130,7 @@ import sys, os from pathlib import Path specs = Path(sys.argv[1]) / "specs" plans = sorted( - specs.glob("*/plan.md"), + specs.glob("**/plan.md"), key=lambda p: p.stat().st_mtime, reverse=True, ) diff --git a/extensions/agent-context/scripts/powershell/update-agent-context.ps1 b/extensions/agent-context/scripts/powershell/update-agent-context.ps1 index dad309c03a..df22990f95 100644 --- a/extensions/agent-context/scripts/powershell/update-agent-context.ps1 +++ b/extensions/agent-context/scripts/powershell/update-agent-context.ps1 @@ -166,13 +166,12 @@ if ($cm) { } 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. + # Discover plan.md anywhere under specs/, picking the most recently modified file. + # 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 } | + $candidate = Get-ChildItem -Path $specsDir -Recurse -Filter 'plan.md' -File -ErrorAction SilentlyContinue | Where-Object { $_ } | Sort-Object LastWriteTime -Descending | Select-Object -First 1 diff --git a/tests/extensions/test_extension_agent_context.py b/tests/extensions/test_extension_agent_context.py index 61ecab91af..a0e78d5d19 100644 --- a/tests/extensions/test_extension_agent_context.py +++ b/tests/extensions/test_extension_agent_context.py @@ -3,8 +3,12 @@ from __future__ import annotations import json +import os +import shutil +import subprocess from pathlib import Path +import pytest import yaml from specify_cli import ( @@ -15,10 +19,13 @@ ) from specify_cli.integrations.base import IntegrationBase from specify_cli.integrations.claude import ClaudeIntegration +from tests.conftest import requires_bash PROJECT_ROOT = Path(__file__).resolve().parent.parent.parent EXT_DIR = PROJECT_ROOT / "extensions" / "agent-context" +HAS_PWSH = shutil.which("pwsh") is not None +_WINDOWS_POWERSHELL = (shutil.which("powershell.exe") or shutil.which("powershell")) if os.name == "nt" else None def _write_ext_config(project_root: Path, **overrides: object) -> None: @@ -36,6 +43,22 @@ def _write_ext_config(project_root: Path, **overrides: object) -> None: _save_agent_context_config(project_root, cfg) +def _prepare_agent_context_project(project_root: Path) -> None: + (project_root / ".specify" / "extensions" / "agent-context").mkdir( + parents=True, + exist_ok=True, + ) + _write_ext_config(project_root, context_file="CLAUDE.md") + + +def _clean_env() -> dict[str, str]: + env = os.environ.copy() + for key in list(env): + if key.startswith("SPECIFY_"): + env.pop(key) + return env + + # ── Bundled extension layout ───────────────────────────────────────────────── @@ -426,6 +449,53 @@ def test_marker_resolution_with_corrupt_yaml(self, tmp_path): assert start == IntegrationBase.CONTEXT_MARKER_START assert end == IntegrationBase.CONTEXT_MARKER_END + +# ── Script discovery for nested plan paths ────────────────────────────────── + + +class TestNestedPlanDiscovery: + @requires_bash + def test_bash_script_resolves_nested_plan(self, tmp_path): + _prepare_agent_context_project(tmp_path) + nested_plan = tmp_path / "specs" / "scope" / "feature" / "plan.md" + nested_plan.parent.mkdir(parents=True, exist_ok=True) + nested_plan.write_text("# plan\n", encoding="utf-8") + script = EXT_DIR / "scripts" / "bash" / "update-agent-context.sh" + result = subprocess.run( + ["bash", str(script)], + cwd=tmp_path, + capture_output=True, + text=True, + check=False, + env=_clean_env(), + ) + assert result.returncode == 0, result.stderr + text = (tmp_path / "CLAUDE.md").read_text(encoding="utf-8") + assert "specs/scope/feature/plan.md" in text + + @pytest.mark.skipif( + not (HAS_PWSH or _WINDOWS_POWERSHELL), + reason="no PowerShell available", + ) + def test_powershell_script_resolves_nested_plan(self, tmp_path): + _prepare_agent_context_project(tmp_path) + nested_plan = tmp_path / "specs" / "scope" / "feature" / "plan.md" + nested_plan.parent.mkdir(parents=True, exist_ok=True) + nested_plan.write_text("# plan\n", encoding="utf-8") + script = EXT_DIR / "scripts" / "powershell" / "update-agent-context.ps1" + exe = "pwsh" if HAS_PWSH else _WINDOWS_POWERSHELL + result = subprocess.run( + [exe, "-NoProfile", "-File", str(script)], + cwd=tmp_path, + capture_output=True, + text=True, + check=False, + env=_clean_env(), + ) + assert result.returncode == 0, result.stderr + text = (tmp_path / "CLAUDE.md").read_text(encoding="utf-8") + assert "specs/scope/feature/plan.md" in text + def test_upsert_with_corrupt_config_uses_defaults(self, tmp_path): """upsert_context_section still works when config YAML is corrupt.""" cfg_path = (