From 3d715ed0a7ae8f64215eb6697602965b3c081ab0 Mon Sep 17 00:00:00 2001 From: Ed Harrod Date: Mon, 11 May 2026 12:41:15 +0100 Subject: [PATCH 1/3] claude: run /analyze in a forked subagent MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit /analyze is explicitly read-only and produces a compact analysis report from heavy artefact reads (spec.md, plan.md, tasks.md). It matches the canonical use case for context: fork — bulk inputs that collapse to a short summary, no need for conversation history. Forking keeps the artefact contents out of the main conversation context, which is the concern raised in #752. Done as a per-command opt-in via FORK_CONTEXT_COMMANDS so other spec-kit commands (which are interactive or have side effects) are unaffected. Refs #752 --- .../integrations/claude/__init__.py | 14 ++++ tests/integrations/test_integration_claude.py | 68 ++++++++++++++++++- 2 files changed, 81 insertions(+), 1 deletion(-) diff --git a/src/specify_cli/integrations/claude/__init__.py b/src/specify_cli/integrations/claude/__init__.py index 6a7d483db3..6fc150f56c 100644 --- a/src/specify_cli/integrations/claude/__init__.py +++ b/src/specify_cli/integrations/claude/__init__.py @@ -23,6 +23,15 @@ "taskstoissues": "Optional filter or label for GitHub issues", } +# Per-command frontmatter overrides for skills that should run in a forked +# subagent context. Read-only analysis commands are good candidates: the +# heavy reads (spec/plan/tasks artefacts) collapse to a short summary, +# so isolating them keeps the main conversation context clean. +# See https://code.claude.com/docs/en/skills#run-skills-in-a-subagent +FORK_CONTEXT_COMMANDS: dict[str, dict[str, str]] = { + "analyze": {"context": "fork", "agent": "general-purpose"}, +} + class ClaudeIntegration(SkillsIntegration): """Integration for Claude Code skills.""" @@ -190,6 +199,11 @@ def setup( if hint: updated = self.inject_argument_hint(updated, hint) + fork_config = FORK_CONTEXT_COMMANDS.get(stem) + if fork_config: + for key, value in fork_config.items(): + updated = self._inject_frontmatter_flag(updated, key, value) + if updated != content: path.write_bytes(updated.encode("utf-8")) self.record_file_in_manifest(path, project_root, manifest) diff --git a/tests/integrations/test_integration_claude.py b/tests/integrations/test_integration_claude.py index e8350114a7..483cb1f6d8 100644 --- a/tests/integrations/test_integration_claude.py +++ b/tests/integrations/test_integration_claude.py @@ -10,7 +10,7 @@ from specify_cli.integrations import INTEGRATION_REGISTRY, get_integration from specify_cli.integrations.base import IntegrationBase, SkillsIntegration -from specify_cli.integrations.claude import ARGUMENT_HINTS +from specify_cli.integrations.claude import ARGUMENT_HINTS, FORK_CONTEXT_COMMANDS from specify_cli.integrations.manifest import IntegrationManifest @@ -536,6 +536,72 @@ def test_skills_default_post_process_preserves_content_without_hooks(self, tmp_p assert agy.post_process_skill_content(content) == content +class TestClaudeForkContext: + """Verify context: fork is injected only for commands listed in FORK_CONTEXT_COMMANDS.""" + + def test_analyze_skill_runs_in_forked_subagent(self, tmp_path): + """speckit-analyze must opt into context: fork + agent.""" + i = get_integration("claude") + m = IntegrationManifest("claude", tmp_path) + i.setup(tmp_path, m, script_type="sh") + analyze_skill = tmp_path / ".claude/skills/speckit-analyze/SKILL.md" + assert analyze_skill.exists() + content = analyze_skill.read_text(encoding="utf-8") + parts = content.split("---", 2) + parsed = yaml.safe_load(parts[1]) + assert parsed.get("context") == "fork" + assert parsed.get("agent") == "general-purpose" + + def test_other_skills_do_not_fork(self, tmp_path): + """Skills not in FORK_CONTEXT_COMMANDS must not get context: fork.""" + i = get_integration("claude") + m = IntegrationManifest("claude", tmp_path) + created = i.setup(tmp_path, m, script_type="sh") + skill_files = [f for f in created if f.name == "SKILL.md"] + for f in skill_files: + stem = f.parent.name + if stem.startswith("speckit-"): + stem = stem[len("speckit-"):] + if stem in FORK_CONTEXT_COMMANDS: + continue + content = f.read_text(encoding="utf-8") + parts = content.split("---", 2) + parsed = yaml.safe_load(parts[1]) + assert "context" not in parsed, ( + f"{f.parent.name}: must not have context frontmatter" + ) + assert "agent" not in parsed, ( + f"{f.parent.name}: must not have agent frontmatter" + ) + + def test_fork_flags_inside_frontmatter(self, tmp_path): + """context/agent must appear in the frontmatter, not in the body.""" + i = get_integration("claude") + m = IntegrationManifest("claude", tmp_path) + i.setup(tmp_path, m, script_type="sh") + analyze_skill = tmp_path / ".claude/skills/speckit-analyze/SKILL.md" + content = analyze_skill.read_text(encoding="utf-8") + parts = content.split("---", 2) + assert len(parts) >= 3 + frontmatter = parts[1] + body = parts[2] + assert "context: fork" in frontmatter + assert "agent: general-purpose" in frontmatter + assert "context: fork" not in body + assert "agent: general-purpose" not in body + + def test_fork_injection_idempotent(self, tmp_path): + """Re-running setup must not duplicate the fork frontmatter keys.""" + i = get_integration("claude") + m = IntegrationManifest("claude", tmp_path) + i.setup(tmp_path, m, script_type="sh") + i.setup(tmp_path, m, script_type="sh") + analyze_skill = tmp_path / ".claude/skills/speckit-analyze/SKILL.md" + content = analyze_skill.read_text(encoding="utf-8") + assert content.count("context: fork") == 1 + assert content.count("agent: general-purpose") == 1 + + class TestClaudeHookCommandNote: """Verify dot-to-hyphen normalization note is injected in hook sections.""" From 3b1ef7e79d78821cd2ca1b78aa4a501a0838a3ad Mon Sep 17 00:00:00 2001 From: Ed Harrod Date: Thu, 11 Jun 2026 12:12:42 +0100 Subject: [PATCH 2/3] claude: apply per-command frontmatter on every skill-generation path argument-hint and fork context were injected only in setup(), so skills produced via post_process_skill_content() directly (presets, extensions) lost them - e.g. a preset overriding speckit-analyze dropped context: fork. Move the per-command injection into post_process_skill_content(), deriving the command stem from the frontmatter name, so all generation paths stay consistent. setup() now just calls post_process_skill_content(). Co-Authored-By: Claude Opus 4.8 (1M context) --- .../integrations/claude/__init__.py | 56 +++++++++++++------ tests/integrations/test_integration_claude.py | 30 ++++++++++ 2 files changed, 70 insertions(+), 16 deletions(-) diff --git a/src/specify_cli/integrations/claude/__init__.py b/src/specify_cli/integrations/claude/__init__.py index 6fc150f56c..f52013285b 100644 --- a/src/specify_cli/integrations/claude/__init__.py +++ b/src/specify_cli/integrations/claude/__init__.py @@ -157,11 +157,49 @@ def _inject_frontmatter_flag(content: str, key: str, value: str = "true") -> str out.append(line) return "".join(out) + @staticmethod + def _skill_stem_from_content(content: str) -> str | None: + """Derive the command stem (e.g. ``analyze``) from a skill's frontmatter. + + Reads the ``name:`` field of the first frontmatter block and strips + the ``speckit-`` prefix. Returns ``None`` when no name is present. + """ + dash_count = 0 + for line in content.splitlines(): + stripped = line.rstrip("\r\n") + if stripped == "---": + dash_count += 1 + if dash_count == 2: + break + continue + if dash_count == 1 and stripped.startswith("name:"): + name = stripped[len("name:"):].strip().strip('"').strip("'") + if name.startswith("speckit-"): + return name[len("speckit-"):] + return name or None + return None + def post_process_skill_content(self, content: str) -> str: - """Inject Claude-specific frontmatter flags and hook notes.""" + """Inject Claude-specific frontmatter flags, hook notes, and any + per-command frontmatter. + + Applied by every skill-generation path (setup, presets, extensions), + so command-specific frontmatter (argument-hint, fork context) stays + consistent however the SKILL.md was produced. + """ updated = super().post_process_skill_content(content) updated = self._inject_frontmatter_flag(updated, "user-invocable") updated = self._inject_frontmatter_flag(updated, "disable-model-invocation", "false") + + stem = self._skill_stem_from_content(updated) + if stem: + hint = ARGUMENT_HINTS.get(stem, "") + if hint: + updated = self.inject_argument_hint(updated, hint) + fork_config = FORK_CONTEXT_COMMANDS.get(stem) + if fork_config: + for key, value in fork_config.items(): + updated = self._inject_frontmatter_flag(updated, key, value) return updated def setup( @@ -188,21 +226,7 @@ def setup( content_bytes = path.read_bytes() content = content_bytes.decode("utf-8") - updated = content - - # Inject argument-hint if available for this skill - skill_dir_name = path.parent.name # e.g. "speckit-plan" - stem = skill_dir_name - if stem.startswith("speckit-"): - stem = stem[len("speckit-"):] - hint = ARGUMENT_HINTS.get(stem, "") - if hint: - updated = self.inject_argument_hint(updated, hint) - - fork_config = FORK_CONTEXT_COMMANDS.get(stem) - if fork_config: - for key, value in fork_config.items(): - updated = self._inject_frontmatter_flag(updated, key, value) + updated = self.post_process_skill_content(content) if updated != content: path.write_bytes(updated.encode("utf-8")) diff --git a/tests/integrations/test_integration_claude.py b/tests/integrations/test_integration_claude.py index 483cb1f6d8..c7ecef95d0 100644 --- a/tests/integrations/test_integration_claude.py +++ b/tests/integrations/test_integration_claude.py @@ -601,6 +601,36 @@ def test_fork_injection_idempotent(self, tmp_path): assert content.count("context: fork") == 1 assert content.count("agent: general-purpose") == 1 + def test_fork_context_injected_via_post_process(self): + """Preset/extension generators call post_process_skill_content directly, + bypassing setup(); fork context must be injected there too.""" + i = get_integration("claude") + content = '---\nname: "speckit-analyze"\ndescription: "x"\n---\n\nBody\n' + result = i.post_process_skill_content(content) + parsed = yaml.safe_load(result.split("---", 2)[1]) + assert parsed.get("context") == "fork" + assert parsed.get("agent") == "general-purpose" + assert parsed.get("argument-hint") == ARGUMENT_HINTS["analyze"] + + def test_post_process_no_fork_for_other_skills(self): + """Skills not in FORK_CONTEXT_COMMANDS must not gain context/agent.""" + i = get_integration("claude") + content = '---\nname: "speckit-plan"\ndescription: "x"\n---\n\nBody\n' + result = i.post_process_skill_content(content) + parsed = yaml.safe_load(result.split("---", 2)[1]) + assert "context" not in parsed + assert "agent" not in parsed + + def test_post_process_fork_idempotent(self): + """Re-running post_process must not duplicate fork frontmatter keys.""" + i = get_integration("claude") + content = '---\nname: "speckit-analyze"\ndescription: "x"\n---\n\nBody\n' + once = i.post_process_skill_content(content) + twice = i.post_process_skill_content(once) + assert once == twice + assert twice.count("context: fork") == 1 + assert twice.count("agent: general-purpose") == 1 + class TestClaudeHookCommandNote: """Verify dot-to-hyphen normalization note is injected in hook sections.""" From f2bc718fe088a837900ed8b90884a8351e114425 Mon Sep 17 00:00:00 2001 From: Ed Harrod Date: Thu, 18 Jun 2026 15:29:12 +0100 Subject: [PATCH 3/3] claude: drop redundant post-process loop from setup SkillsIntegration.setup() already runs post_process_skill_content() on every SKILL.md before writing it, and that method now applies the argument-hint and fork-context injection. The per-file re-process loop in ClaudeIntegration.setup() was therefore a no-op, so inherit the base setup() directly. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../integrations/claude/__init__.py | 34 ------------------- 1 file changed, 34 deletions(-) diff --git a/src/specify_cli/integrations/claude/__init__.py b/src/specify_cli/integrations/claude/__init__.py index f52013285b..0df388172d 100644 --- a/src/specify_cli/integrations/claude/__init__.py +++ b/src/specify_cli/integrations/claude/__init__.py @@ -2,11 +2,9 @@ from __future__ import annotations -from pathlib import Path from typing import Any from ..base import SkillsIntegration -from ..manifest import IntegrationManifest from ..._utils import dump_frontmatter # Mapping of command template stem → argument-hint text shown inline @@ -201,35 +199,3 @@ def post_process_skill_content(self, content: str) -> str: for key, value in fork_config.items(): updated = self._inject_frontmatter_flag(updated, key, value) return updated - - def setup( - self, - project_root: Path, - manifest: IntegrationManifest, - parsed_options: dict[str, Any] | None = None, - **opts: Any, - ) -> list[Path]: - """Install Claude skills, then inject argument-hints.""" - created = super().setup(project_root, manifest, parsed_options, **opts) - - skills_dir = self.skills_dest(project_root).resolve() - - for path in created: - # Only touch SKILL.md files under the skills directory - try: - path.resolve().relative_to(skills_dir) - except ValueError: - continue - if path.name != "SKILL.md": - continue - - content_bytes = path.read_bytes() - content = content_bytes.decode("utf-8") - - updated = self.post_process_skill_content(content) - - if updated != content: - path.write_bytes(updated.encode("utf-8")) - self.record_file_in_manifest(path, project_root, manifest) - - return created