Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@ The script reads the agent-context extension config at
- `context_file` — the path of the coding agent context file to manage.
- `context_markers.start` / `.end` — the delimiters surrounding the managed section. Defaults to `<!-- SPECKIT START -->` and `<!-- SPECKIT END -->` 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/<feature>/plan.md`).
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`).

If `context_file` is empty or the file cannot be located, the command reports nothing to do and exits successfully.

Expand All @@ -23,4 +23,4 @@ If `context_file` is empty or the file cannot be located, the command reports no
- **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`.
When `plan_path` is omitted, the script auto-detects the most recently modified `plan.md` anywhere under `specs/`.
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@
# Usage: update-agent-context.sh [plan_path]
#
# When `plan_path` is omitted, the script picks the most recently modified
# `specs/*/plan.md` if any exist, otherwise emits the section without a
# `specs/**/plan.md` if any exist, otherwise emits the section without a
# concrete plan path.

set -euo pipefail
Expand Down Expand Up @@ -122,15 +122,15 @@ unset _cf_parts _seg

PLAN_PATH="${1:-}"
if [[ -z "$PLAN_PATH" ]]; then
# Pick the most recently modified plan.md one level deep (specs/<feature>/plan.md).
# Pick the most recently modified plan.md anywhere under specs/.
# Use find + 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.glob("**/plan.md"),
key=lambda p: p.stat().st_mtime,
reverse=True,
)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -166,13 +166,12 @@ if ($cm) {
}

if (-not $PlanPath) {
# Discover plan.md exactly one level deep (specs/<feature>/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 plan.md anywhere under specs/, picking the most recently modified file.
# 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 } |
$candidate = Get-ChildItem -Path $specsDir -Recurse -Filter 'plan.md' -File -ErrorAction SilentlyContinue |
Where-Object { $_ } |
Sort-Object LastWriteTime -Descending |
Select-Object -First 1
Expand Down
70 changes: 70 additions & 0 deletions tests/extensions/test_extension_agent_context.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,8 +3,12 @@
from __future__ import annotations

import json
import os
import shutil
import subprocess
from pathlib import Path

import pytest
import yaml

from specify_cli import (
Expand All @@ -15,10 +19,13 @@
)
from specify_cli.integrations.base import IntegrationBase
from specify_cli.integrations.claude import ClaudeIntegration
from tests.conftest import requires_bash


PROJECT_ROOT = Path(__file__).resolve().parent.parent.parent
EXT_DIR = PROJECT_ROOT / "extensions" / "agent-context"
HAS_PWSH = shutil.which("pwsh") is not None
_WINDOWS_POWERSHELL = (shutil.which("powershell.exe") or shutil.which("powershell")) if os.name == "nt" else None


def _write_ext_config(project_root: Path, **overrides: object) -> None:
Expand All @@ -36,6 +43,22 @@ def _write_ext_config(project_root: Path, **overrides: object) -> None:
_save_agent_context_config(project_root, cfg)


def _prepare_agent_context_project(project_root: Path) -> None:
(project_root / ".specify" / "extensions" / "agent-context").mkdir(
parents=True,
exist_ok=True,
)
_write_ext_config(project_root, context_file="CLAUDE.md")


def _clean_env() -> dict[str, str]:
env = os.environ.copy()
for key in list(env):
if key.startswith("SPECIFY_"):
env.pop(key)
return env


# ── Bundled extension layout ─────────────────────────────────────────────────


Expand Down Expand Up @@ -426,6 +449,53 @@ def test_marker_resolution_with_corrupt_yaml(self, tmp_path):
assert start == IntegrationBase.CONTEXT_MARKER_START
assert end == IntegrationBase.CONTEXT_MARKER_END


# ── Script discovery for nested plan paths ──────────────────────────────────


class TestNestedPlanDiscovery:
@requires_bash
def test_bash_script_resolves_nested_plan(self, tmp_path):
_prepare_agent_context_project(tmp_path)
nested_plan = tmp_path / "specs" / "scope" / "feature" / "plan.md"
nested_plan.parent.mkdir(parents=True, exist_ok=True)
nested_plan.write_text("# plan\n", encoding="utf-8")
script = EXT_DIR / "scripts" / "bash" / "update-agent-context.sh"
result = subprocess.run(
["bash", str(script)],
cwd=tmp_path,
capture_output=True,
text=True,
check=False,
env=_clean_env(),
)
assert result.returncode == 0, result.stderr
text = (tmp_path / "CLAUDE.md").read_text(encoding="utf-8")
assert "specs/scope/feature/plan.md" in text

@pytest.mark.skipif(
not (HAS_PWSH or _WINDOWS_POWERSHELL),
reason="no PowerShell available",
)
def test_powershell_script_resolves_nested_plan(self, tmp_path):
_prepare_agent_context_project(tmp_path)
nested_plan = tmp_path / "specs" / "scope" / "feature" / "plan.md"
nested_plan.parent.mkdir(parents=True, exist_ok=True)
nested_plan.write_text("# plan\n", encoding="utf-8")
script = EXT_DIR / "scripts" / "powershell" / "update-agent-context.ps1"
exe = "pwsh" if HAS_PWSH else _WINDOWS_POWERSHELL
result = subprocess.run(
[exe, "-NoProfile", "-File", str(script)],
cwd=tmp_path,
capture_output=True,
text=True,
check=False,
env=_clean_env(),
)
assert result.returncode == 0, result.stderr
text = (tmp_path / "CLAUDE.md").read_text(encoding="utf-8")
assert "specs/scope/feature/plan.md" in text

def test_upsert_with_corrupt_config_uses_defaults(self, tmp_path):
"""upsert_context_section still works when config YAML is corrupt."""
cfg_path = (
Expand Down