From cf0a80d03d81dfd5018fe4da5decdf631df6a07b Mon Sep 17 00:00:00 2001 From: Amirreza Alibeigi Date: Fri, 19 Jun 2026 14:46:16 +0200 Subject: [PATCH 01/15] fix: derive plan path from feature.json in update-agent-context When `plan_path` is omitted, prefer `.specify/feature.json` (written by /speckit-specify) over the mtime heuristic. The old approach picked the most recently modified `specs/*/plan.md`, which could inject an unrelated plan into CLAUDE.md if another spec's plan was touched after the active feature directory was created but before its own plan.md existed. Bash: handle both relative and absolute feature_directory values, normalizing absolute paths back to project-relative for the context file. Fall back to mtime only when feature.json is absent or the derived plan.md does not yet exist. PowerShell: same logic, PS 5.1-compatible (nested Join-Path, IsPathRooted guard to avoid Unix Join-Path mis-joining absolute ChildPaths, manual prefix-strip instead of GetRelativePath). Fixes #3067 --- .../scripts/bash/update-agent-context.sh | 51 ++++- .../powershell/update-agent-context.ps1 | 63 ++++-- .../test_update_agent_context_feature_json.py | 181 ++++++++++++++++++ 3 files changed, 271 insertions(+), 24 deletions(-) create mode 100644 tests/extensions/test_update_agent_context_feature_json.py diff --git a/extensions/agent-context/scripts/bash/update-agent-context.sh b/extensions/agent-context/scripts/bash/update-agent-context.sh index 42ce44df9a..614a0523dd 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,41 @@ 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: + d = json.load(open(sys.argv[1])) + print(d.get("feature_directory", "")) +except Exception: + print("") +PY +)" + 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 +167,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..a4e4e79308 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,52 @@ 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) { + # 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) { + $PlanPath = [System.IO.Path]::GetRelativePath($ProjectRoot, $candidate.FullName).Replace('\','/') + } + } 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..210085fb2d --- /dev/null +++ b/tests/extensions/test_update_agent_context_feature_json.py @@ -0,0 +1,181 @@ +"""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 _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. + _make_plan(tmp_path, "specs/001-active") + time.sleep(0.05) + _make_plan(tmp_path, "specs/000-stale") + + _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) + _make_plan(tmp_path, "specs/000-old") + time.sleep(0.05) + _make_plan(tmp_path, "specs/001-newer") + + 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 + + +@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: feature.json points to the active feature; that plan is injected.""" + _setup_project(tmp_path) + _make_plan(tmp_path, "specs/001-active") + _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 + + +@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) + _make_plan(tmp_path, "specs/001-active") + time.sleep(0.05) + _make_plan(tmp_path, "specs/000-stale") + _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 From b290c03c13422cd7881ef289d4ebddc0013225ec Mon Sep 17 00:00:00 2001 From: Amirreza Alibeigi Date: Fri, 19 Jun 2026 19:56:21 +0200 Subject: [PATCH 02/15] fix: address Copilot review feedback on update-agent-context - bash: add explicit encoding="utf-8" to feature.json open() call - powershell: replace GetRelativePath (.NET 5+ only) with manual prefix-strip in mtime fallback for PS 5.1 compatibility - tests: add coverage for absolute feature_directory values (under and outside PROJECT_ROOT) --- .../scripts/bash/update-agent-context.sh | 2 +- .../powershell/update-agent-context.ps1 | 9 +++- .../test_update_agent_context_feature_json.py | 46 +++++++++++++++++++ 3 files changed, 55 insertions(+), 2 deletions(-) diff --git a/extensions/agent-context/scripts/bash/update-agent-context.sh b/extensions/agent-context/scripts/bash/update-agent-context.sh index 614a0523dd..c10c01ee0a 100755 --- a/extensions/agent-context/scripts/bash/update-agent-context.sh +++ b/extensions/agent-context/scripts/bash/update-agent-context.sh @@ -128,7 +128,7 @@ if [[ -z "$PLAN_PATH" ]]; then _feature_dir="$("$_python" - "$_feature_json" <<'PY' import sys, json try: - d = json.load(open(sys.argv[1])) + d = json.load(open(sys.argv[1], encoding="utf-8")) print(d.get("feature_directory", "")) except Exception: print("") diff --git a/extensions/agent-context/scripts/powershell/update-agent-context.ps1 b/extensions/agent-context/scripts/powershell/update-agent-context.ps1 index a4e4e79308..c3aa3036a3 100644 --- a/extensions/agent-context/scripts/powershell/update-agent-context.ps1 +++ b/extensions/agent-context/scripts/powershell/update-agent-context.ps1 @@ -211,7 +211,14 @@ if (-not $PlanPath) { Sort-Object LastWriteTime -Descending | Select-Object -First 1 if ($candidate) { - $PlanPath = [System.IO.Path]::GetRelativePath($ProjectRoot, $candidate.FullName).Replace('\','/') + # 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. diff --git a/tests/extensions/test_update_agent_context_feature_json.py b/tests/extensions/test_update_agent_context_feature_json.py index 210085fb2d..dff1e954c6 100644 --- a/tests/extensions/test_update_agent_context_feature_json.py +++ b/tests/extensions/test_update_agent_context_feature_json.py @@ -138,6 +138,52 @@ def test_bash_falls_back_to_mtime_when_plan_not_yet_created(tmp_path: Path) -> N 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) + _make_plan(tmp_path, "specs/001-active") + # Write absolute path to feature.json + _write_feature_json(tmp_path, str(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 str(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, str(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 str(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: feature.json points to the active feature; that plan is injected.""" From d710b6275abcf66ffb122d41f68ec4c48cb6c296 Mon Sep 17 00:00:00 2001 From: Amirreza Alibeigi Date: Fri, 19 Jun 2026 20:07:28 +0200 Subject: [PATCH 03/15] Potential fix for pull request finding Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --- .../extensions/test_update_agent_context_feature_json.py | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/tests/extensions/test_update_agent_context_feature_json.py b/tests/extensions/test_update_agent_context_feature_json.py index dff1e954c6..1e341a8ee5 100644 --- a/tests/extensions/test_update_agent_context_feature_json.py +++ b/tests/extensions/test_update_agent_context_feature_json.py @@ -79,9 +79,11 @@ def test_bash_ignores_newer_stale_plan_when_feature_json_present(tmp_path: Path) _setup_project(tmp_path) # Create active feature plan first, then touch the stale one to make it newer. - _make_plan(tmp_path, "specs/001-active") - time.sleep(0.05) - _make_plan(tmp_path, "specs/000-stale") + 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") From b2aee0350507d5c90ae7aaa89ba11e1b65a567ac Mon Sep 17 00:00:00 2001 From: Amirreza Alibeigi Date: Fri, 19 Jun 2026 20:08:52 +0200 Subject: [PATCH 04/15] test: replace time.sleep with os.utime and strengthen PS normalization assertion --- .../test_update_agent_context_feature_json.py | 25 ++++++++++++------- 1 file changed, 16 insertions(+), 9 deletions(-) diff --git a/tests/extensions/test_update_agent_context_feature_json.py b/tests/extensions/test_update_agent_context_feature_json.py index 1e341a8ee5..90104172f9 100644 --- a/tests/extensions/test_update_agent_context_feature_json.py +++ b/tests/extensions/test_update_agent_context_feature_json.py @@ -104,9 +104,11 @@ def test_bash_ignores_newer_stale_plan_when_feature_json_present(tmp_path: Path) 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) - _make_plan(tmp_path, "specs/000-old") - time.sleep(0.05) - _make_plan(tmp_path, "specs/001-newer") + 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)], @@ -188,10 +190,11 @@ def test_bash_absolute_feature_dir_outside_project_root(tmp_path: Path) -> None: @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: feature.json points to the active feature; that plan is injected.""" + """PowerShell: absolute feature_directory under project root is normalized to relative path.""" _setup_project(tmp_path) _make_plan(tmp_path, "specs/001-active") - _write_feature_json(tmp_path, "specs/001-active") + # Use absolute path to exercise the normalization code path + _write_feature_json(tmp_path, str(tmp_path / "specs" / "001-active")) exe = "pwsh" if HAS_PWSH else _WINDOWS_POWERSHELL result = subprocess.run( @@ -203,16 +206,20 @@ def test_ps_uses_feature_json_when_plan_exists(tmp_path: Path) -> None: ) 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 + # Must be project-relative, not machine-specific absolute + assert "at specs/001-active/plan.md" in ctx + assert str(tmp_path) 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) - _make_plan(tmp_path, "specs/001-active") - time.sleep(0.05) - _make_plan(tmp_path, "specs/000-stale") + 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 From 9940cdbebff731f2399ecbfc9eb206ab1f578da0 Mon Sep 17 00:00:00 2001 From: Amirreza Alibeigi Date: Fri, 19 Jun 2026 20:52:20 +0200 Subject: [PATCH 05/15] Apply suggestions from code review Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --- extensions/agent-context/scripts/bash/update-agent-context.sh | 1 + .../agent-context/scripts/powershell/update-agent-context.ps1 | 1 + 2 files changed, 2 insertions(+) diff --git a/extensions/agent-context/scripts/bash/update-agent-context.sh b/extensions/agent-context/scripts/bash/update-agent-context.sh index c10c01ee0a..8b072b0dcd 100755 --- a/extensions/agent-context/scripts/bash/update-agent-context.sh +++ b/extensions/agent-context/scripts/bash/update-agent-context.sh @@ -135,6 +135,7 @@ except Exception: PY )" if [[ -n "$_feature_dir" ]]; then + _feature_dir="${_feature_dir%/}" # 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 diff --git a/extensions/agent-context/scripts/powershell/update-agent-context.ps1 b/extensions/agent-context/scripts/powershell/update-agent-context.ps1 index c3aa3036a3..6843df5bb3 100644 --- a/extensions/agent-context/scripts/powershell/update-agent-context.ps1 +++ b/extensions/agent-context/scripts/powershell/update-agent-context.ps1 @@ -176,6 +176,7 @@ if (-not $PlanPath) { try { $fj = Get-Content -LiteralPath $FeatureJson -Raw -Encoding UTF8 | ConvertFrom-Json $featureDir = $fj.feature_directory + if ($featureDir -is [string]) { $featureDir = $featureDir.TrimEnd('\\', '/') } if ($featureDir) { # Join-Path on Unix does not treat absolute ChildPath as "wins"; check explicitly. if ([System.IO.Path]::IsPathRooted($featureDir)) { From be127bdbd87eb31e5c4dfb86993b51ef5c135b96 Mon Sep 17 00:00:00 2001 From: Amirreza Alibeigi Date: Fri, 19 Jun 2026 20:59:19 +0200 Subject: [PATCH 06/15] Potential fix for pull request finding Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --- .../agent-context/scripts/powershell/update-agent-context.ps1 | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/extensions/agent-context/scripts/powershell/update-agent-context.ps1 b/extensions/agent-context/scripts/powershell/update-agent-context.ps1 index 6843df5bb3..4dfdea24d9 100644 --- a/extensions/agent-context/scripts/powershell/update-agent-context.ps1 +++ b/extensions/agent-context/scripts/powershell/update-agent-context.ps1 @@ -176,8 +176,8 @@ if (-not $PlanPath) { try { $fj = Get-Content -LiteralPath $FeatureJson -Raw -Encoding UTF8 | ConvertFrom-Json $featureDir = $fj.feature_directory - if ($featureDir -is [string]) { $featureDir = $featureDir.TrimEnd('\\', '/') } - if ($featureDir) { + if ($featureDir -isnot [string]) { $featureDir = $null } + if ($featureDir) { $featureDir = $featureDir.TrimEnd('\\', '/') } # 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' From 80b96c8b99d8bd7fae85becfbf0991eca5067818 Mon Sep 17 00:00:00 2001 From: Amirreza Alibeigi Date: Fri, 19 Jun 2026 21:04:43 +0200 Subject: [PATCH 07/15] fix: normalize trailing slash and guard non-string feature_directory in PS script --- .../scripts/powershell/update-agent-context.ps1 | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/extensions/agent-context/scripts/powershell/update-agent-context.ps1 b/extensions/agent-context/scripts/powershell/update-agent-context.ps1 index 4dfdea24d9..9e7dcac298 100644 --- a/extensions/agent-context/scripts/powershell/update-agent-context.ps1 +++ b/extensions/agent-context/scripts/powershell/update-agent-context.ps1 @@ -176,8 +176,12 @@ if (-not $PlanPath) { try { $fj = Get-Content -LiteralPath $FeatureJson -Raw -Encoding UTF8 | ConvertFrom-Json $featureDir = $fj.feature_directory - if ($featureDir -isnot [string]) { $featureDir = $null } - if ($featureDir) { $featureDir = $featureDir.TrimEnd('\\', '/') } + 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' From 11530c2a5160c2d0959017ce394d12ae513da65f Mon Sep 17 00:00:00 2001 From: Amirreza Alibeigi Date: Fri, 19 Jun 2026 21:14:44 +0200 Subject: [PATCH 08/15] Fix: use .resolve().as_posix(). Valid. The PS tests run on Windows where str(tmp_path) uses backslashes, but the PS script normalizes output to forward slashes. Assertions like assert str(tmp_path) not in ctx become false negatives on Windows CI. Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --- tests/extensions/test_update_agent_context_feature_json.py | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/tests/extensions/test_update_agent_context_feature_json.py b/tests/extensions/test_update_agent_context_feature_json.py index 90104172f9..26b2df8180 100644 --- a/tests/extensions/test_update_agent_context_feature_json.py +++ b/tests/extensions/test_update_agent_context_feature_json.py @@ -185,7 +185,7 @@ def test_bash_absolute_feature_dir_outside_project_root(tmp_path: Path) -> None: ) assert result.returncode == 0, result.stderr + result.stdout ctx = (project / "CLAUDE.md").read_text(encoding="utf-8") - assert str(external) + "/plan.md" in ctx + assert (external / "plan.md").resolve().as_posix() in ctx @pytest.mark.skipif(not (HAS_PWSH or _WINDOWS_POWERSHELL), reason="no PowerShell available") @@ -208,8 +208,7 @@ def test_ps_uses_feature_json_when_plan_exists(tmp_path: Path) -> None: 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 str(tmp_path) 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: From 870e7ec714f69e2fe4875a72d086e1b1166d8c5d Mon Sep 17 00:00:00 2001 From: Amirreza Alibeigi Date: Fri, 19 Jun 2026 21:37:29 +0200 Subject: [PATCH 09/15] fix: use context manager for feature.json open() in bash heredoc --- extensions/agent-context/scripts/bash/update-agent-context.sh | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/extensions/agent-context/scripts/bash/update-agent-context.sh b/extensions/agent-context/scripts/bash/update-agent-context.sh index 8b072b0dcd..bc628da1fb 100755 --- a/extensions/agent-context/scripts/bash/update-agent-context.sh +++ b/extensions/agent-context/scripts/bash/update-agent-context.sh @@ -128,7 +128,8 @@ if [[ -z "$PLAN_PATH" ]]; then _feature_dir="$("$_python" - "$_feature_json" <<'PY' import sys, json try: - d = json.load(open(sys.argv[1], encoding="utf-8")) + with open(sys.argv[1], encoding="utf-8") as fh: + d = json.load(fh) print(d.get("feature_directory", "")) except Exception: print("") From 93dda104e94ce6f5bce23cf4ee8212bfec95b28d Mon Sep 17 00:00:00 2001 From: Amirreza Alibeigi Date: Fri, 19 Jun 2026 21:43:14 +0200 Subject: [PATCH 10/15] test: add PS coverage for absolute feature_directory outside project root --- .../test_update_agent_context_feature_json.py | 25 +++++++++++++++++++ 1 file changed, 25 insertions(+) diff --git a/tests/extensions/test_update_agent_context_feature_json.py b/tests/extensions/test_update_agent_context_feature_json.py index 26b2df8180..7bca88b5f1 100644 --- a/tests/extensions/test_update_agent_context_feature_json.py +++ b/tests/extensions/test_update_agent_context_feature_json.py @@ -233,3 +233,28 @@ def test_ps_ignores_newer_stale_plan_when_feature_json_present(tmp_path: Path) - 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 From dc1c0fa873114272577eaa0202584ebfc59c8677 Mon Sep 17 00:00:00 2001 From: Amirreza Alibeigi Date: Fri, 19 Jun 2026 21:54:16 +0200 Subject: [PATCH 11/15] fix: guard null feature_directory, re-check empty after trailing-slash strip, fix blank line --- .../agent-context/scripts/bash/update-agent-context.sh | 5 +++-- tests/extensions/test_update_agent_context_feature_json.py | 1 + 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/extensions/agent-context/scripts/bash/update-agent-context.sh b/extensions/agent-context/scripts/bash/update-agent-context.sh index bc628da1fb..67b1a2867a 100755 --- a/extensions/agent-context/scripts/bash/update-agent-context.sh +++ b/extensions/agent-context/scripts/bash/update-agent-context.sh @@ -130,13 +130,14 @@ import sys, json try: with open(sys.argv[1], encoding="utf-8") as fh: d = json.load(fh) - print(d.get("feature_directory", "")) + 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_dir="${_feature_dir%/}" # 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 diff --git a/tests/extensions/test_update_agent_context_feature_json.py b/tests/extensions/test_update_agent_context_feature_json.py index 7bca88b5f1..b26def956d 100644 --- a/tests/extensions/test_update_agent_context_feature_json.py +++ b/tests/extensions/test_update_agent_context_feature_json.py @@ -210,6 +210,7 @@ def test_ps_uses_feature_json_when_plan_exists(tmp_path: Path) -> None: assert "at specs/001-active/plan.md" 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.""" From 1808ce7729cb8567c474aa14ac612628d9e5b7cd Mon Sep 17 00:00:00 2001 From: Amirreza Alibeigi Date: Fri, 19 Jun 2026 22:00:31 +0200 Subject: [PATCH 12/15] test: add stale plan to absolute-path tests so feature.json preference is actually exercised --- .../test_update_agent_context_feature_json.py | 18 ++++++++++++++---- 1 file changed, 14 insertions(+), 4 deletions(-) diff --git a/tests/extensions/test_update_agent_context_feature_json.py b/tests/extensions/test_update_agent_context_feature_json.py index b26def956d..448036f792 100644 --- a/tests/extensions/test_update_agent_context_feature_json.py +++ b/tests/extensions/test_update_agent_context_feature_json.py @@ -146,8 +146,12 @@ def test_bash_falls_back_to_mtime_when_plan_not_yet_created(tmp_path: Path) -> N 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) - _make_plan(tmp_path, "specs/001-active") - # Write absolute path to feature.json + 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, str(tmp_path / "specs" / "001-active")) result = subprocess.run( @@ -161,6 +165,7 @@ def test_bash_absolute_feature_dir_under_project_root(tmp_path: Path) -> None: 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 str(tmp_path) not in ctx @@ -192,8 +197,12 @@ def test_bash_absolute_feature_dir_outside_project_root(tmp_path: Path) -> None: 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) - _make_plan(tmp_path, "specs/001-active") - # Use absolute path to exercise the normalization code 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, str(tmp_path / "specs" / "001-active")) exe = "pwsh" if HAS_PWSH else _WINDOWS_POWERSHELL @@ -208,6 +217,7 @@ def test_ps_uses_feature_json_when_plan_exists(tmp_path: Path) -> None: 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 From b24279b20f611e4f8daf38d6d9650bbcead7ce7d Mon Sep 17 00:00:00 2001 From: Amirreza Alibeigi Date: Fri, 19 Jun 2026 22:09:35 +0200 Subject: [PATCH 13/15] test: convert absolute paths to MSYS2 style for Git-for-Windows bash compatibility --- .../test_update_agent_context_feature_json.py | 21 ++++++++++++++++--- 1 file changed, 18 insertions(+), 3 deletions(-) diff --git a/tests/extensions/test_update_agent_context_feature_json.py b/tests/extensions/test_update_agent_context_feature_json.py index 448036f792..2124556fc2 100644 --- a/tests/extensions/test_update_agent_context_feature_json.py +++ b/tests/extensions/test_update_agent_context_feature_json.py @@ -47,6 +47,21 @@ def _write_feature_json(root: Path, feature_directory: str) -> None: ) +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) @@ -152,7 +167,7 @@ def test_bash_absolute_feature_dir_under_project_root(tmp_path: Path) -> None: 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, str(tmp_path / "specs" / "001-active")) + _write_feature_json(tmp_path, _to_bash_path(tmp_path / "specs" / "001-active")) result = subprocess.run( ["bash", str(UPDATE_AGENT_CTX_SH)], @@ -179,7 +194,7 @@ def test_bash_absolute_feature_dir_outside_project_root(tmp_path: Path) -> None: (external / "plan.md").write_text("# plan\n", encoding="utf-8") _setup_project(project) - _write_feature_json(project, str(external)) + _write_feature_json(project, _to_bash_path(external)) result = subprocess.run( ["bash", str(UPDATE_AGENT_CTX_SH)], @@ -203,7 +218,7 @@ def test_ps_uses_feature_json_when_plan_exists(tmp_path: Path) -> None: 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, str(tmp_path / "specs" / "001-active")) + _write_feature_json(tmp_path, _to_bash_path(tmp_path / "specs" / "001-active")) exe = "pwsh" if HAS_PWSH else _WINDOWS_POWERSHELL result = subprocess.run( From 855550e15507f50d62f7c1fbbe498089aa327b66 Mon Sep 17 00:00:00 2001 From: Amirreza Alibeigi Date: Fri, 19 Jun 2026 22:13:37 +0200 Subject: [PATCH 14/15] fix: revert PS test to native path, fix bash outside-root assertion for Git bash --- tests/extensions/test_update_agent_context_feature_json.py | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/tests/extensions/test_update_agent_context_feature_json.py b/tests/extensions/test_update_agent_context_feature_json.py index 2124556fc2..a0ed55a255 100644 --- a/tests/extensions/test_update_agent_context_feature_json.py +++ b/tests/extensions/test_update_agent_context_feature_json.py @@ -205,7 +205,7 @@ def test_bash_absolute_feature_dir_outside_project_root(tmp_path: Path) -> None: ) assert result.returncode == 0, result.stderr + result.stdout ctx = (project / "CLAUDE.md").read_text(encoding="utf-8") - assert (external / "plan.md").resolve().as_posix() in ctx + assert _to_bash_path(external) + "/plan.md" in ctx @pytest.mark.skipif(not (HAS_PWSH or _WINDOWS_POWERSHELL), reason="no PowerShell available") @@ -218,7 +218,8 @@ def test_ps_uses_feature_json_when_plan_exists(tmp_path: Path) -> None: 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")) + # 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( From 8e3d78a83bde0b0afbd8fb3c13043d0d0c7c4227 Mon Sep 17 00:00:00 2001 From: Amirreza Alibeigi Date: Fri, 19 Jun 2026 22:17:42 +0200 Subject: [PATCH 15/15] fix: use _to_bash_path in not-in assertion for Git bash Windows compat --- tests/extensions/test_update_agent_context_feature_json.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/extensions/test_update_agent_context_feature_json.py b/tests/extensions/test_update_agent_context_feature_json.py index a0ed55a255..6fcdd73ffe 100644 --- a/tests/extensions/test_update_agent_context_feature_json.py +++ b/tests/extensions/test_update_agent_context_feature_json.py @@ -181,7 +181,7 @@ def test_bash_absolute_feature_dir_under_project_root(tmp_path: Path) -> None: # 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 str(tmp_path) not in ctx + assert _to_bash_path(tmp_path) not in ctx @requires_bash