From 98ee02a98b44bfdc444734d30fd04d4af517c3eb Mon Sep 17 00:00:00 2001 From: Ed Harrod Date: Fri, 19 Jun 2026 16:28:45 +0100 Subject: [PATCH] feat(claude): run /analyze in a forked subagent (#2511) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * claude: run /analyze in a forked subagent /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 * 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) * 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) --------- Co-authored-by: Claude Opus 4.8 (1M context) --- .../integrations/claude/__init__.py | 86 ++++++++-------- tests/integrations/test_integration_claude.py | 98 ++++++++++++++++++- 2 files changed, 142 insertions(+), 42 deletions(-) diff --git a/src/specify_cli/integrations/claude/__init__.py b/src/specify_cli/integrations/claude/__init__.py index 6a7d483db..0df388172 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 @@ -23,6 +21,15 @@ ARGUMENT_HINTS: dict[str, str] = { "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.""" @@ -148,50 +155,47 @@ class ClaudeIntegration(SkillsIntegration): 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") - 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 = 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-"):] + stem = self._skill_stem_from_content(updated) + if stem: hint = ARGUMENT_HINTS.get(stem, "") if hint: updated = self.inject_argument_hint(updated, hint) - - if updated != content: - path.write_bytes(updated.encode("utf-8")) - self.record_file_in_manifest(path, project_root, manifest) - - return created + 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 diff --git a/tests/integrations/test_integration_claude.py b/tests/integrations/test_integration_claude.py index e8350114a..c7ecef95d 100644 --- a/tests/integrations/test_integration_claude.py +++ b/tests/integrations/test_integration_claude.py @@ -10,7 +10,7 @@ import yaml 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,102 @@ class TestClaudeDisableModelInvocation: 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 + + 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."""