Extract agent context updates into bundled agent-context extension#2546
Extract agent context updates into bundled agent-context extension#2546Copilot wants to merge 20 commits into
Conversation
Co-authored-by: Copilot Autofix powered by AI <223894421+github-code-quality[bot]@users.noreply.github.com>
There was a problem hiding this comment.
Pull request overview
This PR refactors agent-context-file management (the <!-- SPECKIT START --> / <!-- SPECKIT END --> block injected into files like CLAUDE.md) out of IntegrationBase and into a new bundled agent-context extension. Configuration now flows through .specify/init-options.json (context_file, context_markers), giving users opt-out (specify extension disable agent-context) and customizable markers, with parallel bash/PowerShell scripts that mirror the Python upsert logic.
Changes:
- Adds a bundled
agent-contextextension (manifest, README, command, bash + PowerShell scripts) and registers it inextensions/catalog.json. - Extends
IntegrationBasewith_resolve_context_markers()and_agent_context_extension_enabled(), gating bothupsert_context_section()andremove_context_section()on the registry, and seeds/clearscontext_markersininit-options.jsonfromspecify_cli.__init__. - Updates
AGENTS.mdand adds 25 tests covering layout, marker resolution, custom-marker upsert/remove, the disabled-gate, and init-options writers.
Show a summary per file
| File | Description |
|---|---|
src/specify_cli/integrations/base.py |
New marker-resolution + extension-enabled helpers; upsert/remove use them. |
src/specify_cli/__init__.py |
New tracker step that auto-installs bundled agent-context; seeds/clears context_markers. |
extensions/catalog.json |
Registers agent-context as bundled: true. |
extensions/agent-context/extension.yml |
Extension manifest with after_specify / after_plan hooks. |
extensions/agent-context/README.md |
User-facing docs for opt-out and configuration. |
extensions/agent-context/commands/speckit.agent-context.update.md |
Slash-command spec referencing the bash/ps1 scripts. |
extensions/agent-context/scripts/bash/update-agent-context.sh |
Bash hook that re-renders the managed section. |
extensions/agent-context/scripts/powershell/update-agent-context.ps1 |
PowerShell counterpart. |
AGENTS.md |
Removes wrapper-script guidance; documents the new init-options.json flow and opt-out. |
tests/extensions/test_extension_agent_context.py |
25 new tests for the extension and plumbing. |
Copilot's findings
Tip
Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
- Files reviewed: 10/10 changed files
- Comments generated: 7
Co-authored-by: Copilot Autofix powered by AI <223894421+github-code-quality[bot]@users.noreply.github.com>
- bash: parse init-options.json with a single python3 invocation instead of three separate read_json_field calls, for parity with the PowerShell ConvertFrom-Json approach and to avoid divergent error semantics - bash: use parameter expansion to strip PROJECT_ROOT prefix from plan path instead of sed interpolation, avoiding special-character fragility - powershell: limit Get-ChildItem to -Depth 1 so plan.md discovery matches the bash glob specs/*/plan.md (one level deep) — fixes cross-platform inconsistency with nested plan.md files - powershell: replace Substring+Length relative-path with [System.IO.Path]::GetRelativePath for robustness across case/PSDrive differences - __init__.py: move agent-context extension install to after save_init_options so init-options.json is present when hooks run - __init__.py: seed context_markers in init-options only when context_file is truthy; avoids noise for integrations without a context file - integrations/base.py: narrow blanket except Exception in _resolve_context_markers to ImportError / (OSError, ValueError) so unexpected bugs surface instead of being silently swallowed
…context_file Apply the same gating logic used during `specify init`: only write context_markers to init-options.json when the integration actually has a context_file set. When switching to an integration without a context file the stale markers are removed, keeping the two init paths consistent.
Applied all three actionable fixes from that review thread:
Commits: the changes are in the two most recent commits on this branch. |
Co-authored-by: Copilot Autofix powered by AI <223894421+github-code-quality[bot]@users.noreply.github.com>
Co-authored-by: Copilot Autofix powered by AI <223894421+github-code-quality[bot]@users.noreply.github.com>
| finally: | ||
| # Always write context_file into the extension config so the | ||
| # shell scripts work even if the extension install itself | ||
| # failed (e.g. dev pre-release version mismatch in CI). | ||
| # User-customised markers are preserved. | ||
| if _ac_bundled is not None: | ||
| try: | ||
| _update_agent_context_config_file( | ||
| project_path, | ||
| resolved_integration.context_file, | ||
| preserve_markers=True, | ||
| ) | ||
| if _ac_err_msg is not None: | ||
| # Config was written despite the failed install; | ||
| # the Python context-section plumbing remains active. | ||
| _ac_err_msg += "; config written, Python context plumbing active" | ||
| except Exception as cfg_err: | ||
| sanitized_cfg = str(cfg_err).replace('\n', ' ').strip() | ||
| cfg_msg = f"config update failed: {sanitized_cfg[:120]}" | ||
| if _ac_err_msg is not None: | ||
| _ac_err_msg += f"; {cfg_msg}" | ||
| else: | ||
| _ac_err_msg = cfg_msg | ||
| if _ac_err_msg is not None: | ||
| tracker.error("agent-context", _ac_err_msg) |
| # Installed AFTER init-options are saved so hooks can read from | ||
| # the project. After install, the extension config is updated | ||
| # with the active integration's context_file. |
| except (ImportError, OSError, yaml.YAMLError): | ||
| # Best-effort read: ignore extension config load/parse errors and | ||
| # fall back to init-options.json context_file below. |
| if opts.get("integration") == integration_key or opts.get("ai") == integration_key: | ||
| opts.pop("integration", None) | ||
| opts.pop("ai", None) | ||
| opts.pop("ai_skills", None) | ||
| opts.pop("context_file", None) | ||
| save_init_options(project_root, opts) | ||
| # Clear context_file in the extension config too. | ||
| ext_cfg_path = project_root / _AGENT_CTX_EXT_CONFIG | ||
| if ext_cfg_path.exists(): | ||
| _update_agent_context_config_file( | ||
| project_root, "", preserve_markers=True | ||
| ) | ||
| elif has_legacy_context_keys: | ||
| save_init_options(project_root, opts) |
| IFS= read -r CONTEXT_FILE | ||
| IFS= read -r MARKER_START | ||
| IFS= read -r MARKER_END |
| try { | ||
| $Options = Get-Content -LiteralPath $ExtConfig -Raw | ConvertFrom-Yaml -ErrorAction Stop | ||
| } catch { | ||
| # ConvertFrom-Yaml may not be available on all systems. | ||
| # Fall back to Python+PyYAML for consistent parsing semantics. | ||
| $pythonCmd = $null | ||
| foreach ($candidate in @('python3', 'python')) { | ||
| if (Get-Command $candidate -ErrorAction SilentlyContinue) { | ||
| $pythonCmd = $candidate | ||
| break | ||
| } | ||
| } | ||
|
|
||
| if ($pythonCmd) { | ||
| try { | ||
| $jsonOut = & $pythonCmd -c @' | ||
| import json | ||
| import sys | ||
| try: | ||
| import yaml | ||
| except ImportError: | ||
| print( | ||
| "agent-context: PyYAML is required to parse extension config; cannot update context.", | ||
| 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 = {} | ||
|
|
||
| print(json.dumps(data)) | ||
| '@ $ExtConfig | ||
| if ($LASTEXITCODE -eq 0 -and $jsonOut) { | ||
| $Options = $jsonOut | ConvertFrom-Json -ErrorAction Stop | ||
| } | ||
| } catch { | ||
| $Options = $null | ||
| } | ||
| } | ||
|
|
||
| if (-not $Options) { | ||
| Write-Warning "agent-context: unable to parse $ExtConfig; skipping update." | ||
| exit 0 | ||
| } | ||
| } | ||
|
|
||
| $ContextFile = Get-ConfigValue -Object $Options -Key 'context_file' | ||
| if (-not $ContextFile) { | ||
| Write-Host 'agent-context: context_file not set in extension config; nothing to do.' | ||
| exit 0 | ||
| } |
|
@copilot apply changes based on the comments in this thread |
Applied the latest review-thread changes in commits This includes:
|
| except ImportError: | ||
| # Best-effort read: if the helper cannot be imported (e.g. during | ||
| # circular import setup), fall back to init-options.json below. |
| # Clear context_file in the extension config too. If the config file | ||
| # does not exist yet, create it so no stale target can persist. | ||
| _update_agent_context_config_file( | ||
| project_root, "", preserve_markers=True | ||
| ) |
| 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 |
| try { | ||
| $Options = Get-Content -LiteralPath $ExtConfig -Raw | ConvertFrom-Yaml -ErrorAction Stop | ||
| } catch { | ||
| # ConvertFrom-Yaml may not be available on all systems. | ||
| # Fall back to Python+PyYAML for consistent parsing semantics. | ||
| $pythonCmd = $null | ||
| foreach ($candidate in @('python3', 'python')) { | ||
| if (Get-Command $candidate -ErrorAction SilentlyContinue) { | ||
| $pythonCmd = $candidate | ||
| break | ||
| } | ||
| } | ||
|
|
||
| if ($pythonCmd) { | ||
| try { | ||
| $jsonOut = & $pythonCmd -c @' | ||
| import json | ||
| import sys | ||
| try: | ||
| import yaml | ||
| except ImportError: | ||
| print( | ||
| "agent-context: PyYAML is required to parse extension config; cannot update context.", | ||
| 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 = {} | ||
|
|
||
| print(json.dumps(data)) | ||
| '@ $ExtConfig | ||
| if ($LASTEXITCODE -eq 0 -and $jsonOut) { | ||
| $Options = $jsonOut | ConvertFrom-Json -ErrorAction Stop | ||
| } | ||
| } catch { | ||
| $Options = $null | ||
| } | ||
| } | ||
|
|
||
| if (-not $Options) { | ||
| Write-Warning "agent-context: unable to parse $ExtConfig; skipping update." | ||
| exit 0 | ||
| } | ||
| } |
|
@copilot apply changes based on the comments in this thread |
…n, explicit ConvertFrom-Yaml check
Applied all four changes in commits
|
New Feature
Coding agent context file management (
<!-- SPECKIT START -->/<!-- SPECKIT END -->injection intoCLAUDE.md,.github/copilot-instructions.md, etc.) was hardcoded intoIntegrationBase, with no way to opt out or customize markers. This change moves that behavior into a bundledagent-contextextension driven by.specify/init-options.json.What does this feature do?
Adds
extensions/agent-context/(bundled: true, opt-out) that owns the lifecycle of the managed context section. Both the Python paths and the new shell/PowerShell scripts readcontext_fileandcontext_markersfrom.specify/init-options.json— single source of truth, no per-agent logic, user-customizable markers.{ "context_file": "CLAUDE.md", "context_markers": { "start": "<!-- SPECKIT START -->", "end": "<!-- SPECKIT END -->" } }Implementation details
integrations/base.py_resolve_context_markers(project_root)— readscontext_markersfrominit-options.json, falls back toCONTEXT_MARKER_START/CONTEXT_MARKER_ENDconstants per side._agent_context_extension_enabled(project_root)— reads.specify/extensions/.registry; returnsTruewhen registry/entry absent (backwards compat) orenabled != false.upsert_context_section()/remove_context_section()now use the resolved markers and short-circuit when the extension is disabled.specify_cli/__init__.pyspecify initseedscontext_markersdefaults intoinit_optsfrom the class constants._update_init_options_for_integration()seeds defaults while preserving any user-customized markers._clear_init_options_for_integration()popscontext_markersalongsidecontext_file.specify init(parallel to thegitextension flow).extensions/agent-context/—extension.yml(idagent-context, hooksafter_specify/after_plan),README.md,commands/speckit.agent-context.update.md, and bash + PowerShellupdate-agent-contextscripts. Both scripts parseinit-options.json, resolve markers (with default fallback), auto-detect the most recentspecs/*/plan.mdwhen no plan path is supplied, and perform the same upsert algorithm as the Python path (CRLF normalization, BOM strip, marker-corruption recovery).extensions/catalog.json—agent-contextregistered asbundled: true, alphabetized first.AGENTS.md— wrapper-script and dispatcher-script guidance removed; replaced with theinit-options.jsonflow and thespecify extension disable agent-contextopt-out.tests/extensions/test_extension_agent_context.py— 25 tests covering extension layout, catalog entry, marker resolution (defaults / custom / partial / invalid), upsert+remove with custom markers, the disabled-extension gate (upsertreturnsNone,removereturnsFalseand leaves file untouched), andinit-options.jsonwriters.Backwards compatibility
init-options.jsonor missingcontext_markers→ class-constant defaults, identical to current behavior.agent-contextentry → treated as enabled, so existing projects continue to receive context updates without re-init.IntegrationBase.