Bug Description
Derive the active plan from feature.json instead of mtime. When after_specify fires before the current feature has touched plan.md, or when multiple specs are present, picking the most recently modified specs/*/plan.md can point CLAUDE.md at an unrelated plan. The repo already persists the active feature in .specify/feature.json, so this fallback should use that source (or be a no-op until an explicit plan path is known), rather than guessing based on modification time.
Steps to Reproduce
claude
/speckit-constitution
/speckit-specify
/speckit-plan
/speckit-tasks
Expected Behavior
CLAUDE.md always points to the plan for the currently active feature — the one set by the most recent /speckit-specify run.
Actual Behavior
When /speckit-plan triggers after_plan → update-agent-context.sh with no explicit plan path, the script selects the most recently modified specs/*/plan.md by mtime. If/speckit-specify just created a new spec directory but /speckit-plan has not yet run (so plan.md has not been touched), or if an older spec's plan.md was modified more recently, the script injects the wrong plan path into CLAUDE.md. The active feature is already recorded in .specify/feature.json (written by /speckit-specify), but the script ignores it.
Specify CLI Version
0.11.2
AI Agent
Claude Code
Operating System
Mac OS
Python Version
3.11.15
Error Logs
No error is raised. The failure is silent — CLAUDE.md is silently updated to reference an unrelated plan file.
Additional Context
Reproduced with this workflow on a repo containing multiple specs/:
- Run /speckit-specify → creates specs/001-foo/ and writes .specify/feature.json
- Touch or edit an older specs/000-bar/plan.md (simulates prior work)
- Run /speckit-plan → after_plan hook calls update-agent-context.sh
- CLAUDE.md is updated to reference specs/000-bar/plan.md instead of specs/001-foo/plan.md
Fix applied locally: read .specify/feature.json first; derive plan path from feature_directory field; fall back to mtime only if feature.json is absent or the derived plan.md does not exist on disk. See lines 123–143 of update-agent-context.sh.
#!/usr/bin/env bash
# update-agent-context.sh
#
# Refresh the managed Spec Kit section in the coding agent's context file
# (e.g. CLAUDE.md, .github/copilot-instructions.md, AGENTS.md).
#
# Reads `context_file` and `context_markers.{start,end}` from the
# agent-context extension config:
# .specify/extensions/agent-context/agent-context-config.yml
#
# 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.
set -euo pipefail
PROJECT_ROOT="$(pwd)"
EXT_CONFIG="$PROJECT_ROOT/.specify/extensions/agent-context/agent-context-config.yml"
DEFAULT_START="<!-- SPECKIT START -->"
DEFAULT_END="<!-- SPECKIT END -->"
if [[ ! -f "$EXT_CONFIG" ]]; then
echo "agent-context: $EXT_CONFIG not found; nothing to do." >&2
exit 0
fi
# Locate a suitable Python interpreter (python3, then python).
_python=""
if command -v python3 >/dev/null 2>&1; then
_python="python3"
elif command -v python >/dev/null 2>&1 && python --version 2>&1 | grep -q "^Python 3"; then
_python="python"
fi
if [[ -z "$_python" ]]; then
echo "agent-context: Python 3 not found on PATH; skipping update." >&2
exit 0
fi
# Parse extension config once; emit three newline-separated fields:
# context_file, context_markers.start, context_markers.end
if ! _raw_opts="$("$_python" - "$EXT_CONFIG" <<'PY'
import sys
try:
import yaml
except ImportError:
print(
"agent-context: PyYAML is required to parse extension config but is not available "
"in the current Python environment.\n"
" To resolve: pip install pyyaml (or install it into the environment used by python3).\n"
" Context file will not be updated until PyYAML is importable.",
file=sys.stderr,
)
sys.exit(2)
try:
with open(sys.argv[1], "r", encoding="utf-8") as fh:
data = yaml.safe_load(fh)
except Exception as exc:
print(
f"agent-context: unable to parse {sys.argv[1]} ({exc}); cannot update context.",
file=sys.stderr,
)
sys.exit(2)
if not isinstance(data, dict):
data = {}
def get_str(obj, *keys):
node = obj
for k in keys:
if isinstance(node, dict) and k in node:
node = node[k]
else:
return ""
return node if isinstance(node, str) else ""
print(get_str(data, "context_file"))
print(get_str(data, "context_markers", "start"))
print(get_str(data, "context_markers", "end"))
PY
)"; then
echo "agent-context: skipping update (see above for details)." >&2
exit 0
fi
_opts_lines=()
while IFS= read -r _line || [[ -n "$_line" ]]; do
_opts_lines+=("$_line")
done < <(printf '%s\n' "$_raw_opts")
if (( ${#_opts_lines[@]} < 3 )); then
echo "agent-context: malformed config parser output; expected 3 lines (context_file, marker_start, marker_end), got ${#_opts_lines[@]}; skipping update." >&2
exit 0
fi
CONTEXT_FILE="${_opts_lines[0]}"
MARKER_START="${_opts_lines[1]}"
MARKER_END="${_opts_lines[2]}"
if [[ -z "$CONTEXT_FILE" ]]; then
echo "agent-context: context_file not set in extension config; nothing to do." >&2
exit 0
fi
# Reject absolute paths, backslash separators, and '..' path segments in context_file
if [[ "$CONTEXT_FILE" == /* ]] || [[ "$CONTEXT_FILE" =~ ^[A-Za-z]: ]]; then
echo "agent-context: context_file must be a project-relative path; got '$CONTEXT_FILE'." >&2
exit 1
fi
if [[ "$CONTEXT_FILE" == *\\* ]]; then
echo "agent-context: context_file must not contain backslash separators; got '$CONTEXT_FILE'." >&2
exit 1
fi
IFS='/' read -ra _cf_parts <<< "$CONTEXT_FILE"
for _seg in "${_cf_parts[@]}"; do
if [[ "$_seg" == ".." ]]; then
echo "agent-context: context_file must not contain '..' path segments; got '$CONTEXT_FILE'." >&2
exit 1
fi
done
unset _cf_parts _seg
[[ -z "$MARKER_START" ]] && MARKER_START="$DEFAULT_START"
[[ -z "$MARKER_END" ]] && MARKER_END="$DEFAULT_END"
PLAN_PATH="${1:-}"
if [[ -z "$PLAN_PATH" ]]; then
# 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
_candidate="$PROJECT_ROOT/$_feature_dir/plan.md"
if [[ -f "$_candidate" ]]; then
PLAN_PATH="$_feature_dir/plan.md"
fi
fi
fi
# Fall back to mtime only when feature.json is absent or points to no plan.
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(
specs.glob("*/plan.md"),
key=lambda p: p.stat().st_mtime,
reverse=True,
)
print(plans[0] if plans else "")
PY
)"
if [[ -n "$_plan_abs" ]]; then
PLAN_PATH="${_plan_abs#"$PROJECT_ROOT/"}"
fi
fi
fi
CTX_PATH="$PROJECT_ROOT/$CONTEXT_FILE"
mkdir -p "$(dirname "$CTX_PATH")"
# Build the managed section
TMP_SECTION="$(mktemp)"
trap 'rm -f "$TMP_SECTION"' EXIT
{
echo "$MARKER_START"
echo "For additional context about technologies to be used, project structure,"
echo "shell commands, and other important information, read the current plan"
if [[ -n "$PLAN_PATH" ]]; then
echo "at $PLAN_PATH"
fi
echo "$MARKER_END"
} > "$TMP_SECTION"
"$_python" - "$CTX_PATH" "$MARKER_START" "$MARKER_END" "$TMP_SECTION" <<'PY'
import sys, os
ctx_path, start, end, section_path = sys.argv[1:5]
with open(section_path, "r", encoding="utf-8") as fh:
section = fh.read().rstrip("\n") + "\n"
if os.path.exists(ctx_path):
with open(ctx_path, "r", encoding="utf-8-sig") as fh:
content = fh.read()
s = content.find(start)
e = content.find(end, s if s != -1 else 0)
if s != -1 and e != -1 and e > s:
end_of_marker = e + len(end)
if end_of_marker < len(content) and content[end_of_marker] == "\r":
end_of_marker += 1
if end_of_marker < len(content) and content[end_of_marker] == "\n":
end_of_marker += 1
new_content = content[:s] + section + content[end_of_marker:]
elif s != -1:
new_content = content[:s] + section
elif e != -1:
end_of_marker = e + len(end)
if end_of_marker < len(content) and content[end_of_marker] == "\r":
end_of_marker += 1
if end_of_marker < len(content) and content[end_of_marker] == "\n":
end_of_marker += 1
new_content = section + content[end_of_marker:]
else:
if content and not content.endswith("\n"):
content += "\n"
new_content = (content + "\n" + section) if content else section
else:
new_content = section
new_content = new_content.replace("\r\n", "\n").replace("\r", "\n")
with open(ctx_path, "wb") as fh:
fh.write(new_content.encode("utf-8"))
PY
echo "agent-context: updated $CONTEXT_FILE"
Bug Description
Derive the active plan from feature.json instead of mtime. When
after_specifyfires before the current feature has touchedplan.md, or when multiple specs are present, picking the most recently modifiedspecs/*/plan.mdcan pointCLAUDE.mdat an unrelated plan. The repo already persists the active feature in.specify/feature.json, so this fallback should use that source (or be a no-op until an explicit plan path is known), rather than guessing based on modification time.Steps to Reproduce
Expected Behavior
CLAUDE.mdalways points to the plan for the currently active feature — the one set by the most recent/speckit-specifyrun.Actual Behavior
When
/speckit-plantriggersafter_plan→update-agent-context.shwith no explicit plan path, the script selects the most recently modifiedspecs/*/plan.mdby mtime. If/speckit-specifyjust created a new spec directory but/speckit-planhas not yet run (soplan.mdhas not been touched), or if an older spec'splan.mdwas modified more recently, the script injects the wrong plan path intoCLAUDE.md. The active feature is already recorded in.specify/feature.json(written by/speckit-specify), but the script ignores it.Specify CLI Version
0.11.2
AI Agent
Claude Code
Operating System
Mac OS
Python Version
3.11.15
Error Logs
Additional Context
Reproduced with this workflow on a repo containing multiple specs/:
Fix applied locally: read .specify/feature.json first; derive plan path from feature_directory field; fall back to mtime only if feature.json is absent or the derived plan.md does not exist on disk. See lines 123–143 of update-agent-context.sh.