From c13dfbca792a6216fd853680660723076ff01f8a Mon Sep 17 00:00:00 2001 From: Amirreza Alibeigi Date: Fri, 19 Jun 2026 14:46:16 +0200 Subject: [PATCH 01/29] 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 9d57b08cf5..90172ad463 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 @@ -202,11 +202,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( @@ -217,8 +247,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 d31fcd64c0..2aeec3661f 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( @@ -280,21 +284,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 0710d125b0fb66ddc367d9b6fd5aab879da456fd Mon Sep 17 00:00:00 2001 From: Amirreza Alibeigi Date: Fri, 19 Jun 2026 19:56:21 +0200 Subject: [PATCH 02/29] 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 90172ad463..e079b6dc54 100755 --- a/extensions/agent-context/scripts/bash/update-agent-context.sh +++ b/extensions/agent-context/scripts/bash/update-agent-context.sh @@ -208,7 +208,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 2aeec3661f..9da33dd273 100644 --- a/extensions/agent-context/scripts/powershell/update-agent-context.ps1 +++ b/extensions/agent-context/scripts/powershell/update-agent-context.ps1 @@ -325,7 +325,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 6f302a3cfcd8724855298d77378f30e09a89c9e8 Mon Sep 17 00:00:00 2001 From: Amirreza Alibeigi Date: Fri, 19 Jun 2026 20:07:28 +0200 Subject: [PATCH 03/29] 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 780d19ab3d66a8790df42ea0fbd082b5f8cc5a52 Mon Sep 17 00:00:00 2001 From: Amirreza Alibeigi Date: Fri, 19 Jun 2026 20:08:52 +0200 Subject: [PATCH 04/29] 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 1a723d487915bdef5cc8f5c2dc0f2fe78ede5a0b Mon Sep 17 00:00:00 2001 From: Amirreza Alibeigi Date: Fri, 19 Jun 2026 20:52:20 +0200 Subject: [PATCH 05/29] 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 e079b6dc54..58437c44d8 100755 --- a/extensions/agent-context/scripts/bash/update-agent-context.sh +++ b/extensions/agent-context/scripts/bash/update-agent-context.sh @@ -215,6 +215,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 9da33dd273..55ea7b2573 100644 --- a/extensions/agent-context/scripts/powershell/update-agent-context.ps1 +++ b/extensions/agent-context/scripts/powershell/update-agent-context.ps1 @@ -290,6 +290,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 5d30c89147c207471bd0bd636b716819028d267a Mon Sep 17 00:00:00 2001 From: Amirreza Alibeigi Date: Fri, 19 Jun 2026 20:59:19 +0200 Subject: [PATCH 06/29] 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 55ea7b2573..1e28975d84 100644 --- a/extensions/agent-context/scripts/powershell/update-agent-context.ps1 +++ b/extensions/agent-context/scripts/powershell/update-agent-context.ps1 @@ -290,8 +290,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 9dc0172aee53ca0e5d866c0354d1966e7e9189fa Mon Sep 17 00:00:00 2001 From: Amirreza Alibeigi Date: Fri, 19 Jun 2026 21:04:43 +0200 Subject: [PATCH 07/29] 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 1e28975d84..98700a2b1a 100644 --- a/extensions/agent-context/scripts/powershell/update-agent-context.ps1 +++ b/extensions/agent-context/scripts/powershell/update-agent-context.ps1 @@ -290,8 +290,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 19d475dba08843793983ae762a4f15dc8c7055be Mon Sep 17 00:00:00 2001 From: Amirreza Alibeigi Date: Fri, 19 Jun 2026 21:14:44 +0200 Subject: [PATCH 08/29] 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 8de30ccf0292ab309d872a7096fd68ff635f64b2 Mon Sep 17 00:00:00 2001 From: Amirreza Alibeigi Date: Fri, 19 Jun 2026 21:37:29 +0200 Subject: [PATCH 09/29] 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 58437c44d8..b9b32a0510 100755 --- a/extensions/agent-context/scripts/bash/update-agent-context.sh +++ b/extensions/agent-context/scripts/bash/update-agent-context.sh @@ -208,7 +208,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 85ed168c7360b29a93be8a7cfb9cde8f9ae13f04 Mon Sep 17 00:00:00 2001 From: Amirreza Alibeigi Date: Fri, 19 Jun 2026 21:43:14 +0200 Subject: [PATCH 10/29] 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 900b98f38173821fe27ccfb8d55d7b547dd2dc1f Mon Sep 17 00:00:00 2001 From: Amirreza Alibeigi Date: Fri, 19 Jun 2026 21:54:16 +0200 Subject: [PATCH 11/29] 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 b9b32a0510..84ab88907b 100755 --- a/extensions/agent-context/scripts/bash/update-agent-context.sh +++ b/extensions/agent-context/scripts/bash/update-agent-context.sh @@ -210,13 +210,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 90cf965b3bc3fc8c7c237d45f7b230879433ec4b Mon Sep 17 00:00:00 2001 From: Amirreza Alibeigi Date: Fri, 19 Jun 2026 22:00:31 +0200 Subject: [PATCH 12/29] 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 ad51464a7ae5a0f46a240f11a9df443030317ea4 Mon Sep 17 00:00:00 2001 From: Amirreza Alibeigi Date: Fri, 19 Jun 2026 22:09:35 +0200 Subject: [PATCH 13/29] 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 a73e611ec156270021fc2e0a78722255b1dd8dfb Mon Sep 17 00:00:00 2001 From: Amirreza Alibeigi Date: Fri, 19 Jun 2026 22:13:37 +0200 Subject: [PATCH 14/29] 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 1e759902dbca572b72098f11711dbe5fecc9ced5 Mon Sep 17 00:00:00 2001 From: Amirreza Alibeigi Date: Fri, 19 Jun 2026 22:17:42 +0200 Subject: [PATCH 15/29] 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 From 5f7a1d9ce0f0aa1ffd5c3e0daaed381e580058dd Mon Sep 17 00:00:00 2001 From: Amirreza Alibeigi Date: Tue, 23 Jun 2026 12:07:47 +0200 Subject: [PATCH 16/29] fix: add ConvertFrom-Json fallback in PS script, write test config as JSON --- .../powershell/update-agent-context.ps1 | 14 +++++++++++++- .../test_update_agent_context_feature_json.py | 19 ++++++++++++++----- 2 files changed, 27 insertions(+), 6 deletions(-) diff --git a/extensions/agent-context/scripts/powershell/update-agent-context.ps1 b/extensions/agent-context/scripts/powershell/update-agent-context.ps1 index 98700a2b1a..79b8ea9ba0 100644 --- a/extensions/agent-context/scripts/powershell/update-agent-context.ps1 +++ b/extensions/agent-context/scripts/powershell/update-agent-context.ps1 @@ -137,7 +137,19 @@ if (Get-Command ConvertFrom-Yaml -ErrorAction SilentlyContinue) { } if ($null -eq $Options) { - # ConvertFrom-Yaml unavailable or failed; fall back to Python+PyYAML. + # ConvertFrom-Yaml unavailable or failed; try ConvertFrom-Json (no external deps, + # works when the config file is valid JSON, which is a subset of YAML). + try { + $raw = Get-Content -LiteralPath $ExtConfig -Raw -Encoding UTF8 + $Options = $raw | ConvertFrom-Json -ErrorAction Stop + if (-not (Test-ConfigObject -Object $Options)) { $Options = $null } + } catch { + $Options = $null + } +} + +if ($null -eq $Options) { + # ConvertFrom-Yaml/Json unavailable or failed; fall back to Python+PyYAML. $pythonCmd = $null $pythonCandidates = @() if ($env:SPECKIT_PYTHON) { diff --git a/tests/extensions/test_update_agent_context_feature_json.py b/tests/extensions/test_update_agent_context_feature_json.py index 6fcdd73ffe..a0ea6b0a35 100644 --- a/tests/extensions/test_update_agent_context_feature_json.py +++ b/tests/extensions/test_update_agent_context_feature_json.py @@ -26,14 +26,23 @@ def _setup_project(root: Path, context_file: str = "CLAUDE.md") -> None: - """Write the minimal agent-context extension config.""" + """Write the minimal agent-context extension config as JSON. + + JSON is a valid subset of YAML so bash+Python/PyYAML can still parse it, + but it also lets PowerShell's built-in ConvertFrom-Json parse it without + needing the powershell-yaml module or an external Python call. + """ cfg_dir = root / ".specify" / "extensions" / "agent-context" cfg_dir.mkdir(parents=True, exist_ok=True) + cfg = { + "context_file": context_file, + "context_markers": { + "start": "", + "end": "", + }, + } (cfg_dir / "agent-context-config.yml").write_text( - f"context_file: {context_file}\n" - "context_markers:\n" - " start: ''\n" - " end: ''\n", + json.dumps(cfg), encoding="utf-8", ) From db5e8ac6da44f477206bc0a766bad9122e25367c Mon Sep 17 00:00:00 2001 From: Amirreza Alibeigi Date: Tue, 23 Jun 2026 12:28:38 +0200 Subject: [PATCH 17/29] fix: use OS-appropriate StringComparison in PS prefix-strip (matches common.ps1) --- .../scripts/powershell/update-agent-context.ps1 | 10 ++++++++-- 1 file changed, 8 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 79b8ea9ba0..a02f95f536 100644 --- a/extensions/agent-context/scripts/powershell/update-agent-context.ps1 +++ b/extensions/agent-context/scripts/powershell/update-agent-context.ps1 @@ -316,11 +316,14 @@ if (-not $PlanPath) { } if (Test-Path -LiteralPath $candidatePlan) { # Normalize absolute feature paths to project-relative (mirrors bash behavior). + # Use case-insensitive comparison on Windows only (matches common.ps1 pattern). $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)) { + if ($null -ne $IsWindows) { $onWin = $IsWindows } else { $onWin = $true } + $cmp = if ($onWin) { [System.StringComparison]::OrdinalIgnoreCase } else { [System.StringComparison]::Ordinal } + if ($normDir.StartsWith($normRoot, $cmp)) { $relDir = $normDir.Substring($normRoot.Length) } } @@ -343,9 +346,12 @@ if (-not $PlanPath) { Select-Object -First 1 if ($candidate) { # GetRelativePath is .NET 5+ only; strip prefix manually for PS 5.1 compat. + # Use case-insensitive comparison on Windows only (matches common.ps1 pattern). $fullPath = $candidate.FullName.Replace('\', '/') $normRoot = $ProjectRoot.Replace('\', '/').TrimEnd('/') + '/' - if ($fullPath.StartsWith($normRoot, [System.StringComparison]::OrdinalIgnoreCase)) { + if ($null -ne $IsWindows) { $onWin = $IsWindows } else { $onWin = $true } + $cmp = if ($onWin) { [System.StringComparison]::OrdinalIgnoreCase } else { [System.StringComparison]::Ordinal } + if ($fullPath.StartsWith($normRoot, $cmp)) { $PlanPath = $fullPath.Substring($normRoot.Length) } else { $PlanPath = $fullPath From 92ad47273f777a67d8194cb11681f7ec5e9a5d7c Mon Sep 17 00:00:00 2001 From: Amirreza Alibeigi Date: Tue, 23 Jun 2026 14:39:04 +0200 Subject: [PATCH 18/29] fix: emit project-relative POSIX path from mtime fallback; use upstream test helpers --- .../scripts/bash/update-agent-context.sh | 21 ++- .../test_update_agent_context_feature_json.py | 158 ++++-------------- 2 files changed, 49 insertions(+), 130 deletions(-) diff --git a/extensions/agent-context/scripts/bash/update-agent-context.sh b/extensions/agent-context/scripts/bash/update-agent-context.sh index 84ab88907b..589b118c1b 100755 --- a/extensions/agent-context/scripts/bash/update-agent-context.sh +++ b/extensions/agent-context/scripts/bash/update-agent-context.sh @@ -237,21 +237,30 @@ PY fi # Fall back to mtime only when feature.json is absent or its plan does not exist yet. + # Python emits a project-relative POSIX path directly to avoid bash prefix-strip + # issues with backslash paths on Windows (Git bash / MSYS2). if [[ -z "$PLAN_PATH" ]]; then - _plan_abs="$("$_python" - "$PROJECT_ROOT" <<'PY' + _plan_rel="$("$_python" - "$PROJECT_ROOT" <<'PY' import sys -from pathlib import Path -specs = Path(sys.argv[1]) / "specs" +from pathlib import Path, PurePosixPath +root = Path(sys.argv[1]).resolve() +specs = root / "specs" plans = sorted( specs.glob("*/plan.md"), key=lambda p: p.stat().st_mtime, reverse=True, ) -print(plans[0] if plans else "") +if plans: + try: + print(str(PurePosixPath(plans[0].relative_to(root)))) + except ValueError: + print("") +else: + print("") PY )" - if [[ -n "$_plan_abs" ]]; then - PLAN_PATH="${_plan_abs#"$PROJECT_ROOT/"}" + if [[ -n "$_plan_rel" ]]; then + PLAN_PATH="$_plan_rel" fi fi fi diff --git a/tests/extensions/test_update_agent_context_feature_json.py b/tests/extensions/test_update_agent_context_feature_json.py index a0ea6b0a35..13c10402ec 100644 --- a/tests/extensions/test_update_agent_context_feature_json.py +++ b/tests/extensions/test_update_agent_context_feature_json.py @@ -4,46 +4,35 @@ 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" +from tests.extensions.test_extension_agent_context import ( + BASH, + POWERSHELL, + _bash_posix_path, + _install_agent_context_config, + _run_bash_agent_context_script, + _run_powershell_agent_context_script, ) -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 as JSON. + """Write agent-context extension config as JSON. - JSON is a valid subset of YAML so bash+Python/PyYAML can still parse it, - but it also lets PowerShell's built-in ConvertFrom-Json parse it without - needing the powershell-yaml module or an external Python call. + JSON is valid YAML so bash+PyYAML can parse it, and PowerShell's built-in + ConvertFrom-Json can parse it without needing powershell-yaml or Python. """ - cfg_dir = root / ".specify" / "extensions" / "agent-context" - cfg_dir.mkdir(parents=True, exist_ok=True) - cfg = { - "context_file": context_file, - "context_markers": { + _install_agent_context_config( + root, + context_file=context_file, + context_markers={ "start": "", "end": "", }, - } - (cfg_dir / "agent-context-config.yml").write_text( - json.dumps(cfg), - encoding="utf-8", ) @@ -56,21 +45,6 @@ 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) @@ -85,13 +59,7 @@ def test_bash_uses_feature_json_when_plan_exists(tmp_path: Path) -> None: _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, - ) + result = _run_bash_agent_context_script(tmp_path) 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 @@ -101,23 +69,14 @@ def test_bash_uses_feature_json_when_plan_exists(tmp_path: Path) -> None: 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, - ) + result = _run_bash_agent_context_script(tmp_path) 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 @@ -134,13 +93,7 @@ def test_bash_falls_back_to_mtime_when_feature_json_absent(tmp_path: Path) -> No 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, - ) + result = _run_bash_agent_context_script(tmp_path) 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 @@ -151,16 +104,9 @@ def test_bash_falls_back_to_mtime_when_plan_not_yet_created(tmp_path: Path) -> N """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, - ) + result = _run_bash_agent_context_script(tmp_path) 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 @@ -175,22 +121,15 @@ def test_bash_absolute_feature_dir_under_project_root(tmp_path: Path) -> None: 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, - ) + # Write POSIX absolute path — mtime would pick 000-stale without feature.json + _write_feature_json(tmp_path, _bash_posix_path(tmp_path / "specs" / "001-active")) + + result = _run_bash_agent_context_script(tmp_path) 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 + assert _bash_posix_path(tmp_path) not in ctx @requires_bash @@ -203,21 +142,15 @@ 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, _to_bash_path(external)) - - result = subprocess.run( - ["bash", str(UPDATE_AGENT_CTX_SH)], - cwd=project, - capture_output=True, - text=True, - check=False, - ) + _write_feature_json(project, _bash_posix_path(external)) + + result = _run_bash_agent_context_script(project) 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 + assert _bash_posix_path(external) + "/plan.md" in ctx -@pytest.mark.skipif(not (HAS_PWSH or _WINDOWS_POWERSHELL), reason="no PowerShell available") +@pytest.mark.skipif(not 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) @@ -226,27 +159,18 @@ def test_ps_uses_feature_json_when_plan_exists(tmp_path: Path) -> None: 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 + # Native str() — 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, - ) + result = _run_powershell_agent_context_script(tmp_path) 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") +@pytest.mark.skipif(not 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) @@ -257,21 +181,14 @@ def test_ps_ignores_newer_stale_plan_when_feature_json_present(tmp_path: Path) - 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, - ) + result = _run_powershell_agent_context_script(tmp_path) 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") +@pytest.mark.skipif(not 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" @@ -283,14 +200,7 @@ def test_ps_absolute_feature_dir_outside_project_root(tmp_path: Path) -> None: _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, - ) + result = _run_powershell_agent_context_script(project) 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 2e8f99ea76b24fafff5f391e351ddac7fa1ab83b Mon Sep 17 00:00:00 2001 From: Amirreza Alibeigi Date: Tue, 23 Jun 2026 14:45:42 +0200 Subject: [PATCH 19/29] fix: write config as JSON directly, drop _install_agent_context_config --- .../test_update_agent_context_feature_json.py | 21 ++++++++++++------- 1 file changed, 13 insertions(+), 8 deletions(-) diff --git a/tests/extensions/test_update_agent_context_feature_json.py b/tests/extensions/test_update_agent_context_feature_json.py index 13c10402ec..957415708c 100644 --- a/tests/extensions/test_update_agent_context_feature_json.py +++ b/tests/extensions/test_update_agent_context_feature_json.py @@ -14,7 +14,6 @@ BASH, POWERSHELL, _bash_posix_path, - _install_agent_context_config, _run_bash_agent_context_script, _run_powershell_agent_context_script, ) @@ -25,14 +24,20 @@ def _setup_project(root: Path, context_file: str = "CLAUDE.md") -> None: JSON is valid YAML so bash+PyYAML can parse it, and PowerShell's built-in ConvertFrom-Json can parse it without needing powershell-yaml or Python. + Written directly as JSON (not via yaml.safe_dump) so the PS ConvertFrom-Json + fallback actually works on Windows CI. """ - _install_agent_context_config( - root, - context_file=context_file, - context_markers={ - "start": "", - "end": "", - }, + cfg_dir = root / ".specify" / "extensions" / "agent-context" + cfg_dir.mkdir(parents=True, exist_ok=True) + (cfg_dir / "agent-context-config.yml").write_text( + json.dumps({ + "context_file": context_file, + "context_markers": { + "start": "", + "end": "", + }, + }), + encoding="utf-8", ) From 94f714b941146be5e632a01be39be2cdec63af24 Mon Sep 17 00:00:00 2001 From: Amirreza Alibeigi Date: Tue, 23 Jun 2026 16:01:40 +0200 Subject: [PATCH 20/29] fix: normalize backslashes to forward slashes in feature_directory before path ops --- extensions/agent-context/scripts/bash/update-agent-context.sh | 2 ++ 1 file 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 589b118c1b..a2a6a907b6 100755 --- a/extensions/agent-context/scripts/bash/update-agent-context.sh +++ b/extensions/agent-context/scripts/bash/update-agent-context.sh @@ -216,6 +216,8 @@ except Exception: print("") PY )" + # Normalize backslashes (written by PS on Windows) to forward slashes before path ops. + _feature_dir="$(printf '%s' "$_feature_dir" | tr '\\' '/')" _feature_dir="${_feature_dir%/}" if [[ -n "$_feature_dir" ]]; then # feature_directory may be relative or absolute (absolute paths outside PROJECT_ROOT From 0cf914d19c09aa62583f5760f62c84a8f514d689 Mon Sep 17 00:00:00 2001 From: Amirreza Alibeigi Date: Thu, 25 Jun 2026 11:32:35 +0200 Subject: [PATCH 21/29] fix: treat drive-qualified paths (C:/...) as absolute after backslash normalization --- 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 a2a6a907b6..31120ba85b 100755 --- a/extensions/agent-context/scripts/bash/update-agent-context.sh +++ b/extensions/agent-context/scripts/bash/update-agent-context.sh @@ -222,7 +222,8 @@ 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 + # Also match drive-qualified paths (C:/...) written by PowerShell on Windows. + if [[ "$_feature_dir" == /* ]] || [[ "$_feature_dir" =~ ^[A-Za-z]:/ ]]; then _candidate="$_feature_dir/plan.md" else _candidate="$PROJECT_ROOT/$_feature_dir/plan.md" From d98c55e7c779b58ddd584f0d86bfdabf9205c7fe Mon Sep 17 00:00:00 2001 From: Amirreza Alibeigi Date: Thu, 25 Jun 2026 15:01:00 +0200 Subject: [PATCH 22/29] fix: resolve symlinks when computing relative plan path; use UTF8 encoding in PS ConvertFrom-Yaml path --- .../scripts/bash/update-agent-context.sh | 19 +++++++++++++------ .../powershell/update-agent-context.ps1 | 2 +- 2 files changed, 14 insertions(+), 7 deletions(-) diff --git a/extensions/agent-context/scripts/bash/update-agent-context.sh b/extensions/agent-context/scripts/bash/update-agent-context.sh index 31120ba85b..c4d6a43586 100755 --- a/extensions/agent-context/scripts/bash/update-agent-context.sh +++ b/extensions/agent-context/scripts/bash/update-agent-context.sh @@ -229,12 +229,19 @@ PY _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 + # Resolve symlinks before comparing so paths like /var/… vs /private/var/… + # (macOS) are treated as equivalent. Mirrors the mtime-fallback approach. + PLAN_PATH="$("$_python" - "$PROJECT_ROOT" "$_candidate" <<'PY' +import sys +from pathlib import Path, PurePosixPath +root = Path(sys.argv[1]).resolve() +cand = Path(sys.argv[2]).resolve() +try: + print(str(PurePosixPath(cand.relative_to(root)))) +except ValueError: + print(str(PurePosixPath(cand))) +PY +)" 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 a02f95f536..10fdd93924 100644 --- a/extensions/agent-context/scripts/powershell/update-agent-context.ps1 +++ b/extensions/agent-context/scripts/powershell/update-agent-context.ps1 @@ -130,7 +130,7 @@ if (-not (Test-Path -LiteralPath $ExtConfig)) { $Options = $null if (Get-Command ConvertFrom-Yaml -ErrorAction SilentlyContinue) { try { - $Options = Get-Content -LiteralPath $ExtConfig -Raw | ConvertFrom-Yaml -ErrorAction Stop + $Options = Get-Content -LiteralPath $ExtConfig -Raw -Encoding UTF8 | ConvertFrom-Yaml -ErrorAction Stop } catch { # fall through to Python fallback } From d16e4853cd7b27d41989ae0d80573a22dab4779a Mon Sep 17 00:00:00 2001 From: Amirreza Alibeigi Date: Thu, 25 Jun 2026 15:27:50 +0200 Subject: [PATCH 23/29] fix: use bash-side path for outside-root case to avoid WindowsPath backslashes --- extensions/agent-context/scripts/bash/update-agent-context.sh | 4 +++- 1 file changed, 3 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 c4d6a43586..27fa1469be 100755 --- a/extensions/agent-context/scripts/bash/update-agent-context.sh +++ b/extensions/agent-context/scripts/bash/update-agent-context.sh @@ -239,7 +239,9 @@ cand = Path(sys.argv[2]).resolve() try: print(str(PurePosixPath(cand.relative_to(root)))) except ValueError: - print(str(PurePosixPath(cand))) + # Outside project root: use the bash-side path (already forward-slash + # normalised via tr), not cand which may be a WindowsPath with backslashes. + print(sys.argv[2]) PY )" fi From 3c67851c2146a2ec9653b4a19ce31934ae1ac79d Mon Sep 17 00:00:00 2001 From: Amirreza Alibeigi Date: Thu, 25 Jun 2026 15:47:10 +0200 Subject: [PATCH 24/29] fix: use .as_posix() instead of PurePosixPath() to avoid backslashes on native Windows Python --- extensions/agent-context/scripts/bash/update-agent-context.sh | 4 ++-- 1 file changed, 2 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 27fa1469be..2bdf7d683d 100755 --- a/extensions/agent-context/scripts/bash/update-agent-context.sh +++ b/extensions/agent-context/scripts/bash/update-agent-context.sh @@ -237,7 +237,7 @@ from pathlib import Path, PurePosixPath root = Path(sys.argv[1]).resolve() cand = Path(sys.argv[2]).resolve() try: - print(str(PurePosixPath(cand.relative_to(root)))) + print(cand.relative_to(root).as_posix()) except ValueError: # Outside project root: use the bash-side path (already forward-slash # normalised via tr), not cand which may be a WindowsPath with backslashes. @@ -264,7 +264,7 @@ plans = sorted( ) if plans: try: - print(str(PurePosixPath(plans[0].relative_to(root)))) + print(plans[0].relative_to(root).as_posix()) except ValueError: print("") else: From 91b672e8e4d34b7f3f85c884b7ebd93142321db5 Mon Sep 17 00:00:00 2001 From: Amirreza Alibeigi Date: Thu, 25 Jun 2026 16:09:45 +0200 Subject: [PATCH 25/29] fix: resolve ./.. segments in PS feature_directory via GetFullPath before relativizing --- .../powershell/update-agent-context.ps1 | 25 ++++++++++--------- 1 file changed, 13 insertions(+), 12 deletions(-) diff --git a/extensions/agent-context/scripts/powershell/update-agent-context.ps1 b/extensions/agent-context/scripts/powershell/update-agent-context.ps1 index 10fdd93924..e768cfc8a5 100644 --- a/extensions/agent-context/scripts/powershell/update-agent-context.ps1 +++ b/extensions/agent-context/scripts/powershell/update-agent-context.ps1 @@ -315,19 +315,20 @@ if (-not $PlanPath) { $candidatePlan = Join-Path (Join-Path $ProjectRoot $featureDir) 'plan.md' } if (Test-Path -LiteralPath $candidatePlan) { - # Normalize absolute feature paths to project-relative (mirrors bash behavior). - # Use case-insensitive comparison on Windows only (matches common.ps1 pattern). - $relDir = $featureDir - if ([System.IO.Path]::IsPathRooted($featureDir)) { - $normRoot = $ProjectRoot.TrimEnd('\', '/') + [System.IO.Path]::DirectorySeparatorChar - $normDir = $featureDir.Replace('/', [System.IO.Path]::DirectorySeparatorChar) - if ($null -ne $IsWindows) { $onWin = $IsWindows } else { $onWin = $true } - $cmp = if ($onWin) { [System.StringComparison]::OrdinalIgnoreCase } else { [System.StringComparison]::Ordinal } - if ($normDir.StartsWith($normRoot, $cmp)) { - $relDir = $normDir.Substring($normRoot.Length) - } + # Resolve ./ .. segments before relativizing (mirrors bash Path.resolve()). + # GetFullPath is available in .NET Framework 4.x (PS 5.1 compatible). + $resolvedPlan = [System.IO.Path]::GetFullPath($candidatePlan) + $resolvedDir = [System.IO.Path]::GetDirectoryName($resolvedPlan) + $normRoot = $ProjectRoot.TrimEnd('\', '/') + [System.IO.Path]::DirectorySeparatorChar + $normDir = $resolvedDir.TrimEnd('\', '/') + [System.IO.Path]::DirectorySeparatorChar + if ($null -ne $IsWindows) { $onWin = $IsWindows } else { $onWin = $true } + $cmp = if ($onWin) { [System.StringComparison]::OrdinalIgnoreCase } else { [System.StringComparison]::Ordinal } + if ($normDir.StartsWith($normRoot, $cmp)) { + $relDir = $normDir.Substring($normRoot.Length).TrimEnd('\', '/') + $PlanPath = $relDir.Replace('\', '/') + '/plan.md' + } else { + $PlanPath = $resolvedPlan.Replace('\', '/') } - $PlanPath = $relDir.Replace('\', '/') + '/plan.md' } } } catch { From 45cd96546e7afc6eda1884a3ed0c8bf348310029 Mon Sep 17 00:00:00 2001 From: Amirreza Alibeigi Date: Thu, 25 Jun 2026 16:45:28 +0200 Subject: [PATCH 26/29] fix: replace $IsWindows guard with OSVersion.Platform check for PS 5.1 StrictMode compat --- .../scripts/powershell/update-agent-context.ps1 | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/extensions/agent-context/scripts/powershell/update-agent-context.ps1 b/extensions/agent-context/scripts/powershell/update-agent-context.ps1 index e768cfc8a5..76588d1fe6 100644 --- a/extensions/agent-context/scripts/powershell/update-agent-context.ps1 +++ b/extensions/agent-context/scripts/powershell/update-agent-context.ps1 @@ -321,8 +321,7 @@ if (-not $PlanPath) { $resolvedDir = [System.IO.Path]::GetDirectoryName($resolvedPlan) $normRoot = $ProjectRoot.TrimEnd('\', '/') + [System.IO.Path]::DirectorySeparatorChar $normDir = $resolvedDir.TrimEnd('\', '/') + [System.IO.Path]::DirectorySeparatorChar - if ($null -ne $IsWindows) { $onWin = $IsWindows } else { $onWin = $true } - $cmp = if ($onWin) { [System.StringComparison]::OrdinalIgnoreCase } else { [System.StringComparison]::Ordinal } + $cmp = if ([System.Environment]::OSVersion.Platform -eq [System.PlatformID]::Win32NT) { [System.StringComparison]::OrdinalIgnoreCase } else { [System.StringComparison]::Ordinal } if ($normDir.StartsWith($normRoot, $cmp)) { $relDir = $normDir.Substring($normRoot.Length).TrimEnd('\', '/') $PlanPath = $relDir.Replace('\', '/') + '/plan.md' @@ -350,8 +349,7 @@ if (-not $PlanPath) { # Use case-insensitive comparison on Windows only (matches common.ps1 pattern). $fullPath = $candidate.FullName.Replace('\', '/') $normRoot = $ProjectRoot.Replace('\', '/').TrimEnd('/') + '/' - if ($null -ne $IsWindows) { $onWin = $IsWindows } else { $onWin = $true } - $cmp = if ($onWin) { [System.StringComparison]::OrdinalIgnoreCase } else { [System.StringComparison]::Ordinal } + $cmp = if ([System.Environment]::OSVersion.Platform -eq [System.PlatformID]::Win32NT) { [System.StringComparison]::OrdinalIgnoreCase } else { [System.StringComparison]::Ordinal } if ($fullPath.StartsWith($normRoot, $cmp)) { $PlanPath = $fullPath.Substring($normRoot.Length) } else { From b153b6640df136f986bd754a5ba70a065484992f Mon Sep 17 00:00:00 2001 From: Amirreza Alibeigi Date: Thu, 25 Jun 2026 17:20:50 +0200 Subject: [PATCH 27/29] fix: guard empty relDir to avoid leading slash in PlanPath when feature_directory is project root --- .../agent-context/scripts/powershell/update-agent-context.ps1 | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/extensions/agent-context/scripts/powershell/update-agent-context.ps1 b/extensions/agent-context/scripts/powershell/update-agent-context.ps1 index 76588d1fe6..50d59ee665 100644 --- a/extensions/agent-context/scripts/powershell/update-agent-context.ps1 +++ b/extensions/agent-context/scripts/powershell/update-agent-context.ps1 @@ -324,7 +324,7 @@ if (-not $PlanPath) { $cmp = if ([System.Environment]::OSVersion.Platform -eq [System.PlatformID]::Win32NT) { [System.StringComparison]::OrdinalIgnoreCase } else { [System.StringComparison]::Ordinal } if ($normDir.StartsWith($normRoot, $cmp)) { $relDir = $normDir.Substring($normRoot.Length).TrimEnd('\', '/') - $PlanPath = $relDir.Replace('\', '/') + '/plan.md' + $PlanPath = if ($relDir) { $relDir.Replace('\', '/') + '/plan.md' } else { 'plan.md' } } else { $PlanPath = $resolvedPlan.Replace('\', '/') } From e95f5213032bf24457ce2a805b91ae919ce54a24 Mon Sep 17 00:00:00 2001 From: Amirreza Alibeigi Date: Fri, 26 Jun 2026 10:16:49 +0200 Subject: [PATCH 28/29] fix: remove unused PurePosixPath import; fix stale PS comment after ConvertFrom-Json fallback was added --- extensions/agent-context/scripts/bash/update-agent-context.sh | 4 ++-- .../agent-context/scripts/powershell/update-agent-context.ps1 | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/extensions/agent-context/scripts/bash/update-agent-context.sh b/extensions/agent-context/scripts/bash/update-agent-context.sh index 2bdf7d683d..159469fa99 100755 --- a/extensions/agent-context/scripts/bash/update-agent-context.sh +++ b/extensions/agent-context/scripts/bash/update-agent-context.sh @@ -233,7 +233,7 @@ PY # (macOS) are treated as equivalent. Mirrors the mtime-fallback approach. PLAN_PATH="$("$_python" - "$PROJECT_ROOT" "$_candidate" <<'PY' import sys -from pathlib import Path, PurePosixPath +from pathlib import Path root = Path(sys.argv[1]).resolve() cand = Path(sys.argv[2]).resolve() try: @@ -254,7 +254,7 @@ PY if [[ -z "$PLAN_PATH" ]]; then _plan_rel="$("$_python" - "$PROJECT_ROOT" <<'PY' import sys -from pathlib import Path, PurePosixPath +from pathlib import Path root = Path(sys.argv[1]).resolve() specs = root / "specs" plans = sorted( diff --git a/extensions/agent-context/scripts/powershell/update-agent-context.ps1 b/extensions/agent-context/scripts/powershell/update-agent-context.ps1 index 50d59ee665..da9ff443cb 100644 --- a/extensions/agent-context/scripts/powershell/update-agent-context.ps1 +++ b/extensions/agent-context/scripts/powershell/update-agent-context.ps1 @@ -132,7 +132,7 @@ if (Get-Command ConvertFrom-Yaml -ErrorAction SilentlyContinue) { try { $Options = Get-Content -LiteralPath $ExtConfig -Raw -Encoding UTF8 | ConvertFrom-Yaml -ErrorAction Stop } catch { - # fall through to Python fallback + # fall through to ConvertFrom-Json fallback } } From 9695b2cab29ce0caf5a364cae7db8f80e911b61e Mon Sep 17 00:00:00 2001 From: Amirreza Alibeigi Date: Fri, 26 Jun 2026 10:30:47 +0200 Subject: [PATCH 29/29] fix: use cand.as_posix() for outside-root path instead of raw bash-side argv --- .../agent-context/scripts/bash/update-agent-context.sh | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/extensions/agent-context/scripts/bash/update-agent-context.sh b/extensions/agent-context/scripts/bash/update-agent-context.sh index 159469fa99..64e1bae89b 100755 --- a/extensions/agent-context/scripts/bash/update-agent-context.sh +++ b/extensions/agent-context/scripts/bash/update-agent-context.sh @@ -239,9 +239,9 @@ cand = Path(sys.argv[2]).resolve() try: print(cand.relative_to(root).as_posix()) except ValueError: - # Outside project root: use the bash-side path (already forward-slash - # normalised via tr), not cand which may be a WindowsPath with backslashes. - print(sys.argv[2]) + # Outside project root: emit the resolved path in POSIX form. + # as_posix() converts backslashes correctly on native Windows Python. + print(cand.as_posix()) PY )" fi