Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
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
76 changes: 76 additions & 0 deletions src/specify_cli/integrations/claude/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@

from __future__ import annotations

import re
from pathlib import Path
from typing import Any

Expand All @@ -24,6 +25,61 @@
"taskstoissues": "Optional filter or label for GitHub issues",
}

# Begin/end markers used to fence question-rendering blocks in command
# templates. The post-processor replaces the content between these markers
# with Claude Code-native AskUserQuestion instructions.
_QUESTION_FENCE_BEGIN = "<!-- speckit:question-render:begin -->"
_QUESTION_FENCE_END = "<!-- speckit:question-render:end -->"

# Replacement block for /clarify. Maps the `Option | Description` table
# schema to AskUserQuestion's `{label, description}` shape.
# The recommended option is placed first with a "Recommended — <reasoning>"
# description prefix.
_CLARIFY_ASK_USER = """\
- For multiple‑choice questions:
- **Analyze all options** and determine the **most suitable option** based on:
- Best practices for the project type
- Common patterns in similar implementations
- Risk reduction (security, performance, maintainability)
- Alignment with any explicit project goals or constraints visible in the spec
- Use the `AskUserQuestion` tool to present the question as a native structured picker:
- `question`: the clarification question text, prefixed with your recommendation:
"Recommended: Option [X] — <1-2 sentence reasoning>\\n\\n<question text>"
- `options[]`: an array of `{label, description}` objects. Place the **recommended option first** and prefix its `description` with `Recommended — <reasoning>.`
Build each option as: `{label: "<A|B|C|...>", description: "<option description>"}`.
- Append a final option: `{label: "Short", description: "Provide my own short answer (≤5 words)"}` to preserve the free-form escape hatch.
- `multiSelect`: `false`
- If the user selects the "Short" option, ask a follow-up free-text question constrained to ≤5 words.
- For short‑answer style (no meaningful discrete options):
- Determine your **suggested answer** based on best practices and context.
- Use the `AskUserQuestion` tool:
- `question`: "Suggested: <your proposed answer> — <brief reasoning>\\n\\n<question text>\\nFormat: Short answer (≤5 words)."
- `options[]`: `[{label: "Accept suggestion", description: "Use the suggested answer above"}, {label: "Custom", description: "Provide my own short answer (≤5 words)"}]`
- `multiSelect`: `false`
- If the user selects "Custom", ask a follow-up free-text question constrained to ≤5 words."""

# Replacement block for /checklist. Maps the `Option | Candidate | Why It
# Matters` table schema to AskUserQuestion's `{label, description}` shape:
# - "Candidate" becomes the option `label`
# - "Why It Matters" becomes the option `description`
_CHECKLIST_ASK_USER = """\
Question formatting rules:
- If presenting options, use the `AskUserQuestion` tool to present a native structured picker:
- `question`: the clarification question text
- `options[]`: an array of `{label, description}` objects. For each candidate option: `{label: "<Candidate value>", description: "<Why It Matters value>"}`.
- Append a final option: `{label: "Custom", description: "Provide my own short answer (≤5 words)"}` to preserve the free-form escape hatch.
- `multiSelect`: `false`
- If the user selects the "Custom" option, ask a follow-up free-text question.
- Omit the picker if a free-form answer is clearer (use `AskUserQuestion` with only a `question` and no `options[]` in that case).
- Never ask the user to restate what they already said.
- Avoid speculative categories (no hallucination). If uncertain, ask explicitly: "Confirm whether X belongs in scope.\""""

# Map of skill stem → replacement content for the question-rendering fence.
_QUESTION_RENDER_REPLACEMENTS: dict[str, str] = {
"clarify": _CLARIFY_ASK_USER,
"checklist": _CHECKLIST_ASK_USER,
}


class ClaudeIntegration(SkillsIntegration):
"""Integration for Claude Code skills."""
Expand All @@ -44,6 +100,21 @@ class ClaudeIntegration(SkillsIntegration):
}
context_file = "CLAUDE.md"

@staticmethod
def replace_question_render_block(content: str, replacement: str) -> str:
"""Replace the fenced question-rendering block with *replacement*.

Looks for ``<!-- speckit:question-render:begin -->`` …
``<!-- speckit:question-render:end -->`` and swaps the entire fence
(markers included) with *replacement*. Returns *content* unchanged
when no fence is found.
"""
pattern = re.compile(
re.escape(_QUESTION_FENCE_BEGIN) + r".*?" + re.escape(_QUESTION_FENCE_END),
re.DOTALL,
)
return pattern.sub(replacement, content, count=1)

@staticmethod
def inject_argument_hint(content: str, hint: str) -> str:
"""Insert ``argument-hint`` after the first ``description:`` in YAML frontmatter.
Expand Down Expand Up @@ -188,6 +259,11 @@ def setup(
if hint:
updated = self.inject_argument_hint(updated, hint)

# Replace question-rendering fences with AskUserQuestion instructions
replacement = _QUESTION_RENDER_REPLACEMENTS.get(stem, "")
if replacement:
updated = self.replace_question_render_block(updated, replacement)

if updated != content:
path.write_bytes(updated.encode("utf-8"))
self.record_file_in_manifest(path, project_root, manifest)
Expand Down
2 changes: 2 additions & 0 deletions templates/commands/checklist.md
Original file line number Diff line number Diff line change
Expand Up @@ -93,11 +93,13 @@ You **MUST** consider the user input before proceeding (if not empty).
- Boundary exclusion (e.g., "Should we explicitly exclude performance tuning items this round?")
- Scenario class gap (e.g., "No recovery flows detected—are rollback / partial failure paths in scope?")

<!-- speckit:question-render:begin -->
Question formatting rules:
- If presenting options, generate a compact table with columns: Option | Candidate | Why It Matters
- Limit to A–E options maximum; omit table if a free-form answer is clearer
- Never ask the user to restate what they already said
- Avoid speculative categories (no hallucination). If uncertain, ask explicitly: "Confirm whether X belongs in scope."
<!-- speckit:question-render:end -->
Comment on lines +96 to +102

Defaults when interaction impossible:
- Depth: Standard
Expand Down
2 changes: 2 additions & 0 deletions templates/commands/clarify.md
Original file line number Diff line number Diff line change
Expand Up @@ -135,6 +135,7 @@ Execution steps:
4. Sequential questioning loop (interactive):
- Present EXACTLY ONE question at a time.
<!-- speckit:question-render:begin -->
- For multiple‑choice questions:
Comment on lines 136 to 139
- **Analyze all options** and determine the **most suitable option** based on:
- Best practices for the project type
Expand All @@ -157,6 +158,7 @@ Execution steps:
- Provide your **suggested answer** based on best practices and context.
- Format as: `**Suggested:** <your proposed answer> - <brief reasoning>`
- Then output: `Format: Short answer (<=5 words). You can accept the suggestion by saying "yes" or "suggested", or provide your own answer.`
<!-- speckit:question-render:end -->
- After the user answers:
- If the user replies with "yes", "recommended", or "suggested", use your previously stated recommendation/suggestion as the answer.
- Otherwise, validate the answer maps to one option or fits the <=5 word constraint.
Expand Down
164 changes: 164 additions & 0 deletions tests/integrations/test_integration_claude.py
Original file line number Diff line number Diff line change
Expand Up @@ -400,3 +400,167 @@ def test_inject_argument_hint_skips_if_already_present(self):
lines = result.splitlines()
hint_count = sum(1 for ln in lines if ln.startswith("argument-hint:"))
assert hint_count == 1


class TestQuestionRenderReplacement:
"""Verify that question-render fences are replaced with AskUserQuestion for Claude skills."""

def test_clarify_skill_uses_ask_user_question(self, tmp_path):
"""speckit-clarify/SKILL.md must contain AskUserQuestion instructions,
not the Option | Description table."""
i = get_integration("claude")
m = IntegrationManifest("claude", tmp_path)
i.setup(tmp_path, m, script_type="sh")

skill_file = tmp_path / ".claude" / "skills" / "speckit-clarify" / "SKILL.md"
assert skill_file.exists()
content = skill_file.read_text(encoding="utf-8")

# Must contain AskUserQuestion instructions
assert "AskUserQuestion" in content
# Must NOT contain the original Markdown table header
assert "| Option | Description |" not in content
# Must NOT contain the fence markers
assert "speckit:question-render:begin" not in content
assert "speckit:question-render:end" not in content
# Must contain the free-form escape hatch option
assert "Provide my own short answer" in content

def test_checklist_skill_uses_ask_user_question(self, tmp_path):
"""speckit-checklist/SKILL.md must contain AskUserQuestion instructions,
not the Option | Candidate | Why It Matters table."""
i = get_integration("claude")
m = IntegrationManifest("claude", tmp_path)
i.setup(tmp_path, m, script_type="sh")

skill_file = tmp_path / ".claude" / "skills" / "speckit-checklist" / "SKILL.md"
assert skill_file.exists()
content = skill_file.read_text(encoding="utf-8")

# Must contain AskUserQuestion instructions
assert "AskUserQuestion" in content
# Must NOT contain the original table schema
assert "| Candidate | Why It Matters" not in content
# Must NOT contain the fence markers
assert "speckit:question-render:begin" not in content
assert "speckit:question-render:end" not in content

def test_clarify_recommended_option_first(self, tmp_path):
"""The Claude clarify skill must instruct placing the recommended option first."""
i = get_integration("claude")
m = IntegrationManifest("claude", tmp_path)
i.setup(tmp_path, m, script_type="sh")

skill_file = tmp_path / ".claude" / "skills" / "speckit-clarify" / "SKILL.md"
content = skill_file.read_text(encoding="utf-8")

assert "recommended option first" in content.lower()
assert "Recommended —" in content

def test_non_question_skills_unchanged(self, tmp_path):
"""Skills other than clarify/checklist must NOT contain AskUserQuestion."""
i = get_integration("claude")
m = IntegrationManifest("claude", tmp_path)
i.setup(tmp_path, m, script_type="sh")

skills_dir = tmp_path / ".claude" / "skills"
for skill_dir in skills_dir.iterdir():
if not skill_dir.is_dir():
continue
stem = skill_dir.name
if stem in ("speckit-clarify", "speckit-checklist"):
continue
skill_file = skill_dir / "SKILL.md"
if skill_file.exists():
content = skill_file.read_text(encoding="utf-8")
assert "AskUserQuestion" not in content, (
f"{stem}/SKILL.md should not contain AskUserQuestion"
)

def test_clarify_preserves_downstream_behavior(self, tmp_path):
"""The clarify skill must still contain question-cap and validation instructions."""
i = get_integration("claude")
m = IntegrationManifest("claude", tmp_path)
i.setup(tmp_path, m, script_type="sh")

skill_file = tmp_path / ".claude" / "skills" / "speckit-clarify" / "SKILL.md"
content = skill_file.read_text(encoding="utf-8")

# 5-question cap
assert "5" in content
assert "maximum" in content.lower() or "Maximum" in content
Comment on lines +490 to +491
# Validation pass
assert "Validation" in content
# Incremental spec writes
assert "Clarifications" in content

def test_checklist_preserves_downstream_behavior(self, tmp_path):
"""The checklist skill must still contain question-cap and labeling instructions."""
i = get_integration("claude")
m = IntegrationManifest("claude", tmp_path)
i.setup(tmp_path, m, script_type="sh")

skill_file = tmp_path / ".claude" / "skills" / "speckit-checklist" / "SKILL.md"
content = skill_file.read_text(encoding="utf-8")

# 3-initial / up-to-5-total cap
assert "THREE" in content or "three" in content.lower()
assert "Q1" in content or "Q4" in content or "Q5" in content
Comment on lines +507 to +508
# Generation algorithm preserved
assert "Generation algorithm" in content

def test_replace_question_render_block_no_fence(self):
"""Content without a fence must be returned unchanged."""
from specify_cli.integrations.claude import ClaudeIntegration

content = "No fence here.\nJust regular text.\n"
result = ClaudeIntegration.replace_question_render_block(content, "REPLACED")
assert result == content

def test_replace_question_render_block_basic(self):
"""A fenced block must be replaced with the given content."""
from specify_cli.integrations.claude import ClaudeIntegration

content = (
"Before\n"
"<!-- speckit:question-render:begin -->\n"
"Old content\n"
"More old content\n"
"<!-- speckit:question-render:end -->\n"
"After\n"
)
result = ClaudeIntegration.replace_question_render_block(content, "NEW BLOCK")
assert "NEW BLOCK" in result
assert "Old content" not in result
assert "Before\n" in result
assert "After\n" in result
assert "speckit:question-render" not in result


class TestNonClaudeIntegrationsParity:
"""Verify non-Claude integrations produce output unaffected by the fence markers."""

def test_markdown_integrations_contain_fence_as_is(self, tmp_path):
"""MarkdownIntegration-based agents must output the fence as plain Markdown."""
from specify_cli.integrations import get_integration as get_int

# Pick a representative Markdown integration
for key in ("windsurf", "cursor-agent"):
integration = get_int(key)
if integration is None:
continue
m = IntegrationManifest(key, tmp_path)
created = integration.setup(tmp_path, m, script_type="sh")

# Find clarify command output
clarify_files = [
f for f in created
if "clarify" in f.name.lower() or "clarify" in str(f.parent).lower()
]
for f in clarify_files:
content = f.read_text(encoding="utf-8")
# The fence is invisible HTML comments — just verify no
# AskUserQuestion was injected
Comment on lines +562 to +563
assert "AskUserQuestion" not in content, (
f"{key}: {f} should not contain AskUserQuestion"
)
Loading