mirror of
https://github.com/github/spec-kit.git
synced 2026-08-15 14:02:04 +02:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d1f50fcbe6 | ||
|
|
71470cb551 | ||
|
|
7e0db461ec | ||
|
|
4faecb8422 | ||
|
|
0121cabd3e | ||
|
|
7f4f576829 | ||
|
|
d6e09a17c4 | ||
|
|
672f812927 | ||
|
|
ea26fc265f | ||
|
|
83883a2eba | ||
|
|
b485cd8c1f | ||
|
|
2b36f0ce94 | ||
|
|
c807e2450c | ||
|
|
56aec8a936 | ||
|
|
7346819039 | ||
|
|
618d16e94b | ||
|
|
5a052d36d1 | ||
|
|
bfabf4ce65 | ||
|
|
b66044ae8e | ||
|
|
54f8b2cdf0 | ||
|
|
13c3c942fc | ||
|
|
47938c30d7 | ||
|
|
16f45774a5 | ||
|
|
229022943c | ||
|
|
e79fa25f3f | ||
|
|
750ce47cb8 | ||
|
|
197dde6253 | ||
|
|
f2583e675c | ||
|
|
996b24c561 | ||
|
|
b77ca572ca | ||
|
|
c1bceb625c | ||
|
|
7dd706880e | ||
|
|
bd595cf838 | ||
|
|
85d3ed289d | ||
|
|
5ec937e07a | ||
|
|
6461f09483 | ||
|
|
fb427e47ef | ||
|
|
bd04776dcc | ||
|
|
c5258e680b | ||
|
|
9d15554c08 | ||
|
|
c3bbcc40a0 | ||
|
|
39b56626c9 |
@@ -16,6 +16,8 @@ body:
|
||||
- If you host a bundle catalog, test catalog installation with `specify bundle catalog add <catalog-url> --id <catalog-id> --policy install-allowed` and `specify bundle install <bundle-id>`
|
||||
- If your bundle depends on components from non-default catalogs, document those catalog URLs and test installation from a clean project
|
||||
|
||||
**After submitting:** a maintainer applies the `bundle-submission` label during issue triage, which starts the automated catalog validation. You don't need to apply any label or ask for one.
|
||||
|
||||
- type: input
|
||||
id: bundle-id
|
||||
attributes:
|
||||
|
||||
@@ -14,6 +14,8 @@ body:
|
||||
- Create a GitHub release with a version tag (e.g., v1.0.0)
|
||||
- Test installation: `specify extension add <extension-name> --from <your-release-url>`
|
||||
|
||||
**After submitting:** a maintainer applies the `extension-submission` label during issue triage, which starts the automated catalog validation. You don't need to apply any label or ask for one.
|
||||
|
||||
- type: input
|
||||
id: extension-id
|
||||
attributes:
|
||||
|
||||
@@ -14,6 +14,8 @@ body:
|
||||
- Create a GitHub release with a version tag (e.g., v1.0.0)
|
||||
- Test installation from the release archive: `specify preset add --from <download-url>`
|
||||
|
||||
**After submitting:** a maintainer applies the `preset-submission` label during issue triage, which starts the automated catalog validation. You don't need to apply any label or ask for one.
|
||||
|
||||
- type: input
|
||||
id: preset-id
|
||||
attributes:
|
||||
|
||||
@@ -8,6 +8,10 @@ updates:
|
||||
- dependency-name: "github/gh-aw-actions/**"
|
||||
- dependency-name: "github/gh-aw-actions" # Managed by gh aw compile. Version-locked to the gh-aw compiler; do not bump.
|
||||
package-ecosystem: github-actions
|
||||
groups:
|
||||
codeql-action:
|
||||
patterns:
|
||||
- "github/codeql-action*"
|
||||
schedule:
|
||||
interval: weekly
|
||||
version: 2
|
||||
|
||||
+5
-5
@@ -1,4 +1,4 @@
|
||||
# gh-aw-metadata: {"schema_version":"v4","frontmatter_hash":"687ea37b376b3b918331c13fce6cdbf5b9898bab8e514ca57b662b92b6d3cd2c","body_hash":"83b7e917f475d6ddf32f17e7da09dd4097a01dddbcbbf8eeec673912285de8b2","compiler_version":"v0.79.8","strict":true,"agent_id":"copilot","engine_versions":{"copilot":"1.0.60"}}
|
||||
# gh-aw-metadata: {"schema_version":"v4","frontmatter_hash":"f9532e77722bfd32e8f626cbfbf6c5372ddcd9997963d965d53e8531b3e28a15","body_hash":"83b7e917f475d6ddf32f17e7da09dd4097a01dddbcbbf8eeec673912285de8b2","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_CI_TRIGGER_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/upload-artifact","sha":"043fb46d1a93c77aae656e7c1c64a875d1fc6a0a","version":"v7.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
|
||||
#
|
||||
@@ -511,9 +511,9 @@ jobs:
|
||||
mkdir -p "${RUNNER_TEMP}/gh-aw/safeoutputs"
|
||||
mkdir -p /tmp/gh-aw/safeoutputs
|
||||
mkdir -p /tmp/gh-aw/mcp-logs/safeoutputs
|
||||
cat > "${RUNNER_TEMP}/gh-aw/safeoutputs/config.json" << 'GH_AW_SAFE_OUTPUTS_CONFIG_7b8091d12bfe1e7b_EOF'
|
||||
{"add_comment":{"max":2},"add_labels":{"allowed":["extension-submission","validation-passed","validation-failed","needs-info"],"max":3},"create_pull_request":{"draft":true,"labels":["extension-submission","automated"],"max":1,"max_patch_files":100,"max_patch_size":1024,"protect_top_level_dot_folders":true,"protected_files":["package.json","bun.lockb","bunfig.toml","deno.json","deno.jsonc","deno.lock","global.json","NuGet.Config","Directory.Packages.props","mix.exs","mix.lock","go.mod","go.sum","stack.yaml","stack.yaml.lock","pom.xml","build.gradle","build.gradle.kts","settings.gradle","settings.gradle.kts","gradle.properties","package-lock.json","yarn.lock","pnpm-lock.yaml","npm-shrinkwrap.json","requirements.txt","Pipfile","Pipfile.lock","pyproject.toml","setup.py","setup.cfg","Gemfile","Gemfile.lock","uv.lock","CODEOWNERS","DESIGN.md","CONTRIBUTING.md","SECURITY.md","CODE_OF_CONDUCT.md","AGENTS.md","CLAUDE.md","GEMINI.md"],"protected_files_policy":"blocked","title_prefix":"[extension] "},"create_report_incomplete_issue":{},"missing_data":{},"missing_tool":{},"noop":{"max":1,"report-as-issue":"false"},"report_incomplete":{}}
|
||||
GH_AW_SAFE_OUTPUTS_CONFIG_7b8091d12bfe1e7b_EOF
|
||||
cat > "${RUNNER_TEMP}/gh-aw/safeoutputs/config.json" << 'GH_AW_SAFE_OUTPUTS_CONFIG_253299f841a5ea45_EOF'
|
||||
{"add_comment":{"max":2},"add_labels":{"allowed":["extension-submission","validation-passed","validation-failed","needs-info"],"max":3},"create_pull_request":{"allowed_files":["extensions/catalog.community.json","docs/community/extensions.md"],"draft":true,"labels":["extension-submission","automated"],"max":1,"max_patch_files":100,"max_patch_size":1024,"protect_top_level_dot_folders":true,"protected_files":["package.json","bun.lockb","bunfig.toml","deno.json","deno.jsonc","deno.lock","global.json","NuGet.Config","Directory.Packages.props","mix.exs","mix.lock","go.mod","go.sum","stack.yaml","stack.yaml.lock","pom.xml","build.gradle","build.gradle.kts","settings.gradle","settings.gradle.kts","gradle.properties","package-lock.json","yarn.lock","pnpm-lock.yaml","npm-shrinkwrap.json","requirements.txt","Pipfile","Pipfile.lock","pyproject.toml","setup.py","setup.cfg","Gemfile","Gemfile.lock","uv.lock","CODEOWNERS","DESIGN.md","CONTRIBUTING.md","SECURITY.md","CODE_OF_CONDUCT.md","AGENTS.md","CLAUDE.md","GEMINI.md"],"protected_files_policy":"blocked","title_prefix":"[extension] "},"create_report_incomplete_issue":{},"missing_data":{},"missing_tool":{},"noop":{"max":1,"report-as-issue":"false"},"report_incomplete":{}}
|
||||
GH_AW_SAFE_OUTPUTS_CONFIG_253299f841a5ea45_EOF
|
||||
- name: Generate Safe Outputs Tools
|
||||
env:
|
||||
GH_AW_TOOLS_META_JSON: |
|
||||
@@ -1705,7 +1705,7 @@ jobs:
|
||||
GH_AW_ALLOWED_DOMAINS: "api.business.githubcopilot.com,api.enterprise.githubcopilot.com,api.github.com,api.githubcopilot.com,api.individual.githubcopilot.com,api.snapcraft.io,archive.ubuntu.com,azure.archive.ubuntu.com,crl.geotrust.com,crl.globalsign.com,crl.identrust.com,crl.sectigo.com,crl.thawte.com,crl.usertrust.com,crl.verisign.com,crl3.digicert.com,crl4.digicert.com,crls.ssl.com,github.com,host.docker.internal,json-schema.org,json.schemastore.org,keyserver.ubuntu.com,ocsp.digicert.com,ocsp.geotrust.com,ocsp.globalsign.com,ocsp.identrust.com,ocsp.sectigo.com,ocsp.ssl.com,ocsp.thawte.com,ocsp.usertrust.com,ocsp.verisign.com,packagecloud.io,packages.cloud.google.com,packages.microsoft.com,ppa.launchpad.net,raw.githubusercontent.com,registry.npmjs.org,s.symcb.com,s.symcd.com,security.ubuntu.com,telemetry.enterprise.githubcopilot.com,ts-crl.ws.symantec.com,ts-ocsp.ws.symantec.com,www.googleapis.com"
|
||||
GITHUB_SERVER_URL: ${{ github.server_url }}
|
||||
GITHUB_API_URL: ${{ github.api_url }}
|
||||
GH_AW_SAFE_OUTPUTS_HANDLER_CONFIG: "{\"add_comment\":{\"max\":2},\"add_labels\":{\"allowed\":[\"extension-submission\",\"validation-passed\",\"validation-failed\",\"needs-info\"],\"max\":3},\"create_pull_request\":{\"draft\":true,\"labels\":[\"extension-submission\",\"automated\"],\"max\":1,\"max_patch_files\":100,\"max_patch_size\":1024,\"protect_top_level_dot_folders\":true,\"protected_files\":[\"package.json\",\"bun.lockb\",\"bunfig.toml\",\"deno.json\",\"deno.jsonc\",\"deno.lock\",\"global.json\",\"NuGet.Config\",\"Directory.Packages.props\",\"mix.exs\",\"mix.lock\",\"go.mod\",\"go.sum\",\"stack.yaml\",\"stack.yaml.lock\",\"pom.xml\",\"build.gradle\",\"build.gradle.kts\",\"settings.gradle\",\"settings.gradle.kts\",\"gradle.properties\",\"package-lock.json\",\"yarn.lock\",\"pnpm-lock.yaml\",\"npm-shrinkwrap.json\",\"requirements.txt\",\"Pipfile\",\"Pipfile.lock\",\"pyproject.toml\",\"setup.py\",\"setup.cfg\",\"Gemfile\",\"Gemfile.lock\",\"uv.lock\",\"CODEOWNERS\",\"DESIGN.md\",\"CONTRIBUTING.md\",\"SECURITY.md\",\"CODE_OF_CONDUCT.md\",\"AGENTS.md\",\"CLAUDE.md\",\"GEMINI.md\"],\"protected_files_policy\":\"blocked\",\"title_prefix\":\"[extension] \"},\"create_report_incomplete_issue\":{},\"missing_data\":{},\"missing_tool\":{},\"noop\":{\"max\":1,\"report-as-issue\":\"false\"},\"report_incomplete\":{}}"
|
||||
GH_AW_SAFE_OUTPUTS_HANDLER_CONFIG: "{\"add_comment\":{\"max\":2},\"add_labels\":{\"allowed\":[\"extension-submission\",\"validation-passed\",\"validation-failed\",\"needs-info\"],\"max\":3},\"create_pull_request\":{\"allowed_files\":[\"extensions/catalog.community.json\",\"docs/community/extensions.md\"],\"draft\":true,\"labels\":[\"extension-submission\",\"automated\"],\"max\":1,\"max_patch_files\":100,\"max_patch_size\":1024,\"protect_top_level_dot_folders\":true,\"protected_files\":[\"package.json\",\"bun.lockb\",\"bunfig.toml\",\"deno.json\",\"deno.jsonc\",\"deno.lock\",\"global.json\",\"NuGet.Config\",\"Directory.Packages.props\",\"mix.exs\",\"mix.lock\",\"go.mod\",\"go.sum\",\"stack.yaml\",\"stack.yaml.lock\",\"pom.xml\",\"build.gradle\",\"build.gradle.kts\",\"settings.gradle\",\"settings.gradle.kts\",\"gradle.properties\",\"package-lock.json\",\"yarn.lock\",\"pnpm-lock.yaml\",\"npm-shrinkwrap.json\",\"requirements.txt\",\"Pipfile\",\"Pipfile.lock\",\"pyproject.toml\",\"setup.py\",\"setup.cfg\",\"Gemfile\",\"Gemfile.lock\",\"uv.lock\",\"CODEOWNERS\",\"DESIGN.md\",\"CONTRIBUTING.md\",\"SECURITY.md\",\"CODE_OF_CONDUCT.md\",\"AGENTS.md\",\"CLAUDE.md\",\"GEMINI.md\"],\"protected_files_policy\":\"blocked\",\"title_prefix\":\"[extension] \"},\"create_report_incomplete_issue\":{},\"missing_data\":{},\"missing_tool\":{},\"noop\":{\"max\":1,\"report-as-issue\":\"false\"},\"report_incomplete\":{}}"
|
||||
GH_AW_CI_TRIGGER_TOKEN: ${{ secrets.GH_AW_CI_TRIGGER_TOKEN }}
|
||||
with:
|
||||
github-token: ${{ secrets.GH_AW_GITHUB_TOKEN || secrets.GITHUB_TOKEN }}
|
||||
|
||||
@@ -31,6 +31,9 @@ safe-outputs:
|
||||
labels: [extension-submission, automated]
|
||||
draft: true
|
||||
max: 1
|
||||
allowed-files:
|
||||
- extensions/catalog.community.json
|
||||
- docs/community/extensions.md
|
||||
protected-files:
|
||||
policy: blocked
|
||||
exclude:
|
||||
|
||||
+5
-5
@@ -1,4 +1,4 @@
|
||||
# gh-aw-metadata: {"schema_version":"v4","frontmatter_hash":"b4ba1db5fdec754fa825cc3160879924118bc454a781eed70ef6c90beab83a95","body_hash":"cb6c19088fa13da0a8320c174e8c14c4887d2c8a005a5cb2d2d2faa3f890de39","compiler_version":"v0.79.8","strict":true,"agent_id":"copilot","engine_versions":{"copilot":"1.0.60"}}
|
||||
# gh-aw-metadata: {"schema_version":"v4","frontmatter_hash":"09fb89e95c57c7beeaa0c823fb35f38d5f9db898a419e59dd0a323e7a9209753","body_hash":"cb6c19088fa13da0a8320c174e8c14c4887d2c8a005a5cb2d2d2faa3f890de39","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_CI_TRIGGER_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/upload-artifact","sha":"043fb46d1a93c77aae656e7c1c64a875d1fc6a0a","version":"v7.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
|
||||
#
|
||||
@@ -511,9 +511,9 @@ jobs:
|
||||
mkdir -p "${RUNNER_TEMP}/gh-aw/safeoutputs"
|
||||
mkdir -p /tmp/gh-aw/safeoutputs
|
||||
mkdir -p /tmp/gh-aw/mcp-logs/safeoutputs
|
||||
cat > "${RUNNER_TEMP}/gh-aw/safeoutputs/config.json" << 'GH_AW_SAFE_OUTPUTS_CONFIG_78499ff7c917c441_EOF'
|
||||
{"add_comment":{"max":2},"add_labels":{"allowed":["preset-submission","validation-passed","validation-failed","needs-info"],"max":3},"create_pull_request":{"draft":true,"labels":["preset-submission","automated"],"max":1,"max_patch_files":100,"max_patch_size":1024,"protect_top_level_dot_folders":true,"protected_files":["package.json","bun.lockb","bunfig.toml","deno.json","deno.jsonc","deno.lock","global.json","NuGet.Config","Directory.Packages.props","mix.exs","mix.lock","go.mod","go.sum","stack.yaml","stack.yaml.lock","pom.xml","build.gradle","build.gradle.kts","settings.gradle","settings.gradle.kts","gradle.properties","package-lock.json","yarn.lock","pnpm-lock.yaml","npm-shrinkwrap.json","requirements.txt","Pipfile","Pipfile.lock","pyproject.toml","setup.py","setup.cfg","Gemfile","Gemfile.lock","uv.lock","CODEOWNERS","DESIGN.md","CONTRIBUTING.md","SECURITY.md","CODE_OF_CONDUCT.md","AGENTS.md","CLAUDE.md","GEMINI.md"],"protected_files_policy":"blocked","title_prefix":"[preset] "},"create_report_incomplete_issue":{},"missing_data":{},"missing_tool":{},"noop":{"max":1,"report-as-issue":"false"},"report_incomplete":{}}
|
||||
GH_AW_SAFE_OUTPUTS_CONFIG_78499ff7c917c441_EOF
|
||||
cat > "${RUNNER_TEMP}/gh-aw/safeoutputs/config.json" << 'GH_AW_SAFE_OUTPUTS_CONFIG_429ff69f52872d0b_EOF'
|
||||
{"add_comment":{"max":2},"add_labels":{"allowed":["preset-submission","validation-passed","validation-failed","needs-info"],"max":3},"create_pull_request":{"allowed_files":["presets/catalog.community.json","docs/community/presets.md"],"draft":true,"labels":["preset-submission","automated"],"max":1,"max_patch_files":100,"max_patch_size":1024,"protect_top_level_dot_folders":true,"protected_files":["package.json","bun.lockb","bunfig.toml","deno.json","deno.jsonc","deno.lock","global.json","NuGet.Config","Directory.Packages.props","mix.exs","mix.lock","go.mod","go.sum","stack.yaml","stack.yaml.lock","pom.xml","build.gradle","build.gradle.kts","settings.gradle","settings.gradle.kts","gradle.properties","package-lock.json","yarn.lock","pnpm-lock.yaml","npm-shrinkwrap.json","requirements.txt","Pipfile","Pipfile.lock","pyproject.toml","setup.py","setup.cfg","Gemfile","Gemfile.lock","uv.lock","CODEOWNERS","DESIGN.md","CONTRIBUTING.md","SECURITY.md","CODE_OF_CONDUCT.md","AGENTS.md","CLAUDE.md","GEMINI.md"],"protected_files_policy":"blocked","title_prefix":"[preset] "},"create_report_incomplete_issue":{},"missing_data":{},"missing_tool":{},"noop":{"max":1,"report-as-issue":"false"},"report_incomplete":{}}
|
||||
GH_AW_SAFE_OUTPUTS_CONFIG_429ff69f52872d0b_EOF
|
||||
- name: Generate Safe Outputs Tools
|
||||
env:
|
||||
GH_AW_TOOLS_META_JSON: |
|
||||
@@ -1705,7 +1705,7 @@ jobs:
|
||||
GH_AW_ALLOWED_DOMAINS: "api.business.githubcopilot.com,api.enterprise.githubcopilot.com,api.github.com,api.githubcopilot.com,api.individual.githubcopilot.com,api.snapcraft.io,archive.ubuntu.com,azure.archive.ubuntu.com,crl.geotrust.com,crl.globalsign.com,crl.identrust.com,crl.sectigo.com,crl.thawte.com,crl.usertrust.com,crl.verisign.com,crl3.digicert.com,crl4.digicert.com,crls.ssl.com,github.com,host.docker.internal,json-schema.org,json.schemastore.org,keyserver.ubuntu.com,ocsp.digicert.com,ocsp.geotrust.com,ocsp.globalsign.com,ocsp.identrust.com,ocsp.sectigo.com,ocsp.ssl.com,ocsp.thawte.com,ocsp.usertrust.com,ocsp.verisign.com,packagecloud.io,packages.cloud.google.com,packages.microsoft.com,ppa.launchpad.net,raw.githubusercontent.com,registry.npmjs.org,s.symcb.com,s.symcd.com,security.ubuntu.com,telemetry.enterprise.githubcopilot.com,ts-crl.ws.symantec.com,ts-ocsp.ws.symantec.com,www.googleapis.com"
|
||||
GITHUB_SERVER_URL: ${{ github.server_url }}
|
||||
GITHUB_API_URL: ${{ github.api_url }}
|
||||
GH_AW_SAFE_OUTPUTS_HANDLER_CONFIG: "{\"add_comment\":{\"max\":2},\"add_labels\":{\"allowed\":[\"preset-submission\",\"validation-passed\",\"validation-failed\",\"needs-info\"],\"max\":3},\"create_pull_request\":{\"draft\":true,\"labels\":[\"preset-submission\",\"automated\"],\"max\":1,\"max_patch_files\":100,\"max_patch_size\":1024,\"protect_top_level_dot_folders\":true,\"protected_files\":[\"package.json\",\"bun.lockb\",\"bunfig.toml\",\"deno.json\",\"deno.jsonc\",\"deno.lock\",\"global.json\",\"NuGet.Config\",\"Directory.Packages.props\",\"mix.exs\",\"mix.lock\",\"go.mod\",\"go.sum\",\"stack.yaml\",\"stack.yaml.lock\",\"pom.xml\",\"build.gradle\",\"build.gradle.kts\",\"settings.gradle\",\"settings.gradle.kts\",\"gradle.properties\",\"package-lock.json\",\"yarn.lock\",\"pnpm-lock.yaml\",\"npm-shrinkwrap.json\",\"requirements.txt\",\"Pipfile\",\"Pipfile.lock\",\"pyproject.toml\",\"setup.py\",\"setup.cfg\",\"Gemfile\",\"Gemfile.lock\",\"uv.lock\",\"CODEOWNERS\",\"DESIGN.md\",\"CONTRIBUTING.md\",\"SECURITY.md\",\"CODE_OF_CONDUCT.md\",\"AGENTS.md\",\"CLAUDE.md\",\"GEMINI.md\"],\"protected_files_policy\":\"blocked\",\"title_prefix\":\"[preset] \"},\"create_report_incomplete_issue\":{},\"missing_data\":{},\"missing_tool\":{},\"noop\":{\"max\":1,\"report-as-issue\":\"false\"},\"report_incomplete\":{}}"
|
||||
GH_AW_SAFE_OUTPUTS_HANDLER_CONFIG: "{\"add_comment\":{\"max\":2},\"add_labels\":{\"allowed\":[\"preset-submission\",\"validation-passed\",\"validation-failed\",\"needs-info\"],\"max\":3},\"create_pull_request\":{\"allowed_files\":[\"presets/catalog.community.json\",\"docs/community/presets.md\"],\"draft\":true,\"labels\":[\"preset-submission\",\"automated\"],\"max\":1,\"max_patch_files\":100,\"max_patch_size\":1024,\"protect_top_level_dot_folders\":true,\"protected_files\":[\"package.json\",\"bun.lockb\",\"bunfig.toml\",\"deno.json\",\"deno.jsonc\",\"deno.lock\",\"global.json\",\"NuGet.Config\",\"Directory.Packages.props\",\"mix.exs\",\"mix.lock\",\"go.mod\",\"go.sum\",\"stack.yaml\",\"stack.yaml.lock\",\"pom.xml\",\"build.gradle\",\"build.gradle.kts\",\"settings.gradle\",\"settings.gradle.kts\",\"gradle.properties\",\"package-lock.json\",\"yarn.lock\",\"pnpm-lock.yaml\",\"npm-shrinkwrap.json\",\"requirements.txt\",\"Pipfile\",\"Pipfile.lock\",\"pyproject.toml\",\"setup.py\",\"setup.cfg\",\"Gemfile\",\"Gemfile.lock\",\"uv.lock\",\"CODEOWNERS\",\"DESIGN.md\",\"CONTRIBUTING.md\",\"SECURITY.md\",\"CODE_OF_CONDUCT.md\",\"AGENTS.md\",\"CLAUDE.md\",\"GEMINI.md\"],\"protected_files_policy\":\"blocked\",\"title_prefix\":\"[preset] \"},\"create_report_incomplete_issue\":{},\"missing_data\":{},\"missing_tool\":{},\"noop\":{\"max\":1,\"report-as-issue\":\"false\"},\"report_incomplete\":{}}"
|
||||
GH_AW_CI_TRIGGER_TOKEN: ${{ secrets.GH_AW_CI_TRIGGER_TOKEN }}
|
||||
with:
|
||||
github-token: ${{ secrets.GH_AW_GITHUB_TOKEN || secrets.GITHUB_TOKEN }}
|
||||
|
||||
@@ -31,6 +31,9 @@ safe-outputs:
|
||||
labels: [preset-submission, automated]
|
||||
draft: true
|
||||
max: 1
|
||||
allowed-files:
|
||||
- presets/catalog.community.json
|
||||
- docs/community/presets.md
|
||||
protected-files:
|
||||
policy: blocked
|
||||
exclude:
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
name: "Catalog: Auto-assign submission"
|
||||
name: "Catalog: Notify submission"
|
||||
|
||||
on:
|
||||
issues:
|
||||
types: [opened, labeled]
|
||||
|
||||
jobs:
|
||||
assign:
|
||||
notify:
|
||||
if: >
|
||||
(github.event.action == 'opened' && (
|
||||
contains(github.event.issue.labels.*.name, 'extension-submission') ||
|
||||
@@ -24,24 +24,8 @@ jobs:
|
||||
- uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9
|
||||
with:
|
||||
script: |
|
||||
const issue = context.payload.issue;
|
||||
const assigned = (issue.assignees || []).map(a => a.login);
|
||||
const marker = '<!-- catalog-assign-bot -->';
|
||||
|
||||
// Assign mnriem if not already assigned
|
||||
if (!assigned.includes('mnriem')) {
|
||||
try {
|
||||
await github.rest.issues.addAssignees({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
issue_number: context.issue.number,
|
||||
assignees: ['mnriem'],
|
||||
});
|
||||
} catch (e) {
|
||||
console.log(`Warning: could not assign mnriem: ${e.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
// Post team notification if not already posted
|
||||
const comments = await github.paginate(
|
||||
github.rest.issues.listComments,
|
||||
|
||||
@@ -22,11 +22,11 @@ jobs:
|
||||
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
|
||||
- name: Initialize CodeQL
|
||||
uses: github/codeql-action/init@d1ba80a13dd99fba24a470575428917156a28b43 # v4
|
||||
uses: github/codeql-action/init@5595ccaf912efad79be6eef63a5619ff05969be3 # v4
|
||||
with:
|
||||
languages: ${{ matrix.language }}
|
||||
|
||||
- name: Perform CodeQL Analysis
|
||||
uses: github/codeql-action/analyze@d1ba80a13dd99fba24a470575428917156a28b43 # v4
|
||||
uses: github/codeql-action/analyze@5595ccaf912efad79be6eef63a5619ff05969be3 # v4
|
||||
with:
|
||||
category: "/language:${{ matrix.language }}"
|
||||
|
||||
@@ -2,6 +2,82 @@
|
||||
|
||||
<!-- insert new changelog below this comment -->
|
||||
|
||||
## [0.16.4] - 2026-08-14
|
||||
|
||||
### Changed
|
||||
|
||||
- Add SpecAssay preset to community catalog (#4123)
|
||||
- Update Intake Authoring Governance preset to v0.3.1 (#4121)
|
||||
- Update Superspec extension to v1.0.2 (#4120)
|
||||
- fix(taskstoissues): widen task-ID regex to match IDs longer than 3 digits (#4101)
|
||||
- Add Architecture Governance extension to community catalog (#4122)
|
||||
- fix(workflows): validate non-string step types (#4111)
|
||||
- Harden community submission workflow output allowlists (#4103)
|
||||
- chore(deps): bump github/codeql-action (init + analyze) from 4.37.5 to 4.37.6 (#4114)
|
||||
- Add SpecAssay Check extension to community catalog (#4113)
|
||||
- fix(integrations): dispatch goose commands via `goose run` (#2416) (#3781)
|
||||
- fix(powershell): stop Out-Null swallowing the AVAILABLE_DOCS status lines (#3891)
|
||||
- fix: remove TOCTOU race in RunState.load (#3839)
|
||||
- fix: decode the zipped manifest as UTF-8 before parsing (#3958)
|
||||
- Update Agent Parity Governance preset to v0.4.2 (#4110)
|
||||
- fix: log progress tracker refresh errors instead of silently swallowing (#3975)
|
||||
- [extension] Add SpecJudge extension to community catalog (#4079)
|
||||
- fix(bundler): read the authoritative `default_integration` field, not only its legacy aliases (#3880)
|
||||
- fix(auth): treat exact host patterns literally (#4108)
|
||||
- feat: add Mistral Vibe integration with Claude parity (#4075)
|
||||
- [extension] Add spec-kit-atlas extension to community catalog (#4105)
|
||||
- chore: release 0.16.3, begin 0.16.4.dev0 development (#4107)
|
||||
|
||||
## [0.16.3] - 2026-08-13
|
||||
|
||||
### Changed
|
||||
|
||||
- fix: narrow bare except Exception in VS Code settings merge (#3844)
|
||||
- feat(presets): list presets in resolution/precedence order (#4086) (#4104)
|
||||
- Fix: scaffold self-contained namespaced preset commands (#4076) (#4082)
|
||||
- Update Cross-Platform Governance preset to v0.2.2 (#4080)
|
||||
- fix(bundler): treat a blank active integration as indeterminate in FR-019 (#3886)
|
||||
- Integrate Junie with dot-to-hyphen behavior and command formatting (#4073)
|
||||
- Update A11Y Governance preset to v0.4.3 (#4074)
|
||||
- Fix Alquimia argument hints after folded descriptions (#4063)
|
||||
- fix: use bounded read for bundle download HTTP responses (#3764)
|
||||
- Update iSAQB Architecture Governance preset to v0.2.2 (#4056)
|
||||
- fix(claude): make argument-hint injection fold-aware for long descriptions (#4045)
|
||||
- Add SpecKit Grill Me extension to community catalog (#4052)
|
||||
- Update Architecture Governance preset to v0.5.2 (#4050)
|
||||
- Remove auto-assign from catalog submission workflow (#4054)
|
||||
- docs: clarify example spec guidance (#4048)
|
||||
- Clarify custom checklist ownership and lifecycle (#4028)
|
||||
- Update Archive Extension to v1.2.2 (#4053)
|
||||
- Update Security Governance preset to v0.6.2 (#4040)
|
||||
- docs: clarify maintainer applies submission label during triage (#4041)
|
||||
- chore: release 0.16.2, begin 0.16.3.dev0 development (#4038)
|
||||
|
||||
## [0.16.2] - 2026-08-10
|
||||
|
||||
### Changed
|
||||
|
||||
- Add Command Code integration to spec-kit (#4019)
|
||||
- fix(workflows): strip a resolved condition before the true/false check (#3883)
|
||||
- fix(workflows): guard a non-string overlay edit 'operation' (#3881)
|
||||
- fix: bound response read in integration catalog fetch (#3818)
|
||||
- Fix bug-test Python dependency provisioning (#4030)
|
||||
- fix(bundle): escape Rich markup in bundle CLI error and status output (#4023)
|
||||
- fix(presets): skip an unreadable restore source in `preset remove` (#4020)
|
||||
- Add Keel Discovery extension to community catalog (#4035)
|
||||
- fix: show error details in preset catalog config read failure (#3840)
|
||||
- Update Reconcile Extension to v1.1.0 (#4034)
|
||||
- Add Model Routing Governance preset to community catalog (#4033)
|
||||
- fix: use missing_ok=True in integration JSON removal (#3846)
|
||||
- fix: use missing_ok=True in extension cache clear (#3845)
|
||||
- fix(extensions): reject duplicate provides.templates/scripts names (#4016)
|
||||
- feat(presets): resolve constitution templates at command time (#3984)
|
||||
- [bug-fix] Fix preset-wrap-drops-argument-hint: inherit argument-hint from core template (#3996)
|
||||
- docs: document installing specify-cli from a custom package index (#4032)
|
||||
- feat(extensions): accept provides.templates and provides.scripts in manifest (#4012)
|
||||
- fix(presets): treat an unreadable core template as missing (#3961)
|
||||
- chore: release 0.16.1, begin 0.16.2.dev0 development (#4014)
|
||||
|
||||
## [0.16.1] - 2026-08-07
|
||||
|
||||
### Changed
|
||||
|
||||
@@ -283,9 +283,11 @@ specify bundle validate --path ./my-bundle # structural + reference checks
|
||||
specify bundle build --path ./my-bundle # produce a versioned .zip artifact
|
||||
```
|
||||
|
||||
Four ready-to-read example manifests live under
|
||||
Four ready-to-read example bundle manifests live under
|
||||
[`examples/bundles/`](examples/bundles/) (product manager, business analyst,
|
||||
security researcher, developer).
|
||||
security researcher, developer). These are bundle packaging examples, not
|
||||
filled generated feature specs; for end-to-end community examples, see the
|
||||
[community walkthroughs](https://github.github.io/spec-kit/community/walkthroughs.html).
|
||||
|
||||
Key guarantees: `info` shows exactly what `install` adds (transparency);
|
||||
installs are idempotent and confined to the project root; `remove` never touches
|
||||
|
||||
@@ -32,9 +32,11 @@ The following community-contributed extensions are available in [`catalog.commun
|
||||
| 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) |
|
||||
| API Evolve | Managed API contract evolution — breaking-change detection, semver enforcement, deprecation orchestration, and lifecycle gates across REST, GraphQL, and gRPC | `process` | Read+Write | [spec-kit-api-evolve](https://github.com/Quratulain-bilal/spec-kit-api-evolve) |
|
||||
| Architect Impact Previewer | Predicts architectural impact, complexity, and risks of proposed changes before implementation. | `visibility` | Read-only | [spec-kit-architect-preview](https://github.com/UmmeHabiba1312/spec-kit-architect-preview) |
|
||||
| Architecture Governance | Keep specs, code & ADRs in sync: citation slots + a read-only, fail-closed validator | `docs` | Read+Write | [spec-kit-arch-governance](https://github.com/ashbrener/spec-kit-arch-governance) |
|
||||
| Architecture Guard | Framework-agnostic architecture review extension for validating implementation against governance and architecture constitutions, detecting architectural drift, and generating non-blocking refactor tasks | `process` | Read+Write | [spec-kit-architecture-guard](https://github.com/DyanGalih/spec-kit-architecture-guard) |
|
||||
| Architecture Workflow | Generate or reverse project-level 4+1 architecture views with per-view and full-workflow commands | `docs` | Read+Write | [spec-kit-arch](https://github.com/bigsmartben/spec-kit-arch) |
|
||||
| Archive Extension | Archive merged features into main project memory. | `docs` | Read+Write | [spec-kit-archive](https://github.com/stn1slv/spec-kit-archive) |
|
||||
| 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) |
|
||||
| spec-kit-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) |
|
||||
| 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) |
|
||||
| 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) |
|
||||
@@ -145,7 +147,10 @@ The following community-contributed extensions are available in [`catalog.commun
|
||||
| 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, and a turbo pipeline profile | `visibility` | 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) |
|
||||
| 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) |
|
||||
|
||||
@@ -7,22 +7,22 @@ The following community-contributed presets customize how Spec Kit behaves — o
|
||||
|
||||
| Preset | Purpose | Provides | Requires | URL |
|
||||
|--------|---------|----------|----------|-----|
|
||||
| A11Y Governance | Adds accessibility (WCAG 2.2 AA), accessible text and JSON status parity, bilingual DE/EN delivery, CEFR-B2 readability, inclusive-content governance, didactic inline-code-comment review, and audit-ready Spec-Kit run evidence to Spec Kit | 10 templates, 3 commands | — | [spec-kit-preset-a11y-governance](https://github.com/hindermath/spec-kit-preset-a11y-governance) |
|
||||
| Agent Parity Governance | Adds shared-guidance and generated-command parity, repository-fleet completion evidence, secret-free runner/status metadata, audit-ready Spec-Kit run evidence, and agent-neutral model-routing guidance across declared AI-agent surfaces. | 6 templates, 3 commands | — | [spec-kit-preset-agent-parity-governance](https://github.com/hindermath/spec-kit-preset-agent-parity-governance) |
|
||||
| A11Y Governance | Adds WCAG 2.2 AA, accessible status output, bilingual CEFR-B2 delivery, inclusive-content and didactic-comment governance, and provider-neutral model routing. | 11 templates, 3 commands | — | [spec-kit-preset-a11y-governance](https://github.com/hindermath/spec-kit-preset-a11y-governance) |
|
||||
| Agent Parity Governance | Adds shared-guidance parity, fleet-completion evidence, secret-free runner metadata, audit-ready Spec Kit evidence, and agent-neutral model routing across declared AI-agent surfaces. | 7 templates, 3 commands | — | [spec-kit-preset-agent-parity-governance](https://github.com/hindermath/spec-kit-preset-agent-parity-governance) |
|
||||
| AIDE In-Place Migration | Adapts the AIDE extension workflow for in-place technology migrations (X → Y pattern) — adds migration objectives, verification gates, knowledge documents, and behavioral equivalence criteria | 2 templates, 8 commands | AIDE extension | [spec-kit-presets](https://github.com/mnriem/spec-kit-presets) |
|
||||
| Architecture Governance | Adds secure software architecture, resumable remote-transaction boundaries, STRIDE+CAPEC threat modeling, arc42 security cross-cutting concepts, S-ADRs, Zero Trust applicability, OWASP SAMM governance, BSI C3A cloud autonomy, BSI C5 cloud compliance assurance, and audit-ready Spec Kit run evidence | 13 templates, 3 commands | — | [spec-kit-preset-architecture-governance](https://github.com/hindermath/spec-kit-preset-architecture-governance) |
|
||||
| Architecture Governance | Adds secure architecture, STRIDE/CAPEC threat modeling, arc42/S-ADR guidance, Zero Trust, SAMM, BSI cloud assurance, audit evidence, and provider-neutral model routing. | 14 templates, 3 commands | — | [spec-kit-preset-architecture-governance](https://github.com/hindermath/spec-kit-preset-architecture-governance) |
|
||||
| Autonomous Run Governance | Adds permission-bounded autonomous delivery, an optional intake-review gate, and preservation of the project's learner and accessibility contract. | 13 templates, 5 commands, 4 scripts | — | [spec-kit-preset-autonomous-run-governance](https://github.com/hindermath/spec-kit-preset-autonomous-run-governance) |
|
||||
| Canon Core | Adapts original Spec Kit workflow to work together with Canon extension | 2 templates, 8 commands | — | [spec-kit-canon](https://github.com/maximiliamus/spec-kit-canon) |
|
||||
| Claude AskUserQuestion | Upgrades `/speckit.clarify` and `/speckit.checklist` on Claude Code from Markdown-table prompts to the native AskUserQuestion picker, with a recommended option and reasoning on every question | 2 commands | — | [spec-kit-preset-claude-ask-questions](https://github.com/0xrafasec/spec-kit-preset-claude-ask-questions) |
|
||||
| Command Density | Compacts the nine core Spec Kit command prompts while preserving scripts, handoffs, placeholders, hook output blocks, and rule structure | 9 commands | — | [spec-kit-preset-command-density](https://github.com/Xopoko/spec-kit-preset-command-density) |
|
||||
| Cross-Platform Governance | Adds Bash/PowerShell and read-only check parity, root-path and native-override review, Unix man pages, bilingual help, Verb-Noun discipline, and audit-ready evidence. | 8 templates, 3 commands | — | [spec-kit-preset-cross-platform-governance](https://github.com/hindermath/spec-kit-preset-cross-platform-governance) |
|
||||
| Cross-Platform Governance | Adds Bash/PowerShell parity, read-only checks, path and native-override review, Unix man pages, bilingual PowerShell help, and provider-neutral model routing. | 9 templates, 3 commands | — | [spec-kit-preset-cross-platform-governance](https://github.com/hindermath/spec-kit-preset-cross-platform-governance) |
|
||||
| Explicit Task Dependencies | Adds explicit `(depends on T###)` dependency declarations and an Execution Wave DAG to tasks.md for parallel scheduling | 1 template, 1 command | — | [spec-kit-preset-explicit-task-dependencies](https://github.com/Quratulain-bilal/spec-kit-preset-explicit-task-dependencies) |
|
||||
| Fiction Book Writing | It adapts the Spec-Driven Development workflow for storytelling to create books or audiobooks (with annotations) in 12 languages: features become story elements, specs become story briefs, plans become story structures, and tasks become scene-by-scene writing tasks. Supports single and multi-POV, all major plot structure frameworks, and two style modes: an author voice sample or humanized AI prose principles. Supports interactive elements like brainstorming, interview, roleplay, and extras like statistics, cover builder, illustration builder, and bio command. Export with templates for KDP, D2D, etc. | 26 templates, 34 commands, 2 scripts | — | [speckit-preset-fiction-book-writing](https://github.com/adaumann/speckit-preset-fiction-book-writing) |
|
||||
| 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 and language-aware requirements collections with atomic migrations, rollback evidence, and safe series authoring. | 12 templates, 5 commands, 7 scripts | — | [spec-kit-preset-intake-authoring-governance](https://github.com/hindermath/spec-kit-preset-intake-authoring-governance) |
|
||||
| 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. | 8 templates, 3 commands, 4 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) |
|
||||
| iSAQB Architecture Governance | Adds iSAQB/CPSA-F and arc42 architecture governance with audit-ready evidence for goals, views, resumability, partial-failure scenarios, ADRs, risks, and technical debt. | 13 templates, 3 commands | — | [spec-kit-preset-isaqb-architecture-governance](https://github.com/hindermath/spec-kit-preset-isaqb-architecture-governance) |
|
||||
| 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) |
|
||||
@@ -30,9 +30,10 @@ The following community-contributed presets customize how Spec Kit behaves — o
|
||||
| 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) |
|
||||
| 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 and security-gate evidence, provider-failure classification, ASVS, supply-chain transparency, and EU regulatory screening. | 14 templates, 3 commands | — | [spec-kit-preset-security-governance](https://github.com/hindermath/spec-kit-preset-security-governance) |
|
||||
| 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) |
|
||||
| SicarioSpec Core | Baseline secure-by-default Spec Kit governance profile. | 5 templates | — | [sicario-spec](https://github.com/dfirs1car1o/sicario-spec) |
|
||||
| Spec2Cloud | Spec-driven workflow tuned for shipping to Azure: spec → plan → tasks → implement → deploy | 5 templates, 8 commands | — | [spec2cloud](https://github.com/Azure-Samples/Spec2Cloud) |
|
||||
| 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) |
|
||||
| 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) |
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
> [!NOTE]
|
||||
> Community walkthroughs are independently created and maintained by their respective authors. They are **not reviewed, nor endorsed, nor supported by GitHub**. Review their content before following along and use at your own discretion.
|
||||
|
||||
See Spec-Driven Development in action across different scenarios with these community-contributed walkthroughs:
|
||||
See Spec-Driven Development in action across different scenarios with these community-contributed walkthroughs. They are useful read-only examples of completed flows, but they are not official golden outputs for generated specs, plans, or tasks.
|
||||
|
||||
- **[Greenfield .NET CLI tool](https://github.com/mnriem/spec-kit-dotnet-cli-demo)** — Builds a Timezone Utility as a .NET single-binary CLI tool from a blank directory, covering the full spec-kit workflow: constitution, specify, plan, tasks, and multi-pass implement using GitHub Copilot agents.
|
||||
|
||||
|
||||
+5
-5
@@ -5,8 +5,7 @@ This guide will help you get started with Spec-Driven Development using Spec Kit
|
||||
> [!NOTE]
|
||||
> Automation scripts are provided as Bash (`.sh`), PowerShell (`.ps1`), and Python (`.py`) variants. Interactive `specify init` prompts you to choose one; non-interactive runs default to a shell variant for your OS. Pass `--script sh|ps|py` to select explicitly.
|
||||
|
||||
> [!NOTE]
|
||||
> Commands are shown here in `/speckit.*` form, but the exact invocation depends on your agent. Some skills-based agents use `$speckit-*` (e.g. Codex, ZCode) or `/skill:speckit-*` (e.g. Kimi). Use whichever form your agent exposes — the steps are otherwise identical.
|
||||
Commands are shown here in `/speckit.*` form, but the exact invocation depends on your agent. Some skills-based agents use `$speckit-*` (e.g. Codex, ZCode) or `/skill:speckit-*` (e.g. Kimi). Use whichever form your agent exposes — the steps are otherwise identical.
|
||||
|
||||
## Recommended Process
|
||||
|
||||
@@ -83,7 +82,7 @@ Generates the design artifacts from the spec. This is where implementation detai
|
||||
|
||||
### Step 5: `/speckit.checklist` — validate the spec
|
||||
|
||||
Generates a quality checklist — "unit tests for your requirements" — to confirm the spec is complete, clear, and consistent before you break the work down.
|
||||
Generates a custom quality checklist — "unit tests for your requirements" — to confirm the spec is complete, clear, and consistent before you break the work down. These custom checklists are reviewer-owned requirements-quality review artifacts: mark an item `[x]` only when the reviewer determines that requirement-quality criterion is satisfied. Checked custom items do not mean implementation work is complete.
|
||||
|
||||
```text
|
||||
/speckit.checklist
|
||||
@@ -107,7 +106,7 @@ Reports conflicts, gaps, and ambiguities across `spec.md`, `plan.md`, and `tasks
|
||||
|
||||
### Step 8: `/speckit.implement` — build it
|
||||
|
||||
Executes the tasks in `tasks.md` in dependency order. Run it once to build everything, or scope it to one phase at a time for large features.
|
||||
Executes the tasks in `tasks.md` in dependency order. Before implementation, it reads checklist checkbox state as a gate and asks before proceeding if any checklist items are unchecked; it does not change any checklist files or markers. The built-in `checklists/requirements.md` checklist is maintained by `/speckit.specify` and `/speckit.clarify`, while custom checklists remain reviewer-owned. Run it once to build everything, or scope it to one phase at a time for large features.
|
||||
|
||||
```text
|
||||
/speckit.implement
|
||||
@@ -136,5 +135,6 @@ Checks the codebase against the spec, plan, and tasks. If it finds gaps, it appe
|
||||
|
||||
- See the [Agentic SDD](reference/agentic-sdd.md) reference for full detail on every command
|
||||
- Read the [complete methodology](https://github.com/github/spec-kit/blob/main/spec-driven.md) for in-depth guidance
|
||||
- Check out [more examples](https://github.com/github/spec-kit/tree/main/templates) in the repository
|
||||
- Compare the [core templates](https://github.com/github/spec-kit/tree/main/templates) with
|
||||
[community walkthroughs](community/walkthroughs.md) to see how Spec-Driven Development is used in real projects
|
||||
- Explore the [source code on GitHub](https://github.com/github/spec-kit)
|
||||
|
||||
@@ -23,6 +23,8 @@ Creates or updates the project **constitution** — the guiding principles that
|
||||
|
||||
Creates or updates the feature **specification** from a natural-language description. Focus on the **what** and **why** — the user-facing behavior and goals — not the tech stack, which belongs in `/speckit.plan`.
|
||||
|
||||
This workflow may also maintain `checklists/requirements.md`, the built-in spec-quality checklist that `/speckit.specify` creates and `/speckit.clarify` re-evaluates. That lifecycle is separate from custom checklists generated by `/speckit.checklist`.
|
||||
|
||||
```text
|
||||
/speckit.specify Build an application that helps me organize photos into albums grouped by date, re-orderable by drag-and-drop on the main page, with a tile preview inside each album.
|
||||
```
|
||||
@@ -37,6 +39,8 @@ Asks up to five targeted questions about underspecified areas of the current spe
|
||||
|
||||
Clarifying before planning keeps you from designing on top of ambiguity. If `/speckit.analyze` later surfaces requirement gaps, come back and run `/speckit.clarify` (or `/speckit.specify`) again.
|
||||
|
||||
When `checklists/requirements.md` exists, `/speckit.clarify` may update its evaluated state as part of tightening the spec. This exception applies only to the built-in requirements checklist, not to custom review checklists.
|
||||
|
||||
## `/speckit.plan`
|
||||
|
||||
Runs the planning process to generate design artifacts from the spec. This is where implementation detail belongs — provide your tech stack, architecture, and technical constraints as arguments.
|
||||
@@ -49,6 +53,8 @@ Runs the planning process to generate design artifacts from the spec. This is wh
|
||||
|
||||
Generates a quality checklist for the feature — think of it as **"unit tests for your requirements."** Rather than testing code, it checks whether the spec itself is complete, clear, unambiguous, and consistent (for example: "Are the drag-and-drop rules defined for every column?", "Is behavior specified for a deleted assigned user?").
|
||||
|
||||
Custom checklists generated by this command are reviewer-owned requirements-quality review artifacts. An agent may help evaluate them when explicitly asked, but implementation must not silently self-approve them. In a custom checklist, `[x]` means the reviewer determined the requirements-quality criterion is satisfied; it does not mean implementation work is complete.
|
||||
|
||||
Run it with no arguments for a broad pass, or pass a focus area to target one aspect:
|
||||
|
||||
```text
|
||||
@@ -59,7 +65,7 @@ Run it with no arguments for a broad pass, or pass a focus area to target one as
|
||||
/speckit.checklist Focus on the Kanban board interactions and comment permissions.
|
||||
```
|
||||
|
||||
Review the generated checklist. If it surfaces gaps, loop back to `/speckit.clarify` or `/speckit.specify` to tighten the spec before breaking the work down.
|
||||
Review the generated checklist. If it surfaces gaps, loop back to `/speckit.clarify` or `/speckit.specify` to tighten the spec before breaking the work down, then mark each custom checklist item `[x]` only after the requirements-quality criterion has been reviewed and satisfied.
|
||||
|
||||
## `/speckit.tasks`
|
||||
|
||||
@@ -83,6 +89,8 @@ Run it before implementing, while the artifacts can still be adjusted cheaply. I
|
||||
|
||||
Executes the tasks in `tasks.md`, running each phase in dependency order and respecting parallel markers.
|
||||
|
||||
Before executing tasks, it reads checklist checkbox state as a gate. Checklist markers are read-only for this command: `/speckit.implement` counts checked and unchecked items and asks before proceeding when any are unchecked, but it must not change checklist markers. For custom checklists, checked items mean reviewer approval of requirements quality, not completed implementation work.
|
||||
|
||||
For a small feature, run it once to build everything:
|
||||
|
||||
```text
|
||||
|
||||
@@ -47,6 +47,8 @@ specify preset list
|
||||
|
||||
Lists installed presets with their versions, descriptions, template counts, and current status.
|
||||
|
||||
Presets are printed in **resolution/precedence order**: the highest-precedence preset (lowest priority number) is listed first, and ties on priority are broken alphabetically by preset id. This matches the order used when composing commands and resolving templates, so the top entry is the one that wins for overlapping files.
|
||||
|
||||
## Preset Info
|
||||
|
||||
```bash
|
||||
|
||||
@@ -151,7 +151,7 @@ To submit your extension to the community catalog, file a new issue using the **
|
||||
|
||||
### What Happens After You Submit
|
||||
|
||||
1. Your issue is automatically labeled and assigned to a maintainer for review
|
||||
1. A maintainer reviews the issue during issue triage and applies the `extension-submission` label, which starts the automated catalog validation. On this public repository, contributors cannot apply that label themselves, so there is nothing to label or re-request — the issue simply waits in triage.
|
||||
2. A maintainer verifies that the catalog entry is complete and correctly formatted
|
||||
3. Once approved, the maintainer adds your extension to `extensions/catalog.community.json` and the Community Extensions table in the README
|
||||
4. Your extension becomes discoverable via `specify extension search`
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"schema_version": "1.0",
|
||||
"updated_at": "2026-08-10T00:00:00Z",
|
||||
"updated_at": "2026-08-14T00:00:00Z",
|
||||
"catalog_url": "https://raw.githubusercontent.com/github/spec-kit/main/extensions/catalog.community.json",
|
||||
"extensions": {
|
||||
"adrkit": {
|
||||
@@ -286,6 +286,44 @@
|
||||
"created_at": "2026-05-14T00:00:00Z",
|
||||
"updated_at": "2026-06-30T00:00:00Z"
|
||||
},
|
||||
"arch-governance": {
|
||||
"name": "Architecture Governance",
|
||||
"id": "arch-governance",
|
||||
"description": "Keep specs, code & ADRs in sync: citation slots + a read-only, fail-closed validator.",
|
||||
"author": "Ash Brener",
|
||||
"version": "1.2.2",
|
||||
"download_url": "https://github.com/ashbrener/spec-kit-arch-governance/archive/refs/tags/v1.2.2.zip",
|
||||
"repository": "https://github.com/ashbrener/spec-kit-arch-governance",
|
||||
"homepage": "https://github.com/ashbrener/spec-kit-arch-governance",
|
||||
"documentation": "https://github.com/ashbrener/spec-kit-arch-governance/blob/main/README.md",
|
||||
"changelog": "https://github.com/ashbrener/spec-kit-arch-governance/blob/main/CHANGELOG.md",
|
||||
"license": "MIT",
|
||||
"category": "docs",
|
||||
"effect": "read-write",
|
||||
"requires": {
|
||||
"speckit_version": ">=0.1.0",
|
||||
"tools": [
|
||||
{ "name": "python", "version": ">=3.11", "required": true },
|
||||
{ "name": "uv", "required": true }
|
||||
]
|
||||
},
|
||||
"provides": {
|
||||
"commands": 6,
|
||||
"hooks": 3
|
||||
},
|
||||
"tags": [
|
||||
"architecture",
|
||||
"governance",
|
||||
"adr",
|
||||
"citations",
|
||||
"spec-sync"
|
||||
],
|
||||
"verified": false,
|
||||
"downloads": 0,
|
||||
"stars": 0,
|
||||
"created_at": "2026-08-14T00:00:00Z",
|
||||
"updated_at": "2026-08-14T00:00:00Z"
|
||||
},
|
||||
"architect-preview": {
|
||||
"name": "Architect Impact Previewer",
|
||||
"id": "architect-preview",
|
||||
@@ -362,8 +400,8 @@
|
||||
"id": "archive",
|
||||
"description": "Archive merged features into main project memory, resolving gaps and conflicts.",
|
||||
"author": "Stanislav Deviatov",
|
||||
"version": "1.1.0",
|
||||
"download_url": "https://github.com/stn1slv/spec-kit-archive/archive/refs/tags/v1.1.0.zip",
|
||||
"version": "1.2.2",
|
||||
"download_url": "https://github.com/stn1slv/spec-kit-archive/archive/refs/tags/v1.2.2.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",
|
||||
@@ -388,7 +426,45 @@
|
||||
"downloads": 0,
|
||||
"stars": 0,
|
||||
"created_at": "2026-03-14T00:00:00Z",
|
||||
"updated_at": "2026-08-04T00:00:00Z"
|
||||
"updated_at": "2026-08-11T00:00:00Z"
|
||||
},
|
||||
"atlas": {
|
||||
"name": "spec-kit-atlas",
|
||||
"id": "atlas",
|
||||
"description": "Synthesize spec-kit specs into faithful, interactive architecture storybooks & doc portals.",
|
||||
"author": "Ash Brener",
|
||||
"version": "0.1.0",
|
||||
"download_url": "https://github.com/ashbrener/spec-kit-atlas/archive/refs/tags/v0.1.0.zip",
|
||||
"repository": "https://github.com/ashbrener/spec-kit-atlas",
|
||||
"homepage": "https://github.com/ashbrener/spec-kit-atlas",
|
||||
"documentation": "https://github.com/ashbrener/spec-kit-atlas/blob/main/README.md",
|
||||
"changelog": "https://github.com/ashbrener/spec-kit-atlas/blob/main/CHANGELOG.md",
|
||||
"license": "MIT",
|
||||
"category": "docs",
|
||||
"effect": "read-only",
|
||||
"requires": {
|
||||
"speckit_version": ">=0.1.0",
|
||||
"tools": [
|
||||
{ "name": "python", "version": ">=3.11", "required": true },
|
||||
{ "name": "uv", "required": true }
|
||||
]
|
||||
},
|
||||
"provides": {
|
||||
"commands": 2,
|
||||
"hooks": 0
|
||||
},
|
||||
"tags": [
|
||||
"documentation",
|
||||
"architecture",
|
||||
"storybook",
|
||||
"traceability",
|
||||
"atlas"
|
||||
],
|
||||
"verified": false,
|
||||
"downloads": 0,
|
||||
"stars": 0,
|
||||
"created_at": "2026-08-13T00:00:00Z",
|
||||
"updated_at": "2026-08-13T00:00:00Z"
|
||||
},
|
||||
"azure-devops": {
|
||||
"name": "Azure DevOps Integration",
|
||||
@@ -1815,6 +1891,41 @@
|
||||
"created_at": "2026-06-24T00:00:00Z",
|
||||
"updated_at": "2026-07-07T00:00:00Z"
|
||||
},
|
||||
"grill": {
|
||||
"name": "SpecKit Grill Me",
|
||||
"id": "grill",
|
||||
"description": "Exhaustively resolve specification ambiguities and decisions before planning.",
|
||||
"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",
|
||||
"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",
|
||||
"changelog": "https://github.com/yoshi1220/speckit-grill-me/blob/main/spec-kit-extension/CHANGELOG.md",
|
||||
"license": "MIT",
|
||||
"category": "process",
|
||||
"effect": "read-write",
|
||||
"requires": {
|
||||
"speckit_version": ">=0.16.2",
|
||||
"tools": [{ "name": "bash", "required": true }]
|
||||
},
|
||||
"provides": {
|
||||
"commands": 1,
|
||||
"hooks": 0
|
||||
},
|
||||
"tags": [
|
||||
"clarification",
|
||||
"requirements",
|
||||
"specification",
|
||||
"elicitation",
|
||||
"workflow"
|
||||
],
|
||||
"verified": false,
|
||||
"downloads": 0,
|
||||
"stars": 0,
|
||||
"created_at": "2026-08-11T00:00:00Z",
|
||||
"updated_at": "2026-08-11T00:00:00Z"
|
||||
},
|
||||
"harness": {
|
||||
"name": "Research Harness",
|
||||
"id": "harness",
|
||||
@@ -4113,6 +4224,78 @@
|
||||
"created_at": "2026-04-30T00:00:00Z",
|
||||
"updated_at": "2026-04-30T00:00:00Z"
|
||||
},
|
||||
"specassay-check": {
|
||||
"name": "SpecAssay Check",
|
||||
"id": "specassay-check",
|
||||
"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",
|
||||
"repository": "https://github.com/rdryfoos/specassay",
|
||||
"homepage": "https://www.specassay.com",
|
||||
"documentation": "https://github.com/rdryfoos/specassay/blob/main/extensions/specassay-check/README.md",
|
||||
"changelog": "https://github.com/rdryfoos/specassay/blob/main/CHANGELOG.md",
|
||||
"license": "MIT",
|
||||
"category": "visibility",
|
||||
"effect": "read-write",
|
||||
"requires": {
|
||||
"speckit_version": ">=0.14.0",
|
||||
"tools": [
|
||||
{ "name": "bash", "required": true },
|
||||
{ "name": "python3", "version": ">=3.8", "required": true }
|
||||
]
|
||||
},
|
||||
"provides": {
|
||||
"commands": 1,
|
||||
"hooks": 1
|
||||
},
|
||||
"tags": [
|
||||
"traceability",
|
||||
"gate",
|
||||
"ci",
|
||||
"governance",
|
||||
"sdd"
|
||||
],
|
||||
"verified": false,
|
||||
"downloads": 0,
|
||||
"stars": 0,
|
||||
"created_at": "2026-08-13T00:00:00Z",
|
||||
"updated_at": "2026-08-13T00:00:00Z"
|
||||
},
|
||||
"specjudge": {
|
||||
"name": "SpecJudge — right-size the model before you implement",
|
||||
"id": "specjudge",
|
||||
"description": "Recommends the model that fits your tasks, citing the spec fragment behind every level.",
|
||||
"author": "Joaquín Ruiz",
|
||||
"version": "0.5.4",
|
||||
"download_url": "https://github.com/JoaquinRuiz/SpecJudge/releases/download/v0.5.4/spec-kit-specjudge.zip",
|
||||
"repository": "https://github.com/JoaquinRuiz/SpecJudge",
|
||||
"homepage": "https://github.com/JoaquinRuiz/SpecJudge",
|
||||
"documentation": "https://github.com/JoaquinRuiz/SpecJudge/blob/main/extensions/spec-kit/README.md",
|
||||
"changelog": "https://github.com/JoaquinRuiz/SpecJudge/blob/main/extensions/spec-kit/CHANGELOG.md",
|
||||
"license": "MIT",
|
||||
"category": "process",
|
||||
"effect": "read-only",
|
||||
"requires": {
|
||||
"speckit_version": ">=0.13.0",
|
||||
"tools": [{ "name": "specjudge", "version": ">=0.5.0", "required": true }]
|
||||
},
|
||||
"provides": {
|
||||
"commands": 1,
|
||||
"hooks": 1
|
||||
},
|
||||
"tags": [
|
||||
"cost",
|
||||
"model-selection",
|
||||
"local-first",
|
||||
"ollama"
|
||||
],
|
||||
"verified": false,
|
||||
"downloads": 0,
|
||||
"stars": 0,
|
||||
"created_at": "2026-08-12T00:00:00Z",
|
||||
"updated_at": "2026-08-12T00:00:00Z"
|
||||
},
|
||||
"speckit-superpowers-bridge": {
|
||||
"name": "Superpowers Implementation Bridge",
|
||||
"id": "speckit-superpowers-bridge",
|
||||
@@ -4421,8 +4604,8 @@
|
||||
"id": "superspec",
|
||||
"description": "Bridges spec-kit workflows with obra/superpowers capabilities for brainstorming, TDD, code review, and resumable execution.",
|
||||
"author": "WangX0111",
|
||||
"version": "1.0.1",
|
||||
"download_url": "https://github.com/WangX0111/superspec/archive/refs/tags/v1.0.1.zip",
|
||||
"version": "1.0.2",
|
||||
"download_url": "https://github.com/WangX0111/superspec/archive/refs/tags/v1.0.2.zip",
|
||||
"repository": "https://github.com/WangX0111/superspec",
|
||||
"homepage": "https://github.com/WangX0111/superspec",
|
||||
"documentation": "https://github.com/WangX0111/superspec/blob/main/README.md",
|
||||
@@ -4449,7 +4632,7 @@
|
||||
"downloads": 0,
|
||||
"stars": 0,
|
||||
"created_at": "2026-04-22T00:00:00Z",
|
||||
"updated_at": "2026-05-30T00:00:00Z"
|
||||
"updated_at": "2026-08-14T00:00:00Z"
|
||||
},
|
||||
"sync": {
|
||||
"name": "Spec Sync",
|
||||
|
||||
@@ -300,6 +300,12 @@ git push origin add-your-preset
|
||||
|
||||
## Verification Process
|
||||
|
||||
> **How submissions get picked up:** the automated catalog-validation workflow only runs
|
||||
> once the `preset-submission` label is on the issue. On this public repository, contributors
|
||||
> cannot apply that label themselves — a maintainer applies it during issue triage. Until then
|
||||
> the issue simply waits in triage; there is no action required from you, and there is no need to
|
||||
> re-request the label in a comment.
|
||||
|
||||
After submission, maintainers will review:
|
||||
|
||||
1. **Manifest validation** — valid `preset.yml`, all files exist
|
||||
|
||||
@@ -1,57 +1,52 @@
|
||||
{
|
||||
"schema_version": "1.0",
|
||||
"updated_at": "2026-08-10T00:00:00Z",
|
||||
|
||||
"updated_at": "2026-08-14T00:00:00Z",
|
||||
"catalog_url": "https://raw.githubusercontent.com/github/spec-kit/main/presets/catalog.community.json",
|
||||
"presets": {
|
||||
"a11y-governance": {
|
||||
"name": "A11Y Governance",
|
||||
"id": "a11y-governance",
|
||||
"version": "0.4.2",
|
||||
"description": "Adds accessibility (WCAG 2.2 AA), accessible text and JSON status parity, bilingual DE/EN delivery, CEFR-B2 readability, inclusive-content governance, didactic inline-code-comment review, and audit-ready Spec-Kit run evidence to Spec Kit.",
|
||||
"version": "0.4.3",
|
||||
"description": "Adds WCAG 2.2 AA, accessible status output, bilingual CEFR-B2 delivery, inclusive-content and didactic-comment governance, and provider-neutral model routing.",
|
||||
"author": "Thorsten Hindermann",
|
||||
"repository": "https://github.com/hindermath/spec-kit-preset-a11y-governance",
|
||||
"download_url": "https://github.com/hindermath/spec-kit-preset-a11y-governance/archive/refs/tags/v0.4.2.zip",
|
||||
"download_url": "https://github.com/hindermath/spec-kit-preset-a11y-governance/archive/refs/tags/v0.4.3.zip",
|
||||
"homepage": "https://github.com/hindermath/spec-kit-preset-a11y-governance",
|
||||
"documentation": "https://github.com/hindermath/spec-kit-preset-a11y-governance/blob/v0.4.2/README.md",
|
||||
"documentation": "https://github.com/hindermath/spec-kit-preset-a11y-governance/blob/v0.4.3/README.md",
|
||||
"license": "MIT",
|
||||
"requires": {
|
||||
"speckit_version": ">=0.8.0"
|
||||
},
|
||||
"provides": {
|
||||
"templates": 10,
|
||||
"templates": 11,
|
||||
"commands": 3
|
||||
},
|
||||
"tags": [
|
||||
"a11y",
|
||||
"accessibility",
|
||||
"bilingual",
|
||||
"wcag",
|
||||
"wcag-2-2",
|
||||
"cefr-b2",
|
||||
"inclusion",
|
||||
"include-everyone",
|
||||
"didactic-comments"
|
||||
"model-routing"
|
||||
],
|
||||
"created_at": "2026-04-27T00:00:00Z",
|
||||
"updated_at": "2026-07-28T00:00:00Z"
|
||||
"updated_at": "2026-08-12T00:00:00Z"
|
||||
},
|
||||
"agent-parity-governance": {
|
||||
"name": "Agent Parity Governance",
|
||||
"id": "agent-parity-governance",
|
||||
"version": "0.4.1",
|
||||
"description": "Adds shared-guidance and generated-command parity, repository-fleet completion evidence, secret-free runner/status metadata, audit-ready Spec-Kit run evidence, and agent-neutral model-routing guidance across declared AI-agent surfaces.",
|
||||
"version": "0.4.2",
|
||||
"description": "Adds shared-guidance parity, fleet-completion evidence, secret-free runner metadata, audit-ready Spec Kit evidence, and agent-neutral model routing across declared AI-agent surfaces.",
|
||||
"author": "Thorsten Hindermann",
|
||||
"repository": "https://github.com/hindermath/spec-kit-preset-agent-parity-governance",
|
||||
"download_url": "https://github.com/hindermath/spec-kit-preset-agent-parity-governance/archive/refs/tags/v0.4.1.zip",
|
||||
"download_url": "https://github.com/hindermath/spec-kit-preset-agent-parity-governance/archive/refs/tags/v0.4.2.zip",
|
||||
"homepage": "https://github.com/hindermath/spec-kit-preset-agent-parity-governance",
|
||||
"documentation": "https://github.com/hindermath/spec-kit-preset-agent-parity-governance/blob/v0.4.1/README.md",
|
||||
"documentation": "https://github.com/hindermath/spec-kit-preset-agent-parity-governance/blob/v0.4.2/README.md",
|
||||
"license": "MIT",
|
||||
"requires": {
|
||||
"speckit_version": ">=0.8.0"
|
||||
},
|
||||
"provides": {
|
||||
"templates": 6,
|
||||
"templates": 7,
|
||||
"commands": 3
|
||||
},
|
||||
"tags": [
|
||||
@@ -64,7 +59,7 @@
|
||||
"multi-agent"
|
||||
],
|
||||
"created_at": "2026-04-27T00:00:00Z",
|
||||
"updated_at": "2026-07-28T00:00:00Z"
|
||||
"updated_at": "2026-08-13T00:00:00Z"
|
||||
},
|
||||
"aide-in-place": {
|
||||
"name": "AIDE In-Place Migration",
|
||||
@@ -97,40 +92,30 @@
|
||||
"architecture-governance": {
|
||||
"name": "Architecture Governance",
|
||||
"id": "architecture-governance",
|
||||
"version": "0.5.1",
|
||||
"description": "Adds secure software architecture, resumable remote-transaction boundaries, STRIDE+CAPEC threat modeling, arc42 security cross-cutting concepts, S-ADRs, Zero Trust applicability, OWASP SAMM governance, BSI C3A cloud autonomy, BSI C5 cloud compliance assurance, and audit-ready Spec Kit run evidence.",
|
||||
"version": "0.5.2",
|
||||
"description": "Adds secure architecture, STRIDE/CAPEC threat modeling, arc42/S-ADR guidance, Zero Trust, SAMM, BSI cloud assurance, audit evidence, and provider-neutral model routing.",
|
||||
"author": "Thorsten Hindermann",
|
||||
"repository": "https://github.com/hindermath/spec-kit-preset-architecture-governance",
|
||||
"download_url": "https://github.com/hindermath/spec-kit-preset-architecture-governance/archive/refs/tags/v0.5.1.zip",
|
||||
"download_url": "https://github.com/hindermath/spec-kit-preset-architecture-governance/archive/refs/tags/v0.5.2.zip",
|
||||
"homepage": "https://github.com/hindermath/spec-kit-preset-architecture-governance",
|
||||
"documentation": "https://github.com/hindermath/spec-kit-preset-architecture-governance/blob/v0.5.1/README.md",
|
||||
"documentation": "https://github.com/hindermath/spec-kit-preset-architecture-governance/blob/v0.5.2/README.md",
|
||||
"license": "MIT",
|
||||
"requires": {
|
||||
"speckit_version": ">=0.8.0"
|
||||
},
|
||||
"provides": {
|
||||
"templates": 13,
|
||||
"templates": 14,
|
||||
"commands": 3
|
||||
},
|
||||
"tags": [
|
||||
"architecture",
|
||||
"governance",
|
||||
"threat-modeling",
|
||||
"stride",
|
||||
"capec",
|
||||
"arc42",
|
||||
"adr",
|
||||
"zero-trust",
|
||||
"samm",
|
||||
"isaqb",
|
||||
"cloud",
|
||||
"sovereignty",
|
||||
"c3a",
|
||||
"c5",
|
||||
"assurance"
|
||||
"model-routing"
|
||||
],
|
||||
"created_at": "2026-04-27T00:00:00Z",
|
||||
"updated_at": "2026-07-23T00:00:00Z"
|
||||
"updated_at": "2026-08-11T00:00:00Z"
|
||||
},
|
||||
"autonomous-run-governance": {
|
||||
"name": "Autonomous Run Governance",
|
||||
@@ -243,19 +228,19 @@
|
||||
"cross-platform-governance": {
|
||||
"name": "Cross-Platform Governance",
|
||||
"id": "cross-platform-governance",
|
||||
"version": "0.2.1",
|
||||
"description": "Adds Bash/PowerShell and read-only check parity, root-path and native-override review, Unix man pages, bilingual help, Verb-Noun discipline, and audit-ready evidence.",
|
||||
"version": "0.2.2",
|
||||
"description": "Adds Bash/PowerShell parity, read-only checks, path and native-override review, Unix man pages, bilingual PowerShell help, and provider-neutral model routing.",
|
||||
"author": "Thorsten Hindermann",
|
||||
"repository": "https://github.com/hindermath/spec-kit-preset-cross-platform-governance",
|
||||
"download_url": "https://github.com/hindermath/spec-kit-preset-cross-platform-governance/archive/refs/tags/v0.2.1.zip",
|
||||
"download_url": "https://github.com/hindermath/spec-kit-preset-cross-platform-governance/archive/refs/tags/v0.2.2.zip",
|
||||
"homepage": "https://github.com/hindermath/spec-kit-preset-cross-platform-governance",
|
||||
"documentation": "https://github.com/hindermath/spec-kit-preset-cross-platform-governance/blob/v0.2.1/README.md",
|
||||
"documentation": "https://github.com/hindermath/spec-kit-preset-cross-platform-governance/blob/v0.2.2/README.md",
|
||||
"license": "MIT",
|
||||
"requires": {
|
||||
"speckit_version": ">=0.8.0"
|
||||
},
|
||||
"provides": {
|
||||
"templates": 8,
|
||||
"templates": 9,
|
||||
"commands": 3
|
||||
},
|
||||
"tags": [
|
||||
@@ -263,15 +248,10 @@
|
||||
"governance",
|
||||
"bash",
|
||||
"powershell",
|
||||
"man-page",
|
||||
"cmdlet",
|
||||
"verb-noun",
|
||||
"windows",
|
||||
"macos",
|
||||
"linux"
|
||||
"model-routing"
|
||||
],
|
||||
"created_at": "2026-04-27T00:00:00Z",
|
||||
"updated_at": "2026-07-23T00:00:00Z"
|
||||
"updated_at": "2026-08-12T00:00:00Z"
|
||||
},
|
||||
"explicit-task-dependencies": {
|
||||
"name": "Explicit Task Dependencies",
|
||||
@@ -367,19 +347,19 @@
|
||||
"intake-authoring-governance": {
|
||||
"name": "Intake Authoring Governance",
|
||||
"id": "intake-authoring-governance",
|
||||
"version": "0.3.0",
|
||||
"description": "Governs traceable intake CRUD and language-aware requirements collections with atomic migrations, rollback evidence, and safe series authoring.",
|
||||
"version": "0.3.1",
|
||||
"description": "Governs traceable intake CRUD, language-aware requirements collections, bounded public HTTPS sources, and explicitly approved single or series authoring.",
|
||||
"author": "Thorsten Hindermann",
|
||||
"repository": "https://github.com/hindermath/spec-kit-preset-intake-authoring-governance",
|
||||
"download_url": "https://github.com/hindermath/spec-kit-preset-intake-authoring-governance/archive/refs/tags/v0.3.0.zip",
|
||||
"download_url": "https://github.com/hindermath/spec-kit-preset-intake-authoring-governance/archive/refs/tags/v0.3.1.zip",
|
||||
"homepage": "https://github.com/hindermath/spec-kit-preset-intake-authoring-governance",
|
||||
"documentation": "https://github.com/hindermath/spec-kit-preset-intake-authoring-governance/blob/v0.3.0/README.md",
|
||||
"documentation": "https://github.com/hindermath/spec-kit-preset-intake-authoring-governance/blob/v0.3.1/README.md",
|
||||
"license": "MIT",
|
||||
"requires": {
|
||||
"speckit_version": ">=0.8.3"
|
||||
},
|
||||
"provides": {
|
||||
"templates": 12,
|
||||
"templates": 13,
|
||||
"commands": 5,
|
||||
"scripts": 7
|
||||
},
|
||||
@@ -391,7 +371,7 @@
|
||||
"migration"
|
||||
],
|
||||
"created_at": "2026-07-22T00:00:00Z",
|
||||
"updated_at": "2026-07-28T00:00:00Z"
|
||||
"updated_at": "2026-08-14T00:00:00Z"
|
||||
},
|
||||
"intake-review-governance": {
|
||||
"name": "Intake Review Governance",
|
||||
@@ -454,34 +434,30 @@
|
||||
"isaqb-architecture-governance": {
|
||||
"name": "iSAQB Architecture Governance",
|
||||
"id": "isaqb-architecture-governance",
|
||||
"version": "0.2.1",
|
||||
"description": "Adds iSAQB/CPSA-F and arc42 architecture governance with audit-ready evidence for goals, views, resumability, partial-failure scenarios, ADRs, risks, and technical debt.",
|
||||
"version": "0.2.2",
|
||||
"description": "Adds iSAQB/CPSA-F and arc42 architecture governance, architecture views, quality scenarios, ADRs, risks, technical-debt evidence, and provider-neutral model routing.",
|
||||
"author": "Thorsten Hindermann",
|
||||
"repository": "https://github.com/hindermath/spec-kit-preset-isaqb-architecture-governance",
|
||||
"download_url": "https://github.com/hindermath/spec-kit-preset-isaqb-architecture-governance/archive/refs/tags/v0.2.1.zip",
|
||||
"download_url": "https://github.com/hindermath/spec-kit-preset-isaqb-architecture-governance/archive/refs/tags/v0.2.2.zip",
|
||||
"homepage": "https://github.com/hindermath/spec-kit-preset-isaqb-architecture-governance",
|
||||
"documentation": "https://github.com/hindermath/spec-kit-preset-isaqb-architecture-governance/blob/v0.2.1/README.md",
|
||||
"documentation": "https://github.com/hindermath/spec-kit-preset-isaqb-architecture-governance/blob/v0.2.2/README.md",
|
||||
"license": "MIT",
|
||||
"requires": {
|
||||
"speckit_version": ">=0.8.0"
|
||||
},
|
||||
"provides": {
|
||||
"templates": 13,
|
||||
"templates": 14,
|
||||
"commands": 3
|
||||
},
|
||||
"tags": [
|
||||
"architecture",
|
||||
"governance",
|
||||
"isaqb",
|
||||
"cpsa-f",
|
||||
"arc42",
|
||||
"adr",
|
||||
"quality-attributes",
|
||||
"architecture-views",
|
||||
"technical-debt"
|
||||
"model-routing"
|
||||
],
|
||||
"created_at": "2026-04-27T00:00:00Z",
|
||||
"updated_at": "2026-07-23T00:00:00Z"
|
||||
"updated_at": "2026-08-11T00:00:00Z"
|
||||
},
|
||||
"jira": {
|
||||
"name": "Jira Issue Tracking",
|
||||
@@ -609,7 +585,7 @@
|
||||
"documentation": "https://github.com/hindermath/spec-kit-preset-parallel-autonomous-run-governance/blob/v0.2.4/README.md",
|
||||
"license": "MIT",
|
||||
"requires": {
|
||||
"speckit_version": ">=0.8.3"
|
||||
"speckit_version": ">=0.8.3"
|
||||
},
|
||||
"provides": {
|
||||
"templates": 9,
|
||||
@@ -692,52 +668,30 @@
|
||||
"security-governance": {
|
||||
"name": "Security Governance",
|
||||
"id": "security-governance",
|
||||
"version": "0.6.1",
|
||||
"description": "Adds memory-safe-language and secure-coding governance, exact-head and security-gate evidence, provider-failure classification, ASVS, supply-chain transparency, and EU regulatory screening.",
|
||||
"version": "0.6.2",
|
||||
"description": "Adds memory-safe-language and secure-coding governance, exact-head security evidence, ASVS, supply-chain transparency, EU regulatory screening, and provider-neutral model routing.",
|
||||
"author": "Thorsten Hindermann",
|
||||
"repository": "https://github.com/hindermath/spec-kit-preset-security-governance",
|
||||
"download_url": "https://github.com/hindermath/spec-kit-preset-security-governance/archive/refs/tags/v0.6.1.zip",
|
||||
"download_url": "https://github.com/hindermath/spec-kit-preset-security-governance/archive/refs/tags/v0.6.2.zip",
|
||||
"homepage": "https://github.com/hindermath/spec-kit-preset-security-governance",
|
||||
"documentation": "https://github.com/hindermath/spec-kit-preset-security-governance/blob/v0.6.1/README.md",
|
||||
"documentation": "https://github.com/hindermath/spec-kit-preset-security-governance/blob/v0.6.2/README.md",
|
||||
"license": "MIT",
|
||||
"requires": {
|
||||
"speckit_version": ">=0.8.0"
|
||||
},
|
||||
"provides": {
|
||||
"templates": 14,
|
||||
"templates": 15,
|
||||
"commands": 3
|
||||
},
|
||||
"tags": [
|
||||
"security",
|
||||
"governance",
|
||||
"msl",
|
||||
"ssdf",
|
||||
"asvs",
|
||||
"supply-chain",
|
||||
"sbom",
|
||||
"ai-sbom",
|
||||
"vex",
|
||||
"slsa",
|
||||
"cwe-top-25",
|
||||
"secure-coding",
|
||||
"rust",
|
||||
"go",
|
||||
"swift",
|
||||
"java",
|
||||
"kotlin",
|
||||
"python",
|
||||
"typescript",
|
||||
"g7",
|
||||
"bsi",
|
||||
"cra",
|
||||
"cyber-resilience-act",
|
||||
"nis2",
|
||||
"ai-act",
|
||||
"dora",
|
||||
"regulatory"
|
||||
"supply-chain",
|
||||
"model-routing"
|
||||
],
|
||||
"created_at": "2026-04-27T00:00:00Z",
|
||||
"updated_at": "2026-07-23T00:00:00Z"
|
||||
"updated_at": "2026-08-10T00:00:00Z"
|
||||
},
|
||||
"sicario-core": {
|
||||
"name": "SicarioSpec Core",
|
||||
@@ -793,6 +747,33 @@
|
||||
"created_at": "2026-04-30T00:00:00Z",
|
||||
"updated_at": "2026-04-30T00:00:00Z"
|
||||
},
|
||||
"specassay": {
|
||||
"name": "SpecAssay",
|
||||
"id": "specassay",
|
||||
"version": "0.3.4",
|
||||
"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",
|
||||
"homepage": "https://github.com/rdryfoos/specassay",
|
||||
"documentation": "https://github.com/rdryfoos/specassay/blob/main/presets/specassay/README.md",
|
||||
"license": "MIT",
|
||||
"requires": {
|
||||
"speckit_version": ">=0.14.0"
|
||||
},
|
||||
"provides": {
|
||||
"templates": 3,
|
||||
"commands": 0
|
||||
},
|
||||
"tags": [
|
||||
"traceability",
|
||||
"durable-ids",
|
||||
"governance",
|
||||
"sdd"
|
||||
],
|
||||
"created_at": "2026-08-14T00:00:00Z",
|
||||
"updated_at": "2026-08-14T00:00:00Z"
|
||||
},
|
||||
"test-first-governance": {
|
||||
"name": "Test-First Governance",
|
||||
"id": "test-first-governance",
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
[project]
|
||||
name = "specify-cli"
|
||||
version = "0.16.2.dev0"
|
||||
version = "0.16.4"
|
||||
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"
|
||||
|
||||
@@ -157,13 +157,18 @@ if ($Json) {
|
||||
Write-Output "FEATURE_DIR:$($paths.FEATURE_DIR)"
|
||||
Write-Output "AVAILABLE_DOCS:"
|
||||
|
||||
# Show status of each potential document
|
||||
Test-FileExists -Path $paths.RESEARCH -Description 'research.md' | Out-Null
|
||||
Test-FileExists -Path $paths.DATA_MODEL -Description 'data-model.md' | Out-Null
|
||||
Test-DirHasFiles -Path $paths.CONTRACTS_DIR -Description 'contracts/' | Out-Null
|
||||
Test-FileExists -Path $paths.QUICKSTART -Description 'quickstart.md' | Out-Null
|
||||
# Show status of each potential document.
|
||||
# These helpers report their line with Write-Output and ALSO return a
|
||||
# bool, both on the Success stream, so 'Out-Null' discarded the report
|
||||
# line along with the return value and left AVAILABLE_DOCS empty. Drop
|
||||
# only the boolean so the per-document lines reach stdout like the
|
||||
# bash and Python twins.
|
||||
Test-FileExists -Path $paths.RESEARCH -Description 'research.md' | Where-Object { $_ -isnot [bool] }
|
||||
Test-FileExists -Path $paths.DATA_MODEL -Description 'data-model.md' | Where-Object { $_ -isnot [bool] }
|
||||
Test-DirHasFiles -Path $paths.CONTRACTS_DIR -Description 'contracts/' | Where-Object { $_ -isnot [bool] }
|
||||
Test-FileExists -Path $paths.QUICKSTART -Description 'quickstart.md' | Where-Object { $_ -isnot [bool] }
|
||||
|
||||
if ($IncludeTasks) {
|
||||
Test-FileExists -Path $paths.TASKS -Description 'tasks.md' | Out-Null
|
||||
Test-FileExists -Path $paths.TASKS -Description 'tasks.md' | Where-Object { $_ -isnot [bool] }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7,6 +7,7 @@ layer, not out of it, to avoid circular imports.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import sys
|
||||
from collections.abc import Callable
|
||||
|
||||
@@ -21,6 +22,8 @@ from rich.text import Text
|
||||
from rich.tree import Tree
|
||||
from typer.core import TyperGroup
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
BANNER = """
|
||||
███████╗██████╗ ███████╗ ██████╗██╗███████╗██╗ ██╗
|
||||
██╔════╝██╔══██╗██╔════╝██╔════╝██║██╔════╝╚██╗ ██╔╝
|
||||
@@ -85,7 +88,7 @@ class StepTracker:
|
||||
try:
|
||||
self._refresh_cb()
|
||||
except Exception:
|
||||
pass
|
||||
logger.debug("Progress tracker refresh failed", exc_info=True)
|
||||
|
||||
def render(self):
|
||||
tree = Tree(f"[cyan]{self.title}[/cyan]", guide_style="grey50")
|
||||
|
||||
@@ -213,7 +213,7 @@ def handle_vscode_settings(sub_item, dest_file, rel_path, verbose=False, tracker
|
||||
shutil.copy2(sub_item, dest_file)
|
||||
log("Copied (no existing settings.json):", "blue")
|
||||
|
||||
except Exception as e:
|
||||
except (OSError, ValueError, KeyError) as e:
|
||||
log(f"Warning: Could not merge settings: {e}", "yellow")
|
||||
if not dest_file.exists():
|
||||
shutil.copy2(sub_item, dest_file)
|
||||
|
||||
@@ -11,7 +11,6 @@ import json
|
||||
import os
|
||||
import stat
|
||||
from dataclasses import dataclass
|
||||
from fnmatch import fnmatch
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
from urllib.parse import urlparse
|
||||
@@ -48,10 +47,21 @@ def _is_valid_host_pattern(pattern: str) -> bool:
|
||||
* ``*.example.com`` — leading ``*.`` wildcard; matches subdomains
|
||||
such as ``myorg.example.com`` but not ``example.com`` itself
|
||||
"""
|
||||
if any(char in pattern for char in "?[]"):
|
||||
return False
|
||||
if "*" not in pattern:
|
||||
return True # exact hostname — already validated as non-empty
|
||||
# Only *.suffix is allowed; no other wildcard positions
|
||||
return pattern.startswith("*.") and "*" not in pattern[2:]
|
||||
return pattern.startswith("*.") and len(pattern) > 2 and "*" not in pattern[2:]
|
||||
|
||||
|
||||
def _host_matches_pattern(hostname: str, pattern: str) -> bool:
|
||||
"""Match a hostname against an exact host or leading ``*.`` wildcard."""
|
||||
hostname = hostname.lower()
|
||||
pattern = pattern.lower()
|
||||
if pattern.startswith("*.") and _is_valid_host_pattern(pattern):
|
||||
return hostname.endswith(pattern[1:])
|
||||
return hostname == pattern
|
||||
|
||||
|
||||
def _norm(value: Any) -> Any:
|
||||
@@ -224,8 +234,5 @@ def find_entries_for_url(
|
||||
return [
|
||||
e
|
||||
for e in entries
|
||||
if any(
|
||||
pattern == hostname or fnmatch(hostname, pattern)
|
||||
for pattern in e.hosts
|
||||
)
|
||||
if any(_host_matches_pattern(hostname, pattern) for pattern in e.hosts)
|
||||
]
|
||||
|
||||
@@ -13,13 +13,18 @@ from __future__ import annotations
|
||||
|
||||
import urllib.error
|
||||
import urllib.request
|
||||
from fnmatch import fnmatch
|
||||
from typing import Callable
|
||||
from urllib.parse import urlparse
|
||||
|
||||
from .._download_security import is_safe_download_redirect
|
||||
from . import get_provider
|
||||
from .config import AuthConfigEntry, _default_config_path, find_entries_for_url, load_auth_config
|
||||
from .config import (
|
||||
AuthConfigEntry,
|
||||
_default_config_path,
|
||||
_host_matches_pattern,
|
||||
find_entries_for_url,
|
||||
load_auth_config,
|
||||
)
|
||||
|
||||
|
||||
_config_override: list[AuthConfigEntry] | None = None
|
||||
@@ -54,8 +59,7 @@ def _load_config() -> list[AuthConfigEntry]:
|
||||
|
||||
def _hostname_in_hosts(hostname: str, hosts: tuple[str, ...]) -> bool:
|
||||
"""Return True if *hostname* matches any pattern in *hosts*."""
|
||||
hostname = hostname.lower()
|
||||
return any(p == hostname or fnmatch(hostname, p) for p in hosts)
|
||||
return any(_host_matches_pattern(hostname, pattern) for pattern in hosts)
|
||||
|
||||
|
||||
RedirectValidator = Callable[[str, str], None]
|
||||
|
||||
@@ -82,7 +82,21 @@ def active_integration(project_root: Path) -> str | None:
|
||||
except BundlerError:
|
||||
return None
|
||||
if isinstance(data, dict):
|
||||
value = data.get("integration") or data.get("id") or data.get("active")
|
||||
# ``default_integration`` first, matching the canonical reader in
|
||||
# ``integration_state`` (line 199):
|
||||
# ``state.get("default_integration") or state.get("integration")``.
|
||||
# ``write_integration_json`` writes both keys, so a marker produced by
|
||||
# the current CLI already resolved through the ``integration`` alias --
|
||||
# this is about which field is authoritative when they disagree, and
|
||||
# about resolving a marker that carries only ``default_integration``
|
||||
# (hand-edited, or written by anything that follows the canonical
|
||||
# reader's shape). ``integration``/``id``/``active`` stay as fallbacks.
|
||||
value = (
|
||||
data.get("default_integration")
|
||||
or data.get("integration")
|
||||
or data.get("id")
|
||||
or data.get("active")
|
||||
)
|
||||
if isinstance(value, str) and value:
|
||||
return value
|
||||
return None
|
||||
|
||||
@@ -77,6 +77,16 @@ def resolve_install_plan(
|
||||
|
||||
# FR-019: integration-compatibility — a bundle that pins a different
|
||||
# integration than the project's active one halts (no silent change).
|
||||
#
|
||||
# A blank integration arrives as ``""``, not ``None`` — which is not a usable
|
||||
# integration id but satisfied NEITHER guard below (the first is a truthiness
|
||||
# test, the second an ``is None`` test), so a pinned bundle was silently
|
||||
# adopted: precisely the outcome this guard exists to prevent. Treat blank as
|
||||
# indeterminate, and strip first like the writer
|
||||
# (``integration_state.clean_integration_key``) so a padded value is not
|
||||
# reported as clashing with itself.
|
||||
if active_integration is not None:
|
||||
active_integration = active_integration.strip() or None
|
||||
effective_integration = active_integration
|
||||
if manifest.integration is not None:
|
||||
required = manifest.integration.id
|
||||
|
||||
@@ -14,8 +14,8 @@ from pathlib import Path
|
||||
import typer
|
||||
from rich.markup import escape as _escape_markup
|
||||
|
||||
from ..._console import console, err_console
|
||||
from ..._download_security import MAX_DOWNLOAD_BYTES, read_response_limited
|
||||
from ..._console import console, err_console
|
||||
from ...bundler import BundlerError
|
||||
from ...bundler.lib.project import (
|
||||
active_integration,
|
||||
@@ -772,8 +772,6 @@ def _local_manifest_source(arg: str):
|
||||
return BundleManifest.from_file(manifest_path)
|
||||
|
||||
if candidate.suffix == ".zip":
|
||||
import io
|
||||
|
||||
import yaml as _yaml
|
||||
|
||||
from ..._download_security import open_zip_bounded, read_zip_member_limited
|
||||
@@ -791,8 +789,20 @@ def _local_manifest_source(arg: str):
|
||||
error_type=BundlerError,
|
||||
label="bundle manifest",
|
||||
)
|
||||
# The bounded-zip helpers above keep archive failures inside the
|
||||
# BundlerError contract, but the manifest bytes need the same
|
||||
# treatment as yamlio.load_yaml: decode as UTF-8 explicitly —
|
||||
# feeding PyYAML the byte stream would let its Reader auto-detect
|
||||
# a UTF-16 BOM and accept a manifest the directory and bundle.yml
|
||||
# sources reject.
|
||||
try:
|
||||
data = _yaml.safe_load(io.BytesIO(raw))
|
||||
text = raw.decode("utf-8")
|
||||
except UnicodeError as exc:
|
||||
raise BundlerError(
|
||||
f"Could not read bundle.yml inside '{candidate}': {exc}"
|
||||
) from exc
|
||||
try:
|
||||
data = _yaml.safe_load(text)
|
||||
except _yaml.YAMLError as exc:
|
||||
# The sibling directory/bundle.yml branches reach YAML through
|
||||
# load_yaml(), which turns a parse failure into a BundlerError. This
|
||||
|
||||
+171
-5
@@ -297,6 +297,10 @@ def _emit(output, envelope, native_event=""):
|
||||
hookSpecificOutput → {"hookSpecificOutput": {"hookEventName": ..., "additionalContext": ...}}
|
||||
additionalContext → {"additionalContext": ...} (top-level, Copilot)
|
||||
additional_context → {"additional_context": ...} (top-level, Cursor)
|
||||
hook_specific_output → {"decision": "allow", "hook_specific_output":
|
||||
{"additional_context": ...}} (Vibe: any non-empty
|
||||
stdout must parse as a HookStructuredResponse or
|
||||
the hook is reported failed and output dropped)
|
||||
suppress → emit nothing (strict-JSON agents on events whose
|
||||
output can't be used)
|
||||
plain (default) → passthrough (Claude/Codex inject plain stdout)
|
||||
@@ -320,6 +324,9 @@ def _emit(output, envelope, native_event=""):
|
||||
if envelope == "additional_context":
|
||||
sys.stdout.write(json.dumps({"additional_context": output}) + "\\n")
|
||||
return
|
||||
if envelope == "hook_specific_output":
|
||||
sys.stdout.write(json.dumps({"decision": "allow", "hook_specific_output": {"additional_context": output}}) + "\\n")
|
||||
return
|
||||
sys.stdout.write(output)
|
||||
|
||||
|
||||
@@ -339,9 +346,10 @@ def main():
|
||||
timeout = 120
|
||||
# Optional 5th arg: context-injection envelope for stdout (C13): plain
|
||||
# (default), hookSpecificOutput, additionalContext, additional_context,
|
||||
# or suppress. Unknown values fall back to plain passthrough.
|
||||
# hook_specific_output, or suppress. Unknown values fall back to plain
|
||||
# passthrough.
|
||||
envelope = sys.argv[4] if len(sys.argv) >= 5 else "plain"
|
||||
if envelope not in ("plain", "hookSpecificOutput", "additionalContext", "additional_context", "suppress"):
|
||||
if envelope not in ("plain", "hookSpecificOutput", "additionalContext", "additional_context", "hook_specific_output", "suppress"):
|
||||
envelope = "plain"
|
||||
# Optional 6th arg: native event name for hookSpecificOutput's
|
||||
# hookEventName field (required by Qwen's hooks spec; included by
|
||||
@@ -672,8 +680,10 @@ def resolve_and_run_event_command(
|
||||
context-injection protocol (C13): ``plain`` passthrough (Claude/Codex
|
||||
inject plain stdout), ``hookSpecificOutput``/``additionalContext``/
|
||||
``additional_context`` JSON wrappers (Gemini/Tabnine/Qwen/Devin, Copilot,
|
||||
Cursor respectively), or ``suppress`` (strict-JSON agents on events whose
|
||||
output can't be used).
|
||||
Cursor respectively), ``hook_specific_output`` (Vibe's
|
||||
HookStructuredResponse — any non-empty stdout that isn't valid JSON is
|
||||
reported as a hook failure and dropped), or ``suppress`` (strict-JSON
|
||||
agents on events whose output can't be used).
|
||||
|
||||
*native_event* is the agent's native hookEventName (e.g. ``"SessionStart"``),
|
||||
required inside ``hookSpecificOutput`` by Qwen's hooks spec (and included
|
||||
@@ -738,6 +748,13 @@ def _emit_event_stdout(output: str, envelope: str, native_event: str = "") -> No
|
||||
if envelope == "additional_context":
|
||||
sys.stdout.write(json.dumps({"additional_context": output}) + "\n")
|
||||
return
|
||||
if envelope == "hook_specific_output":
|
||||
# Vibe parses any non-empty hook stdout as a HookStructuredResponse;
|
||||
# plain text would be reported as a hook failure. Wrap it as an
|
||||
# explicit allow with additional_context (injected on post_tool,
|
||||
# harmlessly ignored on pre_tool/post_agent).
|
||||
sys.stdout.write(json.dumps({"decision": "allow", "hook_specific_output": {"additional_context": output}}) + "\n")
|
||||
return
|
||||
sys.stdout.write(output)
|
||||
|
||||
|
||||
@@ -1093,6 +1110,15 @@ def _shell_quote(value: str, target_os: str) -> str:
|
||||
"""
|
||||
if target_os == "windows":
|
||||
return "'" + value.replace("'", "''") + "'"
|
||||
if target_os == "cmd":
|
||||
# cmd.exe (Vibe launches hooks via create_subprocess_shell, which is
|
||||
# %COMSPEC% on Windows): single quotes are not quoting there, so a
|
||||
# POSIX-quoted path with spaces would break apart. Double-quote only
|
||||
# when needed; embedded double quotes are doubled (MSVCRT argv
|
||||
# parsing treats "" inside a quoted string as a literal quote).
|
||||
if re.fullmatch(r"[A-Za-z0-9_.\-\\/:]+", value):
|
||||
return value
|
||||
return '"' + value.replace('"', '""') + '"'
|
||||
# "host" and "posix" both use POSIX quoting. On Windows the single-
|
||||
# command-string formats (Claude/Gemini/Qwen/Devin/Tabnine) are run via
|
||||
# Git Bash or the agent's POSIX-ish shell, so POSIX quoting is correct and
|
||||
@@ -1100,6 +1126,17 @@ def _shell_quote(value: str, target_os: str) -> str:
|
||||
return shlex.quote(value)
|
||||
|
||||
|
||||
def _vibe_target_os() -> str:
|
||||
"""Quoting target for Vibe hook commands.
|
||||
|
||||
Vibe launches hooks with ``asyncio.create_subprocess_shell`` — the host's
|
||||
native shell: POSIX ``sh`` on Unix, ``cmd.exe`` (%COMSPEC%) on Windows,
|
||||
where POSIX single-quoting is not quoting at all and an interpreter or
|
||||
dispatcher path containing spaces would split.
|
||||
"""
|
||||
return "cmd" if os.name == "nt" else "host"
|
||||
|
||||
|
||||
def _dispatcher_command(
|
||||
integration: IntegrationBase,
|
||||
project_root: Path,
|
||||
@@ -1122,6 +1159,8 @@ def _dispatcher_command(
|
||||
both POSIX and Windows variants into one checked-in file (Copilot): ``host``
|
||||
uses the host-resolved interpreter (venv-aware), while ``posix``/``windows``
|
||||
emit portable interpreters so the config works on either OS (#S4).
|
||||
``cmd`` also uses the host-resolved interpreter but quotes for cmd.exe —
|
||||
for agents that launch hooks through the native Windows shell (Vibe).
|
||||
|
||||
Each component is shell-quoted for the target shell (R2) so an interpreter
|
||||
path with spaces or a command/event containing shell metacharacters is
|
||||
@@ -1147,7 +1186,10 @@ def _dispatcher_command(
|
||||
shape the agent's hook protocol requires. Plain-passthrough agents
|
||||
(Claude/Codex) declare no envelope and get no extra argument.
|
||||
"""
|
||||
if target_os == "host":
|
||||
if target_os in ("host", "cmd"):
|
||||
# "cmd" is host-resolved too (venv-aware): it is selected only when
|
||||
# generating on a Windows host for an agent that runs hooks through
|
||||
# cmd.exe (Vibe), and differs from "host" purely in quoting style.
|
||||
interpreter = _resolve_interpreter(project_root)
|
||||
else:
|
||||
interpreter = _resolve_interpreter_for_target(target_os)
|
||||
@@ -1357,6 +1399,55 @@ def install_integration_events(
|
||||
manifest.record_existing(rel)
|
||||
created.append(config_path)
|
||||
|
||||
elif fmt == "toml-vibe":
|
||||
# Vibe hooks.toml custom merge. Flat [[hooks]] array; Vibe's
|
||||
# HookConfig schema is name/type/command/match/timeout, with type
|
||||
# limited to "pre_tool" | "post_tool" | "post_agent". Hook names must
|
||||
# be unique (Vibe silently drops duplicates by name), so a per-file
|
||||
# counter suffix disambiguates handlers whose commands share a final
|
||||
# segment (e.g. speckit.a.validate vs speckit.b.validate).
|
||||
lines: list[str] = []
|
||||
used_names: set[str] = set()
|
||||
for ev, handlers in filtered.items():
|
||||
native = canonical_to_native[ev]
|
||||
for cfg in handlers:
|
||||
command = cfg.get("command", "")
|
||||
dispatcher_cmd = _dispatcher_command(
|
||||
integration, project_root, command, ev,
|
||||
target_os=_vibe_target_os(),
|
||||
timeout_seconds=cfg.get("timeout", 60),
|
||||
)
|
||||
command_stem = command.split('.')[-1] if command else "unknown"
|
||||
command_stem = re.sub(r'[^A-Za-z0-9_-]+', '-', command_stem) or "unknown"
|
||||
base_name = f"speckit-{native}-{command_stem}"
|
||||
hook_name = base_name
|
||||
suffix = 2
|
||||
while hook_name in used_names:
|
||||
hook_name = f"{base_name}-{suffix}"
|
||||
suffix += 1
|
||||
used_names.add(hook_name)
|
||||
lines.append("[[hooks]]")
|
||||
lines.append(f'name = {_toml_quote(hook_name)}')
|
||||
lines.append(f'type = {_toml_quote(native)}')
|
||||
# Vibe's field is `match` (fnmatch glob, or `re:`-prefixed
|
||||
# regex, case-insensitive) and it is only valid on tool
|
||||
# hooks — HookConfig rejects `match` on post_agent. Canonical
|
||||
# matchers are Claude-style regexes ("Edit|Write"), so
|
||||
# non-wildcard matchers are emitted as `re:` patterns.
|
||||
matcher = cfg.get("matcher", "*")
|
||||
if matcher and matcher != "*" and native in ("pre_tool", "post_tool"):
|
||||
lines.append(f'match = {_toml_quote("re:" + matcher)}')
|
||||
lines.append(f'command = {_toml_quote(dispatcher_cmd)}')
|
||||
lines.append(f'timeout = {_native_timeout(integration, cfg.get("timeout", 60) + EVENT_TIMEOUT_BUFFER)}')
|
||||
lines.append('speckit_marker = true')
|
||||
lines.append('')
|
||||
# S5: only track when the merge wrote (skips on unreadable file).
|
||||
if _merge_vibe_toml_fragment(config_path, "\n".join(lines)):
|
||||
rel = str(config_path.relative_to(project_root))
|
||||
if rel not in manifest.files:
|
||||
manifest.record_existing(rel)
|
||||
created.append(config_path)
|
||||
|
||||
elif fmt == "json-flat":
|
||||
# Cursor hooks.json custom merge. Flat command-string entries, one
|
||||
# per handler (#2), single resolved command string (#6/#16).
|
||||
@@ -1479,6 +1570,8 @@ def _remove_native_event_hooks(
|
||||
_remove_copilot_entries(config_path)
|
||||
elif fmt == "toml":
|
||||
_remove_toml_entries(config_path)
|
||||
elif fmt == "toml-vibe":
|
||||
_remove_vibe_toml_entries(config_path)
|
||||
elif fmt in ("json-nested", "json-flat"):
|
||||
_remove_json_entries(config_path)
|
||||
elif fmt == "json-root-nested":
|
||||
@@ -1973,6 +2066,42 @@ def _merge_toml_fragment(dst: Path, fragment: str) -> bool:
|
||||
return True
|
||||
|
||||
|
||||
def _merge_vibe_toml_fragment(dst: Path, fragment: str) -> bool:
|
||||
"""Merge Specify-owned Vibe TOML hook entries into *dst*, regenerating the file.
|
||||
|
||||
Vibe uses a flat [[hooks]] array with type/matcher/command fields.
|
||||
This removes any existing Specify-marked hooks and appends the new fragment.
|
||||
An unreadable or undecodable pre-existing file aborts the merge instead
|
||||
of discarding the user's bytes, mirroring ``_load_user_json`` (#22).
|
||||
Returns False when skipped so callers avoid tracking the untouched file
|
||||
(S5).
|
||||
"""
|
||||
_ensure_safe_destination(dst)
|
||||
existing = ""
|
||||
if dst.exists():
|
||||
try:
|
||||
existing = dst.read_text(encoding="utf-8")
|
||||
except (OSError, UnicodeDecodeError) as exc:
|
||||
logger.warning(
|
||||
"Could not read %s (it may be unreadable or not UTF-8); "
|
||||
"skipping event-config merge to preserve user content.",
|
||||
dst,
|
||||
)
|
||||
logger.debug("Read error detail: %s", exc)
|
||||
return False
|
||||
# Remove existing Specify-marked [[hooks]] blocks
|
||||
# Match [[hooks]] ... speckit_marker = true (with any content in between)
|
||||
existing = re.sub(
|
||||
r'\[\[hooks\]\]\n(?:(?!\[\[hooks\]\]).)*?speckit_marker = true\n*',
|
||||
"",
|
||||
existing,
|
||||
flags=re.DOTALL,
|
||||
)
|
||||
dst.parent.mkdir(parents=True, exist_ok=True)
|
||||
dst.write_text(existing.rstrip() + "\n\n" + fragment + "\n", encoding="utf-8")
|
||||
return True
|
||||
|
||||
|
||||
def _remove_toml_entries(dst: Path) -> bool:
|
||||
"""Remove Specify-marked TOML entries; delete the file if now empty (#14).
|
||||
|
||||
@@ -2016,6 +2145,43 @@ def _remove_toml_entries(dst: Path) -> bool:
|
||||
return False
|
||||
|
||||
|
||||
def _remove_vibe_toml_entries(dst: Path) -> bool:
|
||||
"""Remove Specify-marked Vibe TOML hook entries; delete the file if now empty.
|
||||
|
||||
Returns True if the file was deleted (no user content remained).
|
||||
"""
|
||||
if not dst.exists():
|
||||
return False
|
||||
_ensure_safe_destination(dst)
|
||||
try:
|
||||
existing = dst.read_text(encoding="utf-8")
|
||||
except (OSError, UnicodeDecodeError) as exc:
|
||||
logger.warning(
|
||||
"Could not read %s (it may be unreadable or not UTF-8); "
|
||||
"skipping event-config cleanup to preserve user content.",
|
||||
dst,
|
||||
)
|
||||
logger.debug("Read error detail: %s", exc)
|
||||
return False
|
||||
# Remove Specify-marked [[hooks]] blocks
|
||||
cleaned = re.sub(
|
||||
r'\[\[hooks\]\]\n(?:(?!\[\[hooks\]\]).)*?speckit_marker = true\n*',
|
||||
"",
|
||||
existing,
|
||||
flags=re.DOTALL,
|
||||
)
|
||||
# If only whitespace/comments remain, the file had no user content
|
||||
stripped = "\n".join(
|
||||
line for line in cleaned.splitlines()
|
||||
if line.strip() and not line.strip().startswith("#")
|
||||
)
|
||||
if not stripped:
|
||||
dst.unlink(missing_ok=True)
|
||||
return True
|
||||
dst.write_text(cleaned, encoding="utf-8")
|
||||
return False
|
||||
|
||||
|
||||
def _merge_copilot_json(dst: Path, new_hooks: dict[str, list]) -> bool:
|
||||
"""Merge Specify-owned hooks into Copilot's dedicated hooks JSON (#8).
|
||||
|
||||
|
||||
@@ -4712,6 +4712,7 @@ class HookExecutor:
|
||||
kimi_skill_mode = selected_ai == "kimi"
|
||||
cline_mode = selected_ai == "cline"
|
||||
forge_mode = selected_ai == "forge"
|
||||
junie_mode = selected_ai == "junie"
|
||||
|
||||
skill_name = self._skill_name_from_command(command_id)
|
||||
if dollar_skill_mode and skill_name:
|
||||
@@ -4726,6 +4727,10 @@ class HookExecutor:
|
||||
from ..integrations.forge import format_forge_command_name
|
||||
|
||||
return f"/{format_forge_command_name(command_id)}"
|
||||
if junie_mode:
|
||||
from ..integrations.junie import format_junie_command_name
|
||||
|
||||
return f"/{format_junie_command_name(command_id)}"
|
||||
|
||||
use_slash = is_slash_skills_agent(selected_ai, ai_skills_enabled)
|
||||
|
||||
|
||||
@@ -65,7 +65,16 @@ class AlquimiaAIIntegration(SkillsIntegration):
|
||||
|
||||
@staticmethod
|
||||
def inject_argument_hint(content: str, hint: str) -> str:
|
||||
"""Insert ``argument-hint`` after the first ``description:`` in YAML frontmatter.
|
||||
"""Insert ``argument-hint`` after the ``description:`` scalar in YAML frontmatter.
|
||||
|
||||
A long ``description`` gets folded by the YAML dumper across
|
||||
indented continuation lines (plain or quoted), and an embedded
|
||||
paragraph break can add unindented blank lines inside a quoted
|
||||
scalar. Inserting the new line right after the *first* line of
|
||||
that scalar — instead of after the whole scalar — either produces
|
||||
invalid YAML or gets silently absorbed into the description
|
||||
string (#4044), so every continuation line (indented, or blank)
|
||||
is skipped first.
|
||||
|
||||
Skips injection if ``argument-hint:`` already exists in the
|
||||
frontmatter to avoid duplicate keys.
|
||||
@@ -88,15 +97,29 @@ class AlquimiaAIIntegration(SkillsIntegration):
|
||||
in_fm = False
|
||||
dash_count = 0
|
||||
injected = False
|
||||
for line in lines:
|
||||
i = 0
|
||||
n = len(lines)
|
||||
while i < n:
|
||||
line = lines[i]
|
||||
stripped = line.rstrip("\n\r")
|
||||
if stripped == "---":
|
||||
dash_count += 1
|
||||
in_fm = dash_count == 1
|
||||
out.append(line)
|
||||
i += 1
|
||||
continue
|
||||
if in_fm and not injected and stripped.startswith("description:"):
|
||||
out.append(line)
|
||||
i += 1
|
||||
# Skip folded/quoted continuation lines before inserting
|
||||
# so the new key lands after the description scalar ends.
|
||||
# Blank lines count too: PyYAML emits unindented blank
|
||||
# lines for embedded "\n\n" inside a quoted scalar.
|
||||
while i < n and (
|
||||
lines[i][:1] in (" ", "\t") or lines[i].rstrip("\r\n") == ""
|
||||
):
|
||||
out.append(lines[i])
|
||||
i += 1
|
||||
# Preserve the exact line-ending style (\r\n vs \n)
|
||||
if line.endswith("\r\n"):
|
||||
eol = "\r\n"
|
||||
@@ -109,6 +132,7 @@ class AlquimiaAIIntegration(SkillsIntegration):
|
||||
injected = True
|
||||
continue
|
||||
out.append(line)
|
||||
i += 1
|
||||
return "".join(out)
|
||||
|
||||
@staticmethod
|
||||
|
||||
@@ -67,7 +67,16 @@ class ClaudeIntegration(SkillsIntegration):
|
||||
|
||||
@staticmethod
|
||||
def inject_argument_hint(content: str, hint: str) -> str:
|
||||
"""Insert ``argument-hint`` after the first ``description:`` in YAML frontmatter.
|
||||
"""Insert ``argument-hint`` after the ``description:`` scalar in YAML frontmatter.
|
||||
|
||||
A long ``description`` gets folded by the YAML dumper across
|
||||
indented continuation lines (plain or quoted), and an embedded
|
||||
paragraph break can add unindented blank lines inside a quoted
|
||||
scalar. Inserting the new line right after the *first* line of
|
||||
that scalar — instead of after the whole scalar — either produces
|
||||
invalid YAML or gets silently absorbed into the description
|
||||
string (#4044), so every continuation line (indented, or blank)
|
||||
is skipped first.
|
||||
|
||||
Skips injection if ``argument-hint:`` already exists in the
|
||||
frontmatter to avoid duplicate keys.
|
||||
@@ -90,15 +99,29 @@ class ClaudeIntegration(SkillsIntegration):
|
||||
in_fm = False
|
||||
dash_count = 0
|
||||
injected = False
|
||||
for line in lines:
|
||||
i = 0
|
||||
n = len(lines)
|
||||
while i < n:
|
||||
line = lines[i]
|
||||
stripped = line.rstrip("\n\r")
|
||||
if stripped == "---":
|
||||
dash_count += 1
|
||||
in_fm = dash_count == 1
|
||||
out.append(line)
|
||||
i += 1
|
||||
continue
|
||||
if in_fm and not injected and stripped.startswith("description:"):
|
||||
out.append(line)
|
||||
i += 1
|
||||
# Skip past folded/quoted continuation lines of the scalar
|
||||
# before inserting, so the new key lands after it ends.
|
||||
# Blank lines count too: PyYAML emits unindented blank
|
||||
# lines for embedded "\n\n" inside a quoted scalar.
|
||||
while i < n and (
|
||||
lines[i][:1] in (" ", "\t") or lines[i].rstrip("\r\n") == ""
|
||||
):
|
||||
out.append(lines[i])
|
||||
i += 1
|
||||
# Preserve the exact line-ending style (\r\n vs \n)
|
||||
if line.endswith("\r\n"):
|
||||
eol = "\r\n"
|
||||
@@ -111,6 +134,7 @@ class ClaudeIntegration(SkillsIntegration):
|
||||
injected = True
|
||||
continue
|
||||
out.append(line)
|
||||
i += 1
|
||||
return "".join(out)
|
||||
|
||||
def _render_skill(self, template_name: str, frontmatter: dict[str, Any], body: str) -> str:
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
"""Goose integration — open source AI agent (Agentic AI Foundation)."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from ..base import YamlIntegration
|
||||
|
||||
|
||||
@@ -18,3 +20,84 @@ class GooseIntegration(YamlIntegration):
|
||||
"args": "{{args}}",
|
||||
"extension": ".yaml",
|
||||
}
|
||||
|
||||
def build_exec_args(
|
||||
self,
|
||||
prompt: str,
|
||||
*,
|
||||
model: str | None = None,
|
||||
output_json: bool = True,
|
||||
) -> list[str] | None:
|
||||
"""Build CLI arguments for non-interactive ``goose`` execution.
|
||||
|
||||
``YamlIntegration`` never overrode ``build_exec_args()``, so Goose
|
||||
inherited the ``IntegrationBase`` no-op returning ``None``. Callers read
|
||||
``None`` as "this CLI is unavailable", so a workflow command/prompt step
|
||||
targeting Goose reported ``CLI not found or not installed`` even with
|
||||
``goose`` on ``PATH`` (the Goose item in issue #2416).
|
||||
|
||||
``goose`` has no ``-p`` flag; its non-interactive entry point is
|
||||
``goose run``, which takes ``-t/--text`` for free-form text,
|
||||
``--recipe`` for a stored recipe, ``--params KEY=VALUE`` for recipe
|
||||
parameters, plus ``--model`` and ``--output-format``.
|
||||
|
||||
Spec Kit installs its commands as Goose *recipes* under
|
||||
``.goose/recipes/``, each declaring an optional ``args`` string
|
||||
parameter, so a ``/speckit.<name> <rest>`` invocation maps onto
|
||||
``--recipe <path> --params args=<rest>``. Only that namespace is
|
||||
mapped: ``--recipe`` is a *path* Spec Kit synthesizes, unlike
|
||||
opencode's ``--command`` or hermes' ``-s``, which hand a bare name to
|
||||
the agent's own resolver. Any other prompt -- including Goose's own
|
||||
session commands such as ``/help`` or ``/plan`` -- goes to ``-t``.
|
||||
"""
|
||||
args = [self._resolve_executable(), "run"]
|
||||
# Extra args are applied first, matching the opencode / codex /
|
||||
# cursor-agent ordering. Positional parity only, NOT precedence:
|
||||
# ``goose run`` is clap-derive based, and --recipe / --model /
|
||||
# --output-format are single-value args with no ``args_override_self``,
|
||||
# so re-passing any of them through
|
||||
# SPECKIT_INTEGRATION_GOOSE_EXTRA_ARGS makes goose exit with
|
||||
# "cannot be used multiple times" whichever side comes first. The same
|
||||
# is true of goose's boolean flags. Only its ``Vec``-typed args (which
|
||||
# clap infers as ArgAction::Append) may legitimately repeat.
|
||||
self._apply_extra_args_env_var(args)
|
||||
|
||||
if model:
|
||||
args.extend(["--model", model])
|
||||
if output_json:
|
||||
args.extend(["--output-format", "json"])
|
||||
|
||||
# Only the ``speckit.`` namespace maps to a recipe: this branch
|
||||
# synthesizes a *path*, and ``command_filename()`` can only ever spell
|
||||
# ``speckit.<name>.yaml``. ``PromptStep`` passes arbitrary ``prompt:``
|
||||
# strings here, so other slash text -- including Goose's own session
|
||||
# commands ``/help`` and ``/plan`` -- must reach ``-t`` unchanged.
|
||||
if prompt.startswith("/speckit."):
|
||||
command, _, remainder = prompt[1:].partition(" ")
|
||||
# ``command_filename`` re-adds the ``speckit.`` prefix and the
|
||||
# ``.yaml`` extension, so strip it here; a dotted extension command
|
||||
# (``speckit.git.commit``) round-trips too. A bare ``/speckit.``
|
||||
# leaves no stem and falls through to ``-t``.
|
||||
stem = command[len("speckit."):]
|
||||
if stem:
|
||||
# Derive the recipe path from the same two sources ``setup()``
|
||||
# uses -- ``config["folder"]`` + ``config["commands_subdir"]``
|
||||
# (exactly what ``commands_dest()`` does) and
|
||||
# ``command_filename()`` -- so the dispatch target cannot drift
|
||||
# from the file that was actually installed.
|
||||
folder = (self.config.get("folder") or "").strip("/")
|
||||
subdir = (self.config.get("commands_subdir") or "").strip("/")
|
||||
# Relative, forward-slash path: dispatch runs with
|
||||
# ``cwd=project_root``, and goose accepts a POSIX separator on
|
||||
# every platform (``commands_dest()`` yields backslashes on
|
||||
# win32).
|
||||
parts = [
|
||||
p for p in (folder, subdir, self.command_filename(stem)) if p
|
||||
]
|
||||
args.extend(["--recipe", "/".join(parts)])
|
||||
if remainder.strip():
|
||||
args.extend(["--params", f"args={remainder}"])
|
||||
return args
|
||||
|
||||
args.extend(["-t", prompt])
|
||||
return args
|
||||
|
||||
@@ -1,6 +1,51 @@
|
||||
"""Junie integration (JetBrains)."""
|
||||
|
||||
from ..base import MarkdownIntegration
|
||||
from ..manifest import IntegrationManifest
|
||||
|
||||
|
||||
import re
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
# Note injected into hook sections so Junie maps dot-notation command
|
||||
# names (from extensions.yml) to the hyphenated slash commands it uses.
|
||||
_HOOK_COMMAND_NOTE = (
|
||||
"- When constructing slash commands from hook command names, "
|
||||
"replace dots (`.`) with hyphens (`-`). "
|
||||
"For example, `speckit.git.commit` → `/speckit-git-commit`.\n"
|
||||
)
|
||||
|
||||
|
||||
def format_junie_command_name(cmd_name: str) -> str:
|
||||
"""Convert command name to Junie-compatible hyphenated format.
|
||||
|
||||
Junie does not allow dots inside of slash-commands.
|
||||
This function converts dot-notation command names to hyphenated format.
|
||||
|
||||
The function is idempotent: already-formatted names are returned unchanged.
|
||||
|
||||
Examples:
|
||||
>>> format_junie_command_name("plan")
|
||||
'speckit-plan'
|
||||
>>> format_junie_command_name("speckit.plan")
|
||||
'speckit-plan'
|
||||
>>> format_junie_command_name("speckit.git.commit")
|
||||
'speckit-git-commit'
|
||||
|
||||
Args:
|
||||
cmd_name: Command name in dot notation (speckit.foo.bar),
|
||||
hyphenated format (speckit-foo-bar), or plain name (foo)
|
||||
|
||||
Returns:
|
||||
Hyphenated command name with 'speckit-' prefix
|
||||
"""
|
||||
cmd_name = cmd_name.replace(".", "-")
|
||||
|
||||
if not cmd_name.startswith("speckit-"):
|
||||
cmd_name = f"speckit-{cmd_name}"
|
||||
|
||||
return cmd_name
|
||||
|
||||
|
||||
class JunieIntegration(MarkdownIntegration):
|
||||
@@ -17,5 +62,117 @@ class JunieIntegration(MarkdownIntegration):
|
||||
"format": "markdown",
|
||||
"args": "$ARGUMENTS",
|
||||
"extension": ".md",
|
||||
"inject_name": True,
|
||||
"format_name": format_junie_command_name,
|
||||
"invoke_separator": "-",
|
||||
}
|
||||
multi_install_safe = True
|
||||
invoke_separator = "-"
|
||||
|
||||
def command_filename(self, template_name: str) -> str:
|
||||
return format_junie_command_name(template_name) + ".md"
|
||||
|
||||
def build_command_invocation(self, command_name: str, args: str = "") -> str:
|
||||
"""Junie installs hyphenated slash-commands (``/speckit-<name>``), so the
|
||||
dispatch invocation must match. The inherited MarkdownIntegration default
|
||||
builds the dotted ``/speckit.<name>``, which references a command Junie
|
||||
never registered. Reuse the same hyphenation as command_filename /
|
||||
the injected frontmatter name (see ``format_junie_command_name``),
|
||||
mirroring the forge integration.
|
||||
"""
|
||||
invocation = "/" + format_junie_command_name(command_name)
|
||||
if args:
|
||||
invocation = f"{invocation} {args}"
|
||||
return invocation
|
||||
|
||||
def process_template(self, *args, **kwargs):
|
||||
"""Ensure shared templates render Junie command references with hyphens."""
|
||||
kwargs.setdefault("invoke_separator", self.invoke_separator)
|
||||
return super().process_template(*args, **kwargs)
|
||||
|
||||
@staticmethod
|
||||
def _inject_hook_command_note(content: str) -> str:
|
||||
"""Insert a dot-to-hyphen note before each hook output instruction.
|
||||
|
||||
Targets the line ``- For each executable hook, output the following``
|
||||
and inserts the note on the line before it, matching its indentation.
|
||||
Skips if the note is already present.
|
||||
"""
|
||||
if "replace dots" in content:
|
||||
return content
|
||||
|
||||
def repl(m: re.Match[str]) -> str:
|
||||
indent = m.group(1)
|
||||
instruction = m.group(2)
|
||||
# ``eol`` is empty when the regex matched via ``$`` because the
|
||||
# instruction was the final line of a file with no trailing
|
||||
# newline. Default to ``\n`` so the note never collapses onto
|
||||
# the same line as the instruction.
|
||||
eol = m.group(3) or "\n"
|
||||
return (
|
||||
indent
|
||||
+ _HOOK_COMMAND_NOTE.rstrip("\n")
|
||||
+ eol
|
||||
+ indent
|
||||
+ instruction
|
||||
+ eol
|
||||
)
|
||||
|
||||
return re.sub(
|
||||
r"(?m)^(\s*)(- For each executable hook, output the following[^\r\n]*)(\r\n|\n|$)",
|
||||
repl,
|
||||
content,
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def _rewrite_handoff_references(content: str) -> str:
|
||||
"""Replace dot-notation agent references in handoffs with hyphens."""
|
||||
return re.sub(
|
||||
r"(?m)^(\s*agent:\s*)(speckit\.[A-Za-z0-9-_]+(?:\.[A-Za-z0-9-_]+)*)",
|
||||
lambda m: f"{m.group(1)}{format_junie_command_name(m.group(2))}",
|
||||
content,
|
||||
)
|
||||
def post_process_command_content(self, content: str) -> str:
|
||||
"""Apply Junie-specific transformations to command content.
|
||||
|
||||
Overrides the ``IntegrationBase`` hook of the same name so that
|
||||
``CommandRegistrar.register_commands()`` (which dispatches to
|
||||
``post_process_command_content``) applies these transforms to
|
||||
extension/preset command files too, not just core commands.
|
||||
"""
|
||||
updated = self._inject_hook_command_note(content)
|
||||
updated = self._rewrite_handoff_references(updated)
|
||||
return updated
|
||||
|
||||
def setup(
|
||||
self,
|
||||
project_root: Path,
|
||||
manifest: IntegrationManifest,
|
||||
parsed_options: dict[str, Any] | None = None,
|
||||
**opts: Any,
|
||||
) -> list[Path]:
|
||||
"""Install Junie commands and apply post-processing transformations."""
|
||||
created = super().setup(project_root, manifest, parsed_options, **opts)
|
||||
|
||||
# Post-process generated command files
|
||||
dest_dir = self.commands_dest(project_root).resolve()
|
||||
|
||||
for path in created:
|
||||
# Only touch .md files under the commands directory
|
||||
try:
|
||||
path.resolve().relative_to(dest_dir)
|
||||
except ValueError:
|
||||
continue
|
||||
if path.suffix != ".md":
|
||||
continue
|
||||
|
||||
content_bytes = path.read_bytes()
|
||||
content = content_bytes.decode("utf-8")
|
||||
|
||||
updated = self.post_process_command_content(content)
|
||||
|
||||
if updated != content:
|
||||
path.write_bytes(updated.encode("utf-8"))
|
||||
self.record_file_in_manifest(path, project_root, manifest)
|
||||
|
||||
return created
|
||||
|
||||
@@ -11,9 +11,25 @@ from typing import Any
|
||||
|
||||
from ..base import IntegrationOption, SkillsIntegration
|
||||
from ..manifest import IntegrationManifest
|
||||
from ..._utils import dump_frontmatter
|
||||
|
||||
# Per-command frontmatter overrides for skills that should run in a forked
|
||||
# subagent context.
|
||||
#
|
||||
# This is intentionally empty. ``analyze`` was previously forked (added in
|
||||
# #2511) on the assumption that its heavy reads collapse to a short summary,
|
||||
# but in practice ``/speckit-analyze`` returns a 300-500 line report that is
|
||||
# injected back into the main conversation. In long sessions each subsequent
|
||||
# fork inherits that growing context, compounding overhead until the chat
|
||||
# freezes (#3185). Until a command genuinely returns a compact result, no
|
||||
# command opts into ``context: fork``. The injection mechanism below stays in
|
||||
# place so a future command can be added here when that holds true.
|
||||
FORK_CONTEXT_COMMANDS: dict[str, dict[str, str]] = {}
|
||||
|
||||
|
||||
class VibeIntegration(SkillsIntegration):
|
||||
"""Integration for Mistral Vibe skills."""
|
||||
|
||||
key = "vibe"
|
||||
config = {
|
||||
"name": "Mistral Vibe",
|
||||
@@ -28,24 +44,63 @@ class VibeIntegration(SkillsIntegration):
|
||||
"args": "$ARGUMENTS",
|
||||
"extension": "/SKILL.md",
|
||||
}
|
||||
multi_install_safe = True
|
||||
|
||||
# Vibe's hooks schema supports exactly three hook types (HookConfig
|
||||
# rejects anything else): pre_tool, post_tool, post_agent. Unsupported
|
||||
# canonical events (session_start/session_end/user_prompt_submit) are
|
||||
# intentionally absent so install_integration_events skips them with a
|
||||
# warning instead of writing entries Vibe would refuse to load.
|
||||
CANONICAL_TO_NATIVE = {
|
||||
"pre_tool_use": "pre_tool",
|
||||
"post_tool_use": "post_tool",
|
||||
"stop": "post_agent",
|
||||
}
|
||||
events_config_file = ".vibe/hooks.toml"
|
||||
events_format = "toml-vibe"
|
||||
# Vibe parses any non-empty hook stdout as a JSON HookStructuredResponse;
|
||||
# plain text is reported as a hook failure and its output dropped. The
|
||||
# dispatcher therefore wraps handler stdout as {"decision": "allow",
|
||||
# "hook_specific_output": {"additional_context": ...}} for every event:
|
||||
# post_tool injects additional_context, pre_tool/post_agent ignore it but
|
||||
# still parse cleanly.
|
||||
events_context_envelope = {"*": "hook_specific_output"}
|
||||
|
||||
@classmethod
|
||||
def options(cls) -> list[IntegrationOption]:
|
||||
return [
|
||||
opts = super().options()
|
||||
opts.append(
|
||||
IntegrationOption(
|
||||
"--skills",
|
||||
is_flag=True,
|
||||
default=True,
|
||||
help="Install as agent skills",
|
||||
),
|
||||
]
|
||||
)
|
||||
return opts
|
||||
|
||||
def _render_skill(self, template_name: str, frontmatter: dict[str, Any], body: str) -> str:
|
||||
"""Render a processed command template as a Vibe skill."""
|
||||
skill_name = f"speckit-{template_name.replace('.', '-')}"
|
||||
description = frontmatter.get(
|
||||
"description",
|
||||
f"Spec-kit workflow command: {template_name}",
|
||||
)
|
||||
skill_frontmatter = self._build_skill_fm(
|
||||
skill_name, description, f"templates/commands/{template_name}.md"
|
||||
)
|
||||
frontmatter_text = dump_frontmatter(skill_frontmatter)
|
||||
return f"---\n{frontmatter_text}\n---\n\n{body.strip()}\n"
|
||||
|
||||
def _build_skill_fm(self, name: str, description: str, source: str) -> dict:
|
||||
from specify_cli.agents import CommandRegistrar
|
||||
return CommandRegistrar.build_skill_frontmatter(
|
||||
self.key, name, description, source
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def _inject_frontmatter_flag(content: str, key: str, value: str = "true") -> str:
|
||||
"""
|
||||
Insert ``key: value`` before the closing ``---`` if not already present.
|
||||
Value: true by default
|
||||
"""
|
||||
"""Insert ``key: value`` before the closing ``---`` if not already present."""
|
||||
lines = content.splitlines(keepends=True)
|
||||
|
||||
# Pre-scan: bail out if already present in frontmatter
|
||||
@@ -80,13 +135,45 @@ class VibeIntegration(SkillsIntegration):
|
||||
out.append(line)
|
||||
return "".join(out)
|
||||
|
||||
def post_process_skill_content(self, content: str) -> str:
|
||||
@staticmethod
|
||||
def _skill_stem_from_content(content: str) -> str | None:
|
||||
"""Derive the command stem (e.g. ``analyze``) from a skill's frontmatter.
|
||||
|
||||
Reads the ``name:`` field of the first frontmatter block and strips
|
||||
the ``speckit-`` prefix. Returns ``None`` when no name is present.
|
||||
"""
|
||||
Inject shared hook guidance and Vibe-specific frontmatter flags:
|
||||
- user-invocable: allows the skill to be invoked by the user (not just other agents)
|
||||
dash_count = 0
|
||||
for line in content.splitlines():
|
||||
stripped = line.rstrip("\r\n")
|
||||
if stripped == "---":
|
||||
dash_count += 1
|
||||
if dash_count == 2:
|
||||
break
|
||||
continue
|
||||
if dash_count == 1 and stripped.startswith("name:"):
|
||||
name = stripped[len("name:"):].strip().strip('"').strip("'")
|
||||
if name.startswith("speckit-"):
|
||||
return name[len("speckit-"):]
|
||||
return name or None
|
||||
return None
|
||||
|
||||
def post_process_skill_content(self, content: str) -> str:
|
||||
"""Inject Vibe-specific frontmatter flags.
|
||||
|
||||
Applied by every skill-generation path (setup, presets, extensions),
|
||||
so Vibe-specific frontmatter stays consistent however the SKILL.md
|
||||
was produced.
|
||||
"""
|
||||
updated = super().post_process_skill_content(content)
|
||||
updated = self._inject_frontmatter_flag(updated, "user-invocable")
|
||||
updated = self._inject_frontmatter_flag(updated, "disable-model-invocation", "false")
|
||||
|
||||
stem = self._skill_stem_from_content(updated)
|
||||
if stem:
|
||||
fork_config = FORK_CONTEXT_COMMANDS.get(stem)
|
||||
if fork_config:
|
||||
for key, value in fork_config.items():
|
||||
updated = self._inject_frontmatter_flag(updated, key, value)
|
||||
return updated
|
||||
|
||||
def setup(
|
||||
|
||||
@@ -823,25 +823,6 @@ class PresetManager:
|
||||
|
||||
return True
|
||||
|
||||
def _extension_installed_for_command(self, command_name: str) -> bool:
|
||||
"""Whether *command_name* may be materialized in this project.
|
||||
|
||||
Extension command overrides follow ``speckit.<ext-id>.<cmd-name>``;
|
||||
they must be skipped everywhere preset artifacts are written —
|
||||
registration *and* reconciliation — when the extension isn't
|
||||
installed, or reconciliation would materialize files that
|
||||
registration refused to track. Core commands (single-dot names,
|
||||
e.g. ``speckit.specify``) always pass.
|
||||
"""
|
||||
parts = command_name.split(".")
|
||||
if len(parts) >= 3 and parts[0] == "speckit":
|
||||
ext_id = parts[1]
|
||||
if not (
|
||||
self.project_root / ".specify" / "extensions" / ext_id
|
||||
).is_dir():
|
||||
return False
|
||||
return True
|
||||
|
||||
def _register_commands(
|
||||
self,
|
||||
manifest: PresetManifest,
|
||||
@@ -870,21 +851,20 @@ class PresetManager:
|
||||
if not command_templates:
|
||||
return {}
|
||||
|
||||
# Filter out extension command overrides if the extension isn't installed.
|
||||
filtered = [
|
||||
cmd
|
||||
for cmd in command_templates
|
||||
if self._extension_installed_for_command(cmd["name"])
|
||||
]
|
||||
|
||||
if not filtered:
|
||||
return {}
|
||||
|
||||
# A preset command template always ships its own body, so it is
|
||||
# self-contained and scaffolds regardless of whether any similarly
|
||||
# named extension is installed. Namespaced names (speckit.<ns>.<cmd>)
|
||||
# are treated exactly like short names (speckit.<cmd>) — they are NOT
|
||||
# filtered out just because ``.specify/extensions/<ns>/`` is absent.
|
||||
# The only command that cannot be materialized is a composition
|
||||
# (prepend/append/wrap) with no base layer to compose onto; that case
|
||||
# is handled per-command below (warn + skip), not by dropping names up
|
||||
# front.
|
||||
# Handle composition strategies: resolve composed content for non-replace commands
|
||||
resolver = PresetResolver(self.project_root)
|
||||
composed_dir = None
|
||||
commands_to_register = []
|
||||
for cmd in filtered:
|
||||
for cmd in command_templates:
|
||||
strategy = cmd.get("strategy", "replace")
|
||||
if strategy != "replace":
|
||||
# Only pre-compose if this preset is the top composing layer.
|
||||
@@ -907,13 +887,23 @@ class PresetManager:
|
||||
"file": f".composed/{cmd['name']}.md",
|
||||
})
|
||||
else:
|
||||
raise PresetValidationError(
|
||||
f"Command '{cmd['name']}' uses '{strategy}' strategy "
|
||||
f"but no base command layer exists to compose onto. "
|
||||
f"Ensure a lower-priority preset, extension, or core "
|
||||
f"command provides this command before using "
|
||||
f"composition strategies."
|
||||
# No base layer to compose onto (e.g. the command it
|
||||
# would wrap comes from an extension that isn't
|
||||
# installed). Warn and skip this single command rather
|
||||
# than aborting the whole install — mirrors the
|
||||
# "composed is None" branch in
|
||||
# _reconcile_composed_commands so command-mode and
|
||||
# reconciliation behave identically.
|
||||
import warnings
|
||||
warnings.warn(
|
||||
f"Command '{cmd['name']}' uses '{strategy}' "
|
||||
f"strategy but no base command layer exists to "
|
||||
f"compose onto; skipping. Provide a lower-priority "
|
||||
f"preset, extension, or core command for it before "
|
||||
f"using composition strategies.",
|
||||
stacklevel=2,
|
||||
)
|
||||
continue
|
||||
else:
|
||||
# Not the top layer — register raw file; reconciliation
|
||||
# will overwrite with the correct composed/winning content.
|
||||
@@ -1681,21 +1671,13 @@ class PresetManager:
|
||||
if not command_names:
|
||||
return set()
|
||||
|
||||
# Never materialize extension-scoped commands whose extension isn't
|
||||
# installed. Registration (_register_commands / _register_skills)
|
||||
# already refuses them, so a reconciliation pass writing them would
|
||||
# create files no registry entry tracks. Filtering here — the single
|
||||
# chokepoint every install/remove/rescaffold reconciliation funnels
|
||||
# through — keeps all callers consistent without each one re-applying
|
||||
# the filter when seeding names from manifest templates.
|
||||
command_names = [
|
||||
name
|
||||
for name in command_names
|
||||
if self._extension_installed_for_command(name)
|
||||
]
|
||||
if not command_names:
|
||||
return set()
|
||||
|
||||
# Every preset-owned command name flows through unchanged. Names are
|
||||
# NOT filtered by the ``speckit.<ns>.<cmd>`` shape: a self-contained
|
||||
# preset command scaffolds whether or not a like-named extension is
|
||||
# installed (parity with _register_commands), and a name whose base
|
||||
# layer has disappeared must still reach the loop below so its now
|
||||
# uncomposable stale file gets unregistered. The loop already skips
|
||||
# names that resolve to no layers at all (``if not layers: continue``).
|
||||
try:
|
||||
from ..agents import CommandRegistrar
|
||||
except ImportError:
|
||||
@@ -2136,14 +2118,11 @@ class PresetManager:
|
||||
if not command_names:
|
||||
return set()
|
||||
|
||||
command_names = [
|
||||
name
|
||||
for name in command_names
|
||||
if self._extension_installed_for_command(name)
|
||||
]
|
||||
if not command_names:
|
||||
return set()
|
||||
|
||||
# Preset-owned command names are not filtered by the
|
||||
# ``speckit.<ns>.<cmd>`` shape here either: a self-contained preset
|
||||
# command renders its skill whether or not a like-named extension is
|
||||
# installed. The per-name loop below skips anything that doesn't
|
||||
# resolve to a managed skill directory.
|
||||
resolver = PresetResolver(self.project_root)
|
||||
active_skills_dir = self._get_skills_dir()
|
||||
|
||||
@@ -2673,21 +2652,17 @@ class PresetManager:
|
||||
if not command_templates:
|
||||
return {}
|
||||
|
||||
# Filter out extension command overrides if the extension isn't installed,
|
||||
# matching the same logic used by _register_commands().
|
||||
filtered = [
|
||||
cmd
|
||||
for cmd in command_templates
|
||||
if self._extension_installed_for_command(cmd["name"])
|
||||
]
|
||||
|
||||
if not filtered:
|
||||
return {}
|
||||
|
||||
# Preset command templates are self-contained and render as skills
|
||||
# regardless of whether a like-named extension is installed — the same
|
||||
# rule _register_commands() uses. No ``speckit.<ns>.<cmd>`` name-shape
|
||||
# filtering; the per-command loop below skips anything without a target
|
||||
# skill directory.
|
||||
skills_dir = target_dir if target_dir is not None else self._get_skills_dir()
|
||||
if not skills_dir:
|
||||
return {}
|
||||
|
||||
resolver = PresetResolver(self.project_root)
|
||||
|
||||
from .. import SKILL_DESCRIPTIONS, load_init_options
|
||||
from ..agents import CommandRegistrar
|
||||
from ..integrations import get_integration
|
||||
@@ -2717,7 +2692,7 @@ class PresetManager:
|
||||
|
||||
written: List[str] = []
|
||||
|
||||
for cmd_tmpl in filtered:
|
||||
for cmd_tmpl in command_templates:
|
||||
cmd_name = cmd_tmpl["name"]
|
||||
cmd_file_rel = cmd_tmpl["file"]
|
||||
source_file = preset_dir / cmd_file_rel
|
||||
@@ -2757,6 +2732,29 @@ class PresetManager:
|
||||
content = source_file.read_text(encoding="utf-8")
|
||||
frontmatter, body = registrar.parse_frontmatter(content)
|
||||
|
||||
# A composition-strategy command (wrap/prepend/append) needs a
|
||||
# base layer to compose onto. When _register_commands produced no
|
||||
# composed file for it and the stack still has no base
|
||||
# (resolve_content is None) — e.g. the command it wraps comes from
|
||||
# an extension that isn't installed — rendering the raw preset
|
||||
# fragment as a skill would emit broken output: a literal
|
||||
# {CORE_TEMPLATE} for wrap, or only the preset's own fragment for
|
||||
# prepend/append. Skip it here too so command mode and skills mode
|
||||
# agree (mirrors _register_commands, which skips the same command).
|
||||
# _register_commands already warned for this command in the same
|
||||
# pass, so the skip is silent here to avoid a duplicate warning.
|
||||
effective_strategy = (
|
||||
cmd_tmpl.get("strategy")
|
||||
or frontmatter.get("strategy")
|
||||
or "replace"
|
||||
)
|
||||
if (
|
||||
effective_strategy != "replace"
|
||||
and not composed_file.exists()
|
||||
and resolver.resolve_content(cmd_name, "command") is None
|
||||
):
|
||||
continue
|
||||
|
||||
if frontmatter.get("strategy") == "wrap":
|
||||
body, core_frontmatter = _substitute_core_template(body, cmd_name, self.project_root, registrar)
|
||||
frontmatter = dict(frontmatter)
|
||||
|
||||
@@ -59,7 +59,15 @@ def preset_list():
|
||||
console.print(" [cyan]specify preset add <pack-name>[/cyan]")
|
||||
return
|
||||
|
||||
console.print("\n[bold cyan]Installed Presets:[/bold cyan]\n")
|
||||
# Sort by actual resolution precedence: lower priority number wins, ties
|
||||
# broken by preset id (matching PresetRegistry.list_by_priority()). This
|
||||
# keeps the printed order aligned with how presets are composed/resolved.
|
||||
installed = sorted(
|
||||
installed,
|
||||
key=lambda pack: (pack.get("priority", 10), str(pack.get("id", ""))),
|
||||
)
|
||||
|
||||
console.print("\n[bold cyan]Installed Presets[/bold cyan] [dim](in resolution order — highest precedence first)[/dim]\n")
|
||||
for pack in installed:
|
||||
status = "[green]enabled[/green]" if pack.get("enabled", True) else "[red]disabled[/red]"
|
||||
pri = pack.get('priority', 10)
|
||||
@@ -75,6 +83,8 @@ def preset_list():
|
||||
console.print(f" [dim]Templates: {pack['template_count']}[/dim]")
|
||||
console.print()
|
||||
|
||||
console.print("[dim]Lower priority number = higher precedence. Ties are broken by preset id (alphabetical).[/dim]")
|
||||
|
||||
|
||||
@preset_app.command("add")
|
||||
def preset_add(
|
||||
|
||||
@@ -366,6 +366,17 @@ def _validate_steps(
|
||||
|
||||
# Determine step type
|
||||
step_type = step_config.get("type", "command")
|
||||
if not isinstance(step_type, str):
|
||||
# Registry keys are strings. Checking an unhashable YAML value
|
||||
# (for example ``type: [shell]`` or a mapping) against the set
|
||||
# below raises a raw TypeError before validation can report the
|
||||
# authoring mistake. Guard every non-string shape first, matching
|
||||
# the typed validation already applied to workflow and step IDs.
|
||||
errors.append(
|
||||
f"Step {step_id!r}: 'type' must be a string, got "
|
||||
f"{type(step_type).__name__} ({step_type!r})."
|
||||
)
|
||||
continue
|
||||
if step_type not in _get_valid_step_types():
|
||||
errors.append(
|
||||
f"Step {step_id!r} has invalid type {step_type!r}."
|
||||
@@ -743,12 +754,13 @@ class RunState:
|
||||
cls._validate_run_id(run_id)
|
||||
runs_dir = project_root / ".specify" / "workflows" / "runs" / run_id
|
||||
state_path = runs_dir / "state.json"
|
||||
if not state_path.exists():
|
||||
|
||||
try:
|
||||
with open(state_path, encoding="utf-8") as f:
|
||||
state_data = json.load(f)
|
||||
except FileNotFoundError:
|
||||
msg = f"Run state not found: {state_path}"
|
||||
raise FileNotFoundError(msg)
|
||||
|
||||
with open(state_path, encoding="utf-8") as f:
|
||||
state_data = json.load(f)
|
||||
if not isinstance(state_data, dict):
|
||||
raise ValueError("Invalid run state: expected a JSON object")
|
||||
missing_fields = [
|
||||
|
||||
@@ -4,7 +4,9 @@
|
||||
**Created**: [DATE]
|
||||
**Feature**: [Link to spec.md or relevant documentation]
|
||||
|
||||
**Note**: This checklist is generated by the `__SPECKIT_COMMAND_CHECKLIST__` command based on feature context and requirements.
|
||||
**Note**: This custom checklist is generated by the `__SPECKIT_COMMAND_CHECKLIST__` command based on feature context and requirements.
|
||||
**Review Ownership**: This checklist is a reviewer-owned requirements-quality review artifact. Mark an item `[x]` only when the reviewer determines the requirements-quality criterion is satisfied.
|
||||
**Marker Semantics**: `[x]` means the criterion has been reviewed and satisfied for requirements quality. It does not mean implementation work is complete.
|
||||
|
||||
<!--
|
||||
============================================================================
|
||||
@@ -34,7 +36,10 @@
|
||||
|
||||
## Notes
|
||||
|
||||
- Check items off as completed: `[x]`
|
||||
- Mark items `[x]` only after review confirms the requirement-quality criterion is satisfied
|
||||
- Leave items unchecked when they still require clarification, correction, or reviewer evaluation
|
||||
- `__SPECKIT_COMMAND_IMPLEMENT__` reads checklist checkbox state as a gate and must not modify markers
|
||||
- `checklists/requirements.md` has a separate built-in lifecycle maintained by `__SPECKIT_COMMAND_SPECIFY__` and `__SPECKIT_COMMAND_CLARIFY__`
|
||||
- Add comments or findings inline
|
||||
- Link to relevant resources or documentation
|
||||
- Items are numbered sequentially for easy reference
|
||||
|
||||
@@ -27,6 +27,15 @@ scripts:
|
||||
|
||||
**Metaphor**: If your spec is code written in English, the checklist is its unit test suite. You're testing whether the requirements are well-written, complete, unambiguous, and ready for implementation - NOT whether the implementation works.
|
||||
|
||||
**Ownership and checkbox lifecycle**:
|
||||
|
||||
- Custom checklists generated by this command are reviewer-owned requirements-quality review artifacts.
|
||||
- `[x]` means the reviewer determined the requirements-quality criterion is satisfied.
|
||||
- `[x]` does NOT mean implementation work is complete.
|
||||
- This command generates or appends checklist items; it MUST NOT mark generated items `[x]`.
|
||||
- An agent may assist with evaluating items only when explicitly asked by the reviewer.
|
||||
- `checklists/requirements.md` is a separate built-in spec-quality checklist maintained by `__SPECKIT_COMMAND_SPECIFY__` and `__SPECKIT_COMMAND_CLARIFY__`; do not treat that exception as applying to custom checklists generated here.
|
||||
|
||||
## User Input
|
||||
|
||||
```text
|
||||
@@ -136,6 +145,7 @@ You **MUST** consider the user input before proceeding (if not empty).
|
||||
- If file does NOT exist: Create new file and number items starting from CHK001
|
||||
- If file exists: Append new items to existing file, continuing from the last CHK ID (e.g., if last item is CHK015, start new items at CHK016)
|
||||
- Never delete or replace existing checklist content - always preserve and append
|
||||
- Leave every newly generated item unchecked (`[ ]`); checkbox state belongs to the reviewer
|
||||
|
||||
**CORE PRINCIPLE - Test the Requirements, Not the Implementation**:
|
||||
Every checklist item MUST evaluate the REQUIREMENTS THEMSELVES for:
|
||||
@@ -245,7 +255,7 @@ You **MUST** consider the user input before proceeding (if not empty).
|
||||
- ✅ "Are [edge cases/scenarios] addressed in requirements?"
|
||||
- ✅ "Does the spec define [missing aspect]?"
|
||||
|
||||
7. **Structure Reference**: Generate the checklist following the canonical template in `templates/checklist-template.md` for title, meta section, category headings, and ID formatting. If template is unavailable, use: H1 title, purpose/created meta lines, `##` category sections containing `- [ ] CHK### <requirement item>` lines with globally incrementing IDs starting at CHK001.
|
||||
7. **Structure Reference**: Generate the checklist following the canonical template in `templates/checklist-template.md` for title, meta section, category headings, ownership note, notes section, and ID formatting. If template is unavailable, use: H1 title, purpose/created meta lines, an ownership note explaining that `[x]` means reviewer approval of requirements quality, `##` category sections containing `- [ ] CHK### <requirement item>` lines with globally incrementing IDs starting at CHK001, and notes that `__SPECKIT_COMMAND_IMPLEMENT__` reads checklist state but does not modify markers.
|
||||
|
||||
8. **Report**: Output full path to checklist file, item count, and summarize whether the run created a new file or appended to an existing one. Summarize:
|
||||
- Focus areas selected
|
||||
|
||||
@@ -54,33 +54,36 @@ You **MUST** consider the user input before proceeding (if not empty).
|
||||
1. Run `{SCRIPT}` from repo root and parse FEATURE_DIR and AVAILABLE_DOCS list. All paths must be absolute. 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. **Check checklists status** (if FEATURE_DIR/checklists/ exists):
|
||||
- Treat checklist markers as a read-only gate: scan checkbox state, report status, and ask before proceeding when needed; do NOT modify checklist files or markers
|
||||
- `checklists/requirements.md` is the built-in spec-quality checklist maintained by `__SPECKIT_COMMAND_SPECIFY__` and `__SPECKIT_COMMAND_CLARIFY__`; custom checklists generated by `__SPECKIT_COMMAND_CHECKLIST__` are reviewer-owned requirements-quality review artifacts
|
||||
- For custom checklists, `[x]` means the reviewer determined the requirements-quality criterion is satisfied; it does NOT mean implementation work is complete
|
||||
- Scan all checklist files in the checklists/ directory
|
||||
- For each checklist, count:
|
||||
- Total items: All lines matching `- [ ]` or `- [X]` or `- [x]`
|
||||
- Completed items: Lines matching `- [X]` or `- [x]`
|
||||
- Incomplete items: Lines matching `- [ ]`
|
||||
- Checked items: Lines matching `- [X]` or `- [x]`
|
||||
- Unchecked items: Lines matching `- [ ]`
|
||||
- Create a status table:
|
||||
|
||||
```text
|
||||
| Checklist | Total | Completed | Incomplete | Status |
|
||||
|-----------|-------|-----------|------------|--------|
|
||||
| ux.md | 12 | 12 | 0 | ✓ PASS |
|
||||
| test.md | 8 | 5 | 3 | ✗ FAIL |
|
||||
| security.md | 6 | 6 | 0 | ✓ PASS |
|
||||
| Checklist | Total | Checked | Unchecked | Status |
|
||||
|-----------|-------|---------|-----------|--------|
|
||||
| ux.md | 12 | 12 | 0 | ✓ PASS |
|
||||
| test.md | 8 | 5 | 3 | ✗ FAIL |
|
||||
| security.md | 6 | 6 | 0 | ✓ PASS |
|
||||
```
|
||||
|
||||
- Calculate overall status:
|
||||
- **PASS**: All checklists have 0 incomplete items
|
||||
- **FAIL**: One or more checklists have incomplete items
|
||||
- **PASS**: All checklists have 0 unchecked items
|
||||
- **FAIL**: One or more checklists have unchecked items
|
||||
|
||||
- **If any checklist is incomplete**:
|
||||
- Display the table with incomplete item counts
|
||||
- **STOP** and ask: "Some checklists are incomplete. Do you want to proceed with implementation anyway? (yes/no)"
|
||||
- **If any checklist has unchecked items**:
|
||||
- Display the table with unchecked item counts
|
||||
- **STOP** and ask: "Some checklists have unchecked items. Do you want to proceed with implementation anyway? (yes/no)"
|
||||
- Wait for user response before continuing
|
||||
- If user says "no" or "wait" or "stop", halt execution
|
||||
- If user says "yes" or "proceed" or "continue", proceed to step 3
|
||||
|
||||
- **If all checklists are complete**:
|
||||
- **If all checklists are checked**:
|
||||
- Display the table showing all checklists passed
|
||||
- Automatically proceed to step 3
|
||||
|
||||
|
||||
@@ -64,7 +64,7 @@ git config --get remote.origin.url
|
||||
> [!CAUTION]
|
||||
> ONLY PROCEED TO NEXT STEPS IF THE REMOTE IS A GITHUB URL
|
||||
|
||||
1. **Fetch existing issues for deduplication**: Before creating anything, build the set of task IDs you are about to process from `tasks.md` (each is a `T` followed by three digits, e.g. `T001`). Then use the GitHub MCP server's `list_issues` tool to look for issues that already cover those IDs. Do not pass a `state` value, since omitting it makes the tool return both open and closed issues. Request `perPage: 100` to keep the number of calls down, and since the tool uses cursor-based pagination, request pages with the `after` parameter (using the `endCursor` from the previous response). For each issue title, match it against the task ID pattern `\bT\d{3}\b` (word boundaries so tokens like `ST001` or `T0010` are not matched by mistake; this also recognises titles written as `T001 ...`, `T001: ...` or `[T001] ...`) and, when it matches one of your task IDs, mark that ID as already having an issue. Stop paginating as soon as every task ID has been matched, or when there are no more pages, so you do not keep fetching the whole repository's issue history once all task IDs are accounted for. This bounds the number of calls on repos with large issue histories and still prevents duplicates when the command is re-run after `tasks.md` is regenerated or the skill is re-invoked.
|
||||
1. **Fetch existing issues for deduplication**: Before creating anything, build the set of task IDs you are about to process from `tasks.md` (each is a `T` followed by **at least** three digits, e.g. `T001` — `__SPECKIT_COMMAND_CONVERGE__` assigns new IDs with `T{M+1:03d}`, which is a floor rather than a cap, so once a file has more than 999 tasks the IDs are four digits or longer). Then use the GitHub MCP server's `list_issues` tool to look for issues that already cover those IDs. Do not pass a `state` value, since omitting it makes the tool return both open and closed issues. Request `perPage: 100` to keep the number of calls down, and since the tool uses cursor-based pagination, request pages with the `after` parameter (using the `endCursor` from the previous response). For each issue title, match it against the task ID pattern `\bT\d{3,}\b` (the `{3,}` accepts four-digit and longer IDs — with `\d{3}` a title containing `T1000` would not match at all, because the trailing `\b` cannot fall between two digits, so that task would be silently neither deduplicated nor created; word boundaries still stop a token like `ST001` from matching, and force the whole digit run to be consumed so `T100` can never match inside `T1000`; this also recognises titles written as `T001 ...`, `T001: ...` or `[T001] ...`) and, when it matches one of your task IDs, mark that ID as already having an issue. Stop paginating as soon as every task ID has been matched, or when there are no more pages, so you do not keep fetching the whole repository's issue history once all task IDs are accounted for. This bounds the number of calls on repos with large issue histories and still prevents duplicates when the command is re-run after `tasks.md` is regenerated or the skill is re-invoked.
|
||||
1. For each task in the list, use the GitHub MCP server to create a new issue in the repository that is representative of the Git remote. Task lines in `tasks.md` start with a markdown checkbox, so first strip the leading `- [ ]` (and any `[P]` / `[US#]` markers) to recover the task ID and its description. Create the issue with a single canonical title of the form `T001: <description>`, with the ID written once followed by the task description (for example, the line `- [ ] T001 Create project structure` becomes the title `T001: Create project structure`).
|
||||
- **Skip** any task whose ID is already present in the set of existing issues from the previous step, and report it (for example, `T001 already has an issue, skipping`).
|
||||
- Only create issues for tasks that do not yet have a matching issue.
|
||||
|
||||
@@ -1010,3 +1010,33 @@ def test_bundle_info_resolves_ghes_browser_release_url(project: Path):
|
||||
|
||||
payload = json.loads(result.output)
|
||||
assert payload["id"] == "demo-bundle"
|
||||
|
||||
|
||||
def test_bundle_download_rejects_oversized_response(project: Path, monkeypatch):
|
||||
"""Bundle download rejects responses exceeding MAX_DOWNLOAD_BYTES."""
|
||||
# Monkeypatch to a small limit so the test is fast and low-memory.
|
||||
monkeypatch.setattr(
|
||||
"specify_cli.commands.bundle.MAX_DOWNLOAD_BYTES", 100
|
||||
)
|
||||
|
||||
api_asset_url = "https://api.github.com/repos/org/repo/releases/assets/99"
|
||||
|
||||
def fake_open_url(url, timeout=None, extra_headers=None, redirect_validator=None):
|
||||
# Return a response that exceeds 100 bytes.
|
||||
return FakeBundleResponse(b"x" * 200, 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"])
|
||||
|
||||
# Must fail with a size-limit error, not an unhandled traceback.
|
||||
assert result.exit_code == 1
|
||||
# Rich may wrap the message across lines; normalise whitespace before checking.
|
||||
output_flat = " ".join(result.output.split())
|
||||
assert "exceeds maximum size of 100 bytes" in output_flat
|
||||
|
||||
@@ -62,6 +62,39 @@ def test_local_source_rejects_unknown_file(tmp_path: Path):
|
||||
_local_manifest_source(str(weird))
|
||||
|
||||
|
||||
def test_local_source_zip_non_utf8_manifest_raises_bundler_error(tmp_path: Path):
|
||||
"""Undecodable bundle.yml bytes inside a .zip must raise BundlerError.
|
||||
|
||||
The manifest bytes are decoded as UTF-8 explicitly, matching
|
||||
``yamlio.load_yaml``'s "Could not read ..." contract, instead of
|
||||
escaping as a raw ``UnicodeDecodeError``/``ReaderError`` traceback.
|
||||
"""
|
||||
artifact = tmp_path / "demo.zip"
|
||||
with zipfile.ZipFile(artifact, "w") as archive:
|
||||
archive.writestr("bundle.yml", b"\xff\xfe bundle \xc3\x28\n")
|
||||
|
||||
with pytest.raises(BundlerError, match="Could not read"):
|
||||
_local_manifest_source(str(artifact))
|
||||
|
||||
|
||||
def test_local_source_zip_utf16_manifest_rejected_like_directory(tmp_path: Path):
|
||||
"""A well-formed UTF-16 manifest must fail the same way in a .zip.
|
||||
|
||||
``yamlio.load_yaml`` decodes strictly as UTF-8, so a UTF-16 bundle.yml
|
||||
(the realistic PowerShell ``Out-File`` output) is rejected when read
|
||||
from a directory. Feeding the zip bytes straight to PyYAML would let
|
||||
its Reader honour the UTF-16 BOM and *accept* the same manifest,
|
||||
making zip and directory sources diverge.
|
||||
"""
|
||||
artifact = tmp_path / "demo.zip"
|
||||
manifest_text = "bundle:\n id: demo-bundle\n version: 1.0.0\n"
|
||||
with zipfile.ZipFile(artifact, "w") as archive:
|
||||
archive.writestr("bundle.yml", manifest_text.encode("utf-16"))
|
||||
|
||||
with pytest.raises(BundlerError, match="Could not read"):
|
||||
_local_manifest_source(str(artifact))
|
||||
|
||||
|
||||
def test_install_bundled_extension_from_zip_offline(tmp_path: Path):
|
||||
"""End-to-end: build → install (offline, local .zip) → list → remove."""
|
||||
project = make_project(tmp_path / "proj")
|
||||
|
||||
@@ -126,6 +126,50 @@ def test_active_integration_refuses_symlinked_specify_escape(tmp_path: Path):
|
||||
assert active_integration(project) is None
|
||||
|
||||
|
||||
def _write_marker(tmp_path: Path, payload: str) -> Path:
|
||||
project = tmp_path / "proj"
|
||||
(project / ".specify").mkdir(parents=True)
|
||||
(project / ".specify" / "integration.json").write_text(
|
||||
payload, encoding="utf-8"
|
||||
)
|
||||
return project
|
||||
|
||||
|
||||
def test_active_integration_reads_default_integration(tmp_path: Path):
|
||||
"""A marker carrying only ``default_integration`` must resolve.
|
||||
|
||||
``write_integration_json`` writes both ``integration`` and
|
||||
``default_integration``, so a marker produced by the current CLI already
|
||||
resolved through the alias. This covers the authoritative field on its own —
|
||||
hand-edited, or written by anything that follows the shape of the canonical
|
||||
reader (``integration_state`` line 199:
|
||||
``state.get("default_integration") or state.get("integration")``).
|
||||
"""
|
||||
from specify_cli.bundler.lib.project import active_integration
|
||||
|
||||
project = _write_marker(tmp_path, '{"default_integration": "copilot"}')
|
||||
assert active_integration(project) == "copilot"
|
||||
|
||||
|
||||
def test_active_integration_prefers_default_over_legacy_alias(tmp_path: Path):
|
||||
"""When both are present the authoritative field wins, matching
|
||||
``integration_state``'s own ordering."""
|
||||
from specify_cli.bundler.lib.project import active_integration
|
||||
|
||||
project = _write_marker(
|
||||
tmp_path, '{"integration": "stale", "default_integration": "copilot"}'
|
||||
)
|
||||
assert active_integration(project) == "copilot"
|
||||
|
||||
|
||||
def test_active_integration_still_reads_legacy_alias(tmp_path: Path):
|
||||
"""Projects initialised by older versions carry only ``integration``."""
|
||||
from specify_cli.bundler.lib.project import active_integration
|
||||
|
||||
project = _write_marker(tmp_path, '{"integration": "copilot"}')
|
||||
assert active_integration(project) == "copilot"
|
||||
|
||||
|
||||
def test_read_catalog_config_refuses_symlinked_specify_escape(tmp_path: Path):
|
||||
from specify_cli.bundler.commands_impl import catalog_config as cc
|
||||
|
||||
|
||||
@@ -565,6 +565,49 @@ def test_executable_env_var_devin_integration(monkeypatch):
|
||||
assert args[0] == "/opt/devin"
|
||||
|
||||
|
||||
def test_goose_integration_honours_extra_args(monkeypatch):
|
||||
"""Goose gained ``build_exec_args()`` (the Goose item in #2416), so it must
|
||||
honour the shared extra-args hook like every other dispatching integration."""
|
||||
from specify_cli.integrations.goose import GooseIntegration
|
||||
|
||||
monkeypatch.setenv("SPECKIT_INTEGRATION_GOOSE_EXTRA_ARGS", "--debug")
|
||||
args = GooseIntegration().build_exec_args("hi", output_json=False)
|
||||
assert args == ["goose", "run", "--debug", "-t", "hi"]
|
||||
|
||||
|
||||
def test_goose_extra_args_precede_canonical_flags(monkeypatch):
|
||||
"""Extra args are applied before Spec Kit's canonical flags, matching the
|
||||
opencode / codex / cursor-agent ordering.
|
||||
|
||||
Ordering parity only. This deliberately does not assert that a duplicated
|
||||
canonical flag gets overridden: ``goose run`` is clap-derive based, and its
|
||||
``--recipe`` / ``--model`` / ``--output-format`` are single-value args with
|
||||
no ``args_override_self``, so duplicating one makes goose exit with "cannot
|
||||
be used multiple times" regardless of which side wins the ordering.
|
||||
"""
|
||||
from specify_cli.integrations.goose import GooseIntegration
|
||||
|
||||
monkeypatch.setenv("SPECKIT_INTEGRATION_GOOSE_EXTRA_ARGS", "--debug")
|
||||
args = GooseIntegration().build_exec_args("/speckit.specify", model="gpt-4o")
|
||||
assert args[:3] == ["goose", "run", "--debug"]
|
||||
assert args.index("--debug") < args.index("--model")
|
||||
assert args.index("--debug") < args.index("--output-format")
|
||||
assert args.index("--debug") < args.index("--recipe")
|
||||
# Spec Kit itself must never emit a duplicate single-value flag.
|
||||
for flag in ("--recipe", "--model", "--output-format"):
|
||||
assert args.count(flag) == 1
|
||||
|
||||
|
||||
def test_executable_env_var_goose_integration(monkeypatch):
|
||||
"""GooseIntegration honours the executable env var."""
|
||||
from specify_cli.integrations.goose import GooseIntegration
|
||||
|
||||
monkeypatch.setenv("SPECKIT_INTEGRATION_GOOSE_EXECUTABLE", "/opt/goose")
|
||||
args = GooseIntegration().build_exec_args("hi")
|
||||
assert args[0] == "/opt/goose"
|
||||
assert args[1] == "run"
|
||||
|
||||
|
||||
def test_executable_env_var_opencode_integration(monkeypatch):
|
||||
"""OpencodeIntegration honours the executable env var."""
|
||||
from specify_cli.integrations.opencode import OpencodeIntegration
|
||||
|
||||
@@ -471,6 +471,101 @@ class TestAlquimiaArgumentHints:
|
||||
hint_count = sum(1 for ln in lines if ln.startswith("argument-hint:"))
|
||||
assert hint_count == 1
|
||||
|
||||
def test_inject_argument_hint_survives_folded_description(self):
|
||||
"""A long description folded across lines must not corrupt the YAML (#4044).
|
||||
|
||||
A description long enough for the YAML dumper to fold it into a
|
||||
multi-line plain scalar previously had ``argument-hint:`` spliced
|
||||
into the *middle* of that scalar, producing invalid YAML.
|
||||
"""
|
||||
from specify_cli.integrations.alquimia import AlquimiaAIIntegration
|
||||
|
||||
frontmatter = {
|
||||
"name": "speckit-specify",
|
||||
"description": (
|
||||
"Create or update the feature specification from a natural "
|
||||
"language feature description. Also accepts an issue URL "
|
||||
"resolved via gh CLI (demo customization)."
|
||||
),
|
||||
"compatibility": "Requires spec-kit project structure with .specify/ directory",
|
||||
}
|
||||
frontmatter_text = yaml.safe_dump(
|
||||
frontmatter, sort_keys=False, allow_unicode=True
|
||||
).strip()
|
||||
content = f"---\n{frontmatter_text}\n---\n\nBody text\n"
|
||||
assert "\n " in content, "fixture description must actually fold across lines"
|
||||
|
||||
result = AlquimiaAIIntegration.inject_argument_hint(
|
||||
content, "Describe the feature"
|
||||
)
|
||||
|
||||
parsed = yaml.safe_load(result.split("---")[1])
|
||||
assert parsed["argument-hint"] == "Describe the feature"
|
||||
assert parsed["description"] == frontmatter["description"]
|
||||
|
||||
def test_inject_argument_hint_survives_quoted_folded_description(self):
|
||||
"""A folded description forced into quotes must not absorb the hint (#4044)."""
|
||||
from specify_cli.integrations.alquimia import AlquimiaAIIntegration
|
||||
|
||||
frontmatter = {
|
||||
"name": "speckit-specify",
|
||||
"description": (
|
||||
"Create or update the feature specification from a natural "
|
||||
"language feature description. Also accepts a GitHub "
|
||||
"issue/PR URL or #N reference resolved via gh CLI (demo)."
|
||||
),
|
||||
"compatibility": "Requires spec-kit project structure with .specify/ directory",
|
||||
}
|
||||
frontmatter_text = yaml.safe_dump(
|
||||
frontmatter, sort_keys=False, allow_unicode=True
|
||||
).strip()
|
||||
content = f"---\n{frontmatter_text}\n---\n\nBody text\n"
|
||||
assert "\n " in content, "fixture description must actually fold across lines"
|
||||
|
||||
result = AlquimiaAIIntegration.inject_argument_hint(
|
||||
content, "Describe the feature"
|
||||
)
|
||||
|
||||
parsed = yaml.safe_load(result.split("---")[1])
|
||||
assert parsed["argument-hint"] == "Describe the feature"
|
||||
assert parsed["description"] == frontmatter["description"]
|
||||
|
||||
def test_inject_argument_hint_survives_multi_paragraph_description(self):
|
||||
"""A description with an embedded blank line must not absorb the hint.
|
||||
|
||||
PyYAML serializes an embedded ``\\n\\n`` inside a quoted scalar as
|
||||
unindented blank lines, not indented ones, so a fix that only skips
|
||||
indented continuation lines still fails on this case.
|
||||
"""
|
||||
from specify_cli.integrations.alquimia import AlquimiaAIIntegration
|
||||
|
||||
frontmatter = {
|
||||
"name": "speckit-specify",
|
||||
"description": (
|
||||
"First paragraph of a fairly long description that will "
|
||||
"need to wrap across multiple lines when dumped by PyYAML."
|
||||
"\n\n"
|
||||
"Second paragraph continues the description after a blank "
|
||||
"line separator to force embedded newlines in the scalar."
|
||||
),
|
||||
"compatibility": "Requires spec-kit project structure with .specify/ directory",
|
||||
}
|
||||
frontmatter_text = yaml.safe_dump(
|
||||
frontmatter, sort_keys=False, allow_unicode=True
|
||||
).strip()
|
||||
content = f"---\n{frontmatter_text}\n---\n\nBody text\n"
|
||||
assert "\n\n" in frontmatter_text, (
|
||||
"fixture must produce a blank continuation line"
|
||||
)
|
||||
|
||||
result = AlquimiaAIIntegration.inject_argument_hint(
|
||||
content, "Describe the feature"
|
||||
)
|
||||
|
||||
parsed = yaml.safe_load(result.split("---")[1])
|
||||
assert parsed["argument-hint"] == "Describe the feature"
|
||||
assert parsed["description"] == frontmatter["description"]
|
||||
|
||||
|
||||
class TestAlquimiaDisableModelInvocation:
|
||||
"""Verify disable-model-invocation is false for Alquimia skills."""
|
||||
|
||||
@@ -451,6 +451,93 @@ class TestClaudeArgumentHints:
|
||||
hint_count = sum(1 for ln in lines if ln.startswith("argument-hint:"))
|
||||
assert hint_count == 1
|
||||
|
||||
def test_inject_argument_hint_survives_folded_description(self):
|
||||
"""A long description folded across lines must not corrupt the YAML (#4044).
|
||||
|
||||
A description long enough for the YAML dumper to fold it into a
|
||||
multi-line plain scalar previously had ``argument-hint:`` spliced
|
||||
into the *middle* of that scalar, producing invalid YAML.
|
||||
"""
|
||||
from specify_cli.integrations.claude import ClaudeIntegration
|
||||
|
||||
frontmatter = {
|
||||
"name": "speckit-specify",
|
||||
"description": (
|
||||
"Create or update the feature specification from a natural "
|
||||
"language feature description. Also accepts an issue URL "
|
||||
"resolved via gh CLI (demo customization)."
|
||||
),
|
||||
"compatibility": "Requires spec-kit project structure with .specify/ directory",
|
||||
}
|
||||
frontmatter_text = yaml.safe_dump(
|
||||
frontmatter, sort_keys=False, allow_unicode=True
|
||||
).strip()
|
||||
content = f"---\n{frontmatter_text}\n---\n\nBody text\n"
|
||||
assert "\n " in content, "fixture description must actually fold across lines"
|
||||
|
||||
result = ClaudeIntegration.inject_argument_hint(content, "Describe the feature")
|
||||
|
||||
parsed = yaml.safe_load(result.split("---")[1])
|
||||
assert parsed["argument-hint"] == "Describe the feature"
|
||||
assert parsed["description"] == frontmatter["description"]
|
||||
|
||||
def test_inject_argument_hint_survives_quoted_folded_description(self):
|
||||
"""A folded description forced into quotes must not absorb the hint (#4044)."""
|
||||
from specify_cli.integrations.claude import ClaudeIntegration
|
||||
|
||||
frontmatter = {
|
||||
"name": "speckit-specify",
|
||||
"description": (
|
||||
"Create or update the feature specification from a natural "
|
||||
"language feature description. Also accepts a GitHub "
|
||||
"issue/PR URL or #N reference resolved via gh CLI (demo)."
|
||||
),
|
||||
"compatibility": "Requires spec-kit project structure with .specify/ directory",
|
||||
}
|
||||
frontmatter_text = yaml.safe_dump(
|
||||
frontmatter, sort_keys=False, allow_unicode=True
|
||||
).strip()
|
||||
content = f"---\n{frontmatter_text}\n---\n\nBody text\n"
|
||||
assert "\n " in content, "fixture description must actually fold across lines"
|
||||
|
||||
result = ClaudeIntegration.inject_argument_hint(content, "Describe the feature")
|
||||
|
||||
parsed = yaml.safe_load(result.split("---")[1])
|
||||
assert parsed["argument-hint"] == "Describe the feature"
|
||||
assert parsed["description"] == frontmatter["description"]
|
||||
|
||||
def test_inject_argument_hint_survives_multi_paragraph_description(self):
|
||||
"""A description with an embedded blank line must not absorb the hint.
|
||||
|
||||
PyYAML serializes an embedded ``\\n\\n`` inside a quoted scalar as
|
||||
unindented blank lines, not indented ones, so a fix that only skips
|
||||
indented continuation lines still fails on this case.
|
||||
"""
|
||||
from specify_cli.integrations.claude import ClaudeIntegration
|
||||
|
||||
frontmatter = {
|
||||
"name": "speckit-specify",
|
||||
"description": (
|
||||
"First paragraph of a fairly long description that will "
|
||||
"need to wrap across multiple lines when dumped by PyYAML."
|
||||
"\n\n"
|
||||
"Second paragraph continues the description after a blank "
|
||||
"line separator to force embedded newlines in the scalar."
|
||||
),
|
||||
"compatibility": "Requires spec-kit project structure with .specify/ directory",
|
||||
}
|
||||
frontmatter_text = yaml.safe_dump(
|
||||
frontmatter, sort_keys=False, allow_unicode=True
|
||||
).strip()
|
||||
content = f"---\n{frontmatter_text}\n---\n\nBody text\n"
|
||||
assert "\n\n" in frontmatter_text, "fixture must produce a blank continuation line"
|
||||
|
||||
result = ClaudeIntegration.inject_argument_hint(content, "Describe the feature")
|
||||
|
||||
parsed = yaml.safe_load(result.split("---")[1])
|
||||
assert parsed["argument-hint"] == "Describe the feature"
|
||||
assert parsed["description"] == frontmatter["description"]
|
||||
|
||||
|
||||
class TestClaudeDisableModelInvocation:
|
||||
"""Verify disable-model-invocation is false for Claude skills."""
|
||||
|
||||
@@ -83,3 +83,101 @@ class TestGooseCommandPlaceholderResolution:
|
||||
assert "{SCRIPT}" not in prompt
|
||||
assert "__AGENT__" not in prompt
|
||||
assert "$ARGUMENTS" not in prompt
|
||||
|
||||
|
||||
class TestGooseCliDispatch:
|
||||
"""`goose` must produce argv for non-interactive dispatch.
|
||||
|
||||
`YamlIntegration` never overrode `build_exec_args()`, so Goose inherited the
|
||||
`IntegrationBase` no-op returning `None`. Callers read `None` as "CLI
|
||||
unavailable", so a workflow command/prompt step targeting Goose reported
|
||||
"CLI not found or not installed" even with `goose` on PATH — the Goose item
|
||||
in issue #2416. `goose run` supports `-t/--text`, `--recipe`,
|
||||
`--params KEY=VALUE`, `--model` and `--output-format`.
|
||||
"""
|
||||
|
||||
def test_build_exec_args_is_not_none(self):
|
||||
integration = get_integration("goose")
|
||||
assert integration.build_exec_args("/speckit.specify") is not None
|
||||
|
||||
def test_slash_command_maps_to_recipe(self):
|
||||
integration = get_integration("goose")
|
||||
args = integration.build_exec_args("/speckit.specify", output_json=False)
|
||||
assert args[1] == "run"
|
||||
assert "--recipe" in args
|
||||
assert args[args.index("--recipe") + 1] == ".goose/recipes/speckit.specify.yaml"
|
||||
# No trailing args -> no --params
|
||||
assert "--params" not in args
|
||||
|
||||
def test_slash_command_arguments_map_to_params(self):
|
||||
integration = get_integration("goose")
|
||||
args = integration.build_exec_args("/speckit.specify add auth", output_json=False)
|
||||
assert args[args.index("--params") + 1] == "args=add auth"
|
||||
|
||||
def test_dotted_extension_command_maps_to_recipe(self):
|
||||
integration = get_integration("goose")
|
||||
args = integration.build_exec_args("/speckit.git.commit msg", output_json=False)
|
||||
assert args[args.index("--recipe") + 1] == (
|
||||
".goose/recipes/speckit.git.commit.yaml"
|
||||
)
|
||||
|
||||
def test_free_form_prompt_uses_text_flag(self):
|
||||
"""goose has no `-p`; free-form text goes to `-t/--text`."""
|
||||
integration = get_integration("goose")
|
||||
args = integration.build_exec_args("just do it", output_json=False)
|
||||
assert args[-2:] == ["-t", "just do it"]
|
||||
assert "--recipe" not in args
|
||||
|
||||
def test_non_speckit_slash_prompt_is_not_treated_as_a_recipe(self):
|
||||
"""`/help` is a goose session command, not a Spec Kit recipe.
|
||||
|
||||
`PromptStep` passes arbitrary `prompt:` strings to `build_exec_args`,
|
||||
and the recipe branch synthesizes a *file path*, so slash text outside
|
||||
the `speckit.` namespace must not become
|
||||
`--recipe .goose/recipes/speckit.help.yaml` — `setup()` only ever
|
||||
writes `command_filename(stem)` = `speckit.<name>.yaml`.
|
||||
"""
|
||||
integration = get_integration("goose")
|
||||
args = integration.build_exec_args("/help", output_json=False)
|
||||
assert "--recipe" not in args
|
||||
assert "--params" not in args
|
||||
assert args[-2:] == ["-t", "/help"]
|
||||
|
||||
def test_non_speckit_slash_prompt_is_not_promoted_to_a_recipe(self):
|
||||
"""`/plan` is goose's own command and must not run speckit.plan.
|
||||
|
||||
`command_filename()` re-adds the `speckit.` prefix, so the old
|
||||
unconditional call silently promoted the free-form goose command
|
||||
`/plan` into a real Spec Kit recipe run. Dispatch always spells
|
||||
commands `/speckit.plan` (`IntegrationBase.build_command_invocation`),
|
||||
so no reachable recipe is lost.
|
||||
"""
|
||||
integration = get_integration("goose")
|
||||
args = integration.build_exec_args("/plan the sprint", output_json=False)
|
||||
assert "--recipe" not in args
|
||||
assert args[-2:] == ["-t", "/plan the sprint"]
|
||||
|
||||
def test_bare_speckit_prefix_falls_through_to_text(self):
|
||||
"""`/speckit.` alone has no stem and must not yield `speckit..yaml`."""
|
||||
integration = get_integration("goose")
|
||||
args = integration.build_exec_args("/speckit.", output_json=False)
|
||||
assert "--recipe" not in args
|
||||
assert args[-2:] == ["-t", "/speckit."]
|
||||
|
||||
def test_model_and_output_format_flags(self):
|
||||
integration = get_integration("goose")
|
||||
args = integration.build_exec_args("hi", model="gpt-4o", output_json=True)
|
||||
assert args[args.index("--model") + 1] == "gpt-4o"
|
||||
assert args[args.index("--output-format") + 1] == "json"
|
||||
|
||||
def test_recipe_target_matches_what_setup_writes(self, tmp_path):
|
||||
"""Anti-drift: the dispatched `--recipe` path must be the file `setup()`
|
||||
actually installed, so the two cannot diverge."""
|
||||
integration = get_integration("goose")
|
||||
manifest = IntegrationManifest("goose", tmp_path)
|
||||
created = integration.setup(tmp_path, manifest, script_type="sh")
|
||||
assert created
|
||||
|
||||
args = integration.build_exec_args("/speckit.specify hello")
|
||||
recipe = args[args.index("--recipe") + 1]
|
||||
assert (tmp_path / recipe).is_file(), f"{recipe} was not installed by setup()"
|
||||
|
||||
@@ -1,10 +1,229 @@
|
||||
"""Tests for JunieIntegration."""
|
||||
|
||||
import os
|
||||
import pytest
|
||||
|
||||
from specify_cli.integrations import get_integration
|
||||
from specify_cli.integrations.junie import format_junie_command_name
|
||||
from .test_integration_base_markdown import MarkdownIntegrationTests
|
||||
|
||||
class TestJunieCommandNameFormatter:
|
||||
"""Test the junie command name formatter."""
|
||||
|
||||
def test_simple_name_without_prefix(self):
|
||||
"""Test formatting a simple name without 'speckit.' prefix."""
|
||||
assert format_junie_command_name("plan") == "speckit-plan"
|
||||
assert format_junie_command_name("tasks") == "speckit-tasks"
|
||||
assert format_junie_command_name("specify") == "speckit-specify"
|
||||
|
||||
def test_name_with_speckit_prefix(self):
|
||||
"""Test formatting a name that already has 'speckit.' prefix."""
|
||||
assert format_junie_command_name("speckit.plan") == "speckit-plan"
|
||||
assert format_junie_command_name("speckit.tasks") == "speckit-tasks"
|
||||
|
||||
def test_extension_command_name(self):
|
||||
"""Test formatting extension command names with dots."""
|
||||
assert (
|
||||
format_junie_command_name("speckit.my-extension.example")
|
||||
== "speckit-my-extension-example"
|
||||
)
|
||||
assert (
|
||||
format_junie_command_name("my-extension.example")
|
||||
== "speckit-my-extension-example"
|
||||
)
|
||||
|
||||
def test_idempotent_already_hyphenated(self):
|
||||
"""Test that already-hyphenated names are returned unchanged (idempotent)."""
|
||||
assert format_junie_command_name("speckit-plan") == "speckit-plan"
|
||||
assert (
|
||||
format_junie_command_name("speckit-my-extension-example")
|
||||
== "speckit-my-extension-example"
|
||||
)
|
||||
|
||||
|
||||
|
||||
|
||||
class TestJunieIntegration(MarkdownIntegrationTests):
|
||||
KEY = "junie"
|
||||
FOLDER = ".junie/"
|
||||
COMMANDS_SUBDIR = "commands"
|
||||
REGISTRAR_DIR = ".junie/commands"
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"cmd_name, expected_filename",
|
||||
[
|
||||
("plan", "speckit-plan.md"),
|
||||
("speckit.plan", "speckit-plan.md"),
|
||||
("speckit.git.commit", "speckit-git-commit.md"),
|
||||
("speckit", "speckit-speckit.md"),
|
||||
("speckitfoo", "speckit-speckitfoo.md"),
|
||||
],
|
||||
)
|
||||
|
||||
def test_junie_command_filename(self, cmd_name, expected_filename):
|
||||
"""Verify junie uses hyphenated filenames."""
|
||||
junie = get_integration("junie")
|
||||
assert junie.command_filename(cmd_name) == expected_filename
|
||||
|
||||
def test_junie_invoke_separator(self):
|
||||
"""Verify junie uses hyphen as invoke separator."""
|
||||
junie = get_integration("junie")
|
||||
assert junie.invoke_separator == "-"
|
||||
assert junie.registrar_config["invoke_separator"] == "-"
|
||||
|
||||
def test_junie_name_injection_and_formatting(self):
|
||||
"""Verify junie has inject_name and format_name configured."""
|
||||
junie = get_integration("junie")
|
||||
assert junie.registrar_config["inject_name"] is True
|
||||
assert junie.registrar_config[
|
||||
"format_name"] == format_junie_command_name
|
||||
|
||||
def test_junie_handoff_rewrite(self):
|
||||
"""Verify junie rewrites agent: speckit.foo to agent: speckit-foo."""
|
||||
junie = get_integration("junie")
|
||||
content = "---\nagent: speckit.plan\n---\n"
|
||||
rewritten = junie._rewrite_handoff_references(content)
|
||||
assert rewritten == "---\nagent: speckit-plan\n---\n"
|
||||
|
||||
def test_junie_hook_instruction_injection(self):
|
||||
"""Verify junie injects the dot-to-hyphen note for hooks."""
|
||||
junie = get_integration("junie")
|
||||
content = "- For each executable hook, output the following:\n"
|
||||
injected = junie._inject_hook_command_note(content)
|
||||
assert "replace dots (`.`) with hyphens (`-`)" in injected
|
||||
assert "- For each executable hook, output the following:" in injected
|
||||
|
||||
def test_junie_hook_instruction_injection_no_trailing_newline(self):
|
||||
"""Note must not collapse onto the instruction line when the
|
||||
instruction is the final line with no trailing newline.
|
||||
|
||||
The injection regex matches the end-of-line via ``(\\r\\n|\\n|$)``, so
|
||||
the captured ``eol`` is empty on a file's last line that lacks a
|
||||
trailing newline. Without an ``or "\\n"`` fallback the note text and
|
||||
the instruction are emitted on the same line.
|
||||
"""
|
||||
junie = get_integration("junie")
|
||||
content = "- For each executable hook, output the following:" # no trailing \n
|
||||
injected = junie._inject_hook_command_note(content)
|
||||
assert "replace dots (`.`) with hyphens (`-`)" in injected
|
||||
# Instruction stays on its own line rather than being mashed onto the note.
|
||||
assert "\n- For each executable hook, output the following:" in injected
|
||||
|
||||
# -- Overrides for MarkdownIntegrationTests ---------------------------
|
||||
|
||||
def test_setup_creates_files(self, tmp_path):
|
||||
from specify_cli.integrations.manifest import IntegrationManifest
|
||||
|
||||
i = get_integration(self.KEY)
|
||||
m = IntegrationManifest(self.KEY, tmp_path)
|
||||
created = i.setup(tmp_path, m)
|
||||
assert len(created) > 0
|
||||
cmd_files = [
|
||||
f
|
||||
for f in created
|
||||
if "scripts" not in f.parts
|
||||
and f.suffix == ".md"
|
||||
]
|
||||
for f in cmd_files:
|
||||
assert f.exists()
|
||||
assert f.name.startswith("speckit-")
|
||||
assert f.name.endswith(".md")
|
||||
|
||||
specify_file = next(
|
||||
(f for f in cmd_files if f.name == "speckit-specify.md"), None
|
||||
)
|
||||
assert specify_file is not None
|
||||
specify_contents = specify_file.read_text(encoding="utf-8")
|
||||
assert "/speckit-plan" in specify_contents
|
||||
assert "/speckit.plan" not in specify_contents
|
||||
|
||||
def test_integration_flag_creates_files(self, tmp_path):
|
||||
from typer.testing import CliRunner
|
||||
from specify_cli import app
|
||||
|
||||
project = tmp_path / f"int-{self.KEY}"
|
||||
project.mkdir()
|
||||
old_cwd = os.getcwd()
|
||||
try:
|
||||
os.chdir(project)
|
||||
runner = CliRunner()
|
||||
result = runner.invoke(
|
||||
app,
|
||||
[
|
||||
"init",
|
||||
"--here",
|
||||
"--integration",
|
||||
self.KEY,
|
||||
"--script",
|
||||
"sh",
|
||||
"--ignore-agent-tools",
|
||||
],
|
||||
catch_exceptions=False,
|
||||
)
|
||||
finally:
|
||||
os.chdir(old_cwd)
|
||||
assert result.exit_code == 0
|
||||
i = get_integration(self.KEY)
|
||||
cmd_dir = i.commands_dest(project)
|
||||
assert cmd_dir.is_dir()
|
||||
commands = sorted(cmd_dir.glob("speckit-*"))
|
||||
assert len(commands) > 0
|
||||
|
||||
def _expected_files(self, script_variant: str) -> list[str]:
|
||||
"""Override to expect hyphenated speckit- prefix."""
|
||||
i = get_integration(self.KEY)
|
||||
cmd_dir = i.registrar_config["dir"]
|
||||
files = []
|
||||
|
||||
# Command files
|
||||
for stem in (
|
||||
self.COMMANDS_SUBDIR_STEMS
|
||||
if hasattr(self, "COMMANDS_SUBDIR_STEMS")
|
||||
else self.COMMAND_STEMS
|
||||
):
|
||||
files.append(f"{cmd_dir}/speckit-{stem.replace('.', '-')}.md")
|
||||
|
||||
# Framework files
|
||||
files.append(".specify/integration.json")
|
||||
files.append(".specify/init-options.json")
|
||||
files.append(f".specify/integrations/{self.KEY}.manifest.json")
|
||||
files.append(".specify/integrations/speckit.manifest.json")
|
||||
files.append(".specify/.gitignore")
|
||||
|
||||
if script_variant == "sh":
|
||||
for name in [
|
||||
"check-prerequisites.sh",
|
||||
"common.sh",
|
||||
"create-new-feature.sh",
|
||||
"resolve-template.sh",
|
||||
"setup-plan.sh",
|
||||
"setup-tasks.sh",
|
||||
]:
|
||||
files.append(f".specify/scripts/bash/{name}")
|
||||
else:
|
||||
for name in [
|
||||
"check-prerequisites.ps1",
|
||||
"common.ps1",
|
||||
"create-new-feature.ps1",
|
||||
"resolve-template.ps1",
|
||||
"setup-plan.ps1",
|
||||
"setup-tasks.ps1",
|
||||
]:
|
||||
files.append(f".specify/scripts/powershell/{name}")
|
||||
|
||||
for name in [
|
||||
"checklist-template.md",
|
||||
"constitution-template.md",
|
||||
"plan-template.md",
|
||||
"spec-template.md",
|
||||
"tasks-template.md",
|
||||
]:
|
||||
files.append(f".specify/templates/{name}")
|
||||
|
||||
files.append(".specify/memory/.constitution-template.json")
|
||||
files.append(".specify/memory/constitution.md")
|
||||
# Bundled workflow
|
||||
files.append(".specify/workflows/speckit/workflow.yml")
|
||||
files.append(".specify/workflows/workflow-registry.json")
|
||||
|
||||
return sorted(files)
|
||||
|
||||
@@ -1,12 +1,29 @@
|
||||
"""Tests for VibeIntegration."""
|
||||
|
||||
from unittest.mock import MagicMock
|
||||
|
||||
import yaml
|
||||
|
||||
from specify_cli.events import install_integration_events, remove_integration_events
|
||||
from specify_cli.integrations import get_integration
|
||||
from specify_cli.integrations.base import IntegrationBase
|
||||
from specify_cli.integrations.manifest import IntegrationManifest
|
||||
|
||||
from .test_integration_base_skills import SkillsIntegrationTests
|
||||
|
||||
try:
|
||||
import tomllib
|
||||
except ModuleNotFoundError: # pragma: no cover - Python < 3.11
|
||||
import tomli as tomllib # type: ignore
|
||||
|
||||
|
||||
def _vibe_manifest() -> MagicMock:
|
||||
manifest = MagicMock(spec=IntegrationManifest)
|
||||
manifest.files = {}
|
||||
manifest.record_file = MagicMock()
|
||||
manifest.record_existing = MagicMock()
|
||||
return manifest
|
||||
|
||||
|
||||
class TestVibeIntegration(SkillsIntegrationTests):
|
||||
KEY = "vibe"
|
||||
@@ -14,6 +31,274 @@ class TestVibeIntegration(SkillsIntegrationTests):
|
||||
COMMANDS_SUBDIR = "skills"
|
||||
REGISTRAR_DIR = ".vibe/skills"
|
||||
|
||||
def test_is_base_integration(self):
|
||||
assert isinstance(get_integration("vibe"), IntegrationBase)
|
||||
|
||||
def test_multi_install_safe(self):
|
||||
integration = get_integration("vibe")
|
||||
assert integration.multi_install_safe is True
|
||||
|
||||
def test_canonical_to_native_events(self):
|
||||
"""Vibe supports exactly three hook types: pre_tool, post_tool, post_agent."""
|
||||
integration = get_integration("vibe")
|
||||
assert integration.CANONICAL_TO_NATIVE == {
|
||||
"pre_tool_use": "pre_tool",
|
||||
"post_tool_use": "post_tool",
|
||||
"stop": "post_agent",
|
||||
}
|
||||
|
||||
def test_events_config(self):
|
||||
integration = get_integration("vibe")
|
||||
assert integration.events_config_file == ".vibe/hooks.toml"
|
||||
assert integration.events_format == "toml-vibe"
|
||||
|
||||
def test_setup_creates_skill_files(self, tmp_path):
|
||||
integration = get_integration("vibe")
|
||||
manifest = IntegrationManifest("vibe", tmp_path)
|
||||
created = integration.setup(tmp_path, manifest, script_type="sh")
|
||||
|
||||
skill_files = [path for path in created if path.name == "SKILL.md"]
|
||||
assert skill_files
|
||||
|
||||
skills_dir = tmp_path / ".vibe" / "skills"
|
||||
assert skills_dir.is_dir()
|
||||
|
||||
plan_skill = skills_dir / "speckit-plan" / "SKILL.md"
|
||||
assert plan_skill.exists()
|
||||
|
||||
content = plan_skill.read_text(encoding="utf-8")
|
||||
assert "{SCRIPT}" not in content
|
||||
assert "{ARGS}" not in content
|
||||
assert "__AGENT__" not in content
|
||||
assert "__SPECKIT_COMMAND_" not in content, "unprocessed __SPECKIT_COMMAND_*__"
|
||||
assert "/speckit." not in content, "skills agent must use /speckit-<name> not /speckit.<name>"
|
||||
|
||||
parts = content.split("---", 2)
|
||||
parsed = yaml.safe_load(parts[1])
|
||||
assert parsed["name"] == "speckit-plan"
|
||||
assert parsed["user-invocable"] is True
|
||||
assert parsed["disable-model-invocation"] is False
|
||||
assert parsed["metadata"]["source"] == "templates/commands/plan.md"
|
||||
|
||||
def test_render_skill_unicode(self):
|
||||
"""Test rendering a skill preserves non-ASCII characters."""
|
||||
integration = get_integration("vibe")
|
||||
rendered = integration._render_skill(
|
||||
"constitution",
|
||||
{"description": "Prüfe Konformität der Implementierung"},
|
||||
"Body",
|
||||
)
|
||||
assert "Prüfe Konformität" in rendered
|
||||
|
||||
def test_setup_does_not_write_context_section(self, tmp_path):
|
||||
"""The CLI no longer manages the agent context file — that is owned by
|
||||
the opt-in agent-context extension. Setup must not create or touch it."""
|
||||
integration = get_integration("vibe")
|
||||
manifest = IntegrationManifest("vibe", tmp_path)
|
||||
integration.setup(tmp_path, manifest, script_type="sh")
|
||||
|
||||
for path in tmp_path.rglob("*"):
|
||||
if path.is_file():
|
||||
text = path.read_text(encoding="utf-8", errors="ignore")
|
||||
assert "<!-- SPECKIT START -->" not in text
|
||||
|
||||
def test_teardown_does_not_touch_existing_context_file(self, tmp_path):
|
||||
"""A user-authored context file is left intact on teardown."""
|
||||
integration = get_integration("vibe")
|
||||
ctx_path = tmp_path / "AGENTS.md"
|
||||
original = "# AGENTS.md\n\nUser content.\n"
|
||||
ctx_path.write_text(original, encoding="utf-8")
|
||||
|
||||
manifest = IntegrationManifest("vibe", tmp_path)
|
||||
integration.setup(tmp_path, manifest, script_type="sh")
|
||||
integration.teardown(tmp_path, manifest)
|
||||
|
||||
assert ctx_path.read_text(encoding="utf-8") == original
|
||||
|
||||
def test_skills_do_not_have_argument_hint(self, tmp_path):
|
||||
"""Vibe does not support argument-hint in skill frontmatter, so it must not be injected."""
|
||||
integration = get_integration("vibe")
|
||||
manifest = IntegrationManifest("vibe", tmp_path)
|
||||
created = integration.setup(tmp_path, manifest, script_type="sh")
|
||||
skill_files = [f for f in created if f.name == "SKILL.md"]
|
||||
assert skill_files
|
||||
for f in skill_files:
|
||||
content = f.read_text(encoding="utf-8")
|
||||
assert "argument-hint:" not in content, (
|
||||
f"{f.parent.name}/SKILL.md unexpectedly has argument-hint frontmatter"
|
||||
)
|
||||
|
||||
|
||||
class TestVibeTomlMerging:
|
||||
"""Behavioral tests for the toml-vibe hooks.toml generation and cleanup."""
|
||||
|
||||
def _install(self, tmp_path, events):
|
||||
integration = get_integration("vibe")
|
||||
manifest = _vibe_manifest()
|
||||
install_integration_events(integration, tmp_path, manifest, events)
|
||||
return integration, manifest
|
||||
|
||||
def _parse(self, tmp_path):
|
||||
return tomllib.loads((tmp_path / ".vibe" / "hooks.toml").read_text(encoding="utf-8"))
|
||||
|
||||
def test_generated_toml_is_valid_and_schema_conformant(self, tmp_path):
|
||||
self._install(tmp_path, {
|
||||
"pre_tool_use": [{"command": "speckit.tdd.validate", "matcher": "Edit|Write"}],
|
||||
"stop": [{"command": "speckit.session.finish"}],
|
||||
})
|
||||
data = self._parse(tmp_path)
|
||||
hooks = data["hooks"]
|
||||
assert len(hooks) == 2
|
||||
by_type = {h["type"]: h for h in hooks}
|
||||
assert set(by_type) == {"pre_tool", "post_agent"}
|
||||
for h in hooks:
|
||||
assert h["name"].startswith("speckit-")
|
||||
assert isinstance(h["command"], str) and h["command"]
|
||||
assert isinstance(h["timeout"], int)
|
||||
# Canonical Claude-style regex matcher lands in Vibe's `match`
|
||||
# field with the `re:` escape — never in a `matcher` field.
|
||||
assert by_type["pre_tool"]["match"] == "re:Edit|Write"
|
||||
assert "matcher" not in by_type["pre_tool"]
|
||||
# HookConfig rejects `match` on post_agent hooks.
|
||||
assert "match" not in by_type["post_agent"]
|
||||
|
||||
def test_wildcard_matcher_omitted(self, tmp_path):
|
||||
self._install(tmp_path, {
|
||||
"pre_tool_use": [{"command": "speckit.tdd.validate", "matcher": "*"}],
|
||||
})
|
||||
(hook,) = self._parse(tmp_path)["hooks"]
|
||||
assert "match" not in hook
|
||||
|
||||
def test_unsupported_events_are_skipped(self, tmp_path, capsys):
|
||||
self._install(tmp_path, {
|
||||
"session_start": [{"command": "speckit.agent-context.update"}],
|
||||
"pre_tool_use": [{"command": "speckit.tdd.validate"}],
|
||||
})
|
||||
hooks = self._parse(tmp_path)["hooks"]
|
||||
assert [h["type"] for h in hooks] == ["pre_tool"]
|
||||
assert "does not support 'session_start'" in capsys.readouterr().err
|
||||
|
||||
def test_multiple_handlers_get_unique_names(self, tmp_path):
|
||||
"""Vibe drops duplicate hook names, so shared command stems must not collide."""
|
||||
self._install(tmp_path, {
|
||||
"pre_tool_use": [
|
||||
{"command": "speckit.tdd.validate"},
|
||||
{"command": "speckit.other.validate"},
|
||||
],
|
||||
})
|
||||
hooks = self._parse(tmp_path)["hooks"]
|
||||
assert len(hooks) == 2
|
||||
names = [h["name"] for h in hooks]
|
||||
assert len(set(names)) == 2
|
||||
commands = " ".join(h["command"] for h in hooks)
|
||||
assert "speckit.tdd.validate" in commands
|
||||
assert "speckit.other.validate" in commands
|
||||
|
||||
def test_reinstall_is_idempotent(self, tmp_path):
|
||||
events = {
|
||||
"pre_tool_use": [{"command": "speckit.tdd.validate", "matcher": "Bash"}],
|
||||
"stop": [{"command": "speckit.session.finish"}],
|
||||
}
|
||||
self._install(tmp_path, events)
|
||||
first = self._parse(tmp_path)["hooks"]
|
||||
self._install(tmp_path, events)
|
||||
second = self._parse(tmp_path)["hooks"]
|
||||
assert second == first
|
||||
|
||||
def test_merge_and_teardown_preserve_user_hooks(self, tmp_path):
|
||||
config_path = tmp_path / ".vibe" / "hooks.toml"
|
||||
config_path.parent.mkdir(parents=True)
|
||||
user_block = (
|
||||
'[[hooks]]\n'
|
||||
'name = "deny-rm-rf"\n'
|
||||
'type = "pre_tool"\n'
|
||||
'match = "bash"\n'
|
||||
'command = "guard-bash"\n'
|
||||
)
|
||||
config_path.write_text(user_block, encoding="utf-8")
|
||||
|
||||
integration, manifest = self._install(tmp_path, {
|
||||
"pre_tool_use": [{"command": "speckit.tdd.validate"}],
|
||||
})
|
||||
merged = self._parse(tmp_path)["hooks"]
|
||||
assert len(merged) == 2
|
||||
assert any(h["name"] == "deny-rm-rf" for h in merged)
|
||||
|
||||
remove_integration_events(integration, tmp_path, manifest)
|
||||
remaining = self._parse(tmp_path)["hooks"]
|
||||
assert [h["name"] for h in remaining] == ["deny-rm-rf"]
|
||||
|
||||
def test_commands_carry_structured_output_envelope(self, tmp_path):
|
||||
"""Vibe parses non-empty hook stdout as JSON (HookStructuredResponse);
|
||||
plain text is reported as a hook failure. Every generated hook command
|
||||
must therefore pass the hook_specific_output envelope to the dispatcher."""
|
||||
self._install(tmp_path, {
|
||||
"pre_tool_use": [{"command": "speckit.tdd.validate"}],
|
||||
"stop": [{"command": "speckit.session.finish"}],
|
||||
})
|
||||
for hook in self._parse(tmp_path)["hooks"]:
|
||||
assert hook["command"].endswith(" hook_specific_output"), hook["name"]
|
||||
|
||||
def test_windows_host_uses_cmd_quoting(self, tmp_path, monkeypatch):
|
||||
"""Vibe runs hooks via create_subprocess_shell — cmd.exe on Windows,
|
||||
where POSIX single quotes don't quote. A host interpreter path with
|
||||
spaces must be double-quoted, never shlex-quoted."""
|
||||
import specify_cli.events as events_mod
|
||||
|
||||
monkeypatch.setattr(events_mod, "_vibe_target_os", lambda: "cmd")
|
||||
monkeypatch.setattr(
|
||||
events_mod, "_resolve_interpreter",
|
||||
lambda root: r"C:\Program Files\Python\python.exe",
|
||||
)
|
||||
self._install(tmp_path, {"pre_tool_use": [{"command": "speckit.tdd.validate"}]})
|
||||
(hook,) = self._parse(tmp_path)["hooks"]
|
||||
assert hook["command"].startswith('"C:\\Program Files\\Python\\python.exe" ')
|
||||
assert "'" not in hook["command"]
|
||||
|
||||
def test_posix_host_keeps_shlex_quoting(self, tmp_path, monkeypatch):
|
||||
import specify_cli.events as events_mod
|
||||
|
||||
# Pin the target: on a Windows CI runner _vibe_target_os() would
|
||||
# return "cmd" and this test asserts the POSIX-host quoting path.
|
||||
monkeypatch.setattr(events_mod, "_vibe_target_os", lambda: "host")
|
||||
monkeypatch.setattr(
|
||||
events_mod, "_resolve_interpreter",
|
||||
lambda root: "/opt/my venv/bin/python3",
|
||||
)
|
||||
self._install(tmp_path, {"pre_tool_use": [{"command": "speckit.tdd.validate"}]})
|
||||
(hook,) = self._parse(tmp_path)["hooks"]
|
||||
assert hook["command"].startswith("'/opt/my venv/bin/python3' ")
|
||||
|
||||
def test_envelope_resolution(self):
|
||||
from specify_cli.events import _context_envelope_for
|
||||
integration = get_integration("vibe")
|
||||
for event in ("pre_tool_use", "post_tool_use", "stop"):
|
||||
assert _context_envelope_for(integration, event) == "hook_specific_output"
|
||||
|
||||
def test_emit_wraps_stdout_as_structured_response(self, capsys):
|
||||
import json
|
||||
|
||||
from specify_cli.events import _emit_event_stdout
|
||||
|
||||
_emit_event_stdout("context line", "hook_specific_output")
|
||||
data = json.loads(capsys.readouterr().out)
|
||||
assert data == {
|
||||
"decision": "allow",
|
||||
"hook_specific_output": {"additional_context": "context line"},
|
||||
}
|
||||
|
||||
# Empty stdout stays empty — Vibe treats it as "no response".
|
||||
_emit_event_stdout("", "hook_specific_output")
|
||||
assert capsys.readouterr().out == ""
|
||||
|
||||
def test_teardown_deletes_file_without_user_content(self, tmp_path):
|
||||
integration, manifest = self._install(tmp_path, {
|
||||
"pre_tool_use": [{"command": "speckit.tdd.validate"}],
|
||||
})
|
||||
assert (tmp_path / ".vibe" / "hooks.toml").is_file()
|
||||
remove_integration_events(integration, tmp_path, manifest)
|
||||
assert not (tmp_path / ".vibe" / "hooks.toml").exists()
|
||||
|
||||
|
||||
class TestVibeUserInvocable:
|
||||
def test_all_skills_have_user_invocable(self, tmp_path):
|
||||
@@ -35,3 +320,17 @@ class TestVibeUserInvocable:
|
||||
assert parsed.get("user-invocable") is True, (
|
||||
f"{f.parent.name}/SKILL.md is missing user-invocable: true in frontmatter"
|
||||
)
|
||||
|
||||
def test_all_skills_have_disable_model_invocation(self, tmp_path):
|
||||
i = get_integration("vibe")
|
||||
m = IntegrationManifest("vibe", tmp_path)
|
||||
created = i.setup(tmp_path, m, script_type="sh")
|
||||
skill_files = [f for f in created if f.name == "SKILL.md"]
|
||||
assert skill_files
|
||||
for f in skill_files:
|
||||
content = f.read_text(encoding="utf-8")
|
||||
parts = content.split("---", 2)
|
||||
parsed = yaml.safe_load(parts[1])
|
||||
assert parsed.get("disable-model-invocation") is False, (
|
||||
f"{f.parent.name}/SKILL.md is missing disable-model-invocation: false in frontmatter"
|
||||
)
|
||||
|
||||
@@ -302,6 +302,20 @@ class TestLoadAuthConfig:
|
||||
with pytest.raises(ValueError, match="invalid host pattern"):
|
||||
load_auth_config(cfg)
|
||||
|
||||
@pytest.mark.parametrize("host", ["gith?b.com", "[a-z].example.com"])
|
||||
def test_unsupported_glob_metacharacters_raise(self, tmp_path, host):
|
||||
cfg = tmp_path / "auth.json"
|
||||
cfg.write_text(json.dumps({
|
||||
"providers": [{
|
||||
"hosts": [host],
|
||||
"provider": "github",
|
||||
"auth": "bearer",
|
||||
"token_env": "X",
|
||||
}]
|
||||
}))
|
||||
with pytest.raises(ValueError, match="invalid host pattern"):
|
||||
load_auth_config(cfg)
|
||||
|
||||
def test_valid_star_dot_host_accepted(self, tmp_path):
|
||||
cfg = tmp_path / "auth.json"
|
||||
cfg.write_text(json.dumps({
|
||||
@@ -344,6 +358,39 @@ class TestFindEntriesForUrl:
|
||||
result = find_entries_for_url("https://myorg.visualstudio.com/project", [entry])
|
||||
assert result == [entry]
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"url",
|
||||
[
|
||||
"https://visualstudio.com/project",
|
||||
"https://evilvisualstudio.com/project",
|
||||
"https://visualstudio.com.evil.example/project",
|
||||
],
|
||||
)
|
||||
def test_wildcard_does_not_match_apex_or_lookalikes(self, url):
|
||||
entry = AuthConfigEntry(
|
||||
hosts=("*.visualstudio.com",),
|
||||
provider="azure-devops",
|
||||
auth="basic-pat",
|
||||
token_env="ADO_PAT",
|
||||
)
|
||||
assert find_entries_for_url(url, [entry]) == []
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("pattern", "url"),
|
||||
[
|
||||
("gith?b.com", "https://github.com/org/repo"),
|
||||
("[a-z].example.com", "https://a.example.com/file"),
|
||||
],
|
||||
)
|
||||
def test_exact_hosts_do_not_apply_glob_semantics(self, pattern, url):
|
||||
entry = AuthConfigEntry(
|
||||
hosts=(pattern,),
|
||||
provider="github",
|
||||
auth="bearer",
|
||||
token="sentinel",
|
||||
)
|
||||
assert find_entries_for_url(url, [entry]) == []
|
||||
|
||||
def test_no_match_returns_empty(self):
|
||||
entry = _github_entry()
|
||||
result = find_entries_for_url("https://evil.example.com/file", [entry])
|
||||
@@ -1049,6 +1096,39 @@ class TestRedirectStripping:
|
||||
assert new_req.headers.get("Authorization") is None
|
||||
assert new_req.unredirected_hdrs.get("Authorization") is None
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("hosts", "target", "expected_auth"),
|
||||
[
|
||||
(("*.example.com",), "https://api.example.com/asset", "Bearer tok"),
|
||||
(("*.example.com",), "https://example.com/asset", None),
|
||||
(("*.example.com",), "https://evil-example.com/asset", None),
|
||||
(("gith?b.com",), "https://github.com/asset", None),
|
||||
(("[a-z].example.com",), "https://a.example.com/asset", None),
|
||||
],
|
||||
)
|
||||
def test_redirect_host_patterns_use_literal_safe_matching(
|
||||
self, hosts, target, expected_auth
|
||||
):
|
||||
from specify_cli.authentication.http import _StripAuthOnRedirect
|
||||
from urllib.request import Request
|
||||
import io
|
||||
|
||||
handler = _StripAuthOnRedirect(hosts)
|
||||
req = Request(
|
||||
"https://source.example.org/file",
|
||||
headers={"Authorization": "Bearer tok"},
|
||||
)
|
||||
new_req = handler.redirect_request(
|
||||
req, io.BytesIO(b""), 302, "Found", {}, target
|
||||
)
|
||||
|
||||
assert new_req is not None
|
||||
auth = (
|
||||
new_req.get_header("Authorization")
|
||||
or new_req.unredirected_hdrs.get("Authorization")
|
||||
)
|
||||
assert auth == expected_auth
|
||||
|
||||
def test_https_to_http_same_host_redirect_rejected(self):
|
||||
from specify_cli.authentication.http import _StripAuthOnRedirect
|
||||
from urllib.request import Request
|
||||
|
||||
@@ -564,3 +564,38 @@ class TestGetInvokeSeparatorTolerance:
|
||||
"integration_settings": {"droid": {"invoke_separator": "-"}},
|
||||
})
|
||||
assert common.get_invoke_separator(self._repo(tmp_path, body)) == "-"
|
||||
|
||||
|
||||
@pytest.mark.skipif(
|
||||
not (HAS_PWSH or _WINDOWS_POWERSHELL), reason="no PowerShell available"
|
||||
)
|
||||
def test_powershell_text_output_lists_available_docs(prereq_repo: Path) -> None:
|
||||
"""Text mode must print a status line per document, like the twins.
|
||||
|
||||
`Test-FileExists` / `Test-DirHasFiles` report their line with `Write-Output`
|
||||
and ALSO `return $true/$false`, both on the Success stream. The callers piped
|
||||
the whole call to `| Out-Null` to discard the boolean, which discarded the
|
||||
report line too — so `AVAILABLE_DOCS:` was emitted with nothing under it
|
||||
while the bash and Python twins list every document.
|
||||
"""
|
||||
feat = prereq_repo / "specs" / "001-my-feature"
|
||||
feat.mkdir(parents=True)
|
||||
(feat / "plan.md").write_text("# plan\n", encoding="utf-8")
|
||||
(feat / "research.md").write_text("# research\n", encoding="utf-8")
|
||||
_write_feature_json(prereq_repo)
|
||||
|
||||
ps = _run(_ps_cmd(prereq_repo, "-IncludeTasks"), prereq_repo)
|
||||
|
||||
assert ps.returncode == 0, ps.stderr
|
||||
assert "AVAILABLE_DOCS:" in ps.stdout
|
||||
for doc in (
|
||||
"research.md",
|
||||
"data-model.md",
|
||||
"contracts/",
|
||||
"quickstart.md",
|
||||
"tasks.md",
|
||||
):
|
||||
assert doc in ps.stdout, (doc, ps.stdout)
|
||||
# The existing file reports [OK], the missing ones [FAIL].
|
||||
assert "[OK] research.md" in _normalize_status_text(ps.stdout), ps.stdout
|
||||
assert "[FAIL] quickstart.md" in _normalize_status_text(ps.stdout), ps.stdout
|
||||
|
||||
@@ -1,9 +1,12 @@
|
||||
"""Regression guard: console symbols must remain importable from specify_cli."""
|
||||
import logging
|
||||
|
||||
from specify_cli import (
|
||||
console,
|
||||
StepTracker,
|
||||
select_with_arrows,
|
||||
)
|
||||
from specify_cli._console import logger as console_logger
|
||||
|
||||
|
||||
def test_console_symbols_importable():
|
||||
@@ -39,3 +42,21 @@ def test_select_with_arrows_raises_on_empty_options():
|
||||
import pytest
|
||||
with pytest.raises(ValueError, match="at least one option"):
|
||||
select_with_arrows({})
|
||||
|
||||
|
||||
def test_step_tracker_refresh_error_is_logged(caplog):
|
||||
"""Regression: _maybe_refresh must log exceptions instead of silently swallowing."""
|
||||
tracker = StepTracker("test")
|
||||
|
||||
def failing_refresh():
|
||||
raise RuntimeError("simulated refresh failure")
|
||||
|
||||
tracker.attach_refresh(failing_refresh)
|
||||
tracker.add("step1", "Step One")
|
||||
|
||||
with caplog.at_level(logging.DEBUG, logger=console_logger.name):
|
||||
tracker.complete("step1", "done")
|
||||
|
||||
assert "Progress tracker refresh failed" in caplog.text
|
||||
assert "RuntimeError: simulated refresh failure" in caplog.text
|
||||
assert tracker.steps[0]["status"] == "done"
|
||||
|
||||
@@ -12,6 +12,49 @@ WORKFLOWS_DIR = REPO_ROOT / ".github" / "workflows"
|
||||
# inline shorthand (` - uses: x@sha`) used in catalog-assign.yml.
|
||||
USES_RE = re.compile(r"^\s*(?:-\s*)?uses:\s*(?P<ref>\S+)", re.MULTILINE)
|
||||
PINNED_SHA_RE = re.compile(r"@[0-9a-f]{40}$", re.IGNORECASE)
|
||||
COMMUNITY_SUBMISSION_WORKFLOWS = (
|
||||
(
|
||||
"bundle",
|
||||
"bundle-submission",
|
||||
"bundles/catalog.community.json",
|
||||
"docs/community/bundles.md",
|
||||
"Modify only `bundles/catalog.community.json`",
|
||||
),
|
||||
(
|
||||
"extension",
|
||||
"extension-submission",
|
||||
"extensions/catalog.community.json",
|
||||
"docs/community/extensions.md",
|
||||
"Do not modify any other files",
|
||||
),
|
||||
(
|
||||
"preset",
|
||||
"preset-submission",
|
||||
"presets/catalog.community.json",
|
||||
"docs/community/presets.md",
|
||||
"Do not modify any other files",
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
def _create_pull_request_allowed_files(source_text: str) -> list[str]:
|
||||
create_pr_match = re.search(
|
||||
r"(?m)^ create-pull-request:\n(?P<body>(?:^ [^\n]*\n?)+)",
|
||||
source_text,
|
||||
)
|
||||
assert create_pr_match is not None
|
||||
|
||||
allowed_files_match = re.search(
|
||||
r"(?m)^ allowed-files:\n(?P<files>(?:^ - [^\n]+\n?)+)",
|
||||
create_pr_match.group("body"),
|
||||
)
|
||||
assert allowed_files_match is not None
|
||||
|
||||
return [
|
||||
line.strip().removeprefix("- ")
|
||||
for line in allowed_files_match.group("files").splitlines()
|
||||
if line.strip()
|
||||
]
|
||||
|
||||
|
||||
def test_github_actions_are_pinned_to_full_commit_shas():
|
||||
@@ -41,22 +84,54 @@ def test_pinned_action_ref_accepts_uppercase_hex_sha():
|
||||
)
|
||||
|
||||
|
||||
def test_community_bundle_submission_automation_is_wired():
|
||||
source = WORKFLOWS_DIR / "add-community-bundle.md"
|
||||
compiled = WORKFLOWS_DIR / "add-community-bundle.lock.yml"
|
||||
def test_community_submission_automation_is_wired_to_allowed_files():
|
||||
assignment = WORKFLOWS_DIR / "catalog-assign.yml"
|
||||
|
||||
assert source.is_file()
|
||||
assert compiled.is_file()
|
||||
source_text = source.read_text(encoding="utf-8")
|
||||
assignment_text = assignment.read_text(encoding="utf-8")
|
||||
|
||||
assert "names: [bundle-submission]" in source_text
|
||||
assert "bundles/catalog.community.json" in source_text
|
||||
assert "docs/community/bundles.md" in source_text
|
||||
assert "verified: false" in source_text
|
||||
assert "allowed-files:" in source_text
|
||||
assert "bundle-submission" in assignment_text
|
||||
for workflow, label, catalog_file, docs_file, instruction in (
|
||||
COMMUNITY_SUBMISSION_WORKFLOWS
|
||||
):
|
||||
source = WORKFLOWS_DIR / f"add-community-{workflow}.md"
|
||||
compiled = WORKFLOWS_DIR / f"add-community-{workflow}.lock.yml"
|
||||
|
||||
assert source.is_file()
|
||||
assert compiled.is_file()
|
||||
source_text = source.read_text(encoding="utf-8")
|
||||
compiled_text = compiled.read_text(encoding="utf-8")
|
||||
|
||||
assert f"names: [{label}]" in source_text
|
||||
assert catalog_file in source_text
|
||||
assert docs_file in source_text
|
||||
assert instruction in source_text
|
||||
assert _create_pull_request_allowed_files(source_text) == [
|
||||
catalog_file,
|
||||
docs_file,
|
||||
]
|
||||
assert f'"allowed_files":["{catalog_file}","{docs_file}"]' in compiled_text
|
||||
assert label in assignment_text
|
||||
|
||||
|
||||
def test_community_submission_allowed_files_do_not_include_other_catalogs_or_docs():
|
||||
allowed_by_workflow = {
|
||||
workflow: set(
|
||||
_create_pull_request_allowed_files(
|
||||
(WORKFLOWS_DIR / f"add-community-{workflow}.md").read_text(
|
||||
encoding="utf-8"
|
||||
)
|
||||
)
|
||||
)
|
||||
for workflow, *_ in COMMUNITY_SUBMISSION_WORKFLOWS
|
||||
}
|
||||
|
||||
workflow_allowed_files = list(allowed_by_workflow.items())
|
||||
|
||||
for index, (workflow, allowed_files) in enumerate(workflow_allowed_files):
|
||||
for other_workflow, other_allowed_files in workflow_allowed_files[index + 1 :]:
|
||||
overlapping_files = allowed_files & other_allowed_files
|
||||
assert overlapping_files == set(), (
|
||||
f"{workflow} and {other_workflow} share allowed files: "
|
||||
f"{sorted(overlapping_files)}"
|
||||
)
|
||||
|
||||
|
||||
def test_bug_test_workflow_provisions_python_dependencies():
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
import stat
|
||||
|
||||
import pytest
|
||||
|
||||
from specify_cli import merge_json_files
|
||||
from specify_cli import handle_vscode_settings
|
||||
|
||||
@@ -188,3 +190,25 @@ def test_handle_vscode_settings_preserves_mode_on_atomic_write(tmp_path):
|
||||
|
||||
after_mode = stat.S_IMODE(dest_file.stat().st_mode)
|
||||
assert after_mode == before_mode
|
||||
|
||||
|
||||
def test_handle_vscode_settings_propagates_programming_errors(tmp_path):
|
||||
"""Unexpected programming errors (TypeError) must propagate, not be silently swallowed."""
|
||||
vscode_dir = tmp_path / ".vscode"
|
||||
vscode_dir.mkdir()
|
||||
dest_file = vscode_dir / "settings.json"
|
||||
dest_file.write_text('{"a": 1}\n', encoding="utf-8")
|
||||
template_file = tmp_path / "template_settings.json"
|
||||
template_file.write_text('{"b": 2}\n', encoding="utf-8")
|
||||
|
||||
import specify_cli._utils as utils_mod
|
||||
original_merge = utils_mod.merge_json_files
|
||||
utils_mod.merge_json_files = lambda *a, **kw: (_ for _ in ()).throw(TypeError("boom"))
|
||||
try:
|
||||
with pytest.raises(TypeError):
|
||||
handle_vscode_settings(
|
||||
template_file, dest_file, "settings.json",
|
||||
verbose=False, tracker=None,
|
||||
)
|
||||
finally:
|
||||
utils_mod.merge_json_files = original_merge
|
||||
|
||||
+202
-39
@@ -4260,8 +4260,14 @@ class TestSelfTestPreset:
|
||||
assert manifest.id == "invalid-wrap"
|
||||
assert manager.registry.is_installed("invalid-wrap")
|
||||
|
||||
def test_extension_command_skipped_when_extension_missing(self, project_dir, temp_dir):
|
||||
"""Test that extension command overrides are skipped if the extension isn't installed."""
|
||||
def test_selfcontained_namespaced_command_scaffolds_without_extension(self, project_dir, temp_dir):
|
||||
"""A preset shipping a self-contained ``speckit.<ns>.<cmd>`` command
|
||||
scaffolds even when no matching extension is installed.
|
||||
|
||||
The command template ships its own body, so it is self-contained and
|
||||
must render just like a short ``speckit.<cmd>`` command. It is not
|
||||
dropped merely because ``.specify/extensions/fakeext/`` is absent.
|
||||
"""
|
||||
claude_dir = project_dir / ".claude" / "skills"
|
||||
claude_dir.mkdir(parents=True)
|
||||
|
||||
@@ -4297,11 +4303,13 @@ class TestSelfTestPreset:
|
||||
manager = PresetManager(project_dir)
|
||||
manager.install_from_directory(preset_dir, "0.1.5")
|
||||
|
||||
# Extension not installed — command should NOT be registered
|
||||
cmd_file = claude_dir / "speckit.fakeext.cmd.md"
|
||||
assert not cmd_file.exists(), "Command registered for missing extension"
|
||||
# Extension not installed, but the preset ships its own command body —
|
||||
# it must scaffold (as a native-skill SKILL.md for claude) and be
|
||||
# tracked in the preset's registered_commands.
|
||||
skill_file = claude_dir / "speckit-fakeext-cmd" / "SKILL.md"
|
||||
assert skill_file.exists(), "Self-contained namespaced command was dropped"
|
||||
metadata = manager.registry.get("ext-override")
|
||||
assert metadata["registered_commands"] == {}
|
||||
assert metadata["registered_commands"] != {}
|
||||
|
||||
def test_extension_command_registered_when_extension_present(self, project_dir, temp_dir):
|
||||
"""Test that extension command overrides ARE registered when the extension is installed."""
|
||||
@@ -6528,17 +6536,16 @@ class TestPresetSkills:
|
||||
"sanity: the new skills-mode artifact should still be written"
|
||||
)
|
||||
|
||||
def test_rescaffold_skips_extension_commands_when_extension_not_installed(
|
||||
def test_rescaffold_scaffolds_selfcontained_namespaced_commands(
|
||||
self, project_dir, temp_dir
|
||||
):
|
||||
"""Rescaffold must not materialize extension-scoped commands
|
||||
(``speckit.<ext>.<cmd>``) when the extension isn't installed.
|
||||
"""A self-contained ``speckit.<ns>.<cmd>`` preset command scaffolds and
|
||||
survives rescaffold, even when no matching extension is installed.
|
||||
|
||||
``_register_commands`` refuses them, but the rescaffold seeded its
|
||||
final reconciliation pass with every command template name
|
||||
unfiltered, so ``_reconcile_composed_commands`` wrote the command
|
||||
file anyway — an artifact no registry entry tracks (review
|
||||
3623357358).
|
||||
The preset ships the command body itself, so it is materialized just
|
||||
like a short ``speckit.<cmd>`` command — both at install and through a
|
||||
later reconciliation/rescaffold pass. It is not dropped by the
|
||||
``speckit.<ns>.<cmd>`` name shape (#4076).
|
||||
"""
|
||||
self._write_init_options(project_dir, ai="copilot", ai_skills=False)
|
||||
commands_dir = project_dir / ".github" / "agents"
|
||||
@@ -6552,24 +6559,24 @@ class TestPresetSkills:
|
||||
manager.install_from_directory(preset_dir, "0.1.5")
|
||||
|
||||
ext_cmd = commands_dir / "speckit.git.feature.agent.md"
|
||||
assert not ext_cmd.exists(), (
|
||||
"sanity: install must not write an extension command when the "
|
||||
"extension isn't installed"
|
||||
assert ext_cmd.exists(), (
|
||||
"sanity: install must scaffold a self-contained namespaced command "
|
||||
"even when its like-named extension isn't installed"
|
||||
)
|
||||
|
||||
manager.register_enabled_presets_for_agent("copilot")
|
||||
|
||||
assert not ext_cmd.exists(), (
|
||||
"rescaffold must not materialize an extension-scoped command "
|
||||
"whose extension isn't installed"
|
||||
assert ext_cmd.exists(), (
|
||||
"rescaffold must keep the self-contained namespaced command"
|
||||
)
|
||||
metadata = manager.registry.get("ext-scoped-preset")
|
||||
assert not (metadata.get("registered_commands") or {}).get("copilot")
|
||||
assert (metadata.get("registered_commands") or {}).get("copilot")
|
||||
|
||||
def test_rescaffold_skips_extension_skills_when_extension_not_installed(
|
||||
def test_rescaffold_scaffolds_selfcontained_namespaced_skills(
|
||||
self, project_dir, temp_dir
|
||||
):
|
||||
"""Historical tracking must not recreate a missing extension's skill."""
|
||||
"""A self-contained ``speckit.<ns>.<cmd>`` preset command renders its
|
||||
skill even when no matching extension is installed."""
|
||||
self._write_init_options(project_dir, ai="copilot", ai_skills=True)
|
||||
skills_dir = project_dir / ".github" / "skills"
|
||||
skills_dir.mkdir(parents=True)
|
||||
@@ -6586,28 +6593,80 @@ class TestPresetSkills:
|
||||
|
||||
skill_name = "speckit-git-feature"
|
||||
skill_file = skills_dir / skill_name / "SKILL.md"
|
||||
assert not skill_file.exists()
|
||||
|
||||
manager.registry.update(
|
||||
"ext-scoped-skill-preset",
|
||||
{"registered_skills": {"copilot": [skill_name]}},
|
||||
)
|
||||
overrides_dir = (
|
||||
project_dir / ".specify" / "templates" / "overrides"
|
||||
)
|
||||
overrides_dir.mkdir(parents=True)
|
||||
(overrides_dir / "speckit.git.feature.md").write_text(
|
||||
"---\ndescription: Project override\n---\n\nOverride body\n",
|
||||
encoding="utf-8",
|
||||
assert skill_file.exists(), (
|
||||
"install must render a self-contained namespaced command's skill "
|
||||
"even when its like-named extension isn't installed"
|
||||
)
|
||||
|
||||
manager.register_enabled_presets_for_agent("copilot")
|
||||
|
||||
assert not skill_file.exists(), (
|
||||
"rescaffold must not materialize an extension-scoped skill "
|
||||
"whose extension isn't installed"
|
||||
assert skill_file.exists(), (
|
||||
"rescaffold must keep the self-contained namespaced command's skill"
|
||||
)
|
||||
|
||||
def test_uncomposable_wrap_command_skips_skill_in_skills_mode(
|
||||
self, project_dir, temp_dir
|
||||
):
|
||||
"""A wrap command with no base layer must not materialize a broken
|
||||
skill in skills mode.
|
||||
|
||||
When ``_register_commands`` skips an uncomposable wrap command (no
|
||||
base to compose onto — e.g. the command it wraps comes from an
|
||||
uninstalled extension), ``_register_skills`` must skip it too. Before
|
||||
this fix, skills mode fell back to the raw preset body and wrote a
|
||||
SKILL.md containing a literal ``{CORE_TEMPLATE}`` placeholder.
|
||||
"""
|
||||
self._write_init_options(project_dir, ai="copilot", ai_skills=True)
|
||||
skills_dir = project_dir / ".github" / "skills"
|
||||
skills_dir.mkdir(parents=True)
|
||||
|
||||
preset_dir = temp_dir / "uncomposable-wrap"
|
||||
preset_dir.mkdir()
|
||||
(preset_dir / "commands").mkdir()
|
||||
# speckit.git.feature has no core command template and no installed
|
||||
# extension, so there is no base layer to wrap.
|
||||
(preset_dir / "commands" / "speckit.git.feature.md").write_text(
|
||||
"---\ndescription: Wrap\nstrategy: wrap\n---\n\n"
|
||||
"wrap start\n{CORE_TEMPLATE}\nwrap end\n"
|
||||
)
|
||||
manifest_data = {
|
||||
"schema_version": "1.0",
|
||||
"preset": {
|
||||
"id": "uncomposable-wrap",
|
||||
"name": "uncomposable-wrap",
|
||||
"version": "1.0.0",
|
||||
"description": "Test",
|
||||
},
|
||||
"requires": {"speckit_version": ">=0.1.0"},
|
||||
"provides": {
|
||||
"templates": [
|
||||
{
|
||||
"type": "command",
|
||||
"name": "speckit.git.feature",
|
||||
"file": "commands/speckit.git.feature.md",
|
||||
"strategy": "wrap",
|
||||
}
|
||||
]
|
||||
},
|
||||
}
|
||||
with open(preset_dir / "preset.yml", "w") as f:
|
||||
yaml.dump(manifest_data, f)
|
||||
|
||||
manager = PresetManager(project_dir)
|
||||
with pytest.warns(UserWarning, match="no base command layer"):
|
||||
manager.install_from_directory(preset_dir, "0.1.5")
|
||||
|
||||
skill_file = skills_dir / "speckit-git-feature" / "SKILL.md"
|
||||
assert not skill_file.exists(), (
|
||||
"an uncomposable wrap command must not be rendered as a skill"
|
||||
)
|
||||
# Belt-and-suspenders: no artifact anywhere may leak the raw placeholder.
|
||||
leaked = [
|
||||
p for p in skills_dir.rglob("*")
|
||||
if p.is_file() and "{CORE_TEMPLATE}" in p.read_text(encoding="utf-8")
|
||||
]
|
||||
assert not leaked, f"literal {{CORE_TEMPLATE}} leaked into {leaked}"
|
||||
|
||||
def test_same_mode_partial_command_rescaffold_keeps_skipped_tracking(
|
||||
self, project_dir, temp_dir
|
||||
):
|
||||
@@ -10017,6 +10076,43 @@ class TestPresetSkills:
|
||||
"claude's real ownership must be preserved in the migrated tracking"
|
||||
)
|
||||
|
||||
def test_short_and_namespaced_commands_scaffold_consistently(
|
||||
self, project_dir, temp_dir
|
||||
):
|
||||
"""A preset's ``speckit.<cmd>`` and ``speckit.<ns>.<cmd>`` commands must
|
||||
scaffold identically in command mode, with no installed extension.
|
||||
|
||||
Regression: the 3-part (``speckit.<ns>.<cmd>``) form was silently
|
||||
dropped by a name-shape guard whenever ``.specify/extensions/<ns>/``
|
||||
was absent, even though the preset ships the command body itself. The
|
||||
2-part form always scaffolded. Both are self-contained and must behave
|
||||
the same (#4076).
|
||||
"""
|
||||
self._write_init_options(project_dir, ai="gemini", ai_skills=False)
|
||||
gemini_commands_dir = project_dir / ".gemini" / "commands"
|
||||
gemini_commands_dir.mkdir(parents=True)
|
||||
|
||||
short_preset = self._create_command_preset(
|
||||
temp_dir, "short-cmd", "speckit.newcmd", "Short", "short body",
|
||||
)
|
||||
ns_preset = self._create_command_preset(
|
||||
temp_dir, "ns-cmd", "speckit.fakeext.newcmd", "Namespaced", "ns body",
|
||||
)
|
||||
|
||||
manager = PresetManager(project_dir)
|
||||
manager.install_from_directory(short_preset, "0.1.5")
|
||||
manager.install_from_directory(ns_preset, "0.1.5")
|
||||
|
||||
short_file = gemini_commands_dir / "speckit.newcmd.toml"
|
||||
ns_file = gemini_commands_dir / "speckit.fakeext.newcmd.toml"
|
||||
assert short_file.exists(), "2-part command should scaffold"
|
||||
assert ns_file.exists(), (
|
||||
"3-part namespaced command must scaffold too, even without the "
|
||||
"matching extension installed"
|
||||
)
|
||||
assert manager.registry.get("short-cmd")["registered_commands"] != {}
|
||||
assert manager.registry.get("ns-cmd")["registered_commands"] != {}
|
||||
|
||||
|
||||
class TestPresetSetPriority:
|
||||
"""Test preset set-priority CLI command."""
|
||||
@@ -13680,6 +13776,73 @@ class TestInstalledPresetRichMarkup:
|
||||
assert "[base]" in output, output
|
||||
assert "[append]" in output, output
|
||||
|
||||
|
||||
class TestPresetListOrdering:
|
||||
"""``preset list`` must print presets in actual resolution/precedence order.
|
||||
|
||||
Regression coverage for #4086: the printed order was registry/insertion
|
||||
order, so a preset with a *higher* priority number (lower precedence) could
|
||||
appear before one with a lower number, misleading users about which preset
|
||||
wins. Output must be sorted by (priority, id) to match
|
||||
``PresetRegistry.list_by_priority()``.
|
||||
"""
|
||||
|
||||
def _install(self, temp_dir, project_dir, pack_id, priority):
|
||||
from specify_cli.presets import PresetManager
|
||||
|
||||
src = temp_dir / f"src-{pack_id}"
|
||||
(src / "templates").mkdir(parents=True)
|
||||
(src / "templates" / "spec-template.md").write_text("# tmpl\n")
|
||||
(src / "preset.yml").write_text(yaml.dump({
|
||||
"schema_version": "1.0",
|
||||
"preset": {
|
||||
"id": pack_id,
|
||||
"name": pack_id,
|
||||
"version": "1.0.0",
|
||||
"description": "plain description",
|
||||
},
|
||||
"requires": {"speckit_version": ">=0.0.1"},
|
||||
"provides": {"templates": [{
|
||||
"type": "template",
|
||||
"name": "spec-template",
|
||||
"file": "templates/spec-template.md",
|
||||
}]},
|
||||
}))
|
||||
PresetManager(project_dir).install_from_directory(src, "9.9.9", priority)
|
||||
|
||||
def _invoke(self, project_dir, args):
|
||||
from typer.testing import CliRunner
|
||||
from unittest.mock import patch
|
||||
from specify_cli import app
|
||||
|
||||
with patch.object(Path, "cwd", return_value=project_dir):
|
||||
return CliRunner().invoke(app, args)
|
||||
|
||||
def test_list_sorted_by_priority(self, temp_dir, project_dir):
|
||||
"""Lower priority number is listed first regardless of install order."""
|
||||
# Install in an order that does NOT match precedence.
|
||||
self._install(temp_dir, project_dir, "copilot-sub-agents", priority=100)
|
||||
self._install(temp_dir, project_dir, "lean", priority=10)
|
||||
|
||||
result = self._invoke(project_dir, ["preset", "list"])
|
||||
assert result.exit_code == 0, result.output
|
||||
output = strip_ansi(result.output)
|
||||
# `lean` (priority 10) must appear before `copilot-sub-agents` (100).
|
||||
assert output.index("(lean)") < output.index("(copilot-sub-agents)"), output
|
||||
assert "resolution order" in output, output
|
||||
assert "Ties are broken by preset id" in output, output
|
||||
|
||||
def test_list_ties_broken_by_id(self, temp_dir, project_dir):
|
||||
"""Equal priority ties are broken alphabetically by preset id."""
|
||||
self._install(temp_dir, project_dir, "zebra", priority=10)
|
||||
self._install(temp_dir, project_dir, "alpha", priority=10)
|
||||
|
||||
result = self._invoke(project_dir, ["preset", "list"])
|
||||
assert result.exit_code == 0, result.output
|
||||
output = strip_ansi(result.output)
|
||||
assert output.index("(alpha)") < output.index("(zebra)"), output
|
||||
|
||||
|
||||
class TestConstitutionSyncPreset:
|
||||
"""The bundled opt-in ``constitution-sync`` preset re-adds materialization.
|
||||
|
||||
|
||||
@@ -4565,6 +4565,29 @@ steps:
|
||||
errors = validate_workflow(definition)
|
||||
assert any("invalid type" in e.lower() for e in errors)
|
||||
|
||||
@pytest.mark.parametrize("step_type", [["shell"], {"name": "shell"}])
|
||||
def test_non_string_step_type_reports_error(self, step_type):
|
||||
"""Unhashable YAML values must not crash registry membership checks."""
|
||||
from specify_cli.workflows.engine import WorkflowDefinition, validate_workflow
|
||||
|
||||
definition = WorkflowDefinition(
|
||||
{
|
||||
"workflow": {
|
||||
"id": "test",
|
||||
"name": "Test",
|
||||
"version": "1.0.0",
|
||||
},
|
||||
"steps": [{"id": "bad", "type": step_type}],
|
||||
}
|
||||
)
|
||||
|
||||
errors = validate_workflow(definition)
|
||||
|
||||
assert errors == [
|
||||
f"Step 'bad': 'type' must be a string, got "
|
||||
f"{type(step_type).__name__} ({step_type!r})."
|
||||
]
|
||||
|
||||
def test_nested_step_validation(self):
|
||||
from specify_cli.workflows.engine import WorkflowDefinition, validate_workflow
|
||||
|
||||
|
||||
@@ -62,6 +62,32 @@ def test_pinned_integration_with_indeterminate_active_fails():
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.parametrize("blank", ["", " ", "\t"])
|
||||
def test_pinned_integration_with_blank_active_fails(blank):
|
||||
"""A blank active integration is indeterminate, not a match.
|
||||
|
||||
The clash guard is a truthiness test and the indeterminate guard is an
|
||||
`is None` test, so `""` satisfied neither and fell through to
|
||||
`effective_integration = required` — silently adopting the bundle's pinned
|
||||
integration, the exact outcome the docstring says the guard prevents.
|
||||
"""
|
||||
manifest = _manifest(integration={"id": "claude"})
|
||||
with pytest.raises(BundlerError, match="could not be determined"):
|
||||
resolve_install_plan(
|
||||
manifest, speckit_version="0.11.2", active_integration=blank
|
||||
)
|
||||
|
||||
|
||||
def test_padded_active_integration_is_not_a_clash_with_itself():
|
||||
"""A padded value must strip, like the writer's clean_integration_key,
|
||||
rather than be reported as clashing with its own unpadded form."""
|
||||
manifest = _manifest(integration={"id": "claude"})
|
||||
plan = resolve_install_plan(
|
||||
manifest, speckit_version="0.11.2", active_integration=" claude "
|
||||
)
|
||||
assert plan.effective_integration == "claude"
|
||||
|
||||
|
||||
def test_pinned_integration_with_indeterminate_active_allows_explicit_override():
|
||||
manifest = _manifest(integration={"id": "claude"})
|
||||
plan = resolve_install_plan(
|
||||
|
||||
Reference in New Issue
Block a user