diff --git a/extensions/agent-context/scripts/bash/update-agent-context.sh b/extensions/agent-context/scripts/bash/update-agent-context.sh index 42ce44df9a..67b1a2867a 100755 --- a/extensions/agent-context/scripts/bash/update-agent-context.sh +++ b/extensions/agent-context/scripts/bash/update-agent-context.sh @@ -10,9 +10,9 @@ # # 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 -# concrete plan path. +# When `plan_path` is omitted, the script derives it from `.specify/feature.json` +# (written by /speckit-specify). Falls back to the most recently modified +# `specs/*/plan.md` only when feature.json is absent or its plan does not exist yet. set -euo pipefail @@ -122,11 +122,44 @@ 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. - _plan_abs="$("$_python" - "$PROJECT_ROOT" <<'PY' -import sys, os + # Prefer .specify/feature.json (written by /speckit-specify) over mtime heuristic. + _feature_json="$PROJECT_ROOT/.specify/feature.json" + if [[ -f "$_feature_json" ]]; then + _feature_dir="$("$_python" - "$_feature_json" <<'PY' +import sys, json +try: + with open(sys.argv[1], encoding="utf-8") as fh: + d = json.load(fh) + val = d.get("feature_directory", "") + print(val if isinstance(val, str) else "") +except Exception: + print("") +PY +)" + _feature_dir="${_feature_dir%/}" + if [[ -n "$_feature_dir" ]]; then + # feature_directory may be relative or absolute (absolute paths outside PROJECT_ROOT + # are preserved as-is by _persist_feature_json in common.sh). + if [[ "$_feature_dir" == /* ]]; then + _candidate="$_feature_dir/plan.md" + else + _candidate="$PROJECT_ROOT/$_feature_dir/plan.md" + fi + if [[ -f "$_candidate" ]]; then + # Emit a PROJECT_ROOT-relative path when possible, otherwise use the absolute path. + if [[ "$_candidate" == "$PROJECT_ROOT/"* ]]; then + PLAN_PATH="${_candidate#"$PROJECT_ROOT/"}" + else + PLAN_PATH="$_candidate" + fi + fi + fi + fi + + # Fall back to mtime only when feature.json is absent or its plan does not exist yet. + if [[ -z "$PLAN_PATH" ]]; then + _plan_abs="$("$_python" - "$PROJECT_ROOT" <<'PY' +import sys from pathlib import Path specs = Path(sys.argv[1]) / "specs" plans = sorted( @@ -137,8 +170,9 @@ plans = sorted( print(plans[0] if plans else "") PY )" - if [[ -n "$_plan_abs" ]]; then - PLAN_PATH="${_plan_abs#"$PROJECT_ROOT/"}" + if [[ -n "$_plan_abs" ]]; then + PLAN_PATH="${_plan_abs#"$PROJECT_ROOT/"}" + fi fi fi diff --git a/extensions/agent-context/scripts/powershell/update-agent-context.ps1 b/extensions/agent-context/scripts/powershell/update-agent-context.ps1 index dad309c03a..9e7dcac298 100644 --- a/extensions/agent-context/scripts/powershell/update-agent-context.ps1 +++ b/extensions/agent-context/scripts/powershell/update-agent-context.ps1 @@ -9,6 +9,10 @@ # .specify/extensions/agent-context/agent-context-config.yml # # Usage: update-agent-context.ps1 [plan_path] +# +# When `plan_path` is omitted, the script derives it from `.specify/feature.json` +# (written by /speckit-specify). Falls back to the most recently modified +# `specs/*/plan.md` only when feature.json is absent or its plan does not exist yet. [CmdletBinding()] param( @@ -166,21 +170,64 @@ 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. - 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 { $_ } | - Sort-Object LastWriteTime -Descending | - Select-Object -First 1 - if ($candidate) { - $PlanPath = [System.IO.Path]::GetRelativePath($ProjectRoot, $candidate.FullName).Replace('\','/') + # Prefer .specify/feature.json (written by /speckit-specify) over mtime heuristic. + $FeatureJson = Join-Path $ProjectRoot '.specify/feature.json' + if (Test-Path -LiteralPath $FeatureJson) { + try { + $fj = Get-Content -LiteralPath $FeatureJson -Raw -Encoding UTF8 | ConvertFrom-Json + $featureDir = $fj.feature_directory + if ($featureDir -isnot [string] -or -not $featureDir) { + $featureDir = $null + } else { + $featureDir = $featureDir.TrimEnd('\', '/') + } + if ($featureDir) { + # Join-Path on Unix does not treat absolute ChildPath as "wins"; check explicitly. + if ([System.IO.Path]::IsPathRooted($featureDir)) { + $candidatePlan = Join-Path $featureDir 'plan.md' + } else { + $candidatePlan = Join-Path (Join-Path $ProjectRoot $featureDir) 'plan.md' + } + if (Test-Path -LiteralPath $candidatePlan) { + # Normalize absolute feature paths to project-relative (mirrors bash behavior). + $relDir = $featureDir + if ([System.IO.Path]::IsPathRooted($featureDir)) { + $normRoot = $ProjectRoot.TrimEnd('\', '/') + [System.IO.Path]::DirectorySeparatorChar + $normDir = $featureDir.Replace('/', [System.IO.Path]::DirectorySeparatorChar) + if ($normDir.StartsWith($normRoot, [System.StringComparison]::OrdinalIgnoreCase)) { + $relDir = $normDir.Substring($normRoot.Length) + } + } + $PlanPath = $relDir.Replace('\', '/') + '/plan.md' + } + } + } catch { + # Non-fatal: fall through to mtime heuristic. + } + } + + # Fall back to mtime only when feature.json is absent or its plan does not exist yet. + if (-not $PlanPath) { + 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 { $_ } | + Sort-Object LastWriteTime -Descending | + Select-Object -First 1 + if ($candidate) { + # GetRelativePath is .NET 5+ only; strip prefix manually for PS 5.1 compat. + $fullPath = $candidate.FullName.Replace('\', '/') + $normRoot = $ProjectRoot.Replace('\', '/').TrimEnd('/') + '/' + if ($fullPath.StartsWith($normRoot, [System.StringComparison]::OrdinalIgnoreCase)) { + $PlanPath = $fullPath.Substring($normRoot.Length) + } else { + $PlanPath = $fullPath + } + } + } catch { + # Non-fatal: continue without a plan path. } - } catch { - # Non-fatal: continue without a plan path. } } diff --git a/tests/extensions/test_update_agent_context_feature_json.py b/tests/extensions/test_update_agent_context_feature_json.py new file mode 100644 index 0000000000..6fcdd73ffe --- /dev/null +++ b/tests/extensions/test_update_agent_context_feature_json.py @@ -0,0 +1,287 @@ +"""Tests that update-agent-context.sh/.ps1 prefer feature.json over mtime.""" + +from __future__ import annotations + +import json +import os +import shutil +import subprocess +import time +from pathlib import Path + +import pytest + +from tests.conftest import requires_bash + +PROJECT_ROOT = Path(__file__).resolve().parent.parent.parent +UPDATE_AGENT_CTX_SH = ( + PROJECT_ROOT / "extensions" / "agent-context" / "scripts" / "bash" / "update-agent-context.sh" +) +UPDATE_AGENT_CTX_PS = ( + PROJECT_ROOT / "extensions" / "agent-context" / "scripts" / "powershell" / "update-agent-context.ps1" +) + +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 _setup_project(root: Path, context_file: str = "CLAUDE.md") -> None: + """Write the minimal agent-context extension config.""" + cfg_dir = root / ".specify" / "extensions" / "agent-context" + cfg_dir.mkdir(parents=True, exist_ok=True) + (cfg_dir / "agent-context-config.yml").write_text( + f"context_file: {context_file}\n" + "context_markers:\n" + " start: ''\n" + " end: ''\n", + encoding="utf-8", + ) + + +def _write_feature_json(root: Path, feature_directory: str) -> None: + specify_dir = root / ".specify" + specify_dir.mkdir(parents=True, exist_ok=True) + (specify_dir / "feature.json").write_text( + json.dumps({"feature_directory": feature_directory}), + encoding="utf-8", + ) + + +def _to_bash_path(p: Path) -> str: + """Return a path string usable inside Git-for-Windows bash. + + On Windows, Python paths use drive letters (C:\\...) but Git bash (MSYS2) + expects POSIX-style paths (/c/...). On all other platforms the path is + returned unchanged. + """ + if os.name != "nt": + return str(p.resolve()) + posix = p.resolve().as_posix() # C:/foo/bar + if len(posix) >= 2 and posix[1] == ":": + return "/" + posix[0].lower() + posix[2:] + return posix + + +def _make_plan(root: Path, feature_dir: str, content: str = "# plan\n") -> Path: + p = root / feature_dir / "plan.md" + p.parent.mkdir(parents=True, exist_ok=True) + p.write_text(content, encoding="utf-8") + return p + + +@requires_bash +def test_bash_uses_feature_json_when_plan_exists(tmp_path: Path) -> None: + """feature.json points to the active feature; that plan.md is injected.""" + _setup_project(tmp_path) + _make_plan(tmp_path, "specs/001-active") + _write_feature_json(tmp_path, "specs/001-active") + + result = subprocess.run( + ["bash", str(UPDATE_AGENT_CTX_SH)], + cwd=tmp_path, + capture_output=True, + text=True, + check=False, + ) + assert result.returncode == 0, result.stderr + result.stdout + ctx = (tmp_path / "CLAUDE.md").read_text(encoding="utf-8") + assert "specs/001-active/plan.md" in ctx + + +@requires_bash +def test_bash_ignores_newer_stale_plan_when_feature_json_present(tmp_path: Path) -> None: + """An older spec's plan.md modified more recently must NOT win over feature.json.""" + _setup_project(tmp_path) + + # Create active feature plan first, then touch the stale one to make it newer. + active = _make_plan(tmp_path, "specs/001-active") + stale = _make_plan(tmp_path, "specs/000-stale") + now = time.time() + os.utime(active, (now - 10, now - 10)) + os.utime(stale, (now, now)) + + _write_feature_json(tmp_path, "specs/001-active") + + result = subprocess.run( + ["bash", str(UPDATE_AGENT_CTX_SH)], + cwd=tmp_path, + capture_output=True, + text=True, + check=False, + ) + assert result.returncode == 0, result.stderr + result.stdout + ctx = (tmp_path / "CLAUDE.md").read_text(encoding="utf-8") + assert "specs/001-active/plan.md" in ctx + assert "specs/000-stale/plan.md" not in ctx + + +@requires_bash +def test_bash_falls_back_to_mtime_when_feature_json_absent(tmp_path: Path) -> None: + """No feature.json → mtime fallback selects the most recently modified plan.""" + _setup_project(tmp_path) + old = _make_plan(tmp_path, "specs/000-old") + newer = _make_plan(tmp_path, "specs/001-newer") + now = time.time() + os.utime(old, (now - 10, now - 10)) + os.utime(newer, (now, now)) + + result = subprocess.run( + ["bash", str(UPDATE_AGENT_CTX_SH)], + cwd=tmp_path, + capture_output=True, + text=True, + check=False, + ) + assert result.returncode == 0, result.stderr + result.stdout + ctx = (tmp_path / "CLAUDE.md").read_text(encoding="utf-8") + assert "specs/001-newer/plan.md" in ctx + + +@requires_bash +def test_bash_falls_back_to_mtime_when_plan_not_yet_created(tmp_path: Path) -> None: + """feature.json exists but plan.md not yet written → fall back to mtime.""" + _setup_project(tmp_path) + _make_plan(tmp_path, "specs/000-old") + # feature.json points to 001-new but its plan.md doesn't exist yet + _write_feature_json(tmp_path, "specs/001-new") + + result = subprocess.run( + ["bash", str(UPDATE_AGENT_CTX_SH)], + cwd=tmp_path, + capture_output=True, + text=True, + check=False, + ) + assert result.returncode == 0, result.stderr + result.stdout + ctx = (tmp_path / "CLAUDE.md").read_text(encoding="utf-8") + assert "specs/000-old/plan.md" in ctx + + +@requires_bash +def test_bash_absolute_feature_dir_under_project_root(tmp_path: Path) -> None: + """Absolute feature_directory under PROJECT_ROOT → project-relative path in context.""" + _setup_project(tmp_path) + active = _make_plan(tmp_path, "specs/001-active") + stale = _make_plan(tmp_path, "specs/000-stale") + now = time.time() + os.utime(active, (now - 10, now - 10)) + os.utime(stale, (now, now)) + # Write absolute path to feature.json — mtime would pick 000-stale without it + _write_feature_json(tmp_path, _to_bash_path(tmp_path / "specs" / "001-active")) + + result = subprocess.run( + ["bash", str(UPDATE_AGENT_CTX_SH)], + cwd=tmp_path, + capture_output=True, + text=True, + check=False, + ) + assert result.returncode == 0, result.stderr + result.stdout + ctx = (tmp_path / "CLAUDE.md").read_text(encoding="utf-8") + # Must be project-relative, not machine-specific absolute + assert "specs/001-active/plan.md" in ctx + assert "specs/000-stale/plan.md" not in ctx + assert _to_bash_path(tmp_path) not in ctx + + +@requires_bash +def test_bash_absolute_feature_dir_outside_project_root(tmp_path: Path) -> None: + """Absolute feature_directory outside PROJECT_ROOT → absolute path preserved in context.""" + project = tmp_path / "project" + external = tmp_path / "external" / "001-feature" + project.mkdir() + external.mkdir(parents=True) + (external / "plan.md").write_text("# plan\n", encoding="utf-8") + + _setup_project(project) + _write_feature_json(project, _to_bash_path(external)) + + result = subprocess.run( + ["bash", str(UPDATE_AGENT_CTX_SH)], + cwd=project, + capture_output=True, + text=True, + check=False, + ) + assert result.returncode == 0, result.stderr + result.stdout + ctx = (project / "CLAUDE.md").read_text(encoding="utf-8") + assert _to_bash_path(external) + "/plan.md" in ctx + + +@pytest.mark.skipif(not (HAS_PWSH or _WINDOWS_POWERSHELL), reason="no PowerShell available") +def test_ps_uses_feature_json_when_plan_exists(tmp_path: Path) -> None: + """PowerShell: absolute feature_directory under project root is normalized to relative path.""" + _setup_project(tmp_path) + active = _make_plan(tmp_path, "specs/001-active") + stale = _make_plan(tmp_path, "specs/000-stale") + now = time.time() + os.utime(active, (now - 10, now - 10)) + os.utime(stale, (now, now)) + # Write absolute path to feature.json — mtime would pick 000-stale without it + # Use native str() here: PowerShell expects Windows-native paths, not MSYS2 /c/... form + _write_feature_json(tmp_path, str(tmp_path / "specs" / "001-active")) + + exe = "pwsh" if HAS_PWSH else _WINDOWS_POWERSHELL + result = subprocess.run( + [exe, "-NoProfile", "-File", str(UPDATE_AGENT_CTX_PS)], + cwd=tmp_path, + capture_output=True, + text=True, + check=False, + ) + assert result.returncode == 0, result.stderr + result.stdout + ctx = (tmp_path / "CLAUDE.md").read_text(encoding="utf-8") + # Must be project-relative, not machine-specific absolute + assert "at specs/001-active/plan.md" in ctx + assert "specs/000-stale/plan.md" not in ctx + assert tmp_path.resolve().as_posix() not in ctx + + +@pytest.mark.skipif(not (HAS_PWSH or _WINDOWS_POWERSHELL), reason="no PowerShell available") +def test_ps_ignores_newer_stale_plan_when_feature_json_present(tmp_path: Path) -> None: + """PowerShell: stale plan touched more recently must not win over feature.json.""" + _setup_project(tmp_path) + active = _make_plan(tmp_path, "specs/001-active") + stale = _make_plan(tmp_path, "specs/000-stale") + now = time.time() + os.utime(active, (now - 10, now - 10)) + os.utime(stale, (now, now)) + _write_feature_json(tmp_path, "specs/001-active") + + exe = "pwsh" if HAS_PWSH else _WINDOWS_POWERSHELL + result = subprocess.run( + [exe, "-NoProfile", "-File", str(UPDATE_AGENT_CTX_PS)], + cwd=tmp_path, + capture_output=True, + text=True, + check=False, + ) + assert result.returncode == 0, result.stderr + result.stdout + ctx = (tmp_path / "CLAUDE.md").read_text(encoding="utf-8") + assert "specs/001-active/plan.md" in ctx + assert "specs/000-stale/plan.md" not in ctx + + +@pytest.mark.skipif(not (HAS_PWSH or _WINDOWS_POWERSHELL), reason="no PowerShell available") +def test_ps_absolute_feature_dir_outside_project_root(tmp_path: Path) -> None: + """PowerShell: absolute feature_directory outside project root → absolute path preserved.""" + project = tmp_path / "project" + external = tmp_path / "external" / "001-feature" + project.mkdir() + external.mkdir(parents=True) + (external / "plan.md").write_text("# plan\n", encoding="utf-8") + + _setup_project(project) + _write_feature_json(project, str(external)) + + exe = "pwsh" if HAS_PWSH else _WINDOWS_POWERSHELL + result = subprocess.run( + [exe, "-NoProfile", "-File", str(UPDATE_AGENT_CTX_PS)], + cwd=project, + capture_output=True, + text=True, + check=False, + ) + assert result.returncode == 0, result.stderr + result.stdout + ctx = (project / "CLAUDE.md").read_text(encoding="utf-8") + assert external.resolve().as_posix() + "/plan.md" in ctx