diff --git a/.github/ISSUE_TEMPLATE/agent_request.yml b/.github/ISSUE_TEMPLATE/agent_request.yml
index 785f9193e3..4e98030af6 100644
--- a/.github/ISSUE_TEMPLATE/agent_request.yml
+++ b/.github/ISSUE_TEMPLATE/agent_request.yml
@@ -8,7 +8,7 @@ body:
value: |
Thanks for requesting a new agent! Before submitting, please check if the agent is already supported.
- **Currently supported agents**: Alquimia AI, Amp, Antigravity, Auggie CLI, Claude Code, Cline, CodeBuddy, Codex CLI, Command Code, Cursor, Devin for Terminal, Factory Droid, Firebender, Forge, Gemini CLI, GitHub Copilot, Goose, Grok Build, Hermes Agent, IBM Bob, Junie, Kilo Code, Kimi Code, Kiro CLI, Lingma, Mistral Vibe, Oh My Pi, opencode, Pi Coding Agent, Qoder CLI, Qwen Code, RovoDev ACLI, SHAI, Tabnine CLI, Trae, ZCode, Zed
+ **Currently supported agents**: Alquimia AI, Amp, Antigravity, Auggie CLI, Claude Code, Cline, CodeBuddy, Codex CLI, Command Code, Cursor, Devin for Terminal, Docker Agent, Factory Droid, DeepSeek Harness, Firebender, Forge, Gemini CLI, GitHub Copilot, Goose, Grok Build, Hermes Agent, IBM Bob, Junie, Kilo Code, Kimi Code, Kiro CLI, Lingma, Mistral Vibe, Muse Code, Oh My Pi, opencode, Pi Coding Agent, Qoder CLI, Qwen Code, RovoDev ACLI, SHAI, Tabnine CLI, Trae, ZCode, Zed
- type: input
id: agent-name
diff --git a/.github/ISSUE_TEMPLATE/bug_report.yml b/.github/ISSUE_TEMPLATE/bug_report.yml
index 03fa6c124f..ca386d644c 100644
--- a/.github/ISSUE_TEMPLATE/bug_report.yml
+++ b/.github/ISSUE_TEMPLATE/bug_report.yml
@@ -73,7 +73,9 @@ body:
- Command Code
- Cursor
- Devin for Terminal
+ - Docker Agent
- Factory Droid
+ - DeepSeek Harness
- Firebender
- Forge
- Gemini CLI
@@ -88,6 +90,7 @@ body:
- Kiro CLI
- Lingma
- Mistral Vibe
+ - Muse Code
- Oh My Pi
- opencode
- Pi Coding Agent
diff --git a/.github/ISSUE_TEMPLATE/feature_request.yml b/.github/ISSUE_TEMPLATE/feature_request.yml
index 4613c8ebae..149ce45c19 100644
--- a/.github/ISSUE_TEMPLATE/feature_request.yml
+++ b/.github/ISSUE_TEMPLATE/feature_request.yml
@@ -67,7 +67,9 @@ body:
- Command Code
- Cursor
- Devin for Terminal
+ - Docker Agent
- Factory Droid
+ - DeepSeek Harness
- Firebender
- Forge
- Gemini CLI
@@ -82,6 +84,7 @@ body:
- Kiro CLI
- Lingma
- Mistral Vibe
+ - Muse Code
- Oh My Pi
- opencode
- Pi Coding Agent
diff --git a/.github/aw/actions-lock.json b/.github/aw/actions-lock.json
index 36daac9877..253a22b53f 100644
--- a/.github/aw/actions-lock.json
+++ b/.github/aw/actions-lock.json
@@ -25,10 +25,10 @@
"version": "v7.0.0",
"sha": "5fda3b95a4ea91299a34e894583c3862153e4b97"
},
- "astral-sh/setup-uv@v9.0.0": {
+ "astral-sh/setup-uv@v10.0.1": {
"repo": "astral-sh/setup-uv",
- "version": "v9.0.0",
- "sha": "c771a70e6277c0a99b617c7a806ffedaca235ff9"
+ "version": "v10.0.1",
+ "sha": "20cfd1bf945f4377ade1205e4dbc17946fc9a30d"
},
"actions/upload-artifact@v7.0.1": {
"repo": "actions/upload-artifact",
diff --git a/.github/workflows/bug-test.lock.yml b/.github/workflows/bug-test.lock.yml
index 810be3ae77..f4fe11ea64 100644
--- a/.github/workflows/bug-test.lock.yml
+++ b/.github/workflows/bug-test.lock.yml
@@ -1,5 +1,5 @@
-# gh-aw-metadata: {"schema_version":"v4","frontmatter_hash":"aa190ac1bd31b2e5e68cafd25951bda4d92a275ce1c55f58856f924e415fdb17","body_hash":"5aa25f2a19d30f31a71fb4fa9c709563d3d2c5060b2984f4ba913b7097158763","compiler_version":"v0.79.8","strict":true,"agent_id":"copilot","engine_versions":{"copilot":"1.0.60"}}
-# gh-aw-manifest: {"version":1,"secrets":["COPILOT_GITHUB_TOKEN","GH_AW_GITHUB_MCP_SERVER_TOKEN","GH_AW_GITHUB_TOKEN","GITHUB_TOKEN"],"actions":[{"repo":"actions/checkout","sha":"3d3c42e5aac5ba805825da76410c181273ba90b1","version":"v7.0.1"},{"repo":"actions/download-artifact","sha":"3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c","version":"v8.0.1"},{"repo":"actions/github-script","sha":"3a2844b7e9c422d3c10d287c895573f7108da1b3","version":"v9.0.0"},{"repo":"actions/setup-node","sha":"820762786026740c76f36085b0efc47a31fe5020","version":"v7.0.0"},{"repo":"actions/setup-python","sha":"5fda3b95a4ea91299a34e894583c3862153e4b97","version":"v7.0.0"},{"repo":"actions/upload-artifact","sha":"043fb46d1a93c77aae656e7c1c64a875d1fc6a0a","version":"v7.0.1"},{"repo":"astral-sh/setup-uv","sha":"c771a70e6277c0a99b617c7a806ffedaca235ff9","version":"v9.0.0"},{"repo":"github/gh-aw-actions/setup","sha":"c0338fef4749d08c21f8f975fb0e37efa17dda47","version":"v0.79.8"}],"containers":[{"image":"ghcr.io/github/gh-aw-firewall/agent:0.27.2","digest":"sha256:f88e5b17b6b7a600117bc121114d6ce2155c88c983c0c939c5df884f730fa1d6","pinned_image":"ghcr.io/github/gh-aw-firewall/agent:0.27.2@sha256:f88e5b17b6b7a600117bc121114d6ce2155c88c983c0c939c5df884f730fa1d6"},{"image":"ghcr.io/github/gh-aw-firewall/api-proxy:0.27.2","digest":"sha256:ee39841d980878ebbb87592903b06d31a1af500c71525c9616f7e8e2a27041a4","pinned_image":"ghcr.io/github/gh-aw-firewall/api-proxy:0.27.2@sha256:ee39841d980878ebbb87592903b06d31a1af500c71525c9616f7e8e2a27041a4"},{"image":"ghcr.io/github/gh-aw-firewall/squid:0.27.2","digest":"sha256:2e3a717e5f19a654cd9a2263beb52012b56bcb68562ec5ae2e42f9d156b49591","pinned_image":"ghcr.io/github/gh-aw-firewall/squid:0.27.2@sha256:2e3a717e5f19a654cd9a2263beb52012b56bcb68562ec5ae2e42f9d156b49591"},{"image":"ghcr.io/github/gh-aw-mcpg:v0.3.25","digest":"sha256:c10331ad17668ef89f38f5e356678788a40b0cd5fef96e8f92e1d9c1de47cbaa","pinned_image":"ghcr.io/github/gh-aw-mcpg:v0.3.25@sha256:c10331ad17668ef89f38f5e356678788a40b0cd5fef96e8f92e1d9c1de47cbaa"},{"image":"ghcr.io/github/github-mcp-server:v1.1.2","digest":"sha256:30197479d8036c7811892bc07e06f9a05c9ef3cdd79bc59f256d50647f95788c","pinned_image":"ghcr.io/github/github-mcp-server:v1.1.2@sha256:30197479d8036c7811892bc07e06f9a05c9ef3cdd79bc59f256d50647f95788c"}]}
+# gh-aw-metadata: {"schema_version":"v4","frontmatter_hash":"ec50d44af032f2f0c04073858a24d73cb1fa9036515b3bc7ee4dcfe02138f34a","body_hash":"5aa25f2a19d30f31a71fb4fa9c709563d3d2c5060b2984f4ba913b7097158763","compiler_version":"v0.79.8","strict":true,"agent_id":"copilot","engine_versions":{"copilot":"1.0.60"}}
+# gh-aw-manifest: {"version":1,"secrets":["COPILOT_GITHUB_TOKEN","GH_AW_GITHUB_MCP_SERVER_TOKEN","GH_AW_GITHUB_TOKEN","GITHUB_TOKEN"],"actions":[{"repo":"actions/checkout","sha":"3d3c42e5aac5ba805825da76410c181273ba90b1","version":"v7.0.1"},{"repo":"actions/download-artifact","sha":"3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c","version":"v8.0.1"},{"repo":"actions/github-script","sha":"3a2844b7e9c422d3c10d287c895573f7108da1b3","version":"v9.0.0"},{"repo":"actions/setup-node","sha":"820762786026740c76f36085b0efc47a31fe5020","version":"v7.0.0"},{"repo":"actions/setup-python","sha":"5fda3b95a4ea91299a34e894583c3862153e4b97","version":"v7.0.0"},{"repo":"actions/upload-artifact","sha":"043fb46d1a93c77aae656e7c1c64a875d1fc6a0a","version":"v7.0.1"},{"repo":"astral-sh/setup-uv","sha":"20cfd1bf945f4377ade1205e4dbc17946fc9a30d","version":"v10.0.1"},{"repo":"github/gh-aw-actions/setup","sha":"c0338fef4749d08c21f8f975fb0e37efa17dda47","version":"v0.79.8"}],"containers":[{"image":"ghcr.io/github/gh-aw-firewall/agent:0.27.2","digest":"sha256:f88e5b17b6b7a600117bc121114d6ce2155c88c983c0c939c5df884f730fa1d6","pinned_image":"ghcr.io/github/gh-aw-firewall/agent:0.27.2@sha256:f88e5b17b6b7a600117bc121114d6ce2155c88c983c0c939c5df884f730fa1d6"},{"image":"ghcr.io/github/gh-aw-firewall/api-proxy:0.27.2","digest":"sha256:ee39841d980878ebbb87592903b06d31a1af500c71525c9616f7e8e2a27041a4","pinned_image":"ghcr.io/github/gh-aw-firewall/api-proxy:0.27.2@sha256:ee39841d980878ebbb87592903b06d31a1af500c71525c9616f7e8e2a27041a4"},{"image":"ghcr.io/github/gh-aw-firewall/squid:0.27.2","digest":"sha256:2e3a717e5f19a654cd9a2263beb52012b56bcb68562ec5ae2e42f9d156b49591","pinned_image":"ghcr.io/github/gh-aw-firewall/squid:0.27.2@sha256:2e3a717e5f19a654cd9a2263beb52012b56bcb68562ec5ae2e42f9d156b49591"},{"image":"ghcr.io/github/gh-aw-mcpg:v0.3.25","digest":"sha256:c10331ad17668ef89f38f5e356678788a40b0cd5fef96e8f92e1d9c1de47cbaa","pinned_image":"ghcr.io/github/gh-aw-mcpg:v0.3.25@sha256:c10331ad17668ef89f38f5e356678788a40b0cd5fef96e8f92e1d9c1de47cbaa"},{"image":"ghcr.io/github/github-mcp-server:v1.1.2","digest":"sha256:30197479d8036c7811892bc07e06f9a05c9ef3cdd79bc59f256d50647f95788c","pinned_image":"ghcr.io/github/github-mcp-server:v1.1.2@sha256:30197479d8036c7811892bc07e06f9a05c9ef3cdd79bc59f256d50647f95788c"}]}
# This file was automatically generated by gh-aw (v0.79.8). DO NOT EDIT. To debug this workflow, load the skill at https://github.com/github/gh-aw/blob/main/debug.md
#
# ___ _ _
@@ -38,7 +38,7 @@
# - actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0
# - actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0
# - actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
-# - astral-sh/setup-uv@c771a70e6277c0a99b617c7a806ffedaca235ff9 # v9.0.0
+# - astral-sh/setup-uv@20cfd1bf945f4377ade1205e4dbc17946fc9a30d # v10.0.1
# - github/gh-aw-actions/setup@c0338fef4749d08c21f8f975fb0e37efa17dda47 # v0.79.8
#
# Container images used:
@@ -438,7 +438,7 @@ jobs:
persist-credentials: false
fetch-depth: 0
- name: Setup uv
- uses: astral-sh/setup-uv@c771a70e6277c0a99b617c7a806ffedaca235ff9 # v9.0.0
+ uses: astral-sh/setup-uv@20cfd1bf945f4377ade1205e4dbc17946fc9a30d # v10.0.1
- name: Create gh-aw temp directory
run: bash "${RUNNER_TEMP}/gh-aw/actions/create_gh_aw_tmp_dir.sh"
- name: Configure gh CLI for GitHub Enterprise
diff --git a/.github/workflows/bug-test.md b/.github/workflows/bug-test.md
index 87656d7eec..6febb032d3 100644
--- a/.github/workflows/bug-test.md
+++ b/.github/workflows/bug-test.md
@@ -68,7 +68,7 @@ network:
steps:
- name: Setup uv
- uses: astral-sh/setup-uv@c771a70e6277c0a99b617c7a806ffedaca235ff9 # v9.0.0
+ uses: astral-sh/setup-uv@20cfd1bf945f4377ade1205e4dbc17946fc9a30d # v10.0.1
- name: Set up Python
uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0
with:
diff --git a/.github/workflows/codeql.yml b/.github/workflows/codeql.yml
index abd808926c..2cc123238c 100644
--- a/.github/workflows/codeql.yml
+++ b/.github/workflows/codeql.yml
@@ -22,11 +22,11 @@ jobs:
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- name: Initialize CodeQL
- uses: github/codeql-action/init@5595ccaf912efad79be6eef63a5619ff05969be3 # v4
+ uses: github/codeql-action/init@db488ddef3bf6cb639b32c2e9a7c0a7ea8271d28 # v4
with:
languages: ${{ matrix.language }}
- name: Perform CodeQL Analysis
- uses: github/codeql-action/analyze@5595ccaf912efad79be6eef63a5619ff05969be3 # v4
+ uses: github/codeql-action/analyze@db488ddef3bf6cb639b32c2e9a7c0a7ea8271d28 # v4
with:
category: "/language:${{ matrix.language }}"
diff --git a/.github/workflows/feature-assess.lock.yml b/.github/workflows/feature-assess.lock.yml
index 5b3adc7c7d..d8c5cab2d9 100644
--- a/.github/workflows/feature-assess.lock.yml
+++ b/.github/workflows/feature-assess.lock.yml
@@ -1,5 +1,5 @@
-# gh-aw-metadata: {"schema_version":"v4","frontmatter_hash":"d0588e989403a51f8849be4ac0ceb184d3a30f1c2e6860f8dc65fd5728592946","body_hash":"6d78e8c183819f6f12a07f0c9cb28a83cc2471ac20c6df6999e503a0d731da4b","compiler_version":"v0.79.8","strict":true,"agent_id":"copilot","engine_versions":{"copilot":"1.0.60"}}
-# gh-aw-manifest: {"version":1,"secrets":["COPILOT_GITHUB_TOKEN","GH_AW_GITHUB_MCP_SERVER_TOKEN","GH_AW_GITHUB_TOKEN","GITHUB_TOKEN"],"actions":[{"repo":"actions/checkout","sha":"df4cb1c069e1874edd31b4311f1884172cec0e10","version":"v6.0.3"},{"repo":"actions/download-artifact","sha":"3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c","version":"v8.0.1"},{"repo":"actions/github-script","sha":"3a2844b7e9c422d3c10d287c895573f7108da1b3","version":"v9.0.0"},{"repo":"actions/setup-node","sha":"48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e","version":"v6.4.0"},{"repo":"actions/setup-python","sha":"5fda3b95a4ea91299a34e894583c3862153e4b97","version":"5fda3b95a4ea91299a34e894583c3862153e4b97"},{"repo":"actions/upload-artifact","sha":"043fb46d1a93c77aae656e7c1c64a875d1fc6a0a","version":"v7.0.1"},{"repo":"astral-sh/setup-uv","sha":"c771a70e6277c0a99b617c7a806ffedaca235ff9","version":"c771a70e6277c0a99b617c7a806ffedaca235ff9"},{"repo":"github/gh-aw-actions/setup","sha":"c0338fef4749d08c21f8f975fb0e37efa17dda47","version":"v0.79.8"}],"containers":[{"image":"ghcr.io/github/gh-aw-firewall/agent:0.27.2","digest":"sha256:f88e5b17b6b7a600117bc121114d6ce2155c88c983c0c939c5df884f730fa1d6","pinned_image":"ghcr.io/github/gh-aw-firewall/agent:0.27.2@sha256:f88e5b17b6b7a600117bc121114d6ce2155c88c983c0c939c5df884f730fa1d6"},{"image":"ghcr.io/github/gh-aw-firewall/api-proxy:0.27.2","digest":"sha256:ee39841d980878ebbb87592903b06d31a1af500c71525c9616f7e8e2a27041a4","pinned_image":"ghcr.io/github/gh-aw-firewall/api-proxy:0.27.2@sha256:ee39841d980878ebbb87592903b06d31a1af500c71525c9616f7e8e2a27041a4"},{"image":"ghcr.io/github/gh-aw-firewall/squid:0.27.2","digest":"sha256:2e3a717e5f19a654cd9a2263beb52012b56bcb68562ec5ae2e42f9d156b49591","pinned_image":"ghcr.io/github/gh-aw-firewall/squid:0.27.2@sha256:2e3a717e5f19a654cd9a2263beb52012b56bcb68562ec5ae2e42f9d156b49591"},{"image":"ghcr.io/github/gh-aw-mcpg:v0.3.25","digest":"sha256:c10331ad17668ef89f38f5e356678788a40b0cd5fef96e8f92e1d9c1de47cbaa","pinned_image":"ghcr.io/github/gh-aw-mcpg:v0.3.25@sha256:c10331ad17668ef89f38f5e356678788a40b0cd5fef96e8f92e1d9c1de47cbaa"},{"image":"ghcr.io/github/github-mcp-server:v1.1.2","digest":"sha256:30197479d8036c7811892bc07e06f9a05c9ef3cdd79bc59f256d50647f95788c","pinned_image":"ghcr.io/github/github-mcp-server:v1.1.2@sha256:30197479d8036c7811892bc07e06f9a05c9ef3cdd79bc59f256d50647f95788c"}]}
+# gh-aw-metadata: {"schema_version":"v4","frontmatter_hash":"669e5f4d2956792cf5b7a2dfbbda10e7ef26f25fc8283f5b3db1cc838f05d940","body_hash":"6d78e8c183819f6f12a07f0c9cb28a83cc2471ac20c6df6999e503a0d731da4b","compiler_version":"v0.79.8","strict":true,"agent_id":"copilot","engine_versions":{"copilot":"1.0.60"}}
+# gh-aw-manifest: {"version":1,"secrets":["COPILOT_GITHUB_TOKEN","GH_AW_GITHUB_MCP_SERVER_TOKEN","GH_AW_GITHUB_TOKEN","GITHUB_TOKEN"],"actions":[{"repo":"actions/checkout","sha":"df4cb1c069e1874edd31b4311f1884172cec0e10","version":"v6.0.3"},{"repo":"actions/download-artifact","sha":"3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c","version":"v8.0.1"},{"repo":"actions/github-script","sha":"3a2844b7e9c422d3c10d287c895573f7108da1b3","version":"v9.0.0"},{"repo":"actions/setup-node","sha":"48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e","version":"v6.4.0"},{"repo":"actions/setup-python","sha":"5fda3b95a4ea91299a34e894583c3862153e4b97","version":"5fda3b95a4ea91299a34e894583c3862153e4b97"},{"repo":"actions/upload-artifact","sha":"043fb46d1a93c77aae656e7c1c64a875d1fc6a0a","version":"v7.0.1"},{"repo":"astral-sh/setup-uv","sha":"20cfd1bf945f4377ade1205e4dbc17946fc9a30d","version":"v10.0.1"},{"repo":"github/gh-aw-actions/setup","sha":"c0338fef4749d08c21f8f975fb0e37efa17dda47","version":"v0.79.8"}],"containers":[{"image":"ghcr.io/github/gh-aw-firewall/agent:0.27.2","digest":"sha256:f88e5b17b6b7a600117bc121114d6ce2155c88c983c0c939c5df884f730fa1d6","pinned_image":"ghcr.io/github/gh-aw-firewall/agent:0.27.2@sha256:f88e5b17b6b7a600117bc121114d6ce2155c88c983c0c939c5df884f730fa1d6"},{"image":"ghcr.io/github/gh-aw-firewall/api-proxy:0.27.2","digest":"sha256:ee39841d980878ebbb87592903b06d31a1af500c71525c9616f7e8e2a27041a4","pinned_image":"ghcr.io/github/gh-aw-firewall/api-proxy:0.27.2@sha256:ee39841d980878ebbb87592903b06d31a1af500c71525c9616f7e8e2a27041a4"},{"image":"ghcr.io/github/gh-aw-firewall/squid:0.27.2","digest":"sha256:2e3a717e5f19a654cd9a2263beb52012b56bcb68562ec5ae2e42f9d156b49591","pinned_image":"ghcr.io/github/gh-aw-firewall/squid:0.27.2@sha256:2e3a717e5f19a654cd9a2263beb52012b56bcb68562ec5ae2e42f9d156b49591"},{"image":"ghcr.io/github/gh-aw-mcpg:v0.3.25","digest":"sha256:c10331ad17668ef89f38f5e356678788a40b0cd5fef96e8f92e1d9c1de47cbaa","pinned_image":"ghcr.io/github/gh-aw-mcpg:v0.3.25@sha256:c10331ad17668ef89f38f5e356678788a40b0cd5fef96e8f92e1d9c1de47cbaa"},{"image":"ghcr.io/github/github-mcp-server:v1.1.2","digest":"sha256:30197479d8036c7811892bc07e06f9a05c9ef3cdd79bc59f256d50647f95788c","pinned_image":"ghcr.io/github/github-mcp-server:v1.1.2@sha256:30197479d8036c7811892bc07e06f9a05c9ef3cdd79bc59f256d50647f95788c"}]}
# This file was automatically generated by gh-aw (v0.79.8). DO NOT EDIT. To debug this workflow, load the skill at https://github.com/github/gh-aw/blob/main/debug.md
#
# ___ _ _
@@ -32,13 +32,13 @@
# - GITHUB_TOKEN
#
# Custom actions used:
-# - actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
+# - actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
# - actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
# - actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0
-# - actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
+# - actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0
# - actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # 5fda3b95a4ea91299a34e894583c3862153e4b97
# - actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
-# - astral-sh/setup-uv@c771a70e6277c0a99b617c7a806ffedaca235ff9 # c771a70e6277c0a99b617c7a806ffedaca235ff9
+# - astral-sh/setup-uv@20cfd1bf945f4377ade1205e4dbc17946fc9a30d # v10.0.1
# - github/gh-aw-actions/setup@c0338fef4749d08c21f8f975fb0e37efa17dda47 # v0.79.8
#
# Container images used:
@@ -163,7 +163,7 @@ jobs:
env:
COPILOT_GITHUB_TOKEN: ${{ secrets.COPILOT_GITHUB_TOKEN }}
- name: Checkout .github and .agents folders
- uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
+ uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
persist-credentials: false
sparse-checkout: |
@@ -432,12 +432,12 @@ jobs:
echo "GH_AW_SAFE_OUTPUTS_TOOLS_PATH=${RUNNER_TEMP}/gh-aw/safeoutputs/tools.json"
} >> "$GITHUB_OUTPUT"
- name: Checkout repository
- uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
+ uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
persist-credentials: false
fetch-depth: 0
- name: Setup uv
- uses: astral-sh/setup-uv@c771a70e6277c0a99b617c7a806ffedaca235ff9 # c771a70e6277c0a99b617c7a806ffedaca235ff9
+ uses: astral-sh/setup-uv@20cfd1bf945f4377ade1205e4dbc17946fc9a30d # v10.0.1
- name: Create gh-aw temp directory
run: bash "${RUNNER_TEMP}/gh-aw/actions/create_gh_aw_tmp_dir.sh"
- name: Configure gh CLI for GitHub Enterprise
@@ -1333,7 +1333,7 @@ jobs:
echo "GH_AW_AGENT_OUTPUT=/tmp/gh-aw/agent_output.json" >> "$GITHUB_OUTPUT"
- name: Checkout repository for patch context
if: needs.agent.outputs.has_patch == 'true'
- uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
+ uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
persist-credentials: false
# --- Threat Detection ---
@@ -1400,7 +1400,7 @@ jobs:
mkdir -p /tmp/gh-aw/threat-detection
touch /tmp/gh-aw/threat-detection/detection.log
- name: Setup Node.js
- uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
+ uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0
with:
node-version: '24'
package-manager-cache: false
diff --git a/.github/workflows/feature-assess.md b/.github/workflows/feature-assess.md
index 4381d44136..4f2dbff5f8 100644
--- a/.github/workflows/feature-assess.md
+++ b/.github/workflows/feature-assess.md
@@ -39,7 +39,7 @@ checkout:
steps:
- name: Setup uv
continue-on-error: true
- uses: astral-sh/setup-uv@c771a70e6277c0a99b617c7a806ffedaca235ff9 # v9.0.0
+ uses: astral-sh/setup-uv@20cfd1bf945f4377ade1205e4dbc17946fc9a30d # v10.0.1
- name: Set up Python
continue-on-error: true
uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0
diff --git a/.github/workflows/publish-pypi.yml b/.github/workflows/publish-pypi.yml
index ce6185ea6c..028565bf5b 100644
--- a/.github/workflows/publish-pypi.yml
+++ b/.github/workflows/publish-pypi.yml
@@ -19,8 +19,9 @@ jobs:
actions: write
steps:
- name: Verify tag format
+ env:
+ TAG: ${{ inputs.tag }}
run: |
- TAG="${{ inputs.tag }}"
if [[ ! "$TAG" =~ ^v[0-9]+\.[0-9]+\.[0-9]+$ ]]; then
echo "Error: '$TAG' is not a valid release tag (expected vX.Y.Z)"
exit 1
@@ -32,7 +33,7 @@ jobs:
ref: refs/tags/${{ inputs.tag }}
- name: Install uv
- uses: astral-sh/setup-uv@c771a70e6277c0a99b617c7a806ffedaca235ff9 # v9.0.0
+ uses: astral-sh/setup-uv@20cfd1bf945f4377ade1205e4dbc17946fc9a30d # v10.0.1
- name: Set up Python
uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0
@@ -40,9 +41,10 @@ jobs:
python-version: "3.13"
- name: Verify tag matches package version
+ env:
+ TAG: ${{ inputs.tag }}
run: |
- TAG_VERSION="${{ inputs.tag }}"
- TAG_VERSION="${TAG_VERSION#v}"
+ TAG_VERSION="${TAG#v}"
PROJECT_VERSION="$(python -c 'import tomllib; print(tomllib.load(open("pyproject.toml","rb"))["project"]["version"])')"
if [[ "$TAG_VERSION" != "$PROJECT_VERSION" ]]; then
echo "Error: Tag version ($TAG_VERSION) does not match pyproject.toml version ($PROJECT_VERSION)"
@@ -74,7 +76,7 @@ jobs:
path: dist/
- name: Install uv
- uses: astral-sh/setup-uv@c771a70e6277c0a99b617c7a806ffedaca235ff9 # v9.0.0
+ uses: astral-sh/setup-uv@20cfd1bf945f4377ade1205e4dbc17946fc9a30d # v10.0.1
- name: Publish to PyPI
run: uv publish
diff --git a/.github/workflows/security.yml b/.github/workflows/security.yml
index ed9f6606ed..8c5a5eb72a 100644
--- a/.github/workflows/security.yml
+++ b/.github/workflows/security.yml
@@ -24,7 +24,7 @@ jobs:
fetch-depth: 0
- name: Install uv
- uses: astral-sh/setup-uv@c771a70e6277c0a99b617c7a806ffedaca235ff9 # v9.0.0
+ uses: astral-sh/setup-uv@20cfd1bf945f4377ade1205e4dbc17946fc9a30d # v10.0.1
- name: Set up Python
uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0
@@ -55,7 +55,7 @@ jobs:
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- name: Install uv
- uses: astral-sh/setup-uv@c771a70e6277c0a99b617c7a806ffedaca235ff9 # v9.0.0
+ uses: astral-sh/setup-uv@20cfd1bf945f4377ade1205e4dbc17946fc9a30d # v10.0.1
- name: Set up Python ${{ matrix.python-version }}
uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0
diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml
index 1d4399cb23..dceb97c6e5 100644
--- a/.github/workflows/test.yml
+++ b/.github/workflows/test.yml
@@ -16,7 +16,7 @@ jobs:
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- name: Install uv
- uses: astral-sh/setup-uv@c771a70e6277c0a99b617c7a806ffedaca235ff9 # v9.0.0
+ uses: astral-sh/setup-uv@20cfd1bf945f4377ade1205e4dbc17946fc9a30d # v10.0.1
- name: Set up Python
uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0
@@ -37,7 +37,7 @@ jobs:
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- name: Install uv
- uses: astral-sh/setup-uv@c771a70e6277c0a99b617c7a806ffedaca235ff9 # v9.0.0
+ uses: astral-sh/setup-uv@20cfd1bf945f4377ade1205e4dbc17946fc9a30d # v10.0.1
- name: Set up Python ${{ matrix.python-version }}
uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0
diff --git a/AGENTS.md b/AGENTS.md
index b6975339c3..17ef40deb1 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -41,6 +41,8 @@ src/specify_cli/integrations/
│ └── __init__.py
├── copilot/ # Example: IntegrationBase subclass (custom setup)
│ └── __init__.py
+├── docker_agent/ # Example: Docker Agent SkillsIntegration subclass
+│ └── __init__.py
└── ... # One subpackage per supported agent
```
@@ -504,7 +506,7 @@ Disclosure is **continuous**, not a one-time event. A single AI-disclosure parag
### Opening pull requests
- Before opening a pull request, check whether the account that will file it already has three open pull requests in this repository.
-- If so, alert the user that additional submissions may receive lower review priority and ask for explicit permission to proceed. Do not assume consent.
+- If so, alert the user that additional submissions may receive lower review priority and ask for explicit permission to proceed. Do not assume consent. If the user is unavailable to provide that permission, including during autonomous or non-interactive operation, do not open the pull request. Preserve the work on a branch and report that confirmation is required.
### Commits
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 0ef915b936..bfa0e2ed0b 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -2,6 +2,131 @@
+## [1.0.4] - 2026-09-02
+
+### Changed
+
+- fix(scripts): stop wrap composition looping on a token in core content (#4396)
+- [extension] Update Charter extension to v0.6.1 (#4409)
+- fix(workflows): keep non-ASCII text readable in written overlay files (#4148)
+- fix(workflows): report overlay operation keys in declaration order (#4146)
+- fix: skip corrupted state.json in list_runs() instead of aborting (#3904)
+- fix(rovodev): guard non-string prompt names when merging prompts.yml (#4145)
+- fix: narrow bare except Exception in preset command reconciliation (#3842)
+- fix(workflows): refuse a filter mixed with a comparison operator instead of silently mis-binding it (#3894)
+- fix: escape Rich markup in workflow error output (#3837)
+- fix: add JSON error handling to auth config loader (#3836)
+- fix: use missing_ok=True in extension ZIP cleanup (#3870)
+- feat(presets): let a preset declare a required extension (#4250)
+- fix(bundler): reject unsupported catalog payload versions (#4090)
+- fix(extensions): install bundled extension updates from the local package (#4351)
+- docs: clarify autonomous PR handling (#4392)
+- fix(workflows): reject malformed step config on add (#4087)
+- fix(powershell): stop create-new-feature crashing on a non-Latin description (#4138)
+- fix(bundler): treat an explicit-null records field as missing, not "None" (#4136)
+- Add DeepSeek Harness (DSH) integration (#4336)
+- chore: release 1.0.3, begin 1.0.4.dev0 development (#4391)
+
+## [1.0.3] - 2026-09-01
+
+### Changed
+
+- fix(workflows): reject malformed step config on remove (#4095)
+- fix(workflows): reject malformed workflow config on remove (#4096)
+- fix(events): skip unreadable extension manifests (#4089)
+- docs(extensions): fix private catalog FAQ command (#4373)
+- [extension] Add Vurnix Honest Gate extension to community catalog (#4388)
+- fix(presets): fail closed on unreadable provenance (#4092)
+- docs: explain how Spec Kit dogfoods itself (#4381)
+- add --require-spec to check-prerequisites (#4367)
+- fix(ci): harden PyPI release tag handling (#4386)
+- fix(github-http): reject malformed explicit URL ports (#4372)
+- fix: scaffold extension config when installing via bundler (#4285)
+- fix: reject unknown setup-plan arguments (#4371)
+- feat(docker-agent): add Docker Agent integration (#4302)
+- fix(workflows): reject a condition that is spliced into text, not evaluated (#4292)
+- fix(bundler): pass explicit workflow_add options from bundle install (#4284)
+- test(bundle): join across wrap points in the build output-path assertion (#4280)
+- chore(deps): bump the codeql-action group with 2 updates (#4357)
+- [extension] Update Spec Kit Figma extension to v3.1.1 (#4377)
+- fix(presets): reject falsy non-mapping catalog roots (#4088)
+- chore: release 1.0.2, begin 1.0.3.dev0 development (#4379)
+
+## [1.0.2] - 2026-08-31
+
+### Changed
+
+- [extension] Add Jira Mirror extension to community catalog (#4376)
+- [extension] Add AgentDocx SpecKit V2 extension to community catalog (#4369)
+- fix(bundler): reject non-string catalog entry tag members (#4318)
+- fix(auth): reject malformed URL ports before credential matching (#4362)
+- fix: decode feature.json as UTF-8 in Windows PowerShell (#4359)
+- fix(events): stop falling back to a fake "pwsh" argv when no launcher exists (#4340)
+- Add Pre-Spec Cards extension to community catalog (#4365)
+- docs(workflows): document Python init script support (#4331)
+- fix(presets): validate catalog URL port, not just hostname (#4341)
+- Add Verified Codebase Context preset to community catalog (#4344)
+- fix(events): stop `event run` crashing on every piped stdin payload (#4326)
+- Add Taco Review extension to community catalog (#4322)
+- Update SpecKit Grill Me extension to v1.0.1 (#4317)
+- [extension] Update BDD extension to v1.0.3 (#4299)
+- Update Parallel Autonomous Run Governance preset to v0.2.6 (#4304)
+- Update SpecAssay bundle to v0.4.12 (#4257)
+- Update SpecAssay preset to v0.4.12 (#4256)
+- Update Archive Extension to v1.3.0 (#4298)
+- Update Reconcile extension to v1.2.1 (#4297)
+- chore: release 1.0.1, begin 1.0.2.dev0 development (#4266)
+
+## [1.0.1] - 2026-08-21
+
+### Changed
+
+- docs: flatten project history navigation (#4265)
+- docs: use Spec Kit branding on documentation site (#4264)
+- docs: add existing project adoption guide (#4263)
+- docs: add project history page (#4262)
+- docs: mark Spec Kit's first anniversary (#4260)
+- docs: add workflow quickstarts (#4258)
+- chore(deps): bump astral-sh/setup-uv from 9.0.0 to 10.0.1 (#4244)
+- Update SpecAssay Check extension to v0.4.12 (#4254)
+- fix(workflows): require a 'cases' block on switch steps (#4144)
+- fix(workflows): strip the resolved value before matching switch cases (#4143)
+- fix(bundler): reject non-string manifest list members (#4091)
+- fix(presets): reject non-mapping catalog mutations (#4094)
+- fix(workflows): stop offering a condition correction that inverts it (#4230)
+- fix: use chunked read for integration and preset manifest hash (#3843)
+- docs: update landing page stats for 1.0.0 (#4251)
+- Add Azure Cosmos DB extension to community catalog (#4247)
+- chore(deps): bump actions/checkout from 6.0.3 to 7.0.1 (#4243)
+- chore(deps): bump actions/setup-node from 6.4.0 to 7.0.0 (#4242)
+- chore(deps): bump the codeql-action group with 2 updates (#4241)
+- chore: release 1.0.0, begin 1.0.1.dev0 development (#4246)
+
+## [1.0.0] - 2026-08-21
+
+### Changed
+
+- [extension] Update Security Review extension to v2.0.0 (#4223)
+- fix(presets): reject duplicate provides.templates name+type entries (#4191)
+- fix(bundler): decode a downloaded (non-zip) bundle manifest as UTF-8 (#4190)
+- Update Intake Sequencing Governance preset to v0.2.3 (#4235)
+- Update MAQA — Multi-Agent & Quality Assurance extension to v0.1.6 (#4234)
+- [bug-fix] Fix qodercli-skills-migration: migrate QodercliIntegration to SkillsIntegration (#4205)
+- [preset] Add Inventory Alignment preset to community catalog (#4229)
+- [extension] Add Spec Inventory extension to community catalog (#4228)
+- [extension] Update Architecture Guard extension to v2.3.6 (#4224)
+- Update SpecKit Companion extension to v0.20.2 (#4225)
+- fix(workflows): reject a condition that has no {{ }} block (#4182)
+- fix: raise feature assessment credit budget (#4222)
+- [extension] Add AgentDocx extension to community catalog (#4184)
+- fix(integrations): report a falsy non-mapping integration descriptor as a shape error (#4187)
+- Update Autonomous Run Governance preset to v0.4.1 (#4203)
+- fix(workflows): validate dispatch defaults (#4181)
+- Update Atlas extension display name in community catalog (#4202)
+- Add Closed Vocabulary Check preset to community catalog (#4201)
+- fix(utils): narrow bare except Exception in merge_json_files (#4189)
+- chore: release 0.16.5, begin 0.16.6.dev0 development (#4206)
+
## [0.16.5] - 2026-08-19
### Changed
diff --git a/README.md b/README.md
index de92639cec..a4e7fdafb9 100644
--- a/README.md
+++ b/README.md
@@ -20,11 +20,22 @@
简体中文
+> [!NOTE]
+> **One year of Spec Kit — and 1.0.0**
+>
+> One year after the first commit, Spec Kit has reached [1.0.0](https://github.com/github/spec-kit/releases/tag/v1.0.0) — not because the work is finished or its shape is frozen, but because the project has grown into something coherent, useful, and shaped by far more people than those who started it.
+>
+> The lead maintainer's personal anniversary post, [*Spec Kit Turns One — and Ships 1.0.0*](https://www.manorrock.com/blog/2026/08/21/spec_kit_turns_one.html), defines what 1.0.0 actually means for the project: **it is now just a number**. As agents make adapting to change dramatically cheaper, the value moves from stability to adaptability.
+>
+> To everyone who has used Spec Kit, challenged its assumptions, reported a problem, contributed code or documentation, created an extension or preset, shared an idea, or helped someone else get started: **thank you**. This milestone belongs to the community that carried the project through its first year and continues to shape where it goes next.
+
---
## Table of Contents
- [🤔 What is Spec-Driven Development?](#-what-is-spec-driven-development)
+- [🐞 Bug Fixing with Spec Kit](#-bug-fixing-with-spec-kit)
+- [💡 Assessing Ideas with Spec Kit](#-assessing-ideas-with-spec-kit)
- [⚡ Get Started](#-get-started)
- [📽️ Video Overview](#️-video-overview)
- [🌍 Community](#-community)
@@ -33,6 +44,7 @@
- [🧩 Making Spec Kit Your Own: Extensions & Presets](#-making-spec-kit-your-own-extensions--presets)
- [📦 Bundles: Role-Based Setups](#-bundles-role-based-setups)
- [📚 Core Philosophy](#-core-philosophy)
+- [🪞 Does Spec Kit Use Spec Kit?](#-does-spec-kit-use-spec-kit)
- [🌟 Development Phases](#-development-phases)
- [🎯 Experimental Goals](#-experimental-goals)
- [🔧 Prerequisites](#-prerequisites)
@@ -45,6 +57,75 @@
Spec-Driven Development **flips the script** on traditional software development. For decades, code has been king — specifications were just scaffolding we built and discarded once the "real work" of coding began. Spec-Driven Development changes this: **specifications become executable**, directly generating working implementations rather than just guiding them.
+### SDD Quickstart
+
+Replace `vX.Y.Z` with the [latest release tag](https://github.com/github/spec-kit/releases), keeping the leading `v`.
+
+```bash
+uv tool install specify-cli --from git+https://github.com/github/spec-kit.git@vX.Y.Z
+specify init my-project --integration copilot
+cd my-project
+```
+
+Launch your coding agent in the project directory, then:
+
+0. **Establish** your project principles once (`/speckit-constitution`). This is a one-time step per project.
+1. **Specify** what you want to build (`/speckit-specify`).
+2. **Plan** how you will build it (`/speckit-plan`).
+3. **Break down** the plan into actionable tasks (`/speckit-tasks`).
+4. **Implement** the tasks (`/speckit-implement`).
+5. **Converge** the implementation against the spec, plan, and tasks (`/speckit-converge`).
+
+> [!NOTE]
+> Repeat steps 4 and 5 until `/speckit-converge` reports **Converged**.
+
+## 🐞 Bug Fixing with Spec Kit
+
+Bug fixes are risky when an agent jumps straight from a report to a patch without validating the diagnosis or confirming that the fix resolves the original symptom. The bundled, opt-in bug extension provides a repeatable **assess → fix → test** workflow that keeps each fix scoped, evidence-based, and documented from root cause through verification.
+
+### Bug Fix Quickstart
+
+Replace `vX.Y.Z` with the [latest release tag](https://github.com/github/spec-kit/releases), keeping the leading `v`.
+
+```bash
+uv tool install specify-cli --from git+https://github.com/github/spec-kit.git@vX.Y.Z
+specify init my-project --integration copilot
+cd my-project
+specify extension add bug
+```
+
+Launch your coding agent in the project directory, then:
+
+1. **Assess** the bug (`/speckit-bug-assess "" slug=login-crash`).
+2. **Fix** the assessed cause (`/speckit-bug-fix slug=login-crash`).
+3. **Test** the fix (`/speckit-bug-test slug=login-crash`).
+
+## 💡 Assessing Ideas with Spec Kit
+
+Good ideas deserve evidence before commitment, whether or not they become software. The bundled, opt-in assess extension turns a raw idea into a documented **go / needs-clarification / kill** decision through an independent **intake → research → define → shape → decide** workflow.
+
+### Idea Assessment Quickstart
+
+Replace `vX.Y.Z` with the [latest release tag](https://github.com/github/spec-kit/releases), keeping the leading `v`.
+
+```bash
+uv tool install specify-cli --from git+https://github.com/github/spec-kit.git@vX.Y.Z
+specify init my-project --integration copilot
+cd my-project
+specify extension add assess
+```
+
+Launch your coding agent in the project directory, then:
+
+1. **Intake** the idea (`/speckit-assess-intake "" slug=offline-mode`).
+2. **Research** supporting and opposing evidence (`/speckit-assess-research slug=offline-mode`).
+3. **Define** the problem, goals, and success metrics (`/speckit-assess-define slug=offline-mode`).
+4. **Shape** possible solutions and their trade-offs (`/speckit-assess-shape slug=offline-mode`).
+5. **Decide** whether to proceed, clarify, or stop (`/speckit-assess-decide slug=offline-mode`).
+
+> [!NOTE]
+> Idea assessment is standalone. If you choose to build an idea with a **go** decision, you can hand it off to `/speckit-specify`.
+
## ⚡ Get Started
### 1. Install Specify CLI
@@ -321,6 +402,26 @@ Spec-Driven Development is a structured process that emphasizes:
- **Multi-step refinement** rather than one-shot code generation from prompts
- **Heavy reliance** on advanced AI model capabilities for specification interpretation
+## 🪞 Does Spec Kit Use Spec Kit?
+
+Yes — we dogfood Spec Kit while developing Spec Kit, especially for substantial
+features and changes to the development workflow. Contributors are asked to test
+relevant changes through the Spec-Driven Development commands. The
+[feature assessment workflow](./.github/workflows/feature-assess.md) is currently
+the automated dogfooding path: its setup uses the CLI from the current checkout
+to initialize Copilot and install the `assess` extension, after which Copilot
+follows the generated assessment skills against feature requests. The other
+agentic workflows currently operate independently of the Specify CLI.
+
+This does not mean every change goes through the full workflow. Small fixes can
+use the normal issue, pull request, review, and test process. Dogfooding
+scaffolding and artifacts under `.github/agents/`, `.github/prompts/`,
+`.github/copilot-instructions.md`, `.grok/`, `.specify/`, and `specs/` are
+intentionally gitignored. The automated assessment workflow is ephemeral and
+neither commits nor pushes its generated Copilot skills, so its output does not
+enter repository history. See the [contributor development
+workflow](./CONTRIBUTING.md#development-workflow) for the validation expectations.
+
## 🌟 Development Phases
| Phase | Focus | Key Activities |
diff --git a/bundles/catalog.community.json b/bundles/catalog.community.json
index ed6b97dcd5..070a1132cd 100644
--- a/bundles/catalog.community.json
+++ b/bundles/catalog.community.json
@@ -1,6 +1,6 @@
{
"schema_version": "1.0",
- "updated_at": "2026-08-14T00:00:00Z",
+ "updated_at": "2026-08-21T00:00:00Z",
"catalog_url": "https://raw.githubusercontent.com/github/spec-kit/main/bundles/catalog.community.json",
"bundles": {
"sicario-spec": {
@@ -34,12 +34,12 @@
"specassay": {
"name": "SpecAssay",
"id": "specassay",
- "version": "0.3.4",
+ "version": "0.4.12",
"role": "developer",
"description": "Durable-ID promotion for stock Spec Kit: templates, Gate 2 refusal, and trace-manifest emission.",
"author": "Rik Dryfoos",
"license": "MIT",
- "download_url": "https://github.com/rdryfoos/specassay/releases/download/v0.3.4/specassay-0.3.4.zip",
+ "download_url": "https://github.com/rdryfoos/specassay/releases/download/v0.4.12/specassay-0.4.12.zip",
"repository": "https://github.com/rdryfoos/specassay",
"requires": {
"speckit_version": ">=0.14.0"
diff --git a/docs/community/extensions.md b/docs/community/extensions.md
index 29c2363125..69119631e0 100644
--- a/docs/community/extensions.md
+++ b/docs/community/extensions.md
@@ -29,6 +29,7 @@ The following community-contributed extensions are available in [`catalog.commun
| Agent Assign | Assign specialized Claude Code agents to spec-kit tasks for targeted execution | `process` | Read+Write | [spec-kit-agent-assign](https://github.com/xymelon/spec-kit-agent-assign) |
| Agent Governance | Generate agent-platform repository governance files from Spec Kit metadata | `process` | Read+Write | [spec-kit-agent-governance](https://github.com/bigsmartben/spec-kit-agent-governance) |
| AgentDocx | Full-stack multi-agent specification pipeline with VS Code extension control, automated Kanban/Jira sync, and React monitoring dashboard | `integration` | Read+Write | [extension-github-spec-kit](https://github.com/abir-ommezzine/extension-github-spec-kit) |
+| AgentDocx SpecKit V2 | AgentDocx evolved: same pipeline + far more autonomous Ticket Manager (5 CLI, per-project Kanban, bulk sync, auto-switch, Auditor) | `integration` | Read+Write | [Extension_GithubSpecKit](https://github.com/ahmed200346/Extension_GithubSpecKit) |
| AgentPay x402 — Spend Controls for Spec Kit Agents | Set USDC spending caps and execute x402 payments to paid APIs during spec implementation. Zero platform fee on Base L2 | `integration` | Read+Write | [spec-kit-pay-x402](https://github.com/shawnhvac/spec-kit-pay-x402) |
| AI-Driven Engineering (AIDE) | A structured 7-step workflow for building new projects from scratch with AI assistants — from vision through implementation | `process` | Read+Write | [aide](https://github.com/mnriem/spec-kit-extensions/tree/main/aide) |
| Analytics | Measure what your AI builds, and how much time it saves you | `visibility` | Read+Write | [spec-kit-analytics](https://github.com/Fyloss/spec-kit-analytics) |
@@ -40,7 +41,10 @@ The following community-contributed extensions are available in [`catalog.commun
| Archive Extension | Archive merged features into main project memory, resolving gaps and conflicts. | `docs` | Read+Write | [spec-kit-archive](https://github.com/stn1slv/spec-kit-archive) |
| ASCII Diagram Renderer | Renders hand-drawn ASCII/Unicode diagrams (state machine, architecture, flow, coverage map) of what spec/plan/tasks/analyze already say — plain text, no Mermaid renderer needed | `docs` | Read+Write | [spec-kit-ascii-diagram](https://github.com/MRZHUH/spec-kit-ascii-diagram) |
| Atlas | Synthesize spec-kit specs into faithful, interactive architecture storybooks & doc portals. | `docs` | Read-only | [spec-kit-atlas](https://github.com/ashbrener/spec-kit-atlas) |
+| Axi Extension | A Spec Kit extension that renders a feature's markdown in a local browser review surface for human annotation, then returns the queued notes to the agent to apply. | `docs` | Read+Write | [spec-kit-axi](https://github.com/d0whc3r/spec-kit-axi) |
+| Azure Cosmos DB | Best-practice Azure Cosmos DB code generation and review for any AI coding agent | `code` | Read+Write | [spec-kit-cosmosdb](https://github.com/AzureCosmosDB/spec-kit-cosmosdb) |
| Azure DevOps Integration | Sync user stories and tasks to Azure DevOps work items using OAuth authentication | `integration` | Read+Write | [spec-kit-azure-devops](https://github.com/pragya247/spec-kit-azure-devops) |
+| BDD | Convert specs to Gherkin scenarios, scaffold step definitions, and verify acceptance test coverage | `process` | Read+Write | [spec-kit-bdd](https://github.com/RSginer/spec-kit-bdd) |
| Blueprint | Stay code-literate in AI-driven development: review a complete code blueprint for every task from spec artifacts before /speckit.implement runs | `docs` | Read+Write | [spec-kit-blueprint](https://github.com/chordpli/spec-kit-blueprint) |
| Blueprint Index — Living Architecture Map | A living architecture map for spec-driven projects, kept honest by a deterministic, low-friction, machine-first CI gate (JSON, self-healable) that blocks only when the map contradicts the specs or code. Brownfield or greenfield. | `process` | Read+Write | [spec-kit-blueprint](https://github.com/ogil109/spec-kit-blueprint) |
| Branch Convention | Configurable branch and folder naming conventions for /specify with presets and custom patterns | `process` | Read+Write | [spec-kit-branch-convention](https://github.com/Quratulain-bilal/spec-kit-branch-convention) |
@@ -49,7 +53,7 @@ The following community-contributed extensions are available in [`catalog.commun
| Bugfix Workflow | Structured bugfix workflow — capture bugs, trace to spec artifacts, and patch specs surgically | `process` | Read+Write | [spec-kit-bugfix](https://github.com/Quratulain-bilal/spec-kit-bugfix) |
| Canon | Adds canon-driven (baseline-driven) workflows: spec-first, code-first, spec-drift. Requires Canon Core preset installation. | `process` | Read+Write | [spec-kit-canon](https://github.com/maximiliamus/spec-kit-canon/tree/master/extension) |
| Catalog CI | Automated validation for spec-kit community catalog entries — structure, URLs, diffs, and linting | `process` | Read-only | [spec-kit-catalog-ci](https://github.com/Quratulain-bilal/spec-kit-catalog-ci) |
-| Charter | Compose modular project constitutions from shared fragment registries. Centralize governance rules, select per-project fragments, track upstream changes, and keep multi-project setups consistent. | `process` | Read+Write | [spec-kit-charter](https://github.com/Fyloss/spec-kit-charter) |
+| Charter | Compose project constitutions from shared fragment registries | `process` | Read+Write | [spec-kit-charter](https://github.com/Fyloss/spec-kit-charter) |
| CI Guard | Spec compliance gates for CI/CD — verify specs exist, check drift, and block merges on gaps | `process` | Read-only | [spec-kit-ci-guard](https://github.com/Quratulain-bilal/spec-kit-ci-guard) |
| Checkpoint Extension | Commit the changes made during the middle of the implementation, so you don't end up with just one very large commit at the end | `code` | Read+Write | [spec-kit-checkpoint](https://github.com/aaronrsun/spec-kit-checkpoint) |
| Cleanup Extension | Post-implementation quality gate that reviews changes, fixes small issues (scout rule), creates tasks for medium issues, and generates analysis for large issues | `code` | Read+Write | [spec-kit-cleanup](https://github.com/dsrednicki/spec-kit-cleanup) |
@@ -63,6 +67,7 @@ The following community-contributed extensions are available in [`catalog.commun
| 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) |
| DUBSAR Memory | Local project memory for Spec Kit with explicit checkpoints, cross-session resume, and SHA-256 freshness for recorded specification, plan, and task references. | `visibility` | Read+Write | [dubsar-memory](https://github.com/kotnisofiane-bit/dubsar-memory) |
| 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) |
+| Evaluator Contract | Provider-neutral evaluator contract for evidence, provenance, uncertainty, and recovery across Spec-Driven Development phases. | `process` | Read+Write | [spec-kit-evaluator](https://github.com/electrohire/spec-kit-evaluator) |
| Extensify | Create and validate extensions and extension catalogs | `process` | Read+Write | [extensify](https://github.com/mnriem/spec-kit-extensions/tree/main/extensify) |
| Figma Starter | Turns a Figma section's screens into per-screen spec.md files, an app-level user-stories.md, and a build-order.md, then hands off to /speckit.specify | `integration` | Read+Write | [spec-kit-figma-starter](https://github.com/wavemaker/spec-kit-figma-starter) |
| Fix Findings | Automated analyze-fix-reanalyze loop that resolves spec findings until clean | `code` | Read+Write | [spec-kit-fix-findings](https://github.com/Quratulain-bilal/spec-kit-fix-findings) |
@@ -78,9 +83,10 @@ The following community-contributed extensions are available in [`catalog.commun
| Iterate | Iterate on spec documents with a two-phase define-and-apply workflow — refine specs mid-implementation and go straight back to building | `docs` | Read+Write | [spec-kit-iterate](https://github.com/imviancagrace/spec-kit-iterate) |
| Jira Integration | Create Jira Epics, Stories, and Issues from spec-kit specifications and task breakdowns with configurable hierarchy and custom field support | `integration` | Read+Write | [spec-kit-jira](https://github.com/mbachorik/spec-kit-jira) |
| Jira Integration (Sync Engine) | Idempotent, drift-aware, fail-closed reconcile engine mirroring spec-kit specs into Jira (Epic per repo, Story per spec, Subtask per phase) | `integration` | Read+Write | [spec-kit-jira-sync](https://github.com/ashbrener/spec-kit-jira-sync) |
+| Jira Mirror | Spec Kit ↔ Jira bridge for team-managed and company-managed projects: configurable workflows & hierarchies (Scrum/SAFe), multi-project, idempotent and fail-closed. macOS/Linux/Windows. | `integration` | Read+Write | [spec-kit-jira-mirror](https://github.com/Fyloss/spec-kit-jira-mirror) |
| Keel Discovery | Evidence-backed discovery upstream of /speckit.specify, plus round-trip drift auditing after implementation | `process` | Read+Write | [spec-kit-keel](https://github.com/keeldiscovery/spec-kit-keel) |
| Learning Extension | Generate educational guides from implementations and enhance clarifications with mentoring context | `docs` | Read+Write | [spec-kit-learn](https://github.com/imviancagrace/spec-kit-learn) |
-| Linear Integration | Mirror spec-kit feature directories into Linear (filesystem → Linear, reconcile-based, unidirectional). | `integration` | Read+Write | [spec-kit-linear-sync](https://github.com/ashbrener/spec-kit-linear-sync) |
+| Linear Integration | Automatically mirror your spec-kit specs into Linear — one issue per spec, a sub-issue per task phase, kept in sync as you work. | `integration` | Read+Write | [spec-kit-linear-sync](https://github.com/ashbrener/spec-kit-linear-sync) |
| Linear Weave | Weave Spec Kit into Linear: pull requirements, mirror tasks.md into sub-issues, sync statuses | `integration` | Read+Write | [spec-kit-linear-weave](https://github.com/tonydwoodhouse/spec-kit-linear-weave) |
| LLM Wiki | LLM-maintained compounding project wiki: source ingestion, cited answers, and consistency linting | `docs` | Read+Write | [spec-kit-wiki](https://github.com/formin/spec-kit-wiki) |
| Loop Engineering | Engineer safe autonomous agent loops for spec-driven development: a maker/checker split, externalized loop state, and stay-the-engineer guardrails against comprehension debt and cognitive surrender | `process` | Read+Write | [spec-kit-loop](https://github.com/formin/spec-kit-loop) |
@@ -109,6 +115,7 @@ The following community-contributed extensions are available in [`catalog.commun
| PatchWarden Evidence Pack | Map Spec Kit tasks into a guarded PatchWarden Goal and export bounded, traceable evidence for an accepted lineage. | `process` | Read+Write | [spec-kit-patchwarden](https://github.com/jiezeng2004-design/spec-kit-patchwarden) |
| Plan Review Gate | Require spec.md and plan.md to be merged via MR/PR before allowing task generation | `process` | Read-only | [spec-kit-plan-review-gate](https://github.com/luno/spec-kit-plan-review-gate) |
| PR Bridge | Auto-generate pull request descriptions, checklists, and summaries from spec artifacts | `process` | Read-only | [spec-kit-pr-bridge-](https://github.com/Quratulain-bilal/spec-kit-pr-bridge-) |
+| Pre-Spec Cards | Card-based pre-spec thinking: paste an idea, get your card plus the paths you'd miss, then play each through — story, snags, trade-offs, difficulty vs payoff — before /speckit.specify | `process` | Read+Write | [pre-spec](https://github.com/bendlikeabamboo/pre-spec) |
| Presetify | Create and validate presets and preset catalogs | `process` | Read+Write | [presetify](https://github.com/mnriem/spec-kit-extensions/tree/main/presetify) |
| Product Forge | Full product-lifecycle orchestrator for Spec Kit: research → product-spec → plan → tasks → implement → verify → test → release-readiness, across express/lite/standard/v-model modes with human-in-the-loop gates. | `process` | Read+Write | [speckit-product-forge](https://github.com/VaiYav/speckit-product-forge) |
| Product Spec Extension | Generates PRFAQ, Lean PRD, stakeholder summaries, and technical designs from engineering specs | `docs` | Read+Write | [spec-kit-product](https://github.com/d0whc3r/spec-kit-product) |
@@ -129,7 +136,7 @@ The following community-contributed extensions are available in [`catalog.commun
| Review Extension | Post-implementation comprehensive code review with specialized agents for code quality, comments, tests, error handling, type design, and simplification | `code` | Read-only | [spec-kit-review](https://github.com/ismaelJimenez/spec-kit-review) |
| Ripple | Detect side effects that tests can't catch after implementation — surface hidden ripple effects across 9 analysis categories | `code` | Read+Write | [spec-kit-ripple](https://github.com/chordpli/spec-kit-ripple) |
| SDD Utilities | Resume interrupted workflows, validate project health, and verify spec-to-task traceability | `process` | Read+Write | [speckit-utils](https://github.com/mvanhorn/speckit-utils) |
-| Security Review | Full-project secure-by-design security audits plus staged, branch/PR, plan, task, follow-up, and apply reviews | `code` | Read+Write | [spec-kit-security-review](https://github.com/DyanGalih/spec-kit-security-review) |
+| Security Review | Full-project secure-by-design security audits plus staged, branch/PR, plan, task, follow-up, and apply reviews | `code` | Read+Write | [security-review](https://github.com/DyanGalih/security-review) |
| SFSpeckit | Enterprise Salesforce SDLC with 18 commands for the full SDD lifecycle. | `process` | Read+Write | [spec-kit-sf](https://github.com/ysumanth06/spec-kit-sf) |
| Ship Release Extension | Automates release pipeline: pre-flight checks, branch sync, changelog generation, CI verification, and PR creation | `process` | Read+Write | [spec-kit-ship](https://github.com/arunt14/spec-kit-ship) |
| Spec Changelog | Auto-generate changelogs and release notes from spec git history and requirement diffs | `docs` | Read-only | [spec-kit-changelog](https://github.com/Quratulain-bilal/spec-kit-changelog) |
@@ -137,7 +144,7 @@ The following community-contributed extensions are available in [`catalog.commun
| Spec Diagram | Auto-generate Mermaid diagrams of SDD workflow state, feature progress, and task dependencies | `visibility` | Read-only | [spec-kit-diagram-](https://github.com/Quratulain-bilal/spec-kit-diagram-) |
| Spec Inventory | Read-only inventory of live requirement and task IDs, with focused per-task context packs instead of whole-file dumps | `visibility` | Read-only | [spec-kit-inventory-alignment](https://github.com/Yash-Chindam/spec-kit-inventory-alignment) |
| Spec Kit Discovery Extension | Run technical discovery commands for feasibility, technology selection, scenario-specific technical decisions, legacy codebase assessment, implementation understanding, and proof-of-concept validation | `process` | Read+Write | [spec-kit-discovery](https://github.com/bigsmartben/spec-kit-discovery) |
-| Spec Kit Figma | Agent-agnostic SpecKit extension that grounds spec, plan & task generation in Figma design context — REST + optional MCP, single/mono/multi-repo, macOS/Linux/Windows. | `integration` | Read+Write | [spec-kit-figma](https://github.com/Fyloss/spec-kit-figma) |
+| Spec Kit Figma | Grounds SpecKit spec/plan/tasks in Figma design context via REST or MCP, on macOS/Linux/Windows. | `integration` | Read+Write | [spec-kit-figma](https://github.com/Fyloss/spec-kit-figma) |
| Spec Kit Memory | Recalls prior specs and decisions from configurable memory tools (e.g. memsearch) before SDLC stages, so planning and specification start from what the project already knows | `docs` | Read+Write | [spec-kit-memory](https://github.com/zaytsevand/spec-kit-memory) |
| Spec Kit Preview | Generate evidence-backed low, mid, or high fidelity previews from Spec Kit artifacts as Markdown or self-contained HTML | `docs` | Read+Write | [spec-kit-preview](https://github.com/bigsmartben/spec-kit-preview) |
| Spec Kit Schedule | Optimal multi-agent task scheduling via CP-SAT — DAG precedence, hallucination-aware caps, file-conflict avoidance, stochastic durations, replanning, and interactive HTML output | `process` | Read+Write | [spec-kit-schedule](https://github.com/jfranc38/spec-kit-schedule) |
@@ -150,12 +157,11 @@ The following community-contributed extensions are available in [`catalog.commun
| Spec Sync | Detect and resolve drift between specs and implementation. AI-assisted resolution with human approval | `docs` | Read+Write | [spec-kit-sync](https://github.com/bgervin/spec-kit-sync) |
| Spec Trace | Build a requirement → test traceability matrix from spec.md and the test suite — surface untested requirements and orphan tests | `code` | Read+Write | [spec-kit-trace](https://github.com/Quratulain-bilal/spec-kit-trace) |
| Spec Validate | Comprehension validation, review gating, and approval state for spec-kit artifacts — staged quizzes, peer review SLA, and a hard gate before /speckit.implement | `process` | Read+Write | [spec-kit-spec-validate](https://github.com/aeltayeb/spec-kit-spec-validate) |
-| Spec-Kit BDD | ATDD/BDD extension: convert specs to Gherkin scenarios, scaffold step definitions, and verify acceptance test coverage | `process` | Read+Write | [spec-kit-bdd](https://github.com/RSginer/spec-kit-bdd) |
| Spec2Cloud | Spec-driven workflow tuned for shipping to Azure | `process` | Read+Write | [spec2cloud](https://github.com/Azure-Samples/Spec2Cloud) |
| SpecAssay Check | Gate 2 refuses silent gaps and emits a trace-manifest (trace-manifest.json). | `visibility` | Read+Write | [specassay](https://github.com/rdryfoos/specassay) |
| SpecJudge — right-size the model before you implement | Recommends the model that fits your tasks, citing the spec fragment behind every level. | `process` | Read-only | [SpecJudge](https://github.com/JoaquinRuiz/SpecJudge) |
| SpecKit Companion | Live spec-driven progress — lifecycle capture, status, resume, living specs, and composable commands with hooks and recipes | `process` | Read+Write | [speckit-companion](https://github.com/alfredoperez/speckit-companion) |
-| SpecKit Grill Me | Exhaustively resolve specification ambiguities and decisions before planning | `process` | Read+Write | [speckit-grill-me](https://github.com/yoshi1220/speckit-grill-me) |
+| SpecKit Grill Me | Exhaustively clarify specifications and optionally sync canonical domain knowledge | `process` | Read+Write | [speckit-grill-me](https://github.com/yoshi1220/speckit-grill-me) |
| SpecTest | Auto-generate test scaffolds from spec criteria, map coverage, and find untested requirements | `code` | Read+Write | [spec-kit-spectest](https://github.com/Quratulain-bilal/spec-kit-spectest) |
| Squad Bridge | Bootstrap and synchronize a Squad agent team from your Speckit spec and tasks. | `process` | Read+Write | [spec-kit-squad](https://github.com/jwill824/spec-kit-squad) |
| Staff Review Extension | Staff-engineer-level code review that validates implementation against spec, checks security, performance, and test coverage | `code` | Read-only | [spec-kit-staff-review](https://github.com/arunt14/spec-kit-staff-review) |
@@ -163,6 +169,7 @@ The following community-contributed extensions are available in [`catalog.commun
| Superpowers Bridge | Bridges selected Superpowers disciplines into Spec Kit as evidence-first trust gates for agent workflows. | `process` | Read+Write | [superpowers-bridge](https://github.com/RbBtSn0w/spec-kit-extensions/tree/main/superpowers-bridge) |
| Superpowers Implementation Bridge | Thin orchestrator between Spec Kit (design) and Superpowers (implementation). Cross-agent. | `process` | Read+Write | [speckit-superpowers-bridge](https://github.com/lihan3238/speckit-superpowers-bridge) |
| Superspec | Bridges spec-kit with obra/superpowers (brainstorming, TDD, subagent, code-review) into a unified, resumable workflow with graceful degradation and session progress tracking | `process` | Read+Write | [superspec](https://github.com/WangX0111/superspec) |
+| Taco Review | Packages Spec Kit features for human review and syncs edits and comments back. | `integration` | Read+Write | [taco](https://github.com/Arcadia822/taco) |
| Tasks to GitHub Project | Publish and synchronize Spec Kit tasks as cards on a GitHub Project (v2) kanban board, with priority and status sync between spec.md/tasks.md and the board. | `integration` | Read+Write | [spec-kit-tasks-to-project](https://github.com/mancioshell/spec-kit-tasks-to-project) |
| TDD Extension | Drives spec-kit implementation with tests: a language-agnostic red-green-refactor loop with a per-feature test list, recorded red and green evidence, and mutation-checked test strength. | `process` | Read+Write | [spec-kit-tdd](https://github.com/d0whc3r/spec-kit-tdd) |
| Team Assign | Assign tasks.md items to human engineers, split into subtasks, and generate a per-engineer workboard | `process` | Read+Write | [spec-kit-team-assign](https://github.com/tarunkumarbhati/spec-kit-team-assign) |
@@ -177,6 +184,7 @@ The following community-contributed extensions are available in [`catalog.commun
| Verify Review Ship | Post-convergence operational verification, technical review, learning governance, and transactional delivery. | `process` | Read+Write | [spec-kit-verify-review-ship](https://github.com/cadugevaerd/spec-kit-verify-review-ship) |
| Verify Tasks Extension | Detect phantom completions: tasks marked [X] in tasks.md with no real implementation | `code` | Read-only | [spec-kit-verify-tasks](https://github.com/datastone-inc/spec-kit-verify-tasks) |
| Version Guard | Verify tech stack versions against live npm registries before planning and implementation | `process` | Read-only | [spec-kit-version-guard](https://github.com/KevinBrown5280/spec-kit-version-guard) |
+| Vurnix Honest Gate | Deterministic three-state honest gate for AI-written code: compile + phantom-import + honest test count in one verdict, executed as code — not as agent self-review. PASS/BLOCK/UNPROVEN by exit code. | `process` | Read-only | [vurnix-spec-kit](https://github.com/shiersa/vurnix-spec-kit) |
| What-if Analysis | Preview the downstream impact (complexity, effort, tasks, risks) of requirement changes before committing to them | `visibility` | Read-only | [spec-kit-whatif](https://github.com/DevAbdullah90/spec-kit-whatif) |
| Wireframe Visual Feedback Loop | SVG wireframe generation, review, and sign-off for spec-driven development. Approved wireframes become spec constraints honored by /speckit.plan, /speckit.tasks, and /speckit.implement | `visibility` | Read+Write | [spec-kit-extension-wireframe](https://github.com/TortoiseWolfe/spec-kit-extension-wireframe) |
| Work IQ | Integrate Microsoft 365 organizational knowledge into spec-driven development workflows | `integration` | Read-only | [spec-kit-workiq](https://github.com/sakitA/spec-kit-workiq) |
diff --git a/docs/community/presets.md b/docs/community/presets.md
index d19eb35447..69a1523343 100644
--- a/docs/community/presets.md
+++ b/docs/community/presets.md
@@ -22,14 +22,14 @@ The following community-contributed presets customize how Spec Kit behaves — o
| Game Narrative Writing | Preset for game narrative design and interactive storytelling. It adapts the Spec-Driven Development workflow for game narratives: features become story mechanics, specs become narrative briefs, plans become story maps, and tasks become dialogue and scene-writing tasks. Supports branching narratives, player agency systems, state machines, and interactive dialogue trees. | 37 templates, 34 commands, 5 scripts | — | [speckit-preset-game-narrative-writing](https://github.com/adaumann/speckit-preset-game-narrative-writing) |
| Intake Authoring Governance | Governs traceable intake CRUD, language-aware requirements collections, bounded public HTTPS sources, and explicitly approved single or series authoring. | 13 templates, 5 commands, 7 scripts | — | [spec-kit-preset-intake-authoring-governance](https://github.com/hindermath/spec-kit-preset-intake-authoring-governance) |
| Intake Review Governance | Reviews single, series, campaign, and language-aware requirements collections before Spec Kit execution. | 9 templates, 3 commands, 5 scripts | — | [spec-kit-preset-intake-review-governance](https://github.com/hindermath/spec-kit-preset-intake-review-governance) |
-| Intake Sequencing Governance | Manages language-aware intake-series order, typed dependencies, lifecycle, and authority-neutral next-candidate selection. | 11 templates, 6 commands, 8 scripts | — | [spec-kit-preset-intake-sequencing-governance](https://github.com/hindermath/spec-kit-preset-intake-sequencing-governance) |
+| Intake Sequencing Governance | Manages language-aware intake-series order, typed dependencies, lifecycle, and authority-neutral next-candidate selection. | 12 templates, 6 commands, 8 scripts | — | [spec-kit-preset-intake-sequencing-governance](https://github.com/hindermath/spec-kit-preset-intake-sequencing-governance) |
| Inventory Alignment | Classifies each requirement against a read-only inventory of live IDs before writing, so reworded requirements are updated instead of duplicated. | 1 template, 2 commands | speckit-inventory extension | [spec-kit-inventory-alignment](https://github.com/Yash-Chindam/spec-kit-inventory-alignment) |
| iSAQB Architecture Governance | Adds iSAQB/CPSA-F and arc42 architecture governance, architecture views, quality scenarios, ADRs, risks, technical-debt evidence, and provider-neutral model routing. | 14 templates, 3 commands | — | [spec-kit-preset-isaqb-architecture-governance](https://github.com/hindermath/spec-kit-preset-isaqb-architecture-governance) |
| Jira Issue Tracking | Overrides `speckit.taskstoissues` to create Jira epics, stories, and tasks instead of GitHub Issues via Atlassian MCP tools | 1 command | — | [spec-kit-preset-jira](https://github.com/luno/spec-kit-preset-jira) |
| Model Driven Engineering | Focuses on streamlined commands, app repository support, cross-spec support, and capability-aware project memory for model-driven engineering workflows | 6 templates, 11 commands | MDE extension | [spec-kit-preset-mde](https://github.com/AI-MDE/spec-kit-preset-mde) |
| Model Routing Governance | Maps provider-neutral Spec Kit roles to validated harness-local runner profiles without storing model availability, credentials, or machine-specific selections in Git. | 4 templates, 2 commands, 2 scripts | — | [spec-kit-preset-model-routing-governance](https://github.com/hindermath/spec-kit-preset-model-routing-governance) |
| Multi-Repo Branching | Coordinates feature branch creation across multiple git repositories (independent repos and submodules) during plan and tasks phases | 2 commands | — | [spec-kit-preset-multi-repo-branching](https://github.com/sakitA/spec-kit-preset-multi-repo-branching) |
-| Parallel Autonomous Run Governance | Coordinates permission-bounded autonomous campaigns while preserving the project's learner and accessibility contract across workers and consolidation. | 9 templates, 5 commands, 2 scripts | autonomous-run-governance >=0.2.2; optional: intake-review-governance >=0.1.0 | [spec-kit-preset-parallel-autonomous-run-governance](https://github.com/hindermath/spec-kit-preset-parallel-autonomous-run-governance) |
+| Parallel Autonomous Run Governance | Coordinates isolated autonomous campaigns and optionally gates worker scheduling on a current campaign intake review. | 10 templates, 5 commands, 2 scripts | autonomous-run-governance >=0.2.2; optional: intake-review-governance >=0.1.0 | [spec-kit-preset-parallel-autonomous-run-governance](https://github.com/hindermath/spec-kit-preset-parallel-autonomous-run-governance) |
| Pirate Speak (Full) | Transforms all Spec Kit output into pirate speak — specs become "Voyage Manifests", plans become "Battle Plans", tasks become "Crew Assignments" | 6 templates, 9 commands | — | [spec-kit-presets](https://github.com/mnriem/spec-kit-presets) |
| Screenwriting | Spec-Driven Development for screenwriting/scriptwriting/tutorials: feature films, television (pilot, episode, limited series), and stage plays. Adapts the Spec Kit workflow to screenplay craft — slug lines, action lines, act breaks, beat sheets, and industry-standard pitch documents. Supports three-act, Save the Cat, TV pilot, network episode, cable/streaming episode, and stage-play structural frameworks. Export to Fountain, FTX, PDF | 26 templates, 32 commands, 1 script | — | [speckit-preset-screenwriting](https://github.com/adaumann/speckit-preset-screenwriting) |
| Security Governance | Adds memory-safe-language and secure-coding governance, exact-head security evidence, ASVS, supply-chain transparency, EU regulatory screening, and provider-neutral model routing. | 15 templates, 3 commands | — | [spec-kit-preset-security-governance](https://github.com/hindermath/spec-kit-preset-security-governance) |
@@ -38,6 +38,7 @@ The following community-contributed presets customize how Spec Kit behaves — o
| SpecAssay | Appends durable-ID, Carries, and SpecAssay vocabulary onto Spec Kit spec, tasks, and constitution templates. | 3 templates | — | [specassay](https://github.com/rdryfoos/specassay) |
| Table of Contents Navigation | Adds a navigable Table of Contents to generated spec.md, plan.md, and tasks.md documents | 3 templates, 3 commands | — | [spec-kit-preset-toc-navigation](https://github.com/Quratulain-bilal/spec-kit-preset-toc-navigation) |
| Test-First Governance | Governs TDD with coverage-complete BDD/ATDD Gherkin scenarios, explicit suite ownership, professional test reports, traceability, and risk-based quality gates. | 10 templates, 8 commands | — | [spec-kit-preset-test-first-governance](https://github.com/ka-zo/spec-kit-preset-test-first-governance) |
+| Verified Codebase Context | Generates evidence-qualified repository context with codebase-memory-mcp and applies it across planning, tasks, analysis, and implementation. | 1 template, 5 commands | — | [spec-kit-preset-codebase-memory-context](https://github.com/philo-x/spec-kit-preset-codebase-memory-context) |
| VS Code Ask Questions | Enhances the clarify command to use `vscode/askQuestions` for batched interactive questioning. | 1 command | — | [spec-kit-presets](https://github.com/fdcastel/spec-kit-presets) |
| Workflow Preset | Behavior-first specification, design artifacts, and agent-native handoff orchestration — adds requirement-phase behavior drafts, formal BDD/UIF/behavior contracts, optional design artifacts, and scoped implementation handoffs with Core Agent, Vertical Planner Agent, and Worker Agent modes | 22 templates, 8 commands | — | [spec-kit-workflow-preset](https://github.com/bigsmartben/spec-kit-workflow-preset) |
diff --git a/docs/docfx.json b/docs/docfx.json
index e22b394ba0..77a7653dd7 100644
--- a/docs/docfx.json
+++ b/docs/docfx.json
@@ -64,6 +64,8 @@
"globalMetadata": {
"_appTitle": "Spec Kit Documentation",
"_appName": "Spec Kit",
+ "_appLogoPath": "images/spec-kit-logo.webp",
+ "_appFaviconPath": "images/spec-kit-logo.webp",
"_appFooter": "Spec Kit - A specification-driven development toolkit",
"_enableSearch": true,
"_disableContribution": false,
diff --git a/docs/guides/evolving-specs.md b/docs/guides/evolving-specs.md
index e2941f08b3..17a91298ea 100644
--- a/docs/guides/evolving-specs.md
+++ b/docs/guides/evolving-specs.md
@@ -1,5 +1,9 @@
# Evolving Specs in Existing Projects
+If the repository has not been initialized with Spec Kit yet, start with
+[Adopting Spec Kit in an Existing Project](existing-projects.md). This page
+covers how to maintain artifacts after adoption.
+
Existing projects need two separate maintenance loops:
- **Spec Kit project-file updates** refresh managed commands, scripts,
diff --git a/docs/guides/existing-projects.md b/docs/guides/existing-projects.md
new file mode 100644
index 0000000000..479715546e
--- /dev/null
+++ b/docs/guides/existing-projects.md
@@ -0,0 +1,106 @@
+# Adopting Spec Kit in an Existing Project
+
+You do not need to recreate an existing system from specifications before using
+Spec Kit. Initialize the repository in place, capture the rules that matter,
+and use the workflow for the next bounded change.
+
+## 1. Start from a Reviewable Baseline
+
+Before initialization, commit or stash existing work and create a branch for the
+adoption. This makes every generated file visible in a normal code review.
+
+Choose the [integration key](../reference/integrations.md) for the coding agent
+you use. Then run the command from the repository root:
+
+```bash
+specify init --here --force --integration
+```
+
+`--here` targets the current directory. `--force` allows initialization in a
+non-empty directory and may replace files at conflicting managed paths, so use
+it only after creating a reviewable baseline. It does not delete the rest of
+your application.
+
+Review the resulting diff before continuing. Initialization adds the shared
+`.specify/` project files and the command or skill files required by your
+selected integration. It does not rewrite your application or infer
+specifications for existing behavior.
+
+> [!NOTE]
+> Git initialization and feature branches are optional and are managed by the
+> **git** extension. Add it with `specify extension add git` if you want that
+> workflow.
+
+## 2. Capture Project Guardrails
+
+Run `/speckit.constitution` with principles that are already true for the
+repository or that the team has explicitly agreed to adopt:
+
+```text
+/speckit.constitution Preserve public API compatibility. Follow the existing
+service boundaries. Every database migration must include a rollback plan.
+Run the repository's established unit and integration test suites.
+```
+
+Use the repository's README, architecture decisions, contribution guide, and
+CI configuration as evidence. Do not invent standards merely to fill the
+constitution template. The constitution governs later planning and analysis,
+so unrealistic rules create noise instead of useful constraints.
+
+## 3. Choose a Bounded First Change
+
+Start with a feature, bug fix, or modernization slice that can be reviewed
+independently. Do not make "document the entire existing system" your first
+feature unless that inventory is itself the intended deliverable.
+
+Describe both the requested outcome and the compatibility boundaries that must
+remain intact:
+
+```text
+/speckit.specify Add CSV export to the existing orders page. Preserve current
+filters and authorization behavior. Export only the rows visible to the signed-in
+user, and do not change the existing JSON API response.
+```
+
+The codebase remains implementation context. The new `spec.md` defines the
+change you intend to make, not a retroactive specification of every existing
+behavior.
+
+## 4. Plan Against the Repository
+
+Continue through the normal workflow:
+
+1. Run `/speckit.clarify` to resolve uncertain behavior and compatibility
+ requirements.
+2. Run `/speckit.plan` and verify that the proposed design reuses the existing
+ architecture, dependencies, and test conventions.
+3. Run `/speckit.tasks`, then `/speckit.analyze` to check consistency before
+ implementation.
+4. Run `/speckit.implement` and review code and artifact changes together.
+5. Run `/speckit.converge` to find remaining gaps. If it adds tasks, repeat
+ implementation and convergence until the feature is complete.
+
+For command details and optional quality gates, see the
+[Quick Start Guide](../quickstart.md) and
+[Agentic SDD reference](../reference/agentic-sdd.md).
+
+## 5. Decide How Specs Will Age
+
+After the first change, agree on how the team will maintain completed feature
+artifacts:
+
+- Keep each feature directory as an immutable historical record.
+- Maintain `spec.md` as a living contract and regenerate downstream artifacts.
+- Allow discoveries to flow back from code, tasks, or plans, then reconcile the
+ full artifact set.
+
+The [Spec Persistence Models](../concepts/spec-persistence.md) page compares
+these choices. The [Evolving Specs guide](evolving-specs.md) provides the
+maintenance loop for each model.
+
+## Existing-Project Examples
+
+The [community walkthroughs](../community/walkthroughs.md) include brownfield
+examples across .NET, Java, and Go/React codebases. Community extensions for
+architecture discovery and brownfield bootstrapping are listed in the
+[extension catalog](../community/extensions.md).
diff --git a/docs/history.md b/docs/history.md
new file mode 100644
index 0000000000..b3aee62909
--- /dev/null
+++ b/docs/history.md
@@ -0,0 +1,173 @@
+# History
+
+Spec Kit began as a toolkit for making specifications the starting point of
+AI-assisted development. From its
+[first full check-in](https://github.com/github/spec-kit/commit/28fdfaa86973d4402eecd89ba6c87d31e1edae03),
+it described three ways to apply Spec-Driven Development:
+
+- **0-to-1 Development ("Greenfield")** generates a new system from
+ requirements.
+- **Creative Exploration** compares parallel implementations, technology
+ choices, and experience designs.
+- **Iterative Enhancement ("Brownfield")** adds features to and modernizes
+ existing systems.
+
+All three moved from durable planning artifacts into implementation:
+
+**Specify → Plan → Tasks → Implement**
+
+Those development paths and that core sequence remain, but the project has
+grown into an extensible harness for coding agents, software delivery
+processes, and other structured work.
+
+## Project stewardship
+
+Spec Kit's history includes two distinct stewardship periods. Recording them
+here preserves the contemporary account of the project's leadership without
+reducing the work to any one person.
+
+### Founding stewardship: August 2025–January 2026
+
+[Den Delimarsky](https://github.com/localden) and
+[John Lam](https://github.com/jflam) conceived Spec Kit and gave the project its
+first shape. Den authored the
+[initial commit](https://github.com/github/spec-kit/commit/fa2736371e077f55c4fe145fea186bab2561386d) on
+August 21, 2025 and led the repository through its first months.
+
+That founding period established the shape users still recognize: the Specify
+CLI, coding-agent-specific scaffolding, project constitutions, and the
+specification → plan → tasks → implementation process. It also framed SDD as
+useful for greenfield development, parallel exploration, and brownfield
+enhancement rather than tying the method to a single agent or development
+scenario.
+
+### Community stewardship: January 2026–present
+
+[Manfred Riem](https://github.com/mnriem) took over as lead maintainer on
+January 22, 2026. The transition became publicly visible when the repository's
+global [`CODEOWNERS` entry](https://github.com/github/spec-kit/commit/3040d33c31d8a26d50f91aec5d62d1cecac3298c)
+changed to `@mnriem` on February 23.
+
+During this stewardship, the maintainer team's focus moved from building a
+composable model to using it to ship complete first-party processes. That shift
+was not sequential for the community: the modular extension system began as a
+community contribution, and contributors adopted and extended each primitive
+as it arrived.
+
+These dates and roles are also documented in the lead maintainer's
+[six-month retrospective](https://www.manorrock.com/blog/2026/07/22/six_months_leading_spec_kit.html)
+and
+[first-anniversary account](https://www.manorrock.com/blog/2026/08/21/spec_kit_turns_one.html),
+and are consistent with the repository's commit and ownership history.
+
+## Milestones
+
+### August 2025: The foundation
+
+The repository history begins on August 21, 2025. The first releases established
+the Specify CLI, reusable templates, and the core Spec-Driven Development
+paths. Support for multiple coding agents through centrally configured,
+agent-specific scaffolding was part of the project from the start, keeping the
+process independent of any one model or tool.
+
+### February–April 2026: Building the primitives
+
+The modular extension system arrived in February as a community contribution
+from Michal Bachorik, allowing capabilities to be added without expanding the
+core process. March brought pluggable presets, which made templates and
+commands replaceable or composable while preserving the same CLI experience.
+
+The founding-era agent scaffolding was rewritten as a registry-backed
+integration architecture. Core assets were also embedded in the Python package,
+enabling reliable offline and air-gapped initialization.
+
+The workflow engine introduced catalog-distributed automation and built-in
+workflow step types in April. Workflows could coordinate reusable steps rather
+than requiring users to invoke every command manually. An integration catalog
+followed, making coding-agent support discoverable and independently
+distributable.
+
+The composable model came to be described through five primitives:
+
+- **Integrations** connect Spec Kit to coding agents.
+- **Extensions** add capabilities, commands, templates, scripts, and hooks.
+- **Presets** customize or replace behavior.
+- **Workflows** automate multi-step processes.
+- **Workflow steps** provide reusable units of workflow behavior.
+
+The emphasis during these first months was on creating reusable machinery:
+making the process configurable, distributable, and automatable before adding
+more first-party processes. Community contributors did not wait for the full
+model to be complete; they quickly used the new extension and preset surfaces
+to publish their own capabilities and process variations.
+
+### June–July 2026: Composing and applying the primitives
+
+For the core team, June marked the turn from mainly building primitives to using
+them. A workflow step catalog made custom step types community-installable,
+extending a primitive that had shipped with the workflow engine in April.
+Bundles then made it possible to package extensions, presets, workflows, and
+steps as a coherent setup for a role or team, optionally targeting a specific
+integration.
+
+Catalogs became the bridge between the primitives and the community. Community
+authors built extensions, presets, integrations, workflows, step types, and
+bundles; the maintainer team checked submission metadata and listed accepted
+entries in community catalogs so users could discover and install them. A
+catalog listing made a component visible, but did not mean its code had been
+audited or endorsed.
+
+At the same time, core maintainers began using the model to add two first-party
+processes alongside feature delivery:
+
+- On June 5, version 0.9.5 introduced the bundled, opt-in
+ [`bug` extension](https://github.com/github/spec-kit/commit/60302fefec541a68fcac6f0428a95ba35f2acadf).
+ Its assess → fix → test process keeps bug diagnosis, remediation, and
+ verification separate and documented.
+- On July 17, version 0.13.0 introduced the bundled, opt-in
+ [`assess` extension](https://github.com/github/spec-kit/commit/208d38695fc88d8eaec7855c96e5098a852927cf).
+ Its intake → research → define → shape → decide process evaluates an idea
+ before it enters SDD.
+
+Distribution broadened too: the release pipeline added PyPI publishing, and
+Python joined Bash and PowerShell as a supported project script type. These
+changes made installation and cross-platform use simpler while preserving
+support for offline and enterprise environments.
+
+### August 2026: First anniversary
+
+Spec Kit turned one and released version 1.0.0 on August 21, 2026. By then, its
+five primitives — integrations, extensions, presets, workflows, and workflow
+steps — already formed a coherent model. Bundles composed extensions, presets,
+workflows, and steps around a selected integration. A README refresh made the
+existing SDD, bug-fixing, and idea-assessment processes easier to discover
+through separate quickstarts.
+
+Version 1.0.0 did not create or freeze that model; it gave the project's
+evolving state a round number. The documentation then reported 38 coding-agent
+integrations, 157 community extensions, 33 presets, and 270+ contributors. Spec
+Kit continues to favor adaptability: processes, integrations, and conventions
+can evolve while agents help projects apply those changes.
+
+## Enduring themes
+
+Several themes connect the project's stewardship periods and technical
+evolution:
+
+- **Intent comes before implementation.** Specifications capture what should be
+ built before technical decisions dominate the work.
+- **Artifacts should be durable.** Specs, plans, and tasks remain useful beyond
+ a single prompt or agent session.
+- **The process should be agent-independent.** Teams can change coding agents
+ without abandoning their development method.
+- **The method should adapt to the work.** The original development paths grew
+ into a formally composable model that teams can modify, automate, or replace.
+- **The community shapes the kit.** Community contributions have influenced
+ both the project's infrastructure and the ecosystem built on it.
+
+## Release history
+
+This page records the project's broad evolution, not every feature or breaking
+change. For release-level detail, see the
+[changelog](https://github.com/github/spec-kit/blob/main/CHANGELOG.md) and
+[GitHub Releases](https://github.com/github/spec-kit/releases).
diff --git a/docs/images/spec-kit-logo.webp b/docs/images/spec-kit-logo.webp
new file mode 100644
index 0000000000..209e3deeff
Binary files /dev/null and b/docs/images/spec-kit-logo.webp differ
diff --git a/docs/index.md b/docs/index.md
index 61cd50dd47..082c22ac7d 100644
--- a/docs/index.md
+++ b/docs/index.md
@@ -1,5 +1,7 @@
+

+
# GitHub Spec Kit
**Spec-Driven Development or your own process — step by step or as an automated workflow.**
@@ -31,7 +33,7 @@ Define what to build before building it. Rich templates, quality checklists, and
### Use any coding agent
-
35 integrations — Copilot, Gemini, Codex, Kilo Code, Zed, Claude, Forge, Kiro, and more. Switch freely between agents with a single command. No lock-in.
+
38 integrations — Copilot, Gemini, Codex, Kilo Code, Zed, Claude, Forge, Kiro, and more. Switch freely between agents with a single command. No lock-in.
Run `specify init` with your agent of choice and Spec Kit sets up the right command files and directory structures automatically. If your agent isn't listed, the `generic` integration is an escape hatch for any tool.
@@ -43,7 +45,7 @@ Run `specify init` with your agent of choice and Spec Kit sets up the right comm
### Make it your own
-
138 community extensions (70+ authors),
25 presets, and growing. Tune the core process with presets, extend it with extensions, orchestrate it with workflows, and package it all up as bundles you can share — or replace the process entirely. The process itself lives in these building blocks, so you're never locked to SDD, or even to software.
+
157 community extensions (90+ authors),
33 presets, and growing. Tune the core process with presets, extend it with extensions, orchestrate it with workflows, and package it all up as bundles you can share — or replace the process entirely. The process itself lives in these building blocks, so you're never locked to SDD, or even to software.
Including entirely different processes:
@@ -82,31 +84,31 @@ Community extensions like CI Guard and Architecture Guard add compliance gates a
## Built by the community
-**240+ contributors** power the Spec Kit ecosystem — from core integrations to entirely new processes. Anyone can create and publish an extension, preset, or workflow.
+**270+ contributors** power the Spec Kit ecosystem — from core integrations to entirely new processes. Anyone can create and publish an extension, preset, or workflow.
- 121K+
+ 130K+
GitHub stars
- 240+
+ 270+
Contributors
- 35
+ 38
Integrations
- 138
+ 157
Extensions
- 25
+ 33
Presets
- 6
+ 7
Friends projects
@@ -124,6 +126,14 @@ Community extensions like CI Guard and Architecture Guard add compliance gates a
Getting Started
Install, configure, and run your first SDD workflow
+
+ Existing Projects
+ Adopt Spec Kit safely in an established codebase
+
+
+ Upgrade
+ Keep an existing Spec Kit project current across releases
+
Reference
Core commands, integrations, extensions, presets, and workflows
@@ -140,6 +150,10 @@ Community extensions like CI Guard and Architecture Guard add compliance gates a
What is SDD?
The philosophy behind Spec-Driven Development
+
+ History
+ How Spec Kit grew from its SDD foundation into an extensible process harness
+
---
@@ -155,4 +169,4 @@ Ready to start? Follow the [Quick Start Guide](quickstart.md).
-Last updated: July 16, 2026
+Last updated: August 21, 2026
diff --git a/docs/quickstart.md b/docs/quickstart.md
index 2813118b5f..fb2ecc2f74 100644
--- a/docs/quickstart.md
+++ b/docs/quickstart.md
@@ -47,6 +47,9 @@ specify init taskify # or: specify init . to use the current directory
> [!NOTE]
> Prefer `pipx`, one-time `uvx` runs, a pinned release, or an offline/air-gapped setup? See the [Installation Guide](installation.md) for all supported methods.
+> Adding Spec Kit to a repository that already contains code? Follow
+> [Adopting Spec Kit in an Existing Project](guides/existing-projects.md) before
+> starting the workflow below.
### Step 1: `/speckit.constitution` — set the ground rules
diff --git a/docs/reference/extensions.md b/docs/reference/extensions.md
index 8de2c18c86..0473e72008 100644
--- a/docs/reference/extensions.md
+++ b/docs/reference/extensions.md
@@ -75,6 +75,8 @@ specify extension update []
Updates a specific extension, or all installed extensions if no name is given.
+Bundled extensions (such as `agent-context` and `git`) have no download URL; their updates install from the copy shipped with the running spec-kit release. When the catalog advertises a newer version than your spec-kit release ships, the update is reported as requiring a spec-kit upgrade first.
+
## Enable / Disable an Extension
```bash
diff --git a/docs/reference/integrations.md b/docs/reference/integrations.md
index 57bb46b10c..551f73c97e 100644
--- a/docs/reference/integrations.md
+++ b/docs/reference/integrations.md
@@ -16,7 +16,9 @@ The Specify CLI supports a wide range of AI coding agents. When you run `specify
| [Codex CLI](https://github.com/openai/codex) | `codex` | Skills-based integration; installs skills into `.agents/skills` and invokes them as `$speckit-` |
| [Command Code](https://commandcode.ai/docs) | `command-code` | Skills-based integration; installs skills into `.commandcode/skills/` and invokes them as `$speckit-` |
| [Cursor](https://cursor.sh/) | `cursor-agent` | |
+| [DeepSeek Harness](https://github.com/deepseek-ai/deepseek-harness) | `dsh` | Skills-based integration; installs skills into `.dsh/skills` and invokes them as `/speckit-` |
| [Devin for Terminal](https://cli.devin.ai/docs) | `devin` | Skills-based integration; installs skills into `.devin/skills/` and invokes them as `/speckit-` |
+| [Docker Agent](https://docs.docker.com/ai/docker-agent/) | `docker-agent` | Skills-based integration; installs skills into `.agents/skills/` (the same directory used by Codex and Zed). In the selected agent YAML, enable local skills with `skills: true` and provide filesystem read access. Detects either the standalone `docker-agent` binary or the Docker CLI plugin (`docker agent`). Configure workflow dispatch with `SPECKIT_INTEGRATION_DOCKER_AGENT_EXTRA_ARGS=./agent.yaml`; the Spec Kit prompt is appended after these arguments. Not multi-install safe by default because the skills directory is shared. |
| [Factory Droid](https://docs.factory.ai/cli/getting-started/overview) | `droid` | Skills-based integration; installs skills into `.factory/skills/` and invokes them as `/speckit-` |
| [Firebender](https://firebender.com/) | `firebender` | IDE-based agent for Android Studio / IntelliJ |
| [Forge](https://forgecode.dev/) | `forge` | |
@@ -32,6 +34,7 @@ The Specify CLI supports a wide range of AI coding agents. When you run `specify
| [Kiro CLI](https://kiro.dev/docs/cli/) | `kiro-cli` | Kiro CLI does not substitute `$ARGUMENTS` in file-based prompts, so Spec Kit ships a prose fallback at render time (see [Manage prompts](https://kiro.dev/docs/cli/chat/manage-prompts/) and issue [#1926](https://github.com/github/spec-kit/issues/1926)). Alias: `--integration kiro` |
| [Lingma](https://lingma.aliyun.com/) | `lingma` | Skills-based integration; skills are installed automatically |
| [Mistral Vibe](https://github.com/mistralai/mistral-vibe) | `vibe` | |
+| [Muse Code](https://dev.meta.ai/docs/muse-code) | `muse` | Skills-based integration; installs skills into `.agents/skills` and invokes them as `/speckit-` |
| [Oh My Pi](https://www.npmjs.com/package/@oh-my-pi/pi-coding-agent) | `omp` | Installs slash commands into `.omp/commands` |
| [opencode](https://opencode.ai/) | `opencode` | |
| [Pi Coding Agent](https://pi.dev) | `pi` | Pi doesn't have MCP support out of the box, so `taskstoissues` won't work as intended. MCP support can be added via [extensions](https://github.com/badlogic/pi-mono/tree/main/packages/coding-agent#extensions) |
@@ -292,7 +295,7 @@ The currently declared multi-install safe integrations are:
| `lingma` | `.lingma/skills` |
| `omp` | `.omp/commands` |
| `pi` | `.pi/prompts` |
-| `qodercli` | `.qoder/commands` |
+| `qodercli` | `.qoder/skills` |
| `qwen` | `.qwen/commands` |
| `shai` | `.shai/commands` |
| `tabnine` | `.tabnine/agent/commands` |
diff --git a/docs/reference/workflows.md b/docs/reference/workflows.md
index 3b838b7227..e2deaf181c 100644
--- a/docs/reference/workflows.md
+++ b/docs/reference/workflows.md
@@ -18,7 +18,7 @@ Runs a workflow from a catalog ID, URL, or local file path. Inputs declared by t
Example:
```bash
-specify workflow run speckit -i spec="Build a kanban board with drag-and-drop task management" -i scope=full
+specify workflow run speckit -i spec="Build a kanban board with drag-and-drop task management"
```
With `--json`, a single machine-readable object is printed instead of formatted text (the default output is unchanged when the flag is omitted):
@@ -286,6 +286,40 @@ edits:
Lower priority values have higher precedence. Change this overlay to `priority: 5` if it must win a conflict with the `add-lint` overlay above. It replaces the `review-plan` gate with a non-interactive command.
+### Workflow slots (upstream extension points)
+
+Workflow authors can declare a named, no-op workflow slot with `type: slot`:
+
+```yaml
+- id: post-implement
+ type: slot
+ name: "Post-implementation checks"
+```
+
+The step `id` is the unique overlay anchor; `name` is a required non-blank,
+human-readable label only. An unfilled slot completes as a `skipped` step with
+`output: {slot: }`, so subsequent steps continue normally.
+
+Fill a slot with a schema-valid overlay `replace` edit anchored on the step
+`id`, not its `name`:
+
+```yaml
+id: fill-post-implement
+extends: my-workflow
+edits:
+ - replace: post-implement
+ step:
+ id: post-implement
+ type: shell
+ run: "echo Run project-specific checks"
+```
+
+Reuse the slot's `id` when later expressions or `fan-in.wait_for` refer to it.
+The replacement must also preserve every output key those later steps consume:
+an unfilled workflow slot supplies only `steps..output.slot`. Slot steps are
+not supported inside `fan-out.step` templates because runtime-multiplied
+templates cannot be overlay anchors.
+
### Interaction with Bundles and Updates
`specify workflow add ` installs the complete local workflow
@@ -397,14 +431,19 @@ schema_version: "1.0"
workflow:
id: "speckit"
name: "Full SDD Cycle"
- version: "1.0.0"
+ version: "1.0.1"
author: "GitHub"
description: "Runs specify → plan → tasks → implement with review gates"
requires:
- speckit_version: ">=0.7.2"
+ speckit_version: ">=0.8.5"
integrations:
- any: ["copilot", "claude", "gemini"]
+ any:
+ - "alquimia"
+ - "claude"
+ - "copilot"
+ - "gemini"
+ - "opencode"
inputs:
spec:
@@ -413,12 +452,8 @@ inputs:
prompt: "Describe what you want to build"
integration:
type: string
- default: "copilot"
- prompt: "Integration to use (e.g. claude, copilot, gemini)"
- scope:
- type: string
- default: "full"
- enum: ["full", "backend-only", "frontend-only"]
+ default: "auto"
+ prompt: "Integration to use (e.g. claude, copilot, gemini; 'auto' uses the project's initialized integration)"
steps:
- id: specify
@@ -494,6 +529,7 @@ specify workflow run speckit -i spec="Build a kanban board with drag-and-drop ta
| `prompt` | Send an arbitrary prompt to the AI coding agent |
| `shell` | Execute a shell command and capture output |
| `init` | Bootstrap a project (like `specify init`) |
+| `slot` | Named workflow slot; skipped when unfilled |
| `gate` | Pause for human approval before continuing |
| `if` | Conditional branching (then/else) |
| `switch` | Multi-branch dispatch on an expression |
diff --git a/docs/template/public/main.css b/docs/template/public/main.css
index 52ce456064..68f91d9dfa 100644
--- a/docs/template/public/main.css
+++ b/docs/template/public/main.css
@@ -25,6 +25,13 @@
--gh-coral-subtle: #2d0f0d;
}
+/* Keep the raster Spec Kit logo aligned with DocFX's default header dimensions. */
+.navbar-brand #logo {
+ width: 1.5rem;
+ height: 1.5rem;
+ margin-right: 0.375rem;
+}
+
/* Override Bootstrap primary with GitHub blue */
body[data-layout="landing"] {
--bs-primary: var(--gh-blue);
@@ -44,6 +51,13 @@ body[data-layout="landing"][data-bs-theme="dark"] {
padding: 3rem 0 1.5rem;
}
+.landing-hero-logo {
+ display: block;
+ width: 7.5rem;
+ height: 7.5rem;
+ margin: 0 auto 1rem;
+}
+
.landing-hero h1 {
font-size: 2.6rem;
font-weight: 800;
diff --git a/docs/toc.yml b/docs/toc.yml
index a2e07b270c..d2f1b2bd21 100644
--- a/docs/toc.yml
+++ b/docs/toc.yml
@@ -2,6 +2,10 @@
- name: Home
href: index.md
+# Project history
+- name: Project History
+ href: history.md
+
# Getting started section
- name: Getting Started
items:
@@ -9,6 +13,8 @@
href: installation.md
- name: Quick Start
href: quickstart.md
+ - name: Existing Projects
+ href: guides/existing-projects.md
- name: Upgrade
href: upgrade.md
- name: Install uv
diff --git a/extensions/EXTENSION-DEVELOPMENT-GUIDE.md b/extensions/EXTENSION-DEVELOPMENT-GUIDE.md
index ac78029f2a..ff0e90c234 100644
--- a/extensions/EXTENSION-DEVELOPMENT-GUIDE.md
+++ b/extensions/EXTENSION-DEVELOPMENT-GUIDE.md
@@ -296,15 +296,16 @@ A command body is a *template* that Spec Kit renders once per agent. Different a
Instead use the agent-neutral token `__SPECKIT_COMMAND___`. Spec Kit resolves it to a `/speckit...` invocation using the active integration's `invoke_separator` (and integrations may post-process that further in skills output).
-Encode the command name in upper case, dropping the `speckit.` prefix and turning each dotted segment separator into an underscore:
+Encode the command name in upper case, dropping the `speckit.` prefix and turning each dotted segment separator into an underscore. A hyphen inside a segment is kept as a hyphen:
| Command file | Token |
| --- | --- |
| `speckit.plan.md` | `__SPECKIT_COMMAND_PLAN__` |
| `speckit.bug.fix.md` | `__SPECKIT_COMMAND_BUG_FIX__` |
| `speckit.git.commit.md` | `__SPECKIT_COMMAND_GIT_COMMIT__` |
+| `speckit.agent-context.update.md` | `__SPECKIT_COMMAND_AGENT-CONTEXT_UPDATE__` |
-The resolver maps each underscore back to the active agent's separator, so use tokens to reference commands whose name segments are single words. (Command names are dotted segments like `git.commit`; the token scheme rebuilds those dots and does not carry hyphens within a segment.)
+The resolver maps each underscore back to the active agent's separator. An underscore separates segments and a hyphen belongs to the segment it sits in, so `AGENT-CONTEXT_UPDATE` is the two segments `agent-context` and `update` rather than three.
**Example** — a command body that points the user at the next step:
@@ -314,15 +315,19 @@ Once the assessment exists, the next step is `__SPECKIT_COMMAND_BUG_FIX__ slug=<
This renders as `/speckit.bug.fix slug=` for a slash-based agent, `/speckit-bug-fix slug=` for a skills-based agent, and so on — the author writes it once and it stays portable. The first-party `bug` and `git` extensions use this token exclusively; see `extensions/bug/commands/` for working examples.
-> **Current limitation — skills mode.** Token resolution runs in the
-> command-rendering path (`CommandRegistrar`), so it applies when an extension
-> installs *command files*. It does **not** yet run when an extension is
-> registered as *skills* for a skills-based agent: `_register_extension_skills`
-> resolves placeholders and post-processes content but never calls
-> `resolve_command_refs`, so a `__SPECKIT_COMMAND___` token reaches
-> agents such as Codex, ZCode, and Kimi verbatim in that mode. Until that
-> rendering step lands, prefer the token for command-file extensions and avoid
-> relying on it inside skill bodies destined for skills-based agents.
+> **Skills mode.** Token resolution runs in both paths, so the token is safe to
+> use either way. Command files go through the command-rendering path
+> (`CommandRegistrar`). Skill bodies go through `_resolve_command_ref_tokens` in
+> `_register_extension_skills`, which resolves the same token shape against the
+> active skill style, so `__SPECKIT_COMMAND_BUG_FIX__` renders as:
+>
+> | Agent | Rendered |
+> | --- | --- |
+> | Codex, ZCode, Command Code | `$speckit-bug-fix` |
+> | Claude, Copilot, Cursor, Devin, Droid, Grok and the other slash agents | `/speckit-bug-fix` |
+> | Kimi | `/skill:speckit-bug-fix` |
+>
+> Anything else falls through to the integration's own `build_command_invocation`.
### Script Path Rewriting
diff --git a/extensions/EXTENSION-PUBLISHING-GUIDE.md b/extensions/EXTENSION-PUBLISHING-GUIDE.md
index f0eff5417b..0cbe93aae6 100644
--- a/extensions/EXTENSION-PUBLISHING-GUIDE.md
+++ b/extensions/EXTENSION-PUBLISHING-GUIDE.md
@@ -274,8 +274,7 @@ When releasing a new version:
A: The main catalog is for public extensions only. For private extensions:
- Host your own catalog.json file
-- Users add your catalog: `specify extension add-catalog https://your-domain.com/catalog.json`
-- Not yet implemented - coming in Phase 4
+- In a Spec Kit project, users add your catalog: `specify extension catalog add https://your-domain.com/catalog.json --name private-catalog`
### Q: How long does review take?
diff --git a/extensions/agent-context/agent-context-defaults.json b/extensions/agent-context/agent-context-defaults.json
index b50c10d69e..8c5f781ab9 100644
--- a/extensions/agent-context/agent-context-defaults.json
+++ b/extensions/agent-context/agent-context-defaults.json
@@ -25,6 +25,7 @@
"kimi": "AGENTS.md",
"kiro-cli": "AGENTS.md",
"lingma": ".lingma/rules/specify-rules.md",
+ "muse": "AGENTS.md",
"omp": "AGENTS.md",
"opencode": "AGENTS.md",
"pi": "AGENTS.md",
diff --git a/extensions/catalog.community.json b/extensions/catalog.community.json
index b798dd9da8..282127c7de 100644
--- a/extensions/catalog.community.json
+++ b/extensions/catalog.community.json
@@ -1,6 +1,6 @@
{
"schema_version": "1.0",
- "updated_at": "2026-08-20T00:00:00Z",
+ "updated_at": "2026-09-03T00:00:00Z",
"catalog_url": "https://raw.githubusercontent.com/github/spec-kit/main/extensions/catalog.community.json",
"extensions": {
"adrkit": {
@@ -221,6 +221,57 @@
"created_at": "2026-08-18T00:00:00Z",
"updated_at": "2026-08-18T00:00:00Z"
},
+ "agentdocx-speckitv2": {
+ "name": "AgentDocx SpecKit V2",
+ "id": "agentdocx-speckitv2",
+ "description": "AgentDocx evolved: same pipeline + far more autonomous Ticket Manager (5 CLI, per-project Kanban, bulk sync, auto-switch, Auditor).",
+ "author": "ahmed200346",
+ "version": "0.0.7",
+ "download_url": "https://github.com/ahmed200346/Extension_GithubSpecKit/archive/refs/tags/v0.0.7.zip",
+ "repository": "https://github.com/ahmed200346/Extension_GithubSpecKit",
+ "homepage": "https://github.com/ahmed200346/Extension_GithubSpecKit",
+ "documentation": "https://github.com/ahmed200346/Extension_GithubSpecKit/blob/main/README.md",
+ "changelog": "https://github.com/ahmed200346/Extension_GithubSpecKit/blob/main/agentdocx-speckit/CHANGELOG.md",
+ "license": "MIT",
+ "category": "integration",
+ "effect": "read-write",
+ "requires": {
+ "speckit_version": ">=0.1.0",
+ "tools": [
+ {
+ "name": "python",
+ "version": ">=3.10",
+ "required": true
+ },
+ {
+ "name": "node",
+ "version": ">=18",
+ "required": true
+ },
+ {
+ "name": "postgresql",
+ "version": ">=12",
+ "required": true
+ }
+ ]
+ },
+ "provides": {
+ "commands": 0,
+ "hooks": 0
+ },
+ "tags": [
+ "integration",
+ "kanban",
+ "ticket-management",
+ "vscode-extension",
+ "multi-agent"
+ ],
+ "verified": false,
+ "downloads": 0,
+ "stars": 0,
+ "created_at": "2026-08-28T00:00:00Z",
+ "updated_at": "2026-08-28T00:00:00Z"
+ },
"analytics": {
"name": "Analytics",
"id": "analytics",
@@ -456,8 +507,8 @@
"id": "archive",
"description": "Archive merged features into main project memory, resolving gaps and conflicts.",
"author": "Stanislav Deviatov",
- "version": "1.2.2",
- "download_url": "https://github.com/stn1slv/spec-kit-archive/archive/refs/tags/v1.2.2.zip",
+ "version": "1.3.0",
+ "download_url": "https://github.com/stn1slv/spec-kit-archive/archive/refs/tags/v1.3.0.zip",
"repository": "https://github.com/stn1slv/spec-kit-archive",
"homepage": "https://github.com/stn1slv/spec-kit-archive",
"documentation": "https://github.com/stn1slv/spec-kit-archive/blob/main/README.md",
@@ -466,7 +517,7 @@
"category": "docs",
"effect": "read-write",
"requires": {
- "speckit_version": ">=0.1.0"
+ "speckit_version": ">=0.14.0"
},
"provides": {
"commands": 1,
@@ -482,7 +533,7 @@
"downloads": 0,
"stars": 0,
"created_at": "2026-03-14T00:00:00Z",
- "updated_at": "2026-08-11T00:00:00Z"
+ "updated_at": "2026-08-24T00:00:00Z"
},
"ascii-diagram": {
"name": "ASCII Diagram Renderer",
@@ -563,6 +614,44 @@
"created_at": "2026-08-13T00:00:00Z",
"updated_at": "2026-08-19T00:00:00Z"
},
+ "axi": {
+ "name": "Axi Extension",
+ "id": "axi",
+ "description": "A Spec Kit extension that renders a feature's markdown in a local browser review surface for human annotation, then returns the queued notes to the agent to apply.",
+ "author": "d0whc3r",
+ "version": "1.1.4",
+ "download_url": "https://github.com/d0whc3r/spec-kit-axi/releases/download/v1.1.4/axi-1.1.4.zip",
+ "repository": "https://github.com/d0whc3r/spec-kit-axi",
+ "homepage": "https://d0whc3r.github.io/spec-kit-axi/",
+ "documentation": "https://github.com/d0whc3r/spec-kit-axi/wiki",
+ "changelog": "https://github.com/d0whc3r/spec-kit-axi/blob/main/CHANGELOG.md",
+ "license": "MIT",
+ "category": "docs",
+ "effect": "read-write",
+ "requires": {
+ "speckit_version": ">=0.2.0",
+ "tools": [
+ {
+ "name": "node",
+ "required": true
+ }
+ ]
+ },
+ "provides": {
+ "commands": 1,
+ "hooks": 0
+ },
+ "tags": [
+ "axi",
+ "spec-kit",
+ "spec-kit-extension"
+ ],
+ "verified": false,
+ "downloads": 0,
+ "stars": 0,
+ "created_at": "2026-09-02T00:00:00Z",
+ "updated_at": "2026-09-02T00:00:00Z"
+ },
"azure-devops": {
"name": "Azure DevOps Integration",
"id": "azure-devops",
@@ -605,15 +694,15 @@
"updated_at": "2026-03-03T00:00:00Z"
},
"bdd": {
- "name": "Spec-Kit BDD",
+ "name": "BDD",
"id": "bdd",
- "description": "ATDD/BDD extension: convert specs to Gherkin scenarios, scaffold step definitions, and verify acceptance test coverage.",
+ "description": "Convert specs to Gherkin scenarios, scaffold step definitions, and verify acceptance test coverage.",
"author": "RSginer",
- "version": "1.0.2",
- "download_url": "https://github.com/RSginer/spec-kit-bdd/archive/refs/tags/v1.0.2.zip",
+ "version": "1.0.3",
+ "download_url": "https://github.com/RSginer/spec-kit-bdd/archive/refs/tags/v1.0.3.zip",
"repository": "https://github.com/RSginer/spec-kit-bdd",
- "homepage": "https://github.com/RSginer/spec-kit-bdd",
- "documentation": "https://github.com/RSginer/spec-kit-bdd/blob/main/docs/usage.md",
+ "homepage": "https://rsginer.github.io/spec-kit-bdd/",
+ "documentation": "https://rsginer.github.io/spec-kit-bdd/",
"changelog": "https://github.com/RSginer/spec-kit-bdd/releases",
"license": "MIT",
"category": "process",
@@ -662,7 +751,7 @@
"downloads": 0,
"stars": 0,
"created_at": "2026-07-15T00:00:00Z",
- "updated_at": "2026-07-15T00:00:00Z"
+ "updated_at": "2026-08-24T00:00:00Z"
},
"blueprint": {
"name": "Blueprint",
@@ -988,13 +1077,13 @@
"charter": {
"name": "Charter",
"id": "charter",
- "description": "Compose modular project constitutions from shared fragment registries. Centralize governance rules, select per-project fragments, track upstream changes, and keep multi-project setups consistent.",
+ "description": "Compose project constitutions from shared fragment registries",
"author": "Fyloss",
- "version": "0.5.1",
- "download_url": "https://github.com/Fyloss/spec-kit-charter/archive/refs/tags/v0.5.1.zip",
+ "version": "0.6.1",
+ "download_url": "https://github.com/Fyloss/spec-kit-charter/archive/refs/tags/v0.6.1.zip",
"repository": "https://github.com/Fyloss/spec-kit-charter",
"homepage": "https://github.com/Fyloss/spec-kit-charter",
- "documentation": "https://github.com/Fyloss/spec-kit-charter/tree/master/docs",
+ "documentation": "https://github.com/Fyloss/spec-kit-charter/blob/master/README.md",
"changelog": "https://github.com/Fyloss/spec-kit-charter/blob/master/CHANGELOG.md",
"license": "MIT",
"category": "process",
@@ -1015,15 +1104,15 @@
"tags": [
"constitution",
"governance",
- "modular",
- "fragments",
- "registry"
+ "multi-repo",
+ "composition",
+ "fragments"
],
"verified": false,
"downloads": 0,
"stars": 0,
"created_at": "2026-07-06T00:00:00Z",
- "updated_at": "2026-08-04T00:00:00Z"
+ "updated_at": "2026-09-02T00:00:00Z"
},
"ci-guard": {
"name": "CI Guard",
@@ -1327,6 +1416,40 @@
"created_at": "2026-07-13T00:00:00Z",
"updated_at": "2026-07-13T00:00:00Z"
},
+ "cosmosdb": {
+ "name": "Azure Cosmos DB",
+ "id": "cosmosdb",
+ "description": "Best-practice Azure Cosmos DB code generation and review for any AI coding agent",
+ "author": "Theo van Kraay (maintained on behalf of the Azure Cosmos DB team; hosted in the AzureCosmosDB org)",
+ "version": "0.1.0",
+ "download_url": "https://github.com/AzureCosmosDB/spec-kit-cosmosdb/archive/refs/tags/v0.1.0.zip",
+ "repository": "https://github.com/AzureCosmosDB/spec-kit-cosmosdb",
+ "homepage": "https://github.com/AzureCosmosDB/spec-kit-cosmosdb",
+ "documentation": "https://github.com/AzureCosmosDB/spec-kit-cosmosdb/blob/main/README.md",
+ "changelog": "https://github.com/AzureCosmosDB/spec-kit-cosmosdb/blob/main/CHANGELOG.md",
+ "license": "MIT",
+ "category": "code",
+ "effect": "read-write",
+ "requires": {
+ "speckit_version": ">=0.1.0"
+ },
+ "provides": {
+ "commands": 53,
+ "hooks": 2
+ },
+ "tags": [
+ "azure",
+ "cosmosdb",
+ "database",
+ "nosql",
+ "recommend-coding"
+ ],
+ "verified": false,
+ "downloads": 0,
+ "stars": 0,
+ "created_at": "2026-08-21T00:00:00Z",
+ "updated_at": "2026-08-21T00:00:00Z"
+ },
"cost": {
"name": "Cost Tracker",
"id": "cost",
@@ -1662,6 +1785,40 @@
"created_at": "2026-07-08T00:00:00Z",
"updated_at": "2026-07-08T00:00:00Z"
},
+ "evaluator": {
+ "name": "Evaluator Contract",
+ "id": "evaluator",
+ "description": "Provider-neutral evaluator contract for evidence, provenance, uncertainty, and recovery across Spec-Driven Development phases.",
+ "author": "ElectroHire",
+ "version": "1.0.0",
+ "download_url": "https://github.com/electrohire/spec-kit-evaluator/archive/refs/tags/v1.0.0.zip",
+ "repository": "https://github.com/electrohire/spec-kit-evaluator",
+ "homepage": "https://github.com/electrohire/spec-kit-evaluator",
+ "documentation": "https://github.com/electrohire/spec-kit-evaluator/blob/main/README.md",
+ "changelog": "https://github.com/electrohire/spec-kit-evaluator/blob/main/CHANGELOG.md",
+ "license": "MIT",
+ "category": "process",
+ "effect": "read-write",
+ "requires": {
+ "speckit_version": ">=1.0.0"
+ },
+ "provides": {
+ "commands": 4,
+ "hooks": 4
+ },
+ "tags": [
+ "evaluator",
+ "evidence",
+ "provenance",
+ "quality",
+ "governance"
+ ],
+ "verified": false,
+ "downloads": 0,
+ "stars": 0,
+ "created_at": "2026-09-03T00:00:00Z",
+ "updated_at": "2026-09-03T00:00:00Z"
+ },
"extensify": {
"name": "Extensify",
"id": "extensify",
@@ -1698,10 +1855,10 @@
"figma": {
"name": "Spec Kit Figma",
"id": "figma",
- "description": "Agent-agnostic SpecKit extension that grounds spec, plan & task generation in Figma design context — REST + optional MCP, single/mono/multi-repo, macOS/Linux/Windows.",
+ "description": "Grounds SpecKit spec/plan/tasks in Figma design context via REST or MCP, on macOS/Linux/Windows.",
"author": "Fyloss",
- "version": "1.6.0",
- "download_url": "https://github.com/Fyloss/spec-kit-figma/archive/refs/tags/v1.6.0.zip",
+ "version": "3.1.1",
+ "download_url": "https://github.com/Fyloss/spec-kit-figma/archive/refs/tags/v3.1.1.zip",
"repository": "https://github.com/Fyloss/spec-kit-figma",
"homepage": "https://github.com/Fyloss/spec-kit-figma",
"documentation": "https://github.com/Fyloss/spec-kit-figma/blob/main/docs/INSTALL.md",
@@ -1710,7 +1867,7 @@
"category": "integration",
"effect": "read-write",
"requires": {
- "speckit_version": ">=0.1.0",
+ "speckit_version": ">=0.11.2",
"tools": [
{
"name": "git",
@@ -1735,8 +1892,8 @@
]
},
"provides": {
- "commands": 5,
- "hooks": 6
+ "commands": 7,
+ "hooks": 11
},
"tags": [
"figma",
@@ -1749,7 +1906,7 @@
"downloads": 0,
"stars": 0,
"created_at": "2026-07-08T00:00:00Z",
- "updated_at": "2026-07-08T00:00:00Z"
+ "updated_at": "2026-08-31T00:00:00Z"
},
"figma-starter": {
"name": "Figma Starter",
@@ -2065,10 +2222,10 @@
"grill": {
"name": "SpecKit Grill Me",
"id": "grill",
- "description": "Exhaustively resolve specification ambiguities and decisions before planning.",
+ "description": "Exhaustively clarify specifications and optionally sync canonical domain knowledge.",
"author": "yoshi1220",
- "version": "1.0.0",
- "download_url": "https://github.com/yoshi1220/speckit-grill-me/releases/download/v1.0.0/speckit-grill-me-extension-v1.0.0.zip",
+ "version": "1.0.1",
+ "download_url": "https://github.com/yoshi1220/speckit-grill-me/releases/download/v1.0.1/speckit-grill-me-extension-v1.0.1.zip",
"repository": "https://github.com/yoshi1220/speckit-grill-me",
"homepage": "https://github.com/yoshi1220/speckit-grill-me/tree/main/spec-kit-extension",
"documentation": "https://github.com/yoshi1220/speckit-grill-me/blob/main/spec-kit-extension/README.md",
@@ -2086,7 +2243,7 @@
]
},
"provides": {
- "commands": 1,
+ "commands": 2,
"hooks": 0
},
"tags": [
@@ -2100,7 +2257,7 @@
"downloads": 0,
"stars": 0,
"created_at": "2026-08-11T00:00:00Z",
- "updated_at": "2026-08-11T00:00:00Z"
+ "updated_at": "2026-08-25T00:00:00Z"
},
"harness": {
"name": "Research Harness",
@@ -2352,6 +2509,46 @@
"created_at": "2026-03-05T00:00:00Z",
"updated_at": "2026-03-05T00:00:00Z"
},
+ "jira-mirror": {
+ "name": "Jira Mirror",
+ "id": "jira-mirror",
+ "description": "Spec Kit ↔ Jira bridge for team-managed and company-managed projects: configurable workflows & hierarchies (Scrum/SAFe), multi-project, idempotent and fail-closed. macOS/Linux/Windows.",
+ "author": "Fyloss",
+ "version": "0.24.0",
+ "download_url": "https://github.com/Fyloss/spec-kit-jira-mirror/releases/download/v0.24.0/spec-kit-jira-mirror-0.24.0.zip",
+ "sha256": "c3fe2f81fc3f47010cf92cdb8c49b612416b4c6aedf91beb579eba871fe1df16",
+ "repository": "https://github.com/Fyloss/spec-kit-jira-mirror",
+ "homepage": "https://github.com/Fyloss/spec-kit-jira-mirror",
+ "documentation": "https://github.com/Fyloss/spec-kit-jira-mirror/tree/main/docs",
+ "changelog": "https://github.com/Fyloss/spec-kit-jira-mirror/blob/main/CHANGELOG.md",
+ "license": "MIT",
+ "category": "integration",
+ "effect": "read-write",
+ "requires": {
+ "speckit_version": ">=0.13.0",
+ "tools": [
+ { "name": "bash", "version": ">=4", "required": false },
+ { "name": "pwsh", "version": ">=7", "required": false },
+ { "name": "curl", "required": false },
+ { "name": "jq", "required": false },
+ { "name": "git", "required": true }
+ ]
+ },
+ "provides": {
+ "commands": 4,
+ "hooks": 7
+ },
+ "tags": [
+ "jira",
+ "integration",
+ "sync"
+ ],
+ "verified": false,
+ "downloads": 0,
+ "stars": 0,
+ "created_at": "2026-08-31T00:00:00Z",
+ "updated_at": "2026-08-31T00:00:00Z"
+ },
"jira-sync": {
"name": "Jira Integration (Sync Engine)",
"id": "jira-sync",
@@ -2483,10 +2680,10 @@
"linear": {
"name": "Linear Integration",
"id": "linear",
- "description": "Mirror spec-kit feature directories into Linear (filesystem → Linear, reconcile-based, unidirectional).",
+ "description": "Automatically mirror your spec-kit specs into Linear — one issue per spec, a sub-issue per task phase, kept in sync as you work.",
"author": "Ash Brener",
- "version": "0.7.0",
- "download_url": "https://github.com/ashbrener/spec-kit-linear-sync/archive/refs/tags/v0.7.0.zip",
+ "version": "0.8.0",
+ "download_url": "https://github.com/ashbrener/spec-kit-linear-sync/archive/refs/tags/v0.8.0.zip",
"repository": "https://github.com/ashbrener/spec-kit-linear-sync",
"homepage": "https://github.com/ashbrener/spec-kit-linear-sync",
"documentation": "https://github.com/ashbrener/spec-kit-linear-sync/blob/main/README.md",
@@ -2513,7 +2710,7 @@
"downloads": 0,
"stars": 0,
"created_at": "2026-06-01T00:00:00Z",
- "updated_at": "2026-06-22T00:00:00Z"
+ "updated_at": "2026-09-03T00:00:00Z"
},
"linear-weave": {
"name": "Linear Weave",
@@ -2633,8 +2830,8 @@
"id": "maqa",
"description": "Coordinator → feature → QA agent workflow with parallel worktree-based implementation. Language-agnostic. Auto-detects installed board plugins (Trello, Linear, GitHub Projects, Jira, Azure DevOps). Optional CI gate.",
"author": "GenieRobot",
- "version": "0.1.3",
- "download_url": "https://github.com/GenieRobot/spec-kit-maqa-ext/releases/download/maqa-v0.1.3/maqa.zip",
+ "version": "0.1.6",
+ "download_url": "https://github.com/GenieRobot/spec-kit-maqa-ext/releases/download/maqa-v0.1.6/maqa.zip",
"repository": "https://github.com/GenieRobot/spec-kit-maqa-ext",
"homepage": "https://github.com/GenieRobot/spec-kit-maqa-ext",
"documentation": "https://github.com/GenieRobot/spec-kit-maqa-ext/blob/main/README.md",
@@ -2643,7 +2840,11 @@
"category": "process",
"effect": "read-write",
"requires": {
- "speckit_version": ">=0.3.0"
+ "speckit_version": ">=0.3.0",
+ "tools": [
+ { "name": "git", "required": true },
+ { "name": "python3", "required": true }
+ ]
},
"provides": {
"commands": 4,
@@ -2661,7 +2862,7 @@
"downloads": 0,
"stars": 0,
"created_at": "2026-03-26T00:00:00Z",
- "updated_at": "2026-03-27T00:00:00Z"
+ "updated_at": "2026-08-20T00:00:00Z"
},
"maqa-azure-devops": {
"name": "MAQA Azure DevOps Integration",
@@ -3552,6 +3753,40 @@
"created_at": "2026-03-18T00:00:00Z",
"updated_at": "2026-03-18T00:00:00Z"
},
+ "prespec": {
+ "name": "Pre-Spec Cards",
+ "id": "prespec",
+ "description": "Card-based pre-spec thinking: paste an idea, get your card plus the paths you'd miss, then play each through — story, snags, trade-offs, difficulty vs payoff — before /speckit.specify.",
+ "author": "bendlikeabamboo",
+ "version": "0.3.0",
+ "download_url": "https://github.com/bendlikeabamboo/pre-spec/archive/refs/tags/v0.3.0.zip",
+ "repository": "https://github.com/bendlikeabamboo/pre-spec",
+ "homepage": "https://github.com/bendlikeabamboo/pre-spec",
+ "documentation": "https://github.com/bendlikeabamboo/pre-spec#readme",
+ "changelog": "https://github.com/bendlikeabamboo/pre-spec/releases",
+ "license": "MIT",
+ "category": "process",
+ "effect": "read-write",
+ "requires": {
+ "speckit_version": ">=0.9.0"
+ },
+ "provides": {
+ "commands": 2,
+ "hooks": 0
+ },
+ "tags": [
+ "discovery",
+ "ideation",
+ "decision-cards",
+ "product",
+ "workflow"
+ ],
+ "verified": false,
+ "downloads": 0,
+ "stars": 0,
+ "created_at": "2026-08-28T00:00:00Z",
+ "updated_at": "2026-08-28T00:00:00Z"
+ },
"preview": {
"name": "Spec Kit Preview",
"id": "preview",
@@ -3788,8 +4023,8 @@
"id": "reconcile",
"description": "Reconcile implementation drift by surgically updating the feature's own spec, plan, and tasks.",
"author": "Stanislav Deviatov",
- "version": "1.1.0",
- "download_url": "https://github.com/stn1slv/spec-kit-reconcile/archive/refs/tags/v1.1.0.zip",
+ "version": "1.2.1",
+ "download_url": "https://github.com/stn1slv/spec-kit-reconcile/archive/refs/tags/v1.2.1.zip",
"repository": "https://github.com/stn1slv/spec-kit-reconcile",
"homepage": "https://github.com/stn1slv/spec-kit-reconcile",
"documentation": "https://github.com/stn1slv/spec-kit-reconcile/blob/main/README.md",
@@ -3798,11 +4033,11 @@
"category": "docs",
"effect": "read-write",
"requires": {
- "speckit_version": ">=0.1.0"
+ "speckit_version": ">=0.16.2"
},
"provides": {
"commands": 1,
- "hooks": 0
+ "hooks": 2
},
"tags": [
"reconcile",
@@ -3814,7 +4049,7 @@
"downloads": 0,
"stars": 0,
"created_at": "2026-03-14T00:00:00Z",
- "updated_at": "2026-08-10T00:00:00Z"
+ "updated_at": "2026-08-24T00:00:00Z"
},
"red-team": {
"name": "Red Team",
@@ -4252,35 +4487,39 @@
"name": "Security Review",
"id": "security-review",
"description": "Full-project secure-by-design security audits plus staged, branch/PR, plan, task, follow-up, and apply reviews",
- "author": "Spec-Kit Security Team",
- "version": "1.5.3",
- "download_url": "https://github.com/DyanGalih/spec-kit-security-review/archive/refs/tags/v1.5.3.zip",
- "repository": "https://github.com/DyanGalih/spec-kit-security-review",
- "homepage": "https://github.com/DyanGalih/spec-kit-security-review",
- "documentation": "https://github.com/DyanGalih/spec-kit-security-review/blob/main/README.md",
- "changelog": "https://github.com/DyanGalih/spec-kit-security-review/blob/main/CHANGELOG.md",
+ "author": "DyanGalih",
+ "version": "2.0.0",
+ "download_url": "https://github.com/DyanGalih/security-review/archive/refs/tags/v2.0.0.zip",
+ "repository": "https://github.com/DyanGalih/security-review",
+ "homepage": "https://github.com/DyanGalih/security-review",
+ "documentation": "https://github.com/DyanGalih/security-review/blob/main/docs/usage.md",
+ "changelog": "https://github.com/DyanGalih/security-review/blob/main/CHANGELOG.md",
"license": "MIT",
"category": "code",
"effect": "read-write",
"requires": {
- "speckit_version": ">=0.1.0"
+ "speckit_version": ">=0.1.0",
+ "tools": [
+ { "name": "git", "version": ">=2.0.0", "required": true },
+ { "name": "node", "version": ">=22.0.0", "required": false }
+ ]
},
"provides": {
- "commands": 9,
+ "commands": 10,
"hooks": 3
},
"tags": [
"security",
- "devsecops",
"audit",
"owasp",
- "compliance"
+ "compliance",
+ "governance"
],
"verified": false,
"downloads": 0,
"stars": 0,
"created_at": "2026-04-03T03:24:03Z",
- "updated_at": "2026-06-08T00:00:00Z"
+ "updated_at": "2026-08-20T00:00:00Z"
},
"sf": {
"name": "SFSpeckit — Salesforce Spec-Driven Development",
@@ -4464,10 +4703,10 @@
"specassay-check": {
"name": "SpecAssay Check",
"id": "specassay-check",
- "description": "Gate 2 refuses silent gaps and emits a trace-manifest (trace-manifest.json).",
+ "description": "Gate 2 refuses silent gaps and emits a trace-manifest (`trace-manifest.json`).",
"author": "Rik Dryfoos",
- "version": "0.3.3",
- "download_url": "https://github.com/rdryfoos/specassay/releases/download/v0.3.3/specassay-check-0.3.3.zip",
+ "version": "0.4.12",
+ "download_url": "https://github.com/rdryfoos/specassay/releases/download/v0.4.12/specassay-check-0.4.12.zip",
"repository": "https://github.com/rdryfoos/specassay",
"homepage": "https://www.specassay.com",
"documentation": "https://github.com/rdryfoos/specassay/blob/main/extensions/specassay-check/README.md",
@@ -4490,7 +4729,7 @@
]
},
"provides": {
- "commands": 1,
+ "commands": 2,
"hooks": 1
},
"tags": [
@@ -4504,7 +4743,7 @@
"downloads": 0,
"stars": 0,
"created_at": "2026-08-13T00:00:00Z",
- "updated_at": "2026-08-13T00:00:00Z"
+ "updated_at": "2026-08-21T00:00:00Z"
},
"specjudge": {
"name": "SpecJudge — right-size the model before you implement",
@@ -4953,6 +5192,46 @@
"created_at": "2026-03-02T00:00:00Z",
"updated_at": "2026-03-02T00:00:00Z"
},
+ "taco": {
+ "name": "Taco Review",
+ "id": "taco",
+ "description": "Packages Spec Kit features for human review and syncs edits and comments back.",
+ "author": "Arcadia822",
+ "version": "0.3.1",
+ "download_url": "https://github.com/Arcadia822/taco/archive/refs/tags/v0.3.1.zip",
+ "repository": "https://github.com/Arcadia822/taco",
+ "homepage": "https://github.com/Arcadia822/taco",
+ "documentation": "https://github.com/Arcadia822/taco/blob/main/extensions/taco/README.md",
+ "changelog": "https://github.com/Arcadia822/taco/blob/v0.3.1/CHANGELOG.md",
+ "license": "MIT",
+ "category": "integration",
+ "effect": "read-write",
+ "requires": {
+ "speckit_version": ">=0.16.0,<2.0.0",
+ "tools": [
+ {
+ "name": "node",
+ "version": ">=22",
+ "required": true
+ }
+ ]
+ },
+ "provides": {
+ "commands": 2,
+ "hooks": 8
+ },
+ "tags": [
+ "documentation",
+ "review",
+ "spec-kit",
+ "human-in-the-loop"
+ ],
+ "verified": false,
+ "downloads": 0,
+ "stars": 0,
+ "created_at": "2026-08-25T00:00:00Z",
+ "updated_at": "2026-08-25T00:00:00Z"
+ },
"tasks-to-project": {
"name": "Tasks to GitHub Project",
"id": "tasks-to-project",
@@ -5589,6 +5868,45 @@
"created_at": "2026-04-20T00:00:00Z",
"updated_at": "2026-04-22T21:10:00Z"
},
+ "vurnix": {
+ "name": "Vurnix Honest Gate",
+ "id": "vurnix",
+ "description": "Deterministic three-state honest gate for AI-written code: compile + phantom-import + honest test count, executed as code — not agent self-review. PASS/BLOCK/UNPROVEN by exit code.",
+ "author": "shiersa",
+ "version": "0.1.1",
+ "download_url": "https://github.com/shiersa/vurnix-spec-kit/releases/download/v0.1.1/vurnix-0.1.1.zip",
+ "repository": "https://github.com/shiersa/vurnix-spec-kit",
+ "homepage": "https://vurnix.dev",
+ "documentation": "https://github.com/shiersa/vurnix-spec-kit#readme",
+ "changelog": "",
+ "license": "MIT",
+ "category": "process",
+ "effect": "read-only",
+ "requires": {
+ "speckit_version": ">=0.16.2",
+ "tools": [
+ { "name": "python3", "version": ">=3.10", "required": true },
+ { "name": "vurnix", "version": ">=0.3.0", "required": true },
+ { "name": "bash", "required": true }
+ ]
+ },
+ "provides": {
+ "commands": 1,
+ "hooks": 1
+ },
+ "tags": [
+ "deterministic",
+ "honest-gate",
+ "test-integrity",
+ "quality",
+ "fail-closed"
+ ],
+ "verified": false,
+ "downloads": 0,
+ "stars": 0,
+ "created_at": "2026-09-01T00:00:00Z",
+ "updated_at": "2026-09-01T00:00:00Z"
+ },
"whatif": {
"name": "What-if Analysis",
"id": "whatif",
diff --git a/extensions/git/scripts/bash/create-new-feature-branch.sh b/extensions/git/scripts/bash/create-new-feature-branch.sh
index 856cb0bec4..5303bb97c9 100755
--- a/extensions/git/scripts/bash/create-new-feature-branch.sh
+++ b/extensions/git/scripts/bash/create-new-feature-branch.sh
@@ -206,9 +206,18 @@ check_existing_branches() {
}
# Function to clean and format a branch name
+#
+# Three details keep this byte-identical to the Python and PowerShell twins:
+# * LC_ALL=C -- in a UTF-8 locale glibc resolves the a-z *range* through
+# collation, so [^a-z0-9] keeps accented lowercase letters that
+# re.sub(r"[^a-z0-9]", ...) and .NET's -replace both strip.
+# * `--*` instead of the GNU-only `\+`, which POSIX/BSD sed reads as a literal
+# '+', leaving repeated separators uncollapsed on macOS.
+# * printf instead of echo, so a name of "-n"/"-e"/"-E" is text, not options.
clean_branch_name() {
local name="$1"
- echo "$name" | tr '[:upper:]' '[:lower:]' | sed 's/[^a-z0-9]/-/g' | sed 's/-\+/-/g' | sed 's/^-//' | sed 's/-$//'
+ local -x LC_ALL=C
+ printf '%s\n' "$name" | tr '[:upper:]' '[:lower:]' | sed 's/[^a-z0-9]/-/g' | sed 's/--*/-/g' | sed 's/^-//' | sed 's/-$//'
}
# ---------------------------------------------------------------------------
@@ -444,6 +453,10 @@ generate_branch_name() {
local stop_words="^(i|a|an|the|to|for|of|in|on|at|by|with|from|is|are|was|were|be|been|being|have|has|had|do|does|did|will|would|should|could|can|may|might|must|shall|this|that|these|those|my|your|our|their|want|need|add|get|set)$"
+ # LC_ALL=C for the same collation reason documented on clean_branch_name,
+ # and so the `grep -qw` acronym probe below uses ASCII word boundaries like
+ # the Python twin's (? str:
if len(word) >= 3:
meaningful_words.append(word)
# Keep short words only when they appear uppercased in the original
- # description (acronyms like "API" or "DB").
- elif re.search(rf"\b{re.escape(word.upper())}\b", description):
+ # description (acronyms like "API" or "DB"). The boundaries are spelled
+ # out as ASCII rather than using \b: \b is Unicode-aware on str, so
+ # "\u00e9DB\u00e9 cache" would hide the acronym behind a non-ASCII word
+ # character, while the bash twin's `grep -qw` runs under LC_ALL=C and
+ # sees a boundary there.
+ elif re.search(
+ rf"(? **From One Pipeline to Five Primitives.** If July was consolidation, August was punctuation. The month's substance was cumulative — ten releases of hardening, packaging, composition, and integration work — but its meaning was the number on the box. v1.0.0 landed a year to the day after the first commit, and the maintainer was unusually direct that it is *not* a feature release and *not* a stability promise: the README still files the project's ambitions under "Experimental Goals," deliberately unrenamed. What 1.0.0 marks is coherence. A tool that began February as a linear `specify → plan → tasks → implement` pipeline now stands on five composable primitives — integrations, extensions, presets, workflows, and workflow steps — with a priority-ordered catalog system beneath all of them and a closed core loop that runs `converge` to ask "is this actually done?" The rest of August pushed the same direction: the Copilot integration flipped to skills by default, a `feature-assess` workflow put the project to work triaging its own backlog, and the security campaign hardened every new surface the primitives opened. Meanwhile the companion tools that sprang up around Spec Kit in July started arriving *inside* the catalog. None of this happens without the community — the contributors, extension and preset authors, bundle builders, agent-integration maintainers, and practitioners writing in more than 20 languages. Thank you.
+
+## Spec Kit Project Updates
+
+### Releases Overview
+
+**v0.15.2–v0.16.5** (August 3–19) ran the month's patch cadence before the milestone. Feature work concentrated on **installation, onboarding, and composition**: `specify init` grew an **`--extension` flag** for opting into extensions at init time (#3914), extensions **scaffold their config templates** on add/enable (#2000), and a **managed `.specify/.gitignore`** is scaffolded at init (#4000). Two integration-selection changes landed together — the default init integration became **overridable via `SPECKIT_INTEGRATION_DEFAULT`** (#3952) and, the month's quiet headline, the **Copilot integration now defaults to skills** (#3976), completing the skills-default rollout flagged since July. The composition layer matured: extension manifests now accept **`provides.templates` and `provides.scripts`** (#4012), presets **resolve constitution templates at command time** (#3984), and `specify` **lists presets in resolution/precedence order** (#4104). Underneath, a deep robustness pass hardened non-UTF-8 and unreadable inputs across the registries, manifests, bundler, and workflow engine. [\[github.com\]](https://github.com/github/spec-kit/releases)
+
+**v1.0.0–v1.0.2** (August 21–31) crossed the milestone. **v1.0.0** (#4246) shipped on August 21 — one year after the project's first commit — with **v1.0.1** the same day and **v1.0.2** on August 31. The releases carried the fortnight's workflow, catalog, bundler, cross-platform, and integration fixes into a published 1.0.0 line, alongside a documentation set built for the moment: a **first-anniversary marker** (#4260), a **project history page** (#4262) that traces the arc from linear pipeline to five primitives, a **branding refresh** replacing the DocFX theme with the Spec Kit logo (#4264), **workflow quickstart guides** (#4258), and operational guidance for **existing-project adoption** (#4263). [\[github.com\]](https://github.com/github/spec-kit/releases)
+
+### The 1.0.0 Milestone: Coherence, Not Permanence
+
+August's headline was **v1.0.0** (#4246), released on August 21 — one year to the day after Spec Kit's first commit — and immediately followed by v1.0.1. The framing matters more than the number: 1.0.0 is explicitly **not a feature release and not a stability promise**. The argument, laid out in the project's new [history page](https://github.com/github/spec-kit/blob/main/docs/history.md), is that a major version used to be insurance against the cost of a breaking change — and that agents have collapsed that cost, since you can point an agent at a diff and it updates the call sites. The version instead marks **coherence between the five primitives**, not permanence; the README still files the project's ambitions under "Experimental Goals," deliberately unrenamed.
+
+What cohered is the shape of the tool. Spec Kit began February 2026 as a linear pipeline — `/speckit.specify`, `/speckit.plan`, `/speckit.tasks`, `/speckit.implement` — with the coding agent chosen by a flag. By 1.0.0 it stands on **five composable primitives**: **integrations** (agents became registry-backed plugins that write hash-tracked files), **extensions** (commands, templates, scripts, and hooks via an `extension.yml` manifest), **presets** (prepend/append/wrap/replace composition over existing core content), **workflows** (the specify-to-implement sequence is now a replaceable YAML definition, not hard-coded control flow), and **workflow steps** (a small interface any community step type can implement). Beneath them sits a single priority-ordered **catalog** system — environment variable → project config → user config → built-in — that governs discovery and install policy uniformly for all five, and the built-in `community` catalog is discovery-only by design: the maintainers verify an entry is well-formed, not that its code is safe. Above the primitives, the core loop closed in June with `/speckit.converge`, which assesses shipped code against the spec, plan, and tasks and appends the missing work — the answer to the tool's most-cited critique, "who verifies the spec?" The 1.0.0 core sequence is now constitution → specify → clarify → plan → checklist → tasks → analyze → implement → converge, with clarify/checklist/analyze as optional gates. [\[medium.com\]](https://stn1slv.medium.com/spec-kit-reaches-1-0-from-one-pipeline-to-five-primitives-f7bb6359e501)
+
+### The Copilot Skills Default Flip
+
+The month's quiet structural change was the completion of the **Copilot skills-default rollout**. `specify init --integration copilot` now installs **`speckit-*` skills by default** (#3976) rather than the legacy markdown-command layout, and the default init integration is **overridable via `SPECKIT_INTEGRATION_DEFAULT`** (#3952) so teams can pin a different default without touching flags. This is the flip that July's release notes warned was coming: agents that support skills install skills, the markdown-command layout becomes the legacy path, and both layouts continue to flow through the same manifest system. It lands the project's Copilot surface squarely on the native skills model that the wider integration layer has been converging toward all year. [\[github.com\]](https://github.com/github/spec-kit/releases)
+
+### Spec Kit Triages Itself: the `feature-assess` Workflow
+
+August's most on-brand feature was the **`feature-assess` agentic workflow** (#4186), which **installs and runs Spec Kit to assess an incoming feature request end to end**. Rather than a human reading each new request cold, the workflow provisions `uv` and Python (#4193), stands up the Spec Kit CLI and the `assess` extension (#4195), and runs the July `assess` "Idea Assessment Pipeline" against the request — intake → research → define → shape → decide — before a maintainer touches it. The initial landing was quickly followed by the provisioning and daily-credit-budget fixes needed to make it run reliably in CI (#4193, #4195, #4222). It is the clearest instance yet of the project **dogfooding its own primitives**: the tool that helps teams decide "should we build this?" is now wired into Spec Kit's own triage, running the `assess` pipeline on a labeled feature-request issue and posting each stage back. [\[github.com\]](https://github.com/github/spec-kit/releases)
+
+### The Composition Layer Matures
+
+Beyond the milestone, August's engineering pushed the **composition primitives** toward everyday practicality. Extension manifests now accept **`provides.templates` and `provides.scripts`** (#4012), letting an extension ship template and script overrides the same way it ships commands, with duplicate `provides` names rejected up front (#4016, #4191). Presets **resolve their constitution templates at command time** rather than eagerly (#3984), inherit `argument-hint` from the core template when wrapping so a wrap no longer silently drops it (#3996), and `specify` now **lists presets in resolution/precedence order** so overrides are visible at a glance (#4104). Namespaced preset commands are scaffolded self-contained (#4082), and the bundler now reads the authoritative `default_integration` field instead of only its legacy aliases (#3880). The through-line is the same as the 1.0.0 story: the core is increasingly a set of **named, overridable slots** that presets and extensions compose against, rather than a monolith to replace. [\[github.com\]](https://github.com/github/spec-kit/releases)
+
+### The Security-and-Robustness Campaign Continues
+
+July's hardening wave carried into August as standing discipline. **Bounded I/O** extended to the extension-catalog/download path (#3775), the integration-catalog fetch (#3812, #3818), and bundle downloads (#3764), and the **stdin read was capped at 1 MiB** to close a DoS path (#3857). **Race elimination** removed TOCTOU windows in file-unlink (#3819), zip packaging (#3855), and `RunState.load` (#3839). **URL and host hardening** taught the auth layer to treat exact host patterns literally (#4108) and, later in the month, to validate URL **ports** as well as hostnames across preset catalogs and credential matching, while **event-hook script paths are now confined to the project tree** (#4133) and the community submission workflow's output allowlists were tightened (#4103).
+
+Running alongside was a broad **non-UTF-8 and "fail-loudly" resilience sweep**: preset and extension registries, manifests, legacy commands, events, hook `config.toml`, resolver layers, and catalog responses all now **degrade gracefully instead of throwing** on malformed, unreadable, or wrong-encoding input (#3896, #3955, #3959, #3900, #3998, #3960, #3962, #3834, #3897, #3957, #3963, #3943, #3980, #3902, #4011, #3958). Workflow validation grew stricter in step — non-string step types, falsy non-mapping overlays and integration descriptors, empty condition blocks, and unvalidated dispatch defaults are now rejected with clear errors (#4111, #3884, #4187, #4182, #4181) — and a user-visible fix stopped `specify init` from **hanging on arrow-key pickers** in non-interactive agent harnesses (#4178). The hardening is prevention, arriving as the primitives open new surface. [\[github.com\]](https://github.com/github/spec-kit/releases)
+
+### Agent Integrations
+
+The agent portfolio kept growing. The built-in integrations catalog (`integrations/catalog.json`) grew from **37 to 38**, adding one new agent — a **Command Code** integration (#4019). Alongside it, integration *implementation* work landed for existing catalog entries: the **Junie** integration module was implemented with dot-to-hyphen command formatting (#4073), the **Mistral Vibe** (`vibe`) integration was brought to Claude parity (#4075), and the **Qoder CLI integration migrated to a skills-based layout** for Qoder IDE 1.24+ (#4205). Existing integrations were further refined: Claude and Alquimia argument-hint injection became **fold-aware** for long, folded descriptions (#4045, #4063), goose commands now dispatch via `goose run` (#3781, closing the 300-day #2416), and Kimi preserves non-UTF-8 user skills (#3895). The 1.0.0 documentation lists **38 integrations**, and the pattern from prior months holds — the surviving integrations keep getting more native to each agent, not merely more numerous. [\[github.com\]](https://github.com/github/spec-kit/releases)
+
+### The Extension, Preset & Bundle Ecosystem
+
+The community extension catalog grew from 144 to **162 entries** during August — eighteen net additions. Community presets grew from 29 to **34**, community workflows held at **2**, and community **bundles doubled from 1 to 2**.
+
+The month's most telling signal was **July's companion tooling entering the official catalog**. **SpecJudge** — the CLI that reads Spec Kit's constitution/spec/tasks artifacts to recommend a right-sized model, profiled in July's newsletter — was cataloged as an extension (#4079). And **SpecAssay** arrived as a **multi-artifact suite**: an extension (SpecAssay Check, #4113), a preset (#4123), and a **bundle** (#4125) — the second cataloged community bundle. The third-party layer that formed *around* Spec Kit in July is consolidating *into* the ecosystem.
+
+Notable catalog additions and updates by category:
+
+- **Verification, review & governance**: Architecture Governance, SpecAssay Check, Taco Review (human review packaging), adrkit (ADR authoring)
+- **Requirements & intake**: SpecKit Grill Me (a more thorough clarification skill), Pre-Spec Cards, Charter (updated)
+- **Knowledge, memory & inventory**: DUBSAR Memory, Spec Inventory, spec-kit-atlas, Keel Discovery
+- **External trackers & bridges**: Jira Mirror, AgentDocx, AgentPay x402 (spend controls), Azure Cosmos DB code-gen
+- **Model routing & sizing**: SpecJudge, Model Routing Governance (preset), Closed Vocabulary Check (preset)
+
+The catalog also showed heavy maintenance: **Archive** (to v1.3.0), **Reconcile** (v1.2.1), **Security Review** (v2.0.0), **Architecture Guard** (v2.3.6), **MAQA**, **Superspec**, and the **Spec Kit Figma** bridge all iterated, and a large **governance-preset** family — Security, Architecture, iSAQB, A11Y, Cross-Platform, Agent-Parity, and the Intake and Autonomous-Run suites — pushed coordinated version bumps. [\[github.com\]](https://github.github.io/spec-kit/community/extensions.html)
+
+### Documentation & Docs Site
+
+August's documentation was built around the milestone. The **first-anniversary marker** (#4260), a **project history page** (#4262) tracing the pipeline-to-primitives arc, and a **branding refresh** to the Spec Kit logo (#4264) framed the 1.0.0 release. Practical guidance expanded: **workflow quickstart guides** (#4258) and **Python init-script** documentation (#4331) for workflows and an **existing-project adoption** guide (#4263). The **extension catalog trust model** was clarified across docs, help, and messaging (#4177), reinforcing the discovery-only nature of the community catalog, and installing `specify-cli` from a **custom package index** was documented (#4032). [\[github.com\]](https://github.com/github/spec-kit/releases)
+
+## Community & Content
+
+### Press and Industry Coverage
+
+August's coverage centered on the 1.0.0 milestone, with the mix continuing July's shift toward comparison pieces, field guides, and pointed "is it too heavy?" critiques. No first-party Microsoft or GitHub (non-maintainer) post appeared in August; the nearest remained June's Microsoft Developer Blog piece.
+
+**Stanislav Deviatov** (Medium, August 24) published the month's marquee article, titled *"Spec Kit Reaches 1.0: From One Pipeline to Five Primitives"* — a deep, well-sourced retrospective (Solution Architect at EPAM) tracing how Spec Kit evolved from a linear pipeline into five primitives plus catalogs and the `/speckit.converge` loop. It endorses the direction while qualifying the "1.0.0 = stable" claim, the real migration cost beyond mechanical call-site edits, and the unenforced trust boundary. It is the clearest external articulation of what the milestone means. [\[medium.com\]](https://stn1slv.medium.com/spec-kit-reaches-1-0-from-one-pipeline-to-five-primitives-f7bb6359e501)
+
+**Roan Brasil Monteiro** (Medium, August 20) published a 21-minute *field guide to BMAD, Spec Kit, OpenSpec, and Kiro*, framed around "too much process burns money, too little burns more," positioning Spec Kit as the thorough, heavier option among four SDD frameworks. [\[medium.com\]](https://medium.com/@roanmonteiro/bmad-spec-kit-openspec-kiro-a-field-guide-to-getting-real-work-out-of-ai-coding-agents-b17833c24b3f)
+
+**百度百家号** (AI钉子铺, August 23) ran a half-year growth-rate review of five SDD frameworks that credits Spec Kit's GitHub/Microsoft brand pull and 14+ agent support but amplifies Martin Fowler's "8+ markdown files per spec" critique, arguing its growth is partly "brand premium." [\[baijiahao.baidu.com\]](https://baijiahao.baidu.com/s?id=1874316315966110702) **Andrew** (DEV Community, August 28), reviewing OpenSpec, engaged Spec Kit substantively as the main comparison — citing 131,957 stars, phase gates, the Python requirement, and the larger extension catalog. [\[dev.to\]](https://dev.to/andrew-ooo/openspec-review-spec-driven-dev-without-the-ceremony-8jb)
+
+### Developer Articles and Field Reports
+
+August's articles skewed toward honest, use-it-in-anger critique, with several first-hand field reports and a strong multilingual current in Japanese, Korean, Chinese, Spanish, and Thai.
+
+Notable articles:
+
+- **Lusivision** (DEV Community, August 28) — *"Spec-Driven Development: The New AI Coding Workflow,"* an SDD overview built around Spec Kit's four gated phases as the central vehicle, honest about where the overhead pays back. [\[dev.to\]](https://dev.to/lusivision/spec-driven-development-the-new-ai-coding-workflow-24lh)
+- **chae_eun_ini** (velog, August 26, Korean) — a candid field report where 13 features grew `specs/` to 12,931 lines (73% of the 17,590-line `src/`); concludes the real problem was that finished spec docs left "nothing to decide, only to approve," and documents **moving off Spec Kit to a self-built human-first harness**. [\[velog.io\]](https://velog.io/@chae_eun_ini/Spec-Kit%EC%97%90%EC%84%9C-%EC%9E%90%EC%B2%B4-%EC%A0%9C%EC%9E%91-%ED%95%98%EB%84%A4%EC%8A%A4%EB%A1%9C-%EA%B7%B8-%EC%82%AC%EC%9D%B4-%EA%B3%BC%EB%8F%84%EA%B8%B0%EC%9D%98-%EA%B8%B0%EB%A1%9D)
+- **ta_kawano** (note.com, August 10, Japanese) — a consolidated eight-part continuation of the "要求AI" series that rigorously measures Spec Kit's cost/tokens/time when adding a requirement (~$31, ~34 min; 301→331 tests), concluding SDD covers "spec→code" but leaves **requirements elicitation outside its scope**. [\[note.com\]](https://note.com/takawano/n/nd01c18c93580)
+- **New2026** (Medium, August 19) — *"AI Coding Frameworks Explained,"* a five-layer "stack" mental model positioning Spec Kit as one control layer among ~8 frameworks ("they are not competing; they constrain different parts"). [\[medium.com\]](https://new2026.medium.com/ai-coding-frameworks-explained-superpowers-gsd-gstack-ralph-spec-kit-and-the-agentic-3dd92e559636)
+- **Katsumata** (Zenn, August 24, Japanese) — a designer, inspired by Spec Kit, builds a spec-driven *design* system (YAML component specs → React/CSS/tests/Storybook via CI gates), candid about where spec→production quality breaks down. [\[zenn.dev\]](https://zenn.dev/katsumata/articles/014affaeb272d00aeae6)
+- **guillermodelpino.com** (Guillermo del Pino, August 15, Spanish) — a positive analytical review walking the `constitution → … → implement → converge` flow, singling out `converge`'s "is this really done?" step and arguing the discipline is valuable even for non-programmers. [\[guillermodelpino.com\]](https://guillermodelpino.com/repos/spec-kit-github-desarrollo-guiado-por-especificacion)
+
+Additional coverage appeared on DEV Community (TekMag, Jeffrey Bakker), Naver/velog/Tistory (Korean), Qiita/Zenn (Japanese), CSDN and 百家号 (Chinese), and Vibe Coding Thailand (Thai) — including several head-to-head OpenSpec-vs-Spec-Kit comparisons and recurring documentation-proliferation critiques. [\[dev.to\]](https://dev.to/tekmag/githubs-spec-kit-the-open-source-toolkit-bringing-spec-driven-development-to-ai-coding-agents-ede)
+
+### Community Growth by the Numbers
+
+| Metric | Start of August | End of August | Change |
+| --- | --- | --- | --- |
+| GitHub stars | 124,655 | ~132,000 | +~7,300 (+6%) |
+| Forks | 11,125 | ~11,900 | +~775 |
+| Contributors | ~258 | ~270 | +~12 |
+| Releases (total) | 205 | 215 | +10 (v0.15.2–v1.0.2) |
+| Community extensions | 144 | 162 | +18 |
+| Community presets | 29 | 34 | +5 |
+| Community workflows | 2 | 2 | steady |
+| Community bundles | 1 | 2 | +1 |
+| Agent integrations (catalog) | 37 | 38 | +1 (Command Code) |
+| Discussions (total) | ~474 | ~482 | +~8 |
+
+## SDD Ecosystem & Industry Trends
+
+### From Companion Tools to Catalog Entries
+
+July's clearest ecosystem signal was a *pattern* — independent developers building tools on top of Spec Kit's artifacts. August's signal was that pattern **consolidating**: those companion tools began arriving in the official catalog. **SpecJudge** (model right-sizing) was cataloged as an extension, and **SpecAssay** landed as a full multi-artifact suite — extension, preset, and the ecosystem's second bundle. The loudest theme across the now-162 cataloged extensions remains verification and quality (gate, review, validate, drift, evidence, sync), and 1.0.0's catalog design — discovery-only community listings, priority-ordered promotion into an organization's own vetted catalog — turns that demand into a governable pipeline rather than an unmanaged sprawl. The community proposes, the catalog measures what grows, and the core promotes the winners; `/speckit.converge` (drift → core loop) was the template, and August's companion-tool intake is the pattern repeating. [\[github.com\]](https://github.github.io/spec-kit/community/extensions.html)
+
+### Competitive Landscape
+
+The "which SDD tool?" genre stayed dominant, but 1.0.0 shifted its framing. Where earlier months ran tool-vs-tool feature bake-offs, August's most substantive pieces — Deviatov's retrospective, Monteiro's field guide, the 百家号 growth review — increasingly argue the frameworks are **converging on the same primitives**, which moves the question from "which tool wins" to "**which platform and governance model.**" On that axis, Spec Kit's five-primitive surface, its uniform catalog/trust model, its agent-neutrality, and the companion layer now consolidating into the catalog are the differentiators. The recurring counter-critique held steady and, if anything, sharpened around the milestone: documentation proliferation, cognitive load, and "who verifies the spec, and who reads all this?" remain the consistent trade-off — with the most pointed field report of the month (chae_eun_ini) documenting a team leaving Spec Kit for a lighter self-built harness. [\[medium.com\]](https://stn1slv.medium.com/spec-kit-reaches-1-0-from-one-pipeline-to-five-primitives-f7bb6359e501)
+
+## Roadmap
+
+Areas under discussion or in progress for future development:
+
+- **After 1.0.0, the trust boundary is the headline work** — the community catalog is discovery-only, and the maintainer calls making the guidance-vs-enforcement boundary *legible* the most valuable unfinished work. Expect continued investment in organization-owned catalogs, promotion pipelines, and provenance so "what is installable" can quietly become "what we approved." [\[medium.com\]](https://stn1slv.medium.com/spec-kit-reaches-1-0-from-one-pipeline-to-five-primitives-f7bb6359e501)
+- **Deeper composition primitives** — August's `provides.templates`/`provides.scripts`, command-time template resolution, and precedence-ordered presets point toward finer-grained, named composition points; early post-milestone signals include reusable **workflow slots** and letting a **preset declare a required extension**, tightening the algebra by which one layer reshapes another. [\[github.com\]](https://github.com/github/spec-kit/releases)
+- **Agentic self-service** — `feature-assess` joins an existing set of Copilot-engine agentic workflows the project runs on itself: the `bug-assess → bug-test → bug-fix` triage pipeline and the `add-community-extension`/`-preset`/`-bundle` catalog-submission automations. Each is label-triggered and installs/runs Spec Kit or its tooling to do real maintenance work; expect these pipelines to deepen as the project dogfoods its own primitives. [\[github.com\]](https://github.com/github/spec-kit/releases)
+- **The Copilot skills surface** — with the skills default now flipped and `SPECKIT_INTEGRATION_DEFAULT` overridable, the markdown-command layout becomes the legacy path. The first-party [`github/spec-kit-copilot`](https://github.com/github/spec-kit-copilot) plugin continues to explore a **visual, Copilot-driven surface** — a Spec Kit Wizard canvas with live boot progress — turning the CLI's flows into an interactive layer. [\[github.com\]](https://github.com/github/spec-kit-copilot)
+- **Security and robustness as routine** — the bounded-read / TOCTOU / URL-port / non-UTF-8 / fail-loudly campaign is now standing discipline rather than a wave. Sustaining the no-unbounded-read and graceful-degradation invariants as the surface (bundles, workflows, catalogs, events, integrations) keeps growing is the ongoing work. [\[github.com\]](https://github.com/github/spec-kit/releases)
+- **Experience simplification** — documentation proliferation and cognitive load remain the single most-cited concern across August's balanced reviews (chae_eun_ini, ta_kawano, the 百家号 review). The `assess` upstream gate, lean presets, `/speckit.converge`, and the consolidating companion-tooling layer all provide answers; surfacing them so new users feel 1.0.0 as *coherence* rather than *weight* is the persistent opportunity. [\[medium.com\]](https://stn1slv.medium.com/spec-kit-reaches-1-0-from-one-pipeline-to-five-primitives-f7bb6359e501)
diff --git a/presets/PUBLISHING.md b/presets/PUBLISHING.md
index f71c1f45d8..ece79bfa0c 100644
--- a/presets/PUBLISHING.md
+++ b/presets/PUBLISHING.md
@@ -68,6 +68,8 @@ preset:
requires:
speckit_version: ">=0.1.0" # Required spec-kit version
+ extensions: # Optional: extensions this preset needs
+ - "companion-extension"
provides:
templates:
@@ -93,6 +95,41 @@ tags: # 2-5 relevant tags
- ✅ Command names use dot notation (e.g. `speckit.specify`)
- ✅ Tags are lowercase and descriptive
+#### Declaring extension dependencies
+
+If your preset overrides commands that call into an extension, declare it in
+`requires.extensions`. Without the extension the preset still installs and the
+overrides fall through to the core workflow, so nothing errors — the feature
+just silently does less than the user expects. Declaring the dependency makes
+`specify preset add` say so, and spell out how to resolve it.
+
+Use a bare id, or a mapping when you need a version constraint or an optional
+dependency:
+
+```yaml
+requires:
+ speckit_version: ">=0.9.0"
+ extensions:
+ - "companion-extension" # required, any version
+ - id: "other-extension"
+ version: ">=1.2.0,<2" # optional PEP 440 specifier
+ required: false # optional, defaults to true
+```
+
+`version` accepts any PEP 440 specifier, not just a lower bound — upper bounds
+(`<2`), exact pins (`==1.2.0`), and exclusions (`!=1.3.0`) all work.
+
+Notes:
+
+- The field is optional. A preset that declares nothing behaves exactly as before.
+- A dependency that is missing, stale, disabled, or version-unsatisfied produces a **warning, not a failure** — the install still succeeds.
+- A disabled extension counts as unmet, and so does one whose registry entry survives after its files were removed. Resolution skips both, so the preset is just as inert as if the extension were absent.
+- The warning names an exact command for the missing, stale, and disabled cases. For a version mismatch it states the constraint to satisfy rather than naming a command, because `specify extension update` only moves forward to the catalog release and cannot satisfy an upper bound, a pin, or a downgrade.
+- A recorded version that cannot be parsed is treated as uncomparable rather than as a mismatch, so an extension whose registry version reads `unknown` is not reported as failing a constraint it was never evaluated against.
+- An extension present on disk but absent from the registry counts as satisfied. Resolution admits unregistered directories, so the preset works and warning about it would be a false alarm — though with no recorded version, a `version` constraint cannot be checked against it.
+- `required: false` documents an enhancing-but-optional extension and is never warned about.
+- Declare it in `preset.yml`, not only in your catalog entry. The catalog is not consulted for `--dev` and `--from ` installs, so the manifest is the only copy present on every install path.
+
### 3. Test Locally
```bash
diff --git a/presets/catalog.community.json b/presets/catalog.community.json
index 567dd354e1..3f608378a0 100644
--- a/presets/catalog.community.json
+++ b/presets/catalog.community.json
@@ -1,6 +1,6 @@
{
"schema_version": "1.0",
- "updated_at": "2026-08-20T00:00:00Z",
+ "updated_at": "2026-08-26T00:00:00Z",
"catalog_url": "https://raw.githubusercontent.com/github/spec-kit/main/presets/catalog.community.json",
"presets": {
"a11y-governance": {
@@ -224,6 +224,33 @@
"created_at": "2026-08-19T00:00:00Z",
"updated_at": "2026-08-19T00:00:00Z"
},
+ "codebase-memory-context": {
+ "name": "Verified Codebase Context",
+ "id": "codebase-memory-context",
+ "version": "1.0.1",
+ "description": "Generates evidence-qualified repository context with codebase-memory-mcp and applies it across planning, tasks, analysis, and implementation.",
+ "author": "Xu Yin (philo-x)",
+ "repository": "https://github.com/philo-x/spec-kit-preset-codebase-memory-context",
+ "download_url": "https://github.com/philo-x/spec-kit-preset-codebase-memory-context/archive/refs/tags/v1.0.1.zip",
+ "homepage": "https://github.com/philo-x/spec-kit-preset-codebase-memory-context",
+ "documentation": "https://github.com/philo-x/spec-kit-preset-codebase-memory-context/blob/v1.0.1/README.md",
+ "license": "MIT",
+ "requires": {
+ "speckit_version": ">=1.0.1"
+ },
+ "provides": {
+ "templates": 1,
+ "commands": 5
+ },
+ "tags": [
+ "code-intelligence",
+ "codebase-memory",
+ "architecture",
+ "workflow"
+ ],
+ "created_at": "2026-08-26T00:00:00Z",
+ "updated_at": "2026-08-26T00:00:00Z"
+ },
"command-density": {
"name": "Command Density",
"id": "command-density",
@@ -432,19 +459,19 @@
"intake-sequencing-governance": {
"name": "Intake Sequencing Governance",
"id": "intake-sequencing-governance",
- "version": "0.2.2",
+ "version": "0.2.3",
"description": "Manages language-aware intake-series order, typed dependencies, lifecycle, and authority-neutral next-candidate selection.",
"author": "Thorsten Hindermann",
"repository": "https://github.com/hindermath/spec-kit-preset-intake-sequencing-governance",
- "download_url": "https://github.com/hindermath/spec-kit-preset-intake-sequencing-governance/archive/refs/tags/v0.2.2.zip",
+ "download_url": "https://github.com/hindermath/spec-kit-preset-intake-sequencing-governance/archive/refs/tags/v0.2.3.zip",
"homepage": "https://github.com/hindermath/spec-kit-preset-intake-sequencing-governance",
- "documentation": "https://github.com/hindermath/spec-kit-preset-intake-sequencing-governance/blob/v0.2.2/README.md",
+ "documentation": "https://github.com/hindermath/spec-kit-preset-intake-sequencing-governance/blob/v0.2.3/README.md",
"license": "MIT",
"requires": {
"speckit_version": ">=0.8.3"
},
"provides": {
- "templates": 11,
+ "templates": 12,
"commands": 6,
"scripts": 8
},
@@ -453,10 +480,10 @@
"sequencing",
"governance",
"dag",
- "lifecycle"
+ "model-routing"
],
"created_at": "2026-07-27T00:00:00Z",
- "updated_at": "2026-07-28T00:00:00Z"
+ "updated_at": "2026-08-20T00:00:00Z"
},
"inventory-alignment": {
"name": "Inventory Alignment",
@@ -635,19 +662,19 @@
"parallel-autonomous-run-governance": {
"name": "Parallel Autonomous Run Governance",
"id": "parallel-autonomous-run-governance",
- "version": "0.2.4",
- "description": "Coordinates permission-bounded autonomous campaigns while preserving the project's learner and accessibility contract across workers and consolidation.",
+ "version": "0.2.6",
+ "description": "Coordinates isolated autonomous campaigns and optionally gates worker scheduling on a current campaign intake review.",
"author": "Thorsten Hindermann",
"repository": "https://github.com/hindermath/spec-kit-preset-parallel-autonomous-run-governance",
- "download_url": "https://github.com/hindermath/spec-kit-preset-parallel-autonomous-run-governance/archive/refs/tags/v0.2.4.zip",
+ "download_url": "https://github.com/hindermath/spec-kit-preset-parallel-autonomous-run-governance/archive/refs/tags/v0.2.6.zip",
"homepage": "https://github.com/hindermath/spec-kit-preset-parallel-autonomous-run-governance",
- "documentation": "https://github.com/hindermath/spec-kit-preset-parallel-autonomous-run-governance/blob/v0.2.4/README.md",
+ "documentation": "https://github.com/hindermath/spec-kit-preset-parallel-autonomous-run-governance/blob/v0.2.6/README.md",
"license": "MIT",
"requires": {
"speckit_version": ">=0.8.3"
},
"provides": {
- "templates": 9,
+ "templates": 10,
"commands": 5,
"scripts": 2
},
@@ -655,11 +682,11 @@
"parallel",
"autonomous",
"governance",
- "accessibility",
- "orchestration"
+ "orchestration",
+ "model-routing"
],
"created_at": "2026-07-22T00:00:00Z",
- "updated_at": "2026-07-28T00:00:00Z"
+ "updated_at": "2026-08-24T00:00:00Z"
},
"pirate": {
"name": "Pirate Speak (Full)",
@@ -809,11 +836,11 @@
"specassay": {
"name": "SpecAssay",
"id": "specassay",
- "version": "0.3.4",
+ "version": "0.4.12",
"description": "Appends durable-ID, Carries, and SpecAssay vocabulary onto Spec Kit spec, tasks, and constitution templates.",
"author": "Rik Dryfoos",
"repository": "https://github.com/rdryfoos/specassay",
- "download_url": "https://github.com/rdryfoos/specassay/releases/download/v0.3.4/specassay-preset-0.3.4.zip",
+ "download_url": "https://github.com/rdryfoos/specassay/releases/download/v0.4.12/specassay-preset-0.4.12.zip",
"homepage": "https://github.com/rdryfoos/specassay",
"documentation": "https://github.com/rdryfoos/specassay/blob/main/presets/specassay/README.md",
"license": "MIT",
@@ -831,7 +858,7 @@
"sdd"
],
"created_at": "2026-08-14T00:00:00Z",
- "updated_at": "2026-08-14T00:00:00Z"
+ "updated_at": "2026-08-21T00:00:00Z"
},
"test-first-governance": {
"name": "Test-First Governance",
diff --git a/pyproject.toml b/pyproject.toml
index 5563328245..e43ab9a1d8 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -1,6 +1,6 @@
[project]
name = "specify-cli"
-version = "0.16.6.dev0"
+version = "1.0.5.dev0"
description = "Specify CLI, part of GitHub Spec Kit. A tool to bootstrap your projects for Spec-Driven Development (SDD)."
readme = "README.md"
requires-python = ">=3.11"
diff --git a/scripts/bash/check-prerequisites.sh b/scripts/bash/check-prerequisites.sh
index c21edc41f0..7d6dba1353 100644
--- a/scripts/bash/check-prerequisites.sh
+++ b/scripts/bash/check-prerequisites.sh
@@ -9,6 +9,7 @@
#
# OPTIONS:
# --json Output in JSON format
+# --require-spec Require spec.md to exist (for analysis phase)
# --require-tasks Require tasks.md to exist (for implementation phase)
# --include-tasks Include tasks.md in AVAILABLE_DOCS list
# --paths-only Only output path variables (no validation)
@@ -24,6 +25,7 @@ set -e
# Parse command line arguments
JSON_MODE=false
+REQUIRE_SPEC=false
REQUIRE_TASKS=false
INCLUDE_TASKS=false
PATHS_ONLY=false
@@ -34,6 +36,9 @@ while [[ $# -gt 0 ]]; do
--json)
JSON_MODE=true
;;
+ --require-spec)
+ REQUIRE_SPEC=true
+ ;;
--require-tasks)
REQUIRE_TASKS=true
;;
@@ -59,6 +64,7 @@ Consolidated prerequisite checking for Spec-Driven Development workflow.
OPTIONS:
--json Output in JSON format
+ --require-spec Require spec.md to exist (for analysis phase)
--require-tasks Require tasks.md to exist (for implementation phase)
--include-tasks Include tasks.md in AVAILABLE_DOCS list
--paths-only Only output path variables (no prerequisite validation)
@@ -142,6 +148,13 @@ if [[ ! -f "$IMPL_PLAN" ]]; then
exit 1
fi
+# Check for spec.md if required
+if $REQUIRE_SPEC && [[ ! -f "$FEATURE_SPEC" ]]; then
+ echo "ERROR: spec.md not found in $FEATURE_DIR" >&2
+ echo "Run $(format_speckit_command specify "$REPO_ROOT") first to create the feature specification." >&2
+ exit 1
+fi
+
# Check for tasks.md if required
if $REQUIRE_TASKS && [[ ! -f "$TASKS" ]]; then
echo "ERROR: tasks.md not found in $FEATURE_DIR" >&2
diff --git a/scripts/bash/common.sh b/scripts/bash/common.sh
index 33f90b8dbb..9efcfad5e6 100644
--- a/scripts/bash/common.sh
+++ b/scripts/bash/common.sh
@@ -902,12 +902,20 @@ except Exception as exc:
*'{CORE_TEMPLATE}'*) ;;
*) echo "Error: wrap strategy missing {CORE_TEMPLATE} placeholder" >&2; return 2 ;;
esac
- while [[ "$layer_content" == *'{CORE_TEMPLATE}'* ]]; do
- local before="${layer_content%%\{CORE_TEMPLATE\}*}"
- local after="${layer_content#*\{CORE_TEMPLATE\}}"
- layer_content="${before}${content}${after}"
+ # Consume the wrapper left to right instead of rewriting it in
+ # place. Rewriting re-scanned the string just modified, so base
+ # content holding a literal {CORE_TEMPLATE} reintroduced the
+ # token every pass and the loop never terminated. Advancing over
+ # ``rest`` bounds the work by the tokens in the original wrapper
+ # and leaves inserted content untouched, matching the single-pass
+ # semantics of .Replace()/.replace() in the PowerShell and Python
+ # ports.
+ local wrapped="" rest="$layer_content"
+ while [[ "$rest" == *'{CORE_TEMPLATE}'* ]]; do
+ wrapped="${wrapped}${rest%%\{CORE_TEMPLATE\}*}${content}"
+ rest="${rest#*\{CORE_TEMPLATE\}}"
done
- content="$layer_content"
+ content="${wrapped}${rest}"
;;
*) echo "Error: unknown strategy '$strat'" >&2; return 2 ;;
esac
diff --git a/scripts/bash/create-new-feature.sh b/scripts/bash/create-new-feature.sh
index abdb2194b1..06681d9b6e 100644
--- a/scripts/bash/create-new-feature.sh
+++ b/scripts/bash/create-new-feature.sh
@@ -145,9 +145,18 @@ spec_prefix_exists() {
}
# Function to clean and format a branch name
+#
+# Three details keep this byte-identical to the Python and PowerShell twins:
+# * LC_ALL=C -- in a UTF-8 locale glibc resolves the a-z *range* through
+# collation, so [^a-z0-9] keeps accented lowercase letters that
+# re.sub(r"[^a-z0-9]", ...) and .NET's -replace both strip.
+# * `--*` instead of the GNU-only `\+`, which POSIX/BSD sed reads as a literal
+# '+', leaving repeated separators uncollapsed on macOS.
+# * printf instead of echo, so a name of "-n"/"-e"/"-E" is text, not options.
clean_branch_name() {
local name="$1"
- echo "$name" | tr '[:upper:]' '[:lower:]' | sed 's/[^a-z0-9]/-/g' | sed 's/-\+/-/g' | sed 's/^-//' | sed 's/-$//'
+ local -x LC_ALL=C
+ printf '%s\n' "$name" | tr '[:upper:]' '[:lower:]' | sed 's/[^a-z0-9]/-/g' | sed 's/--*/-/g' | sed 's/^-//' | sed 's/-$//'
}
# Fit a feature prefix and suffix within GitHub's branch-name limit.
@@ -200,7 +209,11 @@ generate_branch_name() {
# Common stop words to filter out
local stop_words="^(i|a|an|the|to|for|of|in|on|at|by|with|from|is|are|was|were|be|been|being|have|has|had|do|does|did|will|would|should|could|can|may|might|must|shall|this|that|these|those|my|your|our|their|want|need|add|get|set)$"
- # Convert to lowercase and split into words
+ # Convert to lowercase and split into words. LC_ALL=C for the same
+ # collation reason documented on clean_branch_name, and so the `grep -qw`
+ # acronym probe below uses ASCII word boundaries like the Python twin's
+ # (?&2
+ exit 1
;;
esac
done
@@ -70,16 +70,16 @@ if $JSON_MODE; then
jq -cn \
--arg feature_spec "$FEATURE_SPEC" \
--arg impl_plan "$IMPL_PLAN" \
- --arg specs_dir "$FEATURE_DIR" \
+ --arg feature_dir "$FEATURE_DIR" \
--arg branch "$CURRENT_BRANCH" \
- '{FEATURE_SPEC:$feature_spec,IMPL_PLAN:$impl_plan,SPECS_DIR:$specs_dir,BRANCH:$branch}'
+ '{FEATURE_SPEC:$feature_spec,IMPL_PLAN:$impl_plan,FEATURE_DIR:$feature_dir,BRANCH:$branch}'
else
- printf '{"FEATURE_SPEC":"%s","IMPL_PLAN":"%s","SPECS_DIR":"%s","BRANCH":"%s"}\n' \
+ printf '{"FEATURE_SPEC":"%s","IMPL_PLAN":"%s","FEATURE_DIR":"%s","BRANCH":"%s"}\n' \
"$(json_escape "$FEATURE_SPEC")" "$(json_escape "$IMPL_PLAN")" "$(json_escape "$FEATURE_DIR")" "$(json_escape "$CURRENT_BRANCH")"
fi
else
echo "FEATURE_SPEC: $FEATURE_SPEC"
echo "IMPL_PLAN: $IMPL_PLAN"
- echo "SPECS_DIR: $FEATURE_DIR"
+ echo "FEATURE_DIR: $FEATURE_DIR"
echo "BRANCH: $CURRENT_BRANCH"
fi
diff --git a/scripts/powershell/check-prerequisites.ps1 b/scripts/powershell/check-prerequisites.ps1
index 27c87d6c69..0e9434d063 100644
--- a/scripts/powershell/check-prerequisites.ps1
+++ b/scripts/powershell/check-prerequisites.ps1
@@ -9,6 +9,7 @@
#
# OPTIONS:
# -Json Output in JSON format
+# -RequireSpec Require spec.md to exist (for analysis phase)
# -RequireTasks Require tasks.md to exist (for implementation phase)
# -IncludeTasks Include tasks.md in AVAILABLE_DOCS list
# -PathsOnly Only output path variables (no validation)
@@ -18,6 +19,7 @@
[CmdletBinding()]
param(
[switch]$Json,
+ [switch]$RequireSpec,
[switch]$RequireTasks,
[switch]$IncludeTasks,
[switch]$PathsOnly,
@@ -36,6 +38,7 @@ Consolidated prerequisite checking for Spec-Driven Development workflow.
OPTIONS:
-Json Output in JSON format
+ -RequireSpec Require spec.md to exist (for analysis phase)
-RequireTasks Require tasks.md to exist (for implementation phase)
-IncludeTasks Include tasks.md in AVAILABLE_DOCS list
-PathsOnly Only output path variables (no prerequisite validation)
@@ -105,6 +108,14 @@ if (-not (Test-Path $paths.IMPL_PLAN -PathType Leaf)) {
exit 1
}
+# Check for spec.md if required
+if ($RequireSpec -and -not (Test-Path $paths.FEATURE_SPEC -PathType Leaf)) {
+ [Console]::Error.WriteLine("ERROR: spec.md not found in $($paths.FEATURE_DIR)")
+ $specifyCommand = Format-SpecKitCommand -CommandName 'specify' -RepoRoot $paths.REPO_ROOT
+ [Console]::Error.WriteLine("Run $specifyCommand first to create the feature specification.")
+ exit 1
+}
+
# Check for tasks.md if required
if ($RequireTasks -and -not (Test-Path $paths.TASKS -PathType Leaf)) {
[Console]::Error.WriteLine("ERROR: tasks.md not found in $($paths.FEATURE_DIR)")
diff --git a/scripts/powershell/common.ps1 b/scripts/powershell/common.ps1
index 585e884702..bb61f623bb 100644
--- a/scripts/powershell/common.ps1
+++ b/scripts/powershell/common.ps1
@@ -135,7 +135,7 @@ function Save-FeatureJson {
# Read current value and skip write when unchanged
if (Test-Path -LiteralPath $fjPath -PathType Leaf) {
try {
- $raw = Get-Content -LiteralPath $fjPath -Raw
+ $raw = [System.IO.File]::ReadAllText($fjPath, [System.Text.Encoding]::UTF8)
$cfg = $raw | ConvertFrom-Json
if ($cfg.feature_directory -eq $FeatureDirectory) {
return
@@ -187,7 +187,7 @@ function Get-FeaturePathsEnv {
Save-FeatureJson -RepoRoot $repoRoot -FeatureDirectory $env:SPECIFY_FEATURE_DIRECTORY
}
} elseif (Test-Path $featureJson) {
- $featureJsonRaw = Get-Content -LiteralPath $featureJson -Raw
+ $featureJsonRaw = [System.IO.File]::ReadAllText($featureJson, [System.Text.Encoding]::UTF8)
try {
$featureConfig = $featureJsonRaw | ConvertFrom-Json
} catch {
diff --git a/scripts/powershell/create-new-feature.ps1 b/scripts/powershell/create-new-feature.ps1
index e7a68c4076..9ce2c678a4 100644
--- a/scripts/powershell/create-new-feature.ps1
+++ b/scripts/powershell/create-new-feature.ps1
@@ -145,11 +145,13 @@ function Get-BranchName {
# Keep words that are length >= 3 OR appear as uppercase in original (likely acronyms)
if ($word.Length -ge 3) {
$meaningfulWords += $word
- } elseif ($Description -cmatch "\b$($word.ToUpper())\b") {
+ } elseif ($Description -cmatch "(? str:
OPTIONS:
--json Output in JSON format
+ --require-spec Require spec.md to exist (for analysis phase)
--require-tasks Require tasks.md to exist (for implementation phase)
--include-tasks Include tasks.md in AVAILABLE_DOCS list
--paths-only Only output path variables (no prerequisite validation)
@@ -59,6 +60,7 @@ def _json_line(payload: object) -> str:
@dataclass(frozen=True)
class Args:
json_mode: bool = False
+ require_spec: bool = False
require_tasks: bool = False
include_tasks: bool = False
paths_only: bool = False
@@ -67,6 +69,7 @@ class Args:
def _parse_args(argv: list[str]) -> Args:
json_mode = False
+ require_spec = False
require_tasks = False
include_tasks = False
paths_only = False
@@ -77,6 +80,8 @@ def _parse_args(argv: list[str]) -> Args:
arg = argv[index]
if arg == "--json":
json_mode = True
+ elif arg == "--require-spec":
+ require_spec = True
elif arg == "--require-tasks":
require_tasks = True
elif arg == "--include-tasks":
@@ -105,6 +110,7 @@ def _parse_args(argv: list[str]) -> Args:
return Args(
json_mode=json_mode,
+ require_spec=require_spec,
require_tasks=require_tasks,
include_tasks=include_tasks,
paths_only=paths_only,
@@ -230,6 +236,14 @@ def main(argv: list[str] | None = None) -> int:
)
return 1
+ if args.require_spec and not paths.feature_spec.is_file():
+ print(f"ERROR: spec.md not found in {paths.feature_dir}", file=sys.stderr)
+ print(
+ f"Run {format_speckit_command('specify', paths.repo_root)} first to create the feature specification.",
+ file=sys.stderr,
+ )
+ return 1
+
if args.require_tasks and not paths.tasks.is_file():
print(f"ERROR: tasks.md not found in {paths.feature_dir}", file=sys.stderr)
print(
diff --git a/scripts/python/setup_plan.py b/scripts/python/setup_plan.py
index d25fdd7829..721eed8f39 100644
--- a/scripts/python/setup_plan.py
+++ b/scripts/python/setup_plan.py
@@ -42,7 +42,9 @@ def main(argv: list[str] | None = None) -> int:
elif arg in {"--help", "-h"}:
sys.stdout.write(_help_text(sys.argv[0]))
return 0
- # Other arguments are accepted and silently ignored, matching setup-plan.sh.
+ else:
+ print(f"ERROR: Unknown option '{arg}'", file=sys.stderr)
+ return 1
try:
paths = get_feature_paths(script_file=Path(__file__))
@@ -80,7 +82,7 @@ def main(argv: list[str] | None = None) -> int:
{
"FEATURE_SPEC": str(paths.feature_spec),
"IMPL_PLAN": str(paths.impl_plan),
- "SPECS_DIR": str(paths.feature_dir),
+ "FEATURE_DIR": str(paths.feature_dir),
"BRANCH": paths.current_branch,
}
)
@@ -88,7 +90,7 @@ def main(argv: list[str] | None = None) -> int:
else:
print(f"FEATURE_SPEC: {paths.feature_spec}")
print(f"IMPL_PLAN: {paths.impl_plan}")
- print(f"SPECS_DIR: {paths.feature_dir}")
+ print(f"FEATURE_DIR: {paths.feature_dir}")
print(f"BRANCH: {paths.current_branch}")
return 0
diff --git a/src/specify_cli/_github_http.py b/src/specify_cli/_github_http.py
index 017f50b5d1..8734528481 100644
--- a/src/specify_cli/_github_http.py
+++ b/src/specify_cli/_github_http.py
@@ -39,6 +39,7 @@ def build_github_request(url: str) -> urllib.request.Request:
ValueError: If ``url`` is empty or whitespace-only.
ValueError: If ``url`` does not use the ``http`` or ``https`` scheme.
ValueError: If ``url`` does not include a hostname.
+ ValueError: If ``url`` includes a malformed explicit port.
"""
headers: Dict[str, str] = {}
url = url.strip()
@@ -49,6 +50,8 @@ def build_github_request(url: str) -> urllib.request.Request:
raise ValueError(f"url must start with http:// or https://, got: {url!r}")
if not parsed.hostname:
raise ValueError(f"url must include a hostname, got: {url!r}")
+ # Accessing ``port`` validates any explicit port before request construction.
+ parsed.port
github_token = (os.environ.get("GITHUB_TOKEN") or "").strip()
gh_token = (os.environ.get("GH_TOKEN") or "").strip()
token = github_token or gh_token or None
diff --git a/src/specify_cli/_invocation_style.py b/src/specify_cli/_invocation_style.py
index 5cc7098837..9fa115212f 100644
--- a/src/specify_cli/_invocation_style.py
+++ b/src/specify_cli/_invocation_style.py
@@ -12,7 +12,9 @@
DOLLAR_SKILLS_AGENTS: frozenset[str] = frozenset({"codex", "zcode", "command-code"})
# Agents that always render /speckit-, regardless of ai_skills.
-ALWAYS_SLASH_AGENTS: frozenset[str] = frozenset({"devin", "droid", "grok", "trae", "zed"})
+ALWAYS_SLASH_AGENTS: frozenset[str] = frozenset(
+ {"devin", "droid", "dsh", "grok", "muse", "qodercli", "trae", "zed"}
+)
# Agents that render /speckit- only when ai_skills is enabled.
CONDITIONAL_SLASH_AGENTS: frozenset[str] = frozenset(
diff --git a/src/specify_cli/_utils.py b/src/specify_cli/_utils.py
index 0562ea0142..300ca4ff58 100644
--- a/src/specify_cli/_utils.py
+++ b/src/specify_cli/_utils.py
@@ -16,6 +16,45 @@
CLAUDE_LOCAL_PATH = Path.home() / ".claude" / "local" / "claude"
CLAUDE_NPM_LOCAL_PATH = Path.home() / ".claude" / "local" / "node_modules" / ".bin" / "claude"
+DOCKER_AGENT_CHECK_TIMEOUT = 5
+
+
+def docker_agent_command(executable: str | None = None) -> list[str] | None:
+ """Return a runnable Docker Agent command, or ``None`` if unavailable.
+
+ Docker Agent is distributed either as the standalone ``docker-agent``
+ executable or as the ``docker agent`` Docker CLI plugin. The plugin form
+ is verified with a bounded, read-only version probe so a plain Docker CLI
+ is not mistaken for an installed Docker Agent.
+ """
+ resolved_from_path = executable is None
+ if executable is None:
+ if shutil.which("docker-agent"):
+ return ["docker-agent", "run"]
+ executable = shutil.which("docker")
+ if executable is None:
+ return None
+
+ executable_name = Path(executable).name.lower()
+ if executable_name in {"docker", "docker.exe"}:
+ command = [executable, "agent", "version"]
+ run_command = [executable, "agent", "run"]
+ else:
+ # An explicit non-Docker executable is an operator override. Preserve
+ # the existing override contract without probing a custom binary.
+ return [executable, "run"]
+ try:
+ result = subprocess.run(
+ command,
+ capture_output=True,
+ check=False,
+ timeout=DOCKER_AGENT_CHECK_TIMEOUT,
+ )
+ except (OSError, subprocess.TimeoutExpired):
+ return None
+ if result.returncode != 0:
+ return None
+ return ["docker", "agent", "run"] if resolved_from_path else run_command
def relative_extension_path_violation(value: Any) -> str | None:
@@ -137,6 +176,8 @@ def check_tool(tool: str, tracker=None) -> bool:
found = shutil.which("kiro-cli") is not None or shutil.which("kiro") is not None
elif tool == "rovodev":
found = shutil.which("acli") is not None
+ elif tool == "docker-agent":
+ found = docker_agent_command() is not None
else:
found = shutil.which(tool) is not None
diff --git a/src/specify_cli/authentication/config.py b/src/specify_cli/authentication/config.py
index 9f19fbc522..95b8ff99b4 100644
--- a/src/specify_cli/authentication/config.py
+++ b/src/specify_cli/authentication/config.py
@@ -112,7 +112,10 @@ def load_auth_config(
except OSError:
pass # stat failed — skip permission check
- raw = json.loads(config_path.read_text(encoding="utf-8"))
+ try:
+ raw = json.loads(config_path.read_text(encoding="utf-8"))
+ except json.JSONDecodeError as exc:
+ raise ValueError(f"{config_path} contains invalid JSON: {exc}") from exc
if not isinstance(raw, dict):
raise ValueError(f"auth.json must be a JSON object, got {type(raw).__name__}")
@@ -221,12 +224,14 @@ def find_entries_for_url(
) -> list[AuthConfigEntry]:
"""Return entries whose ``hosts`` match the hostname of *url*."""
# A malformed authority (e.g. an unterminated IPv6 bracket "https://[::1")
- # makes urlparse/hostname raise ValueError. Treat that the same as a
+ # makes urlparse, hostname, or port raise ValueError. Treat that the same as a
# host-less URL: no entry can match, so return no matches rather than
# leaking a raw ValueError out of the shared HTTP client (build_request /
# open_url call this before any URL validation).
try:
- hostname = (urlparse(url).hostname or "").lower()
+ parsed = urlparse(url)
+ hostname = (parsed.hostname or "").lower()
+ _ = parsed.port
except ValueError:
return []
if not hostname:
diff --git a/src/specify_cli/bundler/models/catalog.py b/src/specify_cli/bundler/models/catalog.py
index 53e83a52e7..2ef882d576 100644
--- a/src/specify_cli/bundler/models/catalog.py
+++ b/src/specify_cli/bundler/models/catalog.py
@@ -20,6 +20,7 @@
# reject an unsupported major version so a file written by a newer/incompatible
# Spec Kit fails fast instead of being parsed under the wrong assumptions.
CONFIG_SCHEMA_VERSION = "1.0"
+CATALOG_SCHEMA_VERSION = "1.0"
class InstallPolicy(str, Enum):
@@ -106,10 +107,11 @@ def to_dict(self) -> dict[str, Any]:
def _parse_tags(value: Any, entry_id: str) -> tuple[str, ...]:
- """Coerce a catalog entry's ``tags`` into a tuple of strings.
+ """Parse a catalog entry's ``tags`` into a tuple of strings.
Catalogs are untrusted input: a bare string would otherwise be iterated
- character-by-character, so reject anything that is not a list/tuple.
+ character-by-character, so reject anything that is not a list/tuple, and
+ reject any non-string member instead of silently coercing it.
"""
if value is None:
return ()
@@ -117,7 +119,11 @@ def _parse_tags(value: Any, entry_id: str) -> tuple[str, ...]:
raise BundlerError(
f"Catalog entry '{entry_id}': 'tags' must be a list of strings."
)
- return tuple(str(t) for t in value)
+ if any(not isinstance(item, str) for item in value):
+ raise BundlerError(
+ f"Catalog entry '{entry_id}': 'tags' must be a list of strings."
+ )
+ return tuple(value)
def _parse_verified(value: Any, entry_id: str) -> bool:
@@ -215,6 +221,16 @@ def load_catalog_payload(data: Any) -> dict[str, CatalogEntry]:
"""Parse a catalog JSON payload into ``{bundle_id: CatalogEntry}``."""
if not isinstance(data, dict):
raise BundlerError("Catalog payload must be a JSON object.")
+ schema_version = data.get("schema_version")
+ if schema_version is not None and (
+ str(schema_version).strip().split(".")[0]
+ != CATALOG_SCHEMA_VERSION.split(".")[0]
+ ):
+ raise BundlerError(
+ f"Unsupported catalog schema version "
+ f"'{str(schema_version).strip()}'; this Spec Kit understands "
+ f"version {CATALOG_SCHEMA_VERSION}."
+ )
bundles_raw = data.get("bundles")
if not isinstance(bundles_raw, dict):
raise BundlerError("Catalog payload is missing a 'bundles' object.")
diff --git a/src/specify_cli/bundler/models/manifest.py b/src/specify_cli/bundler/models/manifest.py
index 032863a2e8..39684b2327 100644
--- a/src/specify_cli/bundler/models/manifest.py
+++ b/src/specify_cli/bundler/models/manifest.py
@@ -237,17 +237,19 @@ def _text(raw: Any) -> str:
def _parse_str_list(raw: Any, field_name: str) -> tuple[str, ...]:
- """Coerce a manifest list-of-strings field into a tuple of strings.
+ """Parse a manifest list-of-strings field into a tuple of strings.
Rejects a bare string/bytes (which would otherwise be iterated
- character-by-character) and any non-list/tuple, matching the manifest
- contract (``string[]``).
+ character-by-character), any non-list/tuple, and any non-string member,
+ matching the manifest contract (``string[]``).
"""
if raw is None:
return ()
if isinstance(raw, (str, bytes)) or not isinstance(raw, (list, tuple)):
raise BundlerError(f"'{field_name}' must be a list of strings when present.")
- return tuple(str(item) for item in raw)
+ if any(not isinstance(item, str) for item in raw):
+ raise BundlerError(f"'{field_name}' must be a list of strings when present.")
+ return tuple(raw)
def _parse_refs(kind: str, raw: Any) -> list[ComponentRef]:
diff --git a/src/specify_cli/bundler/models/records.py b/src/specify_cli/bundler/models/records.py
index 2d0c8b73a0..748b23759a 100644
--- a/src/specify_cli/bundler/models/records.py
+++ b/src/specify_cli/bundler/models/records.py
@@ -13,7 +13,7 @@
from .. import BundlerError
from ..lib.yamlio import dump_json, ensure_within, load_json
-from .manifest import COMPONENT_KINDS, ComponentRef
+from .manifest import COMPONENT_KINDS, ComponentRef, _text
RECORDS_FILENAME = "bundle-records.json"
RECORDS_SCHEMA_VERSION = "1.0"
@@ -65,8 +65,14 @@ def from_dict(cls, data: Any) -> "InstalledBundleRecord":
raise BundlerError(
"Corrupt record: 'contributed_components' must be a list."
)
- bundle_id = str(data.get("bundle_id", "")).strip()
- version = str(data.get("version", "")).strip()
+ # ``.get(key, "")`` defaults only a *missing* key. A key that is
+ # present but null -- how a hand-edited or corrupt record spells an
+ # empty field -- yields ``None``, and ``str(None)`` is the non-empty
+ # literal ``"None"``, which sails past the required-field checks
+ # below. Reuse the manifest's ``_text`` so records and bundle.yml
+ # agree on what an explicit null means.
+ bundle_id = _text(data.get("bundle_id"))
+ version = _text(data.get("version"))
if not bundle_id:
raise BundlerError(
"Corrupt records file: an installed-bundle record is missing "
@@ -80,7 +86,7 @@ def from_dict(cls, data: Any) -> "InstalledBundleRecord":
return cls(
bundle_id=bundle_id,
version=version,
- installed_at=str(data.get("installed_at", "")).strip(),
+ installed_at=_text(data.get("installed_at")),
contributed_components=tuple(
_component_from_dict(c) for c in components_raw
),
@@ -201,8 +207,8 @@ def _component_to_dict(ref: ComponentRef) -> dict[str, Any]:
def _component_from_dict(data: Any) -> ComponentRef:
if not isinstance(data, dict):
raise BundlerError("Each contributed component must be a mapping.")
- kind = str(data.get("kind", "")).strip()
- cid = str(data.get("id", "")).strip()
+ kind = _text(data.get("kind"))
+ cid = _text(data.get("id"))
if kind not in COMPONENT_KINDS:
raise BundlerError(
f"Corrupt records file: component 'kind' must be one of "
diff --git a/src/specify_cli/bundler/services/primitives.py b/src/specify_cli/bundler/services/primitives.py
index 31b1126a34..01fa14769e 100644
--- a/src/specify_cli/bundler/services/primitives.py
+++ b/src/specify_cli/bundler/services/primitives.py
@@ -263,9 +263,10 @@ def _do_install(self, component: ComponentRef, *, force: bool) -> None:
component.version,
_bundled_manifest_version(bundled / "extension.yml", "extension"),
)
- self._manager.install_from_directory(
+ manifest = self._manager.install_from_directory(
bundled, speckit_version, priority=priority, force=force
)
+ self._manager.scaffold_config(manifest.id)
return
if not self._allow_network:
@@ -293,9 +294,10 @@ def _do_install(self, component: ComponentRef, *, force: bool) -> None:
)
zip_path = catalog.download_extension(component.id)
try:
- self._manager.install_from_zip(
+ manifest = self._manager.install_from_zip(
zip_path, speckit_version, priority=priority, force=force
)
+ self._manager.scaffold_config(manifest.id)
finally:
with contextlib.suppress(Exception):
if zip_path.exists():
@@ -337,7 +339,7 @@ def install(self, component: ComponentRef) -> None:
with _chdir(self._root):
_delegate_command(
"install", f"workflow '{component.id}'",
- lambda: workflow_add(component.id),
+ lambda: workflow_add(component.id, dev=False, from_url=None),
)
def refresh(self, component: ComponentRef) -> None:
diff --git a/src/specify_cli/commands/bundle/__init__.py b/src/specify_cli/commands/bundle/__init__.py
index 1edbeef2ca..165f674a36 100644
--- a/src/specify_cli/commands/bundle/__init__.py
+++ b/src/specify_cli/commands/bundle/__init__.py
@@ -934,7 +934,6 @@ def _download_remote_manifest(
expected_sha256: str | None = None,
):
"""Fetch a remote bundle artifact over HTTPS and extract its manifest."""
- import io
import tempfile
from pathlib import PurePosixPath
from urllib.parse import urlparse as _urlparse
@@ -1038,7 +1037,20 @@ def _validate_redirect(old_url: str, new_url: str) -> None:
)
return manifest
- data = _yaml.safe_load(io.BytesIO(raw))
+ # Decode as UTF-8 explicitly -- matching yamlio.load_yaml's contract --
+ # instead of feeding PyYAML the raw byte stream. PyYAML's Reader
+ # auto-detects a UTF-16 BOM and would silently *accept* a manifest
+ # that the local directory/bundle.yml sources reject, letting this
+ # remote-download path diverge from them (see the sibling .zip fix
+ # for _local_manifest_source, which had the identical bug).
+ try:
+ text = raw.decode("utf-8")
+ except UnicodeError as exc:
+ raise BundlerError(
+ f"Downloaded content for bundle '{entry_id}' from "
+ f"{_source_desc} could not be read: {exc}"
+ ) from exc
+ data = _yaml.safe_load(text)
return BundleManifest.from_dict(data)
except BundlerError:
raise
diff --git a/src/specify_cli/commands/event.py b/src/specify_cli/commands/event.py
index d1576c2c70..47bcf86e70 100644
--- a/src/specify_cli/commands/event.py
+++ b/src/specify_cli/commands/event.py
@@ -27,14 +27,25 @@ def event_run(
# Read payload from stdin if available (capped at 1 MiB to prevent DoS).
MAX_STDIN_BYTES = 1 * 1024 * 1024
if not sys.stdin.isatty():
- raw = sys.stdin.read(MAX_STDIN_BYTES)
- if not sys.stdin.eof:
- raise typer.Exit(
- code=1,
- message="stdin payload exceeds 1 MiB limit; "
+ # Read from the underlying binary buffer so the cap counts encoded
+ # bytes, not decoded characters — `sys.stdin.read()` on a text stream
+ # counts Unicode characters, which lets multibyte payloads (e.g. a
+ # few hundred thousand emoji) exceed 1 MiB on the wire while still
+ # passing the length check. Reading one byte past the cap tells us
+ # whether more data was waiting beyond it.
+ raw = sys.stdin.buffer.read(MAX_STDIN_BYTES + 1)
+ if len(raw) > MAX_STDIN_BYTES:
+ typer.echo(
+ "stdin payload exceeds 1 MiB limit; "
"truncate or pipe a smaller payload",
+ err=True,
)
- payload = raw
+ raise typer.Exit(code=1)
+ try:
+ payload = raw.decode("utf-8")
+ except UnicodeDecodeError:
+ typer.echo("stdin payload must be valid UTF-8", err=True)
+ raise typer.Exit(code=1) from None
else:
payload = "{}"
diff --git a/src/specify_cli/commands/init.py b/src/specify_cli/commands/init.py
index 4af9427bfa..dfce1b3d10 100644
--- a/src/specify_cli/commands/init.py
+++ b/src/specify_cli/commands/init.py
@@ -1012,7 +1012,9 @@ def init(
copilot_skill_mode = selected_ai == "copilot" and _is_skills_integration
devin_skill_mode = selected_ai == "devin"
zed_skill_mode = selected_ai == "zed" and _is_skills_integration
+ muse_skill_mode = selected_ai == "muse" and _is_skills_integration
grok_skill_mode = selected_ai == "grok" and _is_skills_integration
+ dsh_skill_mode = selected_ai == "dsh" and _is_skills_integration
cline_skill_mode = selected_ai == "cline"
forge_skill_mode = selected_ai == "forge"
bob_skill_mode = selected_ai == "bob" and _is_skills_integration
@@ -1027,7 +1029,9 @@ def init(
or copilot_skill_mode
or devin_skill_mode
or zed_skill_mode
+ or muse_skill_mode
or grok_skill_mode
+ or dsh_skill_mode
or bob_skill_mode
)
@@ -1061,11 +1065,21 @@ def init(
f"{step_num}. Start Zed in this project directory; spec-kit skills were installed to [cyan].agents/skills[/cyan]"
)
step_num += 1
+ if muse_skill_mode:
+ steps_lines.append(
+ f"{step_num}. Start Muse Code in this project directory; spec-kit skills were installed to [cyan].agents/skills[/cyan]"
+ )
+ step_num += 1
if grok_skill_mode:
steps_lines.append(
f"{step_num}. Start Grok Build in this project directory; spec-kit skills were installed to [cyan].grok/skills[/cyan]"
)
step_num += 1
+ if dsh_skill_mode:
+ steps_lines.append(
+ f"{step_num}. Start DSH ([cyan]dsh web[/cyan]) in this project directory; spec-kit skills were installed to [cyan].dsh/skills[/cyan]"
+ )
+ step_num += 1
if bob_skill_mode:
steps_lines.append(
f"{step_num}. Start Bob in this project directory; spec-kit skills were installed to [cyan].bob/skills[/cyan]"
diff --git a/src/specify_cli/events.py b/src/specify_cli/events.py
index 83da04d4fb..ba0a4f6363 100644
--- a/src/specify_cli/events.py
+++ b/src/specify_cli/events.py
@@ -677,7 +677,16 @@ def _resolve_event_command_argv(
# subprocess.run(shell=False); invoke via `pwsh -File` (PowerShell 7+),
# falling back to `powershell -File` (Windows PowerShell) when pwsh is
# absent (S6). The default Windows script type would otherwise fail.
- launcher = shutil.which("pwsh") or shutil.which("powershell") or "pwsh"
+ # When NEITHER is on PATH, degrade to "no argv" like every other
+ # failure branch in this resolver (and its documented stdlib mirror,
+ # the generated dispatcher's `_resolve_argv`) — a bare "pwsh" here
+ # would make subprocess.run() raise FileNotFoundError, surfacing as a
+ # confusing "[Errno 2] No such file or directory: 'pwsh'" instead of
+ # the clean "No script found for event command" the caller reports
+ # for a genuinely missing script.
+ launcher = shutil.which("pwsh") or shutil.which("powershell")
+ if not launcher:
+ return None
return [launcher, "-File", str(script_abs), *rest_args]
# sh: the script is chmod'd executable during install on POSIX. On Windows
@@ -1082,7 +1091,7 @@ def collect_extension_events(project_root: Path) -> ResolvedEvents:
continue
try:
data = yaml.safe_load(ext_yml.read_text(encoding="utf-8")) or {}
- except (UnicodeDecodeError, yaml.YAMLError):
+ except (OSError, UnicodeDecodeError, yaml.YAMLError):
continue
if not isinstance(data, dict):
continue
diff --git a/src/specify_cli/extensions/__init__.py b/src/specify_cli/extensions/__init__.py
index fb4a30519d..a440b6da9b 100644
--- a/src/specify_cli/extensions/__init__.py
+++ b/src/specify_cli/extensions/__init__.py
@@ -1596,7 +1596,7 @@ def _replacement(match: re.Match[str]) -> str:
)
return re.sub(
- r"__SPECKIT_COMMAND_([A-Z][A-Z0-9_]*)__", _replacement, body
+ r"__SPECKIT_COMMAND_([A-Z][A-Z0-9_-]*)__", _replacement, body
)
for cmd_info in manifest.commands:
@@ -3100,6 +3100,76 @@ def unregister_agent_artifacts(
if updates:
self.registry.update(ext_id, updates)
+ def _retire_legacy_flat_extension_commands(
+ self,
+ agent_name: str,
+ command_names: List[str],
+ ) -> List[Path]:
+ """Remove old flat commands whose replacement skills were written."""
+ from ..agents import CommandRegistrar
+ from ..integrations import get_integration
+
+ integration = get_integration(agent_name)
+ legacy_dir = getattr(integration, "legacy_flat_command_dir", None)
+ legacy_extension = getattr(
+ integration, "legacy_flat_command_extension", None
+ )
+ if (
+ not isinstance(legacy_dir, str)
+ or not legacy_dir
+ or not isinstance(legacy_extension, str)
+ or not legacy_extension
+ ):
+ return []
+
+ registrar = CommandRegistrar()
+ agent_config = registrar.AGENT_CONFIGS.get(agent_name)
+ if not agent_config or agent_config.get("extension") != "/SKILL.md":
+ return []
+
+ def safe_project_dir(relative: str) -> Optional[Path]:
+ rel = Path(relative)
+ if rel.is_absolute() or ".." in rel.parts:
+ return None
+ current = self.project_root
+ for part in rel.parts:
+ current /= part
+ if current.is_symlink():
+ return None
+ try:
+ current.resolve().relative_to(self.project_root.resolve())
+ except (OSError, ValueError):
+ return None
+ return current
+
+ legacy_root = safe_project_dir(legacy_dir)
+ skills_root = safe_project_dir(str(agent_config.get("dir", "")))
+ if legacy_root is None or skills_root is None or not legacy_root.is_dir():
+ return []
+
+ removed: List[Path] = []
+ for command_name in command_names:
+ if (
+ not isinstance(command_name, str)
+ or not command_name
+ or not registrar._is_safe_command_name(command_name)
+ ):
+ continue
+
+ skill_name = registrar._compute_output_name(
+ agent_name, command_name, agent_config
+ )
+ replacement = skills_root / skill_name / "SKILL.md"
+ if replacement.is_symlink() or not replacement.is_file():
+ continue
+
+ legacy_file = legacy_root / f"{command_name}{legacy_extension}"
+ if legacy_file.is_symlink() or legacy_file.is_file():
+ legacy_file.unlink()
+ removed.append(legacy_file)
+
+ return removed
+
def register_enabled_extensions_for_agent(self, agent_name: str, *, force: bool = False) -> None:
"""Register installed, enabled extensions for ``agent_name``.
@@ -3160,6 +3230,7 @@ def register_enabled_extensions_for_agent(self, agent_name: str, *, force: bool
# registration of the remaining enabled extensions for this agent.
try:
updates: Dict[str, Any] = {}
+ registered: List[str] = []
# Set when a command -> skills toggle for this same agent
# defers stale command-mode cleanup until the skills
# replacement below confirms success (#2948).
@@ -3380,6 +3451,12 @@ def register_enabled_extensions_for_agent(self, agent_name: str, *, force: bool
if new_registered != registered_commands:
updates["registered_commands"] = new_registered
+ if registered:
+ self._retire_legacy_flat_extension_commands(
+ agent_name,
+ registered,
+ )
+
if updates:
self.registry.update(ext_id, updates)
except Exception as ext_err:
diff --git a/src/specify_cli/extensions/_commands.py b/src/specify_cli/extensions/_commands.py
index 7f7933e934..11ab50385e 100644
--- a/src/specify_cli/extensions/_commands.py
+++ b/src/specify_cli/extensions/_commands.py
@@ -15,9 +15,12 @@
import stat
import tempfile
from pathlib import Path
-from typing import Optional
+from typing import Optional, TYPE_CHECKING
from uuid import uuid4
+if TYPE_CHECKING:
+ from packaging.version import Version
+
import typer
import yaml
from rich.markup import escape as _escape_markup
@@ -106,6 +109,58 @@ def _command_safe_id(raw_id: object, placeholder: str = "") -> str
return placeholder
+def _bundled_update_source(ext_id: str) -> tuple[Path, Version] | tuple[None, None]:
+ """Locate the local bundled copy of *ext_id* and its parsed version.
+
+ Bundled extensions have no download URL, so an update can only come
+ from the copy shipped with the running spec-kit release — which may
+ lag the version the catalog on main advertises. Returns
+ ``(path, Version)`` when a valid local copy exists, ``(None, None)``
+ otherwise.
+ """
+ from . import ExtensionManifest, ValidationError
+ from packaging import version as pkg_version
+
+ bundled_dir = _locate_bundled_extension(ext_id)
+ if bundled_dir is None:
+ return None, None
+ try:
+ manifest = ExtensionManifest(bundled_dir / "extension.yml")
+ return bundled_dir, pkg_version.Version(manifest.version)
+ except (ValidationError, pkg_version.InvalidVersion, OSError):
+ return None, None
+
+
+def _archive_extension_directory(source_dir: Path) -> Path:
+ """Package an extension directory as a ZIP archive for the update flow.
+
+ The update pipeline validates and installs archives (bounded
+ extraction, manifest preflight, ID/version checks, backup/rollback),
+ so a locally bundled extension is fed through that identical hardened
+ path rather than growing a second install code path. The caller
+ deletes the archive after the update, the same as a downloaded one.
+ """
+ import zipfile
+
+ fd, tmp_name = tempfile.mkstemp(prefix="speckit-bundled-update-", suffix=".zip")
+ try:
+ with os.fdopen(fd, "wb") as archive_file:
+ with zipfile.ZipFile(archive_file, "w", zipfile.ZIP_DEFLATED) as zf:
+ for path in sorted(source_dir.rglob("*")):
+ # Never follow symlinks: is_file() follows the target
+ # and ZipFile.write() reads its bytes, which would turn
+ # an out-of-tree target into a regular archive member
+ # before the hardened extractor ever sees it.
+ if path.is_symlink():
+ continue
+ if path.is_file():
+ zf.write(path, path.relative_to(source_dir).as_posix())
+ except BaseException:
+ Path(tmp_name).unlink(missing_ok=True)
+ raise
+ return Path(tmp_name)
+
+
def _refresh_events_and_warn(project_root: Path) -> None:
"""Refresh native event config and surface failures (R3).
@@ -1089,8 +1144,7 @@ def extension_add(
force=force,
)
finally:
- if archive_path.exists():
- archive_path.unlink()
+ archive_path.unlink(missing_ok=True)
console.print("\n[green]✓[/green] Extension installed successfully!")
console.print(f"\n[bold]{_escape_markup(str(manifest.name))}[/bold] (v{_escape_markup(str(manifest.version))})")
@@ -1622,6 +1676,7 @@ def extension_update(
console.print("🔄 Checking for updates...\n")
updates_available = []
+ blocked_updates = []
for ext_id in extensions_to_update:
safe_ext_id = _escape_markup(str(ext_id))
@@ -1658,20 +1713,55 @@ def extension_update(
continue
if catalog_version > installed_version:
+ download_url = ext_info.get("download_url")
+ bundled_dir = None
+ available_version = catalog_version
+ if ext_info.get("bundled") and not download_url:
+ # Bundled extensions cannot be downloaded; the update has
+ # to come from the copy shipped with the running spec-kit
+ # release, which may lag the catalog on main (#4345).
+ bundled_dir, bundled_version = _bundled_update_source(ext_id)
+ # Block whenever the local copy lags the catalog, not
+ # just when it lags the installation: installing an
+ # intermediate version would leave the project behind
+ # the catalog while reporting success, contrary to the
+ # documented "upgrade spec-kit first" behavior.
+ if bundled_dir is None or bundled_version < catalog_version:
+ local_desc = (
+ f"only ships v{bundled_version}"
+ if bundled_dir is not None
+ else "does not ship a local copy"
+ )
+ console.print(
+ f"⚠ {safe_ext_id}: v{catalog_version} is available, but this "
+ f"spec-kit release {local_desc} — upgrade spec-kit, then rerun "
+ f"'specify extension update'"
+ )
+ blocked_updates.append(ext_id)
+ continue
+ available_version = bundled_version
updates_available.append(
{
"id": ext_id,
"name": ext_info.get("name", ext_id), # Display name for status messages
"installed": str(installed_version),
- "available": str(catalog_version),
- "download_url": ext_info.get("download_url"),
+ "available": str(available_version),
+ "download_url": download_url,
+ "bundled_dir": bundled_dir,
}
)
else:
console.print(f"✓ {safe_ext_id}: Up to date (v{installed_version})")
if not updates_available:
- console.print("\n[green]All extensions are up to date![/green]")
+ if blocked_updates:
+ console.print(
+ "\n[yellow]Update(s) exist but require a newer spec-kit "
+ "release — upgrade spec-kit, then rerun "
+ "'specify extension update'.[/yellow]"
+ )
+ else:
+ console.print("\n[green]All extensions are up to date![/green]")
raise typer.Exit(0)
# Show available updates
@@ -1968,8 +2058,15 @@ def backup_extension_skills(skill_names, *, skills_dir=None):
if ext_hooks:
backup_hooks[hook_name] = ext_hooks
- # 5. Download new version
- archive_path = catalog.download_extension(extension_id)
+ # 5. Acquire the new version. Bundled extensions install from
+ # the copy shipped with the running spec-kit release (they
+ # have no download URL); everything else downloads. Both are
+ # packaged as archives so the identical validation,
+ # backup/rollback, and install pipeline below applies.
+ if update.get("bundled_dir") is not None:
+ archive_path = _archive_extension_directory(update["bundled_dir"])
+ else:
+ archive_path = catalog.download_extension(extension_id)
try:
# 6. Validate the archive and extension ID before modifying
# the existing installation. The shared extractor applies
@@ -2312,11 +2409,10 @@ def backup_extension_skills(skill_names, *, skills_dir=None):
# Archive cleanup is housekeeping: never replace an install
# error or roll back an already committed update because a
# scanner temporarily locks the download on Windows.
- if archive_path.exists():
- try:
- archive_path.unlink()
- except OSError as error:
- zip_cleanup_error = error
+ try:
+ archive_path.unlink(missing_ok=True)
+ except OSError as error:
+ zip_cleanup_error = error
# 10. Clean up backup on success. The update has committed at
# this point, so a locked backup file must not trigger rollback
diff --git a/src/specify_cli/integrations/__init__.py b/src/specify_cli/integrations/__init__.py
index 75c2f9d0de..ad38366938 100644
--- a/src/specify_cli/integrations/__init__.py
+++ b/src/specify_cli/integrations/__init__.py
@@ -60,7 +60,9 @@ def _register_builtins() -> None:
from .copilot import CopilotIntegration
from .cursor_agent import CursorAgentIntegration
from .devin import DevinIntegration
+ from .docker_agent import DockerAgentIntegration
from .droid import DroidIntegration
+ from .dsh import DshIntegration
from .firebender import FirebenderIntegration
from .forge import ForgeIntegration
from .gemini import GeminiIntegration
@@ -73,6 +75,7 @@ def _register_builtins() -> None:
from .kimi import KimiIntegration
from .kiro_cli import KiroCliIntegration
from .lingma import LingmaIntegration
+ from .muse import MuseIntegration
from .omp import OmpIntegration
from .opencode import OpencodeIntegration
from .pi import PiIntegration
@@ -100,7 +103,9 @@ def _register_builtins() -> None:
_register(CopilotIntegration())
_register(CursorAgentIntegration())
_register(DevinIntegration())
+ _register(DockerAgentIntegration())
_register(DroidIntegration())
+ _register(DshIntegration())
_register(FirebenderIntegration())
_register(ForgeIntegration())
_register(GeminiIntegration())
@@ -113,6 +118,7 @@ def _register_builtins() -> None:
_register(KimiIntegration())
_register(KiroCliIntegration())
_register(LingmaIntegration())
+ _register(MuseIntegration())
_register(OmpIntegration())
_register(OpencodeIntegration())
_register(PiIntegration())
diff --git a/src/specify_cli/integrations/base.py b/src/specify_cli/integrations/base.py
index 03c7a90e74..e58d231d36 100644
--- a/src/specify_cli/integrations/base.py
+++ b/src/specify_cli/integrations/base.py
@@ -142,6 +142,12 @@ class IntegrationBase(ABC):
integration that sets this flag.
"""
+ legacy_flat_command_dir: str | None = None
+ """Previous flat command directory retired after skill replacements exist."""
+
+ legacy_flat_command_extension: str | None = None
+ """File extension used by commands in ``legacy_flat_command_dir``."""
+
def post_process_command_content(self, content: str) -> str:
"""Transform command content after format rendering.
@@ -630,11 +636,15 @@ def resolve_command_refs(
* ``separator="."`` → ``/speckit.plan``, ``/speckit.git.commit``
* ``separator="-"`` → ``/speckit-plan``, ``/speckit-git-commit``
+ A hyphen belongs to the segment it sits in rather than separating
+ segments, so ``__SPECKIT_COMMAND_AGENT-CONTEXT_UPDATE__`` resolves to
+ ``/speckit.agent-context.update``.
+
*prefix* defaults to ``"/"`` but may be ``"$"`` for agents whose
native skills invocation uses dollar-prefixed chat commands.
"""
return re.sub(
- r"__SPECKIT_COMMAND_([A-Z][A-Z0-9_]*)__",
+ r"__SPECKIT_COMMAND_([A-Z][A-Z0-9_-]*)__",
lambda m: prefix
+ "speckit"
+ separator
diff --git a/src/specify_cli/integrations/catalog.py b/src/specify_cli/integrations/catalog.py
index e93dab5185..b8d76cb9c6 100644
--- a/src/specify_cli/integrations/catalog.py
+++ b/src/specify_cli/integrations/catalog.py
@@ -871,5 +871,8 @@ def tools(self) -> List[Dict[str, Any]]:
def get_hash(self) -> str:
"""SHA-256 hash of the descriptor file."""
+ h = hashlib.sha256()
with open(self.path, "rb") as fh:
- return f"sha256:{hashlib.sha256(fh.read()).hexdigest()}"
+ for chunk in iter(lambda: fh.read(8192), b""):
+ h.update(chunk)
+ return f"sha256:{h.hexdigest()}"
diff --git a/src/specify_cli/integrations/docker_agent/__init__.py b/src/specify_cli/integrations/docker_agent/__init__.py
new file mode 100644
index 0000000000..d962f1f994
--- /dev/null
+++ b/src/specify_cli/integrations/docker_agent/__init__.py
@@ -0,0 +1,126 @@
+"""Docker Agent integration — skills-based Docker CLI agent.
+
+Docker Agent discovers project skills from ``.agents/skills`` when the selected
+agent configuration enables local skills and filesystem reads. Runtime
+configuration is owned by Docker Agent and is not managed by Spec Kit.
+"""
+
+from __future__ import annotations
+
+import os
+import shlex
+
+from specify_cli._utils import docker_agent_command
+
+from ..base import IntegrationOption, SkillsIntegration
+
+
+class DockerAgentIntegration(SkillsIntegration):
+ """Integration for Docker Agent."""
+
+ key = "docker-agent"
+ config = {
+ "name": "Docker Agent",
+ "folder": ".agents/",
+ "commands_subdir": "skills",
+ "install_url": "https://docs.docker.com/ai/docker-agent/getting-started/installation/",
+ # Docker Agent is exposed as either `docker-agent` or `docker agent`.
+ "requires_cli": True,
+ }
+ registrar_config = {
+ "dir": ".agents/skills",
+ "format": "markdown",
+ "args": "$ARGUMENTS",
+ "extension": "/SKILL.md",
+ }
+ # Docker Agent shares the ``.agents/skills`` layout with Codex and Zed.
+ # Keep co-installation opt-in until shared manifest ownership is supported.
+ multi_install_safe = False
+
+ # Docker Agent hooks are configured in the selected agent YAML under
+ # ``agents..hooks``. Spec Kit does not edit that user-owned file, so
+ # hooks are intentionally not exposed through the integration event system.
+
+ def _agent_command(self) -> list[str]:
+ """Return the available Docker Agent command form."""
+
+ # The shared executable override supports both a standalone
+ # ``docker-agent`` binary and the Docker CLI plugin form.
+ executable = self._resolve_executable()
+ command = docker_agent_command(
+ None if executable == self.key else executable
+ )
+ if command is None:
+ # Preserve the normal executable-shaped argv for dispatch callers;
+ # preflight and the subprocess runner report the unavailable CLI.
+ return [executable, "run"]
+ return command
+
+
+ @classmethod
+ def options(cls) -> list[IntegrationOption]:
+ opts = super().options()
+ opts.append(
+ IntegrationOption(
+ "--skills",
+ is_flag=True,
+ default=True,
+ help="Install as agent skills (default for Docker Agent)",
+ )
+ )
+ return opts
+
+ def build_exec_args(
+ self,
+ prompt: str,
+ *,
+ model: str | None = None,
+ output_json: bool = True,
+ ) -> list[str] | None:
+ """Build a headless Docker Agent invocation with an agent config."""
+ extra_env_name = "SPECKIT_INTEGRATION_DOCKER_AGENT_EXTRA_ARGS"
+ extra_args = os.environ.get(extra_env_name, "").strip()
+ if not extra_args:
+ raise ValueError(
+ "Docker Agent requires an agent configuration reference. "
+ f"Set {extra_env_name}, for example: "
+ f"{extra_env_name}=./agent.yaml"
+ )
+ # Validate only the argument shape here: require a first positional
+ # agent reference and reject malformed quoting or a leading option.
+ # The reference may be a local file or a registry reference, so its
+ # existence and validity are intentionally left to Docker Agent.
+ try:
+ first_arg = shlex.split(extra_args)[0]
+ except (IndexError, ValueError) as exc:
+ raise ValueError(
+ f"{extra_env_name} must start with an agent configuration reference, "
+ "for example ./agent.yaml"
+ ) from exc
+ if first_arg.startswith("-"):
+ raise ValueError(
+ f"{extra_env_name} must start with an agent configuration reference, "
+ "for example ./agent.yaml"
+ )
+
+ args = [*self._agent_command(), "--exec"]
+
+ # Extra args carry the required agent source (for example
+ # ``./agent.yaml``) and any Docker Agent CLI flags. The shared helper
+ # also preserves shell-style quoting when splitting multiple args.
+ self._apply_extra_args_env_var(args)
+
+ if output_json:
+ args.append("--json")
+ if model:
+ args.extend(["--model", model])
+
+ # Stop Cobra flag parsing before the user prompt so values such as
+ # ``--help`` or ``--json`` are passed as messages, not CLI options.
+ # For example, the complete argv is
+ # ``docker-agent run --exec ./agent.yaml --agent root -- --help``;
+ # everything before ``--`` is parsed by Docker Agent, while ``--help``
+ # is passed to the configured agent as the user message.
+ args.extend(["--", prompt])
+
+ return args
diff --git a/src/specify_cli/integrations/dsh/__init__.py b/src/specify_cli/integrations/dsh/__init__.py
new file mode 100644
index 0000000000..9b533dec99
--- /dev/null
+++ b/src/specify_cli/integrations/dsh/__init__.py
@@ -0,0 +1,64 @@
+"""DeepSeek Harness (DSH) integration — skills-based agent.
+
+DSH discovers project skills from ``.dsh/skills`` (its native root, highest
+provider rank) and from the shared ``.agents/skills`` root, one level deep,
+using ``/SKILL.md`` directory bundles with ``name``/``description``
+frontmatter — the same agentskills.io layout Spec Kit scaffolds for other
+skills-based agents. Skills are user-invocable through the ``/``-trigger
+input in the DSH Web GUI (and any TUI/ACP front end): typing
+``/speckit-specify `` ships the literal token plus the
+user text, and the harness injects the skill's ```` into the
+turn. Project guidance in ``AGENTS.md`` at the repo root is loaded
+automatically by DSH, so no context-file handling is needed here.
+
+See: https://github.com/deepseek-ai/deepseek-harness
+"""
+
+from __future__ import annotations
+
+from ..base import SkillsIntegration
+
+
+class DshIntegration(SkillsIntegration):
+ """Integration for the DeepSeek Harness (DSH) agent."""
+
+ key = "dsh"
+ config = {
+ "name": "DeepSeek Harness",
+ "folder": ".dsh/",
+ "commands_subdir": "skills",
+ "install_url": "https://github.com/deepseek-ai/deepseek-harness",
+ "requires_cli": True,
+ }
+ registrar_config = {
+ "dir": ".dsh/skills",
+ "format": "markdown",
+ "args": "$ARGUMENTS",
+ "extension": "/SKILL.md",
+ }
+ # ``.dsh/`` is a static, unique agent root that no other integration
+ # writes into, so co-installing DSH alongside other agents is safe.
+ multi_install_safe = True
+
+ def build_exec_args(
+ self,
+ prompt: str,
+ *,
+ model: str | None = None,
+ output_json: bool = True,
+ ) -> list[str] | None:
+ """Build non-interactive CLI args for DSH.
+
+ DSH's one-shot mode is ``dsh --profile headless ""``: the
+ runner submits the task as an ordinary user message, waits for
+ quiescence, and prints the last assistant message to stdout. The
+ headless profile recognizes whitespace-bounded ``/name`` tokens
+ naming user-invocable skills, so a slash-command prompt such as
+ ``/speckit-specify build photo albums`` loads the skill exactly as
+ an interactive session would. The CLI has no structured-JSON output
+ flag, so ``output_json`` and ``model`` are ignored.
+ """
+ args = [self._resolve_executable(), "--profile", "headless"]
+ self._apply_extra_args_env_var(args)
+ args.append(prompt)
+ return args
diff --git a/src/specify_cli/integrations/muse/__init__.py b/src/specify_cli/integrations/muse/__init__.py
new file mode 100644
index 0000000000..e812ae386b
--- /dev/null
+++ b/src/specify_cli/integrations/muse/__init__.py
@@ -0,0 +1,63 @@
+"""Muse Code integration — skills-based agent (Meta).
+
+Muse Code discovers project skills from
+``.agents/skills/speckit-/SKILL.md`` and invokes them via their
+slash shortcut (``/speckit-``).
+
+See: https://dev.meta.ai/docs/muse-code
+"""
+
+from __future__ import annotations
+
+from ..base import IntegrationOption, SkillsIntegration
+
+
+class MuseIntegration(SkillsIntegration):
+ """Integration for Muse Code CLI."""
+
+ key = "muse"
+ config = {
+ "name": "Muse Code",
+ "folder": ".agents/",
+ "commands_subdir": "skills",
+ "install_url": "https://dev.meta.ai/docs/muse-code",
+ "requires_cli": True,
+ }
+ registrar_config = {
+ "dir": ".agents/skills",
+ "format": "markdown",
+ "args": "$ARGUMENTS",
+ "extension": "/SKILL.md",
+ }
+ # Muse Code shares the ``.agents/skills`` layout with Codex and Zed.
+ # Keep co-installation opt-in until shared manifest ownership is supported.
+ multi_install_safe = False
+
+ @classmethod
+ def options(cls) -> list[IntegrationOption]:
+ return [
+ IntegrationOption(
+ "--skills",
+ is_flag=True,
+ default=True,
+ help="Install as agent skills (default for Muse Code)",
+ ),
+ ]
+
+ def build_exec_args(
+ self,
+ prompt: str,
+ *,
+ model: str | None = None,
+ output_json: bool = True,
+ ) -> list[str] | None:
+ # Muse Code uses ``muse exec ""`` for non-interactive mode.
+ # Resolve argv[0] via the shared executable resolver so operators can
+ # override the binary with SPECKIT_INTEGRATION_MUSE_EXECUTABLE.
+ args: list[str] = [self._resolve_executable(), "exec", prompt]
+ self._apply_extra_args_env_var(args)
+ if model:
+ args.extend(["--model", model])
+ if output_json:
+ args.append("--json")
+ return args
diff --git a/src/specify_cli/integrations/qodercli/__init__.py b/src/specify_cli/integrations/qodercli/__init__.py
index 13535203cf..0fec683fae 100644
--- a/src/specify_cli/integrations/qodercli/__init__.py
+++ b/src/specify_cli/integrations/qodercli/__init__.py
@@ -1,21 +1,28 @@
-"""Qoder CLI integration."""
+"""Qoder CLI integration.
-from ..base import MarkdownIntegration
+Qoder IDE 1.24+ dropped ``.qoder/commands/`` scanning in favour of the
+skills layout: ``.qoder/skills/{skill-name}/SKILL.md`` with a ``name``
+field in frontmatter. Migrated to ``SkillsIntegration`` to match.
+"""
+from ..base import SkillsIntegration
-class QodercliIntegration(MarkdownIntegration):
+
+class QodercliIntegration(SkillsIntegration):
key = "qodercli"
config = {
"name": "Qoder CLI",
"folder": ".qoder/",
- "commands_subdir": "commands",
+ "commands_subdir": "skills",
"install_url": "https://qoder.com/cli",
"requires_cli": True,
}
registrar_config = {
- "dir": ".qoder/commands",
+ "dir": ".qoder/skills",
"format": "markdown",
"args": "$ARGUMENTS",
- "extension": ".md",
+ "extension": "/SKILL.md",
}
+ legacy_flat_command_dir = ".qoder/commands"
+ legacy_flat_command_extension = ".md"
multi_install_safe = True
diff --git a/src/specify_cli/integrations/rovodev/__init__.py b/src/specify_cli/integrations/rovodev/__init__.py
index 01aa870c66..fe0fcb30b6 100644
--- a/src/specify_cli/integrations/rovodev/__init__.py
+++ b/src/specify_cli/integrations/rovodev/__init__.py
@@ -179,6 +179,17 @@ def _merge_prompt_entries(
for entry in existing:
name = entry.get("name", "")
+ # ``prompts.yml`` is user-editable, and ``_read_prompts_yml`` only
+ # filters at the entry level -- it never validates the entry's
+ # ``name``. A YAML sequence or mapping there is unhashable, so this
+ # dict-membership test raised a raw ``TypeError`` out of ``setup()``
+ # and aborted every ``specify init`` / ``integration install`` for
+ # rovodev on that project, leaving prompts.yml unwritten. A
+ # non-string name can never match a generated entry, so treat it
+ # like any other unmatched entry and preserve it verbatim.
+ if not isinstance(name, str):
+ merged.append(entry)
+ continue
if name in generated_by_name:
merged.append(generated_by_name[name])
seen.add(name)
diff --git a/src/specify_cli/presets/__init__.py b/src/specify_cli/presets/__init__.py
index 3d37f6fb74..abc63299c2 100644
--- a/src/specify_cli/presets/__init__.py
+++ b/src/specify_cli/presets/__init__.py
@@ -63,6 +63,20 @@ def _content_sha256(content: bytes) -> str:
return hashlib.sha256(content).hexdigest()
+def _is_comparable_version(value: str) -> bool:
+ """Return whether a recorded version can be evaluated against a specifier.
+
+ ``version_satisfies()`` answers "does not satisfy" for an unparseable
+ version, which is indistinguishable from a genuine mismatch. Callers that
+ need to tell those apart check here first.
+ """
+ try:
+ pkg_version.Version(value)
+ except pkg_version.InvalidVersion:
+ return False
+ return True
+
+
def _constitution_is_generated(
project_root: Path,
memory_constitution: Path,
@@ -107,7 +121,7 @@ def _constitution_provenance_matches_preset(
return False
try:
metadata = json.loads(provenance.read_text(encoding="utf-8"))
- except (json.JSONDecodeError, UnicodeDecodeError):
+ except (OSError, json.JSONDecodeError, UnicodeDecodeError):
return False
return (
isinstance(metadata, dict)
@@ -381,6 +395,14 @@ def _validate(self):
f"got {type(requires['speckit_version']).__name__}"
)
+ # Validate the optional extension dependency list. A preset that
+ # overrides commands calling into an extension is inert without it, and
+ # until now the only place that could be said was the README -- see
+ # issue #4231. Absent means "no dependencies", so every existing preset
+ # stays valid.
+ if "extensions" in requires:
+ self._validate_requires_extensions(requires["extensions"])
+
# Validate provides section
provides = self.data["provides"]
if "templates" not in provides:
@@ -409,6 +431,7 @@ def _validate(self):
raise PresetValidationError(
"Preset must provide at least one template"
)
+ seen_name_types: set[tuple[str, str]] = set()
for tmpl in templates:
if not isinstance(tmpl, dict):
raise PresetValidationError(
@@ -438,6 +461,20 @@ def _validate(self):
f"must be one of {sorted(VALID_PRESET_TEMPLATE_TYPES)}"
)
+ # PresetResolver._manifest_declared_template returns the first
+ # 'provides.templates' entry matching a given (name, type) pair, so
+ # a later duplicate would be silently unreachable while still being
+ # counted by PresetManifest.templates. Reject at validation time
+ # instead, mirroring the sibling fix for ExtensionManifest's
+ # provides.templates/scripts (#4016).
+ name_type = (tmpl["name"], tmpl["type"])
+ if name_type in seen_name_types:
+ raise PresetValidationError(
+ f"Duplicate template name '{tmpl['name']}' of type "
+ f"'{tmpl['type']}' in 'provides.templates'"
+ )
+ seen_name_types.add(name_type)
+
# Validate file path safety: must be relative, no parent traversal
file_path = tmpl["file"]
normalized = os.path.normpath(file_path)
@@ -509,11 +546,121 @@ def author(self) -> str:
"""Get preset author."""
return self.data["preset"].get("author", "")
+ @staticmethod
+ def _validate_requires_extensions(declared: Any) -> None:
+ """Validate the optional ``requires.extensions`` list.
+
+ Accepts either a bare extension id or a mapping carrying an optional
+ version specifier and an optional ``required`` flag:
+
+ .. code-block:: yaml
+
+ requires:
+ extensions:
+ - speckit-inventory
+ - id: other-ext
+ version: ">=1.2.0"
+ required: false
+
+ Raises:
+ PresetValidationError: If the list or any entry is malformed.
+ """
+ if not isinstance(declared, list):
+ raise PresetValidationError(
+ "Invalid requires.extensions: expected a list, "
+ f"got {type(declared).__name__}"
+ )
+
+ for index, entry in enumerate(declared):
+ label = f"requires.extensions[{index}]"
+
+ if isinstance(entry, str):
+ entry = {"id": entry}
+ elif not isinstance(entry, dict):
+ raise PresetValidationError(
+ f"Invalid {label}: expected a string or a mapping, "
+ f"got {type(entry).__name__}"
+ )
+
+ if "id" not in entry:
+ raise PresetValidationError(f"Missing {label}.id")
+ extension_id = entry["id"]
+ if not isinstance(extension_id, str):
+ raise PresetValidationError(
+ f"Invalid {label}.id: expected a string, "
+ f"got {type(extension_id).__name__}"
+ )
+ # Same id shape the extension loader enforces, so a dependency can
+ # never name something that could not be installed in the first
+ # place. fullmatch rather than match with an anchored pattern: `$`
+ # also matches before a trailing newline, so "demo-ext\n" would
+ # otherwise validate here while PresetResolver._is_safe_registry_id
+ # (which uses fullmatch) rejects it, and the newline would land in
+ # a suggested command.
+ if not re.fullmatch(r'[a-z0-9-]+', extension_id):
+ raise PresetValidationError(
+ f"Invalid {label}.id {extension_id!r}: "
+ "must be lowercase alphanumeric with hyphens only"
+ )
+
+ if "version" in entry:
+ constraint = entry["version"]
+ # Mirrors the requires.speckit_version reasoning: a non-string
+ # escapes InvalidSpecifier two ways -- scalars raise TypeError
+ # from the constructor, and a list/dict is iterable so it
+ # constructs and only fails later inside .contains().
+ if not isinstance(constraint, str) or not constraint.strip():
+ raise PresetValidationError(
+ f"Invalid {label}.version: expected a non-empty string, "
+ f"got {type(constraint).__name__}"
+ )
+ try:
+ SpecifierSet(constraint)
+ except InvalidSpecifier:
+ raise PresetValidationError(
+ f"Invalid {label}.version '{constraint}': "
+ "not a valid version specifier"
+ )
+
+ if "required" in entry and not isinstance(entry["required"], bool):
+ raise PresetValidationError(
+ f"Invalid {label}.required: expected a boolean, "
+ f"got {type(entry['required']).__name__}"
+ )
+
@property
def requires_speckit_version(self) -> str:
"""Get required spec-kit version range."""
return self.data["requires"]["speckit_version"]
+ @property
+ def requires_extensions(self) -> List[Dict[str, Any]]:
+ """Get declared extension dependencies, normalized to mappings.
+
+ Returns:
+ One entry per dependency with ``id``, ``version`` (``None`` when
+ unconstrained), and ``required`` (defaulting to ``True``). Empty
+ when the manifest declares no dependencies.
+ """
+ declared = self.data["requires"].get("extensions")
+ if not isinstance(declared, list):
+ return []
+
+ normalized: List[Dict[str, Any]] = []
+ for entry in declared:
+ if isinstance(entry, str):
+ entry = {"id": entry}
+ if not isinstance(entry, dict) or not isinstance(entry.get("id"), str):
+ continue
+ normalized.append(
+ {
+ "id": entry["id"],
+ "version": entry.get("version"),
+ "required": entry.get("required", True),
+ }
+ )
+ return normalized
+
@property
def templates(self) -> List[Dict[str, Any]]:
"""Get list of provided templates."""
@@ -526,8 +673,11 @@ def tags(self) -> List[str]:
def get_hash(self) -> str:
"""Calculate SHA256 hash of manifest file."""
+ h = hashlib.sha256()
with open(self.path, 'rb') as f:
- return f"sha256:{hashlib.sha256(f.read()).hexdigest()}"
+ for chunk in iter(lambda: f.read(8192), b""):
+ h.update(chunk)
+ return f"sha256:{h.hexdigest()}"
class PresetRegistry:
@@ -823,6 +973,153 @@ def check_compatibility(
return True
+ def find_unmet_extension_dependencies(
+ self,
+ manifest: PresetManifest
+ ) -> List[Dict[str, Any]]:
+ """Find declared extension dependencies that are not satisfied.
+
+ Reports rather than raises. A preset whose overrides call into an
+ extension is written to degrade safely -- without the extension the
+ core workflow still runs -- so a missing dependency is a warning, not
+ an install failure. See issue #4231.
+
+ Args:
+ manifest: Preset manifest to inspect
+
+ Returns:
+ One entry per unsatisfied dependency, each with ``id``, the
+ requested ``version`` specifier (``None`` when unconstrained), the
+ ``installed`` version (``None`` when absent or unusable), and a
+ ``reason`` of ``"missing"``, ``"corrupt"``, ``"stale"``,
+ ``"disabled"``, or ``"version"``. Optional dependencies
+ (``required: false``) are never reported.
+
+ An unreadable registry yields no results rather than raising, since
+ this runs after the install has already succeeded.
+
+ A registry version that cannot be parsed is treated as
+ uncomparable, not as a mismatch: the extension is installed and
+ usable, and only its recorded version is unreadable. An extension
+ present on disk but absent from the registry is likewise treated as
+ satisfied, because resolution admits unregistered directories.
+ """
+ # Defense in depth, mirroring check_compatibility(): this method is
+ # public and also reachable with a hand-built manifest object that
+ # predates this field. A manifest without it declares nothing.
+ candidates = getattr(manifest, "requires_extensions", None)
+ if not isinstance(candidates, list):
+ return []
+
+ # Collapse exact repeats so a manifest naming the same dependency twice
+ # warns once. Two entries for one id with *different* constraints are
+ # kept, since both genuinely have to hold.
+ declared: List[Dict[str, Any]] = []
+ seen: Set[tuple] = set()
+ for dep in candidates:
+ if not isinstance(dep, dict) or not dep.get("required", True):
+ continue
+ key = (dep.get("id"), dep.get("version"))
+ if key in seen:
+ continue
+ seen.add(key)
+ declared.append(dep)
+ if not declared:
+ return []
+
+ extensions_dir = self.project_root / ".specify" / "extensions"
+ try:
+ registry = ExtensionRegistry(extensions_dir)
+ registered_ids = registry.keys()
+ registry_corrupt = registry.is_corrupt()
+ except OSError:
+ # Both reads can raise: _load() recovers from malformed content but
+ # deliberately lets OSError through, and is_corrupt() re-reads the
+ # file. This check runs *after* the install has completed, and
+ # preset_add only handles preset-domain errors, so letting that
+ # escape would turn a finished install into a traceback over a
+ # warning. An unreadable registry simply cannot be inspected.
+ return []
+
+ unmet: List[Dict[str, Any]] = []
+
+ for dep in declared:
+ metadata = registry.get(dep["id"])
+ if metadata is None:
+ # An absent registry entry does not mean the extension is
+ # unusable. _get_all_extensions_by_priority() admits a safe
+ # on-disk directory as an unregistered extension at implicit
+ # priority 10, so it resolves and the preset works -- but only
+ # when the registry is readable, since a corrupt one makes that
+ # path fail closed and contribute nothing.
+ #
+ # get() returns None for a corrupted (non-dict) entry as well as
+ # an absent one, but keys() retains corrupted ids -- both so
+ # resolution does not re-admit their directories as
+ # unregistered, and because is_installed() still counts them, so
+ # a plain `extension add` would be refused as already installed.
+ # That is a different state from absent, and needs a different
+ # remedy.
+ if dep["id"] in registered_ids:
+ unmet.append({**dep, "installed": None, "reason": "corrupt"})
+ continue
+ if (
+ (extensions_dir / dep["id"]).is_dir()
+ and PresetResolver._is_safe_registry_id(dep["id"])
+ and not registry_corrupt
+ ):
+ # Unregistered means no recorded version, so a constraint
+ # cannot be evaluated -- uncomparable, not unsatisfied.
+ continue
+ unmet.append({**dep, "installed": None, "reason": "missing"})
+ continue
+
+ installed_version = metadata.get("version")
+ installed_version = (
+ installed_version if isinstance(installed_version, str) else None
+ )
+
+ # A registry entry is not proof the extension can contribute. If
+ # its directory is gone, PresetResolver skips it outright (both
+ # template lookup and layer collection guard on ``is_dir()``), so
+ # the preset is as inert as if it were never installed -- but the
+ # surviving entry would otherwise read as satisfied.
+ if not (extensions_dir / dep["id"]).is_dir():
+ unmet.append(
+ {**dep, "installed": installed_version, "reason": "stale"}
+ )
+ continue
+
+ # A disabled extension is registered but contributes nothing:
+ # resolution skips it (see _collect_extension_layers), so the
+ # preset is just as inert as if it were absent. Report it before
+ # any version check -- enabling it is the prerequisite, and the
+ # version may well be fine once it is.
+ if not metadata.get("enabled", True):
+ unmet.append(
+ {**dep, "installed": installed_version, "reason": "disabled"}
+ )
+ continue
+
+ constraint = dep["version"]
+ if not constraint:
+ continue
+
+ # A version that cannot be compared is not a mismatch. Absent or
+ # non-string is one way to be unusable; an unparseable string such
+ # as "unknown" is another, and version_satisfies() cannot tell them
+ # apart -- it catches InvalidVersion and returns False, which would
+ # report a mismatch against a version nobody can evaluate. Check
+ # parseability up front so only real comparisons reach the warning.
+ if installed_version is None or not _is_comparable_version(installed_version):
+ continue
+ if not version_satisfies(installed_version, constraint):
+ unmet.append(
+ {**dep, "installed": installed_version, "reason": "version"}
+ )
+
+ return unmet
+
def _register_commands(
self,
manifest: PresetManifest,
@@ -1800,7 +2097,7 @@ def record_written(written: Dict[str, List[str]]) -> None:
)
record_written(written)
registered = True
- except Exception:
+ except (ImportError, FileNotFoundError, OSError):
# Extension registration failed; fall back to
# generic path-based registration below.
pass
@@ -4147,6 +4444,13 @@ def _validate_catalog_url(self, url: str) -> None:
try:
parsed = urlparse(url)
hostname = parsed.hostname
+ # Accessing ``port`` performs urllib's syntax/range validation;
+ # ``hostname`` alone does not, so a non-numeric or out-of-range
+ # port would otherwise pass validation here and only fail later,
+ # at fetch time, as a raw error this function does not translate
+ # into PresetValidationError. Mirrors specify_cli.catalogs and
+ # bundler/services/adapters.py's copy of this same guard.
+ _ = parsed.port
except ValueError:
raise PresetValidationError(f"Catalog URL is malformed: {url}") from None
is_localhost = hostname in ("localhost", "127.0.0.1", "::1")
@@ -4274,22 +4578,37 @@ def _load_catalog_config(self, config_path: Path) -> Optional[List[PresetCatalog
if not config_path.exists():
return None
try:
- data = yaml.safe_load(config_path.read_text(encoding="utf-8")) or {}
+ data = yaml.safe_load(config_path.read_text(encoding="utf-8"))
except (yaml.YAMLError, OSError, UnicodeError) as e:
raise PresetValidationError(
f"Failed to read catalog config {config_path}: {e}"
)
+ # Do NOT coerce with ``or {}`` here: that also turns a FALSY
+ # non-mapping top level (``[]``, ``false``, ``0``, ``''``) into ``{}``
+ # and silently swallows it, while a TRUTHY non-mapping (``5``, a bare
+ # list) correctly raises below. Only an empty document/explicit
+ # ``null`` means "no document".
+ if data is None:
+ return None
if not isinstance(data, dict):
raise PresetValidationError(
f"Invalid catalog config {config_path}: expected a mapping at root, got {type(data).__name__}"
)
- catalogs_data = data.get("catalogs", [])
- if not catalogs_data:
+ # Same asymmetry one nesting level down: the shape check has to run
+ # BEFORE the emptiness check, or a FALSY non-list ``catalogs`` value
+ # (``{}``, ``''``, ``0``, ``false``) is silently swallowed as "no
+ # catalogs" while a TRUTHY non-list (``catalogs: "not-a-list"``)
+ # correctly raises. An absent key or an explicit ``catalogs: null``
+ # both keep their existing "nothing configured here" behavior.
+ catalogs_data = data.get("catalogs")
+ if catalogs_data is None:
return None
if not isinstance(catalogs_data, list):
raise PresetValidationError(
f"Invalid catalog config: 'catalogs' must be a list, got {type(catalogs_data).__name__}"
)
+ if not catalogs_data:
+ return None
entries: List[PresetCatalogEntry] = []
for idx, item in enumerate(catalogs_data):
if not isinstance(item, dict):
diff --git a/src/specify_cli/presets/_commands.py b/src/specify_cli/presets/_commands.py
index 48d5c9f14f..ab74a8e029 100644
--- a/src/specify_cli/presets/_commands.py
+++ b/src/specify_cli/presets/_commands.py
@@ -40,6 +40,112 @@
preset_app.add_typer(preset_catalog_app, name="catalog")
+def _warn_unmet_extension_dependencies(manager, manifest) -> None:
+ """Warn when a preset's declared extension dependencies are unsatisfied.
+
+ A preset whose command overrides call into an extension is inert without
+ it, but the overrides still fall through to the core workflow, so nothing
+ breaks -- it just silently does less than the user expects. Naming the
+ missing extension and the command that installs it turns that silence into
+ something actionable. See issue #4231.
+ """
+ from ..extensions._commands import _command_safe_id
+
+ unmet = manager.find_unmet_extension_dependencies(manifest)
+ if not unmet:
+ return
+
+ console.print()
+ console.print("[yellow]![/yellow] This preset depends on extensions that are not satisfied:")
+ needs_catalog = False
+ for dep in unmet:
+ uses_catalog = False
+ extension_id = _escape_markup(dep["id"])
+ # The displayed id only needs Rich escaping, but a suggested command
+ # has to survive Typer's parser: `^[a-z0-9-]+$` admits a leading
+ # hyphen, so an id like `--force` would render as an option rather
+ # than the positional argument. _command_safe_id substitutes a
+ # placeholder in that case, the same way extension commands do.
+ command_id = _command_safe_id(dep["id"])
+ reason = dep["reason"]
+ # The remediation has to match the reason. `extension add` refuses an
+ # already-installed extension without --force, and `extension update`
+ # only moves forward to the catalog release. A general PEP 440
+ # constraint may require an exact version, an upper bound, or a
+ # downgrade, so do not promise that update will satisfy it.
+ if reason == "missing":
+ console.print(f" [yellow]{extension_id}[/yellow] is not installed")
+ label, remedy = "Install with", f"specify extension add {command_id}"
+ uses_catalog = True
+ elif reason == "corrupt":
+ console.print(
+ f" [yellow]{extension_id}[/yellow] has an unreadable "
+ "registry entry"
+ )
+ # is_installed() still counts the key, so a plain add is refused.
+ label = "Reinstall with"
+ remedy = f"specify extension add {command_id} --force"
+ uses_catalog = True
+ elif reason == "stale":
+ console.print(
+ f" [yellow]{extension_id}[/yellow] is registered but its "
+ "files are missing"
+ )
+ label = "Reinstall with"
+ remedy = f"specify extension add {command_id} --force"
+ uses_catalog = True
+ elif reason == "disabled":
+ console.print(f" [yellow]{extension_id}[/yellow] is installed but disabled")
+ label, remedy = "Enable with", f"specify extension enable {command_id}"
+ else:
+ console.print(
+ f" [yellow]{extension_id}[/yellow] "
+ f"{_escape_markup(dep['installed'])} does not satisfy "
+ f"{_escape_markup(dep['version'])}"
+ )
+ label = "Needs"
+ remedy = (
+ f"a release of {command_id} satisfying "
+ f"{_escape_markup(dep['version'])}"
+ )
+ console.print(f" {label}: {remedy}")
+ needs_catalog = needs_catalog or uses_catalog
+ console.print()
+ # The consequence differs by reason and must not be overstated. An
+ # unavailable extension contributes nothing, so those features are simply
+ # inert. A version mismatch is the opposite: the extension is installed and
+ # enabled, so the preset does invoke it -- the combination is just untested
+ # against the declared constraint, which is not the same as "safe".
+ console.print("[dim]The preset is installed.[/dim]")
+ if any(
+ dep["reason"] in ("missing", "corrupt", "stale", "disabled")
+ for dep in unmet
+ ):
+ console.print(
+ "[dim]Anything relying on an unavailable extension does nothing "
+ "until that is resolved.[/dim]"
+ )
+ if any(dep["reason"] == "version" for dep in unmet):
+ console.print(
+ "[dim]Where only a version constraint is unmet the extension is "
+ "still used, so it may not behave as the preset expects.[/dim]"
+ )
+ if needs_catalog:
+ # `extension add ` resolves through the catalogs, and the default
+ # community catalog is discovery-only, so installing by id is refused
+ # for anything listed only there -- true of every extension motivating
+ # this feature. Knowing which applies would mean a catalog fetch, and
+ # this runs on an install path that touches no network, so describe
+ # the outcome instead of asserting the command succeeds. The rejection
+ # itself prints the exact --from form, so this is a signpost rather
+ # than a dead end.
+ console.print(
+ "[dim]If an extension is listed only in a discovery-only catalog, "
+ "that command is refused and prints the "
+ "--from form to use instead.[/dim]"
+ )
+
+
# ===== Preset Commands =====
@@ -283,6 +389,12 @@ def _validate_download_redirect(old_url, new_url):
console.print("[red]Error:[/red] Specify a preset ID, --from URL, or --dev path")
raise typer.Exit(1)
+ # Every install path above binds `manifest` and the no-source branch
+ # exits, so one call here covers --dev, --from, and catalog installs
+ # alike. Warns rather than fails: the preset is installed and its
+ # overrides fall through to the core workflow without the extension.
+ _warn_unmet_extension_dependencies(manager, manifest)
+
except PresetCompatibilityError as e:
console.print(f"[red]Compatibility Error:[/red] {_escape_markup(str(e))}")
raise typer.Exit(1)
@@ -767,11 +879,16 @@ def preset_catalog_add(
# Load existing config
if config_path.exists():
try:
- config = yaml.safe_load(config_path.read_text(encoding="utf-8")) or {}
+ config = yaml.safe_load(config_path.read_text(encoding="utf-8"))
except Exception as e:
config_label = _display_project_path(project_root, config_path)
console.print(f"[red]Error:[/red] Failed to read {_escape_markup(str(config_label))}: {_escape_markup(str(e))}")
raise typer.Exit(1)
+ if config is None:
+ config = {}
+ elif not isinstance(config, dict):
+ console.print("[red]Error:[/red] Invalid catalog config: expected a mapping.")
+ raise typer.Exit(1)
else:
config = {}
@@ -827,10 +944,15 @@ def preset_catalog_remove(
raise typer.Exit(1)
try:
- config = yaml.safe_load(config_path.read_text(encoding="utf-8")) or {}
+ config = yaml.safe_load(config_path.read_text(encoding="utf-8"))
except Exception as e:
console.print(f"[red]Error:[/red] Failed to read preset catalog config: {e}")
raise typer.Exit(1)
+ if config is None:
+ config = {}
+ elif not isinstance(config, dict):
+ console.print("[red]Error:[/red] Invalid catalog config: expected a mapping.")
+ raise typer.Exit(1)
catalogs = config.get("catalogs", [])
if not isinstance(catalogs, list):
diff --git a/src/specify_cli/workflows/__init__.py b/src/specify_cli/workflows/__init__.py
index 0d1e101a9e..1e608ca168 100644
--- a/src/specify_cli/workflows/__init__.py
+++ b/src/specify_cli/workflows/__init__.py
@@ -53,6 +53,7 @@ def _register_builtin_steps() -> None:
from .steps.init import InitStep
from .steps.prompt import PromptStep
from .steps.shell import ShellStep
+ from .steps.slot import SlotStep
from .steps.switch import SwitchStep
from .steps.while_loop import WhileStep
@@ -65,6 +66,7 @@ def _register_builtin_steps() -> None:
_register_step(InitStep())
_register_step(PromptStep())
_register_step(ShellStep())
+ _register_step(SlotStep())
_register_step(SwitchStep())
_register_step(WhileStep())
diff --git a/src/specify_cli/workflows/_commands.py b/src/specify_cli/workflows/_commands.py
index 5e40569af0..716b6c8a19 100644
--- a/src/specify_cli/workflows/_commands.py
+++ b/src/specify_cli/workflows/_commands.py
@@ -1383,7 +1383,7 @@ def workflow_run(
err.print(f"[red]Error:[/red] Workflow not found: {source}")
raise typer.Exit(1)
except ValueError as exc:
- err.print(f"[red]Error:[/red] Invalid workflow: {exc}")
+ err.print(f"[red]Error:[/red] Invalid workflow: {_escape_markup(str(exc))}")
raise typer.Exit(1)
# Validate
@@ -1424,10 +1424,10 @@ def workflow_run(
),
)
except ValueError as exc:
- err.print(f"[red]Error:[/red] {exc}")
+ err.print(f"[red]Error:[/red] {_escape_markup(str(exc))}")
raise typer.Exit(1)
except Exception as exc:
- err.print(f"[red]Workflow failed:[/red] {exc}")
+ err.print(f"[red]Workflow failed:[/red] {_escape_markup(str(exc))}")
raise typer.Exit(1)
if json_output:
diff --git a/src/specify_cli/workflows/catalog.py b/src/specify_cli/workflows/catalog.py
index 61f490631c..5fffa4b45f 100644
--- a/src/specify_cli/workflows/catalog.py
+++ b/src/specify_cli/workflows/catalog.py
@@ -784,12 +784,14 @@ def remove_catalog(self, index: int) -> str:
raise WorkflowValidationError("No catalog config file found.")
try:
- data = yaml.safe_load(config_path.read_text(encoding="utf-8")) or {}
+ data = yaml.safe_load(config_path.read_text(encoding="utf-8"))
except (yaml.YAMLError, OSError, UnicodeDecodeError) as exc:
raise WorkflowValidationError(
f"Catalog config file is unreadable or malformed: {exc}"
) from exc
- if not isinstance(data, dict):
+ if data is None:
+ data = {}
+ elif not isinstance(data, dict):
raise WorkflowValidationError(
"Catalog config file is corrupted (expected a mapping)."
)
@@ -1394,12 +1396,14 @@ def add_catalog(self, url: str, name: str | None = None) -> None:
data: dict[str, Any] = {"catalogs": []}
if config_path.exists():
try:
- raw = yaml.safe_load(config_path.read_text(encoding="utf-8")) or {}
+ raw = yaml.safe_load(config_path.read_text(encoding="utf-8"))
except (yaml.YAMLError, OSError, UnicodeDecodeError) as exc:
raise StepValidationError(
f"Catalog config file is unreadable or malformed: {exc}"
) from exc
- if not isinstance(raw, dict):
+ if raw is None:
+ raw = {}
+ elif not isinstance(raw, dict):
raise StepValidationError(
"Catalog config file is corrupted (expected a mapping)."
)
@@ -1463,12 +1467,14 @@ def remove_catalog(self, index: int) -> str:
raise StepValidationError("No step catalog config file found.")
try:
- data = yaml.safe_load(config_path.read_text(encoding="utf-8")) or {}
+ data = yaml.safe_load(config_path.read_text(encoding="utf-8"))
except (yaml.YAMLError, OSError, UnicodeDecodeError) as exc:
raise StepValidationError(
f"Catalog config file is unreadable or malformed: {exc}"
) from exc
- if not isinstance(data, dict):
+ if data is None:
+ data = {}
+ elif not isinstance(data, dict):
raise StepValidationError(
"Catalog config file is corrupted (expected a mapping)."
)
diff --git a/src/specify_cli/workflows/engine.py b/src/specify_cli/workflows/engine.py
index d17513cc0b..411c1aac8a 100644
--- a/src/specify_cli/workflows/engine.py
+++ b/src/specify_cli/workflows/engine.py
@@ -139,7 +139,7 @@ def _get_valid_step_types() -> set[str]:
if STEP_REGISTRY:
return set(STEP_REGISTRY.keys())
return {
- "command", "shell", "prompt", "gate", "if", "init",
+ "command", "shell", "prompt", "gate", "if", "init", "slot",
"switch", "while", "do-while", "fan-out", "fan-in",
}
@@ -432,6 +432,13 @@ def _validate_steps(
step_errors = step_impl.validate(step_config)
errors.extend(step_errors)
+ if step_type == "slot" and inside_fan_out:
+ errors.append(
+ f"Slot step {step_id!r} is not supported inside fan-out "
+ "templates because overlays cannot address runtime-multiplied "
+ "templates."
+ )
+
# Validate optional `continue_on_error` field. The engine honours
# this on any step that returns StepStatus.FAILED so the pipeline can route
# around the failure via a downstream `if` or `switch` (or a
diff --git a/src/specify_cli/workflows/expressions.py b/src/specify_cli/workflows/expressions.py
index 35106758bf..198010838e 100644
--- a/src/specify_cli/workflows/expressions.py
+++ b/src/specify_cli/workflows/expressions.py
@@ -474,6 +474,12 @@ def _apply_filter(value: Any, filter_expr: str, namespace: dict[str, Any]) -> An
)
+# Order matters -- multi-char operators first, so "!=" is not split as "!" + "=".
+# Shared with the remediation check so a validator cannot drift from what the
+# evaluator will actually split on.
+_COMPARISON_OPERATORS = ("!=", "==", ">=", "<=", ">", "<", " not in ", " in ")
+
+
def _evaluate_simple_expression(expr: str, namespace: dict[str, Any]) -> Any:
"""Evaluate a simple expression against the namespace.
@@ -505,7 +511,40 @@ def _evaluate_simple_expression(expr: str, namespace: dict[str, Any]) -> Any:
pipe_idx = _find_top_level(expr, "|")
if pipe_idx != -1:
segments = _split_top_level(expr, "|")
- value = _evaluate_simple_expression(segments[0].strip(), namespace)
+ # The pipe is detected before the operators below, so a filter written on
+ # the right-hand operand of a comparison was applied to the comparison's
+ # BOOLEAN RESULT instead: `count > limit | default(5)` evaluated
+ # `count > limit` first and then `default` on the bool, which is a no-op,
+ # so the expression silently returned the comparison against the
+ # *unfiltered* operand. This is the mirror of a filter followed by a
+ # comparison (`default('7') > '5'`), which this module already refuses
+ # rather than guessing at the intended precedence. Refuse both the same
+ # way, so an ambiguous expression is reported instead of quietly
+ # producing the answer the author did not ask for.
+ head = segments[0].strip()
+ # Unary ``not`` is a leading prefix, not an infix token, so it has no
+ # surrounding space for the scan below to match -- it has to be checked
+ # the same way the parser itself does (``expr.startswith("not ")``).
+ # Without this, ``not inputs.missing | default(1)`` still evaluated
+ # ``not inputs.missing`` first and applied the filter to that boolean,
+ # which is the exact mis-binding this guard exists to reject.
+ # (A ``not`` that follows ``and``/``or`` is already caught by those
+ # tokens below.)
+ _ambiguous_op = "not" if head.startswith("not ") else None
+ if _ambiguous_op is None:
+ for _op in ("!=", "==", ">=", "<=", ">", "<", " not in ", " in ",
+ " or ", " and "):
+ if _find_top_level(head, _op) != -1:
+ _ambiguous_op = _op.strip()
+ break
+ if _ambiguous_op is not None:
+ raise ValueError(
+ f"ambiguous filter precedence in '{expr}': "
+ f"'| {segments[1].strip()}' would apply to the result of "
+ f"'{head}', not to an operand of '{_ambiguous_op}'. Filter the "
+ f"operand in its own expression instead."
+ )
+ value = _evaluate_simple_expression(head, namespace)
for segment in segments[1:]:
value = _apply_filter(value, segment.strip(), namespace)
return value
@@ -533,7 +572,7 @@ def _evaluate_simple_expression(expr: str, namespace: dict[str, Any]) -> Any:
# Comparison operators (order matters — check multi-char ops first). Split at
# the first top-level occurrence so an operator inside a quoted operand is
# ignored.
- for op in ("!=", "==", ">=", "<=", ">", "<", " not in ", " in "):
+ for op in _COMPARISON_OPERATORS:
op_idx = _find_top_level(expr, op)
if op_idx != -1:
left = _evaluate_simple_expression(expr[:op_idx].strip(), namespace)
@@ -779,6 +818,42 @@ def condition_is_never_evaluated(condition: Any) -> bool:
return _first_unclosable_block(stripped) == "verbatim"
+def condition_is_interpolated_to_text(condition: Any) -> bool:
+ """True when *condition* holds ``{{ }}`` blocks but is spliced into text, not evaluated.
+
+ ``evaluate_expression`` takes its typed fast path only when the whole string is
+ exactly one ``{{ ... }}`` block (``_is_single_expression``). Anything else — two
+ blocks, or one block with any text around it — goes to ``_interpolate_expressions``,
+ which substitutes each block into the surrounding string and returns a *string*.
+ ``evaluate_condition`` then coerces that with ``bool()``, so the result is true for
+ every rendering except ``""``, ``"true"`` and ``"false"``::
+
+ {{ inputs.ready }} and {{ inputs.count > 100 }} -> "False and False" -> True
+ not {{ inputs.ready }} -> "not False" -> True
+ {{ inputs.count }} > 100 -> "0 > 100" -> True
+
+ Each of those reads as a real expression and is always true, which is the same
+ silent-truthiness fault ``condition_is_never_evaluated`` reports one layer out: there
+ the braces are missing, here they are present but do not cover the whole condition.
+ The operators belong *inside* one block, and the validators already tell authors the
+ condition must be "a single complete '{{ }}' block" -- this is the check behind that
+ sentence.
+
+ Deliberately derived from ``_is_single_expression`` rather than restated, so this
+ cannot drift from the fast path it is predicting.
+ """
+ if not isinstance(condition, str):
+ return False
+ stripped = condition.strip()
+ if not stripped or "{{" not in stripped:
+ return False
+ # Leave both of the faults that already have their own message and advice: a block
+ # the substituter cannot close is not an interpolation problem.
+ if condition_is_never_evaluated(condition) or condition_has_malformed_expression_block(condition):
+ return False
+ return not _is_single_expression(stripped)
+
+
def condition_has_malformed_expression_block(condition: Any) -> bool:
"""True when *condition* holds a ``{{`` block the quote-aware scan cannot close,
but which ``_interpolate_expressions`` still evaluates through its raw-close
@@ -879,3 +954,329 @@ def format_condition_correction(condition: Any) -> str:
# double-spaced "{{ }}" that string concatenation would otherwise produce.
body = "{{ " + core + " }}" if core else "{{ }}"
return json.dumps(body, ensure_ascii=False)
+
+
+def _has_unbalanced_quote(text: str) -> bool:
+ """True when a quote opened in *text* is never closed.
+
+ Same left-to-right, first-quote-wins scan the rest of this module uses, so the
+ answer agrees with what ``_find_block_close`` and ``_strip_stray_delimiters``
+ consider "inside a string".
+ """
+ quote: str | None = None
+ for ch in text:
+ if quote is not None:
+ if ch == quote:
+ quote = None
+ elif ch in ("'", '"'):
+ quote = ch
+ return quote is not None
+
+
+_BRACKET_PAIRS = {")": "(", "]": "[", "}": "{"}
+
+# The operators the evaluator delimits with spaces; derived so the check cannot
+# drift from _COMPARISON_OPERATORS.
+_WORD_OPERATORS = tuple(
+ op for op in (" or ", " and ") + _COMPARISON_OPERATORS if op.startswith(" ")
+)
+
+
+def _has_unbalanced_bracket(text: str) -> bool:
+ """True when brackets outside a quoted operand do not nest and match.
+
+ A depth counter is not enough: it calls ``inputs.f(]`` balanced, because the
+ ``]`` cancels the ``(``. The evaluator then resolves that body to ``None`` and
+ the comparison is false, which is the inversion this module is trying to keep
+ out of the suggested correction. Track the opener types instead.
+ """
+ stack: list[str] = []
+ quote: str | None = None
+ for ch in text:
+ if quote is not None:
+ if ch == quote:
+ quote = None
+ elif ch in ("'", '"'):
+ quote = ch
+ elif ch in "([{":
+ stack.append(ch)
+ elif ch in _BRACKET_PAIRS and (not stack or stack.pop() != _BRACKET_PAIRS[ch]):
+ return True
+ return bool(stack)
+
+
+def _has_incomplete_operand(text: str) -> bool:
+ """True when an operator in *text* is missing an operand on either side.
+
+ Splits on **every** top-level occurrence rather than the first. Checking only
+ the first is the same defect this module exists to reject one level up: it let
+ ``inputs.a == inputs.b ==`` through, because the leading ``==`` has operands on
+ both sides and the scan stopped there.
+
+ Reads ``_COMPARISON_OPERATORS`` from the evaluator rather than restating it, so
+ the check cannot drift from what ``_evaluate_simple_expression`` splits on.
+ """
+ stripped = text.strip()
+ if not stripped:
+ return True
+
+ # `not x` is a valid prefix form; `and x` and `or x` are not, and none of the
+ # three is valid alone or trailing. The keyword scans below use bare words
+ # because a leading operator has no space in front of it to match on.
+ if stripped in ("and", "or", "not") or stripped.endswith(" not"):
+ return True
+ # Word operators lose their delimiting space at the ends of a stripped core, so
+ # a trailing "not in" or a leading "and" needs matching without it. Derived from
+ # the evaluator's own table rather than restated.
+ for op in _WORD_OPERATORS:
+ if stripped.endswith(op.rstrip()) or stripped.startswith(op.lstrip()):
+ return True
+
+ for op in (" or ", " and ") + _COMPARISON_OPERATORS:
+ if _find_top_level(stripped, op) == -1:
+ continue
+ if any(not segment.strip() for segment in _split_top_level(stripped, op)):
+ return True
+
+ return _find_top_level(stripped, "|") != -1 and any(
+ not segment.strip() for segment in _split_top_level(stripped, "|")
+ )
+
+
+# The roots _build_namespace supplies. A reference to anything else resolves to
+# None, so a correction built on one turns a truthy condition false.
+_NAMESPACE_ROOTS = ("inputs", "steps", "item", "fan_in", "context")
+
+# Exactly what _resolve_dot_path accepts: a name, optionally one numeric index.
+_PATH_SEGMENT = re.compile(r"^[\w-]+(\[\d+\])?$")
+
+
+class _ProbeNamespace(dict):
+ """Namespace for the parse probe: every root exists, every leaf is absent.
+
+ Enough for ``_evaluate_simple_expression`` to walk the grammar without needing
+ real inputs. Deliberately *not* resolving leaves to a sentinel value: a probe
+ that answers every lookup also answers ``inputs.count+1``, which is the
+ malformed shape the probe is meant to expose.
+ """
+
+ def __missing__(self, key: str) -> "_ProbeNamespace": # noqa: UP037 # pragma: no cover
+ return _ProbeNamespace()
+
+
+def _evaluator_rejects(text: str) -> str | None:
+ """The evaluator's own complaint about how *text* is wired, or ``None``.
+
+ Structural checks cannot establish that a core is parseable -- four rounds of
+ review found a new shape each time -- so this asks the evaluator. It reports
+ only the two failures ``_apply_filter`` raises about the expression itself: an
+ unknown filter name, and a registered filter used in an unsupported form.
+
+ Anything else a probe run raises is about the probe's placeholder values, not
+ the author's text. ``steps.emit.output.stdout | from_json`` is valid against a
+ string output and is exercised in ``tests/test_workflows.py``; the probe hands
+ ``from_json`` a dict and it raises, so treating every error as a rejection
+ withheld a correction from a perfectly good condition.
+ """
+ try:
+ _evaluate_simple_expression(
+ text, {root: _ProbeNamespace() for root in _NAMESPACE_ROOTS}
+ )
+ except ValueError as exc:
+ message = str(exc)
+ # Every error _apply_filter raises about the filter *expression* quotes the
+ # segment back as `got '| ...'`. Its value errors instead name the type they
+ # received, which under a probe is the placeholder, not anything the author
+ # wrote -- treating those as rejections withheld corrections from valid
+ # conditions such as `steps.emit.output.stdout | from_json`.
+ if "got '| " in message:
+ return message.split(":", 1)[0]
+ except Exception: # noqa: BLE001 - probe values, not the author's text
+ return None
+ return None
+
+
+
+def _looks_numeric(text: str) -> bool:
+ """Mirror the evaluator's numeric literal test exactly.
+
+ `_evaluate_simple_expression` only calls `float()` when a `.` is present and
+ `int()` otherwise, so `1e3` is not a number to it -- it falls through to a path
+ lookup and resolves to None. A bare `float()` here accepted `1e3` and the
+ correction turned a truthy condition false.
+ """
+ try:
+ if "." in text:
+ float(text)
+ else:
+ int(text)
+ except (ValueError, TypeError):
+ return False
+ return True
+
+
+def _is_literal(text: str) -> bool:
+ """Mirror the evaluator's literal tests exactly.
+
+ The string case is the opening quote's *matching close being the final
+ character*, not first/last-character equality: `'a' 'b'` passes the latter but
+ is two literals to the evaluator, which falls through to a path lookup.
+ """
+ if text[:1] in ("'", '"') and text.find(text[0], 1) == len(text) - 1:
+ return True
+ return text.lower() in ("true", "false", "none", "null") or _looks_numeric(text)
+
+
+def _unresolvable_term(text: str) -> str | None:
+ """The first operand in *text* the evaluator cannot resolve, or ``None``.
+
+ Walks operands the way ``_evaluate_simple_expression`` does -- filters, then
+ ``or``/``and``/``not``, then comparisons -- and checks each leaf. A leaf must be
+ a literal or a dotted path rooted in ``_NAMESPACE_ROOTS``.
+
+ Enumerating broken shapes is what made this take several rounds: each new gate
+ only knew the shapes named so far. ``inputs.a === inputs.b`` split cleanly on
+ ``==`` and looked complete, while the evaluator read ``= inputs.b`` as a path
+ and resolved it to ``None``; ``bogus == 'x'`` passed for the same reason one
+ level up. Recursing to the leaves covers both without naming either.
+ """
+ stripped = text.strip()
+ if not stripped:
+ return "an operand is empty"
+
+ if _find_top_level(stripped, "|") != -1:
+ segments = _split_top_level(stripped, "|")
+ reason = _unresolvable_term(segments[0])
+ if reason is not None:
+ return reason
+ # A filter argument is an ordinary operand to `_apply_filter`, which
+ # evaluates it with `_evaluate_simple_expression` like any other. Skipping
+ # it let `inputs.tags | join(bogus)` be offered as paste-ready: `bogus` is
+ # no namespace root, resolves to None, and the wrapped form then raises
+ # `join: expected a string separator, got NoneType`. Parse with the same
+ # pattern `_apply_filter` uses, so a form this does not recognize is left
+ # to the evaluator probe rather than guessed at here.
+ for segment in segments[1:]:
+ match = re.fullmatch(r"(\w+)\((.+)\)", segment.strip())
+ if match is None:
+ continue
+ reason = _unresolvable_term(match.group(2))
+ if reason is not None:
+ return reason
+ return None
+
+ for op in (" or ", " and "):
+ idx = _find_top_level(stripped, op)
+ if idx != -1:
+ return _unresolvable_term(stripped[:idx]) or _unresolvable_term(
+ stripped[idx + len(op):]
+ )
+
+ if stripped.startswith("not "):
+ return _unresolvable_term(stripped[4:])
+
+ for op in _COMPARISON_OPERATORS:
+ idx = _find_top_level(stripped, op)
+ if idx != -1:
+ return _unresolvable_term(stripped[:idx]) or _unresolvable_term(
+ stripped[idx + len(op):]
+ )
+
+ if _is_literal(stripped):
+ return None
+
+ # A list literal is a term the evaluator understands, and it recurses into the
+ # elements rather than resolving the brackets as a name. Not mirroring that
+ # denied the correction to `inputs.tag in ['x', 'y']` -- a condition wrapping
+ # repairs completely -- while reporting the list as an unresolvable name. The
+ # empty-segment skip matches `_evaluate_simple_expression`, which drops them so
+ # `[1, 2,]` is `[1, 2]` rather than `[1, 2, None]`.
+ if stripped.startswith("[") and stripped.endswith("]"):
+ inner = stripped[1:-1].strip()
+ if not inner:
+ return None
+ for element in _split_top_level_commas(inner):
+ if not element.strip():
+ continue
+ reason = _unresolvable_term(element)
+ if reason is not None:
+ return reason
+ return None
+
+ segments = _split_top_level(stripped, ".")
+ if not _PATH_SEGMENT.match(segments[0].strip()):
+ return f"{stripped!r} is not a name the evaluator can resolve"
+ # `item` is the only root that is not always a mapping: `StepContext.item` is
+ # `Any` and a fan-out assigns the item value itself, so when that value is a
+ # list `_resolve_dot_path` indexes it and `item[0] == 'x'` resolves. Every
+ # other root comes back from `_build_namespace` as a mapping, and the index
+ # branch returns None for those however it is written -- so the index is
+ # stripped for `item` alone rather than for roots in general.
+ root = segments[0].strip()
+ indexed_root = re.fullmatch(r"([\w-]+)\[\d+\]", root)
+ if indexed_root is not None and indexed_root.group(1) == "item":
+ root = indexed_root.group(1)
+ if root not in _NAMESPACE_ROOTS:
+ return (
+ f"{segments[0].strip()!r} is not one of the namespace roots "
+ f"({', '.join(_NAMESPACE_ROOTS)})"
+ )
+ for segment in segments[1:]:
+ if not _PATH_SEGMENT.match(segment.strip()):
+ return f"{segment.strip()!r} is not a valid path segment"
+ return None
+
+
+def _wrapping_would_not_repair(core: str) -> str | None:
+ """Why wrapping *core* in ``{{ }}`` would not yield the expression intended.
+
+ ``None`` when it would. Each branch names something observable about the text
+ itself, deliberately not the interpolator path it will take: two earlier
+ versions of this message asserted an internal route -- the raw-close fallback --
+ and were wrong, because ``_is_single_expression`` accepts the wrapped form and
+ sends it down the typed fast path instead.
+ """
+ if not core:
+ return "there is no expression here to wrap"
+ if _has_unbalanced_quote(core):
+ return "the quote opened in it is never closed"
+ if _has_unbalanced_bracket(core):
+ return "its brackets do not balance"
+ if _has_incomplete_operand(core):
+ return "an operator in it is missing an operand"
+ unresolvable = _unresolvable_term(core)
+ if unresolvable is not None:
+ return unresolvable
+ rejected = _evaluator_rejects(core)
+ if rejected is not None:
+ return f"the evaluator rejects it ({rejected})"
+ return None
+
+
+def format_condition_remediation(condition: Any) -> str:
+ """The advice sentence for a condition that is never evaluated.
+
+ ``format_condition_correction`` wraps whatever it is handed, which is right for a
+ formatter but wrong to advertise as paste-ready when wrapping cannot repair the
+ input. Measured, each of these was being offered as the fix and each **inverts**
+ the condition instead:
+
+ " " -> "{{ }}" True -> False
+ {{ inputs.name == 'abc -> "{{ inputs.name == 'abc }}" True -> False
+ inputs.name == -> "{{ inputs.name == }}" True -> False
+
+ The author is told the condition is always true, pastes the suggestion, and now
+ has an always-false one. Naming the fault beats handing back something that looks
+ authoritative and is not -- the same call already made for
+ ``condition_has_malformed_expression_block``, which offers no suggestion at all.
+ """
+ core = _strip_stray_delimiters(str(condition)).strip()
+ reason = _wrapping_would_not_repair(core)
+ if reason is None:
+ return "Wrap the expression: " + format_condition_correction(condition) + "."
+ return (
+ f"No correction is offered because {reason}: wrapping it as written would "
+ "produce a different expression from the one intended, and its result can "
+ "silently invert the condition rather than repair it. Complete the "
+ "expression, or use the literal true or false."
+ )
diff --git a/src/specify_cli/workflows/overlays/_commands.py b/src/specify_cli/workflows/overlays/_commands.py
index 549f1ea151..06c4dca835 100644
--- a/src/specify_cli/workflows/overlays/_commands.py
+++ b/src/specify_cli/workflows/overlays/_commands.py
@@ -214,7 +214,15 @@ def workflow_overlay_add(
existed_before = target_path.exists()
staged = _stage_workflow_file(target_path.parent)
try:
- staged.write_bytes(yaml.safe_dump(data, sort_keys=False).encode("utf-8"))
+ # ``allow_unicode=True`` matches every other YAML writer in the
+ # repo. Without it every non-ASCII character in a hand-authored
+ # overlay is rewritten as a ``\uXXXX`` escape, so merely toggling
+ # an overlay makes the user's own file unreadable.
+ staged.write_bytes(
+ yaml.safe_dump(data, sort_keys=False, allow_unicode=True).encode(
+ "utf-8"
+ )
+ )
backup = _commit_workflow_file(staged, target_path, existed_before)
except BaseException:
_safe_discard_staged_workflow_file(
@@ -267,7 +275,15 @@ def _update_overlay_field(
existed_before = path.exists()
staged = _stage_workflow_file(path.parent)
try:
- staged.write_bytes(yaml.safe_dump(data, sort_keys=False).encode("utf-8"))
+ # ``allow_unicode=True`` matches every other YAML writer in the
+ # repo. Without it every non-ASCII character in a hand-authored
+ # overlay is rewritten as a ``\uXXXX`` escape, so merely toggling
+ # an overlay makes the user's own file unreadable.
+ staged.write_bytes(
+ yaml.safe_dump(data, sort_keys=False, allow_unicode=True).encode(
+ "utf-8"
+ )
+ )
backup = _commit_workflow_file(staged, path, existed_before)
except BaseException:
_safe_discard_staged_workflow_file(staged, path.parent, existed_before)
diff --git a/src/specify_cli/workflows/overlays/schema.py b/src/specify_cli/workflows/overlays/schema.py
index 0a018b7af0..969bbd94a9 100644
--- a/src/specify_cli/workflows/overlays/schema.py
+++ b/src/specify_cli/workflows/overlays/schema.py
@@ -60,7 +60,13 @@ def _validate_safe_id(
def _parse_edit(edit_raw: dict[str, Any], idx: int) -> tuple[OverlayEdit | None, str | None]:
"""Parse a single edit dict into an OverlayEdit or an error string."""
- shorthand_keys = [key for key in _SHORTHAND_OPERATION_KEYS if key in edit_raw]
+ # Iterate ``edit_raw`` rather than ``_SHORTHAND_OPERATION_KEYS``: the latter
+ # is a frozenset, whose iteration order varies between processes with
+ # string-hash randomization, so the error messages built from this list
+ # named the offending keys in a different order on every run for the very
+ # same overlay file. Dict keys are always hashable, so the membership test
+ # is safe in this direction too.
+ shorthand_keys = [key for key in edit_raw if key in _SHORTHAND_OPERATION_KEYS]
has_operation = "operation" in edit_raw
operation: str | None = None
diff --git a/src/specify_cli/workflows/steps/do_while/__init__.py b/src/specify_cli/workflows/steps/do_while/__init__.py
index 84921ef556..926e47b512 100644
--- a/src/specify_cli/workflows/steps/do_while/__init__.py
+++ b/src/specify_cli/workflows/steps/do_while/__init__.py
@@ -7,8 +7,9 @@
from specify_cli.workflows.base import StepBase, StepContext, StepResult, StepStatus
from specify_cli.workflows.expressions import (
condition_has_malformed_expression_block,
+ condition_is_interpolated_to_text,
condition_is_never_evaluated,
- format_condition_correction,
+ format_condition_remediation,
)
@@ -104,8 +105,8 @@ def validate(self, config: dict[str, Any]) -> list[str]:
errors.append(
f"Do-while step {config.get('id', '?')!r}: 'condition' "
f"{config['condition']!r} is not a single complete '{{{{ }}}}' block, so "
- "it is never evaluated as an expression and is always true. Wrap the expression: "
- + format_condition_correction(config["condition"]) + "."
+ "it is never evaluated as an expression and is always true. "
+ + format_condition_remediation(config["condition"])
)
elif condition_has_malformed_expression_block(config["condition"]):
# Different fault, different advice. Here the block is *not* skipped:
@@ -121,6 +122,21 @@ def validate(self, config: dict[str, Any]) -> list[str]:
"truncated expression instead of the one written. Balance the "
"delimiters and quotes."
)
+ elif condition_is_interpolated_to_text(config["condition"]):
+ # Third fault, third message. The braces are here and they close, but they
+ # do not cover the whole condition, so evaluate_expression takes its text
+ # path rather than the typed one: each block is substituted into the
+ # surrounding string and the result is coerced by bool(). Two blocks joined
+ # by `and` render "False and False", which is true. No paste-ready
+ # correction is offered: there is no single right rewrite, because only the
+ # author knows which grouping the operators were meant to have.
+ errors.append(
+ f"Do-while step {config.get('id', '?')!r}: 'condition' "
+ f"{config['condition']!r} holds more than one '{{{{ }}}}' block, or "
+ "text around one, so it is substituted into a string and coerced by "
+ "bool() instead of being evaluated. Put the whole expression inside a "
+ "single '{{ }}' block."
+ )
max_iter = config.get("max_iterations")
if max_iter is not None:
# bool is a subclass of int, so isinstance(True, int) is True and
@@ -131,6 +147,14 @@ def validate(self, config: dict[str, Any]) -> list[str]:
f"Do-while step {config.get('id', '?')!r}: "
f"'max_iterations' must be an integer >= 1."
)
+ if "steps" not in config:
+ # This step's own docstring promises "The first invocation always
+ # returns the nested steps for execution" -- with no body it
+ # validated clean and then returned none, so the loop never ran even
+ # once. See the matching guard in the ``while`` step.
+ errors.append(
+ f"Do-while step {config.get('id', '?')!r} is missing 'steps' field."
+ )
nested = config.get("steps", [])
if not isinstance(nested, list):
errors.append(
diff --git a/src/specify_cli/workflows/steps/if_then/__init__.py b/src/specify_cli/workflows/steps/if_then/__init__.py
index cb74db7b3d..0573785d90 100644
--- a/src/specify_cli/workflows/steps/if_then/__init__.py
+++ b/src/specify_cli/workflows/steps/if_then/__init__.py
@@ -7,8 +7,9 @@
from specify_cli.workflows.base import StepBase, StepContext, StepResult, StepStatus
from specify_cli.workflows.expressions import (
condition_has_malformed_expression_block,
+ condition_is_interpolated_to_text,
condition_is_never_evaluated,
- format_condition_correction,
+ format_condition_remediation,
evaluate_condition,
)
@@ -95,8 +96,8 @@ def validate(self, config: dict[str, Any]) -> list[str]:
errors.append(
f"If step {config.get('id', '?')!r}: 'condition' "
f"{config['condition']!r} is not a single complete '{{{{ }}}}' block, so "
- "it is never evaluated as an expression and is always true. Wrap the expression: "
- + format_condition_correction(config["condition"]) + "."
+ "it is never evaluated as an expression and is always true. "
+ + format_condition_remediation(config["condition"])
)
elif condition_has_malformed_expression_block(config["condition"]):
# Different fault, different advice. Here the block is *not* skipped:
@@ -112,6 +113,21 @@ def validate(self, config: dict[str, Any]) -> list[str]:
"truncated expression instead of the one written. Balance the "
"delimiters and quotes."
)
+ elif condition_is_interpolated_to_text(config["condition"]):
+ # Third fault, third message. The braces are here and they close, but they
+ # do not cover the whole condition, so evaluate_expression takes its text
+ # path rather than the typed one: each block is substituted into the
+ # surrounding string and the result is coerced by bool(). Two blocks joined
+ # by `and` render "False and False", which is true. No paste-ready
+ # correction is offered: there is no single right rewrite, because only the
+ # author knows which grouping the operators were meant to have.
+ errors.append(
+ f"If step {config.get('id', '?')!r}: 'condition' "
+ f"{config['condition']!r} holds more than one '{{{{ }}}}' block, or "
+ "text around one, so it is substituted into a string and coerced by "
+ "bool() instead of being evaluated. Put the whole expression inside a "
+ "single '{{ }}' block."
+ )
if "then" not in config:
errors.append(
f"If step {config.get('id', '?')!r} is missing 'then' field."
diff --git a/src/specify_cli/workflows/steps/slot/__init__.py b/src/specify_cli/workflows/steps/slot/__init__.py
new file mode 100644
index 0000000000..bd784a7b00
--- /dev/null
+++ b/src/specify_cli/workflows/steps/slot/__init__.py
@@ -0,0 +1,67 @@
+"""Slot step — a named, no-op workflow slot.
+
+An upstream workflow declares a slot at the position where a downstream
+project may extend it. The step ``id`` is the overlay anchor; ``name`` is only
+the human-readable slot label. A project overlay fills the slot with the
+standard ``replace`` operation on the slot step's ``id``. Unfilled slots are
+skipped when the workflow runs.
+
+Example YAML::
+
+ # Upstream workflow
+ - id: post-implement
+ type: slot
+ name: post-implement
+
+ # .specify/workflows/overlays/my-workflow/fill-post-implement.yml
+ id: fill-post-implement
+ extends: my-workflow
+ edits:
+ - replace: post-implement
+ step:
+ id: post-implement
+ type: shell
+ run: echo "Run project-specific checks"
+"""
+
+from __future__ import annotations
+
+from typing import Any
+
+from specify_cli.workflows.base import StepBase, StepContext, StepResult, StepStatus
+
+
+class SlotStep(StepBase):
+ """Provide a named workflow slot that skips when unfilled."""
+
+ type_key = "slot"
+
+ def execute(self, config: dict[str, Any], context: StepContext) -> StepResult:
+ if context.inside_fan_out:
+ return StepResult(
+ status=StepStatus.FAILED,
+ error=(
+ f"Slot step {config.get('id', '?')!r} is not supported "
+ "inside fan-out templates because overlays cannot address "
+ "runtime-multiplied templates."
+ ),
+ )
+ return StepResult(
+ status=StepStatus.SKIPPED,
+ output={"slot": config.get("name")},
+ )
+
+ def validate(self, config: dict[str, Any]) -> list[str]:
+ errors = super().validate(config)
+ name = config.get("name")
+ if name is None:
+ errors.append(
+ f"Slot step {config.get('id', '?')!r} requires a 'name' field "
+ "(the slot label)."
+ )
+ elif not isinstance(name, str) or not name.strip():
+ errors.append(
+ f"Slot step {config.get('id', '?')!r}: 'name' must be a "
+ "non-blank string."
+ )
+ return errors
diff --git a/src/specify_cli/workflows/steps/switch/__init__.py b/src/specify_cli/workflows/steps/switch/__init__.py
index 690df0f19a..8a2e4b343e 100644
--- a/src/specify_cli/workflows/steps/switch/__init__.py
+++ b/src/specify_cli/workflows/steps/switch/__init__.py
@@ -12,7 +12,8 @@ class SwitchStep(StepBase):
"""Multi-branch dispatch on an expression.
Evaluates ``expression:`` once, matches against ``cases:`` keys
- (exact match, string-coerced). Falls through to ``default:`` if
+ (exact match; the resolved value is string-coerced and stripped of
+ surrounding whitespace first). Falls through to ``default:`` if
no case matches.
"""
@@ -22,8 +23,18 @@ def execute(self, config: dict[str, Any], context: StepContext) -> StepResult:
expression = config.get("expression", "")
value = evaluate_expression(expression, context)
- # String-coerce for matching
- str_value = str(value) if value is not None else ""
+ # String-coerce for matching, stripping surrounding whitespace first.
+ # The value a switch dispatches on is most often captured command
+ # output, and a ``shell`` step stores ``proc.stdout`` verbatim, so
+ # ``run: echo approve`` resolves to ``"approve\n"`` and matches no
+ # ``approve:`` case -- the switch silently falls through to ``default:``
+ # while still reporting COMPLETED. A workflow cannot strip it itself:
+ # the registered filters are default/join/map/contains/from_json, there
+ # is no ``trim``. ``evaluate_condition`` and ``InitStep._resolve_bool``
+ # already strip before matching a resolved string against declared
+ # literals, and case keys are exactly such literals. ``expression_value``
+ # below still reports the raw value, so nothing downstream loses it.
+ str_value = str(value).strip() if value is not None else ""
cases = config.get("cases", {})
if not isinstance(cases, dict):
@@ -96,6 +107,19 @@ def validate(self, config: dict[str, Any]) -> list[str]:
f"Switch step {config.get('id', '?')!r} is missing "
f"'expression' field."
)
+ # Every other control-flow step requires its branch payload: ``if``
+ # requires ``then``, ``fan-out`` requires ``items`` and ``step``,
+ # ``fan-in`` a non-empty ``wait_for``, ``gate`` a ``message``. Without
+ # the same check, a switch whose ``cases:`` block is missing or mistyped
+ # (``case:`` is the obvious slip) validates clean and then reports
+ # COMPLETED with ``matched_case: "__default__"`` -- a default it may not
+ # even declare -- having dispatched nothing. That is the "silent empty
+ # result + COMPLETED" wiring bug the fan-in guard exists to prevent.
+ if "cases" not in config:
+ errors.append(
+ f"Switch step {config.get('id', '?')!r} is missing "
+ f"'cases' field."
+ )
cases = config.get("cases", {})
if not isinstance(cases, dict):
errors.append(
diff --git a/src/specify_cli/workflows/steps/while_loop/__init__.py b/src/specify_cli/workflows/steps/while_loop/__init__.py
index feda1b334d..cf6499a455 100644
--- a/src/specify_cli/workflows/steps/while_loop/__init__.py
+++ b/src/specify_cli/workflows/steps/while_loop/__init__.py
@@ -7,8 +7,9 @@
from specify_cli.workflows.base import StepBase, StepContext, StepResult, StepStatus
from specify_cli.workflows.expressions import (
condition_has_malformed_expression_block,
+ condition_is_interpolated_to_text,
condition_is_never_evaluated,
- format_condition_correction,
+ format_condition_remediation,
evaluate_condition,
)
@@ -113,8 +114,8 @@ def validate(self, config: dict[str, Any]) -> list[str]:
errors.append(
f"While step {config.get('id', '?')!r}: 'condition' "
f"{config['condition']!r} is not a single complete '{{{{ }}}}' block, so "
- "it is never evaluated as an expression and is always true. Wrap the expression: "
- + format_condition_correction(config["condition"]) + "."
+ "it is never evaluated as an expression and is always true. "
+ + format_condition_remediation(config["condition"])
)
elif condition_has_malformed_expression_block(config["condition"]):
# Different fault, different advice. Here the block is *not* skipped:
@@ -130,6 +131,21 @@ def validate(self, config: dict[str, Any]) -> list[str]:
"truncated expression instead of the one written. Balance the "
"delimiters and quotes."
)
+ elif condition_is_interpolated_to_text(config["condition"]):
+ # Third fault, third message. The braces are here and they close, but they
+ # do not cover the whole condition, so evaluate_expression takes its text
+ # path rather than the typed one: each block is substituted into the
+ # surrounding string and the result is coerced by bool(). Two blocks joined
+ # by `and` render "False and False", which is true. No paste-ready
+ # correction is offered: there is no single right rewrite, because only the
+ # author knows which grouping the operators were meant to have.
+ errors.append(
+ f"While step {config.get('id', '?')!r}: 'condition' "
+ f"{config['condition']!r} holds more than one '{{{{ }}}}' block, or "
+ "text around one, so it is substituted into a string and coerced by "
+ "bool() instead of being evaluated. Put the whole expression inside a "
+ "single '{{ }}' block."
+ )
max_iter = config.get("max_iterations")
if max_iter is not None:
# bool is a subclass of int, so isinstance(True, int) is True and
@@ -140,6 +156,18 @@ def validate(self, config: dict[str, Any]) -> list[str]:
f"While step {config.get('id', '?')!r}: "
f"'max_iterations' must be an integer >= 1."
)
+ if "steps" not in config:
+ # A loop with no body is never what the author meant, but it used to
+ # validate clean and then report COMPLETED at run time while
+ # returning no next_steps -- so the engine's ``if result.next_steps:``
+ # block never fired and the loop the workflow is built around never
+ # ran once. The mistype is easy: fan-out's payload key is the
+ # singular ``step:``, so writing ``step:`` on a ``while`` produced a
+ # silent no-op. ``if`` already requires ``then`` and fan-out already
+ # requires both ``items`` and ``step``; require a body here too.
+ errors.append(
+ f"While step {config.get('id', '?')!r} is missing 'steps' field."
+ )
nested = config.get("steps", [])
if not isinstance(nested, list):
errors.append(
diff --git a/templates/commands/analyze.md b/templates/commands/analyze.md
index 2cd83bd7c0..2e13af58ae 100644
--- a/templates/commands/analyze.md
+++ b/templates/commands/analyze.md
@@ -1,9 +1,9 @@
---
description: Perform a non-destructive cross-artifact consistency and quality analysis across spec.md, plan.md, and tasks.md after task generation.
scripts:
- sh: scripts/bash/check-prerequisites.sh --json --require-tasks --include-tasks
- ps: scripts/powershell/check-prerequisites.ps1 -Json -RequireTasks -IncludeTasks
- py: scripts/python/check_prerequisites.py --json --require-tasks --include-tasks
+ sh: scripts/bash/check-prerequisites.sh --json --require-spec --require-tasks --include-tasks
+ ps: scripts/powershell/check-prerequisites.ps1 -Json -RequireSpec -RequireTasks -IncludeTasks
+ py: scripts/python/check_prerequisites.py --json --require-spec --require-tasks --include-tasks
---
## User Input
diff --git a/templates/commands/converge.md b/templates/commands/converge.md
index eadb96ee58..a177c31371 100644
--- a/templates/commands/converge.md
+++ b/templates/commands/converge.md
@@ -1,9 +1,9 @@
---
description: Assess the current codebase against the feature's spec, plan, and tasks, then append any remaining unbuilt work as new tasks to tasks.md so implement can complete it.
scripts:
- sh: scripts/bash/check-prerequisites.sh --json --require-tasks --include-tasks
- ps: scripts/powershell/check-prerequisites.ps1 -Json -RequireTasks -IncludeTasks
- py: scripts/python/check_prerequisites.py --json --require-tasks --include-tasks
+ sh: scripts/bash/check-prerequisites.sh --json --require-spec --require-tasks --include-tasks
+ ps: scripts/powershell/check-prerequisites.ps1 -Json -RequireSpec -RequireTasks -IncludeTasks
+ py: scripts/python/check_prerequisites.py --json --require-spec --require-tasks --include-tasks
---
## User Input
diff --git a/templates/commands/plan.md b/templates/commands/plan.md
index 664f428114..836e25070c 100644
--- a/templates/commands/plan.md
+++ b/templates/commands/plan.md
@@ -59,7 +59,7 @@ You **MUST** consider the user input before proceeding (if not empty).
## Outline
-1. **Setup**: Run `{SCRIPT}` from repo root and parse JSON for FEATURE_SPEC, IMPL_PLAN, SPECS_DIR, BRANCH. For single quotes in args like "I'm Groot", use escape syntax: e.g 'I'\''m Groot' (or double-quote if possible: "I'm Groot").
+1. **Setup**: Run `{SCRIPT}` from repo root and parse JSON for FEATURE_SPEC, IMPL_PLAN, FEATURE_DIR, BRANCH. For single quotes in args like "I'm Groot", use escape syntax: e.g 'I'\''m Groot' (or double-quote if possible: "I'm Groot").
2. **Load context**: Read FEATURE_SPEC and `/memory/constitution.md`. Load IMPL_PLAN template (already copied).
diff --git a/tests/contract/test_bundle_cli.py b/tests/contract/test_bundle_cli.py
index c458a810ba..6db4dab769 100644
--- a/tests/contract/test_bundle_cli.py
+++ b/tests/contract/test_bundle_cli.py
@@ -340,7 +340,10 @@ def test_build_escapes_markup_in_output_path(project: Path):
assert result.exit_code == 0, repr(result.exception)
assert list(out_dir.glob("*.zip")), "the artifact should still be built"
- assert "dist[bold]out" in strip_ansi(result.output), (
+ # Join across Rich's wrap points: the success line prints an absolute path,
+ # so the console folds it mid-token whenever the temp directory is long
+ # enough, which is a property of the runner's path, not of the escaping.
+ assert "dist[bold]out" in "".join(strip_ansi(result.output).split()), (
"the reported path must match the directory actually written"
)
@@ -786,6 +789,38 @@ def fake_open_url(url, timeout=None, extra_headers=None, redirect_validator=None
assert asset_calls[0][1] == {"Accept": "application/octet-stream"}
+def test_bundle_info_rejects_utf16_remote_manifest_like_local_sources(project: Path):
+ """A downloaded (non-zip) bundle.yml must be decoded strictly as UTF-8.
+
+ ``yamlio.load_yaml`` decodes local ``bundle.yml`` sources strictly as
+ UTF-8, so a well-formed UTF-16 manifest (a realistic PowerShell
+ ``Out-File`` output) is rejected. Feeding the downloaded bytes straight
+ to ``yaml.safe_load(io.BytesIO(raw))`` let PyYAML's Reader honour the
+ UTF-16 BOM and silently *accept* the same manifest instead, diverging
+ from local/zip sources (the zip branch of this same download path was
+ already fixed for the identical bug).
+ """
+ api_asset_url = "https://api.github.com/repos/org/repo/releases/assets/99"
+ manifest_yaml_utf16 = yaml.safe_dump(valid_manifest_dict()).encode("utf-16")
+
+ def fake_open_url(url, timeout=None, extra_headers=None, redirect_validator=None):
+ return FakeBundleResponse(manifest_yaml_utf16, url=api_asset_url)
+
+ catalog = project / "catalog.json"
+ write_catalog_file(
+ catalog,
+ {"demo-bundle": catalog_entry_dict("demo-bundle", download_url=api_asset_url)},
+ )
+ _make_catalog_config(catalog, project)
+
+ with patch("specify_cli.authentication.http.open_url", side_effect=fake_open_url):
+ result = runner.invoke(app, ["bundle", "info", "demo-bundle", "--json"])
+
+ assert result.exit_code == 1
+ output_flat = " ".join(result.output.split())
+ assert "could not be read" in output_flat.lower()
+
+
def test_bundle_info_passes_through_api_asset_url(project: Path):
"""bundle info passes a direct GitHub API asset URL through with octet-stream."""
api_asset_url = "https://api.github.com/repos/org/repo/releases/assets/77"
diff --git a/tests/contract/test_catalog_schema.py b/tests/contract/test_catalog_schema.py
index 15a844118b..3fd3a5c53d 100644
--- a/tests/contract/test_catalog_schema.py
+++ b/tests/contract/test_catalog_schema.py
@@ -238,6 +238,15 @@ def test_catalog_entry_rejects_string_tags():
CatalogEntry.from_dict(data)
+def test_catalog_entry_rejects_non_string_tag_members():
+ from specify_cli.bundler.models.catalog import CatalogEntry
+
+ data = catalog_entry_dict("demo")
+ data["tags"] = ["valid", 1]
+ with pytest.raises(BundlerError, match="'tags' must be a list of strings"):
+ CatalogEntry.from_dict(data)
+
+
def test_catalog_entry_rejects_non_boolean_verified():
from specify_cli.bundler.models.catalog import CatalogEntry
@@ -300,6 +309,23 @@ def test_catalog_entry_rejects_non_mapping_provides():
CatalogEntry.from_dict(data)
+def test_load_payload_rejects_unsupported_schema_version():
+ payload = catalog_payload({"demo": catalog_entry_dict("demo")})
+ payload["schema_version"] = "2.0"
+
+ with pytest.raises(BundlerError, match="Unsupported catalog schema version"):
+ load_catalog_payload(payload)
+
+
+def test_load_payload_accepts_matching_or_absent_schema_version():
+ payload = catalog_payload({"demo": catalog_entry_dict("demo")})
+ payload["schema_version"] = "1.5"
+ assert "demo" in load_catalog_payload(payload)
+
+ payload.pop("schema_version")
+ assert "demo" in load_catalog_payload(payload)
+
+
@pytest.mark.parametrize("field", ["requires", "provides"])
@pytest.mark.parametrize("bad", [[], "", 0, False])
def test_catalog_entry_rejects_falsy_non_mapping(field, bad):
diff --git a/tests/contract/test_manifest_schema.py b/tests/contract/test_manifest_schema.py
index 2f38620423..4784bdf462 100644
--- a/tests/contract/test_manifest_schema.py
+++ b/tests/contract/test_manifest_schema.py
@@ -165,6 +165,25 @@ def test_string_mcp_rejected_not_split_per_character():
BundleManifest.from_dict(data)
+@pytest.mark.parametrize(
+ ("field", "value"),
+ [
+ ("tags", [1]),
+ ("requires.tools", [False]),
+ ("requires.mcp", [{}]),
+ ],
+)
+def test_string_list_fields_reject_non_string_members(field, value):
+ data = valid_manifest_dict()
+ if field == "tags":
+ data["tags"] = value
+ else:
+ data["requires"][field.split(".", 1)[1]] = value
+
+ with pytest.raises(BundlerError, match="must be a list of strings"):
+ BundleManifest.from_dict(data)
+
+
def test_string_integration_rejected_not_silently_dropped():
# A present-but-non-mapping 'integration' (a bare string) was silently
# dropped, leaving the bundle wrongly integration-agnostic. Reject it like
diff --git a/tests/extensions/git/test_git_extension_python_parity.py b/tests/extensions/git/test_git_extension_python_parity.py
index faeef00e7f..8cae45e783 100644
--- a/tests/extensions/git/test_git_extension_python_parity.py
+++ b/tests/extensions/git/test_git_extension_python_parity.py
@@ -17,10 +17,13 @@
import pytest
from tests.conftest import requires_bash
+from tests.parity_helpers import collation_range_locale
from tests.extensions.git.test_git_extension import (
_GIT_ENV,
+ HAS_PWSH,
_init_git,
_run_bash,
+ _run_pwsh,
_setup_project,
_write_config,
)
@@ -139,20 +142,144 @@ def test_slug_generation_stop_words_and_acronyms(self, tmp_path: Path):
p = _run_py("create-new-feature-branch", py_proj, "--json", "--dry-run", description)
_assert_parity(b, p)
- def test_short_name_cleaning(self, tmp_path: Path):
+ @pytest.mark.parametrize(
+ "description",
+ [
+ "Fix \u00e9DB\u00e9 sync",
+ "Tune the \u00fcUI\u00fc layer",
+ ],
+ ids=["db_between_accents", "ui_between_accents"],
+ )
+ def test_acronym_adjacent_to_non_ascii_matches_python(
+ self, tmp_path: Path, description: str
+ ):
+ """A short acronym touching an accented letter is kept by both twins.
+
+ The bash twin probes for acronyms with `grep -qw` under LC_ALL=C, where
+ an accented letter is a non-word byte and therefore a word boundary.
+ Python's \\b and .NET's \\b are Unicode-aware and saw no boundary there,
+ so the twins disagreed on whether the acronym survived.
+ """
+ bash_proj, py_proj = _twin_projects(tmp_path)
+ b = _run_bash(
+ "create-new-feature-branch.sh", bash_proj,
+ "--json", "--dry-run", description,
+ )
+ p = _run_py(
+ "create-new-feature-branch", py_proj,
+ "--json", "--dry-run", description,
+ )
+ _assert_parity(b, p)
+
+ if not HAS_PWSH:
+ pytest.skip("pwsh not available")
+ ps_proj = _setup_project(tmp_path / "ps" / "proj")
+ ps = _run_pwsh(
+ "create-new-feature-branch.ps1", ps_proj,
+ "-Json", "-DryRun", description,
+ )
+ assert ps.returncode == 0, ps.stderr
+ assert json.loads(ps.stdout) == json.loads(b.stdout)
+
+ @pytest.mark.parametrize(
+ ("short_name", "expected"),
+ [
+ ("User_Auth!", "001-user-auth"),
+ ("User__Auth!!", "001-user-auth"),
+ ("auth -- v2", "001-auth-v2"),
+ ],
+ ids=["single_separators", "repeated_separators", "separator_run"],
+ )
+ def test_short_name_cleaning(
+ self, tmp_path: Path, short_name: str, expected: str
+ ):
+ # Repeated separators included: the bash twin used to collapse them with
+ # sed 's/-\+/-/g', a GNU-ism that POSIX/BSD sed reads as a literal '+',
+ # so the runs survived on macOS.
bash_proj, py_proj = _twin_projects(tmp_path)
- # Single separator runs only: the bash twin's collapse step
- # (sed 's/-\+/-/g') is a GNU-ism that BSD sed treats literally.
b = _run_bash(
"create-new-feature-branch.sh", bash_proj,
- "--json", "--dry-run", "--short-name", "User_Auth!", "desc",
+ "--json", "--dry-run", "--short-name", short_name, "desc",
)
p = _run_py(
"create-new-feature-branch", py_proj,
- "--json", "--dry-run", "--short-name", "User_Auth!", "desc",
+ "--json", "--dry-run", "--short-name", short_name, "desc",
)
_assert_parity(b, p)
- assert json.loads(p.stdout)["BRANCH_NAME"] == "001-user-auth"
+ assert json.loads(p.stdout)["BRANCH_NAME"] == expected
+
+ @pytest.mark.parametrize(
+ "description",
+ [
+ "Añadir autenticación de usuario",
+ "Ajouter la réservation hôtelière",
+ ],
+ ids=["spanish", "french"],
+ )
+ def test_branch_name_ignores_locale_collation(
+ self, tmp_path: Path, description: str
+ ):
+ """The created git branch must not depend on the caller's locale.
+
+ The bash twin sanitizes with sed 's/[^a-z0-9]/-/g'. Under a locale whose
+ a-z range is collation-ordered that class keeps accented lowercase
+ letters, so bash checked out 001-ajouter-réservation-hôtelière where
+ the Python twin checks out 001-ajouter-servation-teli.
+ """
+ locale_name = collation_range_locale()
+ if locale_name is None:
+ pytest.skip("no locale with collation-ordered [a-z] ranges available")
+ env_extra = {"LC_ALL": locale_name, "LANG": locale_name}
+
+ bash_proj, py_proj = _twin_projects(tmp_path)
+ b = _run_bash(
+ "create-new-feature-branch.sh", bash_proj,
+ "--json", "--dry-run", description, env_extra=env_extra,
+ )
+ p = _run_py(
+ "create-new-feature-branch", py_proj,
+ "--json", "--dry-run", description, env_extra=env_extra,
+ )
+ _assert_parity(b, p)
+ branch = json.loads(b.stdout)["BRANCH_NAME"]
+ assert branch.isascii(), branch
+
+ # The run above reaches generate_branch_name. --short-name reaches
+ # clean_branch_name, a separate function carrying its own LC_ALL=C, so
+ # exercise the accented value through both: neither copy can then
+ # regress on its own without a failure here.
+ sn_bash_proj, sn_py_proj = _twin_projects(tmp_path / "short")
+ sb = _run_bash(
+ "create-new-feature-branch.sh", sn_bash_proj,
+ "--json", "--dry-run", "--short-name", description, "desc",
+ env_extra=env_extra,
+ )
+ sp = _run_py(
+ "create-new-feature-branch", sn_py_proj,
+ "--json", "--dry-run", "--short-name", description, "desc",
+ env_extra=env_extra,
+ )
+ _assert_parity(sb, sp)
+ short_branch = json.loads(sb.stdout)["BRANCH_NAME"]
+ assert short_branch.isascii(), short_branch
+
+ @pytest.mark.parametrize("short_name", ["-n", "-e", "-E"], ids=["n", "e", "E"])
+ def test_dash_prefixed_short_name(self, tmp_path: Path, short_name: str):
+ """A short name that looks like an ``echo`` option is still text.
+
+ clean_branch_name piped the raw value through `echo "$name"`, so bash
+ consumed -n/-e/-E as options and emitted nothing, yielding a suffix-less
+ 001- where Python yields 001-n. This extension duplicates the
+ implementation, so it needs its own regression case: the core script's
+ test cannot catch a revert to `echo` here.
+ """
+ bash_proj, py_proj = _twin_projects(tmp_path)
+ args = ("--json", "--dry-run", "--short-name", short_name, "desc")
+ b = _run_bash("create-new-feature-branch.sh", bash_proj, *args)
+ p = _run_py("create-new-feature-branch", py_proj, *args)
+ _assert_parity(b, p)
+ expected = f"001-{short_name.lstrip('-').lower()}"
+ assert json.loads(b.stdout)["BRANCH_NAME"] == expected
def test_numbering_from_specs_and_branches(self, tmp_path: Path):
bash_proj, py_proj = _twin_projects(tmp_path)
diff --git a/tests/integrations/test_base.py b/tests/integrations/test_base.py
index 5f99961804..713cf75523 100644
--- a/tests/integrations/test_base.py
+++ b/tests/integrations/test_base.py
@@ -353,6 +353,16 @@ def test_extension_command_hyphen(self):
result = IntegrationBase.resolve_command_refs(text, "-")
assert result == "Run /speckit-git-commit to commit."
+ def test_hyphenated_command_dot(self):
+ text = "Run __SPECKIT_COMMAND_AGENT-CONTEXT_UPDATE__ to refresh."
+ result = IntegrationBase.resolve_command_refs(text, ".")
+ assert result == "Run /speckit.agent-context.update to refresh."
+
+ def test_hyphenated_command_hyphen(self):
+ text = "Run __SPECKIT_COMMAND_AGENT-CONTEXT_UPDATE__ to refresh."
+ result = IntegrationBase.resolve_command_refs(text, "-")
+ assert result == "Run /speckit-agent-context-update to refresh."
+
def test_no_placeholders_unchanged(self):
text = "No placeholders here."
assert IntegrationBase.resolve_command_refs(text, ".") == text
@@ -400,6 +410,10 @@ def test_lowercase_placeholder_not_matched(self):
text = "Run __SPECKIT_COMMAND_plan__ to plan."
assert IntegrationBase.resolve_command_refs(text, ".") == text
+ def test_leading_hyphen_not_matched(self):
+ text = "Run __SPECKIT_COMMAND_-PLAN__ to plan."
+ assert IntegrationBase.resolve_command_refs(text, ".") == text
+
def test_placeholder_adjacent_to_text(self):
text = "foo__SPECKIT_COMMAND_PLAN__bar"
result = IntegrationBase.resolve_command_refs(text, ".")
diff --git a/tests/integrations/test_events.py b/tests/integrations/test_events.py
index f74aeaaa36..f5159c1d41 100644
--- a/tests/integrations/test_events.py
+++ b/tests/integrations/test_events.py
@@ -188,6 +188,22 @@ def test_non_utf8_manifest_skipped(self, tmp_path):
assert collect_extension_events(tmp_path) == {}
+ def test_unreadable_manifest_skipped(self, tmp_path, monkeypatch):
+ ext_dir = tmp_path / ".specify" / "extensions" / "my-ext"
+ ext_dir.mkdir(parents=True)
+ manifest = ext_dir / "extension.yml"
+ manifest.write_text("events: {}\n", encoding="utf-8")
+ real_read_text = Path.read_text
+
+ def unreadable(path, *args, **kwargs):
+ if path == manifest:
+ raise OSError("simulated read failure")
+ return real_read_text(path, *args, **kwargs)
+
+ monkeypatch.setattr(Path, "read_text", unreadable)
+
+ assert collect_extension_events(tmp_path) == {}
+
def test_event_command_ref_canonicalized_via_manifest(self, tmp_path):
"""R1: events are read from a validated ExtensionManifest, so an
obsolete command ref (e.g. my-ext.boot) is canonicalized
@@ -1340,6 +1356,37 @@ def test_ps_variant_prefixed_with_powershell_launcher(self, tmp_path):
assert argv[1] == "-File"
assert PurePath(argv[2]).as_posix().endswith(".specify/scripts/powershell/boot.ps1")
+ def test_ps_variant_returns_none_when_no_launcher_available(self, tmp_path, monkeypatch):
+ """When NEITHER pwsh nor powershell is on PATH, the resolver must
+ degrade to "no argv" like every other failure branch in this
+ function — not fall back to a bare "pwsh" string, which would make
+ subprocess.run() raise FileNotFoundError instead of the caller's
+ clean "No script found for event command" warning.
+
+ The generated dispatcher's documented stdlib mirror, `_resolve_argv`,
+ already does this correctly (`if not launcher: return None`).
+ """
+ from specify_cli.events import _resolve_event_command_argv
+ import shutil as _shutil
+
+ cmd_dir = tmp_path / ".specify" / "templates" / "commands"
+ cmd_dir.mkdir(parents=True)
+ (cmd_dir / "boot.md").write_text(
+ "---\n"
+ "description: \"Boot\"\n"
+ "scripts:\n"
+ " ps: scripts/powershell/boot.ps1\n"
+ "---\nBody\n",
+ encoding="utf-8",
+ )
+ ps_dir = tmp_path / ".specify" / "scripts" / "powershell"
+ ps_dir.mkdir(parents=True)
+ (ps_dir / "boot.ps1").write_text("exit 0\n", encoding="utf-8")
+
+ monkeypatch.setattr(_shutil, "which", lambda name: None)
+ argv = _resolve_event_command_argv(cmd_dir / "boot.md", tmp_path, None)
+ assert argv is None
+
def test_run_command_executes_with_project_root_cwd(self, tmp_path):
"""R1: the event command runs with cwd set to the project root, not the
caller's arbitrary working directory, so project-relative script logic
diff --git a/tests/integrations/test_integration_catalog.py b/tests/integrations/test_integration_catalog.py
index 87ab98a4d0..c414c3d8ea 100644
--- a/tests/integrations/test_integration_catalog.py
+++ b/tests/integrations/test_integration_catalog.py
@@ -745,6 +745,10 @@ def test_get_hash(self, tmp_path):
desc = IntegrationDescriptor(p)
h = desc.get_hash()
assert h.startswith("sha256:")
+ import hashlib
+ content = p.read_bytes()
+ expected = f"sha256:{hashlib.sha256(content).hexdigest()}"
+ assert h == expected
def test_tools_accessor(self, tmp_path):
data = {**VALID_DESCRIPTOR, "requires": {
diff --git a/tests/integrations/test_integration_docker_agent.py b/tests/integrations/test_integration_docker_agent.py
new file mode 100644
index 0000000000..d962ba978f
--- /dev/null
+++ b/tests/integrations/test_integration_docker_agent.py
@@ -0,0 +1,133 @@
+"""Tests for the Docker Agent integration."""
+
+import pytest
+
+from specify_cli.integrations.docker_agent import DockerAgentIntegration
+
+from .test_integration_base_skills import SkillsIntegrationTests
+
+
+class TestDockerAgentIntegration(SkillsIntegrationTests):
+ KEY = "docker-agent"
+ FOLDER = ".agents/"
+ COMMANDS_SUBDIR = "skills"
+ REGISTRAR_DIR = ".agents/skills"
+
+ def test_multi_install_is_opt_in(self):
+ assert DockerAgentIntegration().multi_install_safe is False
+
+
+def test_extra_args_are_applied_to_build_exec_args(monkeypatch):
+ monkeypatch.setenv(
+ "SPECKIT_INTEGRATION_DOCKER_AGENT_EXTRA_ARGS",
+ "./agent.yaml --agent root --model openai/gpt-5",
+ )
+ monkeypatch.setattr(
+ "shutil.which",
+ lambda name: "/usr/bin/docker" if name == "docker" else None,
+ )
+ monkeypatch.setattr("subprocess.run", lambda *args, **kwargs: type("Result", (), {"returncode": 0})())
+
+ args = DockerAgentIntegration().build_exec_args("prompt", output_json=False)
+
+ assert args == [
+ "docker",
+ "agent",
+ "run",
+ "--exec",
+ "./agent.yaml",
+ "--agent",
+ "root",
+ "--model",
+ "openai/gpt-5",
+ "--",
+ "prompt",
+ ]
+
+
+def test_prompt_is_passed_after_agent_config(monkeypatch):
+ monkeypatch.setenv(
+ "SPECKIT_INTEGRATION_DOCKER_AGENT_EXTRA_ARGS", "./agent.yaml"
+ )
+ monkeypatch.setattr(
+ "shutil.which",
+ lambda name: "/usr/bin/docker" if name == "docker" else None,
+ )
+ monkeypatch.setattr("subprocess.run", lambda *args, **kwargs: type("Result", (), {"returncode": 0})())
+
+ args = DockerAgentIntegration().build_exec_args(
+ "/speckit-specify prompt", output_json=False
+ )
+
+ assert args == [
+ "docker",
+ "agent",
+ "run",
+ "--exec",
+ "./agent.yaml",
+ "--",
+ "/speckit-specify prompt",
+ ]
+
+
+def test_prompt_starting_with_flag_is_delimited(monkeypatch):
+ monkeypatch.setenv("SPECKIT_INTEGRATION_DOCKER_AGENT_EXTRA_ARGS", "./agent.yaml")
+ monkeypatch.setattr("shutil.which", lambda name: None)
+
+ args = DockerAgentIntegration().build_exec_args("--help", output_json=False)
+
+ assert args == ["docker-agent", "run", "--exec", "./agent.yaml", "--", "--help"]
+
+
+def test_requires_agent_config(monkeypatch):
+ monkeypatch.delenv("SPECKIT_INTEGRATION_DOCKER_AGENT_EXTRA_ARGS", raising=False)
+ with pytest.raises(ValueError, match="requires an agent configuration reference"):
+ DockerAgentIntegration().build_exec_args("prompt", output_json=False)
+
+
+def test_uses_standalone_executable(monkeypatch):
+ monkeypatch.setenv("SPECKIT_INTEGRATION_DOCKER_AGENT_EXTRA_ARGS", "./agent.yaml")
+ monkeypatch.setattr(
+ "shutil.which",
+ lambda name: "/usr/bin/docker-agent" if name == "docker-agent" else None,
+ )
+
+ args = DockerAgentIntegration().build_exec_args("prompt", output_json=False)
+
+ assert args == ["docker-agent", "run", "--exec", "./agent.yaml", "--", "prompt"]
+
+
+def test_standalone_executable_has_priority(monkeypatch):
+ monkeypatch.setenv("SPECKIT_INTEGRATION_DOCKER_AGENT_EXTRA_ARGS", "./agent.yaml")
+ monkeypatch.setattr("shutil.which", lambda name: "/usr/bin/docker-agent")
+
+ args = DockerAgentIntegration().build_exec_args("prompt", output_json=False)
+
+ assert args == ["docker-agent", "run", "--exec", "./agent.yaml", "--", "prompt"]
+
+
+def test_executable_override(monkeypatch):
+ monkeypatch.setenv("SPECKIT_INTEGRATION_DOCKER_AGENT_EXTRA_ARGS", "./agent.yaml")
+ monkeypatch.setenv(
+ "SPECKIT_INTEGRATION_DOCKER_AGENT_EXECUTABLE", "/opt/docker-agent"
+ )
+
+ args = DockerAgentIntegration().build_exec_args("prompt", output_json=False)
+
+ assert args == ["/opt/docker-agent", "run", "--exec", "./agent.yaml", "--", "prompt"]
+
+
+def test_docker_executable_override_uses_agent_subcommand(monkeypatch):
+ monkeypatch.setenv("SPECKIT_INTEGRATION_DOCKER_AGENT_EXTRA_ARGS", "./agent.yaml")
+ monkeypatch.setenv(
+ "SPECKIT_INTEGRATION_DOCKER_AGENT_EXECUTABLE", "/opt/docker"
+ )
+
+ monkeypatch.setattr(
+ "subprocess.run",
+ lambda *args, **kwargs: type("Result", (), {"returncode": 0})(),
+ )
+
+ args = DockerAgentIntegration().build_exec_args("prompt", output_json=False)
+
+ assert args == ["/opt/docker", "agent", "run", "--exec", "./agent.yaml", "--", "prompt"]
diff --git a/tests/integrations/test_integration_dsh.py b/tests/integrations/test_integration_dsh.py
new file mode 100644
index 0000000000..4a772c826e
--- /dev/null
+++ b/tests/integrations/test_integration_dsh.py
@@ -0,0 +1,314 @@
+"""Tests for DshIntegration (DeepSeek Harness)."""
+
+import json
+
+import pytest
+from typer.testing import CliRunner
+
+from specify_cli import app
+from specify_cli.integrations import get_integration
+from specify_cli.integrations.manifest import IntegrationManifest
+
+from .test_integration_base_skills import SkillsIntegrationTests
+
+
+class TestDshIntegration(SkillsIntegrationTests):
+ KEY = "dsh"
+ FOLDER = ".dsh/"
+ COMMANDS_SUBDIR = "skills"
+ REGISTRAR_DIR = ".dsh/skills"
+
+ def test_options_include_skills_flag(self):
+ """Not applicable to DSH — DSH is always skills-based with no --skills flag."""
+ pytest.skip("DSH is always skills-based and does not expose a --skills option")
+
+ def test_options_do_not_include_skills_flag(self):
+ """DSH is always skills-based; no --skills option is exposed."""
+ i = get_integration(self.KEY)
+ assert i is not None
+ opts = i.options()
+ skills_opts = [o for o in opts if o.name == "--skills"]
+ assert len(skills_opts) == 0, (
+ "DSH is always skills-based and should not expose a --skills option"
+ )
+
+
+class TestDshBuildExecArgs:
+ """Regression tests for DshIntegration.build_exec_args.
+
+ DSH's one-shot mode is ``dsh --profile headless ""``. The CLI has
+ no structured-output or model flag, so ``output_json``/``model`` must
+ not add anything, and the integration must stay CLI-dispatchable
+ (``None`` is the IDE-only sentinel checked by CommandStep).
+ """
+
+ def test_returns_args_not_none_for_dispatch(self):
+ """DSH is CLI-dispatchable; build_exec_args must not return None."""
+ from specify_cli.integrations.dsh import DshIntegration
+
+ impl = DshIntegration()
+ args = impl.build_exec_args("/speckit-specify build photo albums")
+ assert args is not None, (
+ "DshIntegration.build_exec_args must not return None. "
+ "None is the codebase sentinel for IDE-only integrations; "
+ "DSH is dispatchable via 'dsh --profile headless'."
+ )
+ assert args == [
+ "dsh",
+ "--profile",
+ "headless",
+ "/speckit-specify build photo albums",
+ ]
+
+ def test_output_json_and_model_do_not_change_command_line(self):
+ """DSH has no --output-format/--model flags for the headless profile."""
+ from specify_cli.integrations.dsh import DshIntegration
+
+ impl = DshIntegration()
+ base = impl.build_exec_args("hello")
+ assert impl.build_exec_args("hello", output_json=True) == base
+ assert impl.build_exec_args("hello", output_json=False) == base
+ assert impl.build_exec_args("hello", model="deepseek-chat") == base
+
+ def test_extra_args_precede_headless_task(self, monkeypatch):
+ """Launcher options must appear before DSH's task positional."""
+ from specify_cli.integrations.dsh import DshIntegration
+
+ monkeypatch.setenv(
+ "SPECKIT_INTEGRATION_DSH_EXTRA_ARGS", "--patch custom.yml"
+ )
+
+ assert DshIntegration().build_exec_args("/speckit-plan ship it") == [
+ "dsh",
+ "--profile",
+ "headless",
+ "--patch",
+ "custom.yml",
+ "/speckit-plan ship it",
+ ]
+
+
+class TestDshInitFlow:
+ """--integration dsh creates expected files."""
+
+ def test_integration_dsh_creates_skills(self, tmp_path):
+ """--integration dsh should create skills in .dsh/skills."""
+ runner = CliRunner()
+ target = tmp_path / "test-proj"
+ result = runner.invoke(
+ app,
+ ["init", str(target), "--integration", "dsh", "--ignore-agent-tools", "--script", "sh"],
+ )
+
+ assert result.exit_code == 0, f"init --integration dsh failed: {result.output}"
+ assert (target / ".dsh" / "skills" / "speckit-plan" / "SKILL.md").exists()
+
+
+class TestDshNextSteps:
+ """CLI output tests for DSH next-steps display."""
+
+ def test_init_next_steps_show_dsh_skill_guidance(self, tmp_path):
+ """init --integration dsh should guide users to .dsh/skills and /speckit-*."""
+ runner = CliRunner()
+ target = tmp_path / "dsh-next-steps"
+ result = runner.invoke(
+ app,
+ [
+ "init",
+ str(target),
+ "--integration",
+ "dsh",
+ "--ignore-agent-tools",
+ "--script",
+ "sh",
+ ],
+ catch_exceptions=False,
+ )
+
+ assert result.exit_code == 0, f"init --integration dsh failed: {result.output}"
+ assert "Start DSH" in result.output, (
+ f"Expected DSH start guidance in next steps but got:\n{result.output}"
+ )
+ assert "dsh web" in result.output, (
+ f"Expected the 'dsh web' launch command in next steps but got:\n{result.output}"
+ )
+ assert ".dsh/skills" in result.output, (
+ f"Expected .dsh/skills install path in next steps but got:\n{result.output}"
+ )
+ assert "/speckit-plan" in result.output, (
+ f"Expected /speckit-plan in next steps but got:\n{result.output}"
+ )
+ assert "/speckit.plan" not in result.output, (
+ f"Should not show /speckit.plan for DSH skills mode:\n{result.output}"
+ )
+
+
+class TestDshSkillCompatibility:
+ """DSH-specific invariants the generated skills must satisfy.
+
+ The DSH filesystem skill provider discovers one-level-deep
+ ``/SKILL.md`` bundles and parses the frontmatter as an open YAML
+ object, requiring a kebab-case ``name`` and a ``description``; extra
+ keys (``compatibility``, ``metadata``) are tolerated. These tests pin
+ the properties DSH relies on so a template change cannot silently
+ break discovery.
+ """
+
+ def _setup_skills(self, tmp_path):
+ integration = get_integration("dsh")
+ manifest = IntegrationManifest("dsh", tmp_path)
+ integration.setup(tmp_path, manifest, script_type="sh")
+ return tmp_path / ".dsh" / "skills"
+
+ def test_skill_names_are_kebab_case(self, tmp_path):
+ import re
+
+ skills_dir = self._setup_skills(tmp_path)
+ skill_dirs = [d for d in skills_dir.iterdir() if d.is_dir()]
+ assert skill_dirs, "no skill directories were created"
+ for skill_dir in skill_dirs:
+ assert re.fullmatch(r"[a-z0-9]+(-[a-z0-9]+)*", skill_dir.name), (
+ f"skill directory {skill_dir.name!r} is not kebab-case; "
+ "DSH rejects non-kebab-case skill names"
+ )
+
+ def test_skill_frontmatter_has_name_and_description(self, tmp_path):
+ import yaml
+
+ skills_dir = self._setup_skills(tmp_path)
+ for skill_dir in sorted(skills_dir.iterdir()):
+ skill_file = skill_dir / "SKILL.md"
+ assert skill_file.exists(), f"missing SKILL.md in {skill_dir}"
+ content = skill_file.read_text(encoding="utf-8")
+ assert content.startswith("---\n"), f"{skill_file} missing frontmatter"
+ lines = content.splitlines(keepends=True)
+ close = next(
+ i for i in range(1, len(lines)) if lines[i].rstrip() == "---"
+ )
+ frontmatter = yaml.safe_load("".join(lines[1:close]))
+ assert isinstance(frontmatter, dict)
+ # DSH requires a non-empty name matching the bundle directory and
+ # a non-empty description for its model-facing skill catalog.
+ assert frontmatter.get("name") == skill_dir.name
+ assert isinstance(frontmatter.get("description"), str)
+ assert frontmatter["description"].strip()
+
+ def test_skill_definition_is_one_level_deep(self, tmp_path):
+ """DSH discovery only recognizes //SKILL.md — the
+ SKILL.md file must sit directly inside a single skill directory,
+ not in nested subdirectories."""
+ skills_dir = self._setup_skills(tmp_path)
+ for skill_dir in sorted(skills_dir.iterdir()):
+ if not skill_dir.is_dir():
+ continue
+ assert (skill_dir / "SKILL.md").is_file()
+
+
+class TestDshMultiInstallSafe:
+ """DSH confines itself to an isolated ``.dsh/`` root that no other
+ integration touches, so it must be declared multi-install safe."""
+
+ def test_multi_install_safe_is_true(self):
+ integration = get_integration("dsh")
+ assert integration.multi_install_safe is True
+
+ def test_dsh_root_does_not_overlap_other_safe_integrations(self):
+ from pathlib import PurePosixPath
+
+ from specify_cli.integrations import INTEGRATION_REGISTRY
+
+ dsh_root = PurePosixPath(".dsh")
+ for key, integration in INTEGRATION_REGISTRY.items():
+ if key == "dsh" or not integration.multi_install_safe:
+ continue
+ folder = (integration.config or {}).get("folder")
+ if not folder:
+ continue
+ other = PurePosixPath(str(folder).rstrip("/"))
+ for left, right in ((dsh_root, other), (other, dsh_root)):
+ try:
+ left.relative_to(right)
+ except ValueError:
+ continue
+ raise AssertionError(
+ f"dsh agent root .dsh overlaps multi-install-safe "
+ f"integration {key!r} root {other}"
+ )
+
+
+class TestDshHookInvocations:
+ """DSH is in ALWAYS_SLASH_AGENTS: hook messages and init output must
+ reference slash-invokable skills regardless of the persisted ai_skills
+ flag, because the DSH Web GUI invokes skills as ``/speckit-``."""
+
+ def test_hooks_render_skill_invocation(self, tmp_path):
+ from specify_cli.extensions import HookExecutor
+
+ project = tmp_path / "dsh-hooks"
+ project.mkdir()
+ init_options = project / ".specify" / "init-options.json"
+ init_options.parent.mkdir(parents=True, exist_ok=True)
+ init_options.write_text(json.dumps({"ai": "dsh", "ai_skills": False}))
+
+ hook_executor = HookExecutor(project)
+ message = hook_executor.format_hook_message(
+ "before_plan",
+ [
+ {
+ "extension": "test-ext",
+ "command": "speckit.plan",
+ "optional": False,
+ },
+ ],
+ )
+
+ assert "EXECUTE_COMMAND_INVOCATION: /speckit-plan" in message
+
+ def test_init_persists_ai_skills_for_dsh(self, tmp_path, monkeypatch):
+ """specify init --integration dsh must persist ai_skills: true,
+ so HookExecutor renders slash-skill invocations."""
+ from specify_cli.extensions import HookExecutor
+
+ project = tmp_path / "dsh-init-test"
+ project.mkdir()
+ monkeypatch.chdir(project)
+ runner = CliRunner()
+ result = runner.invoke(
+ app,
+ [
+ "init",
+ "--here",
+ "--integration",
+ "dsh",
+ "--script",
+ "sh",
+ "--ignore-agent-tools",
+ ],
+ catch_exceptions=False,
+ )
+
+ assert result.exit_code == 0, f"init failed: {result.output}"
+
+ opts_path = project / ".specify" / "init-options.json"
+ assert opts_path.exists()
+ opts = json.loads(opts_path.read_text(encoding="utf-8"))
+ assert opts.get("ai") == "dsh"
+ assert opts.get("ai_skills") is True, (
+ f"init must persist ai_skills=true for DSH, got: {opts.get('ai_skills')}"
+ )
+
+ hook_executor = HookExecutor(project)
+ message = hook_executor.format_hook_message(
+ "before_plan",
+ [
+ {
+ "extension": "test-ext",
+ "command": "speckit.plan",
+ "optional": False,
+ },
+ ],
+ )
+ assert "Executing: `/speckit-plan`" in message, (
+ "Hook rendering must produce /speckit-plan for DSH"
+ )
+ assert "EXECUTE_COMMAND_INVOCATION: /speckit-plan" in message
diff --git a/tests/integrations/test_integration_muse.py b/tests/integrations/test_integration_muse.py
new file mode 100644
index 0000000000..eb6f3d9087
--- /dev/null
+++ b/tests/integrations/test_integration_muse.py
@@ -0,0 +1,80 @@
+"""Tests for MuseIntegration — skills-based integration (Meta Muse Code)."""
+
+from .test_integration_base_skills import SkillsIntegrationTests
+
+
+class TestMuseIntegration(SkillsIntegrationTests):
+ KEY = "muse"
+ FOLDER = ".agents/"
+ COMMANDS_SUBDIR = "skills"
+ REGISTRAR_DIR = ".agents/skills"
+
+
+class TestMuseMetadata:
+ """Muse Code-specific metadata and headless dispatch."""
+
+ def test_display_name(self):
+ from specify_cli.integrations import get_integration
+
+ assert get_integration("muse").config["name"] == "Muse Code"
+
+ def test_requires_cli(self):
+ from specify_cli.integrations import get_integration
+
+ assert get_integration("muse").config["requires_cli"] is True
+
+ def test_install_url_points_to_muse_docs(self):
+ from specify_cli.integrations import get_integration
+
+ assert (
+ get_integration("muse").config["install_url"]
+ == "https://dev.meta.ai/docs/muse-code"
+ )
+
+ def test_not_multi_install_safe(self):
+ """Muse Code shares ``.agents/skills`` with Codex/Zed, so co-install
+ stays opt-in (same policy as the docker-agent integration)."""
+ from specify_cli.integrations import get_integration
+
+ assert get_integration("muse").multi_install_safe is False
+
+ def test_build_exec_args_uses_muse_exec(self):
+ from specify_cli.integrations import get_integration
+
+ args = get_integration("muse").build_exec_args("do the thing")
+ assert args[:3] == ["muse", "exec", "do the thing"]
+ assert args[-1] == "--json"
+
+ def test_build_exec_args_model_flag(self):
+ from specify_cli.integrations import get_integration
+
+ args = get_integration("muse").build_exec_args(
+ "do the thing", model="muse-spark-1.2", output_json=False
+ )
+ assert "--model" in args
+ assert args[args.index("--model") + 1] == "muse-spark-1.2"
+ assert "--json" not in args
+
+ def test_next_steps_show_slash_skill_invocation(self, tmp_path):
+ """Muse Code next-steps guidance should display /speckit-* usage."""
+ import os
+ from typer.testing import CliRunner
+ from specify_cli import app
+
+ project = tmp_path / "muse-next-steps"
+ project.mkdir()
+ old_cwd = os.getcwd()
+ try:
+ os.chdir(project)
+ runner = CliRunner()
+ result = runner.invoke(app, [
+ "init", "--here", "--integration", "muse",
+ "--ignore-agent-tools", "--script", "sh",
+ ], catch_exceptions=False)
+ finally:
+ os.chdir(old_cwd)
+
+ assert result.exit_code == 0
+ assert "/speckit-constitution" in result.output
+ assert "/speckit.constitution" not in result.output
+ assert "Muse Code" in result.output
diff --git a/tests/integrations/test_integration_qodercli.py b/tests/integrations/test_integration_qodercli.py
index 29a6d16d29..f30f62cae0 100644
--- a/tests/integrations/test_integration_qodercli.py
+++ b/tests/integrations/test_integration_qodercli.py
@@ -1,10 +1,39 @@
"""Tests for QodercliIntegration."""
-from .test_integration_base_markdown import MarkdownIntegrationTests
+import pytest
+from specify_cli.integrations import get_integration
-class TestQodercliIntegration(MarkdownIntegrationTests):
+from .test_integration_base_skills import SkillsIntegrationTests
+
+
+class TestQodercliIntegration(SkillsIntegrationTests):
KEY = "qodercli"
FOLDER = ".qoder/"
- COMMANDS_SUBDIR = "commands"
- REGISTRAR_DIR = ".qoder/commands"
+ COMMANDS_SUBDIR = "skills"
+ REGISTRAR_DIR = ".qoder/skills"
+
+ def test_options_include_skills_flag(self):
+ """Not applicable — Qoder IDE 1.24+ is always skills-based."""
+ pytest.skip(
+ "Qoder is always skills-based and does not expose a --skills option"
+ )
+
+ def test_options_do_not_include_skills_flag(self):
+ """Qoder is always skills-based; no --skills option is exposed."""
+ i = get_integration(self.KEY)
+ assert i is not None
+ opts = i.options()
+ skills_opts = [o for o in opts if o.name == "--skills"]
+ assert len(skills_opts) == 0, (
+ "Qoder is always skills-based and should not expose a --skills option"
+ )
+
+ def test_requires_cli_is_true(self):
+ """Qoder CLI is a CLI-based agent; requires_cli must remain True."""
+ i = get_integration(self.KEY)
+ assert i is not None
+ assert i.config is not None
+ assert i.config["requires_cli"] is True
+ assert i.config["name"] == "Qoder CLI"
+ assert i.multi_install_safe is True
diff --git a/tests/integrations/test_integration_rovodev.py b/tests/integrations/test_integration_rovodev.py
index 5bdafc25f9..5e36c7551f 100644
--- a/tests/integrations/test_integration_rovodev.py
+++ b/tests/integrations/test_integration_rovodev.py
@@ -155,6 +155,45 @@ def test_prompt_wrapper_format(self, tmp_path):
f"{prompt_file} has unexpected wrapper format"
)
+ @pytest.mark.parametrize(
+ "bad_name",
+ [["speckit-plan", "speckit-tasks"], {"a": 1}],
+ ids=["sequence", "mapping"],
+ )
+ def test_prompts_manifest_merge_tolerates_non_scalar_name(
+ self, tmp_path, bad_name
+ ):
+ """An unhashable `name` in a user-edited prompts.yml must not crash setup.
+
+ `_read_prompts_yml` only filters at the entry level, never validating
+ the entry's `name`, so a YAML sequence or mapping there reached a dict
+ membership test and raised a raw `TypeError: unhashable type` out of
+ `setup()` — aborting every `specify init` / `integration install` for
+ rovodev on that project and leaving prompts.yml unwritten.
+ """
+ impl = get_integration(self.KEY)
+ manifest = IntegrationManifest(self.KEY, tmp_path)
+
+ prompts_manifest = tmp_path / ".rovodev" / "prompts.yml"
+ prompts_manifest.parent.mkdir(parents=True, exist_ok=True)
+ prompts_manifest.write_text(
+ yaml.safe_dump(
+ {"prompts": [{"name": bad_name, "content_file": "prompts/x.md"}]}
+ ),
+ encoding="utf-8",
+ )
+
+ impl.setup(tmp_path, manifest, script_type="sh")
+
+ data = yaml.safe_load(prompts_manifest.read_text(encoding="utf-8"))
+ names = [entry.get("name") for entry in data["prompts"]]
+ # The malformed user entry is preserved verbatim...
+ assert bad_name in names, names
+ # ...and the generated entries were still written.
+ assert any(
+ isinstance(n, str) and n.startswith("speckit-") for n in names
+ ), names
+
def test_prompts_manifest_merge_preserves_user_entries(self, tmp_path):
impl = get_integration(self.KEY)
manifest = IntegrationManifest(self.KEY, tmp_path)
diff --git a/tests/integrations/test_integration_subcommand.py b/tests/integrations/test_integration_subcommand.py
index 994fecb148..eaeecc6740 100644
--- a/tests/integrations/test_integration_subcommand.py
+++ b/tests/integrations/test_integration_subcommand.py
@@ -3153,6 +3153,66 @@ def test_upgrade_migrates_kilocode_legacy_dir(self, tmp_path):
f"after upgrade, found: {[f.name for f in core_remaining]}"
)
+ def test_upgrade_migrates_qodercli_extension_commands_to_skills(self, tmp_path):
+ """Qoder upgrade retires old extension commands after skills exist."""
+ project = _init_project(tmp_path, "qodercli")
+ result = _run_in_project(project, ["extension", "add", "git"])
+ assert result.exit_code == 0, f"extension add failed: {result.output}"
+
+ skills = project / ".qoder" / "skills"
+ commands = project / ".qoder" / "commands"
+ commands.mkdir(parents=True)
+
+ manifest_path = (
+ project / ".specify" / "integrations" / "qodercli.manifest.json"
+ )
+ manifest_data = json.loads(manifest_path.read_text(encoding="utf-8"))
+ legacy_manifest_files = {}
+ for path, info in manifest_data["files"].items():
+ skill_path = project / path
+ command_name = skill_path.parent.name.replace("speckit-", "speckit.", 1)
+ legacy_path = commands / f"{command_name}.md"
+ legacy_path.write_bytes(skill_path.read_bytes())
+ legacy_manifest_files[
+ legacy_path.relative_to(project).as_posix()
+ ] = info
+ manifest_data["files"] = legacy_manifest_files
+ manifest_path.write_text(json.dumps(manifest_data), encoding="utf-8")
+
+ registry_path = project / ".specify" / "extensions" / ".registry"
+ registry = json.loads(registry_path.read_text(encoding="utf-8"))
+ git_metadata = registry["extensions"]["git"]
+ registered_commands = git_metadata["registered_commands"]["qodercli"]
+ for command_name in registered_commands:
+ skill_name = command_name.replace("speckit.", "speckit-", 1).replace(
+ ".", "-"
+ )
+ old_command = commands / f"{command_name}.md"
+ old_command.write_bytes(
+ (skills / skill_name / "SKILL.md").read_bytes()
+ )
+ missing_replacement = commands / "speckit.git.missing.md"
+ missing_replacement.write_text("# preserve until replaced\n", encoding="utf-8")
+ registered_commands.append("speckit.git.missing")
+ git_metadata["registered_skills"] = []
+ registry_path.write_text(json.dumps(registry), encoding="utf-8")
+
+ shutil.rmtree(skills)
+ result = _run_in_project(project, [
+ "integration", "upgrade", "qodercli", "--script", "sh", "--force",
+ ])
+ assert result.exit_code == 0, f"upgrade failed: {result.output}"
+
+ for command_name in registered_commands[:-1]:
+ skill_name = command_name.replace("speckit.", "speckit-", 1).replace(
+ ".", "-"
+ )
+ assert (skills / skill_name / "SKILL.md").is_file()
+ assert not (commands / f"{command_name}.md").exists()
+ assert missing_replacement.is_file(), (
+ "a legacy command must remain when no replacement skill was written"
+ )
+
def test_upgrade_kilocode_legacy_dir_rejects_installed_preset_overrides(
self, tmp_path
):
diff --git a/tests/integrations/test_integration_zed.py b/tests/integrations/test_integration_zed.py
index 23627d316d..1a55c9ae87 100644
--- a/tests/integrations/test_integration_zed.py
+++ b/tests/integrations/test_integration_zed.py
@@ -143,6 +143,8 @@ def _render_invocation(project_path, ai: str, ai_skills: bool) -> str:
("devin", False, "/speckit-plan"),
("grok", True, "/speckit-plan"),
("grok", False, "/speckit-plan"),
+ ("qodercli", True, "/speckit-plan"),
+ ("qodercli", False, "/speckit-plan"),
("trae", True, "/speckit-plan"),
("trae", False, "/speckit-plan"),
("zed", True, "/speckit-plan"),
diff --git a/tests/integrations/test_registry.py b/tests/integrations/test_registry.py
index 0d0a724bd8..590b9edf92 100644
--- a/tests/integrations/test_registry.py
+++ b/tests/integrations/test_registry.py
@@ -28,7 +28,7 @@
"gemini", "tabnine",
# Stage 5 — skills, generic & option-driven integrations
"codex", "kimi", "agy", "zed", "generic",
- "droid", "command-code",
+ "droid", "command-code", "dsh", "muse",
]
diff --git a/tests/parity_helpers.py b/tests/parity_helpers.py
index 27627dab5b..5ab3fcfa0d 100644
--- a/tests/parity_helpers.py
+++ b/tests/parity_helpers.py
@@ -82,9 +82,49 @@ def clean_env() -> dict[str, str]:
return env
+def collation_range_locale() -> str | None:
+ """A locale whose ``[a-z]`` bracket range is collation-ordered, or ``None``.
+
+ glibc resolves a bracket-expression *range* through the locale's collation
+ table, so under ``en_US.UTF-8`` ``[^a-z0-9]`` leaves accented lowercase
+ letters alone while ``C.UTF-8`` and the POSIX locale strip them. Probe
+ ``sed`` directly rather than trusting a locale name: the environments where
+ the divergence cannot be reproduced (no such locale installed, a non-glibc
+ libc, Git-for-Windows) are exactly the ones where the probe comes back
+ clean, so the caller can skip.
+ """
+ for name in ("en_US.UTF-8", "en_US.utf8"):
+ env = clean_env()
+ env["LC_ALL"] = name
+ env["LANG"] = name
+ try:
+ probe = subprocess.run(
+ ["sed", "s/[^a-z0-9]/-/g"],
+ input="é\n",
+ capture_output=True,
+ text=True,
+ check=False,
+ env=env,
+ )
+ except OSError: # pragma: no cover - sed missing entirely
+ return None
+ if probe.returncode == 0 and "é" in probe.stdout:
+ return name
+ return None
+
+
def run(
- cmd: list[str], repo: Path, env: dict[str, str] | None = None
+ cmd: list[str],
+ repo: Path,
+ env: dict[str, str] | None = None,
+ timeout: float | None = None,
) -> subprocess.CompletedProcess[str]:
+ """Run a script variant.
+
+ ``timeout`` guards cases whose regression mode is a hang rather than a bad
+ value; without it such a failure would stall the suite instead of failing
+ it. ``subprocess.TimeoutExpired`` propagates so the test reports the hang.
+ """
return subprocess.run(
cmd,
cwd=repo,
@@ -92,6 +132,7 @@ def run(
text=True,
check=False,
env=env if env is not None else clean_env(),
+ timeout=timeout,
)
diff --git a/tests/test_agent_config_consistency.py b/tests/test_agent_config_consistency.py
index 0cebe7bc33..6ca4de9c6d 100644
--- a/tests/test_agent_config_consistency.py
+++ b/tests/test_agent_config_consistency.py
@@ -24,7 +24,9 @@
"command-code",
"cursor-agent",
"devin",
+ "docker-agent",
"droid",
+ "dsh",
"firebender",
"forge",
"gemini",
@@ -39,6 +41,7 @@
"kiro-cli",
"lingma",
"vibe",
+ "muse",
"omp",
"opencode",
"pi",
diff --git a/tests/test_authentication.py b/tests/test_authentication.py
index 6711334a93..38e12edd5f 100644
--- a/tests/test_authentication.py
+++ b/tests/test_authentication.py
@@ -205,7 +205,7 @@ def test_multiple_entries(self, tmp_path):
def test_invalid_json_raises(self, tmp_path):
cfg = tmp_path / "auth.json"
cfg.write_text("not json")
- with pytest.raises(json.JSONDecodeError):
+ with pytest.raises(ValueError, match="invalid JSON"):
load_auth_config(cfg)
def test_not_object_raises(self, tmp_path):
@@ -345,7 +345,7 @@ def test_world_readable_warns(self, tmp_path):
class TestFindEntriesForUrl:
def test_exact_match(self):
entry = _github_entry()
- result = find_entries_for_url("https://github.com/org/repo", [entry])
+ result = find_entries_for_url("https://github.com:443/org/repo", [entry])
assert result == [entry]
def test_wildcard_match(self):
@@ -409,10 +409,12 @@ def test_empty_url_returns_empty(self):
[
"https://[::1", # unterminated ipv6 bracket
"https://[not-an-ip]/file", # bracketed non-ip host
+ "https://github.com:notaport/x", # non-numeric port
+ "https://github.com:99999/x", # out-of-range port
],
)
def test_malformed_url_returns_empty(self, url):
- # A malformed authority makes urlparse/hostname raise ValueError.
+ # A malformed authority makes urlparse, hostname, or port raise ValueError.
# Since no entry can match such a URL, this must return no matches
# (like a host-less URL) rather than leaking a raw ValueError out of
# the shared HTTP client.
diff --git a/tests/test_check_prerequisites_paths_only.py b/tests/test_check_prerequisites_paths_only.py
index 3331cf92e4..dc2ee23f8f 100644
--- a/tests/test_check_prerequisites_paths_only.py
+++ b/tests/test_check_prerequisites_paths_only.py
@@ -38,7 +38,7 @@ def _write_feature_json(
repo: Path, feature_directory: str = "specs/001-my-feature"
) -> None:
(repo / ".specify" / "feature.json").write_text(
- json.dumps({"feature_directory": feature_directory}),
+ json.dumps({"feature_directory": feature_directory}, ensure_ascii=False),
encoding="utf-8",
)
@@ -288,6 +288,40 @@ def test_ps_paths_only_succeeds_on_non_spec_branch(prereq_repo: Path) -> None:
assert "FEATURE_DIR" in data
+@pytest.mark.skipif(
+ not _WINDOWS_POWERSHELL, reason="Windows PowerShell 5.1 not available"
+)
+def test_windows_powershell_reads_bomless_utf8_feature_json(
+ prereq_repo: Path,
+) -> None:
+ """Windows PowerShell must decode non-ASCII feature paths as UTF-8 (#4333)."""
+ feature_directory = "specs/001-后台信息架构"
+ feature_path = prereq_repo / feature_directory
+ feature_path.mkdir(parents=True)
+ _write_feature_json(prereq_repo, feature_directory)
+
+ resolved_path = prereq_repo / "resolved-feature-path.txt"
+ common_ps = prereq_repo / ".specify" / "scripts" / "powershell" / "common.ps1"
+ ps_command = (
+ f". '{common_ps}'; "
+ "$resolved = Get-FeaturePathsEnv -NoPersist; "
+ "$utf8NoBom = New-Object System.Text.UTF8Encoding($false); "
+ f"[System.IO.File]::WriteAllText('{resolved_path}', "
+ "[string]$resolved.FEATURE_DIR, $utf8NoBom)"
+ )
+ result = subprocess.run(
+ [_WINDOWS_POWERSHELL, "-NoProfile", "-Command", ps_command],
+ cwd=prereq_repo,
+ capture_output=True,
+ text=True,
+ check=False,
+ env=_clean_env(),
+ )
+
+ assert result.returncode == 0, result.stderr
+ assert resolved_path.read_text(encoding="utf-8") == str(feature_path)
+
+
@pytest.mark.skipif(not (HAS_PWSH or _WINDOWS_POWERSHELL), reason="no PowerShell available")
@pytest.mark.parametrize(
("use_env_var", "specify_feature", "expected_branch"),
diff --git a/tests/test_check_prerequisites_python_parity.py b/tests/test_check_prerequisites_python_parity.py
index b0e74217c0..69372d3f17 100644
--- a/tests/test_check_prerequisites_python_parity.py
+++ b/tests/test_check_prerequisites_python_parity.py
@@ -247,6 +247,78 @@ def test_python_json_output_matches_bash(prereq_repo: Path, args: tuple[str, ...
assert _json_stdout(py) == _json_stdout(bash)
+@requires_bash
+def test_python_require_spec_matches_bash(prereq_repo: Path) -> None:
+ feat = prereq_repo / "specs" / "001-my-feature"
+ feat.mkdir(parents=True)
+ (feat / "plan.md").write_text("# plan\n", encoding="utf-8")
+ (feat / "tasks.md").write_text("# tasks\n", encoding="utf-8")
+ _write_feature_json(prereq_repo)
+
+ # spec.md is missing, and without the flag that stays the caller's problem
+ bash_without = _run(_bash_cmd(prereq_repo, "--json", "--require-tasks"), prereq_repo)
+ py_without = _run(_py_cmd(prereq_repo, "--json", "--require-tasks"), prereq_repo)
+ assert py_without.returncode == bash_without.returncode == 0
+
+ # with the flag both variants fail the same way and name the same command
+ bash_missing = _run(
+ _bash_cmd(prereq_repo, "--json", "--require-spec", "--require-tasks"), prereq_repo
+ )
+ py_missing = _run(
+ _py_cmd(prereq_repo, "--json", "--require-spec", "--require-tasks"), prereq_repo
+ )
+ assert py_missing.returncode == bash_missing.returncode == 1
+ assert py_missing.stderr == bash_missing.stderr
+ assert "spec.md not found" in bash_missing.stderr
+
+ # and once the spec exists the flag is satisfied
+ (feat / "spec.md").write_text("# spec\n", encoding="utf-8")
+ bash_present = _run(
+ _bash_cmd(prereq_repo, "--json", "--require-spec", "--require-tasks"), prereq_repo
+ )
+ py_present = _run(
+ _py_cmd(prereq_repo, "--json", "--require-spec", "--require-tasks"), prereq_repo
+ )
+ assert py_present.returncode == bash_present.returncode == 0
+ assert _json_stdout(py_present) == _json_stdout(bash_present)
+
+
+@pytest.mark.skipif(not (HAS_PWSH or _WINDOWS_POWERSHELL), reason="no PowerShell available")
+def test_powershell_require_spec_matches_python(prereq_repo: Path) -> None:
+ feat = prereq_repo / "specs" / "001-my-feature"
+ feat.mkdir(parents=True)
+ (feat / "plan.md").write_text("# plan\n", encoding="utf-8")
+ (feat / "tasks.md").write_text("# tasks\n", encoding="utf-8")
+ _write_feature_json(prereq_repo)
+
+ # spec.md is missing, and without the flag that stays the caller's problem
+ ps_without = _run(_ps_cmd(prereq_repo, "-Json", "-RequireTasks"), prereq_repo)
+ py_without = _run(_py_cmd(prereq_repo, "--json", "--require-tasks"), prereq_repo)
+ assert ps_without.returncode == py_without.returncode == 0
+
+ # with the flag both variants fail the same way and name the same file
+ ps_missing = _run(
+ _ps_cmd(prereq_repo, "-Json", "-RequireSpec", "-RequireTasks"), prereq_repo
+ )
+ py_missing = _run(
+ _py_cmd(prereq_repo, "--json", "--require-spec", "--require-tasks"), prereq_repo
+ )
+ assert ps_missing.returncode == py_missing.returncode == 1
+ assert "spec.md not found" in ps_missing.stderr
+ assert "spec.md not found" in py_missing.stderr
+
+ # and once the spec exists the flag is satisfied and the payloads agree
+ (feat / "spec.md").write_text("# spec\n", encoding="utf-8")
+ ps_present = _run(
+ _ps_cmd(prereq_repo, "-Json", "-RequireSpec", "-RequireTasks"), prereq_repo
+ )
+ py_present = _run(
+ _py_cmd(prereq_repo, "--json", "--require-spec", "--require-tasks"), prereq_repo
+ )
+ assert ps_present.returncode == py_present.returncode == 0
+ assert _json_stdout(ps_present) == _json_stdout(py_present)
+
+
@requires_bash
def test_python_text_output_matches_bash(prereq_repo: Path) -> None:
feat = prereq_repo / "specs" / "001-my-feature"
diff --git a/tests/test_check_tool.py b/tests/test_check_tool.py
index 9520046168..ef8a9da2fb 100644
--- a/tests/test_check_tool.py
+++ b/tests/test_check_tool.py
@@ -120,6 +120,42 @@ def fake_which(name):
with patch("shutil.which", side_effect=fake_which):
assert check_tool("rovodev") is True
+ def test_docker_agent_plugin_fallback(self):
+ """docker-agent should detect a working Docker CLI plugin form."""
+
+ def fake_which(name):
+ return "/usr/bin/docker" if name == "docker" else None
+
+ with (
+ patch("shutil.which", side_effect=fake_which),
+ patch("subprocess.run") as run,
+ ):
+ run.return_value.returncode = 0
+ assert check_tool("docker-agent") is True
+ run.assert_called_once_with(
+ ["/usr/bin/docker", "agent", "version"],
+ capture_output=True,
+ check=False,
+ timeout=5,
+ )
+
+ def test_docker_agent_missing(self):
+ """docker-agent should be missing when neither form is installed."""
+ with patch("shutil.which", return_value=None):
+ assert check_tool("docker-agent") is False
+
+ def test_docker_agent_plugin_missing(self):
+ """Plain Docker CLI should not count as Docker Agent."""
+ def fake_which(name):
+ return "/usr/bin/docker" if name == "docker" else None
+
+ with (
+ patch("shutil.which", side_effect=fake_which),
+ patch("subprocess.run") as run,
+ ):
+ run.return_value.returncode = 1
+ assert check_tool("docker-agent") is False
+
class TestCheckTip:
"""`specify check` should point users to the existing version check."""
diff --git a/tests/test_create_new_feature_python_parity.py b/tests/test_create_new_feature_python_parity.py
index 41122b1f5f..6cc50d80eb 100644
--- a/tests/test_create_new_feature_python_parity.py
+++ b/tests/test_create_new_feature_python_parity.py
@@ -14,6 +14,8 @@
HAS_POWERSHELL,
bash_cmd,
break_wrap_layer,
+ clean_env,
+ collation_range_locale,
install_composition_stack,
install_scripts,
json_stdout,
@@ -117,8 +119,18 @@ def deny_listing(_path: Path):
"I want to add the new API rate limiting feature for users",
"Fix UI for DB sync",
"a to the of",
+ # An acronym touching an accented letter: bash probes with `grep -qw`
+ # under LC_ALL=C, where the accent is a word boundary, so the Python
+ # twin must use explicit ASCII lookarounds rather than a Unicode \b.
+ "Fix \u00e9DB\u00e9 sync",
+ ],
+ ids=[
+ "plain",
+ "stop_words",
+ "acronyms",
+ "all_stop_words_fallback",
+ "acronym_next_to_non_ascii",
],
- ids=["plain", "stop_words", "acronyms", "all_stop_words_fallback"],
)
def test_python_branch_name_generation_matches_bash(
repo: Path, description: str
@@ -131,6 +143,26 @@ def test_python_branch_name_generation_matches_bash(
assert json_stdout(py) == json_stdout(bash)
+@requires_bash
+@pytest.mark.skipif(not HAS_POWERSHELL, reason="no PowerShell available")
+def test_all_variants_keep_acronym_next_to_non_ascii(repo: Path) -> None:
+ """An acronym touching an accented letter survives in all three twins.
+
+ bash probes for acronyms with `grep -qw` under LC_ALL=C, where an accented
+ letter is a non-word byte and therefore a boundary. Python's \\b and .NET's
+ \\b are Unicode-aware and saw "\u00e9DB\u00e9" as a single word, dropping the
+ acronym; all three now spell the boundary out as ASCII.
+ """
+ description = "Fix \u00e9DB\u00e9 sync"
+ bash = run(bash_cmd(repo, SCRIPT, "--json", "--dry-run", description), repo)
+ py = run(py_cmd(repo, SCRIPT, "--json", "--dry-run", description), repo)
+ ps = run(ps_cmd(repo, SCRIPT, "-Json", "-DryRun", description), repo)
+
+ assert bash.returncode == py.returncode == ps.returncode == 0
+ assert json_stdout(py) == json_stdout(bash) == json_stdout(ps)
+ assert json_stdout(ps)["BRANCH_NAME"] == "001-fix-db-sync"
+
+
@requires_bash
@pytest.mark.parametrize(
"args",
@@ -1065,3 +1097,161 @@ def test_all_variants_corrected_prefix_skips_timestamp_collision(repo: Path) ->
assert json_stdout(py)["FEATURE_NUM"] == "20260320"
for result in (bash, ps, py):
assert "using 20260320 instead" in result.stderr
+
+
+@requires_bash
+@pytest.mark.parametrize(
+ "description",
+ [
+ "Añadir autenticación de usuario",
+ "Prüfung für Benutzer anlegen",
+ "Ajouter la réservation hôtelière",
+ ],
+ ids=["spanish", "german", "french"],
+)
+def test_bash_branch_name_ignores_locale_collation(
+ repo: Path, description: str
+) -> None:
+ """Branch naming must not depend on the caller's locale.
+
+ ``clean_branch_name``/``generate_branch_name`` sanitize with
+ ``sed 's/[^a-z0-9]/-/g'``. Run under a collation-ordered locale that class
+ keeps accented lowercase letters, so bash produced
+ ``001-ajouter-réservation-hôtelière`` where the Python and PowerShell twins
+ produce ``001-ajouter-servation-teli``: the same description yielded a
+ different ``specs/`` directory on two machines that differ only in ``LANG``.
+ """
+ locale_name = collation_range_locale()
+ if locale_name is None:
+ pytest.skip("no locale with collation-ordered [a-z] ranges available")
+
+ env = clean_env()
+ env["LC_ALL"] = locale_name
+ env["LANG"] = locale_name
+
+ bash = run(bash_cmd(repo, SCRIPT, "--json", "--dry-run", description), repo, env)
+ py = run(py_cmd(repo, SCRIPT, "--json", "--dry-run", description), repo, env)
+
+ assert py.returncode == bash.returncode == 0
+ assert json_stdout(py) == json_stdout(bash)
+ branch = json_stdout(bash)["BRANCH_NAME"]
+ assert isinstance(branch, str) and branch.isascii(), branch
+
+ # The run above reaches generate_branch_name. --short-name reaches
+ # clean_branch_name, a separate function carrying its own LC_ALL=C, so
+ # exercise the accented value through both: neither copy can then regress
+ # on its own without a failure here.
+ short_args = ("--json", "--dry-run", "--short-name", description, "x")
+ bash_short = run(bash_cmd(repo, SCRIPT, *short_args), repo, env)
+ py_short = run(py_cmd(repo, SCRIPT, *short_args), repo, env)
+
+ assert py_short.returncode == bash_short.returncode == 0
+ assert json_stdout(py_short) == json_stdout(bash_short)
+ short_branch = json_stdout(bash_short)["BRANCH_NAME"]
+ assert isinstance(short_branch, str) and short_branch.isascii(), short_branch
+
+
+@requires_bash
+@pytest.mark.parametrize(
+ ("short_name", "expected"),
+ [
+ ("My Fancy!! Name", "001-my-fancy-name"),
+ ("auth -- v2", "001-auth-v2"),
+ ],
+ ids=["punctuation_run", "separator_run"],
+)
+def test_bash_collapses_repeated_separators(
+ repo: Path, short_name: str, expected: str
+) -> None:
+ """Runs of non-alphanumeric characters collapse to a single hyphen.
+
+ The bash twin squeezed them with ``sed 's/-\\+/-/g'``. ``\\+`` is a GNU
+ extension, not POSIX BRE: BSD ``sed`` (macOS) reads it as a literal ``+``,
+ so nothing collapsed and the branch became ``001-my-fancy---name``.
+ """
+ bash = run(
+ bash_cmd(repo, SCRIPT, "--json", "--dry-run", "--short-name", short_name, "x"),
+ repo,
+ )
+ py = run(
+ py_cmd(repo, SCRIPT, "--json", "--dry-run", "--short-name", short_name, "x"),
+ repo,
+ )
+
+ assert bash.returncode == py.returncode == 0
+ assert json_stdout(bash) == json_stdout(py)
+ assert json_stdout(bash)["BRANCH_NAME"] == expected
+
+
+@requires_bash
+@pytest.mark.parametrize("short_name", ["-n", "-e", "-E"], ids=["n", "e", "E"])
+def test_python_dash_prefixed_short_name_matches_bash(
+ repo: Path, short_name: str
+) -> None:
+ """A short name that looks like an ``echo`` option is still text.
+
+ ``clean_branch_name`` piped the raw value through ``echo "$name"``, so bash
+ consumed ``-n``/``-e``/``-E`` as options and emitted nothing, yielding the
+ suffix-less ``001-`` where Python yields ``001-n``.
+ """
+ args = ("--json", "--dry-run", "--short-name", short_name, "x")
+ bash = run(bash_cmd(repo, SCRIPT, *args), repo)
+ py = run(py_cmd(repo, SCRIPT, *args), repo)
+
+ assert py.returncode == bash.returncode == 0
+ assert json_stdout(py) == json_stdout(bash)
+ expected = f"001-{short_name.lstrip('-').lower()}"
+ assert json_stdout(bash)["BRANCH_NAME"] == expected
+
+
+@pytest.mark.skipif(not HAS_POWERSHELL, reason="no PowerShell available")
+@pytest.mark.parametrize(
+ "description",
+ ["!!! ??? ***", "добавить", "添加用户"],
+ ids=["punctuation_only", "cyrillic", "han"],
+)
+def test_powershell_survives_description_with_no_ascii_words(
+ tmp_path: Path, description: str
+):
+ """A description with no [a-z0-9] characters must not crash the PS twin.
+
+ ``ConvertTo-CleanBranchName`` blanks every non-ASCII character, so the
+ fallback pipeline yields nothing and ``[string]::Join`` received ``$null``
+ — an ArgumentNullException, made terminating by
+ ``$ErrorActionPreference = 'Stop'``. The script died with a .NET stack
+ trace and exit 1 where the bash and Python twins both return an empty
+ suffix. This fires for any feature phrased in a non-Latin script.
+ """
+ repo = _setup_repo(tmp_path)
+
+ ps = run(ps_cmd(repo, SCRIPT, "-Json", "-DryRun", description), repo)
+
+ assert ps.returncode == 0, ps.stderr
+ assert "ArgumentNullException" not in ps.stderr
+ assert "Join" not in ps.stderr
+ assert json_stdout(ps)["BRANCH_NAME"] == "001-"
+
+
+@requires_bash
+@pytest.mark.skipif(not HAS_POWERSHELL, reason="no PowerShell available")
+def test_no_ascii_word_description_matches_across_twins(tmp_path: Path):
+ """All three twins agree on the branch name for such a description."""
+ description = "добавить"
+
+ bash_repo = _setup_repo(tmp_path, "b")
+ py_repo = _setup_repo(tmp_path, "p")
+ ps_repo = _setup_repo(tmp_path, "s")
+
+ bash = run(bash_cmd(bash_repo, SCRIPT, "--json", "--dry-run", description), bash_repo)
+ py = run(py_cmd(py_repo, SCRIPT, "--json", "--dry-run", description), py_repo)
+ ps = run(ps_cmd(ps_repo, SCRIPT, "-Json", "-DryRun", description), ps_repo)
+
+ assert bash.returncode == py.returncode == ps.returncode == 0, (
+ bash.stderr, py.stderr, ps.stderr,
+ )
+ names = {
+ json_stdout(bash)["BRANCH_NAME"],
+ json_stdout(py)["BRANCH_NAME"],
+ json_stdout(ps)["BRANCH_NAME"],
+ }
+ assert names == {"001-"}, names
diff --git a/tests/test_event_command.py b/tests/test_event_command.py
new file mode 100644
index 0000000000..0a431fb5c6
--- /dev/null
+++ b/tests/test_event_command.py
@@ -0,0 +1,145 @@
+"""`specify event run` must read piped stdin without crashing.
+
+`event_run` (src/specify_cli/commands/event.py) capped its stdin read at 1
+MiB to prevent a DoS (#3857), but the truncation check read a `.eof`
+attribute that does not exist on any Python file-like object (including
+`sys.stdin`) — every piped-stdin invocation raised `AttributeError` instead
+of running, regardless of payload size. Piped stdin is the command's
+documented primary use case (it is how a native hook feeds it a JSON
+payload), so this broke the feature entirely rather than only rejecting
+oversized payloads. Even the intended oversized-payload branch was broken a
+second way: `typer.Exit(code=1, message=...)` — `typer.Exit` accepts no
+`message` keyword argument, so that path raised `TypeError` instead of a
+clean CLI error.
+"""
+
+from __future__ import annotations
+
+from unittest.mock import patch
+
+import pytest
+import typer
+from typer.testing import CliRunner
+
+from specify_cli import app
+from specify_cli.commands.event import event_run
+
+
+def test_event_run_reads_piped_stdin_payload():
+ """A normal, under-the-cap piped payload must reach the handler intact."""
+ with patch(
+ "specify_cli.events.resolve_and_run_event_command", return_value=0
+ ) as mock_run:
+ result = CliRunner().invoke(
+ app,
+ ["event", "run", "some-command", "session_start"],
+ input='{"key": "value"}',
+ )
+
+ assert result.exit_code == 0, result.output
+ assert mock_run.called
+ payload_arg = mock_run.call_args[0][2]
+ assert payload_arg == '{"key": "value"}'
+
+
+def test_event_run_empty_pipe_reads_empty_payload():
+ """An empty (but non-TTY) piped stream must not crash; it forwards `""`.
+
+ CliRunner always provides a non-TTY stdin, even when no `input=` is
+ given, so this exercises the piped-input branch with zero bytes — not
+ the TTY fallback. See `test_event_run_tty_uses_empty_object` below for
+ the actual TTY case.
+ """
+ with patch(
+ "specify_cli.events.resolve_and_run_event_command", return_value=0
+ ) as mock_run:
+ result = CliRunner().invoke(
+ app,
+ ["event", "run", "some-command", "session_start"],
+ )
+
+ assert result.exit_code == 0, result.output
+ assert mock_run.called
+ payload_arg = mock_run.call_args[0][2]
+ assert payload_arg == ""
+
+
+def test_event_run_tty_uses_empty_object(monkeypatch):
+ """A real TTY (no piped input at all) must fall back to `"{}"`."""
+
+ class FakeTtyStdin:
+ def isatty(self):
+ return True
+
+ monkeypatch.setattr("specify_cli.commands.event.sys.stdin", FakeTtyStdin())
+
+ with patch(
+ "specify_cli.events.resolve_and_run_event_command", return_value=0
+ ) as mock_run:
+ with pytest.raises(typer.Exit):
+ event_run(command_name="some-command", event_name="session_start", timeout=120)
+
+ assert mock_run.called
+ payload_arg = mock_run.call_args[0][2]
+ assert payload_arg == "{}"
+
+
+def test_event_run_oversized_stdin_reports_clean_error():
+ """A payload exceeding the 1 MiB cap must exit 1 with the limit message,
+ not crash with AttributeError (missing `.eof`) or TypeError (`typer.Exit`
+ does not accept `message=`)."""
+ oversized = "x" * (1 * 1024 * 1024 + 10)
+ with patch(
+ "specify_cli.events.resolve_and_run_event_command", return_value=0
+ ) as mock_run:
+ result = CliRunner().invoke(
+ app,
+ ["event", "run", "some-command", "session_start"],
+ input=oversized,
+ )
+
+ assert result.exit_code == 1, result.output
+ assert "1 MiB limit" in result.output
+ assert not mock_run.called
+
+
+def test_event_run_invalid_utf8_reports_clean_error():
+ """A piped payload that isn't valid UTF-8 must exit 1 with the encoding
+ error message, not propagate a raw `UnicodeDecodeError`, and the handler
+ must never be invoked with undecodable data."""
+ with patch(
+ "specify_cli.events.resolve_and_run_event_command", return_value=0
+ ) as mock_run:
+ result = CliRunner().invoke(
+ app,
+ ["event", "run", "some-command", "session_start"],
+ input=b"\xff\xfe",
+ )
+
+ assert result.exit_code == 1, result.output
+ assert "must be valid UTF-8" in result.output
+ assert not mock_run.called
+
+
+def test_event_run_multibyte_payload_enforces_byte_limit():
+ """The 1 MiB cap must be enforced in encoded bytes, not decoded characters.
+
+ 300,000 emoji is ~1.14 MiB of UTF-8 (4 bytes each) but only 300,000
+ *characters* — comfortably under the 1,048,576 character cap a text-mode
+ `sys.stdin.read(MAX_STDIN_BYTES)` would have applied. Reading from the
+ binary buffer instead must still reject it.
+ """
+ oversized = "\U0001F600" * 300_000 # 😀, 4 bytes each in UTF-8
+ assert len(oversized) < 1 * 1024 * 1024 # under the old, wrong character cap
+ with patch(
+ "specify_cli.events.resolve_and_run_event_command", return_value=0
+ ) as mock_run:
+ result = CliRunner().invoke(
+ app,
+ ["event", "run", "some-command", "session_start"],
+ input=oversized,
+ )
+
+ assert result.exit_code == 1, result.output
+ assert "1 MiB limit" in result.output
+ assert not mock_run.called
diff --git a/tests/test_extension_content_staleness.py b/tests/test_extension_content_staleness.py
new file mode 100644
index 0000000000..f76e041869
--- /dev/null
+++ b/tests/test_extension_content_staleness.py
@@ -0,0 +1,134 @@
+"""Tests for the bundled-extension local update route (#4345).
+
+Bundled extensions have no download URL, so `specify extension update`
+installs them from the copy shipped with the running spec-kit release,
+packaged by `_archive_extension_directory` into the same hardened
+archive pipeline that downloaded updates use. These tests pin that
+packaging step and its round trip through the archive installer.
+"""
+
+from __future__ import annotations
+
+import os
+
+import pytest
+import yaml
+from pathlib import Path
+
+from specify_cli.extensions import ExtensionManager
+
+
+def _create_extension_source(
+ base_dir: Path, name: str = "test-ext", version: str = "1.0.0"
+) -> Path:
+ """Create a minimal installable extension source directory."""
+ ext_dir = base_dir / name
+ ext_dir.mkdir(parents=True, exist_ok=True)
+
+ manifest = {
+ "schema_version": "1.0",
+ "extension": {
+ "id": "test-ext",
+ "name": "Test Extension",
+ "version": version,
+ "description": "A test extension",
+ },
+ "requires": {"speckit_version": ">=0.1.0"},
+ "provides": {
+ "commands": [
+ {
+ "name": "speckit.test-ext.hello",
+ "file": "commands/hello.md",
+ "description": "Test command",
+ }
+ ]
+ },
+ }
+
+ (ext_dir / "extension.yml").write_text(yaml.dump(manifest, sort_keys=False))
+ commands_dir = ext_dir / "commands"
+ commands_dir.mkdir(exist_ok=True)
+ (commands_dir / "hello.md").write_text("---\ndescription: Test\n---\n\n$ARGUMENTS\n")
+ scripts_dir = ext_dir / "scripts"
+ scripts_dir.mkdir(exist_ok=True)
+ (scripts_dir / "run.sh").write_text("#!/bin/sh\necho hello\n")
+ (ext_dir / "test-ext-config.yml").write_text("setting: default\n")
+ return ext_dir
+
+
+def _make_project(tmp_path: Path) -> Path:
+ project_dir = tmp_path / "project"
+ project_dir.mkdir()
+ (project_dir / ".specify").mkdir()
+ (project_dir / ".claude" / "skills").mkdir(parents=True)
+ return project_dir
+
+
+class TestArchiveExtensionDirectory:
+ def test_archive_contains_regular_files_only(self, tmp_path):
+ import zipfile
+
+ from specify_cli.extensions._commands import _archive_extension_directory
+
+ ext_dir = _create_extension_source(tmp_path)
+ archive_path = _archive_extension_directory(ext_dir)
+ try:
+ with zipfile.ZipFile(archive_path) as zf:
+ names = set(zf.namelist())
+ assert "extension.yml" in names
+ assert "commands/hello.md" in names
+ finally:
+ archive_path.unlink()
+
+ def test_archive_never_follows_symlinks(self, tmp_path):
+ """A symlink in the source must not pull out-of-tree bytes into the
+ archive before the hardened extractor sees it."""
+ import zipfile
+
+ from specify_cli.extensions._commands import _archive_extension_directory
+
+ ext_dir = _create_extension_source(tmp_path)
+ outside = tmp_path / "outside.txt"
+ outside.write_text("external bytes\n")
+ try:
+ (ext_dir / "scripts" / "link.txt").symlink_to(outside)
+ except OSError:
+ pytest.skip("symlink creation requires privileges on this platform")
+
+ archive_path = _archive_extension_directory(ext_dir)
+ try:
+ with zipfile.ZipFile(archive_path) as zf:
+ names = set(zf.namelist())
+ assert "scripts/link.txt" not in names
+ finally:
+ archive_path.unlink()
+
+ @pytest.mark.skipif(
+ os.name == "nt", reason="POSIX execute bits do not exist on Windows"
+ )
+ def test_archive_route_restores_script_execute_bits(self, tmp_path):
+ """safe_extract_archive writes members without their recorded ZIP
+ modes, so the archive install route depends on install_from_directory's
+ trailing ensure_executable_scripts() call to keep documented
+ `.specify/extensions//scripts/*.sh` invocations executable. Pin
+ that round trip so removing the restoration would fail here instead
+ of surfacing as `Permission denied` after a bundled update."""
+ from specify_cli.extensions._commands import _archive_extension_directory
+
+ project_dir = _make_project(tmp_path)
+ source = _create_extension_source(tmp_path)
+ (source / "scripts" / "run.sh").chmod(0o755)
+
+ archive_path = _archive_extension_directory(source)
+ try:
+ ExtensionManager(project_dir).install_from_zip(archive_path, "0.1.0")
+ finally:
+ archive_path.unlink()
+
+ installed_script = (
+ project_dir / ".specify" / "extensions" / "test-ext" / "scripts" / "run.sh"
+ )
+ assert installed_script.is_file()
+ assert installed_script.stat().st_mode & 0o100, (
+ "execute bit lost through the archive install route"
+ )
diff --git a/tests/test_extension_skills.py b/tests/test_extension_skills.py
index 6eec5e7b47..a0a32f4d29 100644
--- a/tests/test_extension_skills.py
+++ b/tests/test_extension_skills.py
@@ -1166,6 +1166,52 @@ def test_skill_registration_resolves_command_ref_tokens(
assert "__SPECKIT_COMMAND_PLAN__" not in content
assert expected_invocation in content
+ def test_skill_registration_resolves_hyphenated_command_ref_tokens(
+ self, project_dir, temp_dir
+ ):
+ """Command names containing a hyphen resolve like any other name."""
+ _create_init_options(project_dir, ai="claude", ai_skills=True)
+ skills_dir = _create_skills_dir(project_dir, ai="claude")
+
+ ext_dir = temp_dir / "hyphen-ref-ext"
+ ext_dir.mkdir()
+ manifest_data = {
+ "schema_version": "1.0",
+ "extension": {
+ "id": "hyphen-ref-ext",
+ "name": "Hyphen Ref Extension",
+ "version": "1.0.0",
+ "description": "Test",
+ },
+ "requires": {"speckit_version": ">=0.1.0"},
+ "provides": {
+ "commands": [
+ {
+ "name": "speckit.hyphen-ref-ext.run",
+ "file": "commands/run.md",
+ "description": "Run command",
+ }
+ ]
+ },
+ }
+ with open(ext_dir / "extension.yml", "w") as f:
+ yaml.safe_dump(manifest_data, f)
+
+ (ext_dir / "commands").mkdir()
+ (ext_dir / "commands" / "run.md").write_text(
+ "---\n"
+ "description: Run command\n"
+ "---\n\n"
+ "Use __SPECKIT_COMMAND_AGENT-CONTEXT_UPDATE__ before proceeding.\n"
+ )
+
+ manager = ExtensionManager(project_dir)
+ manager.install_from_directory(ext_dir, "0.1.0", register_commands=False)
+
+ content = (skills_dir / "speckit-hyphen-ref-ext-run" / "SKILL.md").read_text()
+ assert "__SPECKIT_COMMAND_AGENT-CONTEXT_UPDATE__" not in content
+ assert "/speckit-agent-context-update" in content
+
def test_skill_registration_does_not_rewrite_literal_speckit_text(
self, project_dir, temp_dir
):
diff --git a/tests/test_extensions.py b/tests/test_extensions.py
index 6642da2b09..aec32dc4ba 100644
--- a/tests/test_extensions.py
+++ b/tests/test_extensions.py
@@ -9190,6 +9190,212 @@ def fake_install_from_zip(self_obj, _zip_path, speckit_version):
).read_text()
assert restored_config_content == original_config_content
+ def test_update_installs_bundled_extension_from_local_copy(self, tmp_path):
+ """A bundled extension (no download URL) updates from the copy shipped
+ with the running spec-kit release instead of failing at download (#4345)."""
+ from typer.testing import CliRunner
+ from unittest.mock import patch
+ from specify_cli import app
+
+ runner = CliRunner()
+ project_dir = tmp_path / "project"
+ project_dir.mkdir()
+ (project_dir / ".specify").mkdir()
+ (project_dir / ".claude" / "skills").mkdir(parents=True)
+
+ manager = ExtensionManager(project_dir)
+ v1_dir = self._create_extension_source(tmp_path, "1.0.0")
+ manager.install_from_directory(v1_dir, "0.1.0")
+ v2_dir = self._create_extension_source(tmp_path, "2.0.0")
+
+ with patch.object(Path, "cwd", return_value=project_dir), \
+ patch.object(ExtensionCatalog, "get_extension_info", return_value={
+ "id": "test-ext",
+ "name": "Test Extension",
+ "version": "2.0.0",
+ "bundled": True,
+ "_install_allowed": True,
+ }), \
+ patch(
+ "specify_cli._locate_bundled_extension", return_value=v2_dir
+ ), \
+ patch.object(
+ ExtensionCatalog,
+ "download_extension",
+ side_effect=AssertionError("bundled update must not download"),
+ ):
+ result = runner.invoke(
+ app, ["extension", "update", "test-ext"], input="y\n", catch_exceptions=True
+ )
+
+ flat = " ".join(result.output.split())
+ assert result.exit_code == 0, result.output
+ assert "Updated to v2.0.0" in flat
+ assert ExtensionManager(project_dir).registry.get("test-ext")["version"] == "2.0.0"
+
+ def test_update_bundled_blocked_when_local_copy_lags_catalog(self, tmp_path):
+ """When the catalog advertises a newer version than the running release
+ bundles, the update is reported as requiring a spec-kit upgrade instead
+ of being offered and then failing."""
+ from typer.testing import CliRunner
+ from unittest.mock import patch
+ from specify_cli import app
+
+ runner = CliRunner()
+ project_dir = tmp_path / "project"
+ project_dir.mkdir()
+ (project_dir / ".specify").mkdir()
+ (project_dir / ".claude" / "skills").mkdir(parents=True)
+
+ manager = ExtensionManager(project_dir)
+ v1_dir = self._create_extension_source(tmp_path, "1.0.0")
+ manager.install_from_directory(v1_dir, "0.1.0")
+
+ with patch.object(Path, "cwd", return_value=project_dir), \
+ patch.object(ExtensionCatalog, "get_extension_info", return_value={
+ "id": "test-ext",
+ "name": "Test Extension",
+ "version": "2.0.0",
+ "bundled": True,
+ "_install_allowed": True,
+ }), \
+ patch(
+ "specify_cli._locate_bundled_extension", return_value=v1_dir
+ ):
+ result = runner.invoke(
+ app, ["extension", "update", "test-ext"], catch_exceptions=True
+ )
+
+ flat = " ".join(result.output.split())
+ assert result.exit_code == 0, result.output
+ assert "only ships v1.0.0" in flat
+ assert "upgrade spec-kit" in flat
+ assert "Update these extensions?" not in flat
+ assert "All extensions are up to date!" not in flat
+ assert ExtensionManager(project_dir).registry.get("test-ext")["version"] == "1.0.0"
+
+ def test_update_bundled_blocked_when_local_copy_is_intermediate_version(self, tmp_path):
+ """A bundled copy newer than the installation but older than the
+ catalog must be blocked, not installed: an intermediate version would
+ leave the project lagging the catalog while reporting success."""
+ from typer.testing import CliRunner
+ from unittest.mock import patch
+ from specify_cli import app
+
+ runner = CliRunner()
+ project_dir = tmp_path / "project"
+ project_dir.mkdir()
+ (project_dir / ".specify").mkdir()
+ (project_dir / ".claude" / "skills").mkdir(parents=True)
+
+ manager = ExtensionManager(project_dir)
+ v1_dir = self._create_extension_source(tmp_path, "1.0.0")
+ manager.install_from_directory(v1_dir, "0.1.0")
+ v2_dir = self._create_extension_source(tmp_path, "2.0.0")
+
+ with patch.object(Path, "cwd", return_value=project_dir), \
+ patch.object(ExtensionCatalog, "get_extension_info", return_value={
+ "id": "test-ext",
+ "name": "Test Extension",
+ "version": "3.0.0",
+ "bundled": True,
+ "_install_allowed": True,
+ }), \
+ patch(
+ "specify_cli._locate_bundled_extension", return_value=v2_dir
+ ), \
+ patch.object(
+ ExtensionCatalog,
+ "download_extension",
+ side_effect=AssertionError("blocked bundled update must not download"),
+ ):
+ result = runner.invoke(
+ app, ["extension", "update", "test-ext"], catch_exceptions=True
+ )
+
+ flat = " ".join(result.output.split())
+ assert result.exit_code == 0, result.output
+ assert "only ships v2.0.0" in flat
+ assert "upgrade spec-kit" in flat
+ assert "Update these extensions?" not in flat
+ assert ExtensionManager(project_dir).registry.get("test-ext")["version"] == "1.0.0"
+
+ def test_update_installs_bundled_copy_newer_than_catalog(self, tmp_path):
+ """A dev/source checkout can ship a copy newer than the fetched
+ catalog advertises; the local copy is offered and installed."""
+ from typer.testing import CliRunner
+ from unittest.mock import patch
+ from specify_cli import app
+
+ runner = CliRunner()
+ project_dir = tmp_path / "project"
+ project_dir.mkdir()
+ (project_dir / ".specify").mkdir()
+ (project_dir / ".claude" / "skills").mkdir(parents=True)
+
+ manager = ExtensionManager(project_dir)
+ v1_dir = self._create_extension_source(tmp_path, "1.0.0")
+ manager.install_from_directory(v1_dir, "0.1.0")
+ v3_dir = self._create_extension_source(tmp_path, "3.0.0")
+
+ with patch.object(Path, "cwd", return_value=project_dir), \
+ patch.object(ExtensionCatalog, "get_extension_info", return_value={
+ "id": "test-ext",
+ "name": "Test Extension",
+ "version": "2.0.0",
+ "bundled": True,
+ "_install_allowed": True,
+ }), \
+ patch(
+ "specify_cli._locate_bundled_extension", return_value=v3_dir
+ ):
+ result = runner.invoke(
+ app, ["extension", "update", "test-ext"], input="y\n", catch_exceptions=True
+ )
+
+ flat = " ".join(result.output.split())
+ assert result.exit_code == 0, result.output
+ assert "Updated to v3.0.0" in flat
+ assert ExtensionManager(project_dir).registry.get("test-ext")["version"] == "3.0.0"
+
+ def test_update_bundled_blocked_when_no_local_copy_exists(self, tmp_path):
+ """A bundled catalog entry with no locally shipped copy points at a
+ spec-kit upgrade instead of failing the update at download time."""
+ from typer.testing import CliRunner
+ from unittest.mock import patch
+ from specify_cli import app
+
+ runner = CliRunner()
+ project_dir = tmp_path / "project"
+ project_dir.mkdir()
+ (project_dir / ".specify").mkdir()
+ (project_dir / ".claude" / "skills").mkdir(parents=True)
+
+ manager = ExtensionManager(project_dir)
+ v1_dir = self._create_extension_source(tmp_path, "1.0.0")
+ manager.install_from_directory(v1_dir, "0.1.0")
+
+ with patch.object(Path, "cwd", return_value=project_dir), \
+ patch.object(ExtensionCatalog, "get_extension_info", return_value={
+ "id": "test-ext",
+ "name": "Test Extension",
+ "version": "2.0.0",
+ "bundled": True,
+ "_install_allowed": True,
+ }), \
+ patch(
+ "specify_cli._locate_bundled_extension", return_value=None
+ ):
+ result = runner.invoke(
+ app, ["extension", "update", "test-ext"], catch_exceptions=True
+ )
+
+ flat = " ".join(result.output.split())
+ assert result.exit_code == 0, result.output
+ assert "does not ship a local copy" in flat
+ assert "upgrade spec-kit" in flat
+ assert ExtensionManager(project_dir).registry.get("test-ext")["version"] == "1.0.0"
+
def test_update_failure_rolls_back_registry_hooks_and_commands(self, tmp_path, monkeypatch):
"""Failed update should restore original registry, hooks, and command files."""
from typer.testing import CliRunner
diff --git a/tests/test_github_http.py b/tests/test_github_http.py
index b31d829907..fcf6ae9936 100644
--- a/tests/test_github_http.py
+++ b/tests/test_github_http.py
@@ -42,6 +42,16 @@ def test_ftp_url_raises_value_error(self):
with pytest.raises(ValueError, match="url must start with http"):
build_github_request("ftp://github.com/file.zip")
+ @pytest.mark.parametrize(
+ "url", ["https://github.com:notaport/file", "https://github.com:65536/file"]
+ )
+ def test_malformed_explicit_port_raises_before_request_construction(self, url):
+ """Malformed explicit ports are rejected before creating a Request."""
+ with patch("specify_cli._github_http.urllib.request.Request") as request:
+ with pytest.raises(ValueError):
+ build_github_request(url)
+ request.assert_not_called()
+
# --- Valid URL Tests ---
def test_valid_https_url_returns_request(self):
@@ -54,6 +64,14 @@ def test_valid_http_url_returns_request(self):
req = build_github_request("http://example.com/file")
assert req.full_url == "http://example.com/file"
+ def test_valid_explicit_port_retains_url_method_and_github_auth(self):
+ """A valid explicit port retains normal GitHub request behavior."""
+ with patch.dict(os.environ, {"GITHUB_TOKEN": "test-token", "GH_TOKEN": ""}):
+ req = build_github_request("https://github.com:8443/github/spec-kit")
+ assert req.full_url == "https://github.com:8443/github/spec-kit"
+ assert req.get_method() == "GET"
+ assert req.get_header("Authorization") == "Bearer test-token"
+
# --- Auth Header Tests ---
def test_github_token_added_for_github_host(self):
diff --git a/tests/test_github_workflows.py b/tests/test_github_workflows.py
index aeb8ad7e21..7bb762ebaf 100644
--- a/tests/test_github_workflows.py
+++ b/tests/test_github_workflows.py
@@ -2,9 +2,16 @@
from __future__ import annotations
+import os
import re
+import subprocess
+import sys
from pathlib import Path
+import yaml
+
+from tests.conftest import requires_bash
+
REPO_ROOT = Path(__file__).resolve().parent.parent
WORKFLOWS_DIR = REPO_ROOT / ".github" / "workflows"
@@ -12,6 +19,11 @@
# inline shorthand (` - uses: x@sha`) used in catalog-assign.yml.
USES_RE = re.compile(r"^\s*(?:-\s*)?uses:\s*(?P[\S+)", re.MULTILINE)
PINNED_SHA_RE = re.compile(r"@[0-9a-f]{40}$", re.IGNORECASE)
+PUBLISH_WORKFLOW = WORKFLOWS_DIR / "publish-pypi.yml"
+PUBLISH_VALIDATION_STEPS = (
+ "Verify tag format",
+ "Verify tag matches package version",
+)
COMMUNITY_SUBMISSION_WORKFLOWS = (
(
"bundle",
@@ -37,6 +49,34 @@
)
+def _publish_workflow_steps() -> dict[str, dict[str, object]]:
+ workflow = yaml.safe_load(PUBLISH_WORKFLOW.read_text(encoding="utf-8"))
+ return {step["name"]: step for step in workflow["jobs"]["build"]["steps"]}
+
+
+def _run_publish_validation_step(
+ step_name: str, tag: str, working_directory: Path
+) -> subprocess.CompletedProcess[str]:
+ step = _publish_workflow_steps()[step_name]
+ env = os.environ.copy()
+ env["TAG"] = tag
+ env["PATH"] = f"{Path(sys.executable).parent}{os.pathsep}{env['PATH']}"
+ return subprocess.run(
+ ["bash", "-euo", "pipefail", "-c", step["run"]],
+ cwd=working_directory,
+ env=env,
+ capture_output=True,
+ text=True,
+ check=False,
+ )
+
+
+def _write_project_version(working_directory: Path, version: str) -> None:
+ (working_directory / "pyproject.toml").write_text(
+ f'[project]\nversion = "{version}"\n', encoding="utf-8"
+ )
+
+
def _create_pull_request_allowed_files(source_text: str) -> list[str]:
create_pr_match = re.search(
r"(?m)^ create-pull-request:\n(?P(?:^ [^\n]*\n?)+)",
@@ -78,6 +118,52 @@ def test_github_actions_are_pinned_to_full_commit_shas():
assert unpinned_refs == []
+def test_publish_tag_validation_uses_environment_variable():
+ steps = _publish_workflow_steps()
+
+ for step_name in PUBLISH_VALIDATION_STEPS:
+ step = steps[step_name]
+ assert step["env"]["TAG"] == "${{ inputs.tag }}"
+ assert "${{ inputs.tag }}" not in step["run"]
+
+
+@requires_bash
+def test_publish_tag_validation_accepts_valid_tag(tmp_path):
+ _write_project_version(tmp_path, "1.2.3")
+
+ for step_name in PUBLISH_VALIDATION_STEPS:
+ result = _run_publish_validation_step(step_name, "v1.2.3", tmp_path)
+ assert result.returncode == 0, result.stderr
+
+
+@requires_bash
+def test_publish_tag_validation_rejects_invalid_tag(tmp_path):
+ for invalid_tag in ("1.2.3", "v1.2", "v1.2.3-rc1"):
+ result = _run_publish_validation_step(
+ "Verify tag format", invalid_tag, tmp_path
+ )
+ assert result.returncode != 0
+ assert "is not a valid release tag" in result.stdout
+
+ injected_file = tmp_path / "interpolated"
+ injected_tag = f'v1.2.3"; touch "{injected_file}"; #'
+ result = _run_publish_validation_step("Verify tag format", injected_tag, tmp_path)
+ assert result.returncode != 0
+ assert not injected_file.exists()
+
+
+@requires_bash
+def test_publish_tag_validation_rejects_version_mismatch(tmp_path):
+ _write_project_version(tmp_path, "1.2.3")
+
+ result = _run_publish_validation_step(
+ "Verify tag matches package version", "v1.2.4", tmp_path
+ )
+
+ assert result.returncode != 0
+ assert "does not match pyproject.toml version" in result.stdout
+
+
def test_pinned_action_ref_accepts_uppercase_hex_sha():
assert PINNED_SHA_RE.search(
"actions/example@0123456789ABCDEF0123456789ABCDEF01234567"
@@ -144,7 +230,7 @@ def test_bug_test_workflow_provisions_python_dependencies():
compiled_text = compiled.read_text(encoding="utf-8")
setup_uv = (
- "astral-sh/setup-uv@c771a70e6277c0a99b617c7a806ffedaca235ff9 # v9.0.0"
+ "astral-sh/setup-uv@20cfd1bf945f4377ade1205e4dbc17946fc9a30d # v10.0.1"
)
setup_python = (
"actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0"
diff --git a/tests/test_presets.py b/tests/test_presets.py
index 9775e0afa9..57a70b4192 100644
--- a/tests/test_presets.py
+++ b/tests/test_presets.py
@@ -40,6 +40,8 @@
VALID_PRESET_TEMPLATE_TYPES,
)
from specify_cli.extensions import ExtensionRegistry
+from specify_cli._console import console
+from specify_cli.presets._commands import _warn_unmet_extension_dependencies
# ===== Fixtures =====
@@ -484,7 +486,10 @@ def test_get_hash(self, pack_dir):
manifest = PresetManifest(pack_dir / "preset.yml")
hash_val = manifest.get_hash()
assert hash_val.startswith("sha256:")
- assert len(hash_val) > 10
+ import hashlib
+ content = (pack_dir / "preset.yml").read_bytes()
+ expected = f"sha256:{hashlib.sha256(content).hexdigest()}"
+ assert hash_val == expected
def test_multiple_templates(self, temp_dir, valid_pack_data):
"""Test pack with multiple templates of different types."""
@@ -500,6 +505,102 @@ def test_multiple_templates(self, temp_dir, valid_pack_data):
manifest = PresetManifest(manifest_path)
assert len(manifest.templates) == 4
+ def test_duplicate_template_name_and_type_raises_validation_error(
+ self, temp_dir, valid_pack_data
+ ):
+ """A later entry with the same (name, type) pair must be rejected.
+
+ ``PresetResolver._manifest_declared_template`` returns the FIRST
+ 'provides.templates' entry matching a given (name, type) pair, so a
+ later duplicate would be silently unreachable while still being
+ counted by ``PresetManifest.templates`` -- mirroring the sibling bug
+ fixed for ``ExtensionManifest``'s provides.templates/scripts (#4016).
+ """
+ valid_pack_data["provides"]["templates"] = [
+ {"type": "command", "name": "specify", "file": "commands/specify-v1.md"},
+ {"type": "command", "name": "specify", "file": "commands/specify-v2.md"},
+ ]
+ manifest_path = temp_dir / "preset.yml"
+ with open(manifest_path, 'w') as f:
+ yaml.dump(valid_pack_data, f)
+ with pytest.raises(PresetValidationError, match="Duplicate template name"):
+ PresetManifest(manifest_path)
+
+ def test_same_name_different_type_templates_allowed(
+ self, temp_dir, valid_pack_data
+ ):
+ """The same name may recur across different template types."""
+ valid_pack_data["provides"]["templates"] = [
+ {"type": "template", "name": "specify", "file": "templates/specify.md"},
+ {"type": "command", "name": "specify", "file": "commands/specify.md"},
+ ]
+ manifest_path = temp_dir / "preset.yml"
+ with open(manifest_path, 'w') as f:
+ yaml.dump(valid_pack_data, f)
+ manifest = PresetManifest(manifest_path)
+ assert len(manifest.templates) == 2
+
+ def test_requires_extensions_absent_is_valid(self, temp_dir, valid_pack_data):
+ """A preset with no declared dependencies stays valid and reports none."""
+ manifest_path = temp_dir / "preset.yml"
+ with open(manifest_path, 'w') as f:
+ yaml.dump(valid_pack_data, f)
+ assert PresetManifest(manifest_path).requires_extensions == []
+
+ def test_requires_extensions_accepts_both_forms(self, temp_dir, valid_pack_data):
+ """Bare ids and mappings normalize to the same shape."""
+ valid_pack_data["requires"]["extensions"] = [
+ "speckit-inventory",
+ {"id": "other-ext", "version": ">=1.2.0", "required": False},
+ ]
+ manifest_path = temp_dir / "preset.yml"
+ with open(manifest_path, 'w') as f:
+ yaml.dump(valid_pack_data, f)
+
+ assert PresetManifest(manifest_path).requires_extensions == [
+ {"id": "speckit-inventory", "version": None, "required": True},
+ {"id": "other-ext", "version": ">=1.2.0", "required": False},
+ ]
+
+ @pytest.mark.parametrize(
+ "bad, expected",
+ [
+ ("speckit-inventory", "Invalid requires.extensions"), # str, not list
+ ({"id": "x"}, "Invalid requires.extensions"), # mapping, not list
+ ([123], r"Invalid requires\.extensions\[0\]"), # member not str/mapping
+ ([None], r"Invalid requires\.extensions\[0\]"),
+ ([{"version": ">=1"}], r"Missing requires\.extensions\[0\]\.id"),
+ ([{"id": 5}], r"Invalid requires\.extensions\[0\]\.id"),
+ ([{"id": "Bad_ID"}], r"Invalid requires\.extensions\[0\]\.id"),
+ (["Bad_ID"], r"Invalid requires\.extensions\[0\]\.id"),
+ ([{"id": "x", "version": 1.0}], r"Invalid requires\.extensions\[0\]\.version"),
+ ([{"id": "x", "version": " "}], r"Invalid requires\.extensions\[0\]\.version"),
+ ([{"id": "x", "version": "nonsense"}], r"Invalid requires\.extensions\[0\]\.version"),
+ ([{"id": "x", "required": "yes"}], r"Invalid requires\.extensions\[0\]\.required"),
+ # `$` also matches before a trailing newline, so an anchored
+ # re.match would admit these while the resolver's fullmatch-based
+ # safe-id check rejects them.
+ (["demo-ext\n"], r"Invalid requires\.extensions\[0\]\.id"),
+ ([{"id": "demo-ext\n"}], r"Invalid requires\.extensions\[0\]\.id"),
+ (["demo\next"], r"Invalid requires\.extensions\[0\]\.id"),
+ ],
+ )
+ def test_requires_extensions_rejects_malformed(
+ self, temp_dir, valid_pack_data, bad, expected
+ ):
+ """Malformed dependency declarations fail as PresetValidationError.
+
+ Same reasoning as requires.speckit_version: an unvalidated value reaches
+ ``SpecifierSet`` or ``re.match`` later and surfaces as a bare TypeError
+ that no caller handles as a malformed manifest.
+ """
+ valid_pack_data["requires"]["extensions"] = bad
+ manifest_path = temp_dir / "preset.yml"
+ with open(manifest_path, 'w') as f:
+ yaml.dump(valid_pack_data, f)
+ with pytest.raises(PresetValidationError, match=expected):
+ PresetManifest(manifest_path)
+
# ===== PresetRegistry Tests =====
@@ -784,6 +885,30 @@ def test_list_by_priority_includes_disabled_when_requested(self, temp_dir):
# ===== PresetManager Tests =====
+def test_unreadable_constitution_provenance_fails_closed(
+ project_dir, monkeypatch
+):
+ from specify_cli.presets import _constitution_provenance_matches_preset
+
+ memory = project_dir / ".specify" / "memory" / "constitution.md"
+ memory.parent.mkdir(parents=True, exist_ok=True)
+ memory.write_text("# Constitution\n", encoding="utf-8")
+ provenance = memory.parent / ".constitution-template.json"
+ provenance.write_text("{}", encoding="utf-8")
+ real_read_text = Path.read_text
+
+ def unreadable(path, *args, **kwargs):
+ if path == provenance:
+ raise OSError("simulated read failure")
+ return real_read_text(path, *args, **kwargs)
+
+ monkeypatch.setattr(Path, "read_text", unreadable)
+
+ assert not _constitution_provenance_matches_preset(
+ project_dir, memory, "example", "1.0.0"
+ )
+
+
class TestPresetManager:
"""Test PresetManager installation and removal."""
@@ -1064,6 +1189,534 @@ def test_list_installed_includes_priority(self, project_dir, pack_dir):
assert installed[0]["priority"] == 3
+class TestPresetExtensionDependencies:
+ """Test find_unmet_extension_dependencies (issue #4231)."""
+
+ @staticmethod
+ def _install_extension(
+ project_dir, extension_id, version, enabled=True, with_files=True
+ ):
+ """Register an installed extension the way the extension installer does.
+
+ ``with_files=False`` leaves the registry entry without its directory,
+ reproducing the stale state left behind when the files are deleted out
+ from under the registry.
+ """
+ extensions_dir = project_dir / ".specify" / "extensions"
+ extensions_dir.mkdir(parents=True, exist_ok=True)
+ if with_files:
+ (extensions_dir / extension_id).mkdir(parents=True, exist_ok=True)
+ registry_path = extensions_dir / ".registry"
+ data = {"schema_version": "1.0", "extensions": {}}
+ if registry_path.exists():
+ data = json.loads(registry_path.read_text(encoding="utf-8"))
+ data["extensions"][extension_id] = {"version": version, "enabled": enabled}
+ registry_path.write_text(json.dumps(data), encoding="utf-8")
+
+ @staticmethod
+ def _manifest(temp_dir, valid_pack_data, declared):
+ valid_pack_data["requires"]["extensions"] = declared
+ manifest_path = temp_dir / "dep-preset.yml"
+ with open(manifest_path, 'w') as f:
+ yaml.dump(valid_pack_data, f)
+ return PresetManifest(manifest_path)
+
+ def test_no_declared_dependencies_is_satisfied(
+ self, project_dir, temp_dir, valid_pack_data
+ ):
+ """A preset declaring nothing never reports an unmet dependency."""
+ manifest_path = temp_dir / "plain-preset.yml"
+ with open(manifest_path, 'w') as f:
+ yaml.dump(valid_pack_data, f)
+
+ manager = PresetManager(project_dir)
+ assert manager.find_unmet_extension_dependencies(
+ PresetManifest(manifest_path)
+ ) == []
+
+ def test_missing_dependency_is_reported(
+ self, project_dir, temp_dir, valid_pack_data
+ ):
+ """An uninstalled required extension is reported as missing."""
+ manifest = self._manifest(temp_dir, valid_pack_data, ["speckit-inventory"])
+
+ unmet = PresetManager(project_dir).find_unmet_extension_dependencies(manifest)
+
+ assert len(unmet) == 1
+ assert unmet[0]["id"] == "speckit-inventory"
+ assert unmet[0]["reason"] == "missing"
+ assert unmet[0]["installed"] is None
+
+ def test_installed_dependency_is_satisfied(
+ self, project_dir, temp_dir, valid_pack_data
+ ):
+ """An installed extension with no version constraint is satisfied."""
+ self._install_extension(project_dir, "speckit-inventory", "0.1.0")
+ manifest = self._manifest(temp_dir, valid_pack_data, ["speckit-inventory"])
+
+ assert PresetManager(project_dir).find_unmet_extension_dependencies(
+ manifest
+ ) == []
+
+ def test_satisfied_version_constraint(
+ self, project_dir, temp_dir, valid_pack_data
+ ):
+ """A satisfied version constraint reports nothing."""
+ self._install_extension(project_dir, "speckit-inventory", "1.5.0")
+ manifest = self._manifest(
+ temp_dir, valid_pack_data,
+ [{"id": "speckit-inventory", "version": ">=1.2.0"}],
+ )
+
+ assert PresetManager(project_dir).find_unmet_extension_dependencies(
+ manifest
+ ) == []
+
+ def test_unsatisfied_version_constraint_reports_both_versions(
+ self, project_dir, temp_dir, valid_pack_data
+ ):
+ """A version mismatch reports the installed version alongside the constraint."""
+ self._install_extension(project_dir, "speckit-inventory", "0.1.0")
+ manifest = self._manifest(
+ temp_dir, valid_pack_data,
+ [{"id": "speckit-inventory", "version": ">=9.0.0"}],
+ )
+
+ unmet = PresetManager(project_dir).find_unmet_extension_dependencies(manifest)
+
+ assert len(unmet) == 1
+ assert unmet[0]["reason"] == "version"
+ assert unmet[0]["installed"] == "0.1.0"
+ assert unmet[0]["version"] == ">=9.0.0"
+
+ def test_version_warning_does_not_promise_update_satisfies_constraint(self):
+ """Version remediation must handle constraints update cannot guarantee."""
+ manager = MagicMock()
+ manager.find_unmet_extension_dependencies.return_value = [
+ {
+ "id": "speckit-inventory",
+ "reason": "version",
+ "installed": "3.0.0",
+ "version": "<2",
+ }
+ ]
+
+ with console.capture() as capture:
+ _warn_unmet_extension_dependencies(manager, MagicMock())
+
+ output = " ".join(strip_ansi(capture.get()).split())
+ assert "Needs: a release of speckit-inventory satisfying <2" in output
+ assert "specify extension update" not in output
+
+ def test_optional_dependency_is_never_reported(
+ self, project_dir, temp_dir, valid_pack_data
+ ):
+ """`required: false` opts out of the warning even when absent."""
+ manifest = self._manifest(
+ temp_dir, valid_pack_data,
+ [{"id": "speckit-inventory", "required": False}],
+ )
+
+ assert PresetManager(project_dir).find_unmet_extension_dependencies(
+ manifest
+ ) == []
+
+ @pytest.mark.parametrize("bad_version", [None, 5, "unknown", "", "latest"])
+ def test_uncomparable_registry_version_is_not_a_mismatch(
+ self, project_dir, temp_dir, valid_pack_data, bad_version
+ ):
+ """A version that cannot be evaluated must not be reported as a mismatch.
+
+ ``version_satisfies()`` returns False for an unparseable version, which
+ is indistinguishable from a genuine mismatch -- so a string like
+ "unknown" would otherwise be reported as failing a constraint nobody
+ can actually evaluate it against.
+ """
+ self._install_extension(project_dir, "speckit-inventory", "0.1.0")
+ registry_path = project_dir / ".specify" / "extensions" / ".registry"
+ data = json.loads(registry_path.read_text(encoding="utf-8"))
+ data["extensions"]["speckit-inventory"]["version"] = bad_version
+ registry_path.write_text(json.dumps(data), encoding="utf-8")
+
+ manifest = self._manifest(
+ temp_dir, valid_pack_data,
+ [{"id": "speckit-inventory", "version": ">=9.0.0"}],
+ )
+
+ assert PresetManager(project_dir).find_unmet_extension_dependencies(
+ manifest
+ ) == []
+
+ def test_unregistered_extension_on_disk_is_satisfied(
+ self, project_dir, temp_dir, valid_pack_data
+ ):
+ """A directory with no registry entry still resolves, so it is not missing.
+
+ ``_get_all_extensions_by_priority`` admits safe unregistered
+ directories at implicit priority 10, so the preset works -- warning
+ that the dependency is absent would be a false alarm.
+ """
+ (project_dir / ".specify" / "extensions" / "speckit-inventory").mkdir(
+ parents=True
+ )
+ manifest = self._manifest(temp_dir, valid_pack_data, ["speckit-inventory"])
+
+ assert PresetManager(project_dir).find_unmet_extension_dependencies(
+ manifest
+ ) == []
+
+ def test_unregistered_extension_cannot_be_version_checked(
+ self, project_dir, temp_dir, valid_pack_data
+ ):
+ """No registry entry means no recorded version, so nothing to compare."""
+ (project_dir / ".specify" / "extensions" / "speckit-inventory").mkdir(
+ parents=True
+ )
+ manifest = self._manifest(
+ temp_dir, valid_pack_data,
+ [{"id": "speckit-inventory", "version": ">=9.0.0"}],
+ )
+
+ assert PresetManager(project_dir).find_unmet_extension_dependencies(
+ manifest
+ ) == []
+
+ def test_corrupted_registry_entry_with_directory_is_not_satisfied(
+ self, project_dir, temp_dir, valid_pack_data
+ ):
+ """A corrupted entry keeps its id registered, so its directory is excluded.
+
+ ``get()`` returns None for a non-dict entry just as it does for an
+ absent one, but ``keys()`` retains the id specifically so resolution
+ does not re-admit the directory as an unregistered extension. The
+ fallback must not revive what resolution excludes.
+ """
+ extensions_dir = project_dir / ".specify" / "extensions"
+ (extensions_dir / "speckit-inventory").mkdir(parents=True)
+ (extensions_dir / ".registry").write_text(
+ json.dumps(
+ {"schema_version": "1.0", "extensions": {"speckit-inventory": "corrupt"}}
+ ),
+ encoding="utf-8",
+ )
+ manifest = self._manifest(temp_dir, valid_pack_data, ["speckit-inventory"])
+
+ unmet = PresetManager(project_dir).find_unmet_extension_dependencies(manifest)
+
+ # Reported as corrupt rather than missing: the id is still registered,
+ # so a plain `extension add` would be refused as already installed.
+ assert [dep["reason"] for dep in unmet] == ["corrupt"]
+
+ def test_missing_and_stale_warnings_mention_discovery_only_catalogs(self):
+ """`extension add ` is rejected for discovery-only entries, so say so."""
+ manager = MagicMock()
+ manager.find_unmet_extension_dependencies.return_value = [
+ {"id": "speckit-inventory", "reason": "missing",
+ "installed": None, "version": None}
+ ]
+
+ with console.capture() as capture:
+ _warn_unmet_extension_dependencies(manager, MagicMock())
+
+ output = strip_ansi(capture.get())
+ assert "discovery-only catalog" in output
+ assert "--from " in output
+ assert "Install with: specify extension add speckit-inventory" in output
+
+ @pytest.mark.parametrize(
+ "reason, extra",
+ [
+ ("missing", {"installed": None, "version": None}),
+ ("stale", {"installed": "0.1.0", "version": None}),
+ ("disabled", {"installed": "0.1.0", "version": None}),
+ ("version", {"installed": "0.1.0", "version": ">=9.0.0"}),
+ ],
+ )
+ def test_leading_hyphen_id_is_not_emitted_into_a_command(self, reason, extra):
+ """A leading-hyphen id satisfies `^[a-z0-9-]+$` but breaks the command.
+
+ Typer would read it as an option rather than the positional extension
+ argument, so the advertised fix would fail. Every remedy substitutes
+ the placeholder `_command_safe_id` returns. The id here is deliberately
+ not a real flag, so a match cannot be confused with `--force` appearing
+ legitimately in the stale remedy.
+ """
+ manager = MagicMock()
+ manager.find_unmet_extension_dependencies.return_value = [
+ {"id": "--not-a-real-flag", "reason": reason, **extra}
+ ]
+
+ with console.capture() as capture:
+ _warn_unmet_extension_dependencies(manager, MagicMock())
+
+ output = " ".join(strip_ansi(capture.get()).split())
+ # Isolate the remedy: the description line legitimately shows the raw
+ # id, escaped for display; only the copyable command must not carry it.
+ label = next(
+ lbl for lbl in ("Install with:", "Reinstall with:", "Enable with:", "Needs:")
+ if lbl in output
+ )
+ remedy = output.split(label, 1)[1].split("The preset is installed.")[0]
+ assert "--not-a-real-flag" not in remedy
+ assert "" in remedy
+
+ def test_version_only_warning_omits_the_discovery_only_note(self):
+ """The note is about installing by id, which a version mismatch does not do."""
+ manager = MagicMock()
+ manager.find_unmet_extension_dependencies.return_value = [
+ {"id": "speckit-inventory", "reason": "version",
+ "installed": "0.1.0", "version": ">=9.0.0"}
+ ]
+
+ with console.capture() as capture:
+ _warn_unmet_extension_dependencies(manager, MagicMock())
+
+ assert "discovery-only" not in strip_ansi(capture.get())
+
+ def test_unregistered_extension_with_corrupt_registry_is_missing(
+ self, project_dir, temp_dir, valid_pack_data
+ ):
+ """A corrupt registry makes resolution fail closed, so it is not usable."""
+ extensions_dir = project_dir / ".specify" / "extensions"
+ (extensions_dir / "speckit-inventory").mkdir(parents=True)
+ (extensions_dir / ".registry").write_text("{not valid json", encoding="utf-8")
+ manifest = self._manifest(temp_dir, valid_pack_data, ["speckit-inventory"])
+
+ unmet = PresetManager(project_dir).find_unmet_extension_dependencies(manifest)
+
+ assert [dep["reason"] for dep in unmet] == ["missing"]
+
+ def test_corrupted_entry_gets_a_forced_reinstall_remedy(
+ self, project_dir, temp_dir, valid_pack_data
+ ):
+ """A corrupted entry is not simply absent: `add ` would be refused.
+
+ ``get()`` returns None for it, but ``is_installed()`` still counts the
+ key, so a plain add reports "already installed". It needs --force.
+ """
+ extensions_dir = project_dir / ".specify" / "extensions"
+ extensions_dir.mkdir(parents=True)
+ (extensions_dir / ".registry").write_text(
+ json.dumps(
+ {"schema_version": "1.0", "extensions": {"speckit-inventory": "bad"}}
+ ),
+ encoding="utf-8",
+ )
+ manifest = self._manifest(temp_dir, valid_pack_data, ["speckit-inventory"])
+
+ unmet = PresetManager(project_dir).find_unmet_extension_dependencies(manifest)
+
+ assert [dep["reason"] for dep in unmet] == ["corrupt"]
+
+ def test_corrupt_warning_suggests_forced_reinstall(self):
+ """The corrupt remedy must use --force, since the id is still registered."""
+ manager = MagicMock()
+ manager.find_unmet_extension_dependencies.return_value = [
+ {"id": "speckit-inventory", "reason": "corrupt",
+ "installed": None, "version": None}
+ ]
+
+ with console.capture() as capture:
+ _warn_unmet_extension_dependencies(manager, MagicMock())
+
+ output = strip_ansi(capture.get())
+ assert "unreadable registry entry" in output
+ assert "Reinstall with: specify extension add speckit-inventory --force" in output
+
+ def test_unreadable_registry_does_not_raise(
+ self, project_dir, temp_dir, valid_pack_data, monkeypatch
+ ):
+ """An OSError from the registry must not crash an already-completed install.
+
+ ``_load()`` lets OSError through, and ``preset_add`` only handles
+ preset-domain errors, so raising here would turn a finished install
+ into a traceback over what is only a warning.
+ """
+ manifest = self._manifest(temp_dir, valid_pack_data, ["speckit-inventory"])
+
+ import specify_cli.presets as presets_mod
+
+ def _boom(*args, **kwargs):
+ raise PermissionError("registry unreadable")
+
+ monkeypatch.setattr(presets_mod, "ExtensionRegistry", _boom)
+
+ assert PresetManager(project_dir).find_unmet_extension_dependencies(
+ manifest
+ ) == []
+
+ def test_version_only_footer_does_not_claim_the_feature_is_inert(self):
+ """A version mismatch still invokes the extension, so wording differs."""
+ manager = MagicMock()
+ manager.find_unmet_extension_dependencies.return_value = [
+ {"id": "speckit-inventory", "reason": "version",
+ "installed": "0.1.0", "version": ">=9.0.0"}
+ ]
+
+ with console.capture() as capture:
+ _warn_unmet_extension_dependencies(manager, MagicMock())
+
+ output = " ".join(strip_ansi(capture.get()).split())
+ assert "may not behave as the preset expects" in output
+ assert "does nothing" not in output
+ assert "safe to use" not in output
+
+ def test_unavailable_footer_states_the_feature_is_inert(self):
+ """An unavailable extension genuinely contributes nothing."""
+ manager = MagicMock()
+ manager.find_unmet_extension_dependencies.return_value = [
+ {"id": "speckit-inventory", "reason": "missing",
+ "installed": None, "version": None}
+ ]
+
+ with console.capture() as capture:
+ _warn_unmet_extension_dependencies(manager, MagicMock())
+
+ output = " ".join(strip_ansi(capture.get()).split())
+ assert "does nothing" in output
+ assert "may not behave as the preset expects" not in output
+
+ def test_exact_duplicate_declarations_warn_once(
+ self, project_dir, temp_dir, valid_pack_data
+ ):
+ """Naming the same dependency twice must not print the warning twice."""
+ manifest = self._manifest(
+ temp_dir, valid_pack_data, ["speckit-inventory", "speckit-inventory"]
+ )
+
+ unmet = PresetManager(project_dir).find_unmet_extension_dependencies(manifest)
+
+ assert [dep["id"] for dep in unmet] == ["speckit-inventory"]
+
+ def test_same_id_with_different_constraints_is_checked_twice(
+ self, project_dir, temp_dir, valid_pack_data
+ ):
+ """Distinct constraints on one id both have to hold, so both are checked."""
+ self._install_extension(project_dir, "speckit-inventory", "1.0.0")
+ manifest = self._manifest(
+ temp_dir, valid_pack_data,
+ [
+ {"id": "speckit-inventory", "version": ">=9.0.0"},
+ {"id": "speckit-inventory", "version": "<0.5"},
+ ],
+ )
+
+ unmet = PresetManager(project_dir).find_unmet_extension_dependencies(manifest)
+
+ assert [dep["version"] for dep in unmet] == [">=9.0.0", "<0.5"]
+
+ def test_stale_registry_entry_is_reported(
+ self, project_dir, temp_dir, valid_pack_data
+ ):
+ """A registry entry whose extension directory is gone counts as unmet.
+
+ PresetResolver guards on ``ext_dir.is_dir()`` in both template lookup
+ and layer collection, so a stale entry contributes nothing -- but the
+ surviving registry entry would otherwise read as satisfied.
+ """
+ self._install_extension(
+ project_dir, "speckit-inventory", "0.1.0", with_files=False
+ )
+ manifest = self._manifest(temp_dir, valid_pack_data, ["speckit-inventory"])
+
+ unmet = PresetManager(project_dir).find_unmet_extension_dependencies(manifest)
+
+ assert len(unmet) == 1
+ assert unmet[0]["reason"] == "stale"
+ assert unmet[0]["installed"] == "0.1.0"
+
+ def test_stale_is_reported_ahead_of_disabled_and_version(
+ self, project_dir, temp_dir, valid_pack_data
+ ):
+ """Restoring the files is the prerequisite, so it is reported first."""
+ self._install_extension(
+ project_dir, "speckit-inventory", "0.1.0",
+ enabled=False, with_files=False,
+ )
+ manifest = self._manifest(
+ temp_dir, valid_pack_data,
+ [{"id": "speckit-inventory", "version": ">=9.0.0"}],
+ )
+
+ unmet = PresetManager(project_dir).find_unmet_extension_dependencies(manifest)
+
+ assert [dep["reason"] for dep in unmet] == ["stale"]
+
+ def test_stale_warning_suggests_a_forced_reinstall(self):
+ """The stale remedy must restore the files, not re-add a registered id."""
+ manager = MagicMock()
+ manager.find_unmet_extension_dependencies.return_value = [
+ {
+ "id": "speckit-inventory",
+ "reason": "stale",
+ "installed": "0.1.0",
+ "version": None,
+ }
+ ]
+
+ with console.capture() as capture:
+ _warn_unmet_extension_dependencies(manager, MagicMock())
+
+ output = strip_ansi(capture.get())
+ assert "its files are missing" in output
+ assert "specify extension add speckit-inventory --force" in output
+
+ def test_disabled_dependency_is_reported(
+ self, project_dir, temp_dir, valid_pack_data
+ ):
+ """A disabled extension contributes nothing, so it counts as unmet.
+
+ Resolution skips disabled extensions, leaving the preset just as inert
+ as if the extension were absent -- but the registry entry exists, so a
+ presence-only check would call it satisfied and stay silent.
+ """
+ self._install_extension(project_dir, "speckit-inventory", "0.1.0", enabled=False)
+ manifest = self._manifest(temp_dir, valid_pack_data, ["speckit-inventory"])
+
+ unmet = PresetManager(project_dir).find_unmet_extension_dependencies(manifest)
+
+ assert len(unmet) == 1
+ assert unmet[0]["reason"] == "disabled"
+ assert unmet[0]["installed"] == "0.1.0"
+
+ def test_disabled_is_reported_ahead_of_version_mismatch(
+ self, project_dir, temp_dir, valid_pack_data
+ ):
+ """Enabling is the prerequisite, so it is reported before the version."""
+ self._install_extension(project_dir, "speckit-inventory", "0.1.0", enabled=False)
+ manifest = self._manifest(
+ temp_dir, valid_pack_data,
+ [{"id": "speckit-inventory", "version": ">=9.0.0"}],
+ )
+
+ unmet = PresetManager(project_dir).find_unmet_extension_dependencies(manifest)
+
+ assert [dep["reason"] for dep in unmet] == ["disabled"]
+
+ def test_multiple_dependencies_report_independently(
+ self, project_dir, temp_dir, valid_pack_data
+ ):
+ """Each declared dependency is evaluated on its own."""
+ self._install_extension(project_dir, "present-ext", "1.0.0")
+ self._install_extension(project_dir, "off-ext", "1.0.0", enabled=False)
+ manifest = self._manifest(
+ temp_dir, valid_pack_data,
+ [
+ "present-ext",
+ "absent-ext",
+ "off-ext",
+ {"id": "opt-ext", "required": False},
+ ],
+ )
+
+ unmet = PresetManager(project_dir).find_unmet_extension_dependencies(manifest)
+
+ assert [(dep["id"], dep["reason"]) for dep in unmet] == [
+ ("absent-ext", "missing"),
+ ("off-ext", "disabled"),
+ ]
+
+
class TestRegistryPriority:
"""Test registry priority sorting."""
@@ -2048,6 +2701,25 @@ def test_validate_catalog_url_malformed_rejected(self, project_dir):
with pytest.raises(PresetValidationError, match="malformed"):
catalog._validate_catalog_url("https://[::1")
+ def test_validate_catalog_url_out_of_range_port_rejected(self, project_dir):
+ """An out-of-range port raises ValueError lazily on ``.port`` access.
+
+ ``urlparse(...).hostname`` alone does not validate the port, so
+ without a ``_ = parsed.port`` probe inside the try/except, a URL like
+ ``https://example.com:99999/catalog.json`` sails through this
+ validator and only fails later, at fetch time, with a raw
+ untranslated error instead of a clean ``PresetValidationError``. The
+ sibling ``preset add --from `` download-URL guard already
+ catches this shape (see
+ ``test_preset_add_from_url_out_of_range_port_exits_cleanly``); this
+ catalog-source-URL validator had drifted from it and from the
+ original guard in ``specify_cli.catalogs``/
+ ``bundler/services/adapters.py``.
+ """
+ catalog = PresetCatalog(project_dir)
+ with pytest.raises(PresetValidationError, match="malformed"):
+ catalog._validate_catalog_url("https://example.com:99999/catalog.json")
+
def test_env_var_catalog_url(self, project_dir, monkeypatch):
"""Test catalog URL from environment variable."""
monkeypatch.setenv("SPECKIT_PRESET_CATALOG_URL", "https://custom.example.com/catalog.json")
@@ -3274,6 +3946,38 @@ def test_catalog_remove_escapes_markup_in_not_found_error(self, project_dir):
assert result.exit_code == 1
assert "[/red]absent" in result.output
+ @pytest.mark.parametrize(
+ "args",
+ [
+ [
+ "preset",
+ "catalog",
+ "add",
+ "https://example.com/catalog.json",
+ "--name",
+ "example",
+ ],
+ ["preset", "catalog", "remove", "example"],
+ ],
+ )
+ def test_catalog_mutation_rejects_non_mapping_config_root(
+ self, project_dir, args
+ ):
+ from typer.testing import CliRunner
+ from unittest.mock import patch
+ from specify_cli import app
+
+ config_path = project_dir / ".specify" / "preset-catalogs.yml"
+ original = "[]\n"
+ config_path.write_text(original, encoding="utf-8")
+
+ with patch.object(Path, "cwd", return_value=project_dir):
+ result = CliRunner().invoke(app, args)
+
+ assert result.exit_code == 1
+ assert "expected a mapping" in result.output
+ assert config_path.read_text(encoding="utf-8") == original
+
def test_env_var_overrides_catalogs(self, project_dir, monkeypatch):
"""Test that SPECKIT_PRESET_CATALOG_URL env var overrides defaults."""
monkeypatch.setenv(
@@ -3324,6 +4028,17 @@ def test_load_catalog_config_empty(self, project_dir):
result = catalog._load_catalog_config(config_path)
assert result is None
+ @pytest.mark.parametrize("bad", [[], False, 0, ""])
+ def test_load_catalog_config_rejects_falsy_non_mapping_root(
+ self, project_dir, bad
+ ):
+ config_path = project_dir / ".specify" / "preset-catalogs.yml"
+ config_path.write_text(yaml.safe_dump(bad), encoding="utf-8")
+
+ catalog = PresetCatalog(project_dir)
+ with pytest.raises(PresetValidationError, match="expected a mapping"):
+ catalog._load_catalog_config(config_path)
+
def test_load_catalog_config_invalid_yaml(self, project_dir):
"""Test loading invalid YAML raises error."""
config_path = project_dir / ".specify" / "preset-catalogs.yml"
@@ -3342,6 +4057,18 @@ def test_load_catalog_config_not_a_list(self, project_dir):
with pytest.raises(PresetValidationError, match="must be a list"):
catalog._load_catalog_config(config_path)
+ @pytest.mark.parametrize("body", ["catalogs: {}\n", "catalogs: ''\n", "catalogs: 0\n", "catalogs: false\n"])
+ def test_load_catalog_config_rejects_falsy_non_list_catalogs(self, project_dir, body):
+ """A FALSY non-list ``catalogs:`` value must raise, like a truthy one
+ (``catalogs: "not-a-list"``) already does. The shape check sat behind
+ the emptiness check, so these were silently swallowed as "no catalogs"."""
+ config_path = project_dir / ".specify" / "preset-catalogs.yml"
+ config_path.write_text(body, encoding="utf-8")
+
+ catalog = PresetCatalog(project_dir)
+ with pytest.raises(PresetValidationError, match="must be a list"):
+ catalog._load_catalog_config(config_path)
+
def test_load_catalog_config_invalid_entry(self, project_dir):
"""Test that non-dict entry raises error."""
config_path = project_dir / ".specify" / "preset-catalogs.yml"
diff --git a/tests/test_resolve_template_python_parity.py b/tests/test_resolve_template_python_parity.py
index 9af5554b44..2bf9977e14 100644
--- a/tests/test_resolve_template_python_parity.py
+++ b/tests/test_resolve_template_python_parity.py
@@ -91,6 +91,40 @@ def test_all_variants_preserve_composition_parity(
)
+@requires_bash
+def test_all_variants_treat_core_token_in_core_content_as_literal(
+ tmp_path: Path,
+) -> None:
+ """Core content holding a literal ``{CORE_TEMPLATE}`` must not be re-expanded.
+
+ The wrap strategy fills the placeholders present in the *wrapper*. A token
+ that arrives as part of the composed core content is data, not a slot, so it
+ survives into the output untouched. Rescanning the substituted string instead
+ reintroduces a token on every pass and never terminates, so the regression
+ mode here is a hang rather than a wrong value -- hence the timeout, without
+ which a reintroduced bug would stall the suite instead of failing it.
+ """
+ repo = make_repo(tmp_path)
+ install_scripts(repo, SCRIPT)
+ expected = install_composition_stack(repo, TEMPLATE, "# Core {CORE_TEMPLATE}\n")
+
+ results = [
+ run(bash_cmd(repo, SCRIPT, TEMPLATE, "--json"), repo, timeout=30),
+ run(py_cmd(repo, SCRIPT, TEMPLATE, "--json"), repo, timeout=30),
+ ]
+ if HAS_POWERSHELL:
+ results.append(run(ps_cmd(repo, SCRIPT, TEMPLATE, "-Json"), repo, timeout=30))
+
+ assert all(result.returncode == 0 for result in results)
+ assert all(result.stderr == "" for result in results)
+ # The wrapper contributes exactly one placeholder, so exactly one literal
+ # token -- the one carried in by the core content -- remains in the output.
+ assert expected.count("{CORE_TEMPLATE}") == 1
+ assert all(
+ json_stdout(result)["TEMPLATE_CONTENT"] == expected for result in results
+ )
+
+
@requires_bash
def test_all_variants_read_utf8_registry_under_ascii_locale(
tmp_path: Path,
diff --git a/tests/test_setup_plan_python_parity.py b/tests/test_setup_plan_python_parity.py
index e8372125a3..09356a142f 100644
--- a/tests/test_setup_plan_python_parity.py
+++ b/tests/test_setup_plan_python_parity.py
@@ -132,7 +132,7 @@ def test_python_existing_plan_matches_bash(repo: Path, args: tuple[str, ...]) ->
@requires_bash
@pytest.mark.skipif(not HAS_POWERSHELL, reason="no PowerShell available")
-def test_all_variants_ignore_extra_arguments(tmp_path: Path) -> None:
+def test_all_variants_reject_unknown_options(tmp_path: Path) -> None:
repos = [
_setup_repo(tmp_path, "bash"),
_setup_repo(tmp_path, "powershell"),
@@ -143,13 +143,15 @@ def test_all_variants_ignore_extra_arguments(tmp_path: Path) -> None:
ps = run(ps_cmd(repos[1], SCRIPT, "-Json", "--bogus"), repos[1])
py = run(py_cmd(repos[2], SCRIPT, "--json", "--bogus"), repos[2])
- assert bash.returncode == ps.returncode == py.returncode == 0
- assert normalize_repo_paths(bash.stdout, repos[0]) == normalize_repo_paths(
- ps.stdout, repos[1]
- ) == normalize_repo_paths(py.stdout, repos[2])
- assert normalize_repo_paths(bash.stderr, repos[0]) == normalize_repo_paths(
- ps.stderr, repos[1]
- ) == normalize_repo_paths(py.stderr, repos[2])
+ assert bash.returncode == ps.returncode == py.returncode == 1
+ assert bash.stdout == ps.stdout == py.stdout == ""
+ assert bash.stderr == ps.stderr == py.stderr == (
+ "ERROR: Unknown option '--bogus'\n"
+ )
+ assert all(
+ not (current / "specs" / "001-my-feature" / "plan.md").exists()
+ for current in repos
+ )
@requires_bash
@@ -372,3 +374,57 @@ def test_python_json_output_matches_powershell(repo: Path) -> None:
assert py.returncode == ps.returncode == 0
assert json_stdout(py) == json_stdout(ps)
+
+
+@requires_bash
+@pytest.mark.parametrize("args", [("--json",), ()], ids=["json", "text"])
+def test_all_variants_emit_feature_dir_not_specs_dir(
+ repo: Path, args: tuple[str, ...]
+) -> None:
+ r"""Pin the output key name, not just cross-port agreement.
+
+ The other tests here compare the ports against each other, so all three
+ could regress to ``SPECS_DIR`` together and still pass. This asserts the
+ contract absolutely: the key is ``FEATURE_DIR``, it carries the feature
+ directory rather than the specs root, and the old name is gone.
+ ``SPECS_DIR`` means the specs root in ``create-new-feature.sh``, so
+ re-emitting it here would reintroduce one name for two paths.
+
+ The value is matched by suffix because the ports legitimately differ in
+ path flavour -- under MSYS bash reports ``/tmp/...`` where the Python and
+ PowerShell ports report ``C:\...``. The suffix still separates
+ ``specs/001-my-feature`` from a bare ``specs``, which is the regression
+ this guards.
+ """
+ json_mode = args == ("--json",)
+ suffix = ("specs", "001-my-feature")
+
+ commands = [bash_cmd(repo, SCRIPT, *args), py_cmd(repo, SCRIPT, *args)]
+ if HAS_POWERSHELL:
+ commands.append(ps_cmd(repo, SCRIPT, *(("-Json",) if json_mode else ())))
+
+ for cmd in commands:
+ result = run(cmd, repo)
+ assert result.returncode == 0, result.stderr
+ assert "SPECS_DIR" not in result.stdout
+
+ if json_mode:
+ payload = json_stdout(result)
+ assert isinstance(payload, dict)
+ assert sorted(payload) == [
+ "BRANCH",
+ "FEATURE_DIR",
+ "FEATURE_SPEC",
+ "IMPL_PLAN",
+ ]
+ value = payload["FEATURE_DIR"]
+ else:
+ lines = dict(
+ line.split(": ", 1)
+ for line in result.stdout.splitlines()
+ if ": " in line
+ )
+ assert "FEATURE_DIR" in lines
+ value = lines["FEATURE_DIR"]
+
+ assert tuple(value.replace("\\", "/").rstrip("/").split("/")[-2:]) == suffix
diff --git a/tests/test_workflows.py b/tests/test_workflows.py
index 60a9b9ce8b..9b1c17881e 100644
--- a/tests/test_workflows.py
+++ b/tests/test_workflows.py
@@ -4,7 +4,7 @@
- Step registry & auto-discovery
- Base classes (StepBase, StepContext, StepResult)
- Expression engine
-- All 10 built-in step types
+- All 12 built-in step types
- Workflow definition loading & validation
- Workflow engine execution & state persistence
- Workflow catalog & registry
@@ -108,7 +108,7 @@ def test_all_step_types_registered(self):
expected = {
"command", "shell", "prompt", "gate", "if", "switch",
- "while", "do-while", "fan-out", "fan-in", "init",
+ "while", "do-while", "fan-out", "fan-in", "init", "slot",
}
assert expected.issubset(set(STEP_REGISTRY.keys()))
@@ -711,6 +711,74 @@ def test_filter_call_with_trailing_tokens_fails_loudly(self):
StepContext(inputs={"tags": ["a", "b"]}),
)
+ def test_filter_on_a_comparison_operand_is_refused(self):
+ """A filter mixed with a comparison must be reported, not guessed at.
+
+ The pipe is detected before the operators, so
+ `count > limit | default(5)` evaluated `count > limit` first and then
+ applied `default` to the resulting bool — a no-op — silently returning
+ the comparison against the *unfiltered* operand (False, where the author
+ meant `10 > 5` = True).
+
+ This is the mirror of a filter followed by a comparison
+ (`default('7') > '5'`), which this module already refuses rather than
+ guessing at the intended precedence. Both are now refused the same way.
+ """
+ import pytest
+ from specify_cli.workflows.expressions import evaluate_expression
+ from specify_cli.workflows.base import StepContext
+
+ ctx = StepContext(inputs={"count": 10, "name": "x"})
+ with pytest.raises(ValueError, match="ambiguous filter precedence"):
+ evaluate_expression("{{ inputs.count > inputs.limit | default(5) }}", ctx)
+ with pytest.raises(ValueError, match="ambiguous filter precedence"):
+ evaluate_expression(
+ '{{ inputs.name == inputs.other | default("x") }}', ctx
+ )
+ with pytest.raises(ValueError, match="ambiguous filter precedence"):
+ evaluate_expression("{{ inputs.a and inputs.b | default(1) }}", ctx)
+
+ def test_filter_after_a_unary_not_is_refused(self):
+ """Unary `not` mis-binds the same way and must be caught too.
+
+ `not` is a leading prefix rather than an infix token (the parser tests
+ it with `expr.startswith("not ")`), so it has no surrounding space for
+ the operator scan to match. Without an explicit check,
+ `not inputs.missing | default(1)` evaluated `not inputs.missing` first
+ and applied `default` to that boolean — a no-op — silently returning
+ True where the author meant `not 1` = False.
+ """
+ import pytest
+ from specify_cli.workflows.expressions import evaluate_expression
+ from specify_cli.workflows.base import StepContext
+
+ ctx = StepContext(inputs={"value": 0, "flag": True})
+ with pytest.raises(ValueError, match="ambiguous filter precedence"):
+ evaluate_expression("{{ not inputs.missing | default(1) }}", ctx)
+ with pytest.raises(ValueError, match="ambiguous filter precedence"):
+ evaluate_expression("{{ not inputs.value | default(1) }}", ctx)
+ # A `not` that follows and/or is already caught by that token.
+ with pytest.raises(ValueError, match="ambiguous filter precedence"):
+ evaluate_expression(
+ "{{ inputs.flag and not inputs.value | default(1) }}", ctx
+ )
+ # Plain unary `not`, with no filter, is untouched.
+ assert evaluate_expression("{{ not inputs.value }}", ctx) is True
+ assert evaluate_expression("{{ not inputs.flag }}", ctx) is False
+
+ def test_plain_filters_and_chains_are_unaffected(self):
+ """Only a filter mixed with an operator is refused."""
+ from specify_cli.workflows.expressions import evaluate_expression
+ from specify_cli.workflows.base import StepContext
+
+ ctx = StepContext(inputs={"items": ["a", "b"], "count": 10, "name": "x"})
+ assert evaluate_expression("{{ inputs.missing | default(5) }}", ctx) == 5
+ assert evaluate_expression('{{ inputs.items | join(", ") }}', ctx) == "a, b"
+ assert evaluate_expression("{{ inputs.items | contains('a') }}", ctx) is True
+ # Operators without a filter, and filters without an operator, both fine.
+ assert evaluate_expression("{{ inputs.count > 5 }}", ctx) is True
+ assert evaluate_expression('{{ inputs.name == "x" }}', ctx) is True
+
def test_chained_filters_apply_left_to_right(self):
# Filters chain: each filter's result feeds the next. `map` yields a
# list and `join` is the only filter that renders a list to a string,
@@ -3128,6 +3196,55 @@ def test_validate_accepts_missing_else(self):
class TestSwitchStep:
"""Test the switch step type."""
+ def test_execute_matches_case_ignoring_surrounding_whitespace(self):
+ """A shell step's stdout keeps its trailing newline; the case must match.
+
+ `ShellStep` stores `proc.stdout` verbatim, so `run: echo approve`
+ resolves to "approve" plus a newline. Unstripped, that matched no
+ `approve:` case and the switch silently fell through to `default:`
+ while still reporting COMPLETED. There is no `trim` filter, so a
+ workflow author cannot strip it themselves.
+ """
+ from specify_cli.workflows.steps.switch import SwitchStep
+ from specify_cli.workflows.base import StepContext, StepStatus
+
+ config = {
+ "id": "route",
+ "expression": "{{ steps.check.output.stdout }}",
+ "cases": {
+ "approve": [{"id": "approved", "type": "command", "command": "echo"}],
+ "reject": [{"id": "rejected", "type": "command", "command": "echo"}],
+ },
+ "default": [{"id": "fallback", "type": "command", "command": "echo"}],
+ }
+ for raw in ("approve\n", "approve\r\n", " approve ", "approve"):
+ ctx = StepContext(steps={"check": {"output": {"stdout": raw}}})
+ result = SwitchStep().execute(config, ctx)
+ assert result.status == StepStatus.COMPLETED
+ assert result.output["matched_case"] == "approve", repr(raw)
+ assert [s["id"] for s in result.next_steps] == ["approved"], repr(raw)
+ # The raw value is still reported unchanged.
+ assert result.output["expression_value"] == raw
+
+ def test_execute_still_falls_through_for_a_genuine_mismatch(self):
+ """Stripping must not make unrelated values match."""
+ from specify_cli.workflows.steps.switch import SwitchStep
+ from specify_cli.workflows.base import StepContext
+
+ config = {
+ "id": "route",
+ "expression": "{{ steps.check.output.stdout }}",
+ "cases": {
+ "approve": [{"id": "approved", "type": "command", "command": "echo"}]
+ },
+ "default": [{"id": "fallback", "type": "command", "command": "echo"}],
+ }
+ ctx = StepContext(steps={"check": {"output": {"stdout": "approve-later\n"}}})
+ result = SwitchStep().execute(config, ctx)
+
+ assert result.output["matched_case"] == "__default__"
+ assert [s["id"] for s in result.next_steps] == ["fallback"]
+
def test_execute_matches_case(self):
from specify_cli.workflows.steps.switch import SwitchStep
from specify_cli.workflows.base import StepContext
@@ -3309,6 +3426,38 @@ def test_validate_missing_expression(self):
errors = step.validate({"id": "test", "cases": {}})
assert any("missing 'expression'" in e for e in errors)
+ def test_validate_missing_cases(self):
+ """`cases` is the switch's branch payload and must be required.
+
+ Every other control-flow step requires its own: `if` requires `then`,
+ `fan-out` requires `items` and `step`, `fan-in` a non-empty `wait_for`,
+ `gate` a `message`. Without it, a `case:` typo validated clean and then
+ reported COMPLETED with `matched_case: "__default__"` having dispatched
+ nothing.
+ """
+ from specify_cli.workflows.steps.switch import SwitchStep
+
+ step = SwitchStep()
+
+ # Absent entirely.
+ errors = step.validate({"id": "route", "expression": "{{ inputs.x }}"})
+ assert any("missing 'cases'" in e for e in errors), errors
+
+ # The realistic slip: `case:` instead of `cases:`.
+ errors = step.validate(
+ {"id": "route", "expression": "{{ inputs.x }}", "case": {"a": []}}
+ )
+ assert any("missing 'cases'" in e for e in errors), errors
+
+ def test_validate_accepts_an_empty_cases_mapping(self):
+ """An explicitly declared but empty `cases:` is still a declaration."""
+ from specify_cli.workflows.steps.switch import SwitchStep
+
+ errors = SwitchStep().validate(
+ {"id": "route", "expression": "{{ inputs.x }}", "cases": {}}
+ )
+ assert not any("missing 'cases'" in e for e in errors), errors
+
def test_validate_invalid_cases_and_default(self):
from specify_cli.workflows.steps.switch import SwitchStep
@@ -3411,6 +3560,33 @@ def test_validate_missing_fields(self):
assert any("missing 'condition'" in e for e in errors)
# max_iterations is optional (defaults to 10)
+ def test_validate_requires_steps_body(self):
+ """A while loop with no body must be rejected, not silently a no-op.
+
+ Without this, ``step:`` written instead of ``steps:`` -- an easy slip,
+ since fan-out's payload key really is the singular ``step:`` -- passed
+ ``specify workflow validate`` with zero errors, and then reported
+ COMPLETED at run time while returning no ``next_steps``, so the loop
+ never ran even once.
+ """
+ from specify_cli.workflows.base import StepContext, StepStatus
+ from specify_cli.workflows.steps.while_loop import WhileStep
+
+ step = WhileStep()
+ config = {
+ "id": "retry",
+ "condition": "true",
+ # The mistype: singular 'step' instead of 'steps'.
+ "step": {"id": "x", "type": "command", "command": "echo"},
+ }
+ errors = step.validate(config)
+ assert errors == ["While step 'retry' is missing 'steps' field."], errors
+
+ # Demonstrates why it matters: execution is a silent no-op.
+ result = step.execute(config, StepContext())
+ assert result.status == StepStatus.COMPLETED
+ assert result.next_steps == []
+
@pytest.mark.parametrize("bad", [["a", "b"], {"k": "v"}, 5, 1.5])
def test_validate_rejects_non_string_non_bool_condition(self, bad):
from specify_cli.workflows.steps.while_loop import WhileStep
@@ -3543,6 +3719,25 @@ def test_validate_missing_fields(self):
assert any("missing 'condition'" in e for e in errors)
# max_iterations is optional (defaults to 10)
+ def test_validate_requires_steps_body(self):
+ """A do-while with no body must be rejected, not silently a no-op.
+
+ The step's own docstring promises "The first invocation always returns
+ the nested steps for execution" -- with no body it validated clean and
+ then returned none, so the loop never ran even once.
+ """
+ from specify_cli.workflows.base import StepContext, StepStatus
+ from specify_cli.workflows.steps.do_while import DoWhileStep
+
+ step = DoWhileStep()
+ config = {"id": "refine", "condition": "true", "max_iterations": 3}
+ errors = step.validate(config)
+ assert errors == ["Do-while step 'refine' is missing 'steps' field."], errors
+
+ result = step.execute(config, StepContext())
+ assert result.status == StepStatus.COMPLETED
+ assert result.next_steps == []
+
@pytest.mark.parametrize("bad", [["a", "b"], {"k": "v"}, 5, 1.5])
def test_validate_rejects_non_string_non_bool_condition(self, bad):
from specify_cli.workflows.steps.do_while import DoWhileStep
@@ -7633,6 +7828,71 @@ def test_list_skips_bad_file_with_valid_sibling(self, project_dir):
assert len(runs) == 1
assert runs[0]["workflow_id"] == "good-run"
+ def test_list_skips_invalid_utf8_with_valid_sibling(self, project_dir):
+ from specify_cli.workflows.engine import WorkflowEngine, WorkflowDefinition
+
+ runs_dir = project_dir / ".specify" / "workflows" / "runs"
+ bad_dir = runs_dir / "bad-utf8"
+ bad_dir.mkdir(parents=True)
+ (bad_dir / "state.json").write_bytes(b"\xff\xfe invalid utf8")
+
+ yaml_str = """
+schema_version: "1.0"
+workflow:
+ id: "good-run-utf8"
+ name: "Good Run UTF8"
+ version: "1.0.0"
+steps:
+ - id: step-one
+ type: shell
+ run: "echo test"
+"""
+ definition = WorkflowDefinition.from_string(yaml_str)
+ engine = WorkflowEngine(project_dir)
+ engine.execute(definition)
+
+ runs = engine.list_runs()
+ assert len(runs) == 1
+ assert runs[0]["workflow_id"] == "good-run-utf8"
+
+ def test_list_skips_oserror_with_valid_sibling(self, project_dir, monkeypatch):
+ import builtins
+ from specify_cli.workflows.engine import WorkflowEngine, WorkflowDefinition
+
+ runs_dir = project_dir / ".specify" / "workflows" / "runs"
+ bad_dir = runs_dir / "bad-oserror"
+ bad_dir.mkdir(parents=True)
+ state_file = bad_dir / "state.json"
+ state_file.write_text('{"run_id": "bad"}', encoding="utf-8")
+
+ original_open = builtins.open
+
+ def _mock_open(path, *args, **kwargs):
+ if str(path).endswith("state.json") and "bad-oserror" in str(path):
+ raise OSError("permission denied")
+ return original_open(path, *args, **kwargs)
+
+ monkeypatch.setattr(builtins, "open", _mock_open)
+
+ yaml_str = """
+schema_version: "1.0"
+workflow:
+ id: "good-run-oserror"
+ name: "Good Run OSError"
+ version: "1.0.0"
+steps:
+ - id: step-one
+ type: shell
+ run: "echo test"
+"""
+ definition = WorkflowDefinition.from_string(yaml_str)
+ engine = WorkflowEngine(project_dir)
+ engine.execute(definition)
+
+ runs = engine.list_runs()
+ assert len(runs) == 1
+ assert runs[0]["workflow_id"] == "good-run-oserror"
+
# ===== Workflow Registry Tests =====
@@ -8328,6 +8588,18 @@ def test_remove_catalog_invalid_index(self, project_dir):
with pytest.raises(WorkflowValidationError, match="out of range"):
catalog.remove_catalog(5)
+ @pytest.mark.parametrize("bad", [[], False, 0, ""])
+ def test_remove_catalog_rejects_falsy_non_mapping_config(
+ self, project_dir, bad
+ ):
+ from specify_cli.workflows.catalog import WorkflowCatalog, WorkflowValidationError
+
+ config_path = project_dir / ".specify" / "workflow-catalogs.yml"
+ config_path.write_text(yaml.safe_dump(bad), encoding="utf-8")
+
+ with pytest.raises(WorkflowValidationError, match="expected a mapping"):
+ WorkflowCatalog(project_dir).remove_catalog(0)
+
def test_get_catalog_configs(self, project_dir):
from specify_cli.workflows.catalog import WorkflowCatalog
@@ -9009,6 +9281,23 @@ def test_add_catalog_empty_yaml_file(self, project_dir):
assert len(data["catalogs"]) == 1
assert data["catalogs"][0]["url"] == "https://example.com/steps.json"
+ @pytest.mark.parametrize("bad", [[], False, 0, ""])
+ def test_add_catalog_rejects_falsy_non_mapping_config(
+ self, project_dir, bad
+ ):
+ from specify_cli.workflows.catalog import StepCatalog, StepValidationError
+
+ config_path = project_dir / ".specify" / "step-catalogs.yml"
+ original = yaml.safe_dump(bad)
+ config_path.write_text(original, encoding="utf-8")
+
+ with pytest.raises(StepValidationError, match="expected a mapping"):
+ StepCatalog(project_dir).add_catalog(
+ "https://example.com/steps.json", "my-steps"
+ )
+
+ assert config_path.read_text(encoding="utf-8") == original
+
def test_add_catalog_duplicate_rejected(self, project_dir):
from specify_cli.workflows.catalog import StepCatalog, StepValidationError
@@ -9041,6 +9330,18 @@ def test_remove_catalog_invalid_index(self, project_dir):
with pytest.raises(StepValidationError, match="out of range"):
catalog.remove_catalog(5)
+ @pytest.mark.parametrize("bad", [[], False, 0, ""])
+ def test_remove_catalog_rejects_falsy_non_mapping_config(
+ self, project_dir, bad
+ ):
+ from specify_cli.workflows.catalog import StepCatalog, StepValidationError
+
+ config_path = project_dir / ".specify" / "step-catalogs.yml"
+ config_path.write_text(yaml.safe_dump(bad), encoding="utf-8")
+
+ with pytest.raises(StepValidationError, match="expected a mapping"):
+ StepCatalog(project_dir).remove_catalog(0)
+
def test_remove_catalog_no_config(self, project_dir):
from specify_cli.workflows.catalog import StepCatalog, StepValidationError
diff --git a/tests/unit/test_bundler_primitives.py b/tests/unit/test_bundler_primitives.py
index dc39106b50..bbbac1133b 100644
--- a/tests/unit/test_bundler_primitives.py
+++ b/tests/unit/test_bundler_primitives.py
@@ -7,6 +7,7 @@
from __future__ import annotations
from pathlib import Path
+from types import SimpleNamespace
import pytest
@@ -77,13 +78,17 @@ def test_offline_workflow_allows_bundled(tmp_path: Path, monkeypatch):
monkeypatch.setattr(
assets, "_locate_bundled_workflow", lambda wid: tmp_path / "wf"
)
- calls: list[str] = []
- monkeypatch.setattr(specify_cli, "workflow_add", lambda wid: calls.append(wid))
+ calls: list[tuple] = []
+ monkeypatch.setattr(
+ specify_cli,
+ "workflow_add",
+ lambda wid, dev=object(), from_url=object(): calls.append((wid, dev, from_url)),
+ )
manager = primitive_manager("workflows", tmp_path, allow_network=False)
manager.install(_component("workflows", "bundled-wf"))
- assert calls == ["bundled-wf"]
+ assert calls == [("bundled-wf", False, None)]
def test_assert_pinned_version_matches_passes():
@@ -169,10 +174,12 @@ def test_bundled_extension_pin_match_installs(tmp_path: Path, monkeypatch):
bundled = _write_manifest(tmp_path / "ext", "extension", "1.0.0")
monkeypatch.setattr(assets, "_locate_bundled_extension", lambda cid: bundled)
called: list = []
- monkeypatch.setattr(
- ExtensionManager, "install_from_directory",
- lambda self, *a, **k: called.append(a),
- )
+
+ def _fake_install(self, *a, **k):
+ called.append(a)
+ return SimpleNamespace(id="my-ext")
+
+ monkeypatch.setattr(ExtensionManager, "install_from_directory", _fake_install)
manager = primitive_manager("extensions", tmp_path, allow_network=False)
# matching pin, and unpinned, both install cleanly
@@ -181,6 +188,93 @@ def test_bundled_extension_pin_match_installs(tmp_path: Path, monkeypatch):
assert len(called) == 2
+def _write_extension_with_config(ext_dir: Path) -> None:
+ """A minimal, real (unmocked) extension source with a provides.config entry."""
+ import yaml
+
+ ext_dir.mkdir(parents=True, exist_ok=True)
+ manifest = {
+ "schema_version": "1.0",
+ "extension": {
+ "id": "my-ext",
+ "name": "My Extension",
+ "version": "1.0.0",
+ "description": "Test extension",
+ },
+ "requires": {"speckit_version": ">=0.1.0"},
+ "provides": {
+ "commands": [
+ {"name": "speckit.my-ext.hello", "file": "commands/hello.md"},
+ ],
+ "config": [
+ {"name": "my-ext-config.yml", "template": "config-template.yml"},
+ ],
+ },
+ }
+ (ext_dir / "extension.yml").write_text(yaml.dump(manifest), encoding="utf-8")
+ (ext_dir / "config-template.yml").write_text("setting: default\n", encoding="utf-8")
+ (ext_dir / "commands").mkdir(exist_ok=True)
+ (ext_dir / "commands" / "hello.md").write_text("---\ndescription: Test\n---\n\nhi\n", encoding="utf-8")
+
+
+def test_bundled_extension_install_scaffolds_config(tmp_path: Path, monkeypatch):
+ """A bundle-installed extension must have its provides.config templates
+ scaffolded, exactly like `specify extension add` does (issue: bundle
+ install skipped ExtensionManager.scaffold_config)."""
+ import specify_cli._assets as assets
+
+ project = tmp_path / "project"
+ ext_source = tmp_path / "ext-source"
+ _write_extension_with_config(ext_source)
+ monkeypatch.setattr(assets, "_locate_bundled_extension", lambda cid: ext_source)
+
+ manager = primitive_manager("extensions", project, allow_network=False)
+ manager.install(ComponentRef(kind="extensions", id="my-ext"))
+
+ scaffolded = project / ".specify" / "extensions" / "my-ext" / "my-ext-config.yml"
+ assert scaffolded.exists()
+ assert scaffolded.read_text(encoding="utf-8") == "setting: default\n"
+
+
+def test_catalog_extension_install_scaffolds_config(tmp_path: Path, monkeypatch):
+ """A catalog-resolved (downloaded ZIP) extension install must also
+ scaffold its provides.config templates, matching the bundled-directory
+ coverage above. Exercises the reported reproduction, which installed an
+ extension resolved from the catalog rather than one shipped with Spec Kit."""
+ import zipfile
+
+ import specify_cli._assets as assets
+ from specify_cli.extensions import ExtensionCatalog
+
+ project = tmp_path / "project"
+ ext_source = tmp_path / "ext-source"
+ _write_extension_with_config(ext_source)
+
+ zip_path = tmp_path / "my-ext.zip"
+ with zipfile.ZipFile(zip_path, "w") as zf:
+ for f in ext_source.rglob("*"):
+ if f.is_file():
+ zf.write(f, f.relative_to(ext_source))
+
+ # No bundled asset located: forces the catalog/ZIP branch (install_from_zip).
+ monkeypatch.setattr(assets, "_locate_bundled_extension", lambda cid: None)
+ monkeypatch.setattr(
+ ExtensionCatalog,
+ "get_extension_info",
+ lambda self, eid: {"id": eid, "_install_allowed": True},
+ )
+ monkeypatch.setattr(
+ ExtensionCatalog, "download_extension", lambda self, eid: zip_path
+ )
+
+ manager = primitive_manager("extensions", project, allow_network=True)
+ manager.install(ComponentRef(kind="extensions", id="my-ext"))
+
+ scaffolded = project / ".specify" / "extensions" / "my-ext" / "my-ext-config.yml"
+ assert scaffolded.exists()
+ assert scaffolded.read_text(encoding="utf-8") == "setting: default\n"
+
+
def test_bundled_preset_pin_mismatch_refuses(tmp_path: Path, monkeypatch):
import specify_cli._assets as assets
from specify_cli.presets import PresetManager
@@ -227,10 +321,12 @@ def test_extension_refresh_calls_install_with_force(tmp_path: Path, monkeypatch)
bundled = _write_manifest(tmp_path / "ext", "extension", "1.0.0")
monkeypatch.setattr(assets, "_locate_bundled_extension", lambda cid: bundled)
force_values: list = []
- monkeypatch.setattr(
- ExtensionManager, "install_from_directory",
- lambda self, *a, **k: force_values.append(k.get("force", False)),
- )
+
+ def _fake_install(self, *a, **k):
+ force_values.append(k.get("force", False))
+ return SimpleNamespace(id="my-ext")
+
+ monkeypatch.setattr(ExtensionManager, "install_from_directory", _fake_install)
manager = primitive_manager("extensions", tmp_path, allow_network=False)
manager.refresh(ComponentRef(kind="extensions", id="my-ext"))
@@ -265,10 +361,12 @@ def test_default_installer_refresh_dispatches_to_kind_manager(tmp_path: Path, mo
bundled = _write_manifest(tmp_path / "ext", "extension", "1.0.0")
monkeypatch.setattr(assets, "_locate_bundled_extension", lambda cid: bundled)
force_values: list = []
- monkeypatch.setattr(
- ExtensionManager, "install_from_directory",
- lambda self, *a, **k: force_values.append(k.get("force", False)),
- )
+
+ def _fake_install(self, *a, **k):
+ force_values.append(k.get("force", False))
+ return SimpleNamespace(id="my-ext")
+
+ monkeypatch.setattr(ExtensionManager, "install_from_directory", _fake_install)
installer = DefaultPrimitiveInstaller(allow_network=False)
installer.refresh(tmp_path, _component("extensions", "my-ext"))
@@ -290,6 +388,7 @@ def test_refresh_succeeds_and_passes_force_true(tmp_path: Path, monkeypatch):
def _fake_install_from_directory(self, *a, **k):
force_seen.append(k.get("force", False))
self.registry.add("my-ext", {"version": "1.0.0"})
+ return SimpleNamespace(id="my-ext")
monkeypatch.setattr(
ExtensionManager, "install_from_directory", _fake_install_from_directory
diff --git a/tests/unit/test_bundler_records.py b/tests/unit/test_bundler_records.py
index 8f6f0d6547..dc1da118a1 100644
--- a/tests/unit/test_bundler_records.py
+++ b/tests/unit/test_bundler_records.py
@@ -209,3 +209,73 @@ def test_load_records_accepts_forward_compatible_minor_schema(tmp_path: Path):
payload = {"schema_version": "1.5", "bundles": []}
records_path(tmp_path).write_text(json.dumps(payload), encoding="utf-8")
assert load_records(tmp_path) == []
+
+
+@pytest.mark.parametrize(
+ "field,message",
+ [
+ ("bundle_id", "missing its 'bundle_id'"),
+ ("version", "missing its 'version'"),
+ ],
+)
+def test_load_records_rejects_explicit_null_record_field(
+ tmp_path: Path, field: str, message: str
+):
+ """An explicit JSON ``null`` is how a corrupt record spells an empty field.
+
+ ``str(data.get(field, ""))`` defaults only a *missing* key, so a
+ present-but-null value became the literal text ``"None"`` — non-empty, so
+ it sailed past the required-field checks and the record was accepted as a
+ bundle actually named ``"None"``. Mirrors ``manifest._text``.
+ """
+ (tmp_path / ".specify").mkdir()
+ record = {"bundle_id": "a", "version": "1.0.0", "contributed_components": []}
+ record[field] = None
+ payload = {"schema_version": "1.0", "bundles": [record]}
+ records_path(tmp_path).write_text(json.dumps(payload), encoding="utf-8")
+
+ with pytest.raises(BundlerError, match=message):
+ load_records(tmp_path)
+
+
+def test_load_records_rejects_explicit_null_component_id(tmp_path: Path):
+ """A null component id became ``"None"`` and entered the refcount.
+
+ ``components_still_needed`` would then report a phantom
+ ``('presets', 'None')`` as protected.
+ """
+ (tmp_path / ".specify").mkdir()
+ payload = {
+ "schema_version": "1.0",
+ "bundles": [
+ {
+ "bundle_id": "a",
+ "version": "1.0.0",
+ "contributed_components": [{"kind": "presets", "id": None}],
+ }
+ ],
+ }
+ records_path(tmp_path).write_text(json.dumps(payload), encoding="utf-8")
+
+ with pytest.raises(BundlerError, match="missing its 'id'"):
+ load_records(tmp_path)
+
+
+def test_load_records_accepts_explicit_null_installed_at(tmp_path: Path):
+ """``installed_at`` is optional, so a null must become "" — not "None"."""
+ (tmp_path / ".specify").mkdir()
+ payload = {
+ "schema_version": "1.0",
+ "bundles": [
+ {
+ "bundle_id": "a",
+ "version": "1.0.0",
+ "installed_at": None,
+ "contributed_components": [],
+ }
+ ],
+ }
+ records_path(tmp_path).write_text(json.dumps(payload), encoding="utf-8")
+
+ records = load_records(tmp_path)
+ assert records[0].installed_at == ""
diff --git a/tests/unit/test_bundler_references.py b/tests/unit/test_bundler_references.py
index b9ad426660..b910a93e99 100644
--- a/tests/unit/test_bundler_references.py
+++ b/tests/unit/test_bundler_references.py
@@ -27,7 +27,7 @@ def test_bundled_extension_resolves(tmp_path: Path):
def test_builtin_step_type_resolves(tmp_path: Path):
"""A built-in step type must resolve, like a bundled extension.
- Spec Kit ships 11 step types as built-ins registered in ``STEP_REGISTRY``
+ Spec Kit ships 12 step types as built-ins registered in ``STEP_REGISTRY``
rather than as on-disk asset directories, so there is no
``_locate_bundled_step``. The ``steps`` branch of ``_resolved_locally`` only
asked ``StepRegistry(root).is_installed()``, which tracks *community* step
@@ -40,7 +40,7 @@ def test_builtin_step_type_resolves(tmp_path: Path):
warnings: list[str] = []
check = make_reference_checker(root, allow_network=True, warnings=warnings)
- for step_id in ("shell", "gate", "command", "if"):
+ for step_id in ("shell", "gate", "command", "if", "slot"):
assert step_id in BUILTIN_STEP_TYPES, step_id
assert check(_ref("steps", step_id)) is None, step_id
assert warnings == []
diff --git a/tests/unit/test_condition_expression_block.py b/tests/unit/test_condition_expression_block.py
index 7d9d235902..0739a2bc29 100644
--- a/tests/unit/test_condition_expression_block.py
+++ b/tests/unit/test_condition_expression_block.py
@@ -6,9 +6,21 @@
from specify_cli.workflows.base import StepContext
from specify_cli.workflows.expressions import (
condition_has_malformed_expression_block,
+ condition_is_interpolated_to_text,
condition_is_never_evaluated,
evaluate_condition,
+ evaluate_expression,
format_condition_correction,
+ _has_unbalanced_quote,
+ _has_unbalanced_bracket,
+ _has_incomplete_operand,
+ _unresolvable_term,
+ _evaluator_rejects,
+ _is_literal,
+ _strip_stray_delimiters,
+ _COMPARISON_OPERATORS,
+ _WORD_OPERATORS,
+ format_condition_remediation,
)
from specify_cli.workflows.steps.do_while import DoWhileStep
from specify_cli.workflows.steps.if_then import IfThenStep
@@ -131,6 +143,75 @@ def test_a_malformed_block_can_raise_rather_than_be_true():
evaluate_condition("{{ inputs.missing | default('oops }}", ctx)
+# A third fault. The braces are present and they close, but they do not cover the
+# whole condition, so `evaluate_expression` leaves its typed fast path: each block is
+# substituted into the surrounding text and the result is a *string*, which
+# `evaluate_condition` then coerces. Every one of these reads as a real expression and
+# is always true. The validators already told authors the condition must be "a single
+# complete '{{ }}' block" -- nothing checked it.
+INTERPOLATED_TO_TEXT = [
+ "{{ inputs.ready }} and {{ inputs.count > 100 }}", # two blocks joined by an operator
+ "{{ inputs.ready }} or {{ inputs.ready }}",
+ "not {{ inputs.ready }}", # operator outside the block
+ "{{ inputs.count }} > 100", # comparison outside the block
+ "ready: {{ inputs.ready }}", # prose around one block
+ "{{ inputs.ready }}x", # a single trailing character
+]
+
+
+@pytest.mark.parametrize("condition", INTERPOLATED_TO_TEXT)
+def test_a_condition_spliced_into_text_is_silently_true_and_is_flagged(condition):
+ # Ground truth first: the interpolated form really is a string, and really is true
+ # for a set of inputs where the expression the author wrote would be false.
+ ctx = StepContext(inputs={"ready": False, "count": 0})
+ rendered = evaluate_expression(condition, ctx)
+ assert isinstance(rendered, str)
+ assert evaluate_condition(condition, ctx) is True
+
+ assert condition_is_interpolated_to_text(condition) is True
+
+
+@pytest.mark.parametrize("condition", INTERPOLATED_TO_TEXT)
+@pytest.mark.parametrize("step_cls", STEP_CLASSES)
+def test_every_condition_step_rejects_a_spliced_condition(step_cls, condition):
+ config = {"id": "s1", "condition": condition, "then": [], "steps": []}
+ errors = [e for e in step_cls().validate(config) if "'condition'" in e]
+
+ assert len(errors) == 1
+ assert "single '{{ }}' block" in errors[0]
+ # No paste-ready correction: there is no single right rewrite of `{{ a }} and {{ b }}`.
+ assert "Wrap the expression" not in errors[0]
+
+
+VALID_SINGLE_BLOCKS = [
+ "{{ inputs.ready }}",
+ "{{ inputs.ready and inputs.count > 100 }}",
+ "{{ not inputs.ready }}",
+ "{{ inputs.tags | join(', ') == 'a, b' }}",
+ # A '}}' inside a quoted argument does not end the block, so this is still one
+ # expression and must stay on the fast path.
+ "{{ inputs.text | contains('}}') }}",
+ # `evaluate_expression` strips before testing the fast path, so surrounding
+ # whitespace is not "text around the block" and must stay accepted.
+ " {{ inputs.ready }} ",
+]
+
+
+@pytest.mark.parametrize("condition", VALID_SINGLE_BLOCKS)
+@pytest.mark.parametrize("step_cls", STEP_CLASSES)
+def test_a_single_complete_block_is_still_accepted(step_cls, condition):
+ """The narrowing must not widen: one block, however complex, is the supported form."""
+ assert condition_is_interpolated_to_text(condition) is False
+ config = {"id": "s1", "condition": condition, "then": [], "steps": []}
+ assert [e for e in step_cls().validate(config) if "'condition'" in e] == []
+
+
+@pytest.mark.parametrize("condition", NEVER_EVALUATED + MALFORMED_BLOCKS)
+def test_the_older_two_faults_keep_their_own_message(condition):
+ """The new check yields to both, so each fault keeps the advice written for it."""
+ assert condition_is_interpolated_to_text(condition) is False
+
+
@pytest.mark.parametrize("condition", NEVER_EVALUATED + MALFORMED_BLOCKS)
def test_the_two_faults_are_mutually_exclusive(condition):
assert condition_is_never_evaluated(condition) != condition_has_malformed_expression_block(condition)
@@ -290,3 +371,459 @@ def test_malformed_message_offers_no_paste_ready_correction(step_cls, condition)
errors = [e for e in step_cls().validate(config) if "'condition'" in e]
assert "Wrap the expression" not in errors[0]
assert errors[0].rstrip().endswith("Balance the delimiters and quotes.")
+
+
+# A correction is only offered when wrapping would actually repair the condition.
+# These two inputs reach the same "never evaluated" branch, but wrapping them
+# produces something the author must not paste, so the advice names the fault
+# instead. Both were previously advertised as paste-ready (Copilot review).
+UNFIXABLE_BY_WRAPPING = [
+ (" ", "no expression here to wrap"),
+ ("{{ inputs.name == 'abc", "quote opened in it is never closed"),
+ ("'unterminated", "quote opened in it is never closed"),
+ ("inputs.name ==", "missing an operand"),
+ ("inputs.count >", "missing an operand"),
+ ("inputs.ready and", "missing an operand"),
+ ("inputs.x | ", "missing an operand"),
+ ("inputs.f(", "brackets do not balance"),
+]
+
+
+@pytest.mark.parametrize("step_cls", STEP_CLASSES)
+@pytest.mark.parametrize("condition,expected", UNFIXABLE_BY_WRAPPING)
+def test_no_paste_ready_correction_when_wrapping_would_not_repair(
+ step_cls, condition, expected
+):
+ config = {"id": "s1", "condition": condition, "then": [], "steps": []}
+ errors = [e for e in step_cls().validate(config) if "'condition'" in e]
+
+ assert len(errors) == 1
+ assert "Wrap the expression" not in errors[0]
+ assert expected in errors[0]
+
+
+def test_wrapping_whitespace_would_invert_the_condition():
+ """Why the blank case gets advice instead of a suggestion.
+
+ `{{ }}` interpolates to the empty string, so pasting it turns an always-true
+ condition into an always-false one -- a different defect, not a repair.
+ """
+ ctx = StepContext(inputs={})
+ assert evaluate_condition(" ", ctx) is True
+ assert evaluate_condition("{{ }}", ctx) is False
+
+
+def test_wrapping_an_open_quote_inverts_the_condition():
+ """Why the unbalanced-quote case gets advice instead of a suggestion.
+
+ The raw-close fallback evaluates a truncated comparison and yields the string
+ "False", which evaluate_condition then reads as the `false` keyword. Pasting
+ the "correction" flips the condition rather than repairing it.
+ """
+ ctx = StepContext(inputs={"name": "Bob"})
+ assert evaluate_condition("{{ inputs.name == 'abc", ctx) is True
+ assert evaluate_condition("{{ inputs.name == 'abc }}", ctx) is False
+
+
+@pytest.mark.parametrize(
+ "text,unbalanced",
+ [
+ ("inputs.name == 'abc'", False),
+ ('inputs.name == "abc"', False),
+ ("inputs.name == 'abc", True),
+ ('inputs.name == "abc', True),
+ ("inputs.text == '\"'", False),
+ ("inputs.count > 100", False),
+ ],
+)
+def test_unbalanced_quote_scan(text, unbalanced):
+ assert _has_unbalanced_quote(text) is unbalanced
+
+
+# The property behind the case list above, stated once so a new malformed shape
+# is caught by the invariant rather than by adding another fixture row.
+# Genuine expressions only. TRICKY_CONDITIONS is a quoting/escaping fixture for
+# the formatter and deliberately includes prose, so it must not be reused here.
+OFFERED_CORRECTION_INPUTS = [
+ "inputs.count > 100",
+ 'inputs.name == "zzz"',
+ "inputs.name == 'zzz'",
+ "{{ inputs.count > 100",
+ "{{ true }} and {{ inputs.ready",
+ "inputs.a and inputs.b",
+ "inputs.name",
+ "not inputs.ready",
+ "inputs.tags | join(',')",
+ # The tricky-quoting cases from TRICKY_CONDITIONS that really are expressions.
+ # Listed rather than filtered out of that fixture, so adding prose there cannot
+ # silently widen what this invariant claims.
+ 'inputs.a == "x" and inputs.b == \'y\'',
+ "inputs.path == 'C:" + BACKSLASH + "tmp'",
+ 'inputs.path == "C:' + BACKSLASH + 'tmp"',
+ "inputs.a == 'x\ty'",
+ "inputs.a == 'x\ry'",
+ "inputs.ten == 'mười'",
+ '{{ inputs.name == "zzz"',
+ "}} inputs.count > 100 {{",
+]
+
+
+@pytest.mark.parametrize("condition", OFFERED_CORRECTION_INPUTS)
+def test_every_offered_correction_is_a_complete_expression(condition):
+ """Whatever is advertised as paste-ready must pass our own validators.
+
+ Both earlier rounds of this fix were partial because they enumerated broken
+ shapes -- blank, then unbalanced quote. This asserts the property instead: if
+ the remediation offers a correction at all, the wrapped form it hands back is
+ a single complete block that neither validator objects to.
+ """
+ advice = format_condition_remediation(condition)
+ assert advice.startswith("Wrap the expression: ")
+
+ suggested = yaml.safe_load(
+ "condition: " + advice.split("Wrap the expression: ", 1)[1].rstrip(".")
+ )["condition"]
+ assert condition_is_never_evaluated(suggested) is False
+ assert condition_has_malformed_expression_block(suggested) is False
+
+
+@pytest.mark.parametrize("condition,_reason", UNFIXABLE_BY_WRAPPING)
+def test_withheld_corrections_would_indeed_have_been_broken(condition, _reason):
+ """The other half: what is withheld really would not have survived wrapping.
+
+ Guards against the gate growing over-eager and refusing to help with input it
+ could have corrected.
+ """
+ core = _strip_stray_delimiters(condition).strip()
+ wrapped = "{{ " + core + " }}"
+ assert (
+ not core
+ or _has_unbalanced_quote(core)
+ or _has_unbalanced_bracket(core)
+ or _has_incomplete_operand(core)
+ or condition_is_never_evaluated(wrapped)
+ or condition_has_malformed_expression_block(wrapped)
+ )
+
+
+@pytest.mark.parametrize(
+ "text,unbalanced",
+ [
+ ("inputs.f(1)", False),
+ ("inputs.f(", True),
+ ("inputs.f)", True),
+ ("inputs.tags[0]", False),
+ ("inputs.text == '('", False),
+ ],
+)
+def test_unbalanced_bracket_scan(text, unbalanced):
+ assert _has_unbalanced_bracket(text) is unbalanced
+
+
+def test_incomplete_operand_reads_the_evaluator_operator_list():
+ """The check must not restate the operator table it is predicting."""
+ for op in _COMPARISON_OPERATORS:
+ assert _has_incomplete_operand("inputs.a" + op) is True
+ assert _has_incomplete_operand("inputs.a" + op + "inputs.b") is False
+
+
+def test_incomplete_operand_covers_every_operator_the_evaluator_splits_on():
+ """Hard-coded on purpose.
+
+ Parametrising over `_COMPARISON_OPERATORS` shrinks with the constant, so
+ dropping an operator from it would make that test pass vacuously -- the same
+ can't-fail-when-it-matters shape this module exists to reject. Listing the
+ operators here means removing one from the evaluator fails a test.
+ """
+ for op in ("!=", "==", ">=", "<=", ">", "<", " not in ", " in ", " and ", " or "):
+ assert _has_incomplete_operand("inputs.a" + op) is True, op
+ assert _has_incomplete_operand("inputs.a" + op + "inputs.b") is False, op
+
+
+# Copilot round 3: the first two gates each inspected only one position. These pin
+# every-position scanning, both ends, and bracket-type matching.
+MULTI_POSITION_UNFIXABLE = [
+ ("inputs.a == inputs.b ==", "missing an operand"), # trailing, not the first op
+ ("and inputs.ready", "missing an operand"), # leading boolean operator
+ ("inputs.a not in", "missing an operand"), # trailing word operator
+ ("in inputs.tags", "missing an operand"), # leading word operator
+ ("inputs.f(]", "brackets do not balance"), # matched count, wrong types
+ ("inputs.f(]", "brackets do not balance"),
+ ("inputs.items | length", "the evaluator rejects it"),
+ ("inputs.tags | join", "used in an unsupported form"),
+ ('he said "hi" then left', "is not a name the evaluator can resolve"),
+ ("inputs.count+1", "is not a valid path segment"),
+ ("inputs.a === inputs.b", "is not a name the evaluator can resolve"),
+ ("bogus == 'x'", "is not one of the namespace roots"),
+ ("inputs.payload | from_json()", "the evaluator rejects it"),
+ # `_find_top_level` matches " and " with literal spaces, so a newline before
+ # the keyword is not an operator: the wrapped form evaluates False where the
+ # same expression with a space evaluates True.
+ ("inputs.x == 1\nand inputs.name == 'abc'", "is not a name the evaluator can resolve"),
+]
+
+
+@pytest.mark.parametrize("step_cls", STEP_CLASSES)
+@pytest.mark.parametrize("condition,expected", MULTI_POSITION_UNFIXABLE)
+def test_gates_inspect_every_position_not_just_the_first(step_cls, condition, expected):
+ config = {"id": "s1", "condition": condition, "then": [], "steps": []}
+ errors = [e for e in step_cls().validate(config) if "'condition'" in e]
+
+ assert len(errors) == 1
+ assert "Wrap the expression" not in errors[0]
+ assert expected in errors[0]
+
+
+@pytest.mark.parametrize(
+ "text,unbalanced",
+ [
+ ("inputs.f(]", True), # counts match, types do not
+ ("inputs.f[)", True),
+ ("inputs.f(}", True),
+ ("inputs.f([])", False),
+ ("inputs.f(])", True),
+ ("inputs.text == '(]'", False), # mismatched pair inside a quoted operand
+ ],
+)
+def test_bracket_scan_matches_types_not_just_depth(text, unbalanced):
+ assert _has_unbalanced_bracket(text) is unbalanced
+
+
+def test_word_operators_are_derived_from_the_evaluator_table():
+ """Guards the derivation, not the literal tuple.
+
+ If a space-delimited operator is added to _COMPARISON_OPERATORS, the end-of-core
+ checks must pick it up without another edit here.
+ """
+ assert _WORD_OPERATORS == (" or ", " and ", " not in ", " in ")
+ for op in _WORD_OPERATORS:
+ assert _has_incomplete_operand("inputs.a" + op.rstrip()) is True, op
+ assert _has_incomplete_operand(op.lstrip() + "inputs.a") is True, op
+
+
+def test_the_probe_reports_what_the_evaluator_reports():
+ """The parse probe must not restate the filter table.
+
+ Four review rounds each found a shape the structural gates did not know about.
+ Asking the evaluator removes that class: any filter used under an unknown name
+ or in an unsupported form is reported by the code that will run.
+ """
+ assert _evaluator_rejects("inputs.items | length") is not None
+ assert _evaluator_rejects("inputs.tags | join") is not None
+ assert _evaluator_rejects("inputs.tags | join(',')") is None
+ assert _evaluator_rejects("inputs.count > 100") is None
+
+
+@pytest.mark.parametrize(
+ "text,not_a_path",
+ [
+ ("inputs.name", False),
+ ("inputs.a.b.c", False),
+ ("inputs.tags[0]", False),
+ ("not inputs.ready", False),
+ ("true", False),
+ ("42", False),
+ ("'a literal'", False),
+ ("inputs.count > 100", False), # has an operator, not a bare term
+ ("inputs.count+1", True), # the evaluator has no arithmetic
+ ('he said "hi" then left', True),
+ # _resolve_dot_path keys on [w-]+, so a key literally named "2bad" resolves.
+ ("inputs.2bad", False),
+ ("inputs.tags[foo]", True),
+ ("inputs.matrix[0][1]", True),
+ # Round 7: an operand one level down, which the single-term gate never saw.
+ ("inputs.a === inputs.b", True),
+ ("bogus", True),
+ ("bogus == 'x'", True),
+ ("item.name == 'x'", False),
+ ("fan_in.results | join(',')", False),
+ ("context.run_id != ''", False),
+ ],
+)
+def test_operands_must_be_literals_or_known_paths(text, not_a_path):
+ """Recursing to the leaves replaced the single-term check.
+
+ The old gate only looked at a core with no operator, so `inputs.a === inputs.b`
+ and `bogus == 'x'` walked past it. This asserts the reachable leaf instead.
+ """
+ assert (_unresolvable_term(text) is not None) is not_a_path
+
+
+@pytest.mark.parametrize(
+ "condition",
+ [
+ # Valid against a string output and exercised in tests/test_workflows.py.
+ # The probe hands from_json a dict, so treating every probe error as a
+ # rejection withheld a correction from a good condition.
+ "steps.emit.output.stdout | from_json",
+ # The filter argument is resolved from the namespace too.
+ "inputs.tags | join(inputs.separator)",
+ ],
+)
+def test_probe_value_errors_are_not_treated_as_rejections(condition):
+ assert _evaluator_rejects(condition) is None
+ assert format_condition_remediation(condition).startswith("Wrap the expression: ")
+
+
+@pytest.mark.parametrize(
+ "condition",
+ ["inputs.items | length", "inputs.tags | join"],
+)
+def test_filter_wiring_errors_are_still_rejections(condition):
+ """The other half: a filter named wrong or used wrong is the author's text."""
+ assert _evaluator_rejects(condition) is not None
+ assert "Wrap the expression" not in format_condition_remediation(condition)
+
+
+@pytest.mark.parametrize(
+ "condition,literal",
+ [
+ ("42", True),
+ ("3.14", True),
+ ("-7", True),
+ # `1e3` has no "." so the evaluator calls int() on it, which fails; it then
+ # falls through to a path lookup. float() alone accepted it here.
+ ("1e3", False),
+ ("'one'", True),
+ ('"one"', True),
+ # Two literals, not one: the evaluator requires the opening quote's match to
+ # be the final character, which first/last-character equality does not.
+ ("'a' 'b'", False),
+ ("'a' == 'b'", False),
+ ("true", True),
+ ("inputs.name", False),
+ ],
+)
+def test_literal_test_mirrors_the_evaluator(condition, literal):
+ assert _is_literal(condition) is literal
+
+
+@pytest.mark.parametrize(
+ "condition",
+ [
+ # `_build_namespace` hands back mappings, so an indexed root always resolves
+ # to None however the index is written.
+ "inputs[0]",
+ "steps[1]",
+ "1e3",
+ "'a' 'b'",
+ ],
+)
+def test_shapes_the_evaluator_resolves_to_none_get_no_correction(condition):
+ advice = format_condition_remediation(condition)
+ assert "Wrap the expression" not in advice
+
+
+# The two shapes below were each offered or withheld for the wrong reason. Both are
+# checked against what the evaluator actually does with the wrapped form, not against
+# a restatement of the check, so a check that drifts from the evaluator fails here.
+CORRECTION_OFFERED = "Wrap the expression"
+
+
+def _wrapped_evaluates(condition: str) -> bool:
+ ctx = StepContext(
+ inputs={
+ "tag": "x",
+ "tags": ["a", "b"],
+ "count": 3,
+ "fallback": ", ",
+ "blob": '{"k": 1}',
+ }
+ )
+ try:
+ evaluate_condition("{{ " + condition + " }}", ctx)
+ except Exception:
+ return False
+ return True
+
+
+@pytest.mark.parametrize(
+ "condition",
+ [
+ "inputs.tag in ['x', 'y']",
+ "inputs.tag not in ['x']",
+ "inputs.tag in [inputs.other, 'z']",
+ # `_evaluate_simple_expression` drops empty segments, so a trailing comma is
+ # `[1, 2]` rather than `[1, 2, None]`, and an empty list is a list.
+ "inputs.count in [1, 2,]",
+ "inputs.count in []",
+ ],
+)
+def test_list_literal_operands_keep_the_correction(condition):
+ """A list literal is a term, not a name.
+
+ Resolving the brackets as a path reported `"['x', 'y']" is not a name the
+ evaluator can resolve` and withheld the correction from a condition that
+ wrapping repairs completely.
+ """
+ assert CORRECTION_OFFERED in format_condition_remediation(condition)
+ assert _wrapped_evaluates(condition)
+
+
+@pytest.mark.parametrize(
+ "condition",
+ ["inputs.tags | join(bogus)", "inputs.tags | map(bogus)"],
+)
+def test_filter_arguments_that_make_the_wrapped_form_raise_lose_the_correction(condition):
+ """A filter argument is an operand like any other.
+
+ `_apply_filter` evaluates it with `_evaluate_simple_expression`, so a name that
+ is no namespace root arrives as None and the filter raises on it. Skipping the
+ argument offered these as paste-ready.
+ """
+ assert CORRECTION_OFFERED not in format_condition_remediation(condition)
+ assert not _wrapped_evaluates(condition)
+
+
+def test_a_filter_argument_that_cannot_resolve_loses_it_even_without_raising():
+ """`default` tolerates the None, so this one is policy rather than a crash.
+
+ Withholding it is the same call already made for an unresolvable name anywhere
+ else -- `bogus == 'x'` evaluates fine and is withheld too -- so the argument
+ check does not need the wrapped form to raise before it declines.
+ """
+ condition = "inputs.count | default(bogus)"
+ assert CORRECTION_OFFERED not in format_condition_remediation(condition)
+ assert _wrapped_evaluates(condition)
+ assert CORRECTION_OFFERED not in format_condition_remediation("bogus == 'x'")
+
+
+@pytest.mark.parametrize(
+ "condition",
+ [
+ "inputs.tags | join(', ')",
+ "inputs.tags | join(inputs.fallback)",
+ "inputs.tags | map('name')",
+ "inputs.count | default(0)",
+ "inputs.blob | from_json",
+ ],
+)
+def test_resolvable_filter_arguments_keep_the_correction(condition):
+ """The other direction: the argument check must not become a blanket refusal."""
+ assert CORRECTION_OFFERED in format_condition_remediation(condition)
+ assert _wrapped_evaluates(condition)
+
+
+@pytest.mark.parametrize("condition", ["item[0] == 'x'", "item[1] == 'y'"])
+def test_an_indexed_item_root_keeps_the_correction(condition):
+ """`item` is the only root that is not always a mapping.
+
+ `StepContext.item` is `Any` and a fan-out assigns the item value itself, so an
+ item that is a list makes `item[0]` resolve. Rejecting every indexed root
+ withheld the correction from a condition that evaluates.
+ """
+ ctx = StepContext(inputs={"a": 1}, item=["x", "y"])
+ assert CORRECTION_OFFERED in format_condition_remediation(condition)
+ assert evaluate_condition("{{ " + condition + " }}", ctx) is True
+
+
+@pytest.mark.parametrize("condition", ["inputs[0]", "steps[1]", "fan_in[0]", "context[0]"])
+def test_indexing_an_always_mapping_root_still_loses_the_correction(condition):
+ """The other side of that split, so it does not widen into "any indexed root".
+
+ `_build_namespace` hands these back as mappings, so `_resolve_dot_path` takes
+ the index branch, finds no list, and returns None however the index is written.
+ """
+ ctx = StepContext(inputs={"a": 1}, item=["x", "y"])
+ assert CORRECTION_OFFERED not in format_condition_remediation(condition)
+ assert evaluate_condition("{{ " + condition + " }}", ctx) is False
diff --git a/tests/workflows/test_bundled_speckit_workflow.py b/tests/workflows/test_bundled_speckit_workflow.py
new file mode 100644
index 0000000000..50b53fa5dd
--- /dev/null
+++ b/tests/workflows/test_bundled_speckit_workflow.py
@@ -0,0 +1,58 @@
+"""Guards for the bundled Full SDD Cycle workflow."""
+
+from __future__ import annotations
+
+from pathlib import Path
+
+import yaml
+
+from specify_cli.workflows.engine import WorkflowDefinition, validate_workflow
+
+REPO_ROOT = Path(__file__).resolve().parents[2]
+BUNDLED = REPO_ROOT / "workflows" / "speckit" / "workflow.yml"
+REFERENCE_DOC = REPO_ROOT / "docs" / "reference" / "workflows.md"
+DOC_INTRO = "Here is the built-in **Full SDD Cycle** workflow that ships with Spec Kit:"
+
+
+def _documented_workflow() -> object:
+ """Return the workflow YAML the reference guide claims is the shipped one."""
+ text = REFERENCE_DOC.read_text(encoding="utf-8")
+ intro = text.index(DOC_INTRO)
+ start = text.index("```yaml", intro) + len("```yaml")
+ end = text.index("```", start)
+ return yaml.safe_load(text[start:end])
+
+
+def test_bundled_speckit_workflow_has_no_unused_scope_input() -> None:
+ """Every declared input must be referenced; scope was a dead prompt (#4384)."""
+ text = BUNDLED.read_text(encoding="utf-8")
+ definition = WorkflowDefinition.from_string(text)
+ assert validate_workflow(definition) == []
+ assert "scope" not in definition.inputs
+ assert "spec" in definition.inputs
+
+ raw = yaml.safe_load(text)
+ assert "scope" not in raw.get("inputs", {})
+ assert "inputs.scope" not in text
+
+ for step in raw["steps"]:
+ args = (step.get("input") or {}).get("args")
+ if args is None:
+ continue
+ assert "inputs.scope" not in str(args)
+
+
+def test_reference_doc_matches_the_shipped_workflow() -> None:
+ """The reference guide reproduces this workflow, so it must not drift from it.
+
+ ``docs/reference/workflows.md`` introduces its YAML block as the workflow
+ that ships with Spec Kit, so a reader is entitled to treat it as the real
+ definition. It had drifted on four points -- a stale ``version`` and
+ ``speckit_version``, a short ``integrations.any`` list, and an
+ ``integration`` default of ``copilot`` where the shipped default is
+ ``auto`` -- which is exactly the sort of thing nothing else would catch.
+
+ The comparison is on parsed YAML, not text, so the guide stays free to
+ format lists however reads best; only the content has to agree.
+ """
+ assert _documented_workflow() == yaml.safe_load(BUNDLED.read_text(encoding="utf-8"))
diff --git a/tests/workflows/test_overlay_commands.py b/tests/workflows/test_overlay_commands.py
index 8a344cacdf..c28f53b050 100644
--- a/tests/workflows/test_overlay_commands.py
+++ b/tests/workflows/test_overlay_commands.py
@@ -220,6 +220,115 @@ def test_overlay_add_rejects_non_positive_priority(self, project_dir, monkeypatc
assert result.exit_code == 1
assert "must be >= 1" in result.output
+ def test_overlay_add_keeps_non_ascii_text_readable(
+ self, project_dir, monkeypatch
+ ):
+ """``overlay add`` must not escape non-ASCII text in the written file.
+
+ Overlay files are documented as hand-authored, so writing them back
+ with ``\\uXXXX`` escapes makes the user's own file unreadable.
+ """
+ monkeypatch.setattr("specify_cli._require_specify_project", lambda: project_dir)
+ _write_workflow(
+ project_dir,
+ "wf",
+ {
+ "schema_version": "1.0",
+ "workflow": {"id": "wf", "name": "WF", "version": "1.0.0"},
+ "steps": [{"id": "a", "type": "command", "command": "echo"}],
+ },
+ )
+ message = "Revisar el plan — ¿aprobar? 日本語"
+ overlay_file = project_dir / "overlay.yml"
+ overlay_file.write_text(
+ yaml.safe_dump(
+ {
+ "id": "ov1",
+ "extends": "wf",
+ "priority": 10,
+ "edits": [
+ {
+ "operation": "replace",
+ "anchor": "a",
+ "step": {
+ "id": "a",
+ "type": "gate",
+ "message": message,
+ "options": ["approve"],
+ },
+ }
+ ],
+ },
+ allow_unicode=True,
+ ),
+ encoding="utf-8",
+ )
+
+ result = runner.invoke(app, ["workflow", "overlay", "add", str(overlay_file)])
+ assert result.exit_code == 0, result.output
+
+ installed = (
+ project_dir / ".specify" / "workflows" / "overlays" / "wf" / "ov1.yml"
+ )
+ text = installed.read_text(encoding="utf-8")
+ assert message in text, text
+ assert "\\u" not in text and "\\x" not in text, text
+ # The value must still round-trip identically.
+ data = yaml.safe_load(text)
+ assert data["edits"][0]["step"]["message"] == message
+
+ def test_overlay_set_priority_keeps_non_ascii_text_readable(
+ self, project_dir, monkeypatch
+ ):
+ """Toggling an overlay must not mangle non-ASCII text already in it."""
+ monkeypatch.setattr("specify_cli._require_specify_project", lambda: project_dir)
+ _write_workflow(
+ project_dir,
+ "wf",
+ {
+ "schema_version": "1.0",
+ "workflow": {"id": "wf", "name": "WF", "version": "1.0.0"},
+ "steps": [{"id": "a", "type": "command", "command": "echo"}],
+ },
+ )
+ message = "Revisar el plan — ¿aprobar? 日本語"
+ _write_overlay(
+ project_dir,
+ "wf",
+ "ov1",
+ {
+ "id": "ov1",
+ "extends": "wf",
+ "priority": 10,
+ "edits": [
+ {
+ "operation": "replace",
+ "anchor": "a",
+ "step": {
+ "id": "a",
+ "type": "gate",
+ "message": message,
+ "options": ["approve"],
+ },
+ }
+ ],
+ },
+ )
+
+ result = runner.invoke(
+ app, ["workflow", "overlay", "set-priority", "wf", "ov1", "20"]
+ )
+ assert result.exit_code == 0, result.output
+
+ text = (
+ project_dir / ".specify" / "workflows" / "overlays" / "wf" / "ov1.yml"
+ ).read_text(encoding="utf-8")
+ assert message in text, text
+ assert "\\u" not in text and "\\x" not in text, text
+ data = yaml.safe_load(text)
+ assert data["priority"] == 20
+ assert data["edits"][0]["step"]["message"] == message
+
def test_overlay_set_priority(self, project_dir, monkeypatch):
monkeypatch.setattr("specify_cli._require_specify_project", lambda: project_dir)
_write_workflow(
diff --git a/tests/workflows/test_overlay_schema.py b/tests/workflows/test_overlay_schema.py
index 77e0432eca..a91efb0a36 100644
--- a/tests/workflows/test_overlay_schema.py
+++ b/tests/workflows/test_overlay_schema.py
@@ -124,6 +124,57 @@ def test_multiple_operation_fields_rejected(self):
assert overlay is None
assert any("multiple" in e.lower() for e in errors), errors
+ @pytest.mark.parametrize(
+ "first,second",
+ [("remove", "insert_after"), ("insert_after", "remove")],
+ ids=["remove-first", "insert_after-first"],
+ )
+ def test_multiple_operation_keys_reported_in_declaration_order(
+ self, first, second
+ ):
+ """The message must name the keys in the order the user wrote them.
+
+ Collecting the keys by iterating the ``_SHORTHAND_OPERATION_KEYS``
+ frozenset made the order depend on per-process string-hash
+ randomization, so the same overlay file produced a different message on
+ every run. Whatever fixed order a frozenset happens to have in a given
+ process, one of these two parametrizations contradicts it -- so this
+ pair fails deterministically without the fix, in every process.
+ """
+ overlay, errors = validate_overlay_yaml(
+ {
+ "id": "ov",
+ "extends": "wf",
+ "edits": [{first: "a", second: "a"}],
+ }
+ )
+ assert overlay is None
+ assert errors == [
+ f"Edit at index 0 has multiple operation keys: {first!r}, {second!r}."
+ ]
+
+ @pytest.mark.parametrize(
+ "first,second",
+ [("remove", "insert_after"), ("insert_after", "remove")],
+ ids=["remove-first", "insert_after-first"],
+ )
+ def test_shorthand_mixed_with_operation_names_first_declared_key(
+ self, first, second
+ ):
+ """``shorthand_keys[0]`` must be the first key the user declared."""
+ overlay, errors = validate_overlay_yaml(
+ {
+ "id": "ov",
+ "extends": "wf",
+ "edits": [{first: "a", second: "a", "operation": "replace"}],
+ }
+ )
+ assert overlay is None
+ assert errors == [
+ f"Edit at index 0 mixes shorthand operation key ({first!r}) "
+ f"with explicit 'operation' field."
+ ]
+
def test_invalid_operation_field_rejected(self):
overlay, errors = validate_overlay_yaml(
{
diff --git a/tests/workflows/test_slot_step.py b/tests/workflows/test_slot_step.py
new file mode 100644
index 0000000000..50416586a7
--- /dev/null
+++ b/tests/workflows/test_slot_step.py
@@ -0,0 +1,242 @@
+"""Tests for the workflow slot step."""
+
+from __future__ import annotations
+
+import json
+from pathlib import Path
+
+import pytest
+import yaml
+
+from specify_cli.workflows import BUILTIN_STEP_TYPES, get_step_type
+from specify_cli.workflows.base import RunStatus, StepContext, StepStatus
+from specify_cli.workflows.engine import WorkflowDefinition, WorkflowEngine, validate_workflow
+from specify_cli.workflows.overlays import WorkflowResolver
+from specify_cli.workflows.steps.slot import SlotStep
+
+
+def _workflow_data(steps: list[dict[str, object]]) -> dict[str, object]:
+ return {
+ "schema_version": "1.0",
+ "workflow": {"id": "slot-workflow", "name": "Slot Workflow", "version": "1.0.0"},
+ "steps": steps,
+ }
+
+
+def _write_workflow(project_root: Path, data: dict[str, object]) -> None:
+ workflow_dir = project_root / ".specify" / "workflows" / "slot-workflow"
+ workflow_dir.mkdir(parents=True, exist_ok=True)
+ (workflow_dir / "workflow.yml").write_text(
+ yaml.safe_dump(data), encoding="utf-8"
+ )
+
+
+def _write_overlay(project_root: Path, data: dict[str, object]) -> None:
+ overlay_dir = (
+ project_root / ".specify" / "workflows" / "overlays" / "slot-workflow"
+ )
+ overlay_dir.mkdir(parents=True, exist_ok=True)
+ (overlay_dir / "fill-slot.yml").write_text(yaml.safe_dump(data), encoding="utf-8")
+
+
+def test_slot_step_is_registered_as_builtin():
+ step = get_step_type("slot")
+
+ assert isinstance(step, SlotStep)
+ assert step.type_key == "slot"
+ assert "slot" in BUILTIN_STEP_TYPES
+
+
+def test_slot_step_validate_returns_errors_for_malformed_names():
+ step = SlotStep()
+
+ assert any("missing required 'id'" in error for error in step.validate({}))
+ assert "requires a 'name' field" in step.validate({"id": "slot"})[0]
+ assert "requires a 'name' field" in step.validate({"id": "slot", "name": None})[0]
+ for name in ("", " ", 123):
+ errors = step.validate({"id": "slot", "name": name})
+ assert len(errors) == 1
+ assert "non-blank string" in errors[0]
+ assert step.validate({"id": "slot", "name": "lint"}) == []
+
+
+@pytest.mark.parametrize(
+ ("name", "expected_error"),
+ [
+ (None, "requires a 'name' field"),
+ ("", "non-blank string"),
+ (" ", "non-blank string"),
+ (123, "non-blank string"),
+ ],
+)
+def test_slot_step_errors_are_reported_through_workflow_validation(
+ name: object, expected_error: str
+):
+ definition = WorkflowDefinition(
+ _workflow_data([{"id": "slot", "type": "slot", "name": name}])
+ )
+
+ errors = validate_workflow(definition)
+
+ assert any("Slot step 'slot'" in error for error in errors)
+ assert any(expected_error in error for error in errors)
+
+
+def test_addressable_nested_slot_step_validates_cleanly():
+ definition = WorkflowDefinition(
+ _workflow_data(
+ [
+ {
+ "id": "conditional",
+ "type": "if",
+ "condition": "true",
+ "then": [{"id": "slot", "type": "slot", "name": "lint"}],
+ }
+ ]
+ )
+ )
+
+ assert validate_workflow(definition) == []
+
+
+def test_slot_step_skips_without_mutating_the_shared_instance():
+ step = SlotStep()
+ before = vars(step).copy()
+
+ result = step.execute({"id": "slot", "name": "lint"}, StepContext())
+
+ assert result.status is StepStatus.SKIPPED
+ assert result.output == {"slot": "lint"}
+ assert vars(step) == before
+
+
+def test_slot_step_fails_when_executed_inside_fan_out():
+ step = SlotStep()
+
+ result = step.execute(
+ {"id": "slot", "name": "per-item"},
+ StepContext(inside_fan_out=True),
+ )
+
+ assert result.status is StepStatus.FAILED
+ assert "not supported inside fan-out" in result.error
+ assert result.output == {}
+
+
+def test_unfilled_slot_is_persisted_and_does_not_halt_workflow(project_dir):
+ _write_workflow(
+ project_dir,
+ _workflow_data(
+ [
+ {"id": "slot", "type": "slot", "name": "post-implement"},
+ {"id": "marker", "type": "shell", "run": "echo marker"},
+ ]
+ ),
+ )
+ engine = WorkflowEngine(project_dir)
+
+ definition = engine.load_workflow("slot-workflow")
+ assert engine.validate(definition) == []
+ state = engine.execute(definition, run_id="slot-run")
+
+ assert state.status is RunStatus.COMPLETED
+ state_data = json.loads((state.runs_dir / "state.json").read_text(encoding="utf-8"))
+ assert state_data["step_results"]["slot"]["status"] == "skipped"
+ assert state_data["step_results"]["slot"]["output"] == {"slot": "post-implement"}
+ assert state_data["step_results"]["marker"]["status"] == "completed"
+
+ log_entries = [
+ json.loads(line)
+ for line in (state.runs_dir / "log.jsonl").read_text(encoding="utf-8").splitlines()
+ ]
+ skipped_events = [
+ entry
+ for entry in log_entries
+ if entry["event"] == "step_completed" and entry["step_id"] == "slot"
+ ]
+ assert len(skipped_events) == 1
+ assert skipped_events[0]["status"] == "skipped"
+
+
+def test_overlay_replaces_slot_and_attributes_it_to_the_overlay(project_dir):
+ _write_workflow(
+ project_dir,
+ _workflow_data(
+ [
+ {"id": "before", "type": "shell", "run": "echo before"},
+ {"id": "slot", "type": "slot", "name": "post-implement"},
+ {"id": "after", "type": "shell", "run": "echo after"},
+ ]
+ ),
+ )
+ _write_overlay(
+ project_dir,
+ {
+ "id": "fill-slot",
+ "extends": "slot-workflow",
+ "edits": [
+ {
+ "replace": "slot",
+ "step": {"id": "slot", "type": "shell", "run": "echo filled"},
+ }
+ ],
+ },
+ )
+ engine = WorkflowEngine(project_dir)
+
+ definition = engine.load_workflow("slot-workflow")
+ assert [step["id"] for step in definition.steps] == ["before", "slot", "after"]
+ assert definition.steps[1]["type"] == "shell"
+ assert engine.validate(definition) == []
+ state = engine.execute(definition, run_id="filled-slot-run")
+ assert state.status is RunStatus.COMPLETED
+ assert "filled" in state.step_results["slot"]["output"]["stdout"]
+
+ _definition, _layers, attribution = WorkflowResolver(project_dir).resolve_with_layers(
+ "slot-workflow"
+ )
+ sources = {step.step_id: step.source for step in attribution}
+ assert sources == {
+ "before": "base",
+ "slot": "project:fill-slot",
+ "after": "base",
+ }
+
+
+def test_slot_steps_are_rejected_inside_fan_out_templates():
+ definition = WorkflowDefinition(
+ _workflow_data(
+ [
+ {
+ "id": "fan",
+ "type": "fan-out",
+ "items": [],
+ "step": {"id": "slot", "type": "slot", "name": "per-item"},
+ }
+ ]
+ )
+ )
+
+ errors = validate_workflow(definition)
+
+ assert any(
+ "Slot step 'slot' is not supported inside fan-out templates" in error
+ for error in errors
+ )
+
+
+def test_non_slot_fan_out_templates_remain_valid():
+ definition = WorkflowDefinition(
+ _workflow_data(
+ [
+ {
+ "id": "fan",
+ "type": "fan-out",
+ "items": [],
+ "step": {"id": "template", "type": "shell", "run": "echo item"},
+ }
+ ]
+ )
+ )
+
+ assert validate_workflow(definition) == []
diff --git a/workflows/ARCHITECTURE.md b/workflows/ARCHITECTURE.md
index 477c0968ae..680cfc4237 100644
--- a/workflows/ARCHITECTURE.md
+++ b/workflows/ARCHITECTURE.md
@@ -19,6 +19,7 @@ flowchart TD
G --> H{Step type?}
H -- command --> I["CommandStep.execute()"]
H -- shell --> J["ShellStep.execute()"]
+ H -- slot --> V["SlotStep.execute()"]
H -- gate --> K["GateStep.execute()"]
H -- "if" --> L["IfThenStep.execute()"]
H -- switch --> M["SwitchStep.execute()"]
@@ -27,12 +28,13 @@ flowchart TD
I --> P{Result status?}
J --> P
+ V --> P
K --> P
L --> P
M --> P
N --> P
O --> P
- P -- COMPLETED --> Q{Has next_steps?}
+ P -- "COMPLETED / SKIPPED" --> Q{Has next_steps?}
P -- PAUSED --> R["Save state → exit"]
P -- FAILED --> S["Log error → exit"]
Q -- Yes --> G
@@ -77,7 +79,7 @@ When a `gate` step pauses execution, the engine persists `current_step_index` an
## Step Types
-The engine ships with 11 built-in step types, each in its own subpackage under `src/specify_cli/workflows/steps/`:
+The engine ships with 12 built-in step types, each in its own subpackage under `src/specify_cli/workflows/steps/`:
| Type Key | Class | Purpose | Returns `next_steps`? |
|----------|-------|---------|-----------------------|
@@ -85,6 +87,7 @@ The engine ships with 11 built-in step types, each in its own subpackage under `
| `prompt` | `PromptStep` | Send an arbitrary inline prompt to integration CLI | No |
| `shell` | `ShellStep` | Run a shell command, capture output | No |
| `init` | `InitStep` | Bootstrap a project (equivalent to `specify init`) | No |
+| `slot` | `SlotStep` | Named workflow slot; skipped when unfilled | No |
| `gate` | `GateStep` | Interactive human review/approval | No (pauses in CI) |
| `if` | `IfThenStep` | Conditional branching (then/else) | Yes |
| `switch` | `SwitchStep` | Multi-branch dispatch on expression | Yes |
@@ -200,6 +203,7 @@ src/specify_cli/
│ ├── command/ # Dispatch command to AI integration
│ ├── shell/ # Run shell command
│ ├── init/ # Bootstrap a project (specify init)
+│ ├── slot/ # Named workflow slot; skipped when unfilled
│ ├── gate/ # Human review checkpoint
│ ├── if_then/ # Conditional branching
│ ├── prompt/ # Arbitrary inline prompts
diff --git a/workflows/PUBLISHING.md b/workflows/PUBLISHING.md
index 2caf55d810..4e26fdfb25 100644
--- a/workflows/PUBLISHING.md
+++ b/workflows/PUBLISHING.md
@@ -90,7 +90,7 @@ steps:
- ✅ `version` follows semantic versioning (X.Y.Z)
- ✅ `description` is concise
- ✅ All step IDs are unique
-- ✅ Step types are valid: `command`, `prompt`, `shell`, `gate`, `if`, `switch`, `while`, `do-while`, `fan-out`, `fan-in`
+- ✅ Step types are valid: `command`, `prompt`, `shell`, `init`, `slot`, `gate`, `if`, `switch`, `while`, `do-while`, `fan-out`, `fan-in`
- ✅ Required fields present per step type (e.g., `condition` for `if`, `expression` for `switch`)
- ✅ Input types are valid: `string`, `number`, `boolean`
- ✅ Step IDs do not contain `:` (reserved for engine-generated nested IDs like `parentId:childId`)
diff --git a/workflows/README.md b/workflows/README.md
index bd0b767938..676ea6faf0 100644
--- a/workflows/README.md
+++ b/workflows/README.md
@@ -70,15 +70,22 @@ specify workflow run ./my-workflow.yml --input spec="Build a user authentication
### Multiple Inputs
+When a workflow declares more than one input, pass each with a separate
+`--input` flag. For example, a custom workflow that gates steps on a
+`scope` selector:
+
```bash
-specify workflow run speckit \
+specify workflow run ./my-workflow.yml \
--input spec="Build a user authentication system with OAuth support" \
--input scope="backend-only"
```
+The bundled `speckit` workflow only declares `spec` (and optional
+`integration`); it does not take a `scope` input.
+
## Step Types
-Workflows support 11 built-in step types:
+Workflows support 12 built-in step types:
### Command Steps (default)
@@ -145,11 +152,29 @@ and resolves the integration from the step config or the workflow default:
here: true # or: project: my-project
integration: copilot # Optional: defaults to workflow integration
integration_options: "--skills" # Optional: extra options for the integration
- script: sh # Optional: sh or ps
+ script: sh # Optional: sh, ps, or py
force: true # Optional: required when target directory already exists
preset: healthcare-compliance # Optional preset ID
```
+### Workflow Slots
+
+Declare a named workflow slot that downstream projects can fill with a
+workflow overlay. The slot is skipped when unfilled; its `id` is the overlay
+anchor and `name` is a required human-readable label:
+
+```yaml
+- id: post-implement
+ type: slot
+ name: "Post-implementation checks"
+```
+
+Use an overlay `replace` edit anchored on `post-implement` to fill the slot.
+Keep the same `id` when downstream expressions or fan-in steps reference it,
+and preserve any output keys they consume. Slot steps are invalid inside
+`fan-out.step` templates because those runtime-multiplied templates cannot be
+targeted by overlays.
+
### Gate Steps
Pause for human review. The workflow resumes when `specify workflow resume` is called:
diff --git a/workflows/catalog.json b/workflows/catalog.json
index 967120afb0..c26a3230a1 100644
--- a/workflows/catalog.json
+++ b/workflows/catalog.json
@@ -1,6 +1,6 @@
{
"schema_version": "1.0",
- "updated_at": "2026-04-13T00:00:00Z",
+ "updated_at": "2026-09-03T00:00:00Z",
"catalog_url": "https://raw.githubusercontent.com/github/spec-kit/main/workflows/catalog.json",
"workflows": {
"speckit": {
@@ -8,7 +8,7 @@
"name": "Full SDD Cycle",
"description": "Runs specify \u2192 plan \u2192 tasks \u2192 implement with review gates",
"author": "GitHub",
- "version": "1.0.0",
+ "version": "1.0.1",
"url": "https://raw.githubusercontent.com/github/spec-kit/main/workflows/speckit/workflow.yml",
"tags": ["sdd", "full-cycle"]
}
diff --git a/workflows/speckit/workflow.yml b/workflows/speckit/workflow.yml
index 230675b327..571318af4d 100644
--- a/workflows/speckit/workflow.yml
+++ b/workflows/speckit/workflow.yml
@@ -2,7 +2,7 @@ schema_version: "1.0"
workflow:
id: "speckit"
name: "Full SDD Cycle"
- version: "1.0.0"
+ version: "1.0.1"
author: "GitHub"
description: "Runs specify → plan → tasks → implement with review gates"
@@ -35,10 +35,6 @@ inputs:
type: string
default: "auto"
prompt: "Integration to use (e.g. claude, copilot, gemini; 'auto' uses the project's initialized integration)"
- scope:
- type: string
- default: "full"
- enum: ["full", "backend-only", "frontend-only"]
steps:
- id: specify
]