From 126e56882b0484d13f0a12e62d6d871cd5f3957a Mon Sep 17 00:00:00 2001 From: Noor ul ain Date: Sat, 11 Jul 2026 00:21:43 +0500 Subject: [PATCH] fix(agent-context): discover nested plan.md in scoped layouts (#3024) (#3301) * 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) * Potential fix for pull request finding Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> * 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) * docs(agent-context): note recursive plan.md discovery in update command Auto-detection now recurses (`specs/**/plan.md`) to support nested scoped layouts created via SPECIFY_FEATURE_DIRECTORY (#3024). The update command doc still described the old one-level `specs/*/plan.md` glob, which could mislead users troubleshooting plan detection. Addresses Copilot review feedback on PR #3301. Co-Authored-By: Claude Opus 4.8 (1M context) * Potential fix for pull request finding Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --------- Co-authored-by: Claude Opus 4.8 (1M context) Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --- .../commands/speckit.agent-context.update.md | 4 +- .../scripts/bash/update-agent-context.sh | 30 ++++++--- .../powershell/update-agent-context.ps1 | 6 +- .../test_extension_agent_context.py | 64 ++++++++++++++++++- 4 files changed, 90 insertions(+), 14 deletions(-) diff --git a/extensions/agent-context/commands/speckit.agent-context.update.md b/extensions/agent-context/commands/speckit.agent-context.update.md index 67700b8f6..71c85f5d8 100644 --- a/extensions/agent-context/commands/speckit.agent-context.update.md +++ b/extensions/agent-context/commands/speckit.agent-context.update.md @@ -15,7 +15,7 @@ The script reads the agent-context extension config at - `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`, any depth). +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 (any `plan.md` under `specs/`, including nested scoped layouts such as `specs///plan.md`). 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, Windows drive paths, backslash separators, and `..` path segments are rejected. @@ -24,4 +24,4 @@ If `context_files` and `context_file` are empty, the command reports nothing to - **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` (any depth, so scoped layouts like `specs///plan.md` are found). +When `plan_path` is omitted, the script auto-detects the most recently modified `specs/**/plan.md` (searched recursively, so nested scoped layouts are discovered). diff --git a/extensions/agent-context/scripts/bash/update-agent-context.sh b/extensions/agent-context/scripts/bash/update-agent-context.sh index 7ce3d4240..747a47a16 100755 --- a/extensions/agent-context/scripts/bash/update-agent-context.sh +++ b/extensions/agent-context/scripts/bash/update-agent-context.sh @@ -307,16 +307,28 @@ import sys from pathlib import Path root = Path(sys.argv[1]).resolve() specs = root / "specs" -plan = max( - specs.glob("**/plan.md"), - key=lambda p: p.stat().st_mtime, - default=None, -) -if plan: + +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(plan.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 = [] +for p in specs.rglob("plan.md"): + rel = _resolved_rel(p) + if rel: + candidates.append((p, rel)) +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 e04c10461..91d067cc4 100644 --- a/extensions/agent-context/scripts/powershell/update-agent-context.ps1 +++ b/extensions/agent-context/scripts/powershell/update-agent-context.ps1 @@ -426,7 +426,11 @@ if (-not $PlanPath) { if (-not $PlanPath) { try { $specsDir = Join-Path $ProjectRoot 'specs' - $candidate = Get-ChildItem -Path $specsDir -Recurse -File -Filter 'plan.md' -ErrorAction SilentlyContinue | + # 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 e82e53284..c881cab31 100644 --- a/tests/extensions/test_extension_agent_context.py +++ b/tests/extensions/test_extension_agent_context.py @@ -25,6 +25,14 @@ BASH = shutil.which("bash") 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 @@ class TestBundledUpdaterPathValidation: 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 @@ class TestBundledUpdaterPathValidation: 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"