diff --git a/extensions/agent-context/scripts/bash/update-agent-context.sh b/extensions/agent-context/scripts/bash/update-agent-context.sh index b7121a2f64..b66eafc09f 100755 --- a/extensions/agent-context/scripts/bash/update-agent-context.sh +++ b/extensions/agent-context/scripts/bash/update-agent-context.sh @@ -307,16 +307,24 @@ import sys from pathlib import Path root = Path(sys.argv[1]).resolve() specs = root / "specs" -plans = sorted( - specs.glob("*/plan.md"), - key=lambda p: p.stat().st_mtime, - reverse=True, -) -if plans: + +def _resolved_rel(p): + # Resolve symlinks before checking containment: relative_to() is lexical + # and would otherwise accept a plan reached through a specs/ symlink that + # points outside the project, emitting an in-project-looking path for an + # out-of-project file (or picking it as "most recent"). try: - print(plans[0].relative_to(root).as_posix()) - except ValueError: - print("") + return p.resolve().relative_to(root) + except (OSError, ValueError): + return None + +# Recurse (rather than the old one-level specs/*/plan.md glob) so scoped layouts +# created via SPECIFY_FEATURE_DIRECTORY, e.g. specs///plan.md, +# are still discovered when feature.json is absent (#3024). +candidates = [(p, rel) for p in specs.rglob("plan.md") if (rel := _resolved_rel(p))] +candidates.sort(key=lambda pr: pr[0].stat().st_mtime, reverse=True) +if candidates: + print(candidates[0][1].as_posix()) else: print("") PY diff --git a/extensions/agent-context/scripts/powershell/update-agent-context.ps1 b/extensions/agent-context/scripts/powershell/update-agent-context.ps1 index 98a55c55fd..29f8e68097 100644 --- a/extensions/agent-context/scripts/powershell/update-agent-context.ps1 +++ b/extensions/agent-context/scripts/powershell/update-agent-context.ps1 @@ -426,9 +426,11 @@ if (-not $PlanPath) { if (-not $PlanPath) { 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 { $_ } | + # Recurse (rather than the old one-level specs/*/plan.md scan) so scoped + # layouts created via SPECIFY_FEATURE_DIRECTORY, e.g. + # specs///plan.md, are still discovered when + # feature.json is absent (#3024). + $candidate = Get-ChildItem -Path $specsDir -Filter 'plan.md' -File -Recurse -ErrorAction SilentlyContinue | Sort-Object LastWriteTime -Descending | Select-Object -First 1 if ($candidate) { diff --git a/tests/extensions/test_extension_agent_context.py b/tests/extensions/test_extension_agent_context.py index f99d449401..bcfb208685 100644 --- a/tests/extensions/test_extension_agent_context.py +++ b/tests/extensions/test_extension_agent_context.py @@ -25,6 +25,14 @@ POWERSHELL = ( shutil.which("pwsh") or shutil.which("powershell.exe") or shutil.which("powershell") ) +# On Windows, prefer the built-in Windows PowerShell 5.1 (.NET Framework) when a +# test needs to exercise a 5.1-specific code path; fall back to whatever +# POWERSHELL resolves to elsewhere. +WINDOWS_POWERSHELL = ( + (shutil.which("powershell.exe") or shutil.which("powershell") or POWERSHELL) + if os.name == "nt" + else POWERSHELL +) def _write_ext_config(project_root: Path, **overrides: object) -> None: @@ -279,12 +287,14 @@ def shlex_quote(value: str) -> str: return "'" + value.replace("'", "'\"'\"'") + "'" -def _run_powershell_agent_context_script(project_root: Path) -> subprocess.CompletedProcess: +def _run_powershell_agent_context_script( + project_root: Path, powershell: str | None = None +) -> subprocess.CompletedProcess: script = EXT_DIR / "scripts" / "powershell" / "update-agent-context.ps1" env = _bundled_script_env(project_root) return subprocess.run( [ - POWERSHELL, + powershell or POWERSHELL, "-NoProfile", "-ExecutionPolicy", "Bypass", @@ -412,6 +422,29 @@ def test_bash_script_deduplicates_context_files_in_order(self, tmp_path): assert output.count("agent-context: updated CLAUDE.md") == 1 assert "agent-context: updated agents.md" not in output + @requires_bash + def test_bash_script_discovers_nested_plan(self, tmp_path): + """Plan discovery recurses into scoped layouts (#3024).""" + project = tmp_path / "project" + project.mkdir() + _install_agent_context_config( + project, + context_file="AGENTS.md", + context_files=[], + ) + plan = project / "specs" / "scope" / "001-feature" / "plan.md" + plan.parent.mkdir(parents=True) + plan.write_text("# Plan\n", encoding="utf-8") + + result = _run_bash_agent_context_script(project) + + assert result.returncode == 0, result.stderr + result.stdout + text = (project / "AGENTS.md").read_text(encoding="utf-8") + # The old one-level glob (specs/*/plan.md) would find nothing here, so no + # "at" line would be emitted. Normalize separators before matching: on + # MSYS bash the emitted path may be absolute with backslashes. + assert "specs/scope/001-feature/plan.md" in text.replace("\\", "/") + @requires_bash def test_bash_script_falls_back_from_invalid_speckit_python(self, tmp_path): project = tmp_path / "project" @@ -484,6 +517,33 @@ def test_powershell_script_deduplicates_context_files_in_order(self, tmp_path): assert output.count("agent-context: updated CLAUDE.md") == 1 assert "agent-context: updated agents.md" not in output + @pytest.mark.skipif(WINDOWS_POWERSHELL is None, reason="PowerShell not available") + def test_powershell_script_discovers_nested_plan(self, tmp_path): + """Plan discovery recurses into scoped layouts (#3024). + + The relative-path fix this covers is specific to Windows PowerShell 5.1 + (.NET Framework), so prefer ``powershell.exe`` over ``pwsh`` here to + actually exercise that failure mode on Windows. + """ + project = tmp_path / "project" + project.mkdir() + _install_agent_context_config( + project, + context_file="AGENTS.md", + context_files=[], + ) + plan = project / "specs" / "scope" / "001-feature" / "plan.md" + plan.parent.mkdir(parents=True) + plan.write_text("# Plan\n", encoding="utf-8") + + result = _run_powershell_agent_context_script( + project, powershell=WINDOWS_POWERSHELL + ) + + assert result.returncode == 0, result.stderr + result.stdout + text = (project / "AGENTS.md").read_text(encoding="utf-8") + assert "at specs/scope/001-feature/plan.md" in text + @pytest.mark.skipif(POWERSHELL is None, reason="PowerShell not available") def test_powershell_script_falls_back_from_invalid_speckit_python(self, tmp_path): project = tmp_path / "project"