Skip to content

Commit 9ddb174

Browse files
0xrafasecclaude
andcommitted
feat: use Claude Code's native AskUserQuestion for /clarify and /checklist
Replace Markdown-table question rendering with Claude Code's native AskUserQuestion structured picker in speckit-clarify and speckit-checklist skills, while preserving the original table behavior for all other agents. - Add HTML-comment fence markers (speckit:question-render:begin/end) around the question-rendering blocks in clarify.md and checklist.md templates - Extend ClaudeIntegration.setup() to post-process fenced blocks at skill generation time, replacing them with AskUserQuestion instructions - Map Option|Description (clarify) and Option|Candidate|Why It Matters (checklist) to AskUserQuestion's {label, description} shape - Place recommended option first with "Recommended — <reasoning>" prefix - Preserve free-form escape hatch as a final synthetic option - All downstream behavior unchanged: question caps, validation, spec writes - Non-Claude integrations output is completely unaffected Closes #2181 Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
1 parent cdbea09 commit 9ddb174

File tree

4 files changed

+244
-0
lines changed

4 files changed

+244
-0
lines changed

src/specify_cli/integrations/claude/__init__.py

Lines changed: 76 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22

33
from __future__ import annotations
44

5+
import re
56
from pathlib import Path
67
from typing import Any
78

@@ -24,6 +25,61 @@
2425
"taskstoissues": "Optional filter or label for GitHub issues",
2526
}
2627

28+
# Begin/end markers used to fence question-rendering blocks in command
29+
# templates. The post-processor replaces the content between these markers
30+
# with Claude Code-native AskUserQuestion instructions.
31+
_QUESTION_FENCE_BEGIN = "<!-- speckit:question-render:begin -->"
32+
_QUESTION_FENCE_END = "<!-- speckit:question-render:end -->"
33+
34+
# Replacement block for /clarify. Maps the `Option | Description` table
35+
# schema to AskUserQuestion's `{label, description}` shape.
36+
# The recommended option is placed first with a "Recommended — <reasoning>"
37+
# description prefix.
38+
_CLARIFY_ASK_USER = """\
39+
- For multiple‑choice questions:
40+
- **Analyze all options** and determine the **most suitable option** based on:
41+
- Best practices for the project type
42+
- Common patterns in similar implementations
43+
- Risk reduction (security, performance, maintainability)
44+
- Alignment with any explicit project goals or constraints visible in the spec
45+
- Use the `AskUserQuestion` tool to present the question as a native structured picker:
46+
- `question`: the clarification question text, prefixed with your recommendation:
47+
"Recommended: Option [X] — <1-2 sentence reasoning>\\n\\n<question text>"
48+
- `options[]`: an array of `{label, description}` objects. Place the **recommended option first** and prefix its `description` with `Recommended — <reasoning>.`
49+
Build each option as: `{label: "<A|B|C|...>", description: "<option description>"}`.
50+
- Append a final option: `{label: "Short", description: "Provide my own short answer (≤5 words)"}` to preserve the free-form escape hatch.
51+
- `multiSelect`: `false`
52+
- If the user selects the "Short" option, ask a follow-up free-text question constrained to ≤5 words.
53+
- For short‑answer style (no meaningful discrete options):
54+
- Determine your **suggested answer** based on best practices and context.
55+
- Use the `AskUserQuestion` tool:
56+
- `question`: "Suggested: <your proposed answer> — <brief reasoning>\\n\\n<question text>\\nFormat: Short answer (≤5 words)."
57+
- `options[]`: `[{label: "Accept suggestion", description: "Use the suggested answer above"}, {label: "Custom", description: "Provide my own short answer (≤5 words)"}]`
58+
- `multiSelect`: `false`
59+
- If the user selects "Custom", ask a follow-up free-text question constrained to ≤5 words."""
60+
61+
# Replacement block for /checklist. Maps the `Option | Candidate | Why It
62+
# Matters` table schema to AskUserQuestion's `{label, description}` shape:
63+
# - "Candidate" becomes the option `label`
64+
# - "Why It Matters" becomes the option `description`
65+
_CHECKLIST_ASK_USER = """\
66+
Question formatting rules:
67+
- If presenting options, use the `AskUserQuestion` tool to present a native structured picker:
68+
- `question`: the clarification question text
69+
- `options[]`: an array of `{label, description}` objects. For each candidate option: `{label: "<Candidate value>", description: "<Why It Matters value>"}`.
70+
- Append a final option: `{label: "Custom", description: "Provide my own short answer (≤5 words)"}` to preserve the free-form escape hatch.
71+
- `multiSelect`: `false`
72+
- If the user selects the "Custom" option, ask a follow-up free-text question.
73+
- Omit the picker if a free-form answer is clearer (use `AskUserQuestion` with only a `question` and no `options[]` in that case).
74+
- Never ask the user to restate what they already said.
75+
- Avoid speculative categories (no hallucination). If uncertain, ask explicitly: "Confirm whether X belongs in scope.\""""
76+
77+
# Map of skill stem → replacement content for the question-rendering fence.
78+
_QUESTION_RENDER_REPLACEMENTS: dict[str, str] = {
79+
"clarify": _CLARIFY_ASK_USER,
80+
"checklist": _CHECKLIST_ASK_USER,
81+
}
82+
2783

2884
class ClaudeIntegration(SkillsIntegration):
2985
"""Integration for Claude Code skills."""
@@ -44,6 +100,21 @@ class ClaudeIntegration(SkillsIntegration):
44100
}
45101
context_file = "CLAUDE.md"
46102

103+
@staticmethod
104+
def replace_question_render_block(content: str, replacement: str) -> str:
105+
"""Replace the fenced question-rendering block with *replacement*.
106+
107+
Looks for ``<!-- speckit:question-render:begin -->`` …
108+
``<!-- speckit:question-render:end -->`` and swaps the entire fence
109+
(markers included) with *replacement*. Returns *content* unchanged
110+
when no fence is found.
111+
"""
112+
pattern = re.compile(
113+
re.escape(_QUESTION_FENCE_BEGIN) + r".*?" + re.escape(_QUESTION_FENCE_END),
114+
re.DOTALL,
115+
)
116+
return pattern.sub(replacement, content, count=1)
117+
47118
@staticmethod
48119
def inject_argument_hint(content: str, hint: str) -> str:
49120
"""Insert ``argument-hint`` after the first ``description:`` in YAML frontmatter.
@@ -188,6 +259,11 @@ def setup(
188259
if hint:
189260
updated = self.inject_argument_hint(updated, hint)
190261

262+
# Replace question-rendering fences with AskUserQuestion instructions
263+
replacement = _QUESTION_RENDER_REPLACEMENTS.get(stem, "")
264+
if replacement:
265+
updated = self.replace_question_render_block(updated, replacement)
266+
191267
if updated != content:
192268
path.write_bytes(updated.encode("utf-8"))
193269
self.record_file_in_manifest(path, project_root, manifest)

templates/commands/checklist.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -93,11 +93,13 @@ You **MUST** consider the user input before proceeding (if not empty).
9393
- Boundary exclusion (e.g., "Should we explicitly exclude performance tuning items this round?")
9494
- Scenario class gap (e.g., "No recovery flows detected—are rollback / partial failure paths in scope?")
9595
96+
<!-- speckit:question-render:begin -->
9697
Question formatting rules:
9798
- If presenting options, generate a compact table with columns: Option | Candidate | Why It Matters
9899
- Limit to A–E options maximum; omit table if a free-form answer is clearer
99100
- Never ask the user to restate what they already said
100101
- Avoid speculative categories (no hallucination). If uncertain, ask explicitly: "Confirm whether X belongs in scope."
102+
<!-- speckit:question-render:end -->
101103
102104
Defaults when interaction impossible:
103105
- Depth: Standard

templates/commands/clarify.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -135,6 +135,7 @@ Execution steps:
135135
136136
4. Sequential questioning loop (interactive):
137137
- Present EXACTLY ONE question at a time.
138+
<!-- speckit:question-render:begin -->
138139
- For multiple‑choice questions:
139140
- **Analyze all options** and determine the **most suitable option** based on:
140141
- Best practices for the project type
@@ -157,6 +158,7 @@ Execution steps:
157158
- Provide your **suggested answer** based on best practices and context.
158159
- Format as: `**Suggested:** <your proposed answer> - <brief reasoning>`
159160
- Then output: `Format: Short answer (<=5 words). You can accept the suggestion by saying "yes" or "suggested", or provide your own answer.`
161+
<!-- speckit:question-render:end -->
160162
- After the user answers:
161163
- If the user replies with "yes", "recommended", or "suggested", use your previously stated recommendation/suggestion as the answer.
162164
- Otherwise, validate the answer maps to one option or fits the <=5 word constraint.

tests/integrations/test_integration_claude.py

Lines changed: 164 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -400,3 +400,167 @@ def test_inject_argument_hint_skips_if_already_present(self):
400400
lines = result.splitlines()
401401
hint_count = sum(1 for ln in lines if ln.startswith("argument-hint:"))
402402
assert hint_count == 1
403+
404+
405+
class TestQuestionRenderReplacement:
406+
"""Verify that question-render fences are replaced with AskUserQuestion for Claude skills."""
407+
408+
def test_clarify_skill_uses_ask_user_question(self, tmp_path):
409+
"""speckit-clarify/SKILL.md must contain AskUserQuestion instructions,
410+
not the Option | Description table."""
411+
i = get_integration("claude")
412+
m = IntegrationManifest("claude", tmp_path)
413+
i.setup(tmp_path, m, script_type="sh")
414+
415+
skill_file = tmp_path / ".claude" / "skills" / "speckit-clarify" / "SKILL.md"
416+
assert skill_file.exists()
417+
content = skill_file.read_text(encoding="utf-8")
418+
419+
# Must contain AskUserQuestion instructions
420+
assert "AskUserQuestion" in content
421+
# Must NOT contain the original Markdown table header
422+
assert "| Option | Description |" not in content
423+
# Must NOT contain the fence markers
424+
assert "speckit:question-render:begin" not in content
425+
assert "speckit:question-render:end" not in content
426+
# Must contain the free-form escape hatch option
427+
assert "Provide my own short answer" in content
428+
429+
def test_checklist_skill_uses_ask_user_question(self, tmp_path):
430+
"""speckit-checklist/SKILL.md must contain AskUserQuestion instructions,
431+
not the Option | Candidate | Why It Matters table."""
432+
i = get_integration("claude")
433+
m = IntegrationManifest("claude", tmp_path)
434+
i.setup(tmp_path, m, script_type="sh")
435+
436+
skill_file = tmp_path / ".claude" / "skills" / "speckit-checklist" / "SKILL.md"
437+
assert skill_file.exists()
438+
content = skill_file.read_text(encoding="utf-8")
439+
440+
# Must contain AskUserQuestion instructions
441+
assert "AskUserQuestion" in content
442+
# Must NOT contain the original table schema
443+
assert "| Candidate | Why It Matters" not in content
444+
# Must NOT contain the fence markers
445+
assert "speckit:question-render:begin" not in content
446+
assert "speckit:question-render:end" not in content
447+
448+
def test_clarify_recommended_option_first(self, tmp_path):
449+
"""The Claude clarify skill must instruct placing the recommended option first."""
450+
i = get_integration("claude")
451+
m = IntegrationManifest("claude", tmp_path)
452+
i.setup(tmp_path, m, script_type="sh")
453+
454+
skill_file = tmp_path / ".claude" / "skills" / "speckit-clarify" / "SKILL.md"
455+
content = skill_file.read_text(encoding="utf-8")
456+
457+
assert "recommended option first" in content.lower()
458+
assert "Recommended —" in content
459+
460+
def test_non_question_skills_unchanged(self, tmp_path):
461+
"""Skills other than clarify/checklist must NOT contain AskUserQuestion."""
462+
i = get_integration("claude")
463+
m = IntegrationManifest("claude", tmp_path)
464+
i.setup(tmp_path, m, script_type="sh")
465+
466+
skills_dir = tmp_path / ".claude" / "skills"
467+
for skill_dir in skills_dir.iterdir():
468+
if not skill_dir.is_dir():
469+
continue
470+
stem = skill_dir.name
471+
if stem in ("speckit-clarify", "speckit-checklist"):
472+
continue
473+
skill_file = skill_dir / "SKILL.md"
474+
if skill_file.exists():
475+
content = skill_file.read_text(encoding="utf-8")
476+
assert "AskUserQuestion" not in content, (
477+
f"{stem}/SKILL.md should not contain AskUserQuestion"
478+
)
479+
480+
def test_clarify_preserves_downstream_behavior(self, tmp_path):
481+
"""The clarify skill must still contain question-cap and validation instructions."""
482+
i = get_integration("claude")
483+
m = IntegrationManifest("claude", tmp_path)
484+
i.setup(tmp_path, m, script_type="sh")
485+
486+
skill_file = tmp_path / ".claude" / "skills" / "speckit-clarify" / "SKILL.md"
487+
content = skill_file.read_text(encoding="utf-8")
488+
489+
# 5-question cap
490+
assert "5" in content
491+
assert "maximum" in content.lower() or "Maximum" in content
492+
# Validation pass
493+
assert "Validation" in content
494+
# Incremental spec writes
495+
assert "Clarifications" in content
496+
497+
def test_checklist_preserves_downstream_behavior(self, tmp_path):
498+
"""The checklist skill must still contain question-cap and labeling instructions."""
499+
i = get_integration("claude")
500+
m = IntegrationManifest("claude", tmp_path)
501+
i.setup(tmp_path, m, script_type="sh")
502+
503+
skill_file = tmp_path / ".claude" / "skills" / "speckit-checklist" / "SKILL.md"
504+
content = skill_file.read_text(encoding="utf-8")
505+
506+
# 3-initial / up-to-5-total cap
507+
assert "THREE" in content or "three" in content.lower()
508+
assert "Q1" in content or "Q4" in content or "Q5" in content
509+
# Generation algorithm preserved
510+
assert "Generation algorithm" in content
511+
512+
def test_replace_question_render_block_no_fence(self):
513+
"""Content without a fence must be returned unchanged."""
514+
from specify_cli.integrations.claude import ClaudeIntegration
515+
516+
content = "No fence here.\nJust regular text.\n"
517+
result = ClaudeIntegration.replace_question_render_block(content, "REPLACED")
518+
assert result == content
519+
520+
def test_replace_question_render_block_basic(self):
521+
"""A fenced block must be replaced with the given content."""
522+
from specify_cli.integrations.claude import ClaudeIntegration
523+
524+
content = (
525+
"Before\n"
526+
"<!-- speckit:question-render:begin -->\n"
527+
"Old content\n"
528+
"More old content\n"
529+
"<!-- speckit:question-render:end -->\n"
530+
"After\n"
531+
)
532+
result = ClaudeIntegration.replace_question_render_block(content, "NEW BLOCK")
533+
assert "NEW BLOCK" in result
534+
assert "Old content" not in result
535+
assert "Before\n" in result
536+
assert "After\n" in result
537+
assert "speckit:question-render" not in result
538+
539+
540+
class TestNonClaudeIntegrationsParity:
541+
"""Verify non-Claude integrations produce output unaffected by the fence markers."""
542+
543+
def test_markdown_integrations_contain_fence_as_is(self, tmp_path):
544+
"""MarkdownIntegration-based agents must output the fence as plain Markdown."""
545+
from specify_cli.integrations import get_integration as get_int
546+
547+
# Pick a representative Markdown integration
548+
for key in ("windsurf", "cursor-agent"):
549+
integration = get_int(key)
550+
if integration is None:
551+
continue
552+
m = IntegrationManifest(key, tmp_path)
553+
created = integration.setup(tmp_path, m, script_type="sh")
554+
555+
# Find clarify command output
556+
clarify_files = [
557+
f for f in created
558+
if "clarify" in f.name.lower() or "clarify" in str(f.parent).lower()
559+
]
560+
for f in clarify_files:
561+
content = f.read_text(encoding="utf-8")
562+
# The fence is invisible HTML comments — just verify no
563+
# AskUserQuestion was injected
564+
assert "AskUserQuestion" not in content, (
565+
f"{key}: {f} should not contain AskUserQuestion"
566+
)

0 commit comments

Comments
 (0)