-
Notifications
You must be signed in to change notification settings - Fork 7.5k
feat: add question-render fenced markers and Claude transformer #2186
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1 @@ | ||
| # Core utilities for specify-cli. |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,137 @@ | ||
| """Question block transformer for Claude Code integration.""" | ||
|
|
||
| from __future__ import annotations | ||
|
|
||
| import json | ||
| import re | ||
|
|
||
| _FENCE_RE = re.compile( | ||
| r"<!-- speckit:question-render:begin -->\s*\n(.*?)\n\s*<!-- speckit:question-render:end -->", | ||
| re.DOTALL, | ||
| ) | ||
| _SEPARATOR_RE = re.compile(r"^\|[-| :]+\|$") | ||
|
|
||
| # Markers that promote an option to the top of the list. | ||
| _RECOMMENDED_RE = re.compile(r"\bRecommended\b\s*[\u2014\-]", re.IGNORECASE) | ||
|
|
||
|
|
||
| def _parse_table_rows(block: str) -> list[list[str]]: | ||
| """Return data rows from a Markdown table, skipping header and separator. | ||
|
|
||
| Handles leading indentation (as found in clarify.md / checklist.md). | ||
| Rows with pipe characters inside cell values are not supported by | ||
| standard Markdown tables, so no special handling is needed. | ||
| """ | ||
| rows: list[list[str]] = [] | ||
| header_seen = False | ||
| separator_seen = False | ||
|
|
||
| for line in block.splitlines(): | ||
| stripped = line.strip() | ||
| if not stripped.startswith("|"): | ||
| continue | ||
| if not header_seen: | ||
| header_seen = True | ||
| continue | ||
| if not separator_seen: | ||
| if _SEPARATOR_RE.match(stripped): | ||
| separator_seen = True | ||
| continue | ||
| cells = [c.strip() for c in stripped.split("|")[1:-1]] | ||
| if cells: | ||
| rows.append(cells) | ||
|
|
||
| return rows | ||
|
|
||
|
|
||
| def parse_clarify(block: str) -> list[dict]: | ||
| """Parse clarify.md schema: | Option | Description | | ||
|
|
||
| - Rows matching ``Recommended —`` / ``Recommended -`` (case-insensitive) | ||
| are placed first. | ||
| - Duplicate labels are deduplicated (first occurrence wins). | ||
| """ | ||
| options: list[dict] = [] | ||
| recommended: dict | None = None | ||
| seen_labels: set[str] = set() | ||
|
|
||
| for cells in _parse_table_rows(block): | ||
| if len(cells) < 2: | ||
| continue | ||
| label = cells[0] | ||
| description = cells[1] | ||
| if label in seen_labels: | ||
| continue | ||
| seen_labels.add(label) | ||
| entry = {"label": label, "description": description} | ||
| if _RECOMMENDED_RE.search(description): | ||
| if recommended is None: | ||
| recommended = entry | ||
| else: | ||
| options.append(entry) | ||
|
Comment on lines
+58
to
+71
|
||
|
|
||
| if recommended: | ||
| options.insert(0, recommended) | ||
|
|
||
| return options | ||
|
|
||
|
|
||
| def parse_checklist(block: str) -> list[dict]: | ||
| """Parse checklist.md schema: | Option | Candidate | Why It Matters | | ||
|
|
||
| Candidate → label, Why It Matters → description. | ||
| Duplicate labels are deduplicated (first occurrence wins). | ||
| """ | ||
| options: list[dict] = [] | ||
| seen_labels: set[str] = set() | ||
|
|
||
| for cells in _parse_table_rows(block): | ||
| if len(cells) < 3: | ||
| continue | ||
| label = cells[1] | ||
| description = cells[2] | ||
| if label in seen_labels: | ||
| continue | ||
| seen_labels.add(label) | ||
| options.append({"label": label, "description": description}) | ||
|
|
||
| return options | ||
|
|
||
|
|
||
| def _build_payload(options: list[dict]) -> str: | ||
| """Serialise options into a validated AskUserQuestion JSON code block.""" | ||
| # Append "Other" only if not already present. | ||
| if not any(o["label"].lower() == "other" for o in options): | ||
| options = options + [ | ||
| { | ||
| "label": "Other", | ||
| "description": "Provide my own short answer (\u226410 words)", | ||
| } | ||
| ] | ||
|
|
||
| payload: dict = { | ||
| "question": "Please select an option:", | ||
| "multiSelect": False, | ||
| "options": options, | ||
| } | ||
|
|
||
| # Validate round-trip before returning — raises ValueError on bad data. | ||
| raw = json.dumps(payload, ensure_ascii=False, indent=2) | ||
| json.loads(raw) # round-trip check | ||
| return f"```json\n{raw}\n```" | ||
|
|
||
|
|
||
| def transform_question_block(content: str) -> str: | ||
| """Replace fenced question blocks with AskUserQuestion JSON payloads. | ||
|
|
||
| Content without markers is returned byte-identical — safe for all | ||
| non-Claude integrations. | ||
| """ | ||
|
|
||
| def _replace(match: re.Match) -> str: | ||
| block = match.group(1) | ||
| is_checklist = "| Candidate |" in block or "|Candidate|" in block | ||
| options = parse_checklist(block) if is_checklist else parse_clarify(block) | ||
| return _build_payload(options) | ||
|
|
||
| return _FENCE_RE.sub(_replace, content) | ||
| Original file line number | Diff line number | Diff line change | ||||||||||||||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
|
|
@@ -94,7 +94,15 @@ You **MUST** consider the user input before proceeding (if not empty). | |||||||||||||||||||||||||
| - Scenario class gap (e.g., "No recovery flows detected—are rollback / partial failure paths in scope?") | ||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||
| Question formatting rules: | ||||||||||||||||||||||||||
| - If presenting options, generate a compact table with columns: Option | Candidate | Why It Matters | ||||||||||||||||||||||||||
| - If presenting options, generate a compact table with columns: | ||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||
| <!-- speckit:question-render:begin --> | ||||||||||||||||||||||||||
| | Option | Candidate | Why It Matters | | ||||||||||||||||||||||||||
| |--------|-----------|----------------| | ||||||||||||||||||||||||||
| | A | <Candidate A> | <Why it matters> | | ||||||||||||||||||||||||||
| | B | <Candidate B> | <Why it matters> | | ||||||||||||||||||||||||||
| <!-- speckit:question-render:end --> | ||||||||||||||||||||||||||
|
Comment on lines
+99
to
+104
|
||||||||||||||||||||||||||
| <!-- speckit:question-render:begin --> | |
| | Option | Candidate | Why It Matters | | |
| |--------|-----------|----------------| | |
| | A | <Candidate A> | <Why it matters> | | |
| | B | <Candidate B> | <Why it matters> | | |
| <!-- speckit:question-render:end --> | |
| <!-- speckit:question-render:begin --> | |
| | Option | Candidate | Why It Matters | | |
| |--------|-----------|----------------| | |
| | A | <Candidate A> | <Why it matters> | | |
| | B | <Candidate B> | <Why it matters> | | |
| <!-- speckit:question-render:end --> |
| Original file line number | Diff line number | Diff line change | ||||||||||||||||||||||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
|
|
@@ -145,12 +145,14 @@ Execution steps: | |||||||||||||||||||||||||||||||||
| - Format as: `**Recommended:** Option [X] - <reasoning>` | ||||||||||||||||||||||||||||||||||
| - Then render all options as a Markdown table: | ||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||
| <!-- speckit:question-render:begin --> | ||||||||||||||||||||||||||||||||||
| | Option | Description | | ||||||||||||||||||||||||||||||||||
| |--------|-------------| | ||||||||||||||||||||||||||||||||||
| | A | <Option A description> | | ||||||||||||||||||||||||||||||||||
| | B | <Option B description> | | ||||||||||||||||||||||||||||||||||
| | C | <Option C description> (add D/E as needed up to 5) | | ||||||||||||||||||||||||||||||||||
| | Short | Provide a different short answer (<=5 words) (Include only if free-form alternative is appropriate) | | ||||||||||||||||||||||||||||||||||
| <!-- speckit:question-render:end --> | ||||||||||||||||||||||||||||||||||
|
Comment on lines
+148
to
+155
|
||||||||||||||||||||||||||||||||||
| <!-- speckit:question-render:begin --> | |
| | Option | Description | | |
| |--------|-------------| | |
| | A | <Option A description> | | |
| | B | <Option B description> | | |
| | C | <Option C description> (add D/E as needed up to 5) | | |
| | Short | Provide a different short answer (<=5 words) (Include only if free-form alternative is appropriate) | | |
| <!-- speckit:question-render:end --> | |
| <!-- speckit:question-render:begin --> | |
| | Option | Description | | |
| |--------|-------------| | |
| | A | <Option A description> | | |
| | B | <Option B description> | | |
| | C | <Option C description> (add D/E as needed up to 5) | | |
| | Short | Provide a different short answer (<=5 words) (Include only if free-form alternative is appropriate) | | |
| <!-- speckit:question-render:end --> |
Copilot
AI
Apr 13, 2026
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The transformer’s “recommended option first” behavior depends on a Recommended —/ - marker inside a table row description, but the template instructs the recommended line to be emitted above the table (**Recommended:** Option [X] ...) and that line is outside the fenced block. As a result, the transformer cannot actually infer which option is recommended from the template output. Either include the recommended line within the fenced block, or extend the transformer to also parse the recommended selection from adjacent text.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
_FENCE_RErequires the begin/end markers to start with<!--at column 1 (no leading whitespace). If the templates keep these markers inside nested lists (which typically require indentation), indented markers won’t match and the transform will silently not run. Consider allowing optional leading whitespace before both markers (and possibly making the newline before the end marker optional).This issue also appears on line 131 of the same file.