Skip to content

Commit 126e568

Browse files
Noor-ul-ain001claudeCopilot
authored
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/<scope>/<feature>/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) <noreply@anthropic.com> * 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) <noreply@anthropic.com> * 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) <noreply@anthropic.com> * 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) <noreply@anthropic.com> Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>
1 parent 74d03a2 commit 126e568

4 files changed

Lines changed: 90 additions & 14 deletions

File tree

extensions/agent-context/commands/speckit.agent-context.update.md

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,7 @@ The script reads the agent-context extension config at
1515
- `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`.
1616
- `context_markers.start` / `.end` — the delimiters surrounding the managed section. Defaults to `<!-- SPECKIT START -->` and `<!-- SPECKIT END -->` when the field is missing.
1717

18-
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).
18+
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/<scope>/<feature>/plan.md`).
1919

2020
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.
2121

@@ -24,4 +24,4 @@ If `context_files` and `context_file` are empty, the command reports nothing to
2424
- **Bash**: `.specify/extensions/agent-context/scripts/bash/update-agent-context.sh [plan_path]`
2525
- **PowerShell**: `.specify/extensions/agent-context/scripts/powershell/update-agent-context.ps1 [plan_path]`
2626

27-
When `plan_path` is omitted, the script auto-detects the most recently modified `specs/**/plan.md` (any depth, so scoped layouts like `specs/<scope>/<feature>/plan.md` are found).
27+
When `plan_path` is omitted, the script auto-detects the most recently modified `specs/**/plan.md` (searched recursively, so nested scoped layouts are discovered).

extensions/agent-context/scripts/bash/update-agent-context.sh

Lines changed: 21 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -307,16 +307,28 @@ import sys
307307
from pathlib import Path
308308
root = Path(sys.argv[1]).resolve()
309309
specs = root / "specs"
310-
plan = max(
311-
specs.glob("**/plan.md"),
312-
key=lambda p: p.stat().st_mtime,
313-
default=None,
314-
)
315-
if plan:
310+
311+
def _resolved_rel(p):
312+
# Resolve symlinks before checking containment: relative_to() is lexical
313+
# and would otherwise accept a plan reached through a specs/ symlink that
314+
# points outside the project, emitting an in-project-looking path for an
315+
# out-of-project file (or picking it as "most recent").
316316
try:
317-
print(plan.relative_to(root).as_posix())
318-
except ValueError:
319-
print("")
317+
return p.resolve().relative_to(root)
318+
except (OSError, ValueError):
319+
return None
320+
321+
# Recurse (rather than the old one-level specs/*/plan.md glob) so scoped layouts
322+
# created via SPECIFY_FEATURE_DIRECTORY, e.g. specs/<scope>/<feature>/plan.md,
323+
# are still discovered when feature.json is absent (#3024).
324+
candidates = []
325+
for p in specs.rglob("plan.md"):
326+
rel = _resolved_rel(p)
327+
if rel:
328+
candidates.append((p, rel))
329+
candidates.sort(key=lambda pr: pr[0].stat().st_mtime, reverse=True)
330+
if candidates:
331+
print(candidates[0][1].as_posix())
320332
else:
321333
print("")
322334
PY

extensions/agent-context/scripts/powershell/update-agent-context.ps1

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -426,7 +426,11 @@ if (-not $PlanPath) {
426426
if (-not $PlanPath) {
427427
try {
428428
$specsDir = Join-Path $ProjectRoot 'specs'
429-
$candidate = Get-ChildItem -Path $specsDir -Recurse -File -Filter 'plan.md' -ErrorAction SilentlyContinue |
429+
# Recurse (rather than the old one-level specs/*/plan.md scan) so scoped
430+
# layouts created via SPECIFY_FEATURE_DIRECTORY, e.g.
431+
# specs/<scope>/<feature>/plan.md, are still discovered when
432+
# feature.json is absent (#3024).
433+
$candidate = Get-ChildItem -Path $specsDir -Filter 'plan.md' -File -Recurse -ErrorAction SilentlyContinue |
430434
Sort-Object LastWriteTime -Descending |
431435
Select-Object -First 1
432436
if ($candidate) {

tests/extensions/test_extension_agent_context.py

Lines changed: 62 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,14 @@
2525
POWERSHELL = (
2626
shutil.which("pwsh") or shutil.which("powershell.exe") or shutil.which("powershell")
2727
)
28+
# On Windows, prefer the built-in Windows PowerShell 5.1 (.NET Framework) when a
29+
# test needs to exercise a 5.1-specific code path; fall back to whatever
30+
# POWERSHELL resolves to elsewhere.
31+
WINDOWS_POWERSHELL = (
32+
(shutil.which("powershell.exe") or shutil.which("powershell") or POWERSHELL)
33+
if os.name == "nt"
34+
else POWERSHELL
35+
)
2836

2937

3038
def _write_ext_config(project_root: Path, **overrides: object) -> None:
@@ -279,12 +287,14 @@ def shlex_quote(value: str) -> str:
279287
return "'" + value.replace("'", "'\"'\"'") + "'"
280288

281289

282-
def _run_powershell_agent_context_script(project_root: Path) -> subprocess.CompletedProcess:
290+
def _run_powershell_agent_context_script(
291+
project_root: Path, powershell: str | None = None
292+
) -> subprocess.CompletedProcess:
283293
script = EXT_DIR / "scripts" / "powershell" / "update-agent-context.ps1"
284294
env = _bundled_script_env(project_root)
285295
return subprocess.run(
286296
[
287-
POWERSHELL,
297+
powershell or POWERSHELL,
288298
"-NoProfile",
289299
"-ExecutionPolicy",
290300
"Bypass",
@@ -412,6 +422,29 @@ def test_bash_script_deduplicates_context_files_in_order(self, tmp_path):
412422
assert output.count("agent-context: updated CLAUDE.md") == 1
413423
assert "agent-context: updated agents.md" not in output
414424

425+
@requires_bash
426+
def test_bash_script_discovers_nested_plan(self, tmp_path):
427+
"""Plan discovery recurses into scoped layouts (#3024)."""
428+
project = tmp_path / "project"
429+
project.mkdir()
430+
_install_agent_context_config(
431+
project,
432+
context_file="AGENTS.md",
433+
context_files=[],
434+
)
435+
plan = project / "specs" / "scope" / "001-feature" / "plan.md"
436+
plan.parent.mkdir(parents=True)
437+
plan.write_text("# Plan\n", encoding="utf-8")
438+
439+
result = _run_bash_agent_context_script(project)
440+
441+
assert result.returncode == 0, result.stderr + result.stdout
442+
text = (project / "AGENTS.md").read_text(encoding="utf-8")
443+
# The old one-level glob (specs/*/plan.md) would find nothing here, so no
444+
# "at" line would be emitted. Normalize separators before matching: on
445+
# MSYS bash the emitted path may be absolute with backslashes.
446+
assert "specs/scope/001-feature/plan.md" in text.replace("\\", "/")
447+
415448
@requires_bash
416449
def test_bash_script_falls_back_from_invalid_speckit_python(self, tmp_path):
417450
project = tmp_path / "project"
@@ -484,6 +517,33 @@ def test_powershell_script_deduplicates_context_files_in_order(self, tmp_path):
484517
assert output.count("agent-context: updated CLAUDE.md") == 1
485518
assert "agent-context: updated agents.md" not in output
486519

520+
@pytest.mark.skipif(WINDOWS_POWERSHELL is None, reason="PowerShell not available")
521+
def test_powershell_script_discovers_nested_plan(self, tmp_path):
522+
"""Plan discovery recurses into scoped layouts (#3024).
523+
524+
The relative-path fix this covers is specific to Windows PowerShell 5.1
525+
(.NET Framework), so prefer ``powershell.exe`` over ``pwsh`` here to
526+
actually exercise that failure mode on Windows.
527+
"""
528+
project = tmp_path / "project"
529+
project.mkdir()
530+
_install_agent_context_config(
531+
project,
532+
context_file="AGENTS.md",
533+
context_files=[],
534+
)
535+
plan = project / "specs" / "scope" / "001-feature" / "plan.md"
536+
plan.parent.mkdir(parents=True)
537+
plan.write_text("# Plan\n", encoding="utf-8")
538+
539+
result = _run_powershell_agent_context_script(
540+
project, powershell=WINDOWS_POWERSHELL
541+
)
542+
543+
assert result.returncode == 0, result.stderr + result.stdout
544+
text = (project / "AGENTS.md").read_text(encoding="utf-8")
545+
assert "at specs/scope/001-feature/plan.md" in text
546+
487547
@pytest.mark.skipif(POWERSHELL is None, reason="PowerShell not available")
488548
def test_powershell_script_falls_back_from_invalid_speckit_python(self, tmp_path):
489549
project = tmp_path / "project"

0 commit comments

Comments
 (0)