Skip to content

Commit df062dd

Browse files
authored
Merge remote-tracking branch 'origin/main' into add-dotdog-extension-50d2a7dab9bb17c6
# Conflicts: # docs/community/extensions.md
2 parents 5b5f8a9 + 29eb6ed commit df062dd

9 files changed

Lines changed: 358 additions & 6 deletions

File tree

CHANGELOG.md

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

33
<!-- insert new changelog below this comment -->
44

5+
## [0.12.17] - 2026-07-16
6+
7+
### Changed
8+
9+
- fix(extensions): resolve __SPECKIT_COMMAND tokens in auto-registered skills (#3544)
10+
- fix(workflows): fail if/switch steps on non-list branch instead of crashing (#3515)
11+
- feat(integrations): add Grok Build skills-based integration (#3535)
12+
- fix(extensions/git): reject negative -Number in create-new-feature-branch.ps1 (#3538)
13+
- test: cover preset constitution seeding through init CLI (#3297)
14+
- fix(integration): preserve ai_skills on `use` for skills-mode Copilot (#3550) (#3551)
15+
- [extension] Add Figma Starter extension to community catalog (#3547)
16+
- [extension] Add Spec-Kit BDD extension to community catalog (#3548)
17+
- [extension] Update Quality Gates (Enforcement Layer) extension to v0.3.2 (#3542)
18+
- chore: release 0.12.16, begin 0.12.17.dev0 development (#3549)
19+
520
## [0.12.16] - 2026-07-15
621

722
### Changed

docs/community/extensions.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -51,7 +51,7 @@ The following community-contributed extensions are available in [`catalog.commun
5151
| Confluence Extension | Create a doc in Confluence summarizing the specifications and planning files | `integration` | Read+Write | [spec-kit-confluence](https://github.com/aaronrsun/spec-kit-confluence) |
5252
| Cost Tracker | Track real LLM dollar cost across SDD workflows — per-feature budgets, per-integration comparison, and finance-ready exports | `visibility` | Read+Write | [spec-kit-cost](https://github.com/Quratulain-bilal/spec-kit-cost) |
5353
| Data Model Diagram | Generates Mermaid ER diagrams from Spec Kit data models after planning | `docs` | Read+Write | [spec-kit-data-model-diagram](https://github.com/benizzio/spec-kit-data-model-diagram) |
54-
| DocGuard — CDD Enforcement | Doc-integrity engine with MCP server, SARIF output, and zero-LLM core. Validates, scores, and traces documentation against code — 24 validators, stable finding codes, spec-kit hooks. Pure Node.js. | `docs` | Read+Write | [spec-kit-docguard](https://github.com/raccioly/docguard) |
54+
| DocGuard — CDD Enforcement | The only doc-integrity engine with an MCP server, SARIF/JUnit output, and a deterministic zero-LLM core. Validates, scores, and traces documentation against code — 27 validators, stable finding codes, adoption baseline for legacy repos, compliance-evidence reports, GitHub Action with PR annotations, spec-kit hooks. Pure Node.js, one pinned dep. | `docs` | Read+Write | [spec-kit-docguard](https://github.com/raccioly/docguard) |
5555
| Dotdog | Import GitHub Spec Kit artifacts into local knowledge graphs for validation, analysis, search, and MCP queries. | `docs` | Read+Write | [dotdog](https://github.com/specdog/dotdog) |
5656
| EARS Requirements Syntax | Author, lint, and convert requirements using EARS - the five industry-standard sentence patterns for unambiguous, testable requirements | `docs` | Read+Write | [spec-kit-ears](https://github.com/dhruv-15-03/spec-kit-ears) |
5757
| Extensify | Create and validate extensions and extension catalogs | `process` | Read+Write | [extensify](https://github.com/mnriem/spec-kit-extensions/tree/main/extensify) |

extensions/catalog.community.json

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1166,10 +1166,10 @@
11661166
"docguard": {
11671167
"name": "DocGuard — CDD Enforcement",
11681168
"id": "docguard",
1169-
"description": "Doc-integrity engine with MCP server, SARIF output, and zero-LLM core. Validates, scores, and traces documentation against code — 24 validators, stable finding codes, spec-kit hooks. Pure Node.js.",
1169+
"description": "The only doc-integrity engine with an MCP server, SARIF/JUnit output, and a deterministic zero-LLM core. Validates, scores, and traces documentation against code — 27 validators, stable finding codes, adoption baseline for legacy repos, compliance-evidence reports, GitHub Action with PR annotations, spec-kit hooks. Pure Node.js, one pinned dep.",
11701170
"author": "raccioly",
1171-
"version": "0.32.0",
1172-
"download_url": "https://github.com/raccioly/docguard/releases/download/v0.32.0/spec-kit-docguard-v0.32.0.zip",
1171+
"version": "0.33.0",
1172+
"download_url": "https://github.com/raccioly/docguard/releases/download/v0.33.0/spec-kit-docguard-v0.33.0.zip",
11731173
"repository": "https://github.com/raccioly/docguard",
11741174
"homepage": "https://www.npmjs.com/package/docguard-cli",
11751175
"documentation": "https://github.com/raccioly/docguard/blob/main/extensions/spec-kit-docguard/README.md",
@@ -1213,7 +1213,7 @@
12131213
"downloads": 0,
12141214
"stars": 0,
12151215
"created_at": "2026-03-13T00:00:00Z",
1216-
"updated_at": "2026-07-13T00:00:00Z"
1216+
"updated_at": "2026-07-16T00:00:00Z"
12171217
},
12181218
"doctor": {
12191219
"name": "Project Health Check",

pyproject.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
[project]
22
name = "specify-cli"
3-
version = "0.12.17.dev0"
3+
version = "0.12.18.dev0"
44
description = "Specify CLI, part of GitHub Spec Kit. A tool to bootstrap your projects for Spec-Driven Development (SDD)."
55
readme = "README.md"
66
requires-python = ">=3.11"

src/specify_cli/extensions/__init__.py

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1004,6 +1004,7 @@ def _register_extension_skills(
10041004
from .. import load_init_options
10051005
from ..agents import CommandRegistrar
10061006
from ..integrations import get_integration
1007+
from ..integrations.base import IntegrationBase
10071008

10081009
written: List[str] = []
10091010
opts = load_init_options(self.project_root)
@@ -1015,6 +1016,30 @@ def _register_extension_skills(
10151016
registrar = CommandRegistrar()
10161017
agent_config = registrar.AGENT_CONFIGS.get(selected_ai, {})
10171018
integration = get_integration(selected_ai)
1019+
ai_skills_enabled = is_ai_skills_enabled(opts)
1020+
1021+
def _resolve_command_ref_tokens(body: str) -> str:
1022+
"""Resolve explicit command-ref tokens with the active skill style."""
1023+
1024+
def _replacement(match: re.Match[str]) -> str:
1025+
command_name = "speckit." + match.group(1).lower().replace("_", ".")
1026+
if is_dollar_skills_agent(selected_ai, ai_skills_enabled):
1027+
return "$" + command_name.replace("speckit.", "speckit-").replace(
1028+
".", "-"
1029+
)
1030+
if is_slash_skills_agent(selected_ai, ai_skills_enabled):
1031+
return "/" + command_name.replace("speckit.", "speckit-").replace(
1032+
".", "-"
1033+
)
1034+
if integration is not None:
1035+
return integration.build_command_invocation(command_name)
1036+
return IntegrationBase.resolve_command_refs(
1037+
match.group(0), agent_config.get("invoke_separator", ".")
1038+
)
1039+
1040+
return re.sub(
1041+
r"__SPECKIT_COMMAND_([A-Z][A-Z0-9_]*)__", _replacement, body
1042+
)
10181043

10191044
for cmd_info in manifest.commands:
10201045
cmd_name = cmd_info["name"]
@@ -1086,6 +1111,7 @@ def _register_extension_skills(
10861111
body = registrar.resolve_skill_placeholders(
10871112
selected_ai, frontmatter, body, self.project_root, extension_id=manifest.id
10881113
)
1114+
body = _resolve_command_ref_tokens(body)
10891115

10901116
original_desc = frontmatter.get("description", "")
10911117
description = original_desc or f"Extension command: {cmd_name}"

src/specify_cli/workflows/steps/if_then/__init__.py

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,10 +22,33 @@ def execute(self, config: dict[str, Any], context: StepContext) -> StepResult:
2222
result = evaluate_condition(condition, context)
2323

2424
if result:
25+
branch_name = "then"
2526
branch = config.get("then", [])
2627
else:
28+
branch_name = "else"
2729
branch = config.get("else", [])
2830

31+
# The engine does not auto-validate step config (see
32+
# ``WorkflowEngine.load_workflow``), and it feeds ``next_steps`` straight
33+
# into ``_execute_steps`` which iterates them as step mappings. A
34+
# non-list branch (a single mapping or scalar authoring mistake) would
35+
# otherwise be iterated element-wise — a dict yields its string keys, a
36+
# str its characters — and crash the whole run with AttributeError on
37+
# ``.get()``. ``validate`` already rejects a non-list branch; fail this
38+
# step loudly on an unvalidated run instead, mirroring the switch/fan-out
39+
# steps. A missing ``else`` defaults to ``[]`` and stays valid.
40+
if branch is None and branch_name == "else":
41+
branch = []
42+
elif not isinstance(branch, list):
43+
return StepResult(
44+
status=StepStatus.FAILED,
45+
output={"condition_result": result},
46+
error=(
47+
f"If step {config.get('id', '?')!r}: {branch_name!r} must be "
48+
f"a list of steps, got {type(branch).__name__}."
49+
),
50+
)
51+
2952
return StepResult(
3053
status=StepStatus.COMPLETED,
3154
output={"condition_result": result},

src/specify_cli/workflows/steps/switch/__init__.py

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -42,6 +42,10 @@ def execute(self, config: dict[str, Any], context: StepContext) -> StepResult:
4242
)
4343
for case_key, case_steps in cases.items():
4444
if str(case_key) == str_value:
45+
if not isinstance(case_steps, list):
46+
return self._non_list_branch_failure(
47+
config, f"case {str(case_key)!r}", case_steps, value
48+
)
4549
return StepResult(
4650
status=StepStatus.COMPLETED,
4751
output={"matched_case": str(case_key), "expression_value": value},
@@ -50,12 +54,41 @@ def execute(self, config: dict[str, Any], context: StepContext) -> StepResult:
5054

5155
# Default fallback
5256
default_steps = config.get("default", [])
57+
if default_steps is None:
58+
default_steps = []
59+
elif not isinstance(default_steps, list):
60+
return self._non_list_branch_failure(
61+
config, "'default'", default_steps, value
62+
)
5363
return StepResult(
5464
status=StepStatus.COMPLETED,
5565
output={"matched_case": "__default__", "expression_value": value},
5666
next_steps=default_steps,
5767
)
5868

69+
@staticmethod
70+
def _non_list_branch_failure(
71+
config: dict[str, Any], branch_label: str, branch: Any, value: Any
72+
) -> StepResult:
73+
"""Fail the step for a non-list branch instead of crashing the run.
74+
75+
``validate`` rejects a non-list case/default branch, but the engine does
76+
not auto-validate and feeds ``next_steps`` straight into
77+
``_execute_steps``, which iterates them as step mappings. A non-list
78+
branch would be iterated element-wise (a dict yields its keys, a str its
79+
characters) and crash the whole run with AttributeError on ``.get()``.
80+
Fail this step loudly on an unvalidated run instead, mirroring the
81+
non-mapping ``cases`` guard above.
82+
"""
83+
return StepResult(
84+
status=StepStatus.FAILED,
85+
output={"matched_case": None, "expression_value": value},
86+
error=(
87+
f"Switch step {config.get('id', '?')!r}: {branch_label} must be "
88+
f"a list of steps, got {type(branch).__name__}."
89+
),
90+
)
91+
5992
def validate(self, config: dict[str, Any]) -> list[str]:
6093
errors = super().validate(config)
6194
if "expression" not in config:

tests/test_extension_skills.py

Lines changed: 107 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -970,6 +970,113 @@ def test_skill_registration_rewrites_extension_subdir_paths(self, project_dir, t
970970
assert "Read agents/control" not in content
971971
assert "and knowledge-base/" not in content
972972

973+
@pytest.mark.parametrize(
974+
("ai", "expected_invocation"),
975+
[
976+
("claude", "/speckit-plan"),
977+
("copilot", "/speckit-plan"),
978+
("codex", "$speckit-plan"),
979+
("kimi", "/skill:speckit-plan"),
980+
("zcode", "$speckit-plan"),
981+
],
982+
)
983+
def test_skill_registration_resolves_command_ref_tokens(
984+
self, project_dir, temp_dir, ai, expected_invocation
985+
):
986+
"""Auto-registered skills should resolve explicit command ref tokens."""
987+
_create_init_options(project_dir, ai=ai, ai_skills=True)
988+
skills_dir = _create_skills_dir(project_dir, ai=ai)
989+
990+
ext_dir = temp_dir / "command-ref-ext"
991+
ext_dir.mkdir()
992+
manifest_data = {
993+
"schema_version": "1.0",
994+
"extension": {
995+
"id": "command-ref-ext",
996+
"name": "Command Ref Extension",
997+
"version": "1.0.0",
998+
"description": "Test",
999+
},
1000+
"requires": {"speckit_version": ">=0.1.0"},
1001+
"provides": {
1002+
"commands": [
1003+
{
1004+
"name": "speckit.command-ref-ext.run",
1005+
"file": "commands/run.md",
1006+
"description": "Run command",
1007+
}
1008+
]
1009+
},
1010+
}
1011+
with open(ext_dir / "extension.yml", "w") as f:
1012+
yaml.safe_dump(manifest_data, f)
1013+
1014+
(ext_dir / "commands").mkdir()
1015+
(ext_dir / "commands" / "run.md").write_text(
1016+
"---\n"
1017+
"description: Run command\n"
1018+
"---\n\n"
1019+
"Use __SPECKIT_COMMAND_PLAN__ before proceeding.\n"
1020+
)
1021+
1022+
manager = ExtensionManager(project_dir)
1023+
manager.install_from_directory(ext_dir, "0.1.0", register_commands=False)
1024+
1025+
content = (skills_dir / "speckit-command-ref-ext-run" / "SKILL.md").read_text()
1026+
assert "__SPECKIT_COMMAND_PLAN__" not in content
1027+
assert expected_invocation in content
1028+
1029+
def test_skill_registration_does_not_rewrite_literal_speckit_text(
1030+
self, project_dir, temp_dir
1031+
):
1032+
"""Auto-registered skills should leave literal speckit text untouched."""
1033+
_create_init_options(project_dir, ai="codex", ai_skills=True)
1034+
skills_dir = _create_skills_dir(project_dir, ai="codex")
1035+
1036+
ext_dir = temp_dir / "literal-ref-ext"
1037+
ext_dir.mkdir()
1038+
manifest_data = {
1039+
"schema_version": "1.0",
1040+
"extension": {
1041+
"id": "literal-ref-ext",
1042+
"name": "Literal Ref Extension",
1043+
"version": "1.0.0",
1044+
"description": "Test",
1045+
},
1046+
"requires": {"speckit_version": ">=0.1.0"},
1047+
"provides": {
1048+
"commands": [
1049+
{
1050+
"name": "speckit.literal-ref-ext.run",
1051+
"file": "commands/run.md",
1052+
"description": "Run command",
1053+
}
1054+
]
1055+
},
1056+
}
1057+
with open(ext_dir / "extension.yml", "w") as f:
1058+
yaml.safe_dump(manifest_data, f)
1059+
1060+
(ext_dir / "commands").mkdir()
1061+
(ext_dir / "commands" / "run.md").write_text(
1062+
"---\n"
1063+
"description: Run command\n"
1064+
"---\n\n"
1065+
"Literal slash form: /speckit.foo.bar\n"
1066+
"Literal skill form: /speckit-plan\n"
1067+
"Literal bare form: speckit.foo.bar\n"
1068+
)
1069+
1070+
manager = ExtensionManager(project_dir)
1071+
manager.install_from_directory(ext_dir, "0.1.0", register_commands=False)
1072+
1073+
content = (skills_dir / "speckit-literal-ref-ext-run" / "SKILL.md").read_text()
1074+
assert "/speckit.foo.bar" in content
1075+
assert "/speckit-plan" in content
1076+
assert "speckit.foo.bar" in content
1077+
assert "/speckit-foo-bar" not in content
1078+
assert "$speckit-plan" not in content
1079+
9731080
def test_missing_command_file_skipped(self, skills_project, temp_dir):
9741081
"""Commands with missing source files should be skipped gracefully."""
9751082
project_dir, skills_dir = skills_project

0 commit comments

Comments
 (0)