Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
25 commits
Select commit Hold shift + click to select a range
104b28e
docs: add Spec Kit spec for agent-context full opt-in
mnriem Jun 22, 2026
a867179
docs: add Spec Kit plan artifacts for agent-context full opt-in
mnriem Jun 22, 2026
e6b07a3
docs: correct Constitution Check against ratified v1.0.0
mnriem Jun 22, 2026
cdabb7e
docs: refresh plan artifacts against synced upstream/main
mnriem Jun 22, 2026
7f03cdd
docs: add Spec Kit tasks for agent-context full opt-in
mnriem Jun 22, 2026
96b50e9
feat!: remove agent-context lifecycle from the Specify CLI
mnriem Jun 22, 2026
7f83b97
feat(agent-context): self-seed context file from the active integration
mnriem Jun 22, 2026
cedefe3
test+docs: update suite and docs for agent-context opt-in
mnriem Jun 22, 2026
56ee0df
fix(agent-context): warn on self-seed failure, correct docs, speed up…
mnriem Jun 22, 2026
4f51e37
feat(agent-context): ship self-owned per-agent context-file defaults
mnriem Jun 22, 2026
55dda37
feat!: remove all agent-context state from the Specify CLI
mnriem Jun 22, 2026
d8014dd
test: drop context_file coverage and guard against CLI reintroduction
mnriem Jun 22, 2026
114379f
docs: reflect full removal of agent-context state from the CLI
mnriem Jun 22, 2026
f64dc36
docs: align SDD artifacts with full context_file removal
mnriem Jun 22, 2026
2737616
docs: scrub stale context-file mentions from CLI docstrings
mnriem Jun 22, 2026
11bf1d3
test+docs: harden agent-context test helper and fix stale docs
mnriem Jun 22, 2026
a1ea61f
chore: remove gitignored SDD artifacts from specs/
mnriem Jun 24, 2026
97cecb3
Merge remote-tracking branch 'upstream/main' into mnriem-agent-contex…
mnriem Jun 24, 2026
d165fd6
chore: keep CHANGELOG.md identical to upstream
mnriem Jun 24, 2026
af96691
fix: preserve Cursor .mdc frontmatter in agent-context updater scripts
mnriem Jun 24, 2026
b8e4ff8
test: scope CLI-free guard to agent-context-specific symbols
mnriem Jun 24, 2026
132ef0a
fix: harden agent-context bash self-seed against malformed init JSON
mnriem Jun 25, 2026
a544033
fix: correct PowerShell hyphenated key lookup and regex replace count
mnriem Jun 25, 2026
74a09ef
fix: make bash .mdc frontmatter guard case-insensitive
mnriem Jun 25, 2026
daf9b12
docs: document separator-agnostic agent-context update invocation
mnriem 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
19 changes: 8 additions & 11 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -75,7 +75,6 @@ class WindsurfIntegration(MarkdownIntegration):
"args": "$ARGUMENTS",
"extension": ".md",
}
context_file = ".windsurf/rules/specify-rules.md"
```

**TOML agent (Gemini):**
Expand All @@ -101,7 +100,6 @@ class GeminiIntegration(TomlIntegration):
"args": "{{args}}",
"extension": ".toml",
}
context_file = "GEMINI.md"
```

**Skills agent (Codex):**
Expand Down Expand Up @@ -129,7 +127,6 @@ class CodexIntegration(SkillsIntegration):
"args": "$ARGUMENTS",
"extension": "/SKILL.md",
}
context_file = "AGENTS.md"

@classmethod
def options(cls) -> list[IntegrationOption]:
Expand All @@ -150,7 +147,6 @@ class CodexIntegration(SkillsIntegration):
| `key` | Class attribute | Unique identifier; for CLI-based integrations (`requires_cli: True`), must match the CLI executable name |
| `config` | Class attribute (dict) | Agent metadata: `name`, `folder`, `commands_subdir`, `install_url`, `requires_cli` |
| `registrar_config` | Class attribute (dict) | Command output config: `dir`, `format`, `args` placeholder, file `extension` |
| `context_file` | Class attribute (str or None) | Path to agent context/instructions file (e.g., `"CLAUDE.md"`, `".github/copilot-instructions.md"`) |

**Key design rule:** For CLI-based integrations (`requires_cli: True`), `key` must be the actual executable name (e.g., `"cursor-agent"` not `"cursor"`). This ensures `shutil.which(key)` works for CLI-tool checks without special-case mappings. IDE-based integrations (`requires_cli: False`) should use their canonical identifier (e.g., `"windsurf"`, `"copilot"`).

Expand All @@ -175,9 +171,11 @@ def _register_builtins() -> None:

### 4. Context file behavior

Set `context_file` on the integration class. The base integration setup creates or updates the managed Spec Kit section in that file, and uninstall removes the managed section when appropriate.
The Specify CLI carries **no agent-context state whatsoever**. Integration classes do **not** declare a `context_file`, and the CLI never creates, updates, removes, resolves, or migrates a context/instruction file (`CLAUDE.md`, `AGENTS.md`, `.github/copilot-instructions.md`, …). New integrations add nothing for context handling.

The managed section is owned by the bundled `agent-context` extension (`extensions/agent-context/`). All configuration flows through the extension's own config file at `.specify/extensions/agent-context/agent-context-config.yml`:
Managing the "Spec Kit" section in the context file is fully owned by the bundled `agent-context` extension (`extensions/agent-context/`), which is a **full opt-in**: `specify init` does not install it. A user adds/enables it through the standard extension verbs, after which the extension's own bundled scripts maintain the context section. When the extension is absent or disabled, nothing in Spec Kit touches the context file.
Comment thread
mnriem marked this conversation as resolved.

The extension reads its own config file at `.specify/extensions/agent-context/agent-context-config.yml`:

```yaml
# Path to the coding agent context file managed by this extension
Expand All @@ -189,10 +187,10 @@ context_markers:
end: "<!-- SPECKIT END -->"
```

- `context_file` is written automatically from the integration's class attribute when `specify init` or `specify integration use` is run.
- `context_markers.{start,end}` defaults to `IntegrationBase.CONTEXT_MARKER_START` / `CONTEXT_MARKER_END`. Users who want custom markers edit `agent-context-config.yml` directly — both the Python layer (`upsert_context_section()` / `remove_context_section()`) and the bundled scripts (`extensions/agent-context/scripts/bash/update-agent-context.sh` and `.ps1`) read from this single source of truth.
- The Specify CLI does **not** write this config. When `context_file` is empty, the extension's bundled scripts self-seed it by looking up the active integration's key in the extension's own `agent-context-defaults.json` map (`extensions/agent-context/scripts/bash/update-agent-context.sh` and `.ps1`). The CLI registry is never consulted — all agent→context-file knowledge lives inside the extension.
- `context_markers.{start,end}` are read solely by the extension's scripts; they default to the Spec Kit markers shown above and can be customized by editing `agent-context-config.yml` directly.

Users can opt out entirely with `specify extension disable agent-context`; while disabled, Spec Kit skips context-file creation, updates, and removal (the gates are inside `upsert_context_section()` and `remove_context_section()`).
Existing projects created by older Spec Kit versions keep working: any previously written managed section or extension config is left intact and is only ever updated by the extension when run.

Only add custom setup logic when the agent needs non-standard behavior. Integrations no longer require per-agent thin wrapper scripts or shared context-update dispatcher scripts — the `agent-context` extension is fully generic.

Expand Down Expand Up @@ -401,7 +399,6 @@ Implementation: Extends `YamlIntegration` (parallel to `TomlIntegration`):
2. Extracts title and description from frontmatter
3. Renders output as Goose recipe YAML (version, title, description, author, extensions, activities, prompt)
4. Uses `yaml.safe_dump()` for header fields to ensure proper escaping
5. Sets `context_file = "AGENTS.md"` so the base setup manages the Spec Kit context section there

## Branch Naming Convention

Expand Down Expand Up @@ -466,7 +463,7 @@ Disclosure is **continuous**, not a one-time event. A single AI-disclosure parag
## Common Pitfalls

1. **Using shorthand keys for CLI-based integrations**: For CLI-based integrations (`requires_cli: True`), the `key` must match the executable name (e.g., `"cursor-agent"` not `"cursor"`). `shutil.which(key)` is used for CLI tool checks — mismatches require special-case mappings. IDE-based integrations (`requires_cli: False`) are not subject to this constraint.
2. **Forgetting context configuration**: The bundled `agent-context` extension reads from `.specify/extensions/agent-context/agent-context-config.yml`. New integrations only need to set `context_file` on the class — markers and dispatcher scripts are managed centrally.
2. **Reintroducing context handling into the CLI**: The opt-in `agent-context` extension owns everything about context files — including the per-agent default mapping in `agent-context-defaults.json`. Integration classes must **not** declare a `context_file`, and no CLI code should read, write, resolve, or migrate context files. All context-file logic lives in `.specify/extensions/agent-context/` and its bundled scripts.
3. **Incorrect `requires_cli` value**: Set to `True` only for agents that have a CLI tool; set to `False` for IDE-based agents.
4. **Wrong argument format**: Use `$ARGUMENTS` for Markdown agents, `{{args}}` for TOML agents.
5. **Skipping registration**: The import and `_register()` call in `_register_builtins()` must both be added.
Expand Down
15 changes: 8 additions & 7 deletions extensions/agent-context/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,15 +6,17 @@ It owns the lifecycle of the managed section delimited by the configurable start

## Why an extension?

Not every Spec Kit user wants Spec Kit to write into the coding agent's context file. Extracting this behavior into a dedicated extension lets users:
Not every Spec Kit user wants Spec Kit to write into the coding agent's context file. Keeping this behavior in a dedicated, **opt-in** extension lets users:

- **Opt out** entirely with `specify extension disable agent-context` — Spec Kit will then never create or modify the agent context file.
- **Customize the markers** by editing `.specify/extensions/agent-context/agent-context-config.yml` — both the Python layer and the bundled scripts honor the same `context_markers` value.
- **Choose whether to install it at all** `specify init` does not install it. Add it explicitly when you want Spec Kit to manage the agent context file; if it is absent or disabled, Spec Kit never creates or modifies that file.
- **Customize the markers** by editing `.specify/extensions/agent-context/agent-context-config.yml` — the bundled scripts honor the `context_markers` value.
- **Synchronize multiple agent anchors** by setting `context_files` when a project intentionally uses more than one coding agent context file, such as `AGENTS.md` and `CLAUDE.md`.
- **Refresh on demand** with `/speckit.agent-context.update`, or automatically through the hooks declared in `extension.yml` (`after_specify`, `after_plan`).
- **Refresh on demand** by running the `speckit.agent-context.update` command in your agent, or automatically through the hooks declared in `extension.yml` (`after_specify`, `after_plan`). Invoke it using your agent's slash-command separator — `/speckit.agent-context.update` for dot-separator agents or `/speckit-agent-context-update` for hyphen-separator agents (e.g. Forge, Cline).

## Commands

The command ID below is canonical. When invoking it as a slash command, use your agent's separator: `/speckit.agent-context.update` for dot-separator agents or `/speckit-agent-context-update` for hyphen-separator agents (e.g. Forge, Cline).

| Command | Description |
|---------|-------------|
| `speckit.agent-context.update` | Refresh the managed section in the agent context file with the current plan path. |
Expand All @@ -40,7 +42,7 @@ context_markers:
end: "<!-- SPECKIT END -->"
```

- `context_file` — the project-relative path to the coding agent context file, written by `specify init` and `specify integration install`.
- `context_file` — the project-relative path to the coding agent context file. When empty, the bundled update scripts self-seed it by looking up the active integration's key in this extension's own `agent-context-defaults.json` map. The Specify CLI is never consulted.
- `context_files` — optional project-relative paths to multiple coding agent context files. When non-empty, the list takes precedence over `context_file`. Absolute paths, backslash separators, and `..` path segments are rejected.
- `context_markers.start` / `.end` — the delimiters around the managed section. Edit these to use custom markers.

Expand All @@ -62,5 +64,4 @@ pip install pyyaml
specify extension disable agent-context
```

When disabled, Spec Kit skips context file creation, updates, and removal (the gates are inside `upsert_context_section()` and `remove_context_section()`).
Disabled projects also ignore stale `context_files` values during command rendering so disabling the extension remains a complete opt-out.
When disabled (or never installed), Spec Kit performs no agent context file creation, updates, or removal — the extension's bundled scripts are the only code that ever touches the managed section. The Specify CLI carries no agent-context state at all: it never reads this config, never resolves a context file, and the `__CONTEXT_FILE__` placeholder (if present in any template) is left untouched. All context-file knowledge — including the per-agent default mapping in `agent-context-defaults.json` — lives entirely within this extension, so disabling it is a complete opt-out.
42 changes: 42 additions & 0 deletions extensions/agent-context/agent-context-defaults.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
{
"_comment": "Default coding agent context file per integration, owned by the agent-context extension. Used to self-seed agent-context-config.yml when it declares no context_file/context_files. Keyed by the Spec Kit integration key recorded in .specify/init-options.json. This mapping is independent of the Specify CLI by design.",
"agents": {
"agy": "AGENTS.md",
"amp": "AGENTS.md",
"auggie": ".augment/rules/specify-rules.md",
"bob": "AGENTS.md",
"claude": "CLAUDE.md",
"cline": ".clinerules/specify-rules.md",
"codebuddy": "CODEBUDDY.md",
"codex": "AGENTS.md",
"copilot": ".github/copilot-instructions.md",
"cursor-agent": ".cursor/rules/specify-rules.mdc",
"devin": "AGENTS.md",
"firebender": ".firebender/rules/specify-rules.mdc",
Comment thread
mnriem marked this conversation as resolved.
"forge": "AGENTS.md",
"gemini": "GEMINI.md",
"generic": "AGENTS.md",
"goose": "AGENTS.md",
"hermes": "AGENTS.md",
"iflow": "IFLOW.md",
"junie": ".junie/AGENTS.md",
"kilocode": ".kilocode/rules/specify-rules.md",
"kimi": "AGENTS.md",
"kiro-cli": "AGENTS.md",
"lingma": ".lingma/rules/specify-rules.md",
"omp": "AGENTS.md",
"opencode": "AGENTS.md",
"pi": "AGENTS.md",
"qodercli": "QODER.md",
"qwen": "QWEN.md",
"roo": ".roo/rules/specify-rules.md",
"rovodev": "AGENTS.md",
"shai": "SHAI.md",
"tabnine": "TABNINE.md",
"trae": ".trae/rules/project_rules.md",
"vibe": "AGENTS.md",
"windsurf": ".windsurf/rules/specify-rules.md",
"zcode": "ZCODE.md",
"zed": "AGENTS.md"
}
}
124 changes: 108 additions & 16 deletions extensions/agent-context/scripts/bash/update-agent-context.sh
Original file line number Diff line number Diff line change
Expand Up @@ -59,7 +59,7 @@ case "$(uname -s 2>/dev/null || true)" in
esac

# Parse extension config once; emit context files as JSON, followed by marker strings.
if ! _raw_opts="$("$_python" - "$EXT_CONFIG" "$_case_insensitive_context_files" <<'PY'
if ! _raw_opts="$("$_python" - "$EXT_CONFIG" "$_case_insensitive_context_files" "$PROJECT_ROOT" <<'PY'
import json
import sys
try:
Expand Down Expand Up @@ -95,24 +95,67 @@ def get_str(obj, *keys):
context_files = []
seen_context_files = set()
case_insensitive = sys.argv[2] == "1" or sys.platform.startswith(("win32", "cygwin"))
def add_context_file(value):
if not isinstance(value, str):
return
candidate = value.strip()
if not candidate:
return
key = candidate.casefold() if case_insensitive else candidate
if key in seen_context_files:
return
context_files.append(candidate)
seen_context_files.add(key)
raw_files = data.get("context_files")
if isinstance(raw_files, list):
for value in raw_files:
if not isinstance(value, str):
continue
candidate = value.strip()
if not candidate:
continue
key = candidate.casefold() if case_insensitive else candidate
if key in seen_context_files:
continue
context_files.append(candidate)
seen_context_files.add(key)
add_context_file(value)
if not context_files:
raw_file = get_str(data, "context_file")
candidate = raw_file.strip()
if candidate:
context_files.append(candidate)
add_context_file(get_str(data, "context_file"))
if not context_files:
# Self-seed: the agent-context extension owns its lifecycle, so when its
# own config declares no target it derives one from the active integration
# recorded in init-options.json, using the extension's OWN bundled mapping
# (agent-context-defaults.json). This is independent of the Specify CLI by
# design — nothing here imports specify_cli.
project_root = sys.argv[3] if len(sys.argv) > 3 else "."
integration_key = ""
try:
with open(
f"{project_root}/.specify/init-options.json", "r", encoding="utf-8"
) as fh:
opts = json.load(fh)
if isinstance(opts, dict):
value = opts.get("integration") or opts.get("ai") or ""
integration_key = value if isinstance(value, str) else ""
except Exception:
integration_key = ""
if integration_key:
defaults_path = (
f"{project_root}/.specify/extensions/agent-context/"
"agent-context-defaults.json"
)
mapping = {}
try:
with open(defaults_path, "r", encoding="utf-8") as fh:
loaded = json.load(fh)
agents = loaded.get("agents", {}) if isinstance(loaded, dict) else {}
mapping = agents if isinstance(agents, dict) else {}
except Exception:
print(
"agent-context: unable to read %s; cannot self-seed the context "
"file. Set 'context_file' in the extension config." % defaults_path,
file=sys.stderr,
)
mapping = {}
add_context_file(mapping.get(integration_key, "") or "")
if not context_files:
print(
"agent-context: no default context file is known for integration "
"'%s'. Set 'context_file' in the extension config to choose one."
% integration_key,
file=sys.stderr,
)
print(json.dumps(context_files))
print(get_str(data, "context_markers", "start"))
print(get_str(data, "context_markers", "end"))
Expand Down Expand Up @@ -240,11 +283,58 @@ for CONTEXT_FILE in "${CONTEXT_FILES[@]}"; do
mkdir -p "$(dirname "$CTX_PATH")"

"$_python" - "$CTX_PATH" "$MARKER_START" "$MARKER_END" "$TMP_SECTION" <<'PY'
import sys, os
import os
import re
import sys

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"


def ensure_mdc_frontmatter(content):
"""Ensure ``.mdc`` content has YAML frontmatter with ``alwaysApply: true``.

Cursor only auto-loads ``.mdc`` rule files that carry frontmatter with
``alwaysApply: true``. Prepend it when missing, or repair the value while
preserving any existing frontmatter comments/formatting.
"""
leading_ws = len(content) - len(content.lstrip())
leading = content[:leading_ws]
stripped = content[leading_ws:]

if not stripped.startswith("---"):
return "---\nalwaysApply: true\n---\n\n" + content

match = re.match(
r"^(---[ \t]*\r?\n)(.*?)(\r?\n---[ \t]*)(\r?\n|$)(.*)",
stripped,
re.DOTALL,
)
if not match:
return "---\nalwaysApply: true\n---\n\n" + content

opening, fm_text, closing, sep, rest = match.groups()
newline = "\r\n" if "\r\n" in opening else "\n"

if re.search(r"(?m)^[ \t]*alwaysApply[ \t]*:[ \t]*true[ \t]*(?:#.*)?$", fm_text):
return content

if re.search(r"(?m)^[ \t]*alwaysApply[ \t]*:", fm_text):
fm_text = re.sub(
r"(?m)^([ \t]*)alwaysApply[ \t]*:.*?([ \t]*(?:#.*)?)$",
r"\1alwaysApply: true\2",
fm_text,
count=1,
)
elif fm_text.strip():
fm_text = fm_text + newline + "alwaysApply: true"
else:
fm_text = "alwaysApply: true"

return f"{leading}{opening}{fm_text}{closing}{sep}{rest}"


if os.path.exists(ctx_path):
with open(ctx_path, "r", encoding="utf-8-sig") as fh:
content = fh.read()
Expand Down Expand Up @@ -274,6 +364,8 @@ else:
new_content = section

new_content = new_content.replace("\r\n", "\n").replace("\r", "\n")
if ctx_path.casefold().endswith(".mdc"):
new_content = ensure_mdc_frontmatter(new_content)
with open(ctx_path, "wb") as fh:
fh.write(new_content.encode("utf-8"))
PY
Expand Down
Loading