Skip to content

[Bug]: update-agent-context.sh:123-137 #3067

@amirreza225

Description

@amirreza225

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_planupdate-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/:

  1. Run /speckit-specify → creates specs/001-foo/ and writes .specify/feature.json
  2. Touch or edit an older specs/000-bar/plan.md (simulates prior work)
  3. Run /speckit-plan → after_plan hook calls update-agent-context.sh
  4. 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"

Metadata

Metadata

Assignees

No one assigned

    Type

    No type
    No fields configured for issues without a type.

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions