Skip to content

Commit 98ee02a

Browse files
echarrodclaude
andauthored
feat(claude): run /analyze in a forked subagent (#2511)
* 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) <noreply@anthropic.com> * 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) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent 4eda983 commit 98ee02a

2 files changed

Lines changed: 142 additions & 42 deletions

File tree

src/specify_cli/integrations/claude/__init__.py

Lines changed: 45 additions & 41 deletions
Original file line numberDiff line numberDiff line change
@@ -2,11 +2,9 @@
22

33
from __future__ import annotations
44

5-
from pathlib import Path
65
from typing import Any
76

87
from ..base import SkillsIntegration
9-
from ..manifest import IntegrationManifest
108
from ..._utils import dump_frontmatter
119

1210
# Mapping of command template stem → argument-hint text shown inline
@@ -23,6 +21,15 @@
2321
"taskstoissues": "Optional filter or label for GitHub issues",
2422
}
2523

24+
# Per-command frontmatter overrides for skills that should run in a forked
25+
# subagent context. Read-only analysis commands are good candidates: the
26+
# heavy reads (spec/plan/tasks artefacts) collapse to a short summary,
27+
# so isolating them keeps the main conversation context clean.
28+
# See https://code.claude.com/docs/en/skills#run-skills-in-a-subagent
29+
FORK_CONTEXT_COMMANDS: dict[str, dict[str, str]] = {
30+
"analyze": {"context": "fork", "agent": "general-purpose"},
31+
}
32+
2633

2734
class ClaudeIntegration(SkillsIntegration):
2835
"""Integration for Claude Code skills."""
@@ -148,50 +155,47 @@ def _inject_frontmatter_flag(content: str, key: str, value: str = "true") -> str
148155
out.append(line)
149156
return "".join(out)
150157

151-
def post_process_skill_content(self, content: str) -> str:
152-
"""Inject Claude-specific frontmatter flags and hook notes."""
153-
updated = super().post_process_skill_content(content)
154-
updated = self._inject_frontmatter_flag(updated, "user-invocable")
155-
updated = self._inject_frontmatter_flag(updated, "disable-model-invocation", "false")
156-
return updated
158+
@staticmethod
159+
def _skill_stem_from_content(content: str) -> str | None:
160+
"""Derive the command stem (e.g. ``analyze``) from a skill's frontmatter.
157161
158-
def setup(
159-
self,
160-
project_root: Path,
161-
manifest: IntegrationManifest,
162-
parsed_options: dict[str, Any] | None = None,
163-
**opts: Any,
164-
) -> list[Path]:
165-
"""Install Claude skills, then inject argument-hints."""
166-
created = super().setup(project_root, manifest, parsed_options, **opts)
167-
168-
skills_dir = self.skills_dest(project_root).resolve()
169-
170-
for path in created:
171-
# Only touch SKILL.md files under the skills directory
172-
try:
173-
path.resolve().relative_to(skills_dir)
174-
except ValueError:
175-
continue
176-
if path.name != "SKILL.md":
162+
Reads the ``name:`` field of the first frontmatter block and strips
163+
the ``speckit-`` prefix. Returns ``None`` when no name is present.
164+
"""
165+
dash_count = 0
166+
for line in content.splitlines():
167+
stripped = line.rstrip("\r\n")
168+
if stripped == "---":
169+
dash_count += 1
170+
if dash_count == 2:
171+
break
177172
continue
173+
if dash_count == 1 and stripped.startswith("name:"):
174+
name = stripped[len("name:"):].strip().strip('"').strip("'")
175+
if name.startswith("speckit-"):
176+
return name[len("speckit-"):]
177+
return name or None
178+
return None
178179

179-
content_bytes = path.read_bytes()
180-
content = content_bytes.decode("utf-8")
180+
def post_process_skill_content(self, content: str) -> str:
181+
"""Inject Claude-specific frontmatter flags, hook notes, and any
182+
per-command frontmatter.
181183
182-
updated = content
184+
Applied by every skill-generation path (setup, presets, extensions),
185+
so command-specific frontmatter (argument-hint, fork context) stays
186+
consistent however the SKILL.md was produced.
187+
"""
188+
updated = super().post_process_skill_content(content)
189+
updated = self._inject_frontmatter_flag(updated, "user-invocable")
190+
updated = self._inject_frontmatter_flag(updated, "disable-model-invocation", "false")
183191

184-
# Inject argument-hint if available for this skill
185-
skill_dir_name = path.parent.name # e.g. "speckit-plan"
186-
stem = skill_dir_name
187-
if stem.startswith("speckit-"):
188-
stem = stem[len("speckit-"):]
192+
stem = self._skill_stem_from_content(updated)
193+
if stem:
189194
hint = ARGUMENT_HINTS.get(stem, "")
190195
if hint:
191196
updated = self.inject_argument_hint(updated, hint)
192-
193-
if updated != content:
194-
path.write_bytes(updated.encode("utf-8"))
195-
self.record_file_in_manifest(path, project_root, manifest)
196-
197-
return created
197+
fork_config = FORK_CONTEXT_COMMANDS.get(stem)
198+
if fork_config:
199+
for key, value in fork_config.items():
200+
updated = self._inject_frontmatter_flag(updated, key, value)
201+
return updated

tests/integrations/test_integration_claude.py

Lines changed: 97 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,7 @@
1010

1111
from specify_cli.integrations import INTEGRATION_REGISTRY, get_integration
1212
from specify_cli.integrations.base import IntegrationBase, SkillsIntegration
13-
from specify_cli.integrations.claude import ARGUMENT_HINTS
13+
from specify_cli.integrations.claude import ARGUMENT_HINTS, FORK_CONTEXT_COMMANDS
1414
from specify_cli.integrations.manifest import IntegrationManifest
1515

1616

@@ -536,6 +536,102 @@ def test_skills_default_post_process_preserves_content_without_hooks(self, tmp_p
536536
assert agy.post_process_skill_content(content) == content
537537

538538

539+
class TestClaudeForkContext:
540+
"""Verify context: fork is injected only for commands listed in FORK_CONTEXT_COMMANDS."""
541+
542+
def test_analyze_skill_runs_in_forked_subagent(self, tmp_path):
543+
"""speckit-analyze must opt into context: fork + agent."""
544+
i = get_integration("claude")
545+
m = IntegrationManifest("claude", tmp_path)
546+
i.setup(tmp_path, m, script_type="sh")
547+
analyze_skill = tmp_path / ".claude/skills/speckit-analyze/SKILL.md"
548+
assert analyze_skill.exists()
549+
content = analyze_skill.read_text(encoding="utf-8")
550+
parts = content.split("---", 2)
551+
parsed = yaml.safe_load(parts[1])
552+
assert parsed.get("context") == "fork"
553+
assert parsed.get("agent") == "general-purpose"
554+
555+
def test_other_skills_do_not_fork(self, tmp_path):
556+
"""Skills not in FORK_CONTEXT_COMMANDS must not get context: fork."""
557+
i = get_integration("claude")
558+
m = IntegrationManifest("claude", tmp_path)
559+
created = i.setup(tmp_path, m, script_type="sh")
560+
skill_files = [f for f in created if f.name == "SKILL.md"]
561+
for f in skill_files:
562+
stem = f.parent.name
563+
if stem.startswith("speckit-"):
564+
stem = stem[len("speckit-"):]
565+
if stem in FORK_CONTEXT_COMMANDS:
566+
continue
567+
content = f.read_text(encoding="utf-8")
568+
parts = content.split("---", 2)
569+
parsed = yaml.safe_load(parts[1])
570+
assert "context" not in parsed, (
571+
f"{f.parent.name}: must not have context frontmatter"
572+
)
573+
assert "agent" not in parsed, (
574+
f"{f.parent.name}: must not have agent frontmatter"
575+
)
576+
577+
def test_fork_flags_inside_frontmatter(self, tmp_path):
578+
"""context/agent must appear in the frontmatter, not in the body."""
579+
i = get_integration("claude")
580+
m = IntegrationManifest("claude", tmp_path)
581+
i.setup(tmp_path, m, script_type="sh")
582+
analyze_skill = tmp_path / ".claude/skills/speckit-analyze/SKILL.md"
583+
content = analyze_skill.read_text(encoding="utf-8")
584+
parts = content.split("---", 2)
585+
assert len(parts) >= 3
586+
frontmatter = parts[1]
587+
body = parts[2]
588+
assert "context: fork" in frontmatter
589+
assert "agent: general-purpose" in frontmatter
590+
assert "context: fork" not in body
591+
assert "agent: general-purpose" not in body
592+
593+
def test_fork_injection_idempotent(self, tmp_path):
594+
"""Re-running setup must not duplicate the fork frontmatter keys."""
595+
i = get_integration("claude")
596+
m = IntegrationManifest("claude", tmp_path)
597+
i.setup(tmp_path, m, script_type="sh")
598+
i.setup(tmp_path, m, script_type="sh")
599+
analyze_skill = tmp_path / ".claude/skills/speckit-analyze/SKILL.md"
600+
content = analyze_skill.read_text(encoding="utf-8")
601+
assert content.count("context: fork") == 1
602+
assert content.count("agent: general-purpose") == 1
603+
604+
def test_fork_context_injected_via_post_process(self):
605+
"""Preset/extension generators call post_process_skill_content directly,
606+
bypassing setup(); fork context must be injected there too."""
607+
i = get_integration("claude")
608+
content = '---\nname: "speckit-analyze"\ndescription: "x"\n---\n\nBody\n'
609+
result = i.post_process_skill_content(content)
610+
parsed = yaml.safe_load(result.split("---", 2)[1])
611+
assert parsed.get("context") == "fork"
612+
assert parsed.get("agent") == "general-purpose"
613+
assert parsed.get("argument-hint") == ARGUMENT_HINTS["analyze"]
614+
615+
def test_post_process_no_fork_for_other_skills(self):
616+
"""Skills not in FORK_CONTEXT_COMMANDS must not gain context/agent."""
617+
i = get_integration("claude")
618+
content = '---\nname: "speckit-plan"\ndescription: "x"\n---\n\nBody\n'
619+
result = i.post_process_skill_content(content)
620+
parsed = yaml.safe_load(result.split("---", 2)[1])
621+
assert "context" not in parsed
622+
assert "agent" not in parsed
623+
624+
def test_post_process_fork_idempotent(self):
625+
"""Re-running post_process must not duplicate fork frontmatter keys."""
626+
i = get_integration("claude")
627+
content = '---\nname: "speckit-analyze"\ndescription: "x"\n---\n\nBody\n'
628+
once = i.post_process_skill_content(content)
629+
twice = i.post_process_skill_content(once)
630+
assert once == twice
631+
assert twice.count("context: fork") == 1
632+
assert twice.count("agent: general-purpose") == 1
633+
634+
539635
class TestClaudeHookCommandNote:
540636
"""Verify dot-to-hyphen normalization note is injected in hook sections."""
541637

0 commit comments

Comments
 (0)