From db51df224306cfc4ffe0c0e3e5a320803cabf6c0 Mon Sep 17 00:00:00 2001 From: Noor-ul-ain001 Date: Thu, 2 Jul 2026 09:19:54 +0500 Subject: [PATCH 1/3] fix(agent-context): discover nested plan.md in scoped layouts (#3024) The agent-context updater only looked for plan.md one level deep (specs/*/plan.md), so scoped layouts created via SPECIFY_FEATURE_DIRECTORY (specs///plan.md) were never picked up and no plan reference was written into the context file. Recurse into specs/ in both the bash (rglob) and PowerShell (-Recurse) scripts. In the PowerShell script, also replace [System.IO.Path]::GetRelativePath, which is .NET Core 2.1+ only and throws under Windows PowerShell 5.1 (.NET Framework); the exception was swallowed by the surrounding try/catch, leaving the plan path empty on 5.1 even when a plan was found. Compute the project-relative path by stripping the root prefix instead. Add regression tests for both scripts covering nested discovery. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../scripts/bash/update-agent-context.sh | 10 +++-- .../powershell/update-agent-context.ps1 | 26 ++++++++--- .../test_extension_agent_context.py | 43 +++++++++++++++++++ 3 files changed, 68 insertions(+), 11 deletions(-) diff --git a/extensions/agent-context/scripts/bash/update-agent-context.sh b/extensions/agent-context/scripts/bash/update-agent-context.sh index 9d57b08cf5..76d4be87d4 100755 --- a/extensions/agent-context/scripts/bash/update-agent-context.sh +++ b/extensions/agent-context/scripts/bash/update-agent-context.sh @@ -202,15 +202,17 @@ 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). - # Use find + sort by modification time to avoid ls/head fragility with - # spaces in paths or SIGPIPE from pipefail. + # Pick the most recently modified plan.md anywhere under specs/. Recursing + # (rather than the old one-level specs/*/plan.md glob) picks up scoped layouts + # created via SPECIFY_FEATURE_DIRECTORY, e.g. specs///plan.md + # (#3024). 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"), + specs.rglob("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 d31fcd64c0..7bf22c2e39 100644 --- a/extensions/agent-context/scripts/powershell/update-agent-context.ps1 +++ b/extensions/agent-context/scripts/powershell/update-agent-context.ps1 @@ -280,18 +280,30 @@ 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 the most recently modified plan.md anywhere under specs/, + # matching the bash glob specs/**/plan.md. Recursing (rather than the old + # one-level specs/*/plan.md) picks up scoped layouts created via + # SPECIFY_FEATURE_DIRECTORY, e.g. specs///plan.md (#3024). + # 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 { $_ } | + $candidate = Get-ChildItem -Path $specsDir -Filter 'plan.md' -File -Recurse -ErrorAction SilentlyContinue | Sort-Object LastWriteTime -Descending | Select-Object -First 1 if ($candidate) { - $PlanPath = [System.IO.Path]::GetRelativePath($ProjectRoot, $candidate.FullName).Replace('\','/') + # [System.IO.Path]::GetRelativePath is .NET Core 2.1+ only and throws + # under Windows PowerShell 5.1 (.NET Framework). The candidate is always + # under $ProjectRoot, so strip the root prefix directly instead. + $rootFull = [System.IO.Path]::GetFullPath($ProjectRoot).TrimEnd( + [System.IO.Path]::DirectorySeparatorChar, + [System.IO.Path]::AltDirectorySeparatorChar + ) + $rel = $candidate.FullName.Substring($rootFull.Length).TrimStart( + [System.IO.Path]::DirectorySeparatorChar, + [System.IO.Path]::AltDirectorySeparatorChar + ) + $PlanPath = $rel.Replace('\','/') } } catch { # Non-fatal: continue without a plan path. diff --git a/tests/extensions/test_extension_agent_context.py b/tests/extensions/test_extension_agent_context.py index ab4194efd8..dd35c73fc6 100644 --- a/tests/extensions/test_extension_agent_context.py +++ b/tests/extensions/test_extension_agent_context.py @@ -872,6 +872,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" @@ -944,6 +967,26 @@ 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(POWERSHELL is None, reason="PowerShell not available") + def test_powershell_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_powershell_agent_context_script(project) + + 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" From a345c3daf007766a840ebadc44f5cbe539b62a78 Mon Sep 17 00:00:00 2001 From: Noor ul ain Date: Fri, 3 Jul 2026 01:15:37 +0500 Subject: [PATCH 2/3] Potential fix for pull request finding Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --- .../test_extension_agent_context.py | 20 ++++++++++++++++++- 1 file changed, 19 insertions(+), 1 deletion(-) diff --git a/tests/extensions/test_extension_agent_context.py b/tests/extensions/test_extension_agent_context.py index 3c2f54d1e5..cec3faba4f 100644 --- a/tests/extensions/test_extension_agent_context.py +++ b/tests/extensions/test_extension_agent_context.py @@ -521,7 +521,25 @@ def test_powershell_script_discovers_nested_plan(self, tmp_path): plan.parent.mkdir(parents=True) plan.write_text("# Plan\n", encoding="utf-8") - result = _run_powershell_agent_context_script(project) + ps51 = shutil.which("powershell.exe") if os.name == "nt" else None + ps = ps51 or POWERSHELL + script = EXT_DIR / "scripts" / "powershell" / "update-agent-context.ps1" + env = _bundled_script_env(project) + result = subprocess.run( + [ + ps, + "-NoProfile", + "-ExecutionPolicy", + "Bypass", + "-File", + str(script), + ], + cwd=project, + env=env, + capture_output=True, + text=True, + timeout=30, + ) assert result.returncode == 0, result.stderr + result.stdout text = (project / "AGENTS.md").read_text(encoding="utf-8") From 6489266a6632edef2fa747899f8ba9ece379425d Mon Sep 17 00:00:00 2001 From: Noor-ul-ain001 Date: Fri, 3 Jul 2026 10:06:41 +0500 Subject: [PATCH 3/3] fix(agent-context): guard mtime plan discovery against symlink escape Address Copilot review feedback on #3301: - bash updater: the mtime fallback filtered candidates lexically via relative_to() on the *unresolved* path, so a plan reached through a specs/ symlink pointing outside the project could be selected and emit an in-project-looking path. Resolve each candidate and keep only those whose resolved path stays under root before picking the newest. - test: the nested-plan PowerShell regression targets a Windows PowerShell 5.1 (.NET Framework) failure mode, but ran whatever POWERSHELL resolved to (prefers pwsh). Prefer powershell.exe on Windows so the 5.1-only compat fix is actually exercised. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../scripts/bash/update-agent-context.sh | 25 ++++++++++------- .../test_extension_agent_context.py | 27 +++++++++++++++---- 2 files changed, 37 insertions(+), 15 deletions(-) diff --git a/extensions/agent-context/scripts/bash/update-agent-context.sh b/extensions/agent-context/scripts/bash/update-agent-context.sh index 64d79f5328..b66eafc09f 100755 --- a/extensions/agent-context/scripts/bash/update-agent-context.sh +++ b/extensions/agent-context/scripts/bash/update-agent-context.sh @@ -307,19 +307,24 @@ import sys from pathlib import Path root = Path(sys.argv[1]).resolve() specs = root / "specs" + +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: + 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). -plans = sorted( - specs.rglob("plan.md"), - key=lambda p: p.stat().st_mtime, - reverse=True, -) -if plans: - try: - print(plans[0].relative_to(root).as_posix()) - except ValueError: - print("") +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/tests/extensions/test_extension_agent_context.py b/tests/extensions/test_extension_agent_context.py index 3c2f54d1e5..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", @@ -507,9 +517,14 @@ 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(POWERSHELL is None, reason="PowerShell not available") + @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).""" + """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( @@ -521,7 +536,9 @@ def test_powershell_script_discovers_nested_plan(self, tmp_path): plan.parent.mkdir(parents=True) plan.write_text("# Plan\n", encoding="utf-8") - result = _run_powershell_agent_context_script(project) + 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")