Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
27 commits
Select commit Hold shift + click to select a range
c13dfbc
fix: derive plan path from feature.json in update-agent-context
amirreza225 Jun 19, 2026
0710d12
fix: address Copilot review feedback on update-agent-context
amirreza225 Jun 19, 2026
6f302a3
Potential fix for pull request finding
amirreza225 Jun 19, 2026
780d19a
test: replace time.sleep with os.utime and strengthen PS normalizatio…
amirreza225 Jun 19, 2026
1a723d4
Apply suggestions from code review
amirreza225 Jun 19, 2026
5d30c89
Potential fix for pull request finding
amirreza225 Jun 19, 2026
9dc0172
fix: normalize trailing slash and guard non-string feature_directory …
amirreza225 Jun 19, 2026
19d475d
Fix: use .resolve().as_posix().
amirreza225 Jun 19, 2026
8de30cc
fix: use context manager for feature.json open() in bash heredoc
amirreza225 Jun 19, 2026
85ed168
test: add PS coverage for absolute feature_directory outside project …
amirreza225 Jun 19, 2026
900b98f
fix: guard null feature_directory, re-check empty after trailing-slas…
amirreza225 Jun 19, 2026
90cf965
test: add stale plan to absolute-path tests so feature.json preferenc…
amirreza225 Jun 19, 2026
ad51464
test: convert absolute paths to MSYS2 style for Git-for-Windows bash …
amirreza225 Jun 19, 2026
a73e611
fix: revert PS test to native path, fix bash outside-root assertion f…
amirreza225 Jun 19, 2026
1e75990
fix: use _to_bash_path in not-in assertion for Git bash Windows compat
amirreza225 Jun 19, 2026
5f7a1d9
fix: add ConvertFrom-Json fallback in PS script, write test config as…
amirreza225 Jun 23, 2026
db5e8ac
fix: use OS-appropriate StringComparison in PS prefix-strip (matches …
amirreza225 Jun 23, 2026
92ad472
fix: emit project-relative POSIX path from mtime fallback; use upstre…
amirreza225 Jun 23, 2026
2e8f99e
fix: write config as JSON directly, drop _install_agent_context_config
amirreza225 Jun 23, 2026
94f714b
fix: normalize backslashes to forward slashes in feature_directory be…
amirreza225 Jun 23, 2026
0cf914d
fix: treat drive-qualified paths (C:/...) as absolute after backslash…
amirreza225 Jun 25, 2026
d98c55e
fix: resolve symlinks when computing relative plan path; use UTF8 enc…
amirreza225 Jun 25, 2026
d16e485
fix: use bash-side path for outside-root case to avoid WindowsPath ba…
amirreza225 Jun 25, 2026
3c67851
fix: use .as_posix() instead of PurePosixPath() to avoid backslashes …
amirreza225 Jun 25, 2026
91b672e
fix: resolve ./.. segments in PS feature_directory via GetFullPath be…
amirreza225 Jun 25, 2026
45cd965
fix: replace $IsWindows guard with OSVersion.Platform check for PS 5.…
amirreza225 Jun 25, 2026
b153b66
fix: guard empty relDir to avoid leading slash in PlanPath when featu…
amirreza225 Jun 25, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
81 changes: 68 additions & 13 deletions extensions/agent-context/scripts/bash/update-agent-context.sh
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -202,23 +202,78 @@ unset _cf_parts _seg

PLAN_PATH="${1:-}"
if [[ -z "$PLAN_PATH" ]]; then
# Pick the most recently modified plan.md one level deep (specs/<feature>/plan.md).
# Use find + sort by modification time to avoid ls/head fragility with
# spaces in paths or SIGPIPE from pipefail.
_plan_abs="$("$_python" - "$PROJECT_ROOT" <<'PY'
import sys, os
from pathlib import Path
specs = Path(sys.argv[1]) / "specs"
# Prefer .specify/feature.json (written by /speckit-specify) over mtime heuristic.
_feature_json="$PROJECT_ROOT/.specify/feature.json"
if [[ -f "$_feature_json" ]]; then
_feature_dir="$("$_python" - "$_feature_json" <<'PY'
import sys, json
try:
with open(sys.argv[1], encoding="utf-8") as fh:
d = json.load(fh)
val = d.get("feature_directory", "")
print(val if isinstance(val, str) else "")
except Exception:
print("")
PY
)"
# 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
# are preserved as-is by _persist_feature_json in common.sh).
# 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"
fi
if [[ -f "$_candidate" ]]; then
# 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()
Comment on lines +235 to +237
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])
PY
)"
fi
fi
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_rel="$("$_python" - "$PROJECT_ROOT" <<'PY'
import sys
from pathlib import Path, PurePosixPath
root = Path(sys.argv[1]).resolve()
Comment on lines +256 to +258
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(plans[0].relative_to(root).as_posix())
except ValueError:
Comment thread
mnriem marked this conversation as resolved.
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

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down Expand Up @@ -126,14 +130,26 @@ 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
}
Comment on lines +133 to 136
}

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
Comment thread
mnriem marked this conversation as resolved.
$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) {
Expand Down Expand Up @@ -280,21 +296,69 @@ if ($cm) {
}

if (-not $PlanPath) {
# Discover plan.md exactly one level deep (specs/<feature>/plan.md),
# matching the bash glob specs/*/plan.md. Wrap in try/catch so access errors under
# $ErrorActionPreference = 'Stop' don't abort the script.
try {
$specsDir = Join-Path $ProjectRoot 'specs'
$candidate = Get-ChildItem -Path $specsDir -Directory -ErrorAction SilentlyContinue |
ForEach-Object { Get-Item -LiteralPath (Join-Path $_.FullName 'plan.md') -ErrorAction SilentlyContinue } |
Where-Object { $_ } |
Sort-Object LastWriteTime -Descending |
Select-Object -First 1
if ($candidate) {
$PlanPath = [System.IO.Path]::GetRelativePath($ProjectRoot, $candidate.FullName).Replace('\','/')
# Prefer .specify/feature.json (written by /speckit-specify) over mtime heuristic.
$FeatureJson = Join-Path $ProjectRoot '.specify/feature.json'
if (Test-Path -LiteralPath $FeatureJson) {
try {
$fj = Get-Content -LiteralPath $FeatureJson -Raw -Encoding UTF8 | ConvertFrom-Json
$featureDir = $fj.feature_directory
if ($featureDir -isnot [string] -or -not $featureDir) {
$featureDir = $null
} else {
$featureDir = $featureDir.TrimEnd('\', '/')
}
if ($featureDir) {
# Join-Path on Unix does not treat absolute ChildPath as "wins"; check explicitly.
if ([System.IO.Path]::IsPathRooted($featureDir)) {
$candidatePlan = Join-Path $featureDir 'plan.md'
} else {
$candidatePlan = Join-Path (Join-Path $ProjectRoot $featureDir) 'plan.md'
}
if (Test-Path -LiteralPath $candidatePlan) {
# 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
$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 = if ($relDir) { $relDir.Replace('\', '/') + '/plan.md' } else { 'plan.md' }
} else {
$PlanPath = $resolvedPlan.Replace('\', '/')
}
}
}
} catch {
# Non-fatal: fall through to mtime heuristic.
}
}

# Fall back to mtime only when feature.json is absent or its plan does not exist yet.
if (-not $PlanPath) {
try {
$specsDir = Join-Path $ProjectRoot 'specs'
$candidate = Get-ChildItem -Path $specsDir -Directory -ErrorAction SilentlyContinue |
ForEach-Object { Get-Item -LiteralPath (Join-Path $_.FullName 'plan.md') -ErrorAction SilentlyContinue } |
Where-Object { $_ } |
Sort-Object LastWriteTime -Descending |
Select-Object -First 1
if ($candidate) {
# GetRelativePath is .NET 5+ only; strip prefix manually for PS 5.1 compat.
# Use case-insensitive comparison on Windows only (matches common.ps1 pattern).
$fullPath = $candidate.FullName.Replace('\', '/')
$normRoot = $ProjectRoot.Replace('\', '/').TrimEnd('/') + '/'
$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 {
$PlanPath = $fullPath
}
}
} catch {
# Non-fatal: continue without a plan path.
}
} catch {
# Non-fatal: continue without a plan path.
}
}

Expand Down
Loading