Skip to content

Commit 292eaa6

Browse files
marcelsafinCopilot
andauthored
fix: find plans in nested spec directories (#3405)
* fix: find plans in nested spec directories The agent-context mtime fallback used a one-level specs/*/plan.md glob, so scoped layouts (specs/<scope>/<feature>/plan.md via SPECIFY_FEATURE_DIRECTORY) never matched and the SPECKIT block was written without a plan path. Recurse in both script variants and update the command doc wording. Fixes #3024 Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * refactor: pick newest plan with max(), align doc wording Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --------- Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
1 parent 55da30c commit 292eaa6

4 files changed

Lines changed: 66 additions & 12 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/<feature>/plan.md`).
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).
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`.
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).

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

Lines changed: 6 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,7 @@
1212
#
1313
# When `plan_path` is omitted, the script derives it from `.specify/feature.json`
1414
# (written by /speckit-specify). Falls back to the most recently modified
15-
# `specs/*/plan.md` only when feature.json is absent or its plan does not exist yet.
15+
# `specs/**/plan.md` only when feature.json is absent or its plan does not exist yet.
1616

1717
set -euo pipefail
1818

@@ -307,14 +307,14 @@ import sys
307307
from pathlib import Path
308308
root = Path(sys.argv[1]).resolve()
309309
specs = root / "specs"
310-
plans = sorted(
311-
specs.glob("*/plan.md"),
310+
plan = max(
311+
specs.glob("**/plan.md"),
312312
key=lambda p: p.stat().st_mtime,
313-
reverse=True,
313+
default=None,
314314
)
315-
if plans:
315+
if plan:
316316
try:
317-
print(plans[0].relative_to(root).as_posix())
317+
print(plan.relative_to(root).as_posix())
318318
except ValueError:
319319
print("")
320320
else:

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

Lines changed: 2 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,7 @@
1212
#
1313
# When `plan_path` is omitted, the script derives it from `.specify/feature.json`
1414
# (written by /speckit-specify). Falls back to the most recently modified
15-
# `specs/*/plan.md` only when feature.json is absent or its plan does not exist yet.
15+
# `specs/**/plan.md` only when feature.json is absent or its plan does not exist yet.
1616

1717
[CmdletBinding()]
1818
param(
@@ -426,9 +426,7 @@ if (-not $PlanPath) {
426426
if (-not $PlanPath) {
427427
try {
428428
$specsDir = Join-Path $ProjectRoot 'specs'
429-
$candidate = Get-ChildItem -Path $specsDir -Directory -ErrorAction SilentlyContinue |
430-
ForEach-Object { Get-Item -LiteralPath (Join-Path $_.FullName 'plan.md') -ErrorAction SilentlyContinue } |
431-
Where-Object { $_ } |
429+
$candidate = Get-ChildItem -Path $specsDir -Recurse -File -Filter 'plan.md' -ErrorAction SilentlyContinue |
432430
Sort-Object LastWriteTime -Descending |
433431
Select-Object -First 1
434432
if ($candidate) {

tests/extensions/test_extension_agent_context.py

Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -688,6 +688,62 @@ def test_bash_script_nothing_to_do_without_integration(self, tmp_path):
688688
_MDC_CONTEXT_FILE = ".cursor/rules/specify-rules.mdc"
689689

690690

691+
class TestPlanDiscovery:
692+
"""Mtime fallback must find plans in nested spec layouts (#3024).
693+
694+
Repos using SPECIFY_FEATURE_DIRECTORY place plans at
695+
``specs/<scope>/<feature>/plan.md``; a one-level ``specs/*/plan.md``
696+
glob never matches those.
697+
"""
698+
699+
@staticmethod
700+
def _make_plans(project: Path) -> Path:
701+
# Older flat plan plus a newer nested plan: recursive discovery
702+
# must pick the nested one by mtime.
703+
flat = project / "specs" / "old-feature" / "plan.md"
704+
flat.parent.mkdir(parents=True)
705+
flat.write_text("flat plan\n", encoding="utf-8")
706+
os.utime(flat, (1_000_000_000, 1_000_000_000))
707+
nested = project / "specs" / "scope" / "new-feature" / "plan.md"
708+
nested.parent.mkdir(parents=True)
709+
nested.write_text("nested plan\n", encoding="utf-8")
710+
return nested
711+
712+
@requires_bash
713+
def test_bash_script_finds_nested_plan(self, tmp_path):
714+
project = tmp_path / "project"
715+
project.mkdir()
716+
_install_agent_context_config(
717+
project,
718+
context_file="AGENTS.md",
719+
context_files=["AGENTS.md"],
720+
)
721+
self._make_plans(project)
722+
723+
result = _run_bash_agent_context_script(project)
724+
725+
assert result.returncode == 0, result.stderr + result.stdout
726+
content = (project / "AGENTS.md").read_text(encoding="utf-8")
727+
assert "specs/scope/new-feature/plan.md" in content
728+
729+
@pytest.mark.skipif(POWERSHELL is None, reason="PowerShell not available")
730+
def test_powershell_script_finds_nested_plan(self, tmp_path):
731+
project = tmp_path / "project"
732+
project.mkdir()
733+
_install_agent_context_config(
734+
project,
735+
context_file="AGENTS.md",
736+
context_files=["AGENTS.md"],
737+
)
738+
self._make_plans(project)
739+
740+
result = _run_powershell_agent_context_script(project)
741+
742+
assert result.returncode == 0, result.stderr + result.stdout
743+
content = (project / "AGENTS.md").read_text(encoding="utf-8")
744+
assert "specs/scope/new-feature/plan.md" in content
745+
746+
691747
class TestMdcFrontmatter:
692748
"""Cursor-style ``.mdc`` targets must carry ``alwaysApply: true`` frontmatter
693749
so the rule file is auto-loaded; non-``.mdc`` targets must not gain any."""

0 commit comments

Comments
 (0)