Compare commits

..
1 Commits
Author SHA1 Message Date
github-actions[bot] 464d57fe30 chore: bump version to 0.12.8 2026-07-08 14:39:10 +00:00
143 changed files with 865 additions and 14940 deletions
+1
View File
@@ -5,3 +5,4 @@
/extensions/catalog.community.json @mnriem /extensions/catalog.community.json @mnriem
/integrations/catalog.community.json @mnriem /integrations/catalog.community.json @mnriem
/presets/catalog.community.json @mnriem /presets/catalog.community.json @mnriem
+1 -1
View File
@@ -7,7 +7,7 @@ body:
attributes: attributes:
value: | value: |
Thanks for requesting a new agent! Before submitting, please check if the agent is already supported. Thanks for requesting a new agent! Before submitting, please check if the agent is already supported.
**Currently supported agents**: Amp, Antigravity, Auggie CLI, Claude Code, Cline, CodeBuddy, Codex CLI, Cursor, Devin for Terminal, Firebender, Forge, Gemini CLI, GitHub Copilot, Goose, Hermes Agent, IBM Bob, Junie, Kilo Code, Kimi Code, Kiro CLI, Lingma, Mistral Vibe, Oh My Pi, opencode, Pi Coding Agent, Qoder CLI, Qwen Code, RovoDev ACLI, SHAI, Tabnine CLI, Trae, ZCode, Zed **Currently supported agents**: Amp, Antigravity, Auggie CLI, Claude Code, Cline, CodeBuddy, Codex CLI, Cursor, Devin for Terminal, Firebender, Forge, Gemini CLI, GitHub Copilot, Goose, Hermes Agent, IBM Bob, Junie, Kilo Code, Kimi Code, Kiro CLI, Lingma, Mistral Vibe, Oh My Pi, opencode, Pi Coding Agent, Qoder CLI, Qwen Code, RovoDev ACLI, SHAI, Tabnine CLI, Trae, ZCode, Zed
- type: input - type: input
@@ -7,7 +7,7 @@ body:
attributes: attributes:
value: | value: |
Thanks for contributing an extension! This template helps you submit your extension to the community catalog. Thanks for contributing an extension! This template helps you submit your extension to the community catalog.
**Before submitting:** **Before submitting:**
- Review the [Extension Publishing Guide](https://github.com/github/spec-kit/blob/main/extensions/EXTENSION-PUBLISHING-GUIDE.md) - Review the [Extension Publishing Guide](https://github.com/github/spec-kit/blob/main/extensions/EXTENSION-PUBLISHING-GUIDE.md)
- Ensure your extension has a valid `extension.yml` manifest - Ensure your extension has a valid `extension.yml` manifest
@@ -209,9 +209,9 @@ body:
**Tested on:** **Tested on:**
- macOS 14.0 with Spec Kit v0.1.0 - macOS 14.0 with Spec Kit v0.1.0
- Linux Ubuntu 22.04 with Spec Kit v0.1.0 - Linux Ubuntu 22.04 with Spec Kit v0.1.0
**Test project:** [Link or description] **Test project:** [Link or description]
**Test scenarios:** **Test scenarios:**
1. Installed extension 1. Installed extension
2. Configured settings 2. Configured settings
@@ -230,7 +230,7 @@ body:
```bash ```bash
# Install extension # Install extension
specify extension add <extension-name> --from https://github.com/your-org/spec-kit-your-extension/archive/refs/tags/v1.0.0.zip specify extension add <extension-name> --from https://github.com/your-org/spec-kit-your-extension/archive/refs/tags/v1.0.0.zip
# Use a command # Use a command
/speckit.your-extension.command-name arg1 arg2 /speckit.your-extension.command-name arg1 arg2
``` ```
+1 -1
View File
@@ -7,7 +7,7 @@ body:
attributes: attributes:
value: | value: |
Thanks for contributing a preset! This template helps you submit your preset to the community catalog. Thanks for contributing a preset! This template helps you submit your preset to the community catalog.
**Before submitting:** **Before submitting:**
- Review the [Preset Publishing Guide](https://github.com/github/spec-kit/blob/main/presets/PUBLISHING.md) - Review the [Preset Publishing Guide](https://github.com/github/spec-kit/blob/main/presets/PUBLISHING.md)
- Ensure your preset has a valid `preset.yml` manifest - Ensure your preset has a valid `preset.yml` manifest
+1
View File
@@ -19,3 +19,4 @@
- [ ] I **did** use AI assistance (describe below) - [ ] I **did** use AI assistance (describe below)
<!-- If you used AI, briefly describe how (e.g., "Code generated by Copilot", "Consulted ChatGPT for approach"): --> <!-- If you used AI, briefly describe how (e.g., "Code generated by Copilot", "Consulted ChatGPT for approach"): -->
+1 -1
View File
@@ -37,7 +37,7 @@ jobs:
fi fi
- name: Run markdownlint-cli2 - name: Run markdownlint-cli2
uses: DavidAnson/markdownlint-cli2-action@8de2aa07cae85fd17c0b35642db70cf5495f1d25 # v24.0.0 uses: DavidAnson/markdownlint-cli2-action@ded1f9488f68a970bc66ea5619e13e9b52e601cd # v23
with: with:
globs: | globs: |
'**/*.md' '**/*.md'
+2 -2
View File
@@ -32,7 +32,7 @@ jobs:
ref: refs/tags/${{ inputs.tag }} ref: refs/tags/${{ inputs.tag }}
- name: Install uv - name: Install uv
uses: astral-sh/setup-uv@11f9893b081a58869d3b5fccaea48c9e9e46f990 # v8.3.2 uses: astral-sh/setup-uv@fac544c07dec837d0ccb6301d7b5580bf5edae39 # v8.2.0
- name: Set up Python - name: Set up Python
uses: actions/setup-python@ece7cb06caefa5fff74198d8649806c4678c61a1 # v6 uses: actions/setup-python@ece7cb06caefa5fff74198d8649806c4678c61a1 # v6
@@ -74,7 +74,7 @@ jobs:
path: dist/ path: dist/
- name: Install uv - name: Install uv
uses: astral-sh/setup-uv@11f9893b081a58869d3b5fccaea48c9e9e46f990 # v8.3.2 uses: astral-sh/setup-uv@fac544c07dec837d0ccb6301d7b5580bf5edae39 # v8.2.0
- name: Publish to PyPI - name: Publish to PyPI
run: uv publish run: uv publish
+5 -5
View File
@@ -20,24 +20,24 @@ jobs:
days-before-stale: 150 days-before-stale: 150
# Days of inactivity before a stale issue or PR is closed (after being marked stale) # Days of inactivity before a stale issue or PR is closed (after being marked stale)
days-before-close: 30 days-before-close: 30
# Stale issue settings # Stale issue settings
stale-issue-message: 'This issue has been automatically marked as stale because it has not had any activity for 150 days. It will be closed in 30 days if no further activity occurs.' stale-issue-message: 'This issue has been automatically marked as stale because it has not had any activity for 150 days. It will be closed in 30 days if no further activity occurs.'
close-issue-message: 'This issue has been automatically closed due to inactivity (180 days total). If you believe this issue is still relevant, please reopen it or create a new issue.' close-issue-message: 'This issue has been automatically closed due to inactivity (180 days total). If you believe this issue is still relevant, please reopen it or create a new issue.'
stale-issue-label: 'stale' stale-issue-label: 'stale'
# Stale PR settings # Stale PR settings
stale-pr-message: 'This pull request has been automatically marked as stale because it has not had any activity for 150 days. It will be closed in 30 days if no further activity occurs.' stale-pr-message: 'This pull request has been automatically marked as stale because it has not had any activity for 150 days. It will be closed in 30 days if no further activity occurs.'
close-pr-message: 'This pull request has been automatically closed due to inactivity (180 days total). If you believe this PR is still relevant, please reopen it or create a new PR.' close-pr-message: 'This pull request has been automatically closed due to inactivity (180 days total). If you believe this PR is still relevant, please reopen it or create a new PR.'
stale-pr-label: 'stale' stale-pr-label: 'stale'
# Exempt issues and PRs with these labels from being marked as stale # Exempt issues and PRs with these labels from being marked as stale
exempt-issue-labels: 'pinned,security' exempt-issue-labels: 'pinned,security'
exempt-pr-labels: 'pinned,security' exempt-pr-labels: 'pinned,security'
# Only issues or PRs with all of these labels are checked # Only issues or PRs with all of these labels are checked
# Leave empty to check all issues and PRs # Leave empty to check all issues and PRs
any-of-labels: '' any-of-labels: ''
# Operations per run (helps avoid rate limits) # Operations per run (helps avoid rate limits)
operations-per-run: 250 operations-per-run: 250
+2 -2
View File
@@ -16,7 +16,7 @@ jobs:
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
- name: Install uv - name: Install uv
uses: astral-sh/setup-uv@11f9893b081a58869d3b5fccaea48c9e9e46f990 # v8.3.2 uses: astral-sh/setup-uv@fac544c07dec837d0ccb6301d7b5580bf5edae39 # v8.2.0
- name: Set up Python - name: Set up Python
uses: actions/setup-python@ece7cb06caefa5fff74198d8649806c4678c61a1 # v6 uses: actions/setup-python@ece7cb06caefa5fff74198d8649806c4678c61a1 # v6
@@ -37,7 +37,7 @@ jobs:
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
- name: Install uv - name: Install uv
uses: astral-sh/setup-uv@11f9893b081a58869d3b5fccaea48c9e9e46f990 # v8.3.2 uses: astral-sh/setup-uv@fac544c07dec837d0ccb6301d7b5580bf5edae39 # v8.2.0
- name: Set up Python ${{ matrix.python-version }} - name: Set up Python ${{ matrix.python-version }}
uses: actions/setup-python@ece7cb06caefa5fff74198d8649806c4678c61a1 # v6 uses: actions/setup-python@ece7cb06caefa5fff74198d8649806c4678c61a1 # v6
+1 -1
View File
@@ -26,4 +26,4 @@
"ignores": [ "ignores": [
".genreleases/" ".genreleases/"
] ]
} }
-12
View File
@@ -1,12 +0,0 @@
---
repos:
- repo: https://github.com/pre-commit/pre-commit-hooks
rev: v6.0.0
hooks:
- id: check-executables-have-shebangs
- id: check-yaml
exclude: \.lock\.yml$
- id: end-of-file-fixer
exclude: \.lock\.yml$
- id: trailing-whitespace
exclude: \.lock\.yml$
-121
View File
@@ -2,127 +2,6 @@
<!-- insert new changelog below this comment --> <!-- insert new changelog below this comment -->
## [0.12.16] - 2026-07-15
### Changed
- fix(workflows): raise a clear error, not a cryptic crash, on non-string filter args (#3522)
- feat(workflows): expose workflow source directory to steps (#3469)
- fix(workflows): fan-out max_concurrency .inf falls back to sequential, not crash (#3521)
- Update Coding Standards Drift Control extension to v0.4.0 (#3540)
- fix(presets): seed constitution from preset constitution-template (#3272) (#3276)
- docs: add PyPI as second supported install route (#3425) (#3516)
- fix(workflows): fail while/do-while steps on non-list steps instead of crashing (#3519)
- Add PatchWarden Evidence Pack extension to community catalog (#3514)
- feat(extensions): port git extension scripts to Python (#3400)
- chore: release 0.12.15, begin 0.12.16.dev0 development (#3513)
## [0.12.15] - 2026-07-14
### Changed
- Update Autonomous Run Governance preset to v0.1.4 (#3511)
- fix(workflows): raise catalog error, not raw ValueError, on a malformed catalog URL (#3484)
- fix(workflows): evaluate 'in'/'not in' safely on a non-iterable right operand (#3447) (#3468)
- fix: add trailing newline to init-options.json output (#3509)
- feat(workflows): align workflow CLI with extension command surface (#3419)
- fix(extensions): stop env-var config leaking across prefix-colliding extension IDs (#3497)
- fix(integrations): escape control characters in goose recipe YAML renderer (#3384)
- [extension] Update DocGuard — CDD Enforcement extension to v0.32.0 (#3489)
- [extension] Add Multi-Repo Branch Sync extension to community catalog (#3411)
- chore: release 0.12.14, begin 0.12.15.dev0 development (#3506)
## [0.12.14] - 2026-07-13
### Changed
- [extension] Add Spec Kit Memory extension to community catalog (#3455)
- Add Test-First Governance preset to community catalog (#3504)
- Add Autonomous Run Governance preset to community catalog (#3501)
- fix(workflows): validate command step input/options are mappings (#3262)
- fix(presets): resolve() honors manifest-declared file: for installed presets (#3351)
- fix(init): don't block on confirmation for 'init --here' without a TTY (#3236)
- [extension] Add Quality Gates (Enforcement Layer) extension to community catalog (#3431)
- fix(integrations): exit cleanly on unbalanced quote in --integration-options (#3457) (#3466)
- fix(integrations): declare kiro-cli multi-install safe (#3471) (#3485)
- fix(workflows): fail fan-in step on non-list wait_for instead of crashing (#3482)
- chore: release 0.12.13, begin 0.12.14.dev0 development (#3498)
## [0.12.13] - 2026-07-13
### Changed
- fix(workflows): fail switch step on non-mapping cases instead of crashing (#3481)
- Cleanup agent-file-template.md (#2579)
- fix: mark Kiro integration as multi-install safe (#3472)
- fix: rewrite extension-relative subdir paths in generated command bodies (#3444)
- fix(templates): point constitution sync checklist at installed command files (#3418)
- feat(workflows): make shell step timeout configurable (#3327) (#3328)
- docs: clarify that release tags keep the leading v prefix (#3463)
- fix(workflows): don't crash on membership test against a non-iterable (#3448)
- fix(workflows): if-step validate accepts falsy non-list else (#3264)
- chore: release 0.12.12, begin 0.12.13.dev0 development (#3490)
## [0.12.12] - 2026-07-13
### Changed
- fix(extensions): set-priority repairs corrupted boolean priority (#3268)
- fix(presets): set-priority repairs corrupted boolean priority (#3269)
- fix(workflows): engine loop cap ignores bool max_iterations (#3270)
- docs(bundles): document --integration on 'bundle update' (#3271)
- fix(workflows): harden catalog.py against mis-shaped registry & non-string fields (#3375)
- Add Verify Review Ship extension to community catalog (#3450)
- fix(bundle): reject file:// / local download_url — catalog URLs are HTTPS-only (#3344)
- fix(extensions): handle prefix-colliding env vars in _get_env_config (#3350)
- docs: document copilot skills mode (--skills) and markdown deprecation (#3313)
- chore: release 0.12.11, begin 0.12.12.dev0 development (#3460)
## [0.12.11] - 2026-07-10
### Changed
- fix(agent-context): discover nested plan.md in scoped layouts (#3024) (#3301)
- fix(auth): return no matches, not raw ValueError, for a malformed URL (#3437)
- fix(catalogs): raise catalog error, not raw ValueError, on a malformed URL (#3435)
- fix(bundler): raise BundlerError, not raw ValueError, on a malformed catalog URL (#3433)
- chore: add pre-commit config and fix trailing whitespace/end-of-file (#3430)
- Add EARS Requirements Syntax extension to community catalog (#3407)
- Add Spec Kit Figma extension to community catalog (#3408)
- fix(workflows): report validation errors instead of crashing on non-string workflow.yml scalars (#3421)
- fix(templates): remove self-referencing path in plan-template.md note (#3417)
- chore: release 0.12.10, begin 0.12.11.dev0 development (#3453)
## [0.12.10] - 2026-07-10
### Changed
- chore(deps): bump astral-sh/setup-uv from 8.2.0 to 8.3.2 (#3439)
- chore(deps): bump DavidAnson/markdownlint-cli2-action (#3438)
- fix(templates): correct phase numbering in plan.md (#3416)
- fix(git-ext): honor explicit -Number 0 in PowerShell branch creation (#3412)
- docs: add 'spectatui' entry to friends.md (#3362)
- test: pin interpreter probe so py-template render test passes on Windows (#3428)
- feat(workflows): make shell step timeout configurable (#3404)
- fix: find plans in nested spec directories (#3405)
- feat(templates): add py: lines to command templates' scripts frontmatter (#3403)
- chore: release 0.12.9, begin 0.12.10.dev0 development (#3426)
## [0.12.9] - 2026-07-09
### Changed
- fix(integrations): skip Windows Store python3 alias stub in resolve_python_interpreter (#3385)
- fix(integrations): escape control characters in SKILL.md frontmatter (#3399)
- fix(workflows): apply chained expression filters left-to-right (#3339)
- fix(scripts): resolve invoke_separator by parse success, not python3 availability (#3304) (#3320)
- fix(shared-infra): refresh_shared_templates preserves recovered user files (#3378)
- fix(agents): resolve skill placeholders in Goose (yaml) command output (#3374)
- fix(bundler): enforce version pin on bundled preset/extension installs (#3377)
- Update Golden Demo extension to v0.3.0 (#3394)
- test: isolate integration test home (#3144)
- chore: release 0.12.8, begin 0.12.9.dev0 development (#3410)
## [0.12.8] - 2026-07-08 ## [0.12.8] - 2026-07-08
### Changed ### Changed
+1
View File
@@ -19,3 +19,4 @@ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE. SOFTWARE.
+1 -7
View File
@@ -45,18 +45,12 @@ Spec-Driven Development **flips the script** on traditional software development
### 1. Install Specify CLI ### 1. Install Specify CLI
Requires **[uv](https://docs.astral.sh/uv/)** ([install uv](./docs/install/uv.md)). Replace `vX.Y.Z` with the latest release tag from [Releases](https://github.com/github/spec-kit/releases) — keep the leading `v` (for example, `v0.12.11`, not `0.12.11`): Requires **[uv](https://docs.astral.sh/uv/)** ([install uv](./docs/install/uv.md)). Replace `vX.Y.Z` with the latest tag from [Releases](https://github.com/github/spec-kit/releases):
```bash ```bash
uv tool install specify-cli --from git+https://github.com/github/spec-kit.git@vX.Y.Z uv tool install specify-cli --from git+https://github.com/github/spec-kit.git@vX.Y.Z
``` ```
Prefer installing from PyPI? The `specify-cli` package is also published there:
```bash
uv tool install specify-cli
```
See the [Installation Guide](./docs/installation.md) for alternative methods, verification, upgrade, and troubleshooting. See the [Installation Guide](./docs/installation.md) for alternative methods, verification, upgrade, and troubleshooting.
### 2. Initialize a project ### 2. Initialize a project
+1
View File
@@ -6,3 +6,4 @@ obj/
# Temporary files # Temporary files
*.tmp *.tmp
*.log *.log
+2 -9
View File
@@ -51,15 +51,14 @@ The following community-contributed extensions are available in [`catalog.commun
| Confluence Extension | Create a doc in Confluence summarizing the specifications and planning files | `integration` | Read+Write | [spec-kit-confluence](https://github.com/aaronrsun/spec-kit-confluence) | | Confluence Extension | Create a doc in Confluence summarizing the specifications and planning files | `integration` | Read+Write | [spec-kit-confluence](https://github.com/aaronrsun/spec-kit-confluence) |
| Cost Tracker | Track real LLM dollar cost across SDD workflows — per-feature budgets, per-integration comparison, and finance-ready exports | `visibility` | Read+Write | [spec-kit-cost](https://github.com/Quratulain-bilal/spec-kit-cost) | | Cost Tracker | Track real LLM dollar cost across SDD workflows — per-feature budgets, per-integration comparison, and finance-ready exports | `visibility` | Read+Write | [spec-kit-cost](https://github.com/Quratulain-bilal/spec-kit-cost) |
| Data Model Diagram | Generates Mermaid ER diagrams from Spec Kit data models after planning | `docs` | Read+Write | [spec-kit-data-model-diagram](https://github.com/benizzio/spec-kit-data-model-diagram) | | Data Model Diagram | Generates Mermaid ER diagrams from Spec Kit data models after planning | `docs` | Read+Write | [spec-kit-data-model-diagram](https://github.com/benizzio/spec-kit-data-model-diagram) |
| DocGuard — CDD Enforcement | Doc-integrity engine with MCP server, SARIF output, and zero-LLM core. Validates, scores, and traces documentation against code — 24 validators, stable finding codes, spec-kit hooks. Pure Node.js. | `docs` | Read+Write | [spec-kit-docguard](https://github.com/raccioly/docguard) | | DocGuard — CDD Enforcement | The only doc-integrity engine with an MCP server, SARIF output, and a deterministic zero-LLM core. Validates, scores, and traces documentation against code — 24 validators, stable finding codes, GitHub Action with PR annotations, spec-kit hooks. Pure Node.js, one pinned dep. | `docs` | Read+Write | [spec-kit-docguard](https://github.com/raccioly/docguard) |
| EARS Requirements Syntax | Author, lint, and convert requirements using EARS - the five industry-standard sentence patterns for unambiguous, testable requirements | `docs` | Read+Write | [spec-kit-ears](https://github.com/dhruv-15-03/spec-kit-ears) |
| Extensify | Create and validate extensions and extension catalogs | `process` | Read+Write | [extensify](https://github.com/mnriem/spec-kit-extensions/tree/main/extensify) | | Extensify | Create and validate extensions and extension catalogs | `process` | Read+Write | [extensify](https://github.com/mnriem/spec-kit-extensions/tree/main/extensify) |
| Fix Findings | Automated analyze-fix-reanalyze loop that resolves spec findings until clean | `code` | Read+Write | [spec-kit-fix-findings](https://github.com/Quratulain-bilal/spec-kit-fix-findings) | | Fix Findings | Automated analyze-fix-reanalyze loop that resolves spec findings until clean | `code` | Read+Write | [spec-kit-fix-findings](https://github.com/Quratulain-bilal/spec-kit-fix-findings) |
| FixIt Extension | Spec-aware bug fixing — maps bugs to spec artifacts, proposes a plan, applies minimal changes | `code` | Read+Write | [spec-kit-fixit](https://github.com/speckit-community/spec-kit-fixit) | | FixIt Extension | Spec-aware bug fixing — maps bugs to spec artifacts, proposes a plan, applies minimal changes | `code` | Read+Write | [spec-kit-fixit](https://github.com/speckit-community/spec-kit-fixit) |
| Fleet Orchestrator | Orchestrate a full feature lifecycle with human-in-the-loop gates across all SpecKit phases | `process` | Read+Write | [spec-kit-fleet](https://github.com/sharathsatish/spec-kit-fleet) | | Fleet Orchestrator | Orchestrate a full feature lifecycle with human-in-the-loop gates across all SpecKit phases | `process` | Read+Write | [spec-kit-fleet](https://github.com/sharathsatish/spec-kit-fleet) |
| GitHub Issues Integration 1 | Generate spec artifacts from GitHub Issues - import issues, sync updates, and maintain bidirectional traceability | `integration` | Read+Write | [spec-kit-github-issues](https://github.com/Fatima367/spec-kit-github-issues) | | GitHub Issues Integration 1 | Generate spec artifacts from GitHub Issues - import issues, sync updates, and maintain bidirectional traceability | `integration` | Read+Write | [spec-kit-github-issues](https://github.com/Fatima367/spec-kit-github-issues) |
| GitHub Issues Integration 2 | Creates and syncs local specs from an existing GitHub issue | `integration` | Read+Write | [spec-kit-issue](https://github.com/aaronrsun/spec-kit-issue) | | GitHub Issues Integration 2 | Creates and syncs local specs from an existing GitHub issue | `integration` | Read+Write | [spec-kit-issue](https://github.com/aaronrsun/spec-kit-issue) |
| Golden Demo | Deterministic behavioral drift oracle. Extracts acceptance criteria, generates fuzz test vectors (seed=42), compares golden Python implementations against real code in any language. CI/CD gatekeeper with warn/strict modes. | `docs` | Read+Write | [spec-kit-golden-demo](https://github.com/jasstt/spec-kit-golden-demo) | | Golden Demo | Extracts acceptance criteria from specs, builds test vectors, and produces a behavioral drift report — complementary to Architecture Guard and CDD | `docs` | Read+Write | [spec-kit-golden-demo](https://github.com/jasstt/spec-kit-golden-demo) |
| Improve Extension | Audits any codebase as a senior advisor and writes prioritized, self-contained spec prompts under specs/ that the spec-kit lifecycle can process | `process` | Read+Write | [spec-kit-improve](https://github.com/d0whc3r/spec-kit-improve) | | Improve Extension | Audits any codebase as a senior advisor and writes prioritized, self-contained spec prompts under specs/ that the spec-kit lifecycle can process | `process` | Read+Write | [spec-kit-improve](https://github.com/d0whc3r/spec-kit-improve) |
| Intake | Normalize PRD, design, HTML SSOT, and test-case evidence into SDD-ready intake artifacts. | `docs` | Read+Write | [spec-kit-intake](https://github.com/bigsmartben/spec-kit-intake) | | Intake | Normalize PRD, design, HTML SSOT, and test-case evidence into SDD-ready intake artifacts. | `docs` | Read+Write | [spec-kit-intake](https://github.com/bigsmartben/spec-kit-intake) |
| Intelligent Agent Orchestrator | Cross-catalog agent discovery and intelligent prompt-to-command routing | `process` | Read+Write | [spec-kit-orchestrator](https://github.com/pragya247/spec-kit-orchestrator) | | Intelligent Agent Orchestrator | Cross-catalog agent discovery and intelligent prompt-to-command routing | `process` | Read+Write | [spec-kit-orchestrator](https://github.com/pragya247/spec-kit-orchestrator) |
@@ -84,14 +83,12 @@ The following community-contributed extensions are available in [`catalog.commun
| MemoryLint | Evidence-driven instruction drift checker: audits agent memory files for boundary, reality, conflict, and redundancy drift. | `process` | Read+Write | [memorylint](https://github.com/RbBtSn0w/spec-kit-extensions/tree/main/memorylint) | | MemoryLint | Evidence-driven instruction drift checker: audits agent memory files for boundary, reality, conflict, and redundancy drift. | `process` | Read+Write | [memorylint](https://github.com/RbBtSn0w/spec-kit-extensions/tree/main/memorylint) |
| Microsoft 365 Integration | Fetch Teams messages, meeting transcripts, and SharePoint/OneDrive files as local Markdown for spec generation | `integration` | Read+Write | [spec-kit-m365](https://github.com/BenBtg/spec-kit-m365) | | Microsoft 365 Integration | Fetch Teams messages, meeting transcripts, and SharePoint/OneDrive files as local Markdown for spec generation | `integration` | Read+Write | [spec-kit-m365](https://github.com/BenBtg/spec-kit-m365) |
| Multi-Model Review | Cross-model Spec Kit handoffs for spec authoring, implementation routing, and review. | `process` | Read+Write | [multi-model-review](https://github.com/formin/multi-model-review) | | Multi-Model Review | Cross-model Spec Kit handoffs for spec authoring, implementation routing, and review. | `process` | Read+Write | [multi-model-review](https://github.com/formin/multi-model-review) |
| Multi-Repo Branch Sync | Creates the feature branch in affected sub-repositories and git submodules via plan/tasks hooks | `process` | Read+Write | [multi-repo-sync](https://github.com/fyloss/spec-kit-multi-repo-sync) |
| Multi-Sites Spec Kit | Multi-site aware specify command with per-site spec folders, auto-increment, and Drupal support | `process` | Read+Write | [spec-kit-multi-sites](https://github.com/teeyo/spec-kit-multi-sites) | | Multi-Sites Spec Kit | Multi-site aware specify command with per-site spec folders, auto-increment, and Drupal support | `process` | Read+Write | [spec-kit-multi-sites](https://github.com/teeyo/spec-kit-multi-sites) |
| .NET Framework to Modern .NET Migration | Orchestrate end-to-end .NET Framework to modern .NET migration across 7 phases, with SDD lifecycle integration | `process` | Read+Write | [spec-kit-fx-to-net](https://github.com/RogerBestMsft/spec-kit-FxToNet) | | .NET Framework to Modern .NET Migration | Orchestrate end-to-end .NET Framework to modern .NET migration across 7 phases, with SDD lifecycle integration | `process` | Read+Write | [spec-kit-fx-to-net](https://github.com/RogerBestMsft/spec-kit-FxToNet) |
| Onboard | Contextual onboarding and progressive growth for developers new to spec-kit projects. Explains specs, maps dependencies, validates understanding, and guides the next step | `process` | Read+Write | [spec-kit-onboard](https://github.com/dmux/spec-kit-onboard) | | Onboard | Contextual onboarding and progressive growth for developers new to spec-kit projects. Explains specs, maps dependencies, validates understanding, and guides the next step | `process` | Read+Write | [spec-kit-onboard](https://github.com/dmux/spec-kit-onboard) |
| Optimize | Audit and optimize AI governance for context efficiency — token budgets, rule health, interpretability, compression, coherence, and echo detection | `process` | Read+Write | [spec-kit-optimize](https://github.com/sakitA/spec-kit-optimize) | | Optimize | Audit and optimize AI governance for context efficiency — token budgets, rule health, interpretability, compression, coherence, and echo detection | `process` | Read+Write | [spec-kit-optimize](https://github.com/sakitA/spec-kit-optimize) |
| Orchestration Task Context Management | Adds subagent work-unit orchestration to generated Spec Kit task files | `process` | Read+Write | [spec-kit-orchestration-task-context-management](https://github.com/benizzio/spec-kit-orchestration-task-context-management) | | Orchestration Task Context Management | Adds subagent work-unit orchestration to generated Spec Kit task files | `process` | Read+Write | [spec-kit-orchestration-task-context-management](https://github.com/benizzio/spec-kit-orchestration-task-context-management) |
| OWASP LLM Threat Model | OWASP Top 10 for LLM Applications 2025 threat analysis on agent artifacts | `code` | Read-only | [spec-kit-threatmodel](https://github.com/NaviaSamal/spec-kit-threatmodel) | | OWASP LLM Threat Model | OWASP Top 10 for LLM Applications 2025 threat analysis on agent artifacts | `code` | Read-only | [spec-kit-threatmodel](https://github.com/NaviaSamal/spec-kit-threatmodel) |
| PatchWarden Evidence Pack | Map Spec Kit tasks into a guarded PatchWarden Goal and export bounded, traceable evidence for an accepted lineage. | `process` | Read+Write | [spec-kit-patchwarden](https://github.com/jiezeng2004-design/spec-kit-patchwarden) |
| Plan Review Gate | Require spec.md and plan.md to be merged via MR/PR before allowing task generation | `process` | Read-only | [spec-kit-plan-review-gate](https://github.com/luno/spec-kit-plan-review-gate) | | Plan Review Gate | Require spec.md and plan.md to be merged via MR/PR before allowing task generation | `process` | Read-only | [spec-kit-plan-review-gate](https://github.com/luno/spec-kit-plan-review-gate) |
| PR Bridge | Auto-generate pull request descriptions, checklists, and summaries from spec artifacts | `process` | Read-only | [spec-kit-pr-bridge-](https://github.com/Quratulain-bilal/spec-kit-pr-bridge-) | | PR Bridge | Auto-generate pull request descriptions, checklists, and summaries from spec artifacts | `process` | Read-only | [spec-kit-pr-bridge-](https://github.com/Quratulain-bilal/spec-kit-pr-bridge-) |
| Presetify | Create and validate presets and preset catalogs | `process` | Read+Write | [presetify](https://github.com/mnriem/spec-kit-extensions/tree/main/presetify) | | Presetify | Create and validate presets and preset catalogs | `process` | Read+Write | [presetify](https://github.com/mnriem/spec-kit-extensions/tree/main/presetify) |
@@ -100,7 +97,6 @@ The following community-contributed extensions are available in [`catalog.commun
| Project Health Check | Diagnose a Spec Kit project and report health issues across structure, agents, features, scripts, extensions, and git | `visibility` | Read-only | [spec-kit-doctor](https://github.com/KhawarHabibKhan/spec-kit-doctor) | | Project Health Check | Diagnose a Spec Kit project and report health issues across structure, agents, features, scripts, extensions, and git | `visibility` | Read-only | [spec-kit-doctor](https://github.com/KhawarHabibKhan/spec-kit-doctor) |
| Project Status | Show current SDD workflow progress — active feature, artifact status, task completion, workflow phase, and extensions summary | `visibility` | Read-only | [spec-kit-status](https://github.com/KhawarHabibKhan/spec-kit-status) | | Project Status | Show current SDD workflow progress — active feature, artifact status, task completion, workflow phase, and extensions summary | `visibility` | Read-only | [spec-kit-status](https://github.com/KhawarHabibKhan/spec-kit-status) |
| QA Testing Extension | Systematic QA testing with browser-driven or CLI-based validation of acceptance criteria from spec | `code` | Read-only | [spec-kit-qa](https://github.com/arunt14/spec-kit-qa) | | QA Testing Extension | Systematic QA testing with browser-driven or CLI-based validation of acceptance criteria from spec | `code` | Read-only | [spec-kit-qa](https://github.com/arunt14/spec-kit-qa) |
| Quality Gates (Enforcement Layer) | Deterministic quality enforcement for Spec Kit projects at three boundaries — agent hooks, git pre-commit, CI — with one policy file and provable enforcement (attestations, canaries, verified parity). | `process` | Read+Write | [spec-gates](https://github.com/schwichtgit/spec-gates) |
| RAG Azure Builder | Spec Kit extension for onboarding and operating an Azure RAG stack with guided workflows. | `process` | Read+Write | [spec-kit-extension-rag-azure-builder](https://github.com/Sertxito/spec-kit-extension-rag-azure-builder) | | RAG Azure Builder | Spec Kit extension for onboarding and operating an Azure RAG stack with guided workflows. | `process` | Read+Write | [spec-kit-extension-rag-azure-builder](https://github.com/Sertxito/spec-kit-extension-rag-azure-builder) |
| Ralph Loop | Autonomous implementation loop using AI agent CLI | `code` | Read+Write | [spec-kit-ralph](https://github.com/Rubiss-Projects/spec-kit-ralph) | | Ralph Loop | Autonomous implementation loop using AI agent CLI | `code` | Read+Write | [spec-kit-ralph](https://github.com/Rubiss-Projects/spec-kit-ralph) |
| Reconcile Extension | Reconcile implementation drift by surgically updating feature artifacts. | `docs` | Read+Write | [spec-kit-reconcile](https://github.com/stn1slv/spec-kit-reconcile) | | Reconcile Extension | Reconcile implementation drift by surgically updating feature artifacts. | `docs` | Read+Write | [spec-kit-reconcile](https://github.com/stn1slv/spec-kit-reconcile) |
@@ -121,8 +117,6 @@ The following community-contributed extensions are available in [`catalog.commun
| Spec Critique Extension | Dual-lens critical review of spec and plan from product strategy and engineering risk perspectives | `docs` | Read-only | [spec-kit-critique](https://github.com/arunt14/spec-kit-critique) | | Spec Critique Extension | Dual-lens critical review of spec and plan from product strategy and engineering risk perspectives | `docs` | Read-only | [spec-kit-critique](https://github.com/arunt14/spec-kit-critique) |
| Spec Diagram | Auto-generate Mermaid diagrams of SDD workflow state, feature progress, and task dependencies | `visibility` | Read-only | [spec-kit-diagram-](https://github.com/Quratulain-bilal/spec-kit-diagram-) | | Spec Diagram | Auto-generate Mermaid diagrams of SDD workflow state, feature progress, and task dependencies | `visibility` | Read-only | [spec-kit-diagram-](https://github.com/Quratulain-bilal/spec-kit-diagram-) |
| Spec Kit Discovery Extension | Run technical discovery commands for feasibility, technology selection, scenario-specific technical decisions, legacy codebase assessment, implementation understanding, and proof-of-concept validation | `process` | Read+Write | [spec-kit-discovery](https://github.com/bigsmartben/spec-kit-discovery) | | Spec Kit Discovery Extension | Run technical discovery commands for feasibility, technology selection, scenario-specific technical decisions, legacy codebase assessment, implementation understanding, and proof-of-concept validation | `process` | Read+Write | [spec-kit-discovery](https://github.com/bigsmartben/spec-kit-discovery) |
| Spec Kit Figma | Agent-agnostic SpecKit extension that grounds spec, plan & task generation in Figma design context — REST + optional MCP, single/mono/multi-repo, macOS/Linux/Windows. | `integration` | Read+Write | [spec-kit-figma](https://github.com/Fyloss/spec-kit-figma) |
| Spec Kit Memory | Recalls prior specs and decisions from configurable memory tools (e.g. memsearch) before SDLC stages, so planning and specification start from what the project already knows | `docs` | Read+Write | [spec-kit-memory](https://github.com/zaytsevand/spec-kit-memory) |
| Spec Kit Preview | Generate evidence-backed low, mid, or high fidelity previews from Spec Kit artifacts as Markdown or self-contained HTML | `docs` | Read+Write | [spec-kit-preview](https://github.com/bigsmartben/spec-kit-preview) | | Spec Kit Preview | Generate evidence-backed low, mid, or high fidelity previews from Spec Kit artifacts as Markdown or self-contained HTML | `docs` | Read+Write | [spec-kit-preview](https://github.com/bigsmartben/spec-kit-preview) |
| Spec Kit Schedule | Optimal multi-agent task scheduling via CP-SAT — DAG precedence, hallucination-aware caps, file-conflict avoidance, stochastic durations, replanning, and interactive HTML output | `process` | Read+Write | [spec-kit-schedule](https://github.com/jfranc38/spec-kit-schedule) | | Spec Kit Schedule | Optimal multi-agent task scheduling via CP-SAT — DAG precedence, hallucination-aware caps, file-conflict avoidance, stochastic durations, replanning, and interactive HTML output | `process` | Read+Write | [spec-kit-schedule](https://github.com/jfranc38/spec-kit-schedule) |
| Spec Kit TLDR | Render a feature's spec.md / plan.md into a review-oriented TLDR (self-contained HTML dashboard + PR-native Markdown) that surfaces risks for faster PR review. | `visibility` | Read+Write | [speckit-tldr](https://github.com/qurore/speckit-tldr) | | Spec Kit TLDR | Render a feature's spec.md / plan.md into a review-oriented TLDR (self-contained HTML dashboard + PR-native Markdown) that surfaces risks for faster PR review. | `visibility` | Read+Write | [speckit-tldr](https://github.com/qurore/speckit-tldr) |
@@ -152,7 +146,6 @@ The following community-contributed extensions are available in [`catalog.commun
| Token Economy | Token routing, measured savings, and context audit workflows | `process` | Read+Write | [spec-kit-token-economy](https://github.com/formin/spec-kit-token-economy) | | Token Economy | Token routing, measured savings, and context audit workflows | `process` | Read+Write | [spec-kit-token-economy](https://github.com/formin/spec-kit-token-economy) |
| V-Model Extension Pack | Enforces V-Model paired generation of development specs and test specs with full traceability | `docs` | Read+Write | [spec-kit-v-model](https://github.com/leocamello/spec-kit-v-model) | | V-Model Extension Pack | Enforces V-Model paired generation of development specs and test specs with full traceability | `docs` | Read+Write | [spec-kit-v-model](https://github.com/leocamello/spec-kit-v-model) |
| Verify Extension | Post-implementation quality gate that validates implemented code against specification artifacts | `code` | Read-only | [spec-kit-verify](https://github.com/ismaelJimenez/spec-kit-verify) | | Verify Extension | Post-implementation quality gate that validates implemented code against specification artifacts | `code` | Read-only | [spec-kit-verify](https://github.com/ismaelJimenez/spec-kit-verify) |
| Verify Review Ship | Adds post-implementation verify, review, and ship readiness gates to Spec Kit workflows | `process` | Read-only | [spec-kit-verify-review-ship](https://github.com/cadugevaerd/spec-kit-verify-review-ship) |
| Verify Tasks Extension | Detect phantom completions: tasks marked [X] in tasks.md with no real implementation | `code` | Read-only | [spec-kit-verify-tasks](https://github.com/datastone-inc/spec-kit-verify-tasks) | | Verify Tasks Extension | Detect phantom completions: tasks marked [X] in tasks.md with no real implementation | `code` | Read-only | [spec-kit-verify-tasks](https://github.com/datastone-inc/spec-kit-verify-tasks) |
| Version Guard | Verify tech stack versions against live npm registries before planning and implementation | `process` | Read-only | [spec-kit-version-guard](https://github.com/KevinBrown5280/spec-kit-version-guard) | | Version Guard | Verify tech stack versions against live npm registries before planning and implementation | `process` | Read-only | [spec-kit-version-guard](https://github.com/KevinBrown5280/spec-kit-version-guard) |
| What-if Analysis | Preview the downstream impact (complexity, effort, tasks, risks) of requirement changes before committing to them | `visibility` | Read-only | [spec-kit-whatif](https://github.com/DevAbdullah90/spec-kit-whatif) | | What-if Analysis | Preview the downstream impact (complexity, effort, tasks, risks) of requirement changes before committing to them | `visibility` | Read-only | [spec-kit-whatif](https://github.com/DevAbdullah90/spec-kit-whatif) |
-2
View File
@@ -14,5 +14,3 @@ Community projects that extend, visualize, or build on Spec Kit:
- **[SpecKit Companion](https://marketplace.visualstudio.com/items?itemName=alfredoperez.speckit-companion)** — A VS Code extension that brings a visual GUI to Spec Kit. Browse specs in a rich markdown viewer with clickable file references, create specifications with image attachments, comment and refine each step inline (GitHub-style review), track your progress through the SDD workflow with a visual phase stepper, and manage steering documents like constitutions and templates. - **[SpecKit Companion](https://marketplace.visualstudio.com/items?itemName=alfredoperez.speckit-companion)** — A VS Code extension that brings a visual GUI to Spec Kit. Browse specs in a rich markdown viewer with clickable file references, create specifications with image attachments, comment and refine each step inline (GitHub-style review), track your progress through the SDD workflow with a visual phase stepper, and manage steering documents like constitutions and templates.
- **[cc-spec-kit](https://github.com/speckit-community/cc-spec-kit)** — Community-maintained plugin for Claude Code and GitHub Copilot CLI that installs Spec Kit skills via the plugin marketplace. - **[cc-spec-kit](https://github.com/speckit-community/cc-spec-kit)** — Community-maintained plugin for Claude Code and GitHub Copilot CLI that installs Spec Kit skills via the plugin marketplace.
- **[spectatui](https://github.com/tinesoft/spectatui)** — A terminal UI (TUI) dashboard for Spec Kit that lets you track features, manage specifications, integrations, presets, workflows, and extensions, and monitor AI agent workflows. Attach to existing AI sessions or launch new ones from your terminal. Keyboard and mouse support. Light/dark theme support. Customizable and performance-oriented. Requires the `specify` CLI in your PATH.
-2
View File
@@ -11,7 +11,6 @@ The following community-contributed presets customize how Spec Kit behaves — o
| Agent Parity Governance | Adds shared-guidance parity, audit-ready Spec-Kit run evidence, and agent-neutral model-routing guidance across a project's declared AI-agent instruction surfaces so agent guidance does not drift. | 6 templates, 3 commands | — | [spec-kit-preset-agent-parity-governance](https://github.com/hindermath/spec-kit-preset-agent-parity-governance) | | Agent Parity Governance | Adds shared-guidance parity, audit-ready Spec-Kit run evidence, and agent-neutral model-routing guidance across a project's declared AI-agent instruction surfaces so agent guidance does not drift. | 6 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) | | 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, 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 software architecture, 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) |
| Autonomous Run Governance | Adds permission-bounded, evidence-first governance for autonomous Spec Kit delivery, convergence, resume, closeout, and retrospective learning. | 12 templates, 2 commands, 2 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) | | 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) | | 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) | | 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) |
@@ -29,7 +28,6 @@ The following community-contributed presets customize how Spec Kit behaves — o
| SicarioSpec Core | Baseline secure-by-default Spec Kit governance profile. | 5 templates | — | [sicario-spec](https://github.com/dfirs1car1o/sicario-spec) | | 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) | | 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) |
| 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) | | 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) | | VS Code Ask Questions | Enhances the clarify command to use `vscode/askQuestions` for batched interactive questioning. | 1 command | — | [spec-kit-presets](https://github.com/fdcastel/spec-kit-presets) |
| Workflow Preset | Behavior-first specification, design artifacts, and agent-native handoff orchestration — adds requirement-phase behavior drafts, formal BDD/UIF/behavior contracts, optional design artifacts, and scoped implementation handoffs with Core Agent, Vertical Planner Agent, and Worker Agent modes | 22 templates, 8 commands | — | [spec-kit-workflow-preset](https://github.com/bigsmartben/spec-kit-workflow-preset) | | Workflow Preset | Behavior-first specification, design artifacts, and agent-native handoff orchestration — adds requirement-phase behavior drafts, formal BDD/UIF/behavior contracts, optional design artifacts, and scoped implementation handoffs with Core Agent, Vertical Planner Agent, and Worker Agent modes | 22 templates, 8 commands | — | [spec-kit-workflow-preset](https://github.com/bigsmartben/spec-kit-workflow-preset) |
+1 -2
View File
@@ -11,8 +11,7 @@ If you want to try Spec Kit without installing it permanently, use `uvx` to run
# Create a new project (latest from main) # Create a new project (latest from main)
uvx --from git+https://github.com/github/spec-kit.git specify init <PROJECT_NAME> uvx --from git+https://github.com/github/spec-kit.git specify init <PROJECT_NAME>
# Or target a specific release (replace vX.Y.Z with a tag from Releases; # Or target a specific release (replace vX.Y.Z with a tag from Releases)
# keep the leading v, e.g. v0.12.11 not 0.12.11)
uvx --from git+https://github.com/github/spec-kit.git@vX.Y.Z specify init <PROJECT_NAME> uvx --from git+https://github.com/github/spec-kit.git@vX.Y.Z specify init <PROJECT_NAME>
# Initialize in the current directory # Initialize in the current directory
+1 -2
View File
@@ -7,8 +7,7 @@
Pin a specific release tag for stability (check [Releases](https://github.com/github/spec-kit/releases) for the latest): Pin a specific release tag for stability (check [Releases](https://github.com/github/spec-kit/releases) for the latest):
```bash ```bash
# Install a specific stable release (recommended — replace vX.Y.Z with the # Install a specific stable release (recommended — replace vX.Y.Z with the latest tag)
# latest tag, keeping the leading v, e.g. v0.12.11 not 0.12.11)
pipx install git+https://github.com/github/spec-kit.git@vX.Y.Z pipx install git+https://github.com/github/spec-kit.git@vX.Y.Z
# Or install latest from main (may include unreleased changes) # Or install latest from main (may include unreleased changes)
-83
View File
@@ -1,83 +0,0 @@
# Installing from PyPI
Spec Kit is published to PyPI as [`specify-cli`](https://pypi.org/project/specify-cli/), maintained by the Spec Kit maintainers. Installing from PyPI is the second supported install route alongside installing from the [GitHub source](../installation.md#install-from-source--persistent-installation-recommended). Use whichever fits your workflow — both provide the same `specify` CLI.
> [!NOTE]
> The PyPI release version tracks the GitHub release tags (for example, PyPI `0.12.11` corresponds to the `v0.12.11` tag). `specify version` is only a local version/runtime sanity check — it reports the installed version but not where the `specify` executable came from, so it cannot distinguish a PyPI install from a Git install. To confirm the install source, inspect the source metadata your package manager records: `pipx list --json` reports the exact install specification for each tool, and for uv/pip installs you can check the package's [PEP 610](https://peps.python.org/pep-0610/) `direct_url.json` inside its `*.dist-info` directory (a Git or URL install records the repository/archive URL there, while a plain PyPI index install does not create that file). Note that `pip show specify-cli` only prints package metadata and will not see uv/pipx-managed environments from the host interpreter.
## Install Specify CLI
Use whichever Python tool you already have:
```bash
# Using uv (recommended)
uv tool install specify-cli
# Or using pipx
pipx install specify-cli
# Or using pip
pip install specify-cli
```
### Install a specific release
Pin an exact version for reproducible installs (check [PyPI](https://pypi.org/project/specify-cli/#history) or [Releases](https://github.com/github/spec-kit/releases) for available versions):
```bash
# Using uv
uv tool install specify-cli==0.12.11
# Or using pipx
pipx install specify-cli==0.12.11
# Or using pip
pip install specify-cli==0.12.11
```
## Verify
```bash
specify version
```
## Initialize a project
```bash
specify init <PROJECT_NAME> --integration copilot
```
## Upgrade
Upgrade by reinstalling the package through the same tool you used for the original install. If you originally pinned a version, note that `uv tool upgrade` preserves that pin; to move to the newest PyPI release, use an unpinned install command so you do not keep the existing version pin:
```bash
# Using uv
uv tool install --force specify-cli
# Or using pipx
pipx install --force specify-cli
# Or using pip
pip install --upgrade specify-cli
```
> [!NOTE]
> `specify self upgrade` currently rebuilds `uv tool` and `pipx` installs from the GitHub source release URL rather than preserving a PyPI-based installation. If you want to stay on the PyPI route, use the package-manager commands above. A plain `pip install specify-cli` is treated as an unmanaged install — upgrade it with `pip install --upgrade specify-cli`. See the [Upgrade Guide](../upgrade.md) for details.
## Uninstall
```bash
# Using uv
uv tool uninstall specify-cli
# Or using pipx
pipx uninstall specify-cli
# Or using pip
pip uninstall specify-cli
```
## Next steps
Head to the [Quick Start](../quickstart.md) to initialize your first project.
+5 -28
View File
@@ -11,16 +11,11 @@
## Installation ## Installation
> [!IMPORTANT] > [!IMPORTANT]
> Spec Kit is distributed through two official channels, both published and maintained by the Spec Kit maintainers: the [github/spec-kit](https://github.com/github/spec-kit) GitHub repository (source installs) and the [`specify-cli`](https://pypi.org/project/specify-cli/) package on [PyPI](https://pypi.org/project/specify-cli/). Either route is supported for normal installs — use the commands shown below. After installing, run `specify version` as a local version/runtime sanity check. It confirms that the `specify` command is available and reports its version, but it does not prove whether the executable came from PyPI or GitHub. For offline or air-gapped environments, locally built wheels created from this repository are also valid. > The only official, maintained packages for Spec Kit come from the [github/spec-kit](https://github.com/github/spec-kit) GitHub repository. Any packages with the same name available on PyPI (e.g. `specify-cli` on pypi.org) are **not** affiliated with this project and are not maintained by the Spec Kit maintainers. For normal installs, use the GitHub-based commands shown below. For offline or air-gapped environments, locally built wheels created from this repository are also valid.
Spec Kit supports two install routes: ### Persistent Installation (Recommended)
1. **Install from source (GitHub)** — the recommended route, pinned to a release tag. Install once and use everywhere. Replace `vX.Y.Z` with a tag from [Releases](https://github.com/github/spec-kit/releases):
2. **Install from PyPI** — install the published `specify-cli` package with your usual Python tooling.
### Install from Source — Persistent Installation (Recommended)
Install once and use everywhere. Replace `vX.Y.Z` with a release tag from [Releases](https://github.com/github/spec-kit/releases) — keep the leading `v` (for example, `v0.12.11`, not `0.12.11`):
> [!NOTE] > [!NOTE]
> The command below requires **[uv](https://docs.astral.sh/uv/)**. If you see `command not found: uv`, [install uv first](./install/uv.md). > The command below requires **[uv](https://docs.astral.sh/uv/)**. If you see `command not found: uv`, [install uv first](./install/uv.md).
@@ -35,30 +30,12 @@ Then initialize a project:
specify init <PROJECT_NAME> --integration copilot specify init <PROJECT_NAME> --integration copilot
``` ```
### Install from PyPI
Spec Kit is also published to PyPI as [`specify-cli`](https://pypi.org/project/specify-cli/), so you can install it with your preferred Python package manager without referencing the Git URL:
```bash
# Using uv (recommended)
uv tool install specify-cli
# Or using pipx
pipx install specify-cli
# Or using pip
pip install specify-cli
```
To install a specific release, pin the version — for example `uv tool install specify-cli==0.12.11`. See the [PyPI installation guide](install/pypi.md) for details, including how to upgrade.
### One-time Usage ### One-time Usage
Run directly without installing — see the [One-time usage (uvx)](install/one-time.md) guide. Run directly without installing — see the [One-time usage (uvx)](install/one-time.md) guide.
### Alternative Package Managers ### Alternative Package Managers
- **PyPI** — see the [PyPI installation guide](install/pypi.md)
- **pipx** — see the [pipx installation guide](install/pipx.md) - **pipx** — see the [pipx installation guide](install/pipx.md)
- **Enterprise / Air-Gapped** — see the [air-gapped installation guide](install/air-gapped.md) - **Enterprise / Air-Gapped** — see the [air-gapped installation guide](install/air-gapped.md)
@@ -104,13 +81,13 @@ specify init <project_name> --integration claude --ignore-agent-tools
## Verification ## Verification
After installation, run the following command as a local version/runtime check: After installation, run the following command to confirm the correct version is installed:
```bash ```bash
specify version specify version
``` ```
This confirms that the `specify` command is available and reporting the expected version. It does not prove whether that executable came from PyPI or GitHub. This helps verify you are running the official Spec Kit build from GitHub, not an unrelated package with the same name.
**Stay current:** Run `specify self check` periodically to learn whether a newer release is available — it is read-only and never modifies your installation. When you are ready to upgrade, follow the [Upgrade Guide](./upgrade.md). **Stay current:** Run `specify self check` periodically to learn whether a newer release is available — it is read-only and never modifies your installation. When you are ready to upgrade, follow the [Upgrade Guide](./upgrade.md).
+4 -5
View File
@@ -51,11 +51,10 @@ If the current directory is not yet a Spec Kit project, `install` initializes on
specify bundle update [<bundle_id>] specify bundle update [<bundle_id>]
``` ```
| Option | Description | | Option | Description |
| ---------------- | --------------------------------------------------------------------------------------------------------------------- | | ------------ | ------------------------------------ |
| `--all` | Update every installed bundle | | `--all` | Update every installed bundle |
| `--integration` | Override the integration used when refreshing components; applied only when the project's active integration can't be determined | | `--offline` | Do not access the network |
| `--offline` | Do not access the network |
Re-resolves a bundle and **refreshes** its components through each primitive's update path, bringing already-installed components up to the bundle's newly pinned versions while preserving primitive-level overrides (such as preset priority). Provide a bundle id, or use `--all` to update everything installed. Re-resolves a bundle and **refreshes** its components through each primitive's update path, bringing already-installed components up to the bundle's newly pinned versions while preserving primitive-level overrides (such as preset priority). Provide a bundle id, or use `--all` to update everything installed.
+1 -2
View File
@@ -18,7 +18,7 @@ The Specify CLI supports a wide range of AI coding agents. When you run `specify
| [Firebender](https://firebender.com/) | `firebender` | IDE-based agent for Android Studio / IntelliJ | | [Firebender](https://firebender.com/) | `firebender` | IDE-based agent for Android Studio / IntelliJ |
| [Forge](https://forgecode.dev/) | `forge` | | | [Forge](https://forgecode.dev/) | `forge` | |
| [Gemini CLI](https://github.com/google-gemini/gemini-cli) | `gemini` | | | [Gemini CLI](https://github.com/google-gemini/gemini-cli) | `gemini` | |
| [GitHub Copilot](https://code.visualstudio.com/) | `copilot` | Defaults to legacy markdown mode: `.agent.md` command files under `.github/agents/`, companion `.prompt.md` files under `.github/prompts/`, and a `.vscode/settings.json` merge. Pass `--integration-options="--skills"` to scaffold skills as `speckit-<command>/SKILL.md` under `.github/skills/` instead. Legacy markdown mode is deprecated and will stop being the default in a future release. | | [GitHub Copilot](https://code.visualstudio.com/) | `copilot` | |
| [Goose](https://goose-docs.ai/) | `goose` | Uses YAML recipe format in `.goose/recipes/` | | [Goose](https://goose-docs.ai/) | `goose` | Uses YAML recipe format in `.goose/recipes/` |
| [Hermes](https://github.com/NousResearch/hermes-agent) | `hermes` | Skills-based integration; installs skills globally into `~/.hermes/skills/` | | [Hermes](https://github.com/NousResearch/hermes-agent) | `hermes` | Skills-based integration; installs skills globally into `~/.hermes/skills/` |
| [IBM Bob](https://www.ibm.com/products/bob) | `bob` | IDE-based agent | | [IBM Bob](https://www.ibm.com/products/bob) | `bob` | IDE-based agent |
@@ -219,7 +219,6 @@ Some integrations accept additional options via `--integration-options`:
| ----------- | ------------------- | -------------------------------------------------------------- | | ----------- | ------------------- | -------------------------------------------------------------- |
| `generic` | `--commands-dir` | Required. Directory for command files | | `generic` | `--commands-dir` | Required. Directory for command files |
| `kimi` | `--migrate-legacy` | Migrate legacy `.kimi/skills/` installs to `.kimi-code/skills/` (including dotted→hyphenated skill naming, e.g. `speckit.xxx` → `speckit-xxx`) | | `kimi` | `--migrate-legacy` | Migrate legacy `.kimi/skills/` installs to `.kimi-code/skills/` (including dotted→hyphenated skill naming, e.g. `speckit.xxx` → `speckit-xxx`) |
| `copilot` | `--skills` | Scaffold commands as agent skills (`speckit-<command>/SKILL.md` under `.github/skills/`, invoked as `/speckit-<command>`) instead of the default legacy markdown mode (`.github/agents/*.agent.md` plus `.github/prompts/*.prompt.md` and a `.vscode/settings.json` merge). Without this flag, install warns that legacy markdown mode is deprecated. |
Example: Example:
+3 -36
View File
@@ -86,30 +86,8 @@ Lists workflows installed in the current project.
specify workflow add <source> specify workflow add <source>
``` ```
| Option | Description |
| --------------- | ------------------------------------------------------ |
| `--dev` | Install from a local workflow YAML file or directory |
| `--from <url>` | Install from a custom URL (`<source>` names the expected workflow ID) |
Installs a workflow from the catalog, a URL (HTTPS required), or a local file path. Installs a workflow from the catalog, a URL (HTTPS required), or a local file path.
## Update Workflows
```bash
specify workflow update [workflow_id]
```
Updates one installed catalog workflow — or all of them when no ID is given — to the latest catalog version. Prompts for confirmation and keeps the installed copy if a download or validation fails.
## Enable or Disable a Workflow
```bash
specify workflow enable <workflow_id>
specify workflow disable <workflow_id>
```
Disabled workflows stay installed and listed (marked `[disabled]`) but refuse to run until re-enabled.
## Remove a Workflow ## Remove a Workflow
```bash ```bash
@@ -124,10 +102,9 @@ Removes an installed workflow from the project.
specify workflow search [query] specify workflow search [query]
``` ```
| Option | Description | | Option | Description |
| ---------- | ----------------- | | ------- | --------------- |
| `--tag` | Filter by tag | | `--tag` | Filter by tag |
| `--author` | Filter by author |
Searches all active catalogs for workflows matching the query. Searches all active catalogs for workflows matching the query.
@@ -305,8 +282,6 @@ Steps can reference inputs and previous step outputs using `{{ expression }}` sy
| `inputs.spec` | Workflow input values | | `inputs.spec` | Workflow input values |
| `steps.specify.output.file` | Output from a previous step | | `steps.specify.output.file` | Output from a previous step |
| `item` | Current item in a fan-out iteration | | `item` | Current item in a fan-out iteration |
| `context.run_id` | Current workflow run ID |
| `context.workflow_dir` | Resolved absolute path to the workflow source directory. Empty string for string-loaded workflows. |
Available filters: `default`, `join`, `contains`, `map`, `from_json`. Available filters: `default`, `join`, `contains`, `map`, `from_json`.
@@ -318,14 +293,6 @@ args: "{{ inputs.spec }}"
message: "{{ status | default('pending') }}" message: "{{ status | default('pending') }}"
``` ```
## Shell Step Environment Variables
Shell steps automatically receive the following environment variables:
| Variable | Description |
| -------- | ----------- |
| `SPECKIT_WORKFLOW_DIR` | Resolved absolute path to the workflow source directory (same value as `{{ context.workflow_dir }}`). Not set when the workflow has no source path. |
## Input Types ## Input Types
| Type | Coercion | | Type | Coercion |
-2
View File
@@ -13,8 +13,6 @@
href: upgrade.md href: upgrade.md
- name: Install uv - name: Install uv
href: install/uv.md href: install/uv.md
- name: Install from PyPI
href: install/pypi.md
- name: Install with pipx - name: Install with pipx
href: install/pipx.md href: install/pipx.md
- name: One-time Usage (uvx) - name: One-time Usage (uvx)
@@ -15,7 +15,7 @@ The script reads the agent-context extension config at
- `context_files` — optional project-relative paths for multiple coding agent context files. When non-empty, the script updates each listed file and the list takes precedence over `context_file`. - `context_files` — optional project-relative paths for multiple coding agent context files. When non-empty, the script updates each listed file and the list takes precedence over `context_file`.
- `context_markers.start` / `.end` — the delimiters surrounding the managed section. Defaults to `<!-- SPECKIT START -->` and `<!-- SPECKIT END -->` when the field is missing. - `context_markers.start` / `.end` — the delimiters surrounding the managed section. Defaults to `<!-- SPECKIT START -->` and `<!-- SPECKIT END -->` when the field is missing.
It then creates, replaces, or appends the managed block so that the section points at the most recent plan path when one can be discovered (any `plan.md` under `specs/`, including nested scoped layouts such as `specs/<scope>/<feature>/plan.md`). It then creates, replaces, or appends the managed block so that the section points at the most recent plan path when one can be discovered (`specs/<feature>/plan.md`).
If `context_files` and `context_file` are empty, the command reports nothing to do and exits successfully. Context file paths must stay project-relative; absolute paths, Windows drive paths, backslash separators, and `..` path segments are rejected. If `context_files` and `context_file` are empty, the command reports nothing to do and exits successfully. Context file paths must stay project-relative; absolute paths, Windows drive paths, backslash separators, and `..` path segments are rejected.
@@ -24,4 +24,4 @@ If `context_files` and `context_file` are empty, the command reports nothing to
- **Bash**: `.specify/extensions/agent-context/scripts/bash/update-agent-context.sh [plan_path]` - **Bash**: `.specify/extensions/agent-context/scripts/bash/update-agent-context.sh [plan_path]`
- **PowerShell**: `.specify/extensions/agent-context/scripts/powershell/update-agent-context.ps1 [plan_path]` - **PowerShell**: `.specify/extensions/agent-context/scripts/powershell/update-agent-context.ps1 [plan_path]`
When `plan_path` is omitted, the script auto-detects the most recently modified `specs/**/plan.md` (searched recursively, so nested scoped layouts are discovered). When `plan_path` is omitted, the script auto-detects the most recently modified `specs/*/plan.md`.
@@ -12,7 +12,7 @@
# #
# When `plan_path` is omitted, the script derives it from `.specify/feature.json` # When `plan_path` is omitted, the script derives it from `.specify/feature.json`
# (written by /speckit-specify). Falls back to the most recently modified # (written by /speckit-specify). Falls back to the most recently modified
# `specs/**/plan.md` only when feature.json is absent or its plan does not exist yet. # `specs/*/plan.md` only when feature.json is absent or its plan does not exist yet.
set -euo pipefail set -euo pipefail
@@ -307,28 +307,16 @@ import sys
from pathlib import Path from pathlib import Path
root = Path(sys.argv[1]).resolve() root = Path(sys.argv[1]).resolve()
specs = root / "specs" specs = root / "specs"
plans = sorted(
def _resolved_rel(p): specs.glob("*/plan.md"),
# Resolve symlinks before checking containment: relative_to() is lexical key=lambda p: p.stat().st_mtime,
# and would otherwise accept a plan reached through a specs/ symlink that reverse=True,
# points outside the project, emitting an in-project-looking path for an )
# out-of-project file (or picking it as "most recent"). if plans:
try: try:
return p.resolve().relative_to(root) print(plans[0].relative_to(root).as_posix())
except (OSError, ValueError): except ValueError:
return None print("")
# Recurse (rather than the old one-level specs/*/plan.md glob) so scoped layouts
# created via SPECIFY_FEATURE_DIRECTORY, e.g. specs/<scope>/<feature>/plan.md,
# are still discovered when feature.json is absent (#3024).
candidates = []
for p in specs.rglob("plan.md"):
rel = _resolved_rel(p)
if rel:
candidates.append((p, rel))
candidates.sort(key=lambda pr: pr[0].stat().st_mtime, reverse=True)
if candidates:
print(candidates[0][1].as_posix())
else: else:
print("") print("")
PY PY
@@ -12,7 +12,7 @@
# #
# When `plan_path` is omitted, the script derives it from `.specify/feature.json` # When `plan_path` is omitted, the script derives it from `.specify/feature.json`
# (written by /speckit-specify). Falls back to the most recently modified # (written by /speckit-specify). Falls back to the most recently modified
# `specs/**/plan.md` only when feature.json is absent or its plan does not exist yet. # `specs/*/plan.md` only when feature.json is absent or its plan does not exist yet.
[CmdletBinding()] [CmdletBinding()]
param( param(
@@ -426,11 +426,9 @@ if (-not $PlanPath) {
if (-not $PlanPath) { if (-not $PlanPath) {
try { try {
$specsDir = Join-Path $ProjectRoot 'specs' $specsDir = Join-Path $ProjectRoot 'specs'
# Recurse (rather than the old one-level specs/*/plan.md scan) so scoped $candidate = Get-ChildItem -Path $specsDir -Directory -ErrorAction SilentlyContinue |
# layouts created via SPECIFY_FEATURE_DIRECTORY, e.g. ForEach-Object { Get-Item -LiteralPath (Join-Path $_.FullName 'plan.md') -ErrorAction SilentlyContinue } |
# specs/<scope>/<feature>/plan.md, are still discovered when Where-Object { $_ } |
# feature.json is absent (#3024).
$candidate = Get-ChildItem -Path $specsDir -Filter 'plan.md' -File -Recurse -ErrorAction SilentlyContinue |
Sort-Object LastWriteTime -Descending | Sort-Object LastWriteTime -Descending |
Select-Object -First 1 Select-Object -First 1
if ($candidate) { if ($candidate) {
+13 -301
View File
@@ -1,6 +1,6 @@
{ {
"schema_version": "1.0", "schema_version": "1.0",
"updated_at": "2026-07-15T00:00:00Z", "updated_at": "2026-07-06T00:00:00Z",
"catalog_url": "https://raw.githubusercontent.com/github/spec-kit/main/extensions/catalog.community.json", "catalog_url": "https://raw.githubusercontent.com/github/spec-kit/main/extensions/catalog.community.json",
"extensions": { "extensions": {
"aide": { "aide": {
@@ -809,8 +809,8 @@
"id": "coding-standards-drift-control", "id": "coding-standards-drift-control",
"description": "Generate coding-standards drift reports and remediation tasks for active Spec Kit features", "description": "Generate coding-standards drift reports and remediation tasks for active Spec Kit features",
"author": "Igor Benicio de Mesquita", "author": "Igor Benicio de Mesquita",
"version": "0.4.0", "version": "0.3.1",
"download_url": "https://github.com/benizzio/spec-kit-coding-standards-drift-control/archive/refs/tags/v0.4.0.zip", "download_url": "https://github.com/benizzio/spec-kit-coding-standards-drift-control/archive/refs/tags/v0.3.1.zip",
"repository": "https://github.com/benizzio/spec-kit-coding-standards-drift-control", "repository": "https://github.com/benizzio/spec-kit-coding-standards-drift-control",
"homepage": "https://github.com/benizzio/spec-kit-coding-standards-drift-control", "homepage": "https://github.com/benizzio/spec-kit-coding-standards-drift-control",
"documentation": "https://github.com/benizzio/spec-kit-coding-standards-drift-control#readme", "documentation": "https://github.com/benizzio/spec-kit-coding-standards-drift-control#readme",
@@ -835,7 +835,7 @@
"downloads": 0, "downloads": 0,
"stars": 0, "stars": 0,
"created_at": "2026-06-11T00:00:00Z", "created_at": "2026-06-11T00:00:00Z",
"updated_at": "2026-07-15T00:00:00Z" "updated_at": "2026-06-11T00:00:00Z"
}, },
"companion": { "companion": {
"name": "SpecKit Companion", "name": "SpecKit Companion",
@@ -1106,10 +1106,10 @@
"docguard": { "docguard": {
"name": "DocGuard — CDD Enforcement", "name": "DocGuard — CDD Enforcement",
"id": "docguard", "id": "docguard",
"description": "Doc-integrity engine with MCP server, SARIF output, and zero-LLM core. Validates, scores, and traces documentation against code — 24 validators, stable finding codes, spec-kit hooks. Pure Node.js.", "description": "The only doc-integrity engine with an MCP server, SARIF output, and a deterministic zero-LLM core. Validates, scores, and traces documentation against code — 24 validators, stable finding codes, GitHub Action with PR annotations, spec-kit hooks. Pure Node.js, one pinned dep.",
"author": "raccioly", "author": "raccioly",
"version": "0.32.0", "version": "0.30.0",
"download_url": "https://github.com/raccioly/docguard/releases/download/v0.32.0/spec-kit-docguard-v0.32.0.zip", "download_url": "https://github.com/raccioly/docguard/releases/download/v0.30.0/spec-kit-docguard-v0.30.0.zip",
"repository": "https://github.com/raccioly/docguard", "repository": "https://github.com/raccioly/docguard",
"homepage": "https://www.npmjs.com/package/docguard-cli", "homepage": "https://www.npmjs.com/package/docguard-cli",
"documentation": "https://github.com/raccioly/docguard/blob/main/extensions/spec-kit-docguard/README.md", "documentation": "https://github.com/raccioly/docguard/blob/main/extensions/spec-kit-docguard/README.md",
@@ -1124,14 +1124,6 @@
"name": "node", "name": "node",
"version": ">=18.0.0", "version": ">=18.0.0",
"required": true "required": true
},
{
"name": "npx",
"required": true
},
{
"name": "specify",
"required": false
} }
] ]
}, },
@@ -1153,7 +1145,7 @@
"downloads": 0, "downloads": 0,
"stars": 0, "stars": 0,
"created_at": "2026-03-13T00:00:00Z", "created_at": "2026-03-13T00:00:00Z",
"updated_at": "2026-07-13T00:00:00Z" "updated_at": "2026-07-06T00:00:00Z"
}, },
"doctor": { "doctor": {
"name": "Project Health Check", "name": "Project Health Check",
@@ -1188,39 +1180,6 @@
"created_at": "2026-03-13T00:00:00Z", "created_at": "2026-03-13T00:00:00Z",
"updated_at": "2026-03-13T00:00:00Z" "updated_at": "2026-03-13T00:00:00Z"
}, },
"ears": {
"name": "EARS Requirements Syntax",
"id": "ears",
"description": "Author, lint, and convert requirements using EARS (Easy Approach to Requirements Syntax) - the five industry-standard sentence patterns for unambiguous, testable requirements.",
"author": "dhruv-15-03",
"version": "1.0.0",
"download_url": "https://github.com/dhruv-15-03/spec-kit-ears/archive/refs/tags/v1.0.0.zip",
"repository": "https://github.com/dhruv-15-03/spec-kit-ears",
"homepage": "https://github.com/dhruv-15-03/spec-kit-ears",
"documentation": "https://github.com/dhruv-15-03/spec-kit-ears/blob/main/README.md",
"changelog": "https://github.com/dhruv-15-03/spec-kit-ears/blob/main/CHANGELOG.md",
"license": "MIT",
"category": "docs",
"effect": "read-write",
"requires": {
"speckit_version": ">=0.9.0"
},
"provides": {
"commands": 3,
"hooks": 0
},
"tags": [
"ears",
"requirements",
"specification",
"quality"
],
"verified": false,
"downloads": 0,
"stars": 0,
"created_at": "2026-07-08T00:00:00Z",
"updated_at": "2026-07-08T00:00:00Z"
},
"extensify": { "extensify": {
"name": "Extensify", "name": "Extensify",
"id": "extensify", "id": "extensify",
@@ -1254,47 +1213,6 @@
"created_at": "2026-03-18T00:00:00Z", "created_at": "2026-03-18T00:00:00Z",
"updated_at": "2026-04-23T00:00:00Z" "updated_at": "2026-04-23T00:00:00Z"
}, },
"figma": {
"name": "Spec Kit Figma",
"id": "figma",
"description": "Agent-agnostic SpecKit extension that grounds spec, plan & task generation in Figma design context — REST + optional MCP, single/mono/multi-repo, macOS/Linux/Windows.",
"author": "Fyloss",
"version": "1.6.0",
"download_url": "https://github.com/Fyloss/spec-kit-figma/archive/refs/tags/v1.6.0.zip",
"repository": "https://github.com/Fyloss/spec-kit-figma",
"homepage": "https://github.com/Fyloss/spec-kit-figma",
"documentation": "https://github.com/Fyloss/spec-kit-figma/blob/main/docs/INSTALL.md",
"changelog": "https://github.com/Fyloss/spec-kit-figma/blob/main/CHANGELOG.md",
"license": "MIT",
"category": "integration",
"effect": "read-write",
"requires": {
"speckit_version": ">=0.1.0",
"tools": [
{ "name": "git", "required": true },
{ "name": "bash", "required": false },
{ "name": "curl", "required": false },
{ "name": "jq", "required": false },
{ "name": "pwsh", "required": false }
]
},
"provides": {
"commands": 5,
"hooks": 6
},
"tags": [
"figma",
"design",
"frontend",
"ui",
"design-system"
],
"verified": false,
"downloads": 0,
"stars": 0,
"created_at": "2026-07-08T00:00:00Z",
"updated_at": "2026-07-08T00:00:00Z"
},
"fix-findings": { "fix-findings": {
"name": "Fix Findings", "name": "Fix Findings",
"id": "fix-findings", "id": "fix-findings",
@@ -1435,57 +1353,6 @@
"created_at": "2026-05-06T00:00:00Z", "created_at": "2026-05-06T00:00:00Z",
"updated_at": "2026-05-06T00:00:00Z" "updated_at": "2026-05-06T00:00:00Z"
}, },
"gates": {
"name": "Quality Gates (Enforcement Layer)",
"id": "gates",
"description": "Deterministic quality enforcement for Spec Kit projects at three boundaries — agent hooks, git pre-commit, CI — with one policy file and provable enforcement (attestations, canaries, verified parity).",
"author": "schwichtgit",
"version": "0.1.0",
"download_url": "https://github.com/schwichtgit/spec-gates/releases/download/v0.1.0/gates-0.1.0.zip",
"repository": "https://github.com/schwichtgit/spec-gates",
"homepage": "https://github.com/schwichtgit/spec-gates",
"documentation": "https://github.com/schwichtgit/spec-gates/blob/main/docs/how-it-works.md",
"license": "MIT",
"category": "process",
"effect": "read-write",
"requires": {
"speckit_version": ">=0.12.0",
"tools": [
{
"name": "jq",
"required": true
},
{
"name": "git",
"required": true
},
{
"name": "node",
"required": false
},
{
"name": "shellcheck",
"required": false
}
]
},
"provides": {
"commands": 5,
"hooks": 1
},
"tags": [
"quality",
"enforcement",
"hooks",
"ci",
"governance"
],
"verified": false,
"downloads": 0,
"stars": 0,
"created_at": "2026-07-09T00:00:00Z",
"updated_at": "2026-07-13T00:00:00Z"
},
"github-issues": { "github-issues": {
"name": "GitHub Issues Integration 1", "name": "GitHub Issues Integration 1",
"id": "github-issues", "id": "github-issues",
@@ -1531,10 +1398,10 @@
"golden-demo": { "golden-demo": {
"name": "Golden Demo", "name": "Golden Demo",
"id": "golden-demo", "id": "golden-demo",
"description": "Deterministic behavioral drift oracle. Extracts acceptance criteria, generates fuzz test vectors (seed=42), compares golden Python implementations against real code in any language. CI/CD gatekeeper with warn/strict modes.", "description": "Extracts acceptance criteria from specs, builds test vectors, and produces a behavioral drift report — complementary to Architecture Guard and CDD.",
"author": "jasstt", "author": "jasstt",
"version": "0.3.0", "version": "0.1.1",
"download_url": "https://github.com/jasstt/spec-kit-golden-demo/archive/refs/tags/v0.3.0.zip", "download_url": "https://github.com/jasstt/spec-kit-golden-demo/archive/refs/tags/v0.1.1.zip",
"repository": "https://github.com/jasstt/spec-kit-golden-demo", "repository": "https://github.com/jasstt/spec-kit-golden-demo",
"homepage": "https://github.com/jasstt/spec-kit-golden-demo", "homepage": "https://github.com/jasstt/spec-kit-golden-demo",
"documentation": "https://github.com/jasstt/spec-kit-golden-demo", "documentation": "https://github.com/jasstt/spec-kit-golden-demo",
@@ -1545,16 +1412,13 @@
"speckit_version": ">=0.1.0" "speckit_version": ">=0.1.0"
}, },
"provides": { "provides": {
"commands": 3, "commands": 2,
"hooks": 2 "hooks": 2
}, },
"tags": [ "tags": [
"testing", "testing",
"drift-detection", "drift-detection",
"behavioral-oracle", "behavioral-oracle",
"fuzzing",
"ci-cd",
"cross-language",
"tdd", "tdd",
"quality" "quality"
], ],
@@ -1562,7 +1426,7 @@
"downloads": 0, "downloads": 0,
"stars": 0, "stars": 0,
"created_at": "2026-06-24T00:00:00Z", "created_at": "2026-06-24T00:00:00Z",
"updated_at": "2026-07-07T00:00:00Z" "updated_at": "2026-06-24T00:00:00Z"
}, },
"harness": { "harness": {
"name": "Research Harness", "name": "Research Harness",
@@ -2276,42 +2140,6 @@
"created_at": "2026-05-08T00:00:00Z", "created_at": "2026-05-08T00:00:00Z",
"updated_at": "2026-05-08T00:00:00Z" "updated_at": "2026-05-08T00:00:00Z"
}, },
"memory": {
"name": "Spec Kit Memory",
"id": "memory",
"description": "Recalls prior specs and decisions from configurable memory tools (e.g. memsearch) before SDLC stages, so planning and specification start from what the project already knows.",
"author": "Andrey Zaytsev",
"version": "0.3.0",
"download_url": "https://github.com/zaytsevand/spec-kit-memory/archive/refs/tags/v0.3.0.zip",
"repository": "https://github.com/zaytsevand/spec-kit-memory",
"homepage": "https://github.com/zaytsevand/spec-kit-memory",
"documentation": "https://github.com/zaytsevand/spec-kit-memory/blob/main/README.md",
"changelog": "",
"license": "MIT",
"category": "docs",
"effect": "read-write",
"requires": {
"speckit_version": ">=0.2.0",
"tools": [
{ "name": "memsearch", "required": false }
]
},
"provides": {
"commands": 2,
"hooks": 3
},
"tags": [
"memory",
"recall",
"research",
"memsearch"
],
"verified": false,
"downloads": 0,
"stars": 0,
"created_at": "2026-07-10T00:00:00Z",
"updated_at": "2026-07-10T00:00:00Z"
},
"memory-loader": { "memory-loader": {
"name": "Memory Loader", "name": "Memory Loader",
"id": "memory-loader", "id": "memory-loader",
@@ -2466,48 +2294,6 @@
"created_at": "2026-05-04T02:51:52Z", "created_at": "2026-05-04T02:51:52Z",
"updated_at": "2026-06-18T00:00:00Z" "updated_at": "2026-06-18T00:00:00Z"
}, },
"multi-repo-sync": {
"name": "Multi-Repo Branch Sync",
"id": "multi-repo-sync",
"description": "Creates the feature branch in affected sub-repositories and git submodules via plan/tasks hooks",
"author": "Fyloss",
"version": "1.0.0",
"download_url": "https://github.com/fyloss/spec-kit-multi-repo-sync/releases/download/v1.0.0/spec-kit-multi-repo-sync.zip",
"sha256": "12a5c7392145b4424b20715aaa3d8b6a8218c143dea596873e344146c1a76ba0",
"repository": "https://github.com/fyloss/spec-kit-multi-repo-sync",
"homepage": "https://github.com/fyloss/spec-kit-multi-repo-sync",
"documentation": "https://github.com/fyloss/spec-kit-multi-repo-sync/blob/main/README.md",
"changelog": "https://github.com/fyloss/spec-kit-multi-repo-sync/blob/main/CHANGELOG.md",
"license": "MIT",
"category": "process",
"effect": "read-write",
"requires": {
"speckit_version": ">=0.2.0",
"tools": [
{
"name": "git",
"version": ">=2.31",
"required": true
}
]
},
"provides": {
"commands": 3,
"hooks": 2
},
"tags": [
"git",
"branching",
"multi-repo",
"submodules",
"workflow"
],
"verified": false,
"downloads": 0,
"stars": 0,
"created_at": "2026-07-13T00:00:00Z",
"updated_at": "2026-07-13T00:00:00Z"
},
"multi-sites": { "multi-sites": {
"name": "Multi-Sites Spec Kit", "name": "Multi-Sites Spec Kit",
"id": "multi-sites", "id": "multi-sites",
@@ -2677,46 +2463,6 @@
"created_at": "2026-04-24T14:00:00Z", "created_at": "2026-04-24T14:00:00Z",
"updated_at": "2026-04-24T14:00:00Z" "updated_at": "2026-04-24T14:00:00Z"
}, },
"patchwarden-evidence": {
"name": "PatchWarden Evidence Pack",
"id": "patchwarden-evidence",
"description": "Map Spec Kit tasks into a guarded PatchWarden Goal and export bounded, traceable evidence for an accepted lineage.",
"author": "Zengjie",
"version": "1.0.1",
"download_url": "https://github.com/jiezeng2004-design/spec-kit-patchwarden/archive/refs/tags/v1.0.1.zip",
"repository": "https://github.com/jiezeng2004-design/spec-kit-patchwarden",
"homepage": "https://github.com/jiezeng2004-design/spec-kit-patchwarden",
"documentation": "https://github.com/jiezeng2004-design/spec-kit-patchwarden/blob/main/README.md",
"changelog": "https://github.com/jiezeng2004-design/spec-kit-patchwarden/blob/main/CHANGELOG.md",
"license": "MIT",
"category": "process",
"effect": "read-write",
"requires": {
"speckit_version": ">=0.1.0",
"tools": [
{
"name": "patchwarden",
"version": ">=1.5.1",
"required": true
}
]
},
"provides": {
"commands": 2,
"hooks": 2
},
"tags": [
"verification",
"evidence",
"traceability",
"security"
],
"verified": false,
"downloads": 0,
"stars": 0,
"created_at": "2026-07-14T00:00:00Z",
"updated_at": "2026-07-14T00:00:00Z"
},
"plan-review-gate": { "plan-review-gate": {
"name": "Plan Review Gate", "name": "Plan Review Gate",
"id": "plan-review-gate", "id": "plan-review-gate",
@@ -4533,40 +4279,6 @@
"created_at": "2026-03-03T00:00:00Z", "created_at": "2026-03-03T00:00:00Z",
"updated_at": "2026-04-09T00:00:00Z" "updated_at": "2026-04-09T00:00:00Z"
}, },
"verify-review-ship": {
"name": "Verify Review Ship",
"id": "verify-review-ship",
"description": "Adds post-implementation verify, review, and ship readiness gates to Spec Kit workflows.",
"author": "Carlos Eduardo Gevaerd Araujo",
"version": "0.1.0",
"download_url": "https://github.com/cadugevaerd/spec-kit-verify-review-ship/archive/refs/tags/v0.1.0.zip",
"repository": "https://github.com/cadugevaerd/spec-kit-verify-review-ship",
"homepage": "https://github.com/cadugevaerd/spec-kit-verify-review-ship",
"documentation": "https://github.com/cadugevaerd/spec-kit-verify-review-ship/blob/main/README.md",
"changelog": "https://github.com/cadugevaerd/spec-kit-verify-review-ship/blob/main/CHANGELOG.md",
"license": "MIT",
"category": "process",
"effect": "read-only",
"requires": {
"speckit_version": ">=0.1.0"
},
"provides": {
"commands": 3,
"hooks": 1
},
"tags": [
"quality",
"review",
"shipping",
"workflow",
"testing"
],
"verified": false,
"downloads": 0,
"stars": 0,
"created_at": "2026-07-10T00:00:00Z",
"updated_at": "2026-07-10T00:00:00Z"
},
"verify-tasks": { "verify-tasks": {
"name": "Verify Tasks Extension", "name": "Verify Tasks Extension",
"id": "verify-tasks", "id": "verify-tasks",
+1 -1
View File
@@ -48,4 +48,4 @@
] ]
} }
} }
} }
@@ -446,10 +446,7 @@ if ($env:GIT_BRANCH_NAME) {
$branchSuffix = Get-BranchName -Description $featureDesc $branchSuffix = Get-BranchName -Description $featureDesc
} }
# Warn if -Number and -Timestamp are both specified. Use ContainsKey (not if ($Timestamp -and $Number -ne 0) {
# `-ne 0`) so an explicit `-Number 0` is also detected, matching the bash twin's
# `[ -n "$BRANCH_NUMBER" ]` check.
if ($Timestamp -and $PSBoundParameters.ContainsKey('Number')) {
Write-Warning "[specify] Warning: -Number is ignored when -Timestamp is used" Write-Warning "[specify] Warning: -Number is ignored when -Timestamp is used"
$Number = 0 $Number = 0
} }
@@ -459,10 +456,7 @@ if ($env:GIT_BRANCH_NAME) {
$branchName = New-BranchName -FeatureNum $featureNum -BranchSuffix $branchSuffix $branchName = New-BranchName -FeatureNum $featureNum -BranchSuffix $branchSuffix
} else { } else {
$branchScopePrefix = Get-BranchScopePrefix -Template $branchTemplate -BranchSuffix $branchSuffix $branchScopePrefix = Get-BranchScopePrefix -Template $branchTemplate -BranchSuffix $branchSuffix
# Auto-detect the next number only when -Number was not supplied; an if ($Number -eq 0) {
# explicit value (including 0) is honored, matching the bash twin's
# `[ -z "$BRANCH_NUMBER" ]` check.
if (-not $PSBoundParameters.ContainsKey('Number')) {
if ($DryRun -and $hasGit) { if ($DryRun -and $hasGit) {
$Number = Get-NextBranchNumber -SpecsDir $specsDir -SkipFetch -ScopePrefix $branchScopePrefix $Number = Get-NextBranchNumber -SpecsDir $specsDir -SkipFetch -ScopePrefix $branchScopePrefix
} elseif ($DryRun) { } elseif ($DryRun) {
@@ -1,187 +0,0 @@
#!/usr/bin/env python3
"""Git extension: auto_commit.py
Automatically commit changes after a Spec Kit command completes.
Python port of ``auto-commit.sh`` / ``auto-commit.ps1``.
Checks per-command config keys in git-config.yml before committing.
Usage: auto_commit.py <event_name>
e.g.: auto_commit.py after_specify
"""
from __future__ import annotations
import re
import shutil
import subprocess
import sys
from pathlib import Path
def _find_project_root(start: Path) -> Path | None:
current = start
while True:
if (current / ".specify").is_dir() or (current / ".git").exists():
return current
if current.parent == current:
return None
current = current.parent
def _value_after_colon(line: str) -> str:
return re.sub(r"^[^:]*:\s*", "", line)
def _strip_quotes(value: str) -> str:
"""Strip one leading quote and all trailing quotes, mirroring the bash sed."""
value = re.sub(r"^[\"']", "", value)
return re.sub(r"[\"']*$", "", value)
def _parse_auto_commit_config(
config_file: Path, event_name: str
) -> tuple[bool, str]:
"""Parse the auto_commit section for this event, mirroring the bash line parser.
Returns (enabled, commit_msg). Looks for auto_commit.<event_name>.enabled
and .message, with auto_commit.default as fallback.
"""
enabled = False
commit_msg = ""
default_enabled = False
in_auto_commit = False
in_event = False
try:
content = config_file.read_text(encoding="utf-8")
except (OSError, UnicodeDecodeError):
# Unreadable or non-UTF-8 config is treated like a missing one:
# auto-commit stays disabled instead of crashing with a traceback.
return False, ""
for record in content.splitlines(keepends=True):
if not record.endswith("\n"):
break
line = record[:-1]
if line.startswith("auto_commit:"):
in_auto_commit = True
in_event = False
continue
# Exit auto_commit section on next top-level key
if in_auto_commit and re.match(r"^[a-z]", line):
break
if not in_auto_commit:
continue
if re.match(r"^\s+default:\s", line):
value = re.sub(r"\s", "", _value_after_colon(line)).lower()
if value == "true":
default_enabled = True
if re.match(rf"^\s+{re.escape(event_name)}:", line):
in_event = True
continue
if in_event:
# Exit on next sibling key (same indent level as event name)
if re.match(r"^\s{2}[a-z]", line) and not re.match(r"^\s{4}", line):
in_event = False
continue
if re.search(r"\s+enabled:", line):
value = re.sub(r"\s", "", _value_after_colon(line)).lower()
if value == "true":
enabled = True
elif value == "false":
enabled = False
if re.search(r"\s+message:", line):
commit_msg = _strip_quotes(_value_after_colon(line))
# If event-specific key not found, use default — but only if the event
# section didn't exist at all (an explicit false must win).
if not enabled and default_enabled:
if not re.search(rf"^\s*{re.escape(event_name)}:", content, re.MULTILINE):
enabled = True
return enabled, commit_msg
def main(argv: list[str]) -> int:
event_name = argv[0] if argv else ""
if not event_name:
print(f"Usage: {Path(sys.argv[0]).name} <event_name>", file=sys.stderr)
return 1
script_dir = Path(__file__).resolve().parent
repo_root = _find_project_root(script_dir) or Path.cwd()
if shutil.which("git") is None:
print("[specify] Warning: Git not found; skipped auto-commit", file=sys.stderr)
return 0
probe = subprocess.run(
["git", "rev-parse", "--is-inside-work-tree"],
cwd=repo_root,
capture_output=True,
text=True,
)
if probe.returncode != 0:
print(
"[specify] Warning: Not a Git repository; skipped auto-commit",
file=sys.stderr,
)
return 0
config_file = repo_root / ".specify" / "extensions" / "git" / "git-config.yml"
if not config_file.is_file():
# No config file — auto-commit disabled by default
return 0
enabled, commit_msg = _parse_auto_commit_config(config_file, event_name)
if not enabled:
return 0
# Check if there are changes to commit
def _quiet(*args: str) -> bool:
return (
subprocess.run(
["git", *args], cwd=repo_root, capture_output=True, text=True
).returncode
== 0
)
untracked = subprocess.run(
["git", "ls-files", "--others", "--exclude-standard"],
cwd=repo_root,
capture_output=True,
text=True,
).stdout.strip()
if _quiet("diff", "--quiet", "HEAD") and _quiet("diff", "--cached", "--quiet") and not untracked:
print(f"[specify] No changes to commit after {event_name}", file=sys.stderr)
return 0
# Derive a human-readable command name from the event
# e.g., after_specify -> specify, before_plan -> plan
command_name = re.sub(r"^(after_|before_)", "", event_name)
phase = "before" if event_name.startswith("before_") else "after"
if not commit_msg:
commit_msg = f"[Spec Kit] Auto-commit {phase} {command_name}"
steps = [
(["git", "add", "."], "git add"),
(["git", "commit", "-q", "-m", commit_msg], "git commit"),
]
for cmd, label in steps:
result = subprocess.run(cmd, cwd=repo_root, capture_output=True, text=True)
if result.returncode != 0:
output = (result.stdout + result.stderr).strip()
print(f"[specify] Error: {label} failed: {output}", file=sys.stderr)
return 1
print(f"[OK] Changes committed {phase} {command_name}", file=sys.stderr)
return 0
if __name__ == "__main__":
raise SystemExit(main(sys.argv[1:]))
@@ -1,634 +0,0 @@
#!/usr/bin/env python3
"""Git extension: create_new_feature_branch.py
Creates a git feature branch only. The feature directory and spec file are
created by the core create-new-feature script. Python port of
``create-new-feature-branch.sh`` / ``create-new-feature-branch.ps1``.
Loads the core Python helpers from the project's installed scripts when
available, falling back to the minimal git helpers next to this script.
"""
from __future__ import annotations
import importlib.util
import json
import os
import re
import shutil
import subprocess
import sys
from dataclasses import dataclass, field
from datetime import datetime
from pathlib import Path
SCRIPT_DIR = Path(__file__).resolve().parent
MAX_BRANCH_LENGTH = 244 # GitHub enforces a 244-byte limit on branch names
USAGE = (
"Usage: create_new_feature_branch.py [--json] [--dry-run] "
"[--allow-existing-branch] [--short-name <name>] [--number N] "
"[--timestamp] <feature_description>"
)
HELP_TEXT = f"""{USAGE}
Options:
--json Output in JSON format
--dry-run Compute branch name without creating the branch
--allow-existing-branch Switch to branch if it already exists instead of failing
--short-name <name> Provide a custom short name (2-4 words) for the branch
--number N Specify branch number manually (overrides auto-detection)
--timestamp Use timestamp prefix (YYYYMMDD-HHMMSS) instead of sequential numbering
--help, -h Show this help message
Environment variables:
GIT_BRANCH_NAME Use this exact branch name, bypassing all prefix/suffix generation
Configuration:
branch_template Optional git-config.yml template with {{author}}, {{app}}, {{number}}, {{slug}}
branch_prefix Optional shorthand namespace expanded before {{number}}-{{slug}}
Examples:
create_new_feature_branch.py 'Add user authentication system' --short-name 'user-auth'
create_new_feature_branch.py 'Implement OAuth2 integration for API' --number 5
create_new_feature_branch.py --timestamp --short-name 'user-auth' 'Add user authentication'
GIT_BRANCH_NAME=my-branch create_new_feature_branch.py 'feature description'
"""
STOP_WORDS = frozenset(
"i a an the to for of in on at by with from is are was were be been being "
"have has had do does did will would should could can may might must shall "
"this that these those my your our their want need add get set".split()
)
def _err(message: str) -> None:
print(message, file=sys.stderr)
def _persist_hint(var_name: str, value: str) -> str:
"""Shell-appropriate guidance for persisting an env var in the caller's shell."""
if os.name == "nt":
escaped_value = value.replace("'", "''")
return f"$env:{var_name} = '{escaped_value}'"
escaped_value = re.sub(r"([^\w@%+=:,./-])", r"\\\1", value)
return f"export {var_name}={escaped_value}"
@dataclass
class Args:
json_mode: bool = False
dry_run: bool = False
allow_existing: bool = False
short_name: str = ""
branch_number: str = ""
use_timestamp: bool = False
description_parts: list[str] = field(default_factory=list)
def parse_args(argv: list[str]) -> Args:
args = Args()
i = 0
while i < len(argv):
arg = argv[i]
if arg == "--json":
args.json_mode = True
elif arg == "--dry-run":
args.dry_run = True
elif arg == "--allow-existing-branch":
args.allow_existing = True
elif arg == "--short-name":
if i + 1 >= len(argv) or argv[i + 1].startswith("--"):
_err("Error: --short-name requires a value")
raise SystemExit(1)
i += 1
args.short_name = argv[i]
elif arg == "--number":
if i + 1 >= len(argv) or argv[i + 1].startswith("--"):
_err("Error: --number requires a value")
raise SystemExit(1)
i += 1
args.branch_number = argv[i]
if not re.fullmatch(r"[0-9]+", args.branch_number):
_err("Error: --number must be a non-negative integer")
raise SystemExit(1)
elif arg == "--timestamp":
args.use_timestamp = True
elif arg in ("--help", "-h"):
print(HELP_TEXT)
raise SystemExit(0)
else:
args.description_parts.append(arg)
i += 1
return args
# ── Core helpers loading ─────────────────────────────────────────────────────
def _find_project_root(start: Path) -> Path | None:
current = start
while True:
if (current / ".specify").is_dir() or (current / ".git").exists():
return current
if current.parent == current:
return None
current = current.parent
def _load_core_common(project_root: Path | None):
"""Load the core common.py from the project's installed scripts.
Search locations in priority order, mirroring the bash script:
1. .specify/scripts/python/common.py (installed project)
2. scripts/python/common.py (source checkout fallback)
Returns the loaded module or None.
"""
if project_root is None:
return None
for relative in (".specify/scripts/python/common.py", "scripts/python/common.py"):
candidate = project_root / relative
if candidate.is_file():
spec = importlib.util.spec_from_file_location("speckit_core_common", candidate)
if spec is None or spec.loader is None:
continue
module = importlib.util.module_from_spec(spec)
sys.modules[spec.name] = module
spec.loader.exec_module(module)
return module
return None
def _local_has_git(repo_root: Path) -> bool:
git_marker = repo_root / ".git"
if not (git_marker.is_dir() or git_marker.is_file()):
return False
if shutil.which("git") is None:
return False
return (
subprocess.run(
["git", "-C", str(repo_root), "rev-parse", "--is-inside-work-tree"],
capture_output=True,
text=True,
).returncode
== 0
)
# ── Numbering ────────────────────────────────────────────────────────────────
def get_highest_from_specs(specs_dir: Path) -> int:
highest = 0
if specs_dir.is_dir():
for entry in specs_dir.iterdir():
if not entry.is_dir():
continue
name = entry.name
# Match sequential prefixes (>=3 digits), but skip timestamp dirs.
if re.match(r"^[0-9]{3,}-", name) and not re.match(
r"^[0-9]{8}-[0-9]{6}-", name
):
number = int(re.match(r"^[0-9]+", name).group(0))
highest = max(highest, number)
return highest
def _extract_highest_number(names: list[str], scope_prefix: str) -> int:
"""Extract the highest sequential feature number from a list of ref names."""
highest = 0
for name in names:
if not name:
continue
if scope_prefix:
if not name.startswith(scope_prefix):
continue
name = name[len(scope_prefix) :]
name = name.rsplit("/", 1)[-1]
if (
re.match(r"^[0-9]{3,}-", name)
and not re.match(r"^[0-9]{8}-[0-9]{6}-", name)
and not re.match(r"^[0-9]{7}-[0-9]{6}-", name)
and not re.fullmatch(r"[0-9]{7,8}-[0-9]{6}", name)
):
match = re.match(r"^([0-9]{3,})-", name)
number = int(match.group(1)) if match else 0
highest = max(highest, number)
return highest
def _git_lines(repo_root: Path, *args: str, env_extra: dict | None = None) -> list[str]:
if shutil.which("git") is None:
return []
env = {**os.environ, **(env_extra or {})}
result = subprocess.run(
["git", *args], cwd=repo_root, capture_output=True, text=True, env=env
)
if result.returncode != 0:
return []
return result.stdout.splitlines()
def get_highest_from_branches(repo_root: Path, scope_prefix: str) -> int:
names = []
for line in _git_lines(repo_root, "branch", "-a"):
line = re.sub(r"^[+*]\s+", "", line)
line = line.lstrip()
line = re.sub(r"^remotes/[^/]*/", "", line)
names.append(line)
return _extract_highest_number(names, scope_prefix)
def get_highest_from_remote_refs(repo_root: Path, scope_prefix: str) -> int:
"""Highest number from remote branches without fetching (side-effect-free)."""
highest = 0
for remote in _git_lines(repo_root, "remote"):
refs = _git_lines(
repo_root,
"ls-remote",
"--heads",
remote,
env_extra={"GIT_TERMINAL_PROMPT": "0"},
)
names = [re.sub(r".*refs/heads/", "", ref) for ref in refs]
highest = max(highest, _extract_highest_number(names, scope_prefix))
return highest
def check_existing_branches(
repo_root: Path, specs_dir: Path, skip_fetch: bool, scope_prefix: str
) -> int:
"""Check existing branches and return the next available number."""
if skip_fetch:
highest_branch = max(
get_highest_from_remote_refs(repo_root, scope_prefix),
get_highest_from_branches(repo_root, scope_prefix),
)
else:
subprocess.run(
["git", "fetch", "--all", "--prune"],
cwd=repo_root,
capture_output=True,
text=True,
)
highest_branch = get_highest_from_branches(repo_root, scope_prefix)
return max(highest_branch, get_highest_from_specs(specs_dir)) + 1
# ── Branch naming ────────────────────────────────────────────────────────────
def clean_branch_name(name: str) -> str:
name = re.sub(r"[^a-z0-9]", "-", name.lower())
name = re.sub(r"-+", "-", name)
return name.strip("-")
def generate_branch_name(description: str) -> str:
"""Generate a branch suffix from the description with stop word filtering."""
clean_name = re.sub(r"[^a-z0-9]", " ", description.lower())
meaningful_words = []
for word in clean_name.split():
if word in STOP_WORDS:
continue
if len(word) >= 3:
meaningful_words.append(word)
# Keep short words only when they appear uppercased in the original
# description (acronyms like "API" or "DB").
elif re.search(rf"\b{re.escape(word.upper())}\b", description):
meaningful_words.append(word)
if meaningful_words:
max_words = 4 if len(meaningful_words) == 4 else 3
return "-".join(meaningful_words[:max_words])
cleaned = clean_branch_name(description)
return "-".join([part for part in cleaned.split("-") if part][:3])
def branch_token(value: str, fallback: str) -> str:
cleaned = clean_branch_name(value)
return cleaned if cleaned else fallback
def get_author_token(repo_root: Path) -> str:
author = ""
if shutil.which("git") is not None:
lines = _git_lines(repo_root, "config", "user.name")
author = lines[0] if lines else ""
if not author:
lines = _git_lines(repo_root, "config", "user.email")
email = lines[0] if lines else ""
author = email.split("@")[0]
if not author:
author = os.environ.get("USER") or os.environ.get("USERNAME") or "unknown"
return branch_token(author, "unknown")
def get_app_token(repo_root: Path) -> str:
return branch_token(repo_root.name, "app")
def read_git_config_value(config_file: Path, key: str) -> str:
if not config_file.is_file():
return ""
try:
lines = config_file.read_text(encoding="utf-8").splitlines()
except (OSError, UnicodeDecodeError):
return ""
for line in lines:
if re.match(rf"^\s*{re.escape(key)}:", line):
value = re.sub(rf"^\s*{re.escape(key)}:\s*", "", line)
value = re.sub(r"\s+#.*$", "", value)
value = value.strip()
value = re.sub(r'^"|"$', "", value)
value = re.sub(r"^'|'$", "", value)
return value
return ""
def resolve_branch_template(config_file: Path) -> str:
template = read_git_config_value(config_file, "branch_template")
if template:
return template
prefix = read_git_config_value(config_file, "branch_prefix")
if not prefix:
return ""
if prefix.endswith("/"):
return f"{prefix}{{number}}-{{slug}}"
return f"{prefix}/{{number}}-{{slug}}"
def validate_branch_template(template: str) -> None:
if not template:
return
if "{number}" not in template:
_err(
"Error: branch_template must include the {number} token so generated "
"branches remain valid feature branches."
)
raise SystemExit(1)
slug_index = template.find("{slug}")
if slug_index != -1 and "{number}" in template[slug_index:]:
_err(
"Error: branch_template must not place {slug} before {number}; "
"use {slug} only in the final feature segment."
)
raise SystemExit(1)
feature_segment = template.rsplit("/", 1)[-1]
if not feature_segment.startswith("{number}-"):
_err(
"Error: branch_template must put {number}- at the start of the final "
"path segment so generated branches remain valid feature branches."
)
raise SystemExit(1)
def render_branch_template(
template: str, feature_num: str, branch_suffix: str, author_token: str, app_token: str
) -> str:
rendered = template
rendered = rendered.replace("{author}", author_token)
rendered = rendered.replace("{app}", app_token)
rendered = rendered.replace("{number}", feature_num)
rendered = rendered.replace("{slug}", branch_suffix)
return rendered
def extract_feature_num_from_branch(branch_name: str) -> str:
feature_segment = branch_name.rsplit("/", 1)[-1]
match = re.match(r"^[0-9]{8}-[0-9]{6}-", feature_segment)
if match:
return match.group(0).rstrip("-")
match = re.match(r"^[0-9]+-", feature_segment)
if match:
return match.group(0).rstrip("-")
return branch_name
def _byte_length(value: str) -> int:
return len(value.encode("utf-8"))
# ── Main ─────────────────────────────────────────────────────────────────────
def main(argv: list[str]) -> int:
args = parse_args(argv)
feature_description = " ".join(args.description_parts)
if not feature_description:
_err(USAGE)
return 1
feature_description = feature_description.strip()
if not feature_description:
_err("Error: Feature description cannot be empty or contain only whitespace")
return 1
project_root = _find_project_root(SCRIPT_DIR)
core = _load_core_common(project_root)
# SPECIFY_INIT_DIR is resolved (and validated) by the core resolver. If the
# core helpers were not found, refuse rather than silently falling back to
# the wrong root.
if os.environ.get("SPECIFY_INIT_DIR") and (
core is None or not hasattr(core, "resolve_specify_init_dir")
):
_err(
"Error: SPECIFY_INIT_DIR requires updated Spec Kit core scripts "
"(common.py with resolve_specify_init_dir), which were not found."
)
return 1
if core is not None and hasattr(core, "get_repo_root"):
# Pass script path so cwd-outside-repo callers land on the same
# fallback the bash twin does. Older cores don't accept the kwarg —
# fall back to the no-arg call for compatibility.
try:
repo_root = core.get_repo_root(script_file=Path(__file__))
except TypeError:
repo_root = core.get_repo_root()
else:
toplevel = _git_lines(Path.cwd(), "rev-parse", "--show-toplevel")
if toplevel:
repo_root = Path(toplevel[0])
elif project_root is not None:
repo_root = project_root
else:
_err("Error: Could not determine repository root.")
return 1
repo_root = Path(repo_root)
has_git_repo = _local_has_git(repo_root)
specs_dir = repo_root / "specs"
config_file = repo_root / ".specify" / "extensions" / "git" / "git-config.yml"
author_token = get_author_token(repo_root)
app_token = get_app_token(repo_root)
branch_template = resolve_branch_template(config_file)
validate_branch_template(branch_template)
def build_branch_name(feature_num: str, branch_suffix: str) -> str:
if branch_template:
return render_branch_template(
branch_template, feature_num, branch_suffix, author_token, app_token
)
return f"{feature_num}-{branch_suffix}"
branch_number = args.branch_number
# Check for GIT_BRANCH_NAME env var override (exact name, no prefix/suffix)
env_branch_name = os.environ.get("GIT_BRANCH_NAME", "")
if env_branch_name:
branch_name = env_branch_name
feature_num = extract_feature_num_from_branch(branch_name)
branch_suffix = branch_name
else:
if args.short_name:
branch_suffix = clean_branch_name(args.short_name)
else:
branch_suffix = generate_branch_name(feature_description)
if args.use_timestamp and branch_number:
_err("[specify] Warning: --number is ignored when --timestamp is used")
branch_number = ""
if args.use_timestamp:
feature_num = datetime.now().strftime("%Y%m%d-%H%M%S")
branch_name = build_branch_name(feature_num, branch_suffix)
else:
scope_prefix = ""
if branch_template:
prefix_template = branch_template.split("{number}")[0]
scope_prefix = render_branch_template(
prefix_template, "", branch_suffix, author_token, app_token
)
if not branch_number:
if args.dry_run and has_git_repo:
branch_number = check_existing_branches(
repo_root, specs_dir, True, scope_prefix
)
elif args.dry_run:
branch_number = get_highest_from_specs(specs_dir) + 1
elif has_git_repo:
branch_number = check_existing_branches(
repo_root, specs_dir, False, scope_prefix
)
else:
branch_number = get_highest_from_specs(specs_dir) + 1
feature_num = f"{int(branch_number):03d}"
branch_name = build_branch_name(feature_num, branch_suffix)
branch_byte_len = _byte_length(branch_name)
if env_branch_name and branch_byte_len > MAX_BRANCH_LENGTH:
_err(
"Error: GIT_BRANCH_NAME must be 244 bytes or fewer in UTF-8. "
f"Provided value is {branch_byte_len} bytes."
)
return 1
if branch_byte_len > MAX_BRANCH_LENGTH:
original_branch_name = branch_name
truncated_suffix = branch_suffix
while _byte_length(branch_name) > MAX_BRANCH_LENGTH and truncated_suffix:
truncated_suffix = truncated_suffix[:-1]
truncated_suffix = truncated_suffix.rstrip("-")
branch_name = build_branch_name(feature_num, truncated_suffix)
if _byte_length(branch_name) > MAX_BRANCH_LENGTH:
_err("Error: Branch template prefix exceeds GitHub's 244-byte branch name limit.")
return 1
_err("[specify] Warning: Branch name exceeded GitHub's 244-byte limit")
_err(
f"[specify] Original: {original_branch_name} "
f"({_byte_length(original_branch_name)} bytes)"
)
_err(f"[specify] Truncated to: {branch_name} ({_byte_length(branch_name)} bytes)")
if not args.dry_run:
if has_git_repo:
create = subprocess.run(
["git", "checkout", "-q", "-b", branch_name],
cwd=repo_root,
capture_output=True,
text=True,
)
if create.returncode != 0:
current_branch_lines = _git_lines(
repo_root, "rev-parse", "--abbrev-ref", "HEAD"
)
current_branch = current_branch_lines[0] if current_branch_lines else ""
branch_exists = bool(
_git_lines(repo_root, "branch", "--list", branch_name)
)
if branch_exists:
if args.allow_existing:
if current_branch != branch_name:
switch = subprocess.run(
["git", "checkout", "-q", branch_name],
cwd=repo_root,
capture_output=True,
text=True,
)
if switch.returncode != 0:
_err(
f"Error: Failed to switch to existing branch '{branch_name}'. "
"Please resolve any local changes or conflicts and try again."
)
if switch.stderr.strip():
_err(switch.stderr.strip())
return 1
elif args.use_timestamp:
_err(
f"Error: Branch '{branch_name}' already exists. Rerun to get "
"a new timestamp or use a different --short-name."
)
return 1
else:
_err(
f"Error: Branch '{branch_name}' already exists. Please use a "
"different feature name or specify a different number with --number."
)
return 1
else:
_err(f"Error: Failed to create git branch '{branch_name}'.")
if create.stderr.strip():
_err(create.stderr.strip())
else:
_err("Please check your git configuration and try again.")
return 1
else:
_err(
"[specify] Warning: Git repository not detected; skipped branch "
f"creation for {branch_name}"
)
_err(f"# To persist: {_persist_hint('SPECIFY_FEATURE', branch_name)}")
if args.json_mode:
payload: dict[str, object] = {
"BRANCH_NAME": branch_name,
"FEATURE_NUM": feature_num,
}
if args.dry_run:
payload["DRY_RUN"] = True
print(json.dumps(payload, ensure_ascii=False, separators=(",", ":")))
else:
print(f"BRANCH_NAME: {branch_name}")
print(f"FEATURE_NUM: {feature_num}")
if not args.dry_run:
print(
"# To persist in your shell: "
f"{_persist_hint('SPECIFY_FEATURE', branch_name)}"
)
return 0
if __name__ == "__main__":
raise SystemExit(main(sys.argv[1:]))
@@ -1,81 +0,0 @@
#!/usr/bin/env python3
"""Git-specific common helpers for the git extension.
Python port of ``git-common.sh`` / ``git-common.ps1`` — contains only
git-specific branch validation and detection logic.
"""
from __future__ import annotations
import re
import shutil
import subprocess
import sys
from pathlib import Path
def has_git(repo_root: Path | None = None) -> bool:
"""Check if we have git available at the repo root."""
root = Path(repo_root) if repo_root is not None else Path.cwd()
git_marker = root / ".git"
if not (git_marker.is_dir() or git_marker.is_file()):
return False
if shutil.which("git") is None:
return False
result = subprocess.run(
["git", "-C", str(root), "rev-parse", "--is-inside-work-tree"],
capture_output=True,
text=True,
)
return result.returncode == 0
def effective_branch_name(raw: str) -> str:
"""Strip a single optional path segment (e.g. gitflow "feat/004-name" -> "004-name").
Only when the full name is exactly two slash-free segments; otherwise
returns the raw name.
"""
match = re.fullmatch(r"([^/]+)/([^/]+)", raw)
if match:
return match.group(2)
return raw
def check_feature_branch(raw: str, has_git_repo: bool) -> bool:
"""Validate that a branch name matches the expected feature branch pattern.
Accepts sequential (###-* with >=3 digits) or timestamp (YYYYMMDD-HHMMSS-*)
formats, either at the start of the branch or after path-style namespace
prefixes. Logic aligned with the bash/PowerShell twins.
"""
if not has_git_repo:
print(
"[specify] Warning: Git repository not detected; skipped branch validation",
file=sys.stderr,
)
return True
branch = effective_branch_name(raw)
feature_segment = branch.rsplit("/", 1)[-1]
# Accept sequential prefix (3+ digits) but exclude malformed timestamps:
# 7-or-8 digit date + 6-digit time with no trailing slug.
is_sequential = bool(
re.match(r"^[0-9]{3,}-", feature_segment)
and not re.match(r"^[0-9]{7}-[0-9]{6}-", feature_segment)
and not re.fullmatch(r"[0-9]{7,8}-[0-9]{6}", feature_segment)
)
is_timestamp = bool(re.match(r"^[0-9]{8}-[0-9]{6}-", feature_segment))
if not is_sequential and not is_timestamp:
print(f"ERROR: Not on a feature branch. Current branch: {raw}", file=sys.stderr)
print(
"Feature branches should be named like: 001-feature-name, "
"1234-feature-name, 20260319-143022-feature-name, or "
"<prefix>/001-feature-name",
file=sys.stderr,
)
return False
return True
@@ -1,89 +0,0 @@
#!/usr/bin/env python3
"""Git extension: initialize_repo.py
Initialize a Git repository with an initial commit.
Python port of ``initialize-repo.sh`` / ``initialize-repo.ps1``.
Customizable — replace this script to add .gitignore templates,
default branch config, git-flow, LFS, signing, etc.
"""
from __future__ import annotations
import re
import shutil
import subprocess
import sys
from pathlib import Path
def _find_project_root(start: Path) -> Path | None:
current = start
while True:
if (current / ".specify").is_dir() or (current / ".git").exists():
return current
if current.parent == current:
return None
current = current.parent
def _read_commit_message(repo_root: Path) -> str:
"""Read init_commit_message from git-config.yml, mirroring the bash sed pipeline."""
default = "[Spec Kit] Initial commit"
config_file = repo_root / ".specify" / "extensions" / "git" / "git-config.yml"
if not config_file.is_file():
return default
try:
lines = config_file.read_text(encoding="utf-8").splitlines()
except (OSError, UnicodeDecodeError):
return default
for line in lines:
if line.startswith("init_commit_message:"):
value = re.sub(r"^init_commit_message:\s*", "", line)
value = re.sub(r"^[\"']", "", value)
value = re.sub(r"[\"']*$", "", value)
if value:
return value
return default
def main() -> int:
script_dir = Path(__file__).resolve().parent
repo_root = _find_project_root(script_dir) or Path.cwd()
commit_msg = _read_commit_message(repo_root)
if shutil.which("git") is None:
print(
"[specify] Warning: Git not found; skipped repository initialization",
file=sys.stderr,
)
return 0
probe = subprocess.run(
["git", "rev-parse", "--is-inside-work-tree"],
cwd=repo_root,
capture_output=True,
text=True,
)
if probe.returncode == 0:
print("[specify] Git repository already initialized; skipping", file=sys.stderr)
return 0
steps = [
(["git", "init", "-q"], "git init"),
(["git", "add", "."], "git add"),
(["git", "commit", "--allow-empty", "-q", "-m", commit_msg], "git commit"),
]
for cmd, label in steps:
result = subprocess.run(cmd, cwd=repo_root, capture_output=True, text=True)
if result.returncode != 0:
output = (result.stdout + result.stderr).strip()
print(f"[specify] Error: {label} failed: {output}", file=sys.stderr)
return 1
print("[OK] Git repository initialized", file=sys.stderr)
return 0
if __name__ == "__main__":
raise SystemExit(main())
+1 -1
View File
@@ -48,7 +48,7 @@ cat .specify/extensions/.registry/$ARGUMENTS.json
### Step 4: Verification Report ### Step 4: Verification Report
Analyze the standard output of the three steps. Analyze the standard output of the three steps.
Generate a terminal-style test output format detailing the results of discovery, installation, and registration. Return this directly to the user. Generate a terminal-style test output format detailing the results of discovery, installation, and registration. Return this directly to the user.
Example output format: Example output format:
+2
View File
@@ -76,3 +76,5 @@ Areas under discussion or in progress for future development:
- **Continued agent expansion** -- seven new agents were added in March alone. The agent-agnostic design means support for emerging tools can be added by anyone. [\[byteiota.com\]](https://byteiota.com/spec-driven-development-kills-vibe-coding-march-2026/) - **Continued agent expansion** -- seven new agents were added in March alone. The agent-agnostic design means support for emerging tools can be added by anyone. [\[byteiota.com\]](https://byteiota.com/spec-driven-development-kills-vibe-coding-march-2026/)
- **Experience simplification** -- the preset system, custom workflows, and growing walkthrough library lower the learning curve, but extension discoverability will need a more robust solution as the catalog grows. [\[github.com\]](https://github.com/github/spec-kit/releases) - **Experience simplification** -- the preset system, custom workflows, and growing walkthrough library lower the learning curve, but extension discoverability will need a more robust solution as the catalog grows. [\[github.com\]](https://github.com/github/spec-kit/releases)
- **Toward a stable release** -- nine releases in one month reflects pre-1.0 momentum. Reaching 1.0 will require stabilizing the extension and preset APIs and ensuring backward compatibility across the agent and extension surface area. [\[github.com\]](https://github.com/github/spec-kit/blob/main/newsletters/2026-February.md) - **Toward a stable release** -- nine releases in one month reflects pre-1.0 momentum. Reaching 1.0 will require stabilizing the extension and preset APIs and ensuring backward compatibility across the agent and extension surface area. [\[github.com\]](https://github.com/github/spec-kit/blob/main/newsletters/2026-February.md)
+2 -1
View File
@@ -158,7 +158,8 @@ presets/
├── plan-template.md ├── plan-template.md
├── tasks-template.md ├── tasks-template.md
├── checklist-template.md ├── checklist-template.md
└── constitution-template.md ├── constitution-template.md
└── agent-file-template.md
``` ```
## Module Structure ## Module Structure
+1 -58
View File
@@ -1,6 +1,6 @@
{ {
"schema_version": "1.0", "schema_version": "1.0",
"updated_at": "2026-07-14T00:00:00Z", "updated_at": "2026-06-30T00:00:00Z",
"catalog_url": "https://raw.githubusercontent.com/github/spec-kit/main/presets/catalog.community.json", "catalog_url": "https://raw.githubusercontent.com/github/spec-kit/main/presets/catalog.community.json",
"presets": { "presets": {
"a11y-governance": { "a11y-governance": {
@@ -131,35 +131,6 @@
"created_at": "2026-04-27T00:00:00Z", "created_at": "2026-04-27T00:00:00Z",
"updated_at": "2026-06-14T00:00:00Z" "updated_at": "2026-06-14T00:00:00Z"
}, },
"autonomous-run-governance": {
"name": "Autonomous Run Governance",
"id": "autonomous-run-governance",
"version": "0.1.4",
"description": "Adds permission-bounded, evidence-first governance for autonomous Spec Kit delivery, convergence, resume, closeout, and retrospective learning.",
"author": "Thorsten Hindermann",
"repository": "https://github.com/hindermath/spec-kit-preset-autonomous-run-governance",
"download_url": "https://github.com/hindermath/spec-kit-preset-autonomous-run-governance/archive/refs/tags/v0.1.4.zip",
"homepage": "https://github.com/hindermath/spec-kit-preset-autonomous-run-governance",
"documentation": "https://github.com/hindermath/spec-kit-preset-autonomous-run-governance/blob/v0.1.4/README.md",
"license": "MIT",
"requires": {
"speckit_version": ">=0.8.3"
},
"provides": {
"templates": 12,
"commands": 2,
"scripts": 2
},
"tags": [
"autonomous",
"governance",
"evidence",
"permissions",
"retrospective"
],
"created_at": "2026-07-13T00:00:00Z",
"updated_at": "2026-07-14T00:00:00Z"
},
"canon-core": { "canon-core": {
"name": "Canon Core", "name": "Canon Core",
"id": "canon-core", "id": "canon-core",
@@ -647,34 +618,6 @@
"created_at": "2026-04-30T00:00:00Z", "created_at": "2026-04-30T00:00:00Z",
"updated_at": "2026-04-30T00:00:00Z" "updated_at": "2026-04-30T00:00:00Z"
}, },
"test-first-governance": {
"name": "Test-First Governance",
"id": "test-first-governance",
"version": "1.3.0",
"description": "Governs TDD with coverage-complete BDD/ATDD Gherkin scenarios, explicit suite ownership, professional test reports, traceability, and risk-based quality gates.",
"author": "Zoltán Katona, PhD",
"repository": "https://github.com/ka-zo/spec-kit-preset-test-first-governance",
"download_url": "https://github.com/ka-zo/spec-kit-preset-test-first-governance/archive/refs/tags/1.3.0.zip",
"homepage": "https://github.com/ka-zo/spec-kit-preset-test-first-governance",
"documentation": "https://github.com/ka-zo/spec-kit-preset-test-first-governance/blob/main/README.md",
"license": "MIT",
"requires": {
"speckit_version": ">=0.12.11"
},
"provides": {
"templates": 10,
"commands": 8
},
"tags": [
"tdd",
"bdd",
"atdd",
"quality-gates",
"traceability"
],
"created_at": "2026-07-13T00:00:00Z",
"updated_at": "2026-07-13T00:00:00Z"
},
"toc-navigation": { "toc-navigation": {
"name": "Table of Contents Navigation", "name": "Table of Contents Navigation",
"id": "toc-navigation", "id": "toc-navigation",
+6
View File
@@ -44,6 +44,12 @@ provides:
description: "Self-test constitution template" description: "Self-test constitution template"
replaces: "constitution-template" replaces: "constitution-template"
- type: "template"
name: "agent-file-template"
file: "templates/agent-file-template.md"
description: "Self-test agent file template"
replaces: "agent-file-template"
- type: "command" - type: "command"
name: "speckit.specify" name: "speckit.specify"
file: "commands/speckit.specify.md" file: "commands/speckit.specify.md"
@@ -0,0 +1,9 @@
# Agent File (Self-Test Preset)
<!-- preset:self-test -->
> This template is provided by the self-test preset.
## Agent Instructions
Follow these guidelines when working on this project.
+2 -1
View File
@@ -1,6 +1,6 @@
[project] [project]
name = "specify-cli" name = "specify-cli"
version = "0.12.16" version = "0.12.8"
description = "Specify CLI, part of GitHub Spec Kit. A tool to bootstrap your projects for Spec-Driven Development (SDD)." description = "Specify CLI, part of GitHub Spec Kit. A tool to bootstrap your projects for Spec-Driven Development (SDD)."
readme = "README.md" readme = "README.md"
requires-python = ">=3.11" requires-python = ">=3.11"
@@ -83,3 +83,4 @@ extend-select = [
"S604", # call-with-shell-equals-true "S604", # call-with-shell-equals-true
"S605", # start-process-with-a-shell "S605", # start-process-with-a-shell
] ]
+5 -5
View File
@@ -57,13 +57,13 @@ OPTIONS:
EXAMPLES: EXAMPLES:
# Check task prerequisites (plan.md required) # Check task prerequisites (plan.md required)
./check-prerequisites.sh --json ./check-prerequisites.sh --json
# Check implementation prerequisites (plan.md + tasks.md required) # Check implementation prerequisites (plan.md + tasks.md required)
./check-prerequisites.sh --json --require-tasks --include-tasks ./check-prerequisites.sh --json --require-tasks --include-tasks
# Get feature paths only (no validation) # Get feature paths only (no validation)
./check-prerequisites.sh --paths-only ./check-prerequisites.sh --paths-only
EOF EOF
exit 0 exit 0
;; ;;
@@ -182,13 +182,13 @@ else
# Text output # Text output
echo "FEATURE_DIR:$FEATURE_DIR" echo "FEATURE_DIR:$FEATURE_DIR"
echo "AVAILABLE_DOCS:" echo "AVAILABLE_DOCS:"
# Show status of each potential document # Show status of each potential document
check_file "$RESEARCH" "research.md" check_file "$RESEARCH" "research.md"
check_file "$DATA_MODEL" "data-model.md" check_file "$DATA_MODEL" "data-model.md"
check_dir "$CONTRACTS_DIR" "contracts/" check_dir "$CONTRACTS_DIR" "contracts/"
check_file "$QUICKSTART" "quickstart.md" check_file "$QUICKSTART" "quickstart.md"
if $INCLUDE_TASKS; then if $INCLUDE_TASKS; then
check_file "$TASKS" "tasks.md" check_file "$TASKS" "tasks.md"
fi fi
+11 -66
View File
@@ -244,29 +244,21 @@ get_invoke_separator() {
local integration_json="$repo_root/.specify/integration.json" local integration_json="$repo_root/.specify/integration.json"
local separator="." local separator="."
local parsed=0 local parsed_with_jq=0
if [[ -f "$integration_json" ]]; then if [[ -f "$integration_json" ]]; then
# Try parsers in order (jq -> python3 -> awk), falling through on
# failure. Selection is by *parse success*, not mere availability: on
# Windows `python3` commonly resolves to the Microsoft Store App
# Execution Alias stub, which passes `command -v` but fails at runtime
# (exit 49). An availability-gated branch would pick python3, swallow
# its failure, and — because this function historically had no text
# fallback — silently return "." even for `-`-separator integrations
# (e.g. forge, cline), yielding wrong command hints (issue #3304).
if command -v jq >/dev/null 2>&1; then if command -v jq >/dev/null 2>&1; then
local jq_separator local jq_separator
if jq_separator=$(jq -r '(.default_integration // .integration // "") as $k | if $k == "" then "." else (.integration_settings[$k].invoke_separator // ".") end' "$integration_json" 2>/dev/null); then if jq_separator=$(jq -r '(.default_integration // .integration // "") as $k | if $k == "" then "." else (.integration_settings[$k].invoke_separator // ".") end' "$integration_json" 2>/dev/null); then
parsed_with_jq=1
case "$jq_separator" in case "$jq_separator" in
"."|"-") separator="$jq_separator"; parsed=1 ;; "."|"-") separator="$jq_separator" ;;
esac esac
fi fi
fi fi
if [[ "$parsed" -eq 0 ]] && command -v python3 >/dev/null 2>&1; then if [[ "$parsed_with_jq" -eq 0 ]] && command -v python3 >/dev/null 2>&1; then
local py_separator if separator=$(python3 - "$integration_json" <<'PY' 2>/dev/null
if py_separator=$(python3 - "$integration_json" <<'PY' 2>/dev/null
import json import json
import sys import sys
@@ -282,64 +274,17 @@ try:
separator = entry["invoke_separator"] separator = entry["invoke_separator"]
print(separator) print(separator)
except Exception: except Exception:
sys.exit(1) print(".")
PY PY
); then ); then
case "$py_separator" in case "$separator" in
"."|"-") separator="$py_separator"; parsed=1 ;; "."|"-") ;;
*) separator="." ;;
esac esac
else
separator="."
fi fi
fi fi
if [[ "$parsed" -eq 0 ]]; then
# Last-resort text fallback for environments with neither jq nor a
# working python3 (e.g. stock Windows + Git Bash). Reads the active
# integration key (default_integration, else integration) and its
# invoke_separator from within the integration_settings object.
# Handles both pretty-printed (the written form) and compact JSON.
# Accumulate all lines into one buffer in END rather than using
# gawk-only whole-file slurp (RS="^$"), so this stays portable to
# the BSD awk on macOS.
local awk_separator
awk_separator=$(awk '
function keyval(d, name, v) {
if (match(d, "\"" name "\"[ \t\r\n]*:[ \t\r\n]*\"[^\"]*\"")) {
v=substr(d,RSTART,RLENGTH); sub(/^.*:[ \t\r\n]*"/,"",v); sub(/"$/,"",v); return v
}
return ""
}
{ doc = doc $0 "\n" }
END {
key=keyval(doc,"default_integration"); if (key=="") key=keyval(doc,"integration")
sep="."
if (key!="") {
settings=doc
if (match(doc, /"integration_settings"[ \t\r\n]*:[ \t\r\n]*[{]/)) {
settings=substr(doc, RSTART+RLENGTH-1)
}
if (match(settings, "\"" key "\"[ \t\r\n]*:[ \t\r\n]*[{]")) {
start=RSTART+RLENGTH-1
depth=0
obj=""
for (i=start; i<=length(settings); i++) {
c=substr(settings,i,1)
obj=obj c
if (c=="{") depth++
else if (c=="}") { depth--; if (depth==0) break }
}
if (match(obj, /"invoke_separator"[ \t\r\n]*:[ \t\r\n]*"[-.]"/)) {
tok=substr(obj,RSTART,RLENGTH); s=substr(tok,length(tok)-1,1)
if (s=="." || s=="-") sep=s
}
}
}
print sep
}
' "$integration_json" 2>/dev/null)
case "$awk_separator" in
"."|"-") separator="$awk_separator" ;;
esac
fi
fi fi
_SPECIFY_INVOKE_SEPARATOR_CACHE_REPO_ROOT="$repo_root" _SPECIFY_INVOKE_SEPARATOR_CACHE_REPO_ROOT="$repo_root"
+11 -11
View File
@@ -94,7 +94,7 @@ fi
get_highest_from_specs() { get_highest_from_specs() {
local specs_dir="$1" local specs_dir="$1"
local highest=0 local highest=0
if [ -d "$specs_dir" ]; then if [ -d "$specs_dir" ]; then
for dir in "$specs_dir"/*; do for dir in "$specs_dir"/*; do
[ -d "$dir" ] || continue [ -d "$dir" ] || continue
@@ -109,7 +109,7 @@ get_highest_from_specs() {
fi fi
done done
fi fi
echo "$highest" echo "$highest"
} }
@@ -135,19 +135,19 @@ fi
# Function to generate branch name with stop word filtering and length filtering # Function to generate branch name with stop word filtering and length filtering
generate_branch_name() { generate_branch_name() {
local description="$1" local description="$1"
# Common stop words to filter out # Common stop words to filter out
local stop_words="^(i|a|an|the|to|for|of|in|on|at|by|with|from|is|are|was|were|be|been|being|have|has|had|do|does|did|will|would|should|could|can|may|might|must|shall|this|that|these|those|my|your|our|their|want|need|add|get|set)$" local stop_words="^(i|a|an|the|to|for|of|in|on|at|by|with|from|is|are|was|were|be|been|being|have|has|had|do|does|did|will|would|should|could|can|may|might|must|shall|this|that|these|those|my|your|our|their|want|need|add|get|set)$"
# Convert to lowercase and split into words # Convert to lowercase and split into words
local clean_name=$(printf '%s' "$description" | tr '[:upper:]' '[:lower:]' | sed 's/[^a-z0-9]/ /g') local clean_name=$(printf '%s' "$description" | tr '[:upper:]' '[:lower:]' | sed 's/[^a-z0-9]/ /g')
# Filter words: remove stop words and words shorter than 3 chars (unless they're uppercase acronyms in original) # Filter words: remove stop words and words shorter than 3 chars (unless they're uppercase acronyms in original)
local meaningful_words=() local meaningful_words=()
for word in $clean_name; do for word in $clean_name; do
# Skip empty words # Skip empty words
[ -z "$word" ] && continue [ -z "$word" ] && continue
# Keep words that are NOT stop words AND (length >= 3 OR are potential acronyms) # Keep words that are NOT stop words AND (length >= 3 OR are potential acronyms)
if ! echo "$word" | grep -qiE "$stop_words"; then if ! echo "$word" | grep -qiE "$stop_words"; then
if [ ${#word} -ge 3 ]; then if [ ${#word} -ge 3 ]; then
@@ -160,12 +160,12 @@ generate_branch_name() {
fi fi
fi fi
done done
# If we have meaningful words, use first 3-4 of them # If we have meaningful words, use first 3-4 of them
if [ ${#meaningful_words[@]} -gt 0 ]; then if [ ${#meaningful_words[@]} -gt 0 ]; then
local max_words=3 local max_words=3
if [ ${#meaningful_words[@]} -eq 4 ]; then max_words=4; fi if [ ${#meaningful_words[@]} -eq 4 ]; then max_words=4; fi
local result="" local result=""
local count=0 local count=0
for word in "${meaningful_words[@]}"; do for word in "${meaningful_words[@]}"; do
@@ -221,15 +221,15 @@ if [ ${#BRANCH_NAME} -gt $MAX_BRANCH_LENGTH ]; then
# Account for prefix length: timestamp (15) + hyphen (1) = 16, or sequential (3) + hyphen (1) = 4 # Account for prefix length: timestamp (15) + hyphen (1) = 16, or sequential (3) + hyphen (1) = 4
PREFIX_LENGTH=$(( ${#FEATURE_NUM} + 1 )) PREFIX_LENGTH=$(( ${#FEATURE_NUM} + 1 ))
MAX_SUFFIX_LENGTH=$((MAX_BRANCH_LENGTH - PREFIX_LENGTH)) MAX_SUFFIX_LENGTH=$((MAX_BRANCH_LENGTH - PREFIX_LENGTH))
# Truncate suffix at word boundary if possible # Truncate suffix at word boundary if possible
TRUNCATED_SUFFIX=$(echo "$BRANCH_SUFFIX" | cut -c1-$MAX_SUFFIX_LENGTH) TRUNCATED_SUFFIX=$(echo "$BRANCH_SUFFIX" | cut -c1-$MAX_SUFFIX_LENGTH)
# Remove trailing hyphen if truncation created one # Remove trailing hyphen if truncation created one
TRUNCATED_SUFFIX=$(echo "$TRUNCATED_SUFFIX" | sed 's/-$//') TRUNCATED_SUFFIX=$(echo "$TRUNCATED_SUFFIX" | sed 's/-$//')
ORIGINAL_BRANCH_NAME="$BRANCH_NAME" ORIGINAL_BRANCH_NAME="$BRANCH_NAME"
BRANCH_NAME="${FEATURE_NUM}-${TRUNCATED_SUFFIX}" BRANCH_NAME="${FEATURE_NUM}-${TRUNCATED_SUFFIX}"
>&2 echo "[specify] Warning: Branch name exceeded GitHub's 244-byte limit" >&2 echo "[specify] Warning: Branch name exceeded GitHub's 244-byte limit"
>&2 echo "[specify] Original: $ORIGINAL_BRANCH_NAME (${#ORIGINAL_BRANCH_NAME} bytes)" >&2 echo "[specify] Original: $ORIGINAL_BRANCH_NAME (${#ORIGINAL_BRANCH_NAME} bytes)"
>&2 echo "[specify] Truncated to: $BRANCH_NAME (${#BRANCH_NAME} bytes)" >&2 echo "[specify] Truncated to: $BRANCH_NAME (${#BRANCH_NAME} bytes)"
+8 -7
View File
@@ -8,17 +8,17 @@ ARGS=()
for arg in "$@"; do for arg in "$@"; do
case "$arg" in case "$arg" in
--json) --json)
JSON_MODE=true JSON_MODE=true
;; ;;
--help|-h) --help|-h)
echo "Usage: $0 [--json]" echo "Usage: $0 [--json]"
echo " --json Output results in JSON format" echo " --json Output results in JSON format"
echo " --help Show this help message" echo " --help Show this help message"
exit 0 exit 0
;; ;;
*) *)
ARGS+=("$arg") ARGS+=("$arg")
;; ;;
esac esac
done done
@@ -77,7 +77,8 @@ if $JSON_MODE; then
fi fi
else else
echo "FEATURE_SPEC: $FEATURE_SPEC" echo "FEATURE_SPEC: $FEATURE_SPEC"
echo "IMPL_PLAN: $IMPL_PLAN" echo "IMPL_PLAN: $IMPL_PLAN"
echo "SPECS_DIR: $FEATURE_DIR" echo "SPECS_DIR: $FEATURE_DIR"
echo "BRANCH: $CURRENT_BRANCH" echo "BRANCH: $CURRENT_BRANCH"
fi fi
+10 -10
View File
@@ -42,10 +42,10 @@ OPTIONS:
EXAMPLES: EXAMPLES:
# Check task prerequisites (plan.md required) # Check task prerequisites (plan.md required)
.\check-prerequisites.ps1 -Json .\check-prerequisites.ps1 -Json
# Check implementation prerequisites (plan.md + tasks.md required) # Check implementation prerequisites (plan.md + tasks.md required)
.\check-prerequisites.ps1 -Json -RequireTasks -IncludeTasks .\check-prerequisites.ps1 -Json -RequireTasks -IncludeTasks
# Get feature paths only (no validation) # Get feature paths only (no validation)
.\check-prerequisites.ps1 -PathsOnly .\check-prerequisites.ps1 -PathsOnly
@@ -118,35 +118,35 @@ if (Test-Path $paths.RESEARCH) { $docs += 'research.md' }
if (Test-Path $paths.DATA_MODEL) { $docs += 'data-model.md' } if (Test-Path $paths.DATA_MODEL) { $docs += 'data-model.md' }
# Check contracts directory (only if it exists and has files) # Check contracts directory (only if it exists and has files)
if ((Test-Path $paths.CONTRACTS_DIR) -and (Get-ChildItem -Path $paths.CONTRACTS_DIR -ErrorAction SilentlyContinue | Select-Object -First 1)) { if ((Test-Path $paths.CONTRACTS_DIR) -and (Get-ChildItem -Path $paths.CONTRACTS_DIR -ErrorAction SilentlyContinue | Select-Object -First 1)) {
$docs += 'contracts/' $docs += 'contracts/'
} }
if (Test-Path $paths.QUICKSTART) { $docs += 'quickstart.md' } if (Test-Path $paths.QUICKSTART) { $docs += 'quickstart.md' }
# Include tasks.md if requested and it exists # Include tasks.md if requested and it exists
if ($IncludeTasks -and (Test-Path $paths.TASKS)) { if ($IncludeTasks -and (Test-Path $paths.TASKS)) {
$docs += 'tasks.md' $docs += 'tasks.md'
} }
# Output results # Output results
if ($Json) { if ($Json) {
# JSON output # JSON output
[PSCustomObject]@{ [PSCustomObject]@{
FEATURE_DIR = $paths.FEATURE_DIR FEATURE_DIR = $paths.FEATURE_DIR
AVAILABLE_DOCS = $docs AVAILABLE_DOCS = $docs
} | ConvertTo-Json -Compress } | ConvertTo-Json -Compress
} else { } else {
# Text output # Text output
Write-Output "FEATURE_DIR:$($paths.FEATURE_DIR)" Write-Output "FEATURE_DIR:$($paths.FEATURE_DIR)"
Write-Output "AVAILABLE_DOCS:" Write-Output "AVAILABLE_DOCS:"
# Show status of each potential document # Show status of each potential document
Test-FileExists -Path $paths.RESEARCH -Description 'research.md' | Out-Null Test-FileExists -Path $paths.RESEARCH -Description 'research.md' | Out-Null
Test-FileExists -Path $paths.DATA_MODEL -Description 'data-model.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-DirHasFiles -Path $paths.CONTRACTS_DIR -Description 'contracts/' | Out-Null
Test-FileExists -Path $paths.QUICKSTART -Description 'quickstart.md' | Out-Null Test-FileExists -Path $paths.QUICKSTART -Description 'quickstart.md' | Out-Null
if ($IncludeTasks) { if ($IncludeTasks) {
Test-FileExists -Path $paths.TASKS -Description 'tasks.md' | Out-Null Test-FileExists -Path $paths.TASKS -Description 'tasks.md' | Out-Null
} }
+1 -1
View File
@@ -191,7 +191,7 @@ function Get-FeaturePathsEnv {
[Console]::Error.WriteLine("ERROR: Feature directory not found. Set SPECIFY_FEATURE_DIRECTORY or run the specify command to create .specify/feature.json.") [Console]::Error.WriteLine("ERROR: Feature directory not found. Set SPECIFY_FEATURE_DIRECTORY or run the specify command to create .specify/feature.json.")
exit 1 exit 1
} }
# When no branch context exists (no SPECIFY_FEATURE, feature resolved via # When no branch context exists (no SPECIFY_FEATURE, feature resolved via
# SPECIFY_FEATURE_DIRECTORY or feature.json), fall back to the feature # SPECIFY_FEATURE_DIRECTORY or feature.json), fall back to the feature
# directory basename so CURRENT_BRANCH is a usable identifier rather than # directory basename so CURRENT_BRANCH is a usable identifier rather than
+1 -1
View File
@@ -63,7 +63,7 @@ if (Test-Path $paths.IMPL_PLAN -PathType Leaf) {
# Output results # Output results
if ($Json) { if ($Json) {
$result = [PSCustomObject]@{ $result = [PSCustomObject]@{
FEATURE_SPEC = $paths.FEATURE_SPEC FEATURE_SPEC = $paths.FEATURE_SPEC
IMPL_PLAN = $paths.IMPL_PLAN IMPL_PLAN = $paths.IMPL_PLAN
SPECS_DIR = $paths.FEATURE_DIR SPECS_DIR = $paths.FEATURE_DIR
+1 -1
View File
@@ -5,4 +5,4 @@
} }
], ],
"settings": {} "settings": {}
} }
+2 -3
View File
@@ -141,9 +141,8 @@ def _install_shared_infra(
Copies ``.specify/scripts/<variant>/`` and ``.specify/templates/`` from Copies ``.specify/scripts/<variant>/`` and ``.specify/templates/`` from
the bundled core_pack or source checkout, where ``<variant>`` is the bundled core_pack or source checkout, where ``<variant>`` is
``bash`` when *script_type* is ``"sh"``, ``python`` when it is ``"py"``, ``bash`` when *script_type* is ``"sh"`` and ``powershell`` when it is
and ``powershell`` when it is ``"ps"``. Tracks all installed files in ``"ps"``. Tracks all installed files in ``speckit.manifest.json``.
``speckit.manifest.json``.
Shared scripts and page templates are processed to resolve Shared scripts and page templates are processed to resolve
``__SPECKIT_COMMAND_<NAME>__`` placeholders using *invoke_separator* ``__SPECKIT_COMMAND_<NAME>__`` placeholders using *invoke_separator*
+5 -30
View File
@@ -24,7 +24,6 @@ GITHUB_HOSTS = frozenset({
"api.github.com", "api.github.com",
"codeload.github.com", "codeload.github.com",
}) })
_MAX_RELEASE_METADATA_BYTES = 5 * 1024 * 1024
def build_github_request(url: str) -> urllib.request.Request: def build_github_request(url: str) -> urllib.request.Request:
@@ -69,8 +68,6 @@ def resolve_github_release_asset_api_url(
open_url_fn: Callable, open_url_fn: Callable,
timeout: int = 60, timeout: int = 60,
github_hosts: tuple[str, ...] = (), github_hosts: tuple[str, ...] = (),
redirect_validator: Callable[[str, str], None] | None = None,
max_metadata_bytes: int = _MAX_RELEASE_METADATA_BYTES,
) -> Optional[str]: ) -> Optional[str]:
"""Resolve a GitHub release browser-download URL to its REST API asset URL. """Resolve a GitHub release browser-download URL to its REST API asset URL.
@@ -94,8 +91,6 @@ def resolve_github_release_asset_api_url(
authenticated release-metadata lookup. authenticated release-metadata lookup.
timeout: Per-request timeout in seconds. timeout: Per-request timeout in seconds.
github_hosts: Host patterns to treat as GitHub Enterprise Server. github_hosts: Host patterns to treat as GitHub Enterprise Server.
redirect_validator: Optional policy applied to metadata redirects.
max_metadata_bytes: Maximum release-metadata response size.
""" """
import json import json
import urllib.error import urllib.error
@@ -154,33 +149,13 @@ def resolve_github_release_asset_api_url(
release_url = f"{api_base}/repos/{owner}/{repo}/releases/tags/{encoded_tag}" release_url = f"{api_base}/repos/{owner}/{repo}/releases/tags/{encoded_tag}"
try: try:
open_kwargs = {"timeout": timeout} with open_url_fn(release_url, timeout=timeout) as response:
if redirect_validator is not None: release_data = json.loads(response.read())
open_kwargs["redirect_validator"] = redirect_validator except (urllib.error.URLError, json.JSONDecodeError):
with open_url_fn(release_url, **open_kwargs) as response:
raw_release_data = response.read(max_metadata_bytes + 1)
if len(raw_release_data) > max_metadata_bytes:
raise ValueError("GitHub release metadata exceeds size limit")
release_data = json.loads(raw_release_data)
except (
urllib.error.URLError,
json.JSONDecodeError,
TypeError,
ValueError,
):
return None return None
if not isinstance(release_data, dict): for asset in release_data.get("assets", []):
return None if asset.get("name") == asset_name and asset.get("url"):
assets = release_data.get("assets", [])
if not isinstance(assets, list):
return None
for asset in assets:
if (
isinstance(asset, dict)
and asset.get("name") == asset_name
and asset.get("url")
):
return str(asset["url"]) return str(asset["url"])
return None return None
+1 -1
View File
@@ -14,7 +14,7 @@ def save_init_options(project_path: Path, options: dict[str, Any]) -> None:
dest = project_path / INIT_OPTIONS_FILE dest = project_path / INIT_OPTIONS_FILE
dest.parent.mkdir(parents=True, exist_ok=True) dest.parent.mkdir(parents=True, exist_ok=True)
dest.write_text( dest.write_text(
json.dumps(options, indent=2, sort_keys=True, ensure_ascii=False) + "\n", json.dumps(options, indent=2, sort_keys=True, ensure_ascii=False),
encoding="utf-8", encoding="utf-8",
) )
-55
View File
@@ -213,52 +213,6 @@ class CommandRegistrar:
".specify.specify/", ".specify/" ".specify.specify/", ".specify/"
) )
@staticmethod
def rewrite_extension_paths(
text: str, extension_id: str, extension_dir: Path
) -> str:
"""Rewrite extension-relative paths to their installed locations.
Extension command bodies reference bundled files relative to the
extension root (e.g. ``agents/control/commander.md``). After install
those files live under ``.specify/extensions/<id>/``, so bare
references would resolve against the workspace root and never be
found (#2101).
Only directories that actually exist inside *extension_dir* are
rewritten, keeping the behaviour conservative and avoiding false
positives on prose. ``commands`` (slash-command sources), ``specs``
(user project artifacts) and dot-directories are never rewritten.
"""
if not isinstance(text, str) or not text:
return text
skip = {"commands", ".git", "specs"}
try:
subdirs = [
entry.name
for entry in extension_dir.iterdir()
if entry.is_dir()
and entry.name not in skip
and not entry.name.startswith(".")
]
except OSError:
return text
for subdir in subdirs:
# Only rewrite relative references (subdir/... or ./subdir/...);
# absolute paths like /subdir/... keep their meaning. Use a
# callable replacement: subdir/extension_id come from the
# filesystem and could contain backslashes or "\1"-like
# sequences, which would corrupt a string replacement template.
replacement = f".specify/extensions/{extension_id}/{subdir}/"
text = re.sub(
r'(^|[\s`"\'(])(?:\./)?' + re.escape(subdir) + "/",
lambda m: m.group(1) + replacement,
text,
)
return text
def render_markdown_command( def render_markdown_command(
self, frontmatter: dict, body: str, source_id: str, context_note: str = None self, frontmatter: dict, body: str, source_id: str, context_note: str = None
) -> str: ) -> str:
@@ -685,9 +639,6 @@ class CommandRegistrar:
frontmatter[key] = core_frontmatter[key] frontmatter[key] = core_frontmatter[key]
frontmatter.pop("strategy", None) frontmatter.pop("strategy", None)
if extension_id:
body = self.rewrite_extension_paths(body, extension_id, source_root)
frontmatter = self._adjust_script_paths( frontmatter = self._adjust_script_paths(
frontmatter, extension_id=extension_id frontmatter, extension_id=extension_id
) )
@@ -750,12 +701,6 @@ class CommandRegistrar:
) )
output = self.render_toml_command(frontmatter, body, source_id) output = self.render_toml_command(frontmatter, body, source_id)
elif agent_config["format"] == "yaml": elif agent_config["format"] == "yaml":
body = self.resolve_skill_placeholders(
agent_name, frontmatter, body, project_root
)
body = self._convert_argument_placeholder(
body, "$ARGUMENTS", agent_config["args"]
)
output = self.render_yaml_command( output = self.render_yaml_command(
frontmatter, body, source_id, cmd_name frontmatter, body, source_id, cmd_name
) )
+1 -9
View File
@@ -196,15 +196,7 @@ def find_entries_for_url(
url: str, entries: list[AuthConfigEntry] url: str, entries: list[AuthConfigEntry]
) -> list[AuthConfigEntry]: ) -> list[AuthConfigEntry]:
"""Return entries whose ``hosts`` match the hostname of *url*.""" """Return entries whose ``hosts`` match the hostname of *url*."""
# A malformed authority (e.g. an unterminated IPv6 bracket "https://[::1") hostname = (urlparse(url).hostname or "").lower()
# makes urlparse/hostname raise ValueError. Treat that the same as a
# host-less URL: no entry can match, so return no matches rather than
# leaking a raw ValueError out of the shared HTTP client (build_request /
# open_url call this before any URL validation).
try:
hostname = (urlparse(url).hostname or "").lower()
except ValueError:
return []
if not hostname: if not hostname:
return [] return []
return [ return [
+2 -50
View File
@@ -10,8 +10,6 @@ from __future__ import annotations
import json import json
import os import os
import re import re
import stat
import tempfile
from pathlib import Path, PurePosixPath from pathlib import Path, PurePosixPath
from typing import Any from typing import Any
@@ -89,63 +87,17 @@ def loads_json(text: str, *, origin: str = "<string>") -> Any:
def dump_json(path: Path, data: Any, *, within: Path | None = None) -> Path: def dump_json(path: Path, data: Any, *, within: Path | None = None) -> Path:
"""Atomically write pretty JSON to *path* (optionally confined to *within*).""" """Write *data* as pretty JSON to *path* (optionally confined to *within*)."""
path = Path(path) path = Path(path)
if within is not None: if within is not None:
path = ensure_within(within, path) path = ensure_within(within, path)
fd = -1
temp_path: Path | None = None
try: try:
path.parent.mkdir(parents=True, exist_ok=True) path.parent.mkdir(parents=True, exist_ok=True)
fd, temp_name = tempfile.mkstemp( with path.open("w", encoding="utf-8") as handle:
dir=path.parent,
prefix=f".{path.name}.",
suffix=".tmp",
)
temp_path = Path(temp_name)
with os.fdopen(os.dup(fd), "w", encoding="utf-8") as handle:
json.dump(data, handle, indent=2, sort_keys=False) json.dump(data, handle, indent=2, sort_keys=False)
handle.write("\n") handle.write("\n")
try:
if path.exists():
existing = path.stat(follow_symlinks=False)
if stat.S_ISREG(existing.st_mode) and hasattr(os, "fchmod"):
os.fchmod(fd, stat.S_IMODE(existing.st_mode))
if stat.S_ISREG(existing.st_mode) and hasattr(os, "fchown"):
try:
os.fchown(fd, existing.st_uid, existing.st_gid)
except PermissionError:
pass
except OSError:
pass
staged = os.stat(temp_path, follow_symlinks=False)
opened = os.fstat(fd)
if (
not stat.S_ISREG(staged.st_mode)
or staged.st_dev != opened.st_dev
or staged.st_ino != opened.st_ino
):
raise OSError("staged JSON file changed before commit")
os.close(fd)
fd = -1
os.replace(temp_path, path)
temp_path = None
except OSError as exc: except OSError as exc:
raise BundlerError(f"Could not write {path}: {exc}") from exc raise BundlerError(f"Could not write {path}: {exc}") from exc
finally:
if fd >= 0:
try:
os.close(fd)
except OSError:
pass
if temp_path is not None:
try:
temp_path.unlink(missing_ok=True)
except OSError:
pass
return path return path
+3 -13
View File
@@ -68,18 +68,8 @@ def _validate_remote_url(source_id: str, url: str) -> None:
Mirrors ``specify_cli.catalogs`` URL validation to avoid MITM/downgrade Mirrors ``specify_cli.catalogs`` URL validation to avoid MITM/downgrade
issues before any network call. issues before any network call.
""" """
# A malformed authority (e.g. an unclosed IPv6 bracket ``https://[::1``) parsed = urlparse(url)
# makes urlparse / hostname access raise ValueError. This function's is_localhost = parsed.hostname in ("localhost", "127.0.0.1", "::1")
# contract is to raise BundlerError for a bad URL, so surface that as a
# clean error rather than leaking a raw ValueError to the caller.
try:
parsed = urlparse(url)
hostname = parsed.hostname
except ValueError:
raise BundlerError(
f"Catalog '{source_id}' URL is malformed: {url}"
) from None
is_localhost = hostname in ("localhost", "127.0.0.1", "::1")
if parsed.scheme != "https" and not (parsed.scheme == "http" and is_localhost): if parsed.scheme != "https" and not (parsed.scheme == "http" and is_localhost):
raise BundlerError( raise BundlerError(
f"Catalog '{source_id}' URL must use HTTPS (got {parsed.scheme}://). " f"Catalog '{source_id}' URL must use HTTPS (got {parsed.scheme}://). "
@@ -89,7 +79,7 @@ def _validate_remote_url(source_id: str, url: str) -> None:
# "https://:8080" or "https://user@...", so requiring netloc would let # "https://:8080" or "https://user@...", so requiring netloc would let
# those through even though they carry no host. hostname is None in those # those through even though they carry no host. hostname is None in those
# cases. Mirrors the fix in ``specify_cli.catalogs`` (#3210). # cases. Mirrors the fix in ``specify_cli.catalogs`` (#3210).
if not hostname: if not parsed.hostname:
raise BundlerError( raise BundlerError(
f"Catalog '{source_id}' URL must be a valid URL with a host: {url}" f"Catalog '{source_id}' URL must be a valid URL with a host: {url}"
) )
+10 -32
View File
@@ -187,41 +187,19 @@ def remove_bundle(
still_needed = components_still_needed(records, exclude_bundle_id=bundle_id) still_needed = components_still_needed(records, exclude_bundle_id=bundle_id)
result = InstallResult(bundle_id=bundle_id) result = InstallResult(bundle_id=bundle_id)
remove_attempted = False
try: for component in target.contributed_components:
for component in target.contributed_components: key = (component.kind, component.id)
key = (component.kind, component.id) if key in still_needed:
if key in still_needed: result.skipped.append(component)
result.skipped.append(component) continue
continue if installer.is_installed(project_root, component):
if installer.is_installed(project_root, component): installer.remove(project_root, component)
remove_attempted = True result.uninstalled.append(component)
installer.remove(project_root, component)
result.uninstalled.append(component)
save_records(project_root, remove_record(records, bundle_id))
except Exception as exc: # noqa: BLE001
if result.uninstalled:
detail = (
f"{len(result.uninstalled)} component(s) were already removed "
"before this failure; the bundle record was left unchanged, "
"so the project may be partially uninstalled."
)
elif remove_attempted:
detail = (
"No components were removed, but the failing component may "
"have made partial changes before raising, so the project "
"may be partially uninstalled."
)
else: else:
detail = ( result.skipped.append(component)
"No components were removed and no removal was attempted; "
"the bundle record was left unchanged."
)
raise BundlerError(
f"Failed to remove bundle '{bundle_id}': {exc}. {detail}"
) from exc
save_records(project_root, remove_record(records, bundle_id))
return result return result
+6 -49
View File
@@ -33,13 +33,12 @@ DEFAULT_PRIORITY = 10
def _assert_pinned_version( def _assert_pinned_version(
kind: str, component_id: str, pinned: str | None, advertised: object kind: str, component_id: str, pinned: str | None, advertised: object
) -> None: ) -> None:
"""Refuse to install when the resolved version differs from the manifest pin. """Refuse to install when the catalog version differs from the manifest pin.
Bundle manifests pin component versions for reproducibility; installing Bundle manifests pin component versions for reproducibility; installing
whatever the resolved source (catalog *or* bundled asset) provides would whatever the active catalog currently serves would silently violate the
silently violate the pin. When the source advertises no version we cannot pin. When the catalog advertises no version we cannot enforce the pin, so
enforce the pin, so installation proceeds (the source, not the bundler, installation proceeds (the catalog, not the bundler, owns that gap).
owns that gap).
""" """
if not pinned or advertised is None: if not pinned or advertised is None:
return return
@@ -55,35 +54,11 @@ def _assert_pinned_version(
if not matches: if not matches:
raise BundlerError( raise BundlerError(
f"{kind} '{component_id}' is pinned to version {pinned} in the bundle " f"{kind} '{component_id}' is pinned to version {pinned} in the bundle "
f"manifest, but the resolved version is {actual}. Update the bundle's " f"manifest, but the active catalog serves {actual}. Update the bundle's "
"pinned version or the source before installing." "pinned version or the catalog before installing."
) )
def _bundled_manifest_version(manifest_path: Path, root_key: str) -> str | None:
"""Best-effort read of a bundled asset's declared version from its manifest.
Returns ``None`` when the manifest is missing/unreadable/invalid, which
``_assert_pinned_version`` treats as "cannot enforce" (proceed) — matching
the catalog "advertises no version" escape hatch.
"""
try:
import yaml
data = yaml.safe_load(manifest_path.read_text(encoding="utf-8"))
if isinstance(data, dict):
section = data.get(root_key)
if isinstance(section, dict):
version = section.get("version")
# Only a non-empty string is a usable version; anything else
# (missing / non-string / whitespace) means "cannot enforce".
if isinstance(version, str) and version.strip():
return version
except Exception: # noqa: BLE001 - unreadable/invalid manifest: skip pin
return None
return None
class _KindManager(Protocol): class _KindManager(Protocol):
def is_installed(self, component: ComponentRef) -> bool: ... def is_installed(self, component: ComponentRef) -> bool: ...
@@ -159,15 +134,6 @@ class _PresetKindManager:
bundled = _locate_bundled_preset(component.id) bundled = _locate_bundled_preset(component.id)
if bundled is not None: if bundled is not None:
# Enforce the manifest pin against the bundled asset's own version,
# mirroring the catalog path below (the bundled path previously
# skipped the pin entirely).
_assert_pinned_version(
"Preset",
component.id,
component.version,
_bundled_manifest_version(bundled / "preset.yml", "preset"),
)
self._manager.install_from_directory(bundled, speckit_version, priority) self._manager.install_from_directory(bundled, speckit_version, priority)
return return
@@ -232,15 +198,6 @@ class _ExtensionKindManager:
bundled = _locate_bundled_extension(component.id) bundled = _locate_bundled_extension(component.id)
if bundled is not None: if bundled is not None:
# Enforce the manifest pin against the bundled asset's own version,
# mirroring the catalog path below (the bundled path previously
# skipped the pin entirely).
_assert_pinned_version(
"Extension",
component.id,
component.version,
_bundled_manifest_version(bundled / "extension.yml", "extension"),
)
self._manager.install_from_directory( self._manager.install_from_directory(
bundled, speckit_version, priority=priority bundled, speckit_version, priority=priority
) )
+3 -7
View File
@@ -71,12 +71,8 @@ class CatalogStackBase:
"""Validate that a catalog URL uses HTTPS, except localhost HTTP.""" """Validate that a catalog URL uses HTTPS, except localhost HTTP."""
from urllib.parse import urlparse from urllib.parse import urlparse
try: parsed = urlparse(url)
parsed = urlparse(url) is_localhost = parsed.hostname in ("localhost", "127.0.0.1", "::1")
hostname = parsed.hostname
except ValueError:
raise cls._error(f"Catalog URL is malformed: {url}") from None
is_localhost = hostname in ("localhost", "127.0.0.1", "::1")
if parsed.scheme != "https" and not (parsed.scheme == "http" and is_localhost): if parsed.scheme != "https" and not (parsed.scheme == "http" and is_localhost):
raise cls._error( raise cls._error(
f"Catalog URL must use HTTPS (got {parsed.scheme}://). " f"Catalog URL must use HTTPS (got {parsed.scheme}://). "
@@ -85,7 +81,7 @@ class CatalogStackBase:
# Check hostname, not netloc: netloc is truthy for host-less URLs like # Check hostname, not netloc: netloc is truthy for host-less URLs like
# "https://:8080" or "https://user@", so the host guarantee this error # "https://:8080" or "https://user@", so the host guarantee this error
# promises would not actually hold. hostname is None in those cases (#3209). # promises would not actually hold. hostname is None in those cases (#3209).
if not hostname: if not parsed.hostname:
raise cls._error("Catalog URL must be a valid URL with a host.") raise cls._error("Catalog URL must be a valid URL with a host.")
def _load_catalog_config(self, config_path: Path) -> list[CatalogEntry] | None: def _load_catalog_config(self, config_path: Path) -> list[CatalogEntry] | None:
+22 -36
View File
@@ -746,16 +746,11 @@ def _resolve_manifest_path(path: Path | None) -> Path:
def _download_manifest(resolved, *, offline: bool): def _download_manifest(resolved, *, offline: bool):
"""Resolve a bundle's manifest from its catalog ``download_url``. """Resolve a bundle's manifest from its catalog ``download_url``.
Catalog ``download_url``s are HTTPS-only (``http`` allowed for localhost), Local/``file://`` URLs always work offline and may point at a ``.zip``
matching the extensions/presets/workflows catalog systems. Remote URLs are artifact, a bundle directory, or a ``bundle.yml`` (handled by
fetched with the shared authenticated, redirect-validated HTTP client, and :func:`_local_manifest_source`). Remote ``https://`` URLs are fetched with
only when not ``--offline``. the shared authenticated, redirect-validated HTTP client, and only when not
``--offline``.
Local and ``file://`` sources are intentionally not resolved here: to
install a bundle from disk, pass the path positionally
(``specify bundle install ./path/to/bundle.yml`` — a bundle directory or a
``.zip`` artifact also works), which :func:`_local_manifest_source` handles
before catalog resolution and which never touches ``download_url``.
""" """
from urllib.parse import urlparse from urllib.parse import urlparse
@@ -768,35 +763,26 @@ def _download_manifest(resolved, *, offline: bool):
parsed = urlparse(url) parsed = urlparse(url)
scheme = parsed.scheme.lower() scheme = parsed.scheme.lower()
# ``file://`` URLs and bare filesystem paths (including Windows drive paths # On Windows an absolute path like ``C:\bundle.yml`` parses with a
# like ``C:\bundle.yml``, which urlparse reads as a single-letter scheme) # single-letter ``scheme``; treat it as a local file, not a URL scheme.
# are not valid catalog download URLs. Catalog URLs are HTTPS-only across
# every catalog system; installing from disk is done by passing the path
# positionally, which never reaches URL resolution. Give an actionable
# error rather than accepting a scheme the rest of the codebase rejects.
if scheme in ("", "file") or re.match(r"^[A-Za-z]:[\\/]", url): if scheme in ("", "file") or re.match(r"^[A-Za-z]:[\\/]", url):
raise BundlerError( local = Path(parsed.path if scheme == "file" else url)
f"Catalog entry '{resolved.entry.id}' has a non-HTTP(S) download_url " manifest = _local_manifest_source(str(local))
f"({url}); catalog download URLs must be HTTPS (http for localhost) — " if manifest is None:
"a file:// URL, a local filesystem path, or a scheme-less value " raise BundlerError(f"Bundle manifest not found: {local}")
"(e.g. 'example.com/bundle.zip') is not accepted. " return manifest
"To install a bundle from disk, pass the path directly: "
"'specify bundle install <path-to-bundle.yml | bundle-dir | .zip>'."
)
# Validate the scheme/host *before* the offline gate so an invalid or if scheme in ("http", "https"):
# non-HTTPS download_url reports the real problem in every mode, rather if offline:
# than a misleading "Network access disabled" under --offline. raise BundlerError(
# (_download_remote_manifest re-checks this, but only once network access f"Network access disabled; cannot download bundle '{resolved.entry.id}' "
# is permitted.) HTTPS-only, http allowed for localhost. f"from {url}."
_require_https(f"bundle '{resolved.entry.id}'", url) )
return _download_remote_manifest(resolved.entry.id, url)
if offline: raise BundlerError(
raise BundlerError( f"Unsupported download_url scheme for bundle '{resolved.entry.id}': {url}"
f"Network access disabled; cannot download bundle '{resolved.entry.id}' " )
f"from {url}."
)
return _download_remote_manifest(resolved.entry.id, url)
def _require_https(label: str, url: str) -> None: def _require_https(label: str, url: str) -> None:
+21 -62
View File
@@ -33,17 +33,11 @@ def _stdin_is_interactive() -> bool:
def ensure_constitution_from_template( def ensure_constitution_from_template(
project_path: Path, tracker: StepTracker | None = None project_path: Path, tracker: StepTracker | None = None
) -> None: ) -> None:
"""Materialize the resolved constitution template to memory if missing. """Copy constitution template to memory if it doesn't exist."""
Resolution walks the full priority stack (project overrides → installed
presets → extensions → core) via :class:`PresetResolver`, so a preset that
ships a ``constitution-template`` (e.g. ``strategy: replace`` with a ratified
constitution) can seed the memory file. When nothing overrides it, the
resolver falls through to the core template.
"""
from ..presets import _materialize_constitution_template
memory_constitution = project_path / ".specify" / "memory" / "constitution.md" memory_constitution = project_path / ".specify" / "memory" / "constitution.md"
template_constitution = (
project_path / ".specify" / "templates" / "constitution-template.md"
)
if memory_constitution.exists(): if memory_constitution.exists():
if tracker: if tracker:
@@ -51,21 +45,18 @@ def ensure_constitution_from_template(
tracker.skip("constitution", "existing file preserved") tracker.skip("constitution", "existing file preserved")
return return
try: if not template_constitution.exists():
materialization = _materialize_constitution_template(
project_path, memory_constitution
)
if materialization is None:
if tracker:
tracker.add("constitution", "Constitution setup")
tracker.error("constitution", "template not found")
return
if tracker: if tracker:
tracker.add("constitution", "Constitution setup") tracker.add("constitution", "Constitution setup")
if materialization == "copied": tracker.error("constitution", "template not found")
tracker.complete("constitution", "copied from template") return
else:
tracker.complete("constitution", "composed from template") try:
memory_constitution.parent.mkdir(parents=True, exist_ok=True)
shutil.copy2(template_constitution, memory_constitution)
if tracker:
tracker.add("constitution", "Constitution setup")
tracker.complete("constitution", "copied from template")
else: else:
console.print("[cyan]Initialized constitution from template[/cyan]") console.print("[cyan]Initialized constitution from template[/cyan]")
except Exception as e: except Exception as e:
@@ -229,45 +220,16 @@ def register(app: typer.Typer) -> None:
console.print( console.print(
f"[yellow]Warning:[/yellow] Current directory is not empty ({len(existing_items)} items)" f"[yellow]Warning:[/yellow] Current directory is not empty ({len(existing_items)} items)"
) )
console.print(
"[yellow]Template files will be merged with existing content and may overwrite existing files[/yellow]"
)
if force: if force:
# Proceeding: the merge/overwrite warning is accurate here.
console.print(
"[yellow]Template files will be merged with existing content and may overwrite existing files[/yellow]"
)
console.print( console.print(
"[cyan]--force supplied: skipping confirmation and proceeding with merge[/cyan]" "[cyan]--force supplied: skipping confirmation and proceeding with merge[/cyan]"
) )
else: else:
# Fold the merge risk into the confirmation prompt rather than response = typer.confirm("Do you want to continue?")
# printing it unconditionally first: on the EOF/no-input path if not response:
# below the command exits without changing anything, so a
# standalone "will be merged" line would mislead. Interactive
# users still see the risk as part of the question.
#
# Call typer.confirm normally so piped y/n is honored — e.g.
# `echo y | specify init --here` keeps reaching the
# non-destructive preserve-merge path.
try:
proceed = typer.confirm(
"Template files will be merged with existing content "
"and may overwrite existing files. Do you want to continue?"
)
except (typer.Abort, EOFError):
# typer.confirm raises Abort for BOTH an interactive Ctrl+C
# and an EOF on closed/empty stdin. Distinguish them: a real
# TTY cancellation is a normal exit (0, "cancelled"), while a
# missing-input EOF (non-interactive) becomes an actionable
# error pointing at --force.
if _stdin_is_interactive():
console.print("[yellow]Operation cancelled[/yellow]")
raise typer.Exit(0) from None
console.print(
"[red]Error:[/red] Current directory is not empty and no "
"confirmation input is available. Re-run with "
"[bold]--force[/bold] to merge into it."
)
raise typer.Exit(1) from None
if not proceed:
console.print("[yellow]Operation cancelled[/yellow]") console.print("[yellow]Operation cancelled[/yellow]")
raise typer.Exit(0) raise typer.Exit(0)
else: else:
@@ -485,6 +447,8 @@ def register(app: typer.Typer) -> None:
"shared-infra", f"scripts ({selected_script}) + templates" "shared-infra", f"scripts ({selected_script}) + templates"
) )
ensure_constitution_from_template(project_path, tracker=tracker)
try: try:
bundled_wf = _locate_bundled_workflow("speckit") bundled_wf = _locate_bundled_workflow("speckit")
if bundled_wf: if bundled_wf:
@@ -612,11 +576,6 @@ def register(app: typer.Typer) -> None:
continuing="Continuing without the optional preset.", continuing="Continuing without the optional preset.",
) )
# Seed the constitution AFTER preset installation so that a
# preset-provided constitution-template (resolved via the
# priority stack) wins over the core template.
ensure_constitution_from_template(project_path, tracker=tracker)
tracker.complete("final", "project ready") tracker.complete("final", "project ready")
except (typer.Exit, SystemExit): except (typer.Exit, SystemExit):
raise raise
+6 -89
View File
@@ -1078,11 +1078,6 @@ class ExtensionManager:
frontmatter = registrar._adjust_script_paths( frontmatter = registrar._adjust_script_paths(
frontmatter, extension_id=manifest.id frontmatter, extension_id=manifest.id
) )
# Mirror the register_commands() rewrite (#2101): resolve
# extension-relative subdir references (agents/, knowledge-base/,
# etc.) to their installed .specify/extensions/<id>/ location
# before the generic placeholder/path resolution below.
body = registrar.rewrite_extension_paths(body, manifest.id, extension_dir)
body = registrar.resolve_skill_placeholders( body = registrar.resolve_skill_placeholders(
selected_ai, frontmatter, body, self.project_root, extension_id=manifest.id selected_ai, frontmatter, body, self.project_root, extension_id=manifest.id
) )
@@ -2737,36 +2732,6 @@ class ConfigManager:
config_file = self.extension_dir / "local-config.yml" config_file = self.extension_dir / "local-config.yml"
return self._load_yaml_config(config_file) return self._load_yaml_config(config_file)
def _sibling_extension_ids(self) -> list[str]:
"""Return IDs of other extensions installed alongside this one.
Sourced from ``ExtensionRegistry`` (``.specify/extensions/.registry``)
rather than a directory scan: ``ExtensionManager.remove(...,
keep_config=True)`` deliberately preserves the extension directory
while dropping the registry entry, so a directory scan would treat
that config-only leftover as an installed sibling and keep silently
absorbing its ``SPECKIT_<sibling>_*`` env vars into no one. The
registry is the source of truth for "installed".
Returns an empty list if the registry is missing or corrupted
(fresh project, ad-hoc test harness) so ``_get_env_config`` degrades
to its pre-fix behaviour rather than crashing. ``UnicodeError`` is
caught alongside ``OSError`` because ``ExtensionRegistry._load()``
opens the file in text mode and only handles ``JSONDecodeError`` /
``FileNotFoundError``, so a registry file with non-UTF-8 bytes would
otherwise surface a ``UnicodeDecodeError`` here and break *every*
config read instead of degrading gracefully.
Used by ``_get_env_config`` to detect env vars whose remainder claims
a longer, sibling-owned prefix (e.g. ``SPECKIT_GIT_HOOKS_URL`` is
owned by ``git-hooks`` when it is co-installed with ``git``).
"""
extensions_dir = self.project_root / ".specify" / "extensions"
try:
return list(ExtensionRegistry(extensions_dir).keys())
except (OSError, UnicodeError):
return []
def _get_env_config(self) -> Dict[str, Any]: def _get_env_config(self) -> Dict[str, Any]:
"""Get configuration from environment variables. """Get configuration from environment variables.
@@ -2786,70 +2751,22 @@ class ConfigManager:
ext_id_upper = self.extension_id.replace("-", "_").upper() ext_id_upper = self.extension_id.replace("-", "_").upper()
prefix = f"SPECKIT_{ext_id_upper}_" prefix = f"SPECKIT_{ext_id_upper}_"
# Cross-extension prefix collision: because ``_`` doubles as both the
# separator between the extension ID and the config path *and* the
# substitute for ``-`` inside an extension ID, an env var like
# ``SPECKIT_GIT_HOOKS_URL`` begins with *both* the ``SPECKIT_GIT_``
# prefix of the ``git`` extension and the ``SPECKIT_GIT_HOOKS_`` prefix
# of a co-installed ``git-hooks`` extension. It logically belongs to
# the extension whose normalized ID is the longer, more specific match
# — otherwise config intended for one extension silently surfaces
# inside another and can drive hooks that only inspect
# ``config.<field> is set``. Build the list of sibling-owned
# remainder-prefixes here so a later env var can be skipped if it
# matches one.
sibling_prefixes: list[str] = []
for sibling_id in self._sibling_extension_ids():
if sibling_id == self.extension_id:
continue
sib_upper = sibling_id.replace("-", "_").upper()
# A sibling collides only when its normalized ID *extends* our own
# (i.e. starts with ``<US>_``). ``git`` vs ``not-git`` is not a
# collision; ``git`` vs ``git-hooks`` is.
if sib_upper.startswith(ext_id_upper + "_"):
# The portion of the env-var *remainder* the sibling claims,
# including the trailing ``_`` so a shorter ID that shares a
# non-boundary prefix cannot false-positive (e.g. sibling
# ``hook`` would not eat env vars under key ``hooks``).
sibling_prefixes.append(sib_upper[len(ext_id_upper) + 1 :] + "_")
for key, value in os.environ.items(): for key, value in os.environ.items():
if not key.startswith(prefix): if not key.startswith(prefix):
continue continue
remainder = key[len(prefix) :] # Remove prefix and split into parts
# Skip when a longer sibling ID claims this var — see the block config_path = key[len(prefix) :].lower().split("_")
# above. Keeps ``SPECKIT_GIT_HOOKS_URL`` out of the ``git``
# extension's config when ``git-hooks`` is co-installed.
if any(remainder.startswith(sp) for sp in sibling_prefixes):
continue
# Remove prefix and split into parts. Drop empty components from a # Build nested dict
# malformed name (e.g. ``SPECKIT_<EXT>_`` with no key, or
# consecutive underscores ``SPECKIT_X__Y``) so we never create an
# entry under an empty key.
config_path = [p for p in remainder.lower().split("_") if p]
if not config_path:
continue
# Build nested dict. Two env vars can collide on a prefix, e.g.
# SPECKIT_X_CONNECTION=a and SPECKIT_X_CONNECTION_URL=b. Guard the
# walk so a colliding scalar is replaced by a dict (deeper/more
# specific vars win) instead of being indexed into — which raised
# TypeError ('str' object does not support item assignment) — and
# guard the leaf so a scalar processed after the nested var does
# not clobber the nested dict. Order-independent: both insertion
# orders yield {'connection': {'url': ...}}. Nested-wins mirrors
# _merge_configs' dict-preserving semantics.
current = env_config current = env_config
for part in config_path[:-1]: for part in config_path[:-1]:
if not isinstance(current.get(part), dict): if part not in current:
current[part] = {} current[part] = {}
current = current[part] current = current[part]
# Set the final value, unless a nested dict already occupies it. # Set the final value
if not isinstance(current.get(config_path[-1]), dict): current[config_path[-1]] = value
current[config_path[-1]] = value
return env_config return env_config
+1 -8
View File
@@ -1566,14 +1566,7 @@ def extension_set_priority(
raw_priority = metadata.get("priority") raw_priority = metadata.get("priority")
# Only skip if the stored value is already a valid int equal to requested priority # Only skip if the stored value is already a valid int equal to requested priority
# This ensures corrupted values (e.g., "high") get repaired even when setting to default (10) # This ensures corrupted values (e.g., "high") get repaired even when setting to default (10)
# A bool is an int in Python (isinstance(True, int) is True), so exclude it explicitly — if isinstance(raw_priority, int) and raw_priority == priority:
# mirroring normalize_priority's bool guard — otherwise a corrupted True/False priority
# equals 1/0 here and is never repaired.
if (
isinstance(raw_priority, int)
and not isinstance(raw_priority, bool)
and raw_priority == priority
):
console.print(f"[yellow]Extension '{_escape_markup(str(display_name))}' already has priority {priority}[/yellow]") console.print(f"[yellow]Extension '{_escape_markup(str(display_name))}' already has priority {priority}[/yellow]")
raise typer.Exit(0) raise typer.Exit(0)
+1 -9
View File
@@ -190,15 +190,7 @@ def _parse_integration_options(integration: Any, raw_options: str) -> dict[str,
""" """
import shlex import shlex
parsed: dict[str, Any] = {} parsed: dict[str, Any] = {}
try: tokens = shlex.split(raw_options)
tokens = shlex.split(raw_options)
except ValueError as exc:
# An unbalanced quote (e.g. --integration-options='--commands-dir "foo')
# makes shlex raise "No closing quotation". Translate it into the same
# clean exit-1 UX as every other bad-input path below rather than
# letting a raw traceback escape.
console.print(f"[red]Error:[/red] Could not parse integration options: {exc}.")
raise typer.Exit(1)
declared_options = list(integration.options()) declared_options = list(integration.options())
declared = {opt.name.lstrip("-"): opt for opt in declared_options} declared = {opt.name.lstrip("-"): opt for opt in declared_options}
allowed = ", ".join(sorted(opt.name for opt in declared_options)) allowed = ", ".join(sorted(opt.name for opt in declared_options))
+17 -85
View File
@@ -17,7 +17,6 @@ import os
import re import re
import shlex import shlex
import shutil import shutil
import subprocess
import sys import sys
from abc import ABC from abc import ABC
from dataclasses import dataclass from dataclasses import dataclass
@@ -55,18 +54,6 @@ _CORE_COMMAND_TEMPLATE_RANK = {
} }
def yaml_quote(value: str) -> str:
"""Emit *value* as a double-quoted YAML scalar on a single line.
A hand-rolled quote cannot carry raw newlines (YAML folds them to
spaces) or control characters (the reader rejects them), so let the
YAML emitter produce the escapes.
"""
return yaml.safe_dump(
str(value), default_style='"', allow_unicode=True, width=sys.maxsize
).strip()
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
# IntegrationOption # IntegrationOption
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
@@ -605,42 +592,10 @@ class IntegrationBase(ABC):
if candidate.exists(): if candidate.exists():
return relative return relative
for name in ("python3", "python"): for name in ("python3", "python"):
found = shutil.which(name) if shutil.which(name):
if not found: return name
continue
# On Windows, python3/python on PATH may be the Microsoft
# Store App Execution Alias stub: it exists but only prints
# an installer hint and exits non-zero, so existence is not
# enough (see #3304 for the same defect in the sh scripts).
if sys.platform == "win32" and not IntegrationBase._interpreter_runs(
found
):
continue
return name
return sys.executable or "python3" return sys.executable or "python3"
@staticmethod
def _interpreter_runs(path: str) -> bool:
"""Return True when *path* executes as a Python interpreter.
Runs isolated (``-I``) without ``site`` (``-S``) and discards
I/O so the probe is a fast liveness check that cannot trigger
``sitecustomize``/user startup hooks.
"""
try:
return (
subprocess.run(
[path, "-I", "-S", "-c", ""],
stdin=subprocess.DEVNULL,
stdout=subprocess.DEVNULL,
stderr=subprocess.DEVNULL,
timeout=15,
).returncode
== 0
)
except (OSError, subprocess.SubprocessError):
return False
@staticmethod @staticmethod
def process_template( def process_template(
content: str, content: str,
@@ -1122,16 +1077,6 @@ class TomlIntegration(IntegrationBase):
# YamlIntegration — YAML-format agents (Goose) # YamlIntegration — YAML-format agents (Goose)
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
# Characters a YAML literal block scalar cannot carry: C0 controls other
# than tab/LF (a bare CR acts as a line break inside the scalar), DEL, the
# C1 range, lone UTF-16 surrogates, and the non-characters U+FFFE/U+FFFF.
# NEL (U+0085) is YAML-printable but, like LS/PS (U+2028/U+2029), YAML 1.1
# treats it as a line break, which corrupts the block scalar's structure
# just the same, so all three are included.
_YAML_BLOCK_SCALAR_UNSAFE = re.compile(
r"[\x00-\x08\x0b-\x1f\x7f-\x9f\u2028\u2029\ud800-\udfff\ufffe\uffff]"
)
class YamlIntegration(IntegrationBase): class YamlIntegration(IntegrationBase):
"""Concrete base for integrations that use YAML recipe format. """Concrete base for integrations that use YAML recipe format.
@@ -1238,9 +1183,9 @@ class YamlIntegration(IntegrationBase):
def _render_yaml(cls, title: str, description: str, body: str, source_id: str) -> str: def _render_yaml(cls, title: str, description: str, body: str, source_id: str) -> str:
"""Render a YAML recipe file from title, description, and body. """Render a YAML recipe file from title, description, and body.
Produces a Goose-compatible recipe with a literal block scalar for Produces a Goose-compatible recipe with a literal block scalar
normal prompt content, or an escaped quoted scalar when control for the prompt content. Uses ``yaml.safe_dump()`` for the
characters require it. Uses ``yaml.safe_dump()`` for the header fields. header fields to ensure proper escaping.
""" """
header = cls._build_yaml_header(title, description) header = cls._build_yaml_header(title, description)
@@ -1251,23 +1196,6 @@ class YamlIntegration(IntegrationBase):
default_flow_style=False, default_flow_style=False,
).strip() ).strip()
# YAML forbids C0 control characters (except tab and newline) and
# DEL in every scalar form, and a bare CR acts as a line break
# inside a block scalar. A literal block scalar emits such bytes
# verbatim, producing a recipe the YAML parser rejects, so fall
# back to an escaped double-quoted scalar for those bodies.
if _YAML_BLOCK_SCALAR_UNSAFE.search(body):
prompt_yaml = yaml.safe_dump(
{"prompt": body}, allow_unicode=True, default_style='"', width=sys.maxsize
).strip()
lines = [
header_yaml,
prompt_yaml,
"",
f"# Source: {source_id}",
]
return "\n".join(lines) + "\n"
# Indent the body for YAML block scalar. Use an explicit indentation # Indent the body for YAML block scalar. Use an explicit indentation
# indicator ("|2") rather than a bare "|": YAML infers a plain block # indicator ("|2") rather than a bare "|": YAML infers a plain block
# scalar's indentation from its first non-empty line, so a body whose # scalar's indentation from its first non-empty line, so a body whose
@@ -1552,17 +1480,21 @@ class SkillsIntegration(IntegrationBase):
if not description: if not description:
description = f"Spec Kit: {command_name} workflow" description = f"Spec Kit: {command_name} workflow"
# Build SKILL.md with manually formatted frontmatter (stable # Build SKILL.md with manually formatted frontmatter to match
# double-quoted values). yaml_quote escapes newlines and control # the release packaging script output exactly (double-quoted
# characters that a plain quoted f-string cannot carry. # values, no yaml.safe_dump quoting differences).
def _quote(v: str) -> str:
escaped = v.replace("\\", "\\\\").replace('"', '\\"')
return f'"{escaped}"'
skill_content = ( skill_content = (
f"---\n" f"---\n"
f"name: {yaml_quote(skill_name)}\n" f"name: {_quote(skill_name)}\n"
f"description: {yaml_quote(description)}\n" f"description: {_quote(description)}\n"
f"compatibility: {yaml_quote('Requires spec-kit project structure with .specify/ directory')}\n" f"compatibility: {_quote('Requires spec-kit project structure with .specify/ directory')}\n"
f"metadata:\n" f"metadata:\n"
f" author: {yaml_quote('github-spec-kit')}\n" f" author: {_quote('github-spec-kit')}\n"
f" source: {yaml_quote('templates/commands/' + src_file.name)}\n" f" source: {_quote('templates/commands/' + src_file.name)}\n"
f"---\n" f"---\n"
f"{processed_body}" f"{processed_body}"
) )
@@ -62,4 +62,4 @@ class DevinIntegration(SkillsIntegration):
default=True, default=True,
help="Install as agent skills (default for Devin)", help="Install as agent skills (default for Devin)",
), ),
] ]
@@ -18,13 +18,13 @@ from ..manifest import IntegrationManifest
def format_forge_command_name(cmd_name: str) -> str: def format_forge_command_name(cmd_name: str) -> str:
"""Convert command name to Forge-compatible hyphenated format. """Convert command name to Forge-compatible hyphenated format.
Forge requires command names to use hyphens instead of dots for Forge requires command names to use hyphens instead of dots for
compatibility with ZSH and other shells. This function converts compatibility with ZSH and other shells. This function converts
dot-notation command names to hyphenated format. dot-notation command names to hyphenated format.
The function is idempotent: already-formatted names are returned unchanged. The function is idempotent: already-formatted names are returned unchanged.
Examples: Examples:
>>> format_forge_command_name("plan") >>> format_forge_command_name("plan")
'speckit-plan' 'speckit-plan'
@@ -38,26 +38,26 @@ def format_forge_command_name(cmd_name: str) -> str:
'speckit-my-extension-example' 'speckit-my-extension-example'
>>> format_forge_command_name("speckit.jira.sync-status") >>> format_forge_command_name("speckit.jira.sync-status")
'speckit-jira-sync-status' 'speckit-jira-sync-status'
Args: Args:
cmd_name: Command name in dot notation (speckit.foo.bar), cmd_name: Command name in dot notation (speckit.foo.bar),
hyphenated format (speckit-foo-bar), or plain name (foo) hyphenated format (speckit-foo-bar), or plain name (foo)
Returns: Returns:
Hyphenated command name with 'speckit-' prefix Hyphenated command name with 'speckit-' prefix
""" """
# Already in hyphenated format - return as-is (idempotent) # Already in hyphenated format - return as-is (idempotent)
if cmd_name.startswith("speckit-"): if cmd_name.startswith("speckit-"):
return cmd_name return cmd_name
# Strip 'speckit.' prefix if present # Strip 'speckit.' prefix if present
short_name = cmd_name short_name = cmd_name
if short_name.startswith("speckit."): if short_name.startswith("speckit."):
short_name = short_name[len("speckit."):] short_name = short_name[len("speckit."):]
# Replace all dots with hyphens # Replace all dots with hyphens
short_name = short_name.replace(".", "-") short_name = short_name.replace(".", "-")
# Return with 'speckit-' prefix # Return with 'speckit-' prefix
return f"speckit-{short_name}" return f"speckit-{short_name}"
@@ -18,7 +18,7 @@ from typing import Any
import yaml import yaml
from ..base import IntegrationOption, SkillsIntegration, yaml_quote from ..base import IntegrationOption, SkillsIntegration
from ..manifest import IntegrationManifest from ..manifest import IntegrationManifest
@@ -153,18 +153,20 @@ class HermesIntegration(SkillsIntegration):
if not description: if not description:
description = f"Spec Kit: {command_name} workflow" description = f"Spec Kit: {command_name} workflow"
# Build SKILL.md with manually formatted frontmatter. yaml_quote # Build SKILL.md with manually formatted frontmatter
# escapes newlines and control characters that a plain quoted def _quote(v: str) -> str:
# f-string cannot carry. escaped = v.replace("\\", "\\\\").replace('"', '\\"')
return f'"{escaped}"'
skill_content = ( skill_content = (
f"---\n" f"---\n"
f"name: {yaml_quote(skill_name)}\n" f"name: {_quote(skill_name)}\n"
f"description: {yaml_quote(description)}\n" f"description: {_quote(description)}\n"
f"compatibility: " f"compatibility: "
f"{yaml_quote('Requires spec-kit project structure with .specify/ directory')}\n" f"{_quote('Requires spec-kit project structure with .specify/ directory')}\n"
f"metadata:\n" f"metadata:\n"
f" author: {yaml_quote('github-spec-kit')}\n" f" author: {_quote('github-spec-kit')}\n"
f" source: {yaml_quote('templates/commands/' + src_file.name)}\n" f" source: {_quote('templates/commands/' + src_file.name)}\n"
f"---\n" f"---\n"
f"{processed_body}" f"{processed_body}"
) )
@@ -13,7 +13,6 @@ _KIRO_ARG_FALLBACK = "(the user will provide the argument in this conversation)"
class KiroCliIntegration(MarkdownIntegration): class KiroCliIntegration(MarkdownIntegration):
key = "kiro-cli" key = "kiro-cli"
multi_install_safe = True
config = { config = {
"name": "Kiro CLI", "name": "Kiro CLI",
"folder": ".kiro/", "folder": ".kiro/",
@@ -27,10 +26,3 @@ class KiroCliIntegration(MarkdownIntegration):
"args": _KIRO_ARG_FALLBACK, "args": _KIRO_ARG_FALLBACK,
"extension": ".md", "extension": ".md",
} }
# Kiro CLI keeps everything under a static, isolated agent root
# (``.kiro/`` with commands in ``.kiro/prompts``) that no other
# integration writes to, so it is safe to install alongside others
# (issue #3471). The registry's multi-install-safe contract tests
# enforce that isolation for every integration setting this flag.
multi_install_safe = True
+26 -320
View File
@@ -31,117 +31,7 @@ from ..extensions import REINSTALL_COMMAND, ExtensionRegistry, normalize_priorit
from .._init_options import is_ai_skills_enabled from .._init_options import is_ai_skills_enabled
from ..integrations.base import IntegrationBase from ..integrations.base import IntegrationBase
from .._utils import dump_frontmatter, version_satisfies from .._utils import dump_frontmatter, version_satisfies
from ..shared_infra import ( from ..shared_infra import verify_archive_sha256
_ensure_safe_shared_destination,
_ensure_safe_shared_directory,
_write_shared_bytes,
_write_shared_text,
verify_archive_sha256,
)
_CONSTITUTION_PROVENANCE_FILE = ".constitution-template.json"
def _content_sha256(content: bytes) -> str:
return hashlib.sha256(content).hexdigest()
def _constitution_is_generated(
project_root: Path,
memory_constitution: Path,
resolver: "PresetResolver",
) -> bool:
"""Return whether the live constitution is an unchanged generated file."""
_ensure_safe_shared_destination(project_root, memory_constitution)
content = memory_constitution.read_bytes()
provenance = memory_constitution.parent / _CONSTITUTION_PROVENANCE_FILE
_ensure_safe_shared_destination(project_root, provenance)
if provenance.exists():
try:
metadata = json.loads(provenance.read_text(encoding="utf-8"))
except (json.JSONDecodeError, UnicodeDecodeError):
return False
return (
isinstance(metadata, dict)
and metadata.get("sha256") == _content_sha256(content)
)
# Older projects have no provenance sidecar. Only the immutable bundled or
# source-checkout core template is safe to treat as generated.
core = resolver._find_bundled_core(
"constitution-template", "template", ".md"
)
return core is not None and core.read_bytes() == content
def _constitution_provenance_matches_preset(
project_root: Path,
memory_constitution: Path,
pack_id: str,
pack_version: str,
) -> bool:
"""Return whether provenance identifies a preset as the materialized source."""
provenance = memory_constitution.parent / _CONSTITUTION_PROVENANCE_FILE
if not provenance.parent.exists():
return False
_ensure_safe_shared_destination(project_root, provenance)
if not provenance.exists():
return False
try:
metadata = json.loads(provenance.read_text(encoding="utf-8"))
except (json.JSONDecodeError, UnicodeDecodeError):
return False
return (
isinstance(metadata, dict)
and metadata.get("source") == f"{pack_id} v{pack_version}"
)
def _materialize_constitution_template(
project_root: Path,
memory_constitution: Path,
) -> str | None:
"""Materialize constitution-template content into memory/constitution.md.
Returns:
"copied" when the winning layer is ``replace`` and the source file is
copied verbatim; "composed" when a composing strategy is materialized
via ``resolve_content``; ``None`` when no constitution template resolves.
"""
resolver = PresetResolver(project_root)
layers = resolver.collect_all_layers("constitution-template", "template")
if not layers:
return None
top_layer = layers[0]
if top_layer["strategy"] == "replace":
content = top_layer["path"].read_bytes()
result = "copied"
else:
composed_content = resolver.resolve_content("constitution-template", "template")
if composed_content is None:
return None
content = composed_content.encode("utf-8")
result = "composed"
_ensure_safe_shared_directory(project_root, memory_constitution.parent)
_write_shared_bytes(project_root, memory_constitution, content)
provenance = memory_constitution.parent / _CONSTITUTION_PROVENANCE_FILE
_write_shared_text(
project_root,
provenance,
json.dumps(
{
"sha256": _content_sha256(content),
"source": top_layer["source"],
},
indent=2,
)
+ "\n",
)
return result
def _substitute_core_template( def _substitute_core_template(
@@ -888,7 +778,6 @@ class PresetManager:
matching_cmds, ext_id, ext_dir, matching_cmds, ext_id, ext_dir,
self.project_root, self.project_root,
context_note=f"\n<!-- Extension: {ext_id} -->\n<!-- Config: .specify/extensions/{ext_id}/ -->\n", context_note=f"\n<!-- Extension: {ext_id} -->\n<!-- Config: .specify/extensions/{ext_id}/ -->\n",
extension_id=ext_id,
) )
registered = True registered = True
except Exception: except Exception:
@@ -1310,8 +1199,6 @@ class PresetManager:
"command_name": cmd_name, "command_name": cmd_name,
"source_file": source_file, "source_file": source_file,
"source": f"extension:{manifest.id}", "source": f"extension:{manifest.id}",
"extension_id": manifest.id,
"extension_dir": ext_root,
} }
modern_skill_name, legacy_skill_name = self._skill_names_for_command(cmd_name) modern_skill_name, legacy_skill_name = self._skill_names_for_command(cmd_name)
restore_index.setdefault(modern_skill_name, restore_info) restore_index.setdefault(modern_skill_name, restore_info)
@@ -1576,17 +1463,6 @@ class PresetManager:
if extension_restore: if extension_restore:
content = extension_restore["source_file"].read_text(encoding="utf-8") content = extension_restore["source_file"].read_text(encoding="utf-8")
frontmatter, body = registrar.parse_frontmatter(content) frontmatter, body = registrar.parse_frontmatter(content)
# Mirror the register-time rewrite (#2101): resolve
# extension-relative subdir references (agents/,
# knowledge-base/, etc.) to their installed location before
# the generic placeholder resolution below, otherwise
# restoring after a preset override removal would leave
# bare, unresolvable paths in the skill body.
body = registrar.rewrite_extension_paths(
body,
extension_restore["extension_id"],
extension_restore["extension_dir"],
)
if isinstance(selected_ai, str): if isinstance(selected_ai, str):
body = registrar.resolve_skill_placeholders( body = registrar.resolve_skill_placeholders(
selected_ai, frontmatter, body, self.project_root selected_ai, frontmatter, body, self.project_root
@@ -1739,73 +1615,8 @@ class PresetManager:
stacklevel=2, stacklevel=2,
) )
# Seed/re-seed memory/constitution.md from a preset-provided
# constitution-template. The constitution is the only template that is
# materialized to a live file rather than resolved on demand, so a
# preset that ships one (e.g. strategy: replace with a ratified
# constitution) must be propagated here. Guard against clobbering an
# already-authored constitution by only replacing a file whose recorded
# hash (or exact legacy core-template content) proves it was generated.
self._seed_constitution_from_preset(manifest, dest_dir)
return manifest return manifest
def _seed_constitution_from_preset(
self, manifest: PresetManifest, preset_dir: Path
) -> None:
"""Seed memory/constitution.md from a preset constitution-template.
Only runs when the preset declares a ``type: template`` entry named
``constitution-template`` or provides one at a convention path, and the
live memory file is either missing or is an unchanged generated file.
Authored constitutions are never overwritten.
"""
provides_constitution = any(
t.get("type") == "template" and t.get("name") == "constitution-template"
for t in manifest.templates
) or any(
(preset_dir / relative_path).is_file()
for relative_path in (
"templates/constitution-template.md",
"constitution-template.md",
)
)
if not provides_constitution:
return
self.reconcile_constitution(
f"Failed to seed constitution from preset {manifest.id}",
create_if_missing=True,
)
def reconcile_constitution(
self, failure_context: str, *, create_if_missing: bool = False
) -> None:
"""Reconcile generated constitution content without failing a persisted change."""
try:
self._reconcile_constitution(create_if_missing=create_if_missing)
except (OSError, UnicodeDecodeError, PresetValidationError, ValueError) as exc:
import warnings
warnings.warn(
f"{failure_context}: {exc}.",
stacklevel=2,
)
def _reconcile_constitution(self, *, create_if_missing: bool = False) -> None:
"""Materialize the winning constitution layer when the live file is generated."""
memory_constitution = (
self.project_root / ".specify" / "memory" / "constitution.md"
)
if not memory_constitution.exists() and not create_if_missing:
return
resolver = PresetResolver(self.project_root)
if memory_constitution.exists() and not _constitution_is_generated(
self.project_root, memory_constitution, resolver
):
return
_materialize_constitution_template(self.project_root, memory_constitution)
def install_from_zip( def install_from_zip(
self, self,
zip_path: Path, zip_path: Path,
@@ -1885,25 +1696,6 @@ class PresetManager:
# Also include aliases from the manifest as a safety net for registries # Also include aliases from the manifest as a safety net for registries
# populated by older versions that may not track aliases. # populated by older versions that may not track aliases.
removed_cmd_names = set() removed_cmd_names = set()
removed_constitution = any(
path.exists()
for path in (
pack_dir / "templates" / "constitution-template.md",
pack_dir / "constitution-template.md",
)
)
if metadata and isinstance(metadata.get("version"), str):
memory_constitution = (
self.project_root / ".specify" / "memory" / "constitution.md"
)
removed_constitution = removed_constitution or (
_constitution_provenance_matches_preset(
self.project_root,
memory_constitution,
pack_id,
metadata["version"],
)
)
for cmd_names in registered_commands.values(): for cmd_names in registered_commands.values():
removed_cmd_names.update(cmd_names) removed_cmd_names.update(cmd_names)
manifest_path = pack_dir / "preset.yml" manifest_path = pack_dir / "preset.yml"
@@ -1911,11 +1703,6 @@ class PresetManager:
try: try:
manifest = PresetManifest(manifest_path) manifest = PresetManifest(manifest_path)
for tmpl in manifest.templates: for tmpl in manifest.templates:
if (
tmpl.get("type") == "template"
and tmpl.get("name") == "constitution-template"
):
removed_constitution = True
if tmpl.get("type") == "command": if tmpl.get("type") == "command":
for alias in tmpl.get("aliases", []): for alias in tmpl.get("aliases", []):
if isinstance(alias, str): if isinstance(alias, str):
@@ -1962,18 +1749,6 @@ class PresetManager:
stacklevel=2, stacklevel=2,
) )
if removed_constitution:
try:
self._reconcile_constitution()
except (OSError, UnicodeDecodeError, PresetValidationError, ValueError) as exc:
import warnings
warnings.warn(
f"Post-removal constitution reconciliation failed for {pack_id}: "
f"{exc}. The live constitution may be stale.",
stacklevel=2,
)
return True return True
def list_installed(self) -> List[Dict[str, Any]]: def list_installed(self) -> List[Dict[str, Any]]:
@@ -2799,39 +2574,6 @@ class PresetResolver:
self._manifest_cache[key] = None self._manifest_cache[key] = None
return self._manifest_cache[key] return self._manifest_cache[key]
def _manifest_declared_template(
self, pack_dir: Path, template_name: str, template_type: str
) -> tuple[dict | None, Path | None]:
"""Resolve a preset's manifest-declared template entry and usable file.
Returns ``(entry, candidate)``:
- ``entry`` is the matching ``provides.templates`` mapping, or ``None`` if
the manifest is absent or does not list this ``(name, type)``.
- ``candidate`` is the declared ``file:`` resolved under ``pack_dir`` IFF
it is a regular file (``is_file()``); ``None`` otherwise — a missing,
empty, or non-file (e.g. directory) declaration yields ``(entry, None)``.
The manifest is authoritative: when it declares a template (``entry`` is
not ``None``) but the file is unusable (``candidate`` is ``None``),
callers must NOT fall back to the convention lookup — that would mask a
typo or pick up an undeclared file. Shared by ``resolve()`` and
``collect_all_layers()`` so their manifest-first resolution cannot
silently diverge again (the divergence this fix addressed).
"""
manifest = self._get_manifest(pack_dir)
if not manifest:
return None, None
for tmpl in manifest.templates:
if tmpl.get("name") == template_name and tmpl.get("type") == template_type:
file_path = tmpl.get("file")
if file_path:
manifest_candidate = pack_dir / file_path
return tmpl, (
manifest_candidate if manifest_candidate.is_file() else None
)
return tmpl, None
return None, None
def _get_all_extensions_by_priority(self) -> list[tuple[int, str, dict | None]]: def _get_all_extensions_by_priority(self) -> list[tuple[int, str, dict | None]]:
"""Build unified list of registered and unregistered extensions sorted by priority. """Build unified list of registered and unregistered extensions sorted by priority.
@@ -2934,27 +2676,6 @@ class PresetResolver:
registry = PresetRegistry(self.presets_dir) registry = PresetRegistry(self.presets_dir)
for pack_id, _metadata in registry.list_by_priority(): for pack_id, _metadata in registry.list_by_priority():
pack_dir = self.presets_dir / pack_id pack_dir = self.presets_dir / pack_id
# The preset manifest is authoritative: if it declares this
# template with an explicit ``file:``, resolve to that path —
# and do NOT fall back to convention when it's missing, to
# avoid masking typos or picking up an undeclared file. Only
# when the manifest is absent or doesn't list this template do
# we use the convention-based subdir lookup. Mirrors
# collect_all_layers()/resolve_content() so resolve() and
# resolve_with_source() agree with them instead of returning
# the core template (or a stray convention file).
entry, manifest_candidate = self._manifest_declared_template(
pack_dir, template_name, template_type
)
if manifest_candidate is not None:
return manifest_candidate
if entry is not None:
# Manifest declares this template but the file is missing,
# non-file (e.g. a directory), or an empty/falsey ``file``
# value. The manifest is authoritative, so skip this pack's
# convention fallback rather than mask a typo — mirrors
# collect_all_layers().
continue
for subdir in subdirs: for subdir in subdirs:
if subdir: if subdir:
candidate = pack_dir / subdir / f"{template_name}{ext}" candidate = pack_dir / subdir / f"{template_name}{ext}"
@@ -3222,22 +2943,31 @@ class PresetResolver:
pack_dir = self.presets_dir / pack_id pack_dir = self.presets_dir / pack_id
# Read strategy and manifest file path from preset manifest # Read strategy and manifest file path from preset manifest
strategy = "replace" strategy = "replace"
manifest_file_path = None
manifest_has_strategy = False manifest_has_strategy = False
entry, manifest_candidate = self._manifest_declared_template( manifest_found_entry = False
pack_dir, template_name, template_type manifest = self._get_manifest(pack_dir)
) if manifest:
if entry is not None: for tmpl in manifest.templates:
strategy = entry.get("strategy", "replace") if (tmpl.get("name") == template_name
manifest_has_strategy = "strategy" in entry and tmpl.get("type") == template_type):
# Use the manifest's declared file when it's a usable regular file; strategy = tmpl.get("strategy", "replace")
# only fall back to convention-based lookup when the manifest manifest_has_strategy = "strategy" in tmpl
# doesn't list this template at all, so preset.yml stays manifest_file_path = tmpl.get("file")
# authoritative (a declared-but-unusable file skips convention — manifest_found_entry = True
# parity with resolve()). break
# Use manifest file path if specified, otherwise convention-based
# lookup — but only when the manifest doesn't exist or doesn't
# list this template, so preset.yml stays authoritative.
candidate = None candidate = None
if manifest_candidate is not None: if manifest_file_path:
candidate = manifest_candidate manifest_candidate = pack_dir / manifest_file_path
elif entry is None: if manifest_candidate.exists():
candidate = manifest_candidate
# Explicit file path that doesn't exist: skip convention
# fallback to avoid masking typos or picking up unintended files.
elif not manifest_found_entry:
# Manifest doesn't list this template — check convention paths
candidate = _find_in_subdirs(pack_dir) candidate = _find_in_subdirs(pack_dir)
if candidate: if candidate:
# Legacy fallback: if manifest doesn't explicitly declare a # Legacy fallback: if manifest doesn't explicitly declare a
@@ -3308,8 +3038,6 @@ class PresetResolver:
"path": candidate, "path": candidate,
"source": source, "source": source,
"strategy": "replace", "strategy": "replace",
"extension_id": ext_id,
"extension_dir": ext_dir,
}) })
# Priority 4: Core templates (always "replace") # Priority 4: Core templates (always "replace")
@@ -3429,32 +3157,10 @@ class PresetResolver:
if not layers: if not layers:
return None return None
def _read_layer_content(layer: Dict[str, Any]) -> str:
"""Read a layer's raw text, rewriting extension-relative subdir
references (agents/, knowledge-base/, etc.) to their installed
location when the layer is extension-provided (#2101).
Extension layers are always inserted with strategy "replace"
(see collect_all_layers), so a layer only ever needs this
rewrite when it wins outright above or serves as the
composition base below — never as a mid-stack composing
(append/prepend/wrap) layer.
"""
text = layer["path"].read_text(encoding="utf-8")
extension_id = layer.get("extension_id")
extension_dir = layer.get("extension_dir")
if extension_id and extension_dir:
from ..agents import CommandRegistrar
text = CommandRegistrar.rewrite_extension_paths(
text, extension_id, extension_dir
)
return text
# If the top (highest-priority) layer is replace, it wins entirely — # If the top (highest-priority) layer is replace, it wins entirely —
# lower layers are irrelevant regardless of their strategies. # lower layers are irrelevant regardless of their strategies.
if layers[0]["strategy"] == "replace": if layers[0]["strategy"] == "replace":
return _read_layer_content(layers[0]) return layers[0]["path"].read_text(encoding="utf-8")
# Composition: build content bottom-up from the effective base. # Composition: build content bottom-up from the effective base.
# The base is the nearest replace layer scanning from highest priority # The base is the nearest replace layer scanning from highest priority
@@ -3477,7 +3183,7 @@ class PresetResolver:
# Convert to reversed_layers index # Convert to reversed_layers index
base_reversed_idx = len(layers) - 1 - base_layer_idx base_reversed_idx = len(layers) - 1 - base_layer_idx
content = _read_layer_content(layers[base_layer_idx]) content = layers[base_layer_idx]["path"].read_text(encoding="utf-8")
# Compose only the layers above the base (higher priority = lower index in layers, # Compose only the layers above the base (higher priority = lower index in layers,
# higher index in reversed_layers). Process bottom-up from base+1. # higher index in reversed_layers). Process bottom-up from base+1.
start_idx = base_reversed_idx + 1 start_idx = base_reversed_idx + 1
+1 -17
View File
@@ -469,14 +469,7 @@ def preset_set_priority(
raw_priority = metadata.get("priority") raw_priority = metadata.get("priority")
# Only skip if the stored value is already a valid int equal to requested priority # Only skip if the stored value is already a valid int equal to requested priority
# This ensures corrupted values (e.g., "high") get repaired even when setting to default (10) # This ensures corrupted values (e.g., "high") get repaired even when setting to default (10)
# A bool is an int in Python (isinstance(True, int) is True), so exclude it explicitly — if isinstance(raw_priority, int) and raw_priority == priority:
# mirroring normalize_priority's bool guard — otherwise a corrupted True/False priority
# equals 1/0 here and is never repaired.
if (
isinstance(raw_priority, int)
and not isinstance(raw_priority, bool)
and raw_priority == priority
):
console.print(f"[yellow]Preset '{preset_id}' already has priority {priority}[/yellow]") console.print(f"[yellow]Preset '{preset_id}' already has priority {priority}[/yellow]")
raise typer.Exit(0) raise typer.Exit(0)
@@ -484,9 +477,6 @@ def preset_set_priority(
# Update priority # Update priority
manager.registry.update(preset_id, {"priority": priority}) manager.registry.update(preset_id, {"priority": priority})
manager.reconcile_constitution(
f"Failed to reconcile constitution after changing priority for preset {preset_id}"
)
console.print(f"[green]✓[/green] Preset '{preset_id}' priority changed: {old_priority} → {priority}") console.print(f"[green]✓[/green] Preset '{preset_id}' priority changed: {old_priority} → {priority}")
console.print("\n[dim]Lower priority = higher precedence in template resolution[/dim]") console.print("\n[dim]Lower priority = higher precedence in template resolution[/dim]")
@@ -520,9 +510,6 @@ def preset_enable(
# Enable the preset # Enable the preset
manager.registry.update(preset_id, {"enabled": True}) manager.registry.update(preset_id, {"enabled": True})
manager.reconcile_constitution(
f"Failed to reconcile constitution after enabling preset {preset_id}"
)
console.print(f"[green]✓[/green] Preset '{preset_id}' enabled") console.print(f"[green]✓[/green] Preset '{preset_id}' enabled")
console.print("\nTemplates from this preset will now be included in resolution.") console.print("\nTemplates from this preset will now be included in resolution.")
@@ -557,9 +544,6 @@ def preset_disable(
# Disable the preset # Disable the preset
manager.registry.update(preset_id, {"enabled": False}) manager.registry.update(preset_id, {"enabled": False})
manager.reconcile_constitution(
f"Failed to reconcile constitution after disabling preset {preset_id}"
)
console.print(f"[green]✓[/green] Preset '{preset_id}' disabled") console.print(f"[green]✓[/green] Preset '{preset_id}' disabled")
console.print("\nTemplates from this preset will be skipped during resolution.") console.print("\nTemplates from this preset will be skipped during resolution.")
+3 -10
View File
@@ -328,10 +328,7 @@ def refresh_shared_templates(
_ensure_safe_shared_destination(project_path, dst) _ensure_safe_shared_destination(project_path, dst)
rel = dst.relative_to(project_path).as_posix() rel = dst.relative_to(project_path).as_posix()
if dst.exists() and not force: if dst.exists() and not force:
if rel not in tracked_files or rel in modified or manifest.is_recovered(rel): if rel not in tracked_files or rel in modified:
# Never overwrite a recovered (pre-existing user) file without
# --force, matching install_shared_infra's is_recovered gate
# (#2918). Without this, refresh clobbers user content.
skipped_files.append(rel) skipped_files.append(rel)
continue continue
@@ -347,7 +344,7 @@ def refresh_shared_templates(
if skipped_files: if skipped_files:
console.print( console.print(
f"[yellow]⚠[/yellow] {len(skipped_files)} modified, untracked, or preserved (recovered) shared template file(s) were not updated:" f"[yellow]⚠[/yellow] {len(skipped_files)} modified or untracked shared template file(s) were not updated:"
) )
for rel in skipped_files: for rel in skipped_files:
console.print(f" {rel}") console.print(f" {rel}")
@@ -403,7 +400,7 @@ def install_shared_infra(
# manifest entries the core no longer ships (stale-script cleanup, #3076). # manifest entries the core no longer ships (stale-script cleanup, #3076).
seen_rels: set[str] = set() seen_rels: set[str] = set()
scripts_scanned = False scripts_scanned = False
variant_dir = {"sh": "bash", "py": "python"}.get(script_type, "powershell") variant_dir = "bash" if script_type == "sh" else "powershell"
def _decide_overwrite(rel: str, dst: Path) -> tuple[bool, str | None]: def _decide_overwrite(rel: str, dst: Path) -> tuple[bool, str | None]:
"""Return (write, bucket) where bucket is 'skip', 'preserved', or None.""" """Return (write, bucket) where bucket is 'skip', 'preserved', or None."""
@@ -465,10 +462,6 @@ def install_shared_infra(
for src_path in variant_src.rglob("*"): for src_path in variant_src.rglob("*"):
if not src_path.is_file(): if not src_path.is_file():
continue continue
# Python bytecode caches are local artifacts, not
# workflow scripts — never install them.
if "__pycache__" in src_path.parts:
continue
# Mark scanned only once a real source file is seen. An # Mark scanned only once a real source file is seen. An
# empty (or symlink-skipped) variant keeps this False, so # empty (or symlink-skipped) variant keeps this False, so
# stale-cleanup is skipped — otherwise it would treat every # stale-cleanup is skipped — otherwise it would treat every
File diff suppressed because it is too large Load Diff
-3
View File
@@ -74,9 +74,6 @@ class StepContext:
#: Current run ID. #: Current run ID.
run_id: str | None = None run_id: str | None = None
#: Source directory of the workflow definition file.
workflow_dir: str | None = None
@dataclass @dataclass
class StepResult: class StepResult:
+26 -219
View File
@@ -13,8 +13,6 @@ from __future__ import annotations
import hashlib import hashlib
import json import json
import os import os
import stat
import tempfile
import time import time
from dataclasses import dataclass from dataclasses import dataclass
from pathlib import Path from pathlib import Path
@@ -73,180 +71,40 @@ class WorkflowRegistry:
self.registry_path = self.workflows_dir / self.REGISTRY_FILE self.registry_path = self.workflows_dir / self.REGISTRY_FILE
self.data = self._load() self.data = self._load()
def _has_symlinked_parent(self) -> bool:
"""Return True if any directory under .specify/workflows is a symlink."""
current = self.project_root
for part in (".specify", "workflows"):
current = current / part
if current.is_symlink():
return True
return False
def _load(self) -> dict[str, Any]: def _load(self) -> dict[str, Any]:
"""Load registry from disk or create default.""" """Load registry from disk or create default."""
default_registry: dict[str, Any] = {
"schema_version": self.SCHEMA_VERSION,
"workflows": {},
}
# Defense-in-depth: refuse to read through symlinked parents or a
# symlinked registry file. Unlike StepRegistry (read-only best-effort
# elsewhere), a fabricated empty registry here is not safe: read-only
# callers (notably the bundler's remove path) query is_installed()
# before ever writing, and would otherwise conclude an installed
# workflow is absent, skip removing it, then delete the bundle
# record -- leaving the workflow untracked but still on disk. Fail
# closed here just like the unreadable-file case below.
if self._has_symlinked_parent() or self.registry_path.is_symlink():
raise OSError(
f"Refusing to read workflow registry at {self.registry_path}: "
"a parent directory or the registry file itself is a symlink"
)
if self.registry_path.exists(): if self.registry_path.exists():
try: try:
with open(self.registry_path, encoding="utf-8") as f: with open(self.registry_path, encoding="utf-8") as f:
data = json.load(f) return json.load(f)
except OSError as exc: except (json.JSONDecodeError, ValueError):
# The real data may still be intact on disk. Fail closed at # Corrupted registry file — reset to default
# construction rather than fabricating an empty registry that return {"schema_version": self.SCHEMA_VERSION, "workflows": {}}
# a read-only caller could mistake for "nothing installed." return {"schema_version": self.SCHEMA_VERSION, "workflows": {}}
raise OSError(
f"Failed to read workflow registry at {self.registry_path}: {exc}"
) from exc
except (
json.JSONDecodeError,
ValueError,
UnicodeError,
) as exc:
raise OSError(
f"Workflow registry at {self.registry_path} is corrupted: "
f"{exc}"
) from exc
# Validate shape: must be a dict with a dict "workflows" field.
if not isinstance(data, dict):
raise OSError(
f"Workflow registry at {self.registry_path} is corrupted: "
"top-level value must be an object"
)
if not isinstance(data.get("workflows"), dict):
raise OSError(
f"Workflow registry at {self.registry_path} is corrupted: "
"'workflows' must be an object"
)
return data
return default_registry
def save(self) -> None: def save(self) -> None:
"""Persist registry to disk atomically.""" """Persist registry to disk."""
# Refuse to write through symlinked parents (mirrors StepRegistry.save
# and the CLI-level _reject_unsafe_dir guard).
if self._has_symlinked_parent() or self.registry_path.is_symlink():
raise OSError(
"Refusing to write workflow registry through a symlinked path."
)
self.workflows_dir.mkdir(parents=True, exist_ok=True) self.workflows_dir.mkdir(parents=True, exist_ok=True)
# Unique, exclusive temp then replace: a failed dump cannot truncate with open(self.registry_path, "w", encoding="utf-8") as f:
# the registry, a pre-created symlink cannot redirect the write, and json.dump(self.data, f, indent=2)
# concurrent CLI processes cannot collide on the same temp path.
fd, tmp = tempfile.mkstemp(
dir=str(self.registry_path.parent),
prefix=f".{self.registry_path.name}.",
suffix=".tmp",
)
try:
# Write through a duplicate so the exclusive mkstemp descriptor
# stays open for fd-based metadata updates and inode verification.
with os.fdopen(os.dup(fd), "w", encoding="utf-8") as f:
json.dump(self.data, f, indent=2)
# mkstemp creates the temp file at 0600. A pre-existing registry
# may be shared more permissively (e.g. 0640/0644); preserve its
# mode across the replace so a save doesn't silently lock other
# project users out. A brand-new registry has no prior mode to
# preserve, so mkstemp's secure 0600 default stands. Mirrors
# _utils.py's atomic_write_json (best-effort; data safety over
# metadata preservation).
try:
if self.registry_path.exists():
existing_stat = self.registry_path.stat(
follow_symlinks=False
)
if stat.S_ISREG(existing_stat.st_mode) and hasattr(
os, "fchmod"
):
os.fchmod(fd, stat.S_IMODE(existing_stat.st_mode))
if stat.S_ISREG(existing_stat.st_mode) and hasattr(
os, "fchown"
):
try:
os.fchown(
fd, existing_stat.st_uid, existing_stat.st_gid
)
except PermissionError:
pass
except OSError:
pass
staged_stat = os.stat(tmp, follow_symlinks=False)
open_stat = os.fstat(fd)
if (
not stat.S_ISREG(staged_stat.st_mode)
or staged_stat.st_dev != open_stat.st_dev
or staged_stat.st_ino != open_stat.st_ino
):
raise OSError(
"Refusing to replace workflow registry: "
"staged file changed before commit"
)
os.close(fd)
fd = -1
os.replace(tmp, self.registry_path)
except BaseException:
if fd >= 0:
try:
os.close(fd)
except OSError:
pass
try:
os.unlink(tmp)
except OSError:
pass
raise
def add(self, workflow_id: str, metadata: dict[str, Any]) -> None: def add(self, workflow_id: str, metadata: dict[str, Any]) -> None:
"""Add or update an installed workflow entry.""" """Add or update an installed workflow entry."""
from datetime import datetime, timezone from datetime import datetime, timezone
raw_existing = self.data["workflows"].get(workflow_id) existing = self.data["workflows"].get(workflow_id, {})
had_entry = workflow_id in self.data["workflows"]
# Corrupted-but-parseable registries may hold non-dict entries.
existing = raw_existing if isinstance(raw_existing, dict) else {}
metadata["installed_at"] = existing.get( metadata["installed_at"] = existing.get(
"installed_at", datetime.now(timezone.utc).isoformat() "installed_at", datetime.now(timezone.utc).isoformat()
) )
metadata["updated_at"] = datetime.now(timezone.utc).isoformat() metadata["updated_at"] = datetime.now(timezone.utc).isoformat()
self.data["workflows"][workflow_id] = metadata self.data["workflows"][workflow_id] = metadata
try: self.save()
self.save()
except (OSError, TypeError, ValueError):
# Roll back the in-memory mutation so a later successful save
# cannot persist metadata for a write that failed.
if had_entry:
self.data["workflows"][workflow_id] = raw_existing
else:
del self.data["workflows"][workflow_id]
raise
def remove(self, workflow_id: str) -> bool: def remove(self, workflow_id: str) -> bool:
"""Remove an installed workflow entry. Returns True if found.""" """Remove an installed workflow entry. Returns True if found."""
if workflow_id in self.data["workflows"]: if workflow_id in self.data["workflows"]:
removed_entry = self.data["workflows"][workflow_id]
del self.data["workflows"][workflow_id] del self.data["workflows"][workflow_id]
try: self.save()
self.save()
except (OSError, TypeError, ValueError):
# Roll back the in-memory deletion so a save failure can't
# desync this instance from the untouched file on disk,
# mirroring add()'s rollback-on-save-failure.
self.data["workflows"][workflow_id] = removed_entry
raise
return True return True
return False return False
@@ -299,20 +157,8 @@ class WorkflowCatalog:
"""Validate that a catalog URL uses HTTPS (localhost HTTP allowed).""" """Validate that a catalog URL uses HTTPS (localhost HTTP allowed)."""
from urllib.parse import urlparse from urllib.parse import urlparse
# A malformed authority (e.g. an unterminated IPv6 bracket parsed = urlparse(url)
# "https://[::1") makes urlparse / hostname access raise ValueError. is_localhost = parsed.hostname in ("localhost", "127.0.0.1", "::1")
# This validator's contract is to raise WorkflowValidationError for a
# bad URL, so surface that rather than leaking a raw ValueError past the
# command handler (which only catches WorkflowValidationError). Mirrors
# specify_cli.catalogs (#3435).
try:
parsed = urlparse(url)
hostname = parsed.hostname
except ValueError:
raise WorkflowValidationError(
f"Catalog URL is malformed: {url}"
) from None
is_localhost = hostname in ("localhost", "127.0.0.1", "::1")
if parsed.scheme != "https" and not ( if parsed.scheme != "https" and not (
parsed.scheme == "http" and is_localhost parsed.scheme == "http" and is_localhost
): ):
@@ -320,7 +166,7 @@ class WorkflowCatalog:
f"Catalog URL must use HTTPS (got {parsed.scheme}://). " f"Catalog URL must use HTTPS (got {parsed.scheme}://). "
"HTTP is only allowed for localhost." "HTTP is only allowed for localhost."
) )
if not hostname: if not parsed.hostname:
raise WorkflowValidationError( raise WorkflowValidationError(
"Catalog URL must be a valid URL with a host." "Catalog URL must be a valid URL with a host."
) )
@@ -486,26 +332,15 @@ class WorkflowCatalog:
from specify_cli.authentication.http import open_url as _open_url from specify_cli.authentication.http import open_url as _open_url
def _validate_catalog_url(url: str) -> None: def _validate_catalog_url(url: str) -> None:
# A malformed authority (e.g. "https://[::1") makes urlparse / parsed = urlparse(url)
# hostname access raise ValueError; treat it as a refused fetch is_localhost = parsed.hostname in ("localhost", "127.0.0.1", "::1")
# rather than leaking a raw ValueError (this also validates the
# post-redirect resp.geturl(), so a hostile redirect target cannot
# crash the fetch either).
try:
parsed = urlparse(url)
hostname = parsed.hostname
except ValueError:
raise WorkflowCatalogError(
f"Refusing to fetch catalog from malformed URL: {url}"
) from None
is_localhost = hostname in ("localhost", "127.0.0.1", "::1")
if parsed.scheme != "https" and not ( if parsed.scheme != "https" and not (
parsed.scheme == "http" and is_localhost parsed.scheme == "http" and is_localhost
): ):
raise WorkflowCatalogError( raise WorkflowCatalogError(
f"Refusing to fetch catalog from non-HTTPS URL: {url}" f"Refusing to fetch catalog from non-HTTPS URL: {url}"
) )
if not hostname: if not parsed.hostname:
raise WorkflowCatalogError( raise WorkflowCatalogError(
f"Refusing to fetch catalog from URL with no hostname: {url}" f"Refusing to fetch catalog from URL with no hostname: {url}"
) )
@@ -592,7 +427,6 @@ class WorkflowCatalog:
self, self,
query: str | None = None, query: str | None = None,
tag: str | None = None, tag: str | None = None,
author: str | None = None,
) -> list[dict[str, Any]]: ) -> list[dict[str, Any]]:
"""Search workflows across all configured catalogs.""" """Search workflows across all configured catalogs."""
merged = self._get_merged_workflows() merged = self._get_merged_workflows()
@@ -604,9 +438,9 @@ class WorkflowCatalog:
q = query.lower() q = query.lower()
searchable = " ".join( searchable = " ".join(
[ [
str(wf_data.get("name") or ""), wf_data.get("name", ""),
str(wf_data.get("description") or ""), wf_data.get("description", ""),
str(wf_data.get("id") or ""), wf_data.get("id", ""),
] ]
).lower() ).lower()
if q not in searchable: if q not in searchable:
@@ -617,10 +451,6 @@ class WorkflowCatalog:
normalized_tags = [t.lower() for t in tags if isinstance(t, str)] normalized_tags = [t.lower() for t in tags if isinstance(t, str)]
if tag.lower() not in normalized_tags: if tag.lower() not in normalized_tags:
continue continue
if author:
wf_author = wf_data.get("author", "")
if not isinstance(wf_author, str) or wf_author.lower() != author.lower():
continue
results.append(wf_data) results.append(wf_data)
return results return results
@@ -944,20 +774,8 @@ class StepCatalog:
"""Validate that a catalog URL uses HTTPS (localhost HTTP allowed).""" """Validate that a catalog URL uses HTTPS (localhost HTTP allowed)."""
from urllib.parse import urlparse from urllib.parse import urlparse
# A malformed authority (e.g. an unterminated IPv6 bracket parsed = urlparse(url)
# "https://[::1") makes urlparse / hostname access raise ValueError. is_localhost = parsed.hostname in ("localhost", "127.0.0.1", "::1")
# This validator's contract is to raise StepValidationError for a bad
# URL, so surface that rather than leaking a raw ValueError past the
# command handler (which only catches StepValidationError). Mirrors
# specify_cli.catalogs (#3435).
try:
parsed = urlparse(url)
hostname = parsed.hostname
except ValueError:
raise StepValidationError(
f"Catalog URL is malformed: {url}"
) from None
is_localhost = hostname in ("localhost", "127.0.0.1", "::1")
if parsed.scheme != "https" and not ( if parsed.scheme != "https" and not (
parsed.scheme == "http" and is_localhost parsed.scheme == "http" and is_localhost
): ):
@@ -965,7 +783,7 @@ class StepCatalog:
f"Catalog URL must use HTTPS (got {parsed.scheme}://). " f"Catalog URL must use HTTPS (got {parsed.scheme}://). "
"HTTP is only allowed for localhost." "HTTP is only allowed for localhost."
) )
if not hostname: if not parsed.hostname:
raise StepValidationError( raise StepValidationError(
"Catalog URL must be a valid URL with a host." "Catalog URL must be a valid URL with a host."
) )
@@ -1131,26 +949,15 @@ class StepCatalog:
from specify_cli.authentication.http import open_url as _open_url from specify_cli.authentication.http import open_url as _open_url
def _validate_url(url: str) -> None: def _validate_url(url: str) -> None:
# A malformed authority (e.g. "https://[::1") makes urlparse / parsed = urlparse(url)
# hostname access raise ValueError; treat it as a refused fetch is_localhost = parsed.hostname in ("localhost", "127.0.0.1", "::1")
# rather than leaking a raw ValueError (this also validates the
# post-redirect resp.geturl(), so a hostile redirect target cannot
# crash the fetch either).
try:
parsed = urlparse(url)
hostname = parsed.hostname
except ValueError:
raise StepCatalogError(
f"Refusing to fetch catalog from malformed URL: {url}"
) from None
is_localhost = hostname in ("localhost", "127.0.0.1", "::1")
if parsed.scheme != "https" and not ( if parsed.scheme != "https" and not (
parsed.scheme == "http" and is_localhost parsed.scheme == "http" and is_localhost
): ):
raise StepCatalogError( raise StepCatalogError(
f"Refusing to fetch catalog from non-HTTPS URL: {url}" f"Refusing to fetch catalog from non-HTTPS URL: {url}"
) )
if not hostname: if not parsed.hostname:
raise StepCatalogError( raise StepCatalogError(
f"Refusing to fetch catalog from URL with no hostname: {url}" f"Refusing to fetch catalog from URL with no hostname: {url}"
) )
+15 -179
View File
@@ -129,50 +129,27 @@ def validate_workflow(definition: WorkflowDefinition) -> list[str]:
errors: list[str] = [] errors: list[str] = []
# -- Schema version --------------------------------------------------- # -- Schema version ---------------------------------------------------
# str() so an unquoted ``schema_version: 1.0`` (YAML float) is accepted — if definition.schema_version not in ("1.0", "1"):
# rejecting it would print "Unsupported schema_version 1.0. Expected '1.0'."
if str(definition.schema_version) != "1.0":
errors.append( errors.append(
f"Unsupported schema_version {definition.schema_version!r}. " f"Unsupported schema_version {definition.schema_version!r}. "
f"Expected '1.0'." f"Expected '1.0'."
) )
# -- Top-level fields ------------------------------------------------- # -- Top-level fields -------------------------------------------------
# YAML parses unquoted scalars like ``id: 123`` or ``version: 1.0`` as if not definition.id:
# int/float; check types before regex/string operations so authoring
# mistakes surface as validation errors instead of tracebacks. Only
# ``None``/empty-string count as missing so falsey non-strings
# (``id: 0``, ``name: false``) still get the typed error.
if definition.id is None or definition.id == "":
errors.append("Workflow is missing 'workflow.id'.") errors.append("Workflow is missing 'workflow.id'.")
elif not isinstance(definition.id, str): elif not _ID_PATTERN.match(definition.id):
errors.append(
f"'workflow.id' must be a string, got "
f"{type(definition.id).__name__} ({definition.id!r})."
)
elif not _ID_PATTERN.fullmatch(definition.id):
errors.append( errors.append(
f"Workflow ID {definition.id!r} must be lowercase alphanumeric " f"Workflow ID {definition.id!r} must be lowercase alphanumeric "
f"with hyphens." f"with hyphens."
) )
if definition.name is None or definition.name == "": if not definition.name:
errors.append("Workflow is missing 'workflow.name'.") errors.append("Workflow is missing 'workflow.name'.")
elif not isinstance(definition.name, str):
errors.append(
f"'workflow.name' must be a string, got "
f"{type(definition.name).__name__} ({definition.name!r})."
)
if definition.version is None or definition.version == "": if not definition.version:
errors.append("Workflow is missing 'workflow.version'.") errors.append("Workflow is missing 'workflow.version'.")
elif not isinstance(definition.version, str): elif not re.match(r"^\d+\.\d+\.\d+$", definition.version):
errors.append(
f"'workflow.version' must be a string, got "
f"{type(definition.version).__name__} ({definition.version!r}) — "
f'quote it in YAML (version: "1.0.0").'
)
elif not re.fullmatch(r"\d+\.\d+\.\d+", definition.version):
errors.append( errors.append(
f"Workflow version {definition.version!r} is not valid " f"Workflow version {definition.version!r} is not valid "
f"semantic versioning (expected X.Y.Z)." f"semantic versioning (expected X.Y.Z)."
@@ -279,15 +256,9 @@ def _validate_steps(
continue continue
step_id = step_config.get("id") step_id = step_config.get("id")
if step_id is None or step_id == "": if not step_id:
errors.append("Step is missing 'id' field.") errors.append("Step is missing 'id' field.")
continue continue
if not isinstance(step_id, str):
errors.append(
f"Step ID must be a string, got "
f"{type(step_id).__name__} ({step_id!r})."
)
continue
if ":" in step_id: if ":" in step_id:
errors.append( errors.append(
@@ -416,57 +387,18 @@ class RunState:
ID into a path so a malicious value cannot probe or read files ID into a path so a malicious value cannot probe or read files
outside ``.specify/workflows/runs/<run_id>/``. outside ``.specify/workflows/runs/<run_id>/``.
""" """
if not isinstance(run_id, str) or not cls._RUN_ID_PATTERN.fullmatch(run_id): if not isinstance(run_id, str) or not cls._RUN_ID_PATTERN.match(run_id):
raise ValueError( raise ValueError(
f"Invalid run_id {run_id!r}: must be alphanumeric with " f"Invalid run_id {run_id!r}: must be alphanumeric with "
"hyphens/underscores only (and must start with an " "hyphens/underscores only (and must start with an "
"alphanumeric character)." "alphanumeric character)."
) )
@staticmethod
def _validate_installed_origin(
installed_workflow_id: str | None,
installed_registry_root: str | None,
) -> None:
"""Validate persisted installed-workflow ownership metadata."""
if installed_workflow_id is not None:
if not isinstance(installed_workflow_id, str):
raise ValueError(
"Invalid run state: 'installed_workflow_id' must be a "
f"string or null, got {type(installed_workflow_id).__name__}"
)
if not _ID_PATTERN.fullmatch(installed_workflow_id):
raise ValueError(
"Invalid run state: 'installed_workflow_id' must be a "
"lowercase alphanumeric workflow ID with hyphens"
)
if installed_registry_root is not None:
if not isinstance(installed_registry_root, str):
raise ValueError(
"Invalid run state: 'installed_registry_root' must be a "
f"string or null, got {type(installed_registry_root).__name__}"
)
if not installed_registry_root or not Path(
installed_registry_root
).is_absolute():
raise ValueError(
"Invalid run state: 'installed_registry_root' must be "
"an absolute path or null"
)
if installed_workflow_id is None:
raise ValueError(
"Invalid run state: 'installed_registry_root' requires "
"'installed_workflow_id'"
)
def __init__( def __init__(
self, self,
run_id: str | None = None, run_id: str | None = None,
workflow_id: str = "", workflow_id: str = "",
project_root: Path | None = None, project_root: Path | None = None,
installed_workflow_id: str | None = None,
installed_registry_root: str | None = None,
installed_origin_tracked: bool = True,
) -> None: ) -> None:
# ``run_id is None`` (omitted) → auto-generate. An explicit empty # ``run_id is None`` (omitted) → auto-generate. An explicit empty
# string is *not* the same as "omitted" and must be validated like # string is *not* the same as "omitted" and must be validated like
@@ -478,22 +410,8 @@ class RunState:
else: else:
self.run_id = run_id self.run_id = run_id
self._validate_run_id(self.run_id) self._validate_run_id(self.run_id)
self._validate_installed_origin(
installed_workflow_id, installed_registry_root
)
self.workflow_id = workflow_id self.workflow_id = workflow_id
self.project_root = project_root or Path(".") self.project_root = project_root or Path(".")
# Identifies the installed workflow (if any) this run was started
# from, and the project root that owns its registry — set by
# execute() when the source was resolved to an installed ID (see
# workflow_run's ownership mapping). None for a direct/non-installed
# YAML source. ``installed_origin_tracked`` distinguishes those
# explicit None values from legacy state files that predate both
# fields, allowing the CLI to conservatively infer same-project
# registry ownership before resuming.
self.installed_workflow_id = installed_workflow_id
self.installed_registry_root = installed_registry_root
self.installed_origin_tracked = installed_origin_tracked
self.status = RunStatus.CREATED self.status = RunStatus.CREATED
self.current_step_index = 0 self.current_step_index = 0
self.current_step_id: str | None = None self.current_step_id: str | None = None
@@ -508,7 +426,6 @@ class RunState:
# append_log is never called while _lock is held, the two never nest. # append_log is never called while _lock is held, the two never nest.
self._log_lock = threading.Lock() self._log_lock = threading.Lock()
self.inputs: dict[str, Any] = {} self.inputs: dict[str, Any] = {}
self.workflow_dir: str | None = None
self.created_at = datetime.now(timezone.utc).isoformat() self.created_at = datetime.now(timezone.utc).isoformat()
self.updated_at = self.created_at self.updated_at = self.created_at
self.log_entries: list[dict[str, Any]] = [] self.log_entries: list[dict[str, Any]] = []
@@ -557,13 +474,10 @@ class RunState:
state_data = { state_data = {
"run_id": self.run_id, "run_id": self.run_id,
"workflow_id": self.workflow_id, "workflow_id": self.workflow_id,
"installed_workflow_id": self.installed_workflow_id,
"installed_registry_root": self.installed_registry_root,
"status": self.status.value, "status": self.status.value,
"current_step_index": self.current_step_index, "current_step_index": self.current_step_index,
"current_step_id": self.current_step_id, "current_step_id": self.current_step_id,
"step_results": self.step_results, "step_results": self.step_results,
"workflow_dir": self.workflow_dir,
"created_at": self.created_at, "created_at": self.created_at,
"updated_at": self.updated_at, "updated_at": self.updated_at,
} }
@@ -611,52 +525,16 @@ class RunState:
with open(state_path, encoding="utf-8") as f: with open(state_path, encoding="utf-8") as f:
state_data = json.load(f) state_data = json.load(f)
if not isinstance(state_data, dict):
raise ValueError("Invalid run state: expected a JSON object")
missing_fields = [
field
for field in ("run_id", "workflow_id", "status")
if field not in state_data
]
if missing_fields:
raise ValueError(
"Invalid run state: missing required field(s): "
+ ", ".join(missing_fields)
)
workflow_id = state_data["workflow_id"]
if not isinstance(workflow_id, str) or not _ID_PATTERN.fullmatch(
workflow_id
):
raise ValueError(
"Invalid run state: 'workflow_id' must be a lowercase "
"alphanumeric workflow ID with hyphens"
)
has_installed_workflow_id = "installed_workflow_id" in state_data
has_installed_registry_root = "installed_registry_root" in state_data
if has_installed_workflow_id != has_installed_registry_root:
raise ValueError(
"Invalid run state: installed workflow origin fields must "
"either both be present or both be absent"
)
installed_workflow_id = state_data.get("installed_workflow_id")
installed_registry_root = state_data.get("installed_registry_root")
state = cls( state = cls(
run_id=state_data["run_id"], run_id=state_data["run_id"],
workflow_id=workflow_id, workflow_id=state_data["workflow_id"],
project_root=project_root, project_root=project_root,
installed_workflow_id=installed_workflow_id,
installed_registry_root=installed_registry_root,
installed_origin_tracked=has_installed_workflow_id,
) )
state.status = RunStatus(state_data["status"]) state.status = RunStatus(state_data["status"])
state.current_step_index = state_data.get("current_step_index", 0) state.current_step_index = state_data.get("current_step_index", 0)
state.current_step_id = state_data.get("current_step_id") state.current_step_id = state_data.get("current_step_id")
state.step_results = state_data.get("step_results", {}) state.step_results = state_data.get("step_results", {})
state.workflow_dir = state_data.get("workflow_dir")
state.created_at = state_data.get("created_at", "") state.created_at = state_data.get("created_at", "")
state.updated_at = state_data.get("updated_at", "") state.updated_at = state_data.get("updated_at", "")
@@ -664,16 +542,7 @@ class RunState:
if inputs_path.exists(): if inputs_path.exists():
with open(inputs_path, encoding="utf-8") as f: with open(inputs_path, encoding="utf-8") as f:
inputs_data = json.load(f) inputs_data = json.load(f)
if not isinstance(inputs_data, dict): state.inputs = inputs_data.get("inputs", {})
raise ValueError(
"Invalid run inputs: expected a JSON object"
)
inputs = inputs_data.get("inputs", {})
if not isinstance(inputs, dict):
raise ValueError(
"Invalid run inputs: 'inputs' must be a JSON object"
)
state.inputs = inputs
return state return state
@@ -756,8 +625,6 @@ class WorkflowEngine:
definition: WorkflowDefinition, definition: WorkflowDefinition,
inputs: dict[str, Any] | None = None, inputs: dict[str, Any] | None = None,
run_id: str | None = None, run_id: str | None = None,
installed_workflow_id: str | None = None,
installed_registry_root: Path | None = None,
) -> RunState: ) -> RunState:
"""Execute a workflow definition. """Execute a workflow definition.
@@ -769,12 +636,6 @@ class WorkflowEngine:
User-provided input values. User-provided input values.
run_id: run_id:
Optional run ID (uses SPECKIT_WORKFLOW_RUN_ID when set, otherwise auto-generated). Optional run ID (uses SPECKIT_WORKFLOW_RUN_ID when set, otherwise auto-generated).
installed_workflow_id, installed_registry_root:
When the run was started from an installed workflow (as opposed
to a direct/non-installed YAML source), identifies it and its
owning registry root so a later ``resume`` can re-check the
registry's current disabled state before continuing — see
``workflow_resume``.
Returns Returns
------- -------
@@ -792,12 +653,6 @@ class WorkflowEngine:
run_id=effective_run_id, run_id=effective_run_id,
workflow_id=definition.id, workflow_id=definition.id,
project_root=self.project_root, project_root=self.project_root,
installed_workflow_id=installed_workflow_id,
installed_registry_root=(
str(installed_registry_root)
if installed_registry_root is not None
else None
),
) )
# Persist a copy of the workflow definition so resume can # Persist a copy of the workflow definition so resume can
@@ -813,12 +668,6 @@ class WorkflowEngine:
# Resolve inputs # Resolve inputs
resolved_inputs = self._resolve_inputs(definition, inputs or {}) resolved_inputs = self._resolve_inputs(definition, inputs or {})
state.inputs = resolved_inputs state.inputs = resolved_inputs
workflow_dir = (
str(definition.source_path.resolve().parent)
if definition.source_path is not None
else None
)
state.workflow_dir = workflow_dir
state.status = RunStatus.RUNNING state.status = RunStatus.RUNNING
state.save() state.save()
@@ -829,7 +678,6 @@ class WorkflowEngine:
default_options=definition.default_options, default_options=definition.default_options,
project_root=str(self.project_root), project_root=str(self.project_root),
run_id=state.run_id, run_id=state.run_id,
workflow_dir=workflow_dir,
) )
# Execute steps # Execute steps
@@ -895,7 +743,6 @@ class WorkflowEngine:
default_options=definition.default_options, default_options=definition.default_options,
project_root=str(self.project_root), project_root=str(self.project_root),
run_id=state.run_id, run_id=state.run_id,
workflow_dir=state.workflow_dir,
) )
from . import STEP_REGISTRY from . import STEP_REGISTRY
@@ -1106,16 +953,7 @@ class WorkflowEngine:
from .expressions import evaluate_condition from .expressions import evaluate_condition
max_iters = step_config.get("max_iterations") max_iters = step_config.get("max_iterations")
# A bool is an int in Python (isinstance(True, int) is True if not isinstance(max_iters, int) or max_iters < 1:
# and True == 1), so a bool max_iterations would slip past
# the int check and cap the loop at range(0)==1 iteration
# instead of the default. Exclude bools, mirroring the
# while/do-while validators and the continue_on_error guard.
if (
isinstance(max_iters, bool)
or not isinstance(max_iters, int)
or max_iters < 1
):
max_iters = 10 max_iters = 10
condition = step_config.get("condition", False) condition = step_config.get("condition", False)
for _loop_iter in range(max_iters - 1): for _loop_iter in range(max_iters - 1):
@@ -1208,9 +1046,9 @@ class WorkflowEngine:
already flipped), so the prefix never drops the actual halting item. already flipped), so the prefix never drops the actual halting item.
``max_concurrency`` is coerced with ``int()``; a value that cannot be ``max_concurrency`` is coerced with ``int()``; a value that cannot be
coerced (``None``, a non-numeric string, ``.inf``/``.nan``, …) or that coerced (``None``, a non-numeric string, …) or that coerces to <= 1 runs
coerces to <= 1 runs sequentially, while a numeric string like ``"4"`` or sequentially, while a numeric string like ``"4"`` or a float like ``4.0``
a float like ``4.0`` is honored. is honored.
""" """
if not items: if not items:
return [] return []
@@ -1218,9 +1056,7 @@ class WorkflowEngine:
halting = (RunStatus.PAUSED, RunStatus.FAILED, RunStatus.ABORTED) halting = (RunStatus.PAUSED, RunStatus.FAILED, RunStatus.ABORTED)
try: try:
workers = max(1, int(max_concurrency)) workers = max(1, int(max_concurrency))
except (TypeError, ValueError, OverflowError): except (TypeError, ValueError):
# OverflowError: int(float("inf")) — a YAML ``max_concurrency: .inf``
# would otherwise crash the whole run instead of falling back.
workers = 1 workers = 1
# Never spin up more workers than there is work — bounds a user-controlled # Never spin up more workers than there is work — bounds a user-controlled
# max_concurrency from over-allocating threads. # max_concurrency from over-allocating threads.
+64 -159
View File
@@ -35,38 +35,14 @@ def _filter_default(value: Any, default_value: Any = "") -> Any:
def _filter_join(value: Any, separator: str = ", ") -> str: def _filter_join(value: Any, separator: str = ", ") -> str:
"""Join a list into a string with *separator*. """Join a list into a string with *separator*."""
Raises ``ValueError`` when *separator* is not a string. Without the guard a
non-string separator (an authoring mistake like ``| join(5)``) reaches
``str.join`` and raises a cryptic ``AttributeError: 'int' object has no
attribute 'join'`` that escapes the evaluator and crashes the whole run,
since the engine wraps neither expression evaluation nor ``execute`` in a
try/except. Mirrors the strict argument handling in ``from_json``.
"""
if not isinstance(separator, str):
raise ValueError(
f"join: expected a string separator, got {type(separator).__name__}"
)
if isinstance(value, list): if isinstance(value, list):
return separator.join(str(v) for v in value) return separator.join(str(v) for v in value)
return str(value) return str(value)
def _filter_map(value: Any, attr: str) -> list[Any]: def _filter_map(value: Any, attr: str) -> list[Any]:
"""Map a list of dicts to a specific attribute. """Map a list of dicts to a specific attribute."""
Raises ``ValueError`` when *attr* is not a string. Without the guard a
non-string attribute (an authoring mistake like ``| map(5)``) reaches
``attr.split(".")`` and raises a cryptic ``AttributeError: 'int' object has
no attribute 'split'`` that escapes the evaluator and crashes the whole run,
since the engine wraps neither expression evaluation nor ``execute`` in a
try/except. Mirrors the strict argument handling in ``from_json``.
"""
if not isinstance(attr, str):
raise ValueError(
f"map: expected a string attribute name, got {type(attr).__name__}"
)
if isinstance(value, list): if isinstance(value, list):
result = [] result = []
for item in value: for item in value:
@@ -87,25 +63,9 @@ def _filter_map(value: Any, attr: str) -> list[Any]:
return [] return []
def _filter_contains(value: Any, substring: Any) -> bool: def _filter_contains(value: Any, substring: str) -> bool:
"""Check if a string or list contains *substring*. """Check if a string or list contains *substring*."""
For a string *value*, *substring* must itself be a string: ``x in y`` on a
string requires a string left operand, so a non-string argument (an
authoring mistake like ``| contains(5)``) would otherwise raise a cryptic
``TypeError`` that escapes the evaluator and crashes the whole run, since
the engine wraps neither expression evaluation nor ``execute`` in a
try/except. Raise a ``ValueError`` naming the problem instead, mirroring the
strict argument handling in ``from_json``. For a list *value*, membership of
any element type is legitimate (``5 in [1, 2, 5]``), so that branch is left
unguarded.
"""
if isinstance(value, str): if isinstance(value, str):
if not isinstance(substring, str):
raise ValueError(
"contains: expected a string argument when the value is a "
f"string, got {type(substring).__name__}"
)
return substring in value return substring in value
if isinstance(value, list): if isinstance(value, list):
return substring in value return substring in value
@@ -182,8 +142,7 @@ def _build_namespace(context: Any) -> dict[str, Any]:
# runs use an 8-character uuid4 hex; operator-supplied ids may be # runs use an 8-character uuid4 hex; operator-supplied ids may be
# any alphanumeric string with hyphens or underscores. # any alphanumeric string with hyphens or underscores.
run_id = getattr(context, "run_id", None) or "" run_id = getattr(context, "run_id", None) or ""
workflow_dir = getattr(context, "workflow_dir", None) or "" ns["context"] = {"run_id": run_id}
ns["context"] = {"run_id": run_id, "workflow_dir": workflow_dir}
return ns return ns
@@ -283,26 +242,6 @@ def _interpolate_expressions(template: str, namespace: dict[str, Any]) -> str:
return "".join(out) return "".join(out)
def _split_top_level(text: str, sep: str) -> list[str]:
"""Split *text* on each occurrence of *sep* that lies outside any quoted
string or nested brackets.
Used to break a filter chain (``a | map('x') | join(',')``) into its
individual filter segments without splitting on a ``|`` that appears inside
a quoted argument. Each returned segment is a slice at a top-level
boundary, so the quote/bracket scan restarts cleanly on the remainder.
"""
parts: list[str] = []
start = 0
while True:
idx = _find_top_level(text[start:], sep)
if idx == -1:
parts.append(text[start:])
return parts
parts.append(text[start:start + idx])
start += idx + len(sep)
def _split_top_level_commas(text: str) -> list[str]: def _split_top_level_commas(text: str) -> list[str]:
"""Split *text* on commas that are not inside quotes or nested brackets. """Split *text* on commas that are not inside quotes or nested brackets.
@@ -366,68 +305,6 @@ def _find_top_level(text: str, token: str) -> int:
return -1 return -1
def _apply_filter(value: Any, filter_expr: str, namespace: dict[str, Any]) -> Any:
"""Apply a single pipe filter segment to *value*.
*filter_expr* is one link of a filter chain — the text between two
top-level ``|`` separators, already stripped (e.g. ``map('name')``,
``default('x')``, ``from_json``). Returns the filtered value so the caller
can feed it into the next link.
Raises ``ValueError`` on any mis-wired or unknown filter rather than
silently returning *value* unchanged: a passthrough would turn a mistyped
or unsupported filter into a wrong result with no signal.
"""
# `from_json` is strict: it takes no arguments and tolerates no trailing
# tokens. Match on the leading filter name and require the whole filter to
# be exactly `from_json`, so every mis-wired form (`from_json()`,
# `from_json('x')`, `from_json)`, `from_json extra`) fails loudly instead of
# silently falling through to the unknown-filter path.
leading = re.match(r"\w+", filter_expr)
if leading and leading.group(0) == "from_json":
if filter_expr != "from_json":
raise ValueError(
"from_json: expected '| from_json' with no arguments or "
f"trailing tokens, got '| {filter_expr}'"
)
return _filter_from_json(value)
# Parse filter name and argument
filter_match = re.match(r"(\w+)\((.+)\)", filter_expr)
if filter_match:
fname = filter_match.group(1)
farg = _evaluate_simple_expression(filter_match.group(2).strip(), namespace)
if fname == "default":
return _filter_default(value, farg)
if fname == "join":
return _filter_join(value, farg)
if fname == "map":
return _filter_map(value, farg)
if fname == "contains":
return _filter_contains(value, farg)
# Filter without args
if filter_expr == "default":
return _filter_default(value)
# No recognized filter matched. Fail loudly rather than silently returning
# the unfiltered value. Distinguish a *registered* filter used in an
# unsupported form (e.g. `| join` or `| map` with no argument) from a
# genuinely unknown filter name, so the message names the real problem
# instead of calling a known filter "unknown".
name = leading.group(0) if leading else filter_expr
expected = (
"expected one of default or default('x'), join('sep'), "
"map('attr'), contains('s'), or from_json"
)
if name in _REGISTERED_FILTERS:
raise ValueError(
f"filter '{name}' used in an unsupported form (got "
f"'| {filter_expr}'): {expected}"
)
raise ValueError(
f"unknown filter '{name}': {expected} (got '| {filter_expr}')"
)
def _evaluate_simple_expression(expr: str, namespace: dict[str, Any]) -> Any: def _evaluate_simple_expression(expr: str, namespace: dict[str, Any]) -> Any:
"""Evaluate a simple expression against the namespace. """Evaluate a simple expression against the namespace.
@@ -452,17 +329,65 @@ def _evaluate_simple_expression(expr: str, namespace: dict[str, Any]) -> Any:
# Handle pipe filters. Detect the pipe at the top level only, so a literal # Handle pipe filters. Detect the pipe at the top level only, so a literal
# '|' inside a quoted operand (e.g. `inputs.x == 'a|b'`) or nested brackets is # '|' inside a quoted operand (e.g. `inputs.x == 'a|b'`) or nested brackets is
# not mistaken for a filter separator — mirroring the operator parsing below. # not mistaken for a filter separator — mirroring the operator parsing below.
# Filters chain left-to-right: `list | map('name') | join(', ')` feeds each
# filter's result into the next, so `map` (which yields a list) can be
# rendered by `join`. Splitting only at the first pipe would hand the whole
# tail to one filter and mangle any later `|`.
pipe_idx = _find_top_level(expr, "|") pipe_idx = _find_top_level(expr, "|")
if pipe_idx != -1: if pipe_idx != -1:
segments = _split_top_level(expr, "|") value = _evaluate_simple_expression(expr[:pipe_idx].strip(), namespace)
value = _evaluate_simple_expression(segments[0].strip(), namespace) filter_expr = expr[pipe_idx + 1:].strip()
for segment in segments[1:]:
value = _apply_filter(value, segment.strip(), namespace) # `from_json` is strict: it takes no arguments and tolerates no
return value # trailing tokens. Match on the leading filter name and require the
# whole filter to be exactly `from_json`, so every mis-wired form
# (`from_json()`, `from_json('x')`, `from_json)`, `from_json extra`)
# fails loudly instead of silently falling through to the
# unknown-filter path and returning the unparsed value. (filter_expr
# is already stripped above.)
leading = re.match(r"\w+", filter_expr)
if leading and leading.group(0) == "from_json":
if filter_expr != "from_json":
raise ValueError(
"from_json: expected '| from_json' with no arguments or "
f"trailing tokens, got '| {filter_expr}'"
)
return _filter_from_json(value)
# Parse filter name and argument
filter_match = re.match(r"(\w+)\((.+)\)", filter_expr)
if filter_match:
fname = filter_match.group(1)
farg = _evaluate_simple_expression(filter_match.group(2).strip(), namespace)
if fname == "default":
return _filter_default(value, farg)
if fname == "join":
return _filter_join(value, farg)
if fname == "map":
return _filter_map(value, farg)
if fname == "contains":
return _filter_contains(value, farg)
# Filter without args
filter_name = filter_expr.strip()
if filter_name == "default":
return _filter_default(value)
# No recognized filter matched. Fail loudly rather than silently
# returning the unfiltered value: a passthrough turns a mis-typed or
# unsupported filter into a wrong result with no signal. Mirrors the
# strict `from_json` handling above. Distinguish a *registered* filter
# used in an unsupported form (e.g. `| join` or `| map` with no
# argument) from a genuinely unknown filter name, so the message names
# the real problem instead of calling a known filter "unknown".
leading_name = re.match(r"\w+", filter_expr)
name = leading_name.group(0) if leading_name else filter_expr
expected = (
"expected one of default or default('x'), join('sep'), "
"map('attr'), contains('s'), or from_json"
)
if name in _REGISTERED_FILTERS:
raise ValueError(
f"filter '{name}' used in an unsupported form (got "
f"'| {filter_expr}'): {expected}"
)
raise ValueError(
f"unknown filter '{name}': {expected} (got '| {filter_expr}')"
)
# Boolean operators — parse 'or' first (lower precedence) so that # Boolean operators — parse 'or' first (lower precedence) so that
# 'a or b and c' is evaluated as 'a or (b and c)'. Splits are quote/bracket # 'a or b and c' is evaluated as 'a or (b and c)'. Splits are quote/bracket
@@ -505,9 +430,9 @@ def _evaluate_simple_expression(expr: str, namespace: dict[str, Any]) -> Any:
if op == "<=": if op == "<=":
return _safe_compare(left, right, "<=") return _safe_compare(left, right, "<=")
if op == " in ": if op == " in ":
return _safe_membership(left, right, negate=False) return left in right if right is not None else False
if op == " not in ": if op == " not in ":
return _safe_membership(left, right, negate=True) return left not in right if right is not None else True
# Numeric literal # Numeric literal
try: try:
@@ -552,26 +477,6 @@ def _coerce_number(value: Any) -> Any:
return value return value
def _safe_membership(left: Any, right: Any, *, negate: bool) -> bool:
"""Safely evaluate ``left in right`` (or ``not in``) without crashing.
``left in right`` raises ``TypeError`` whenever the operands don't support
membership testing — most commonly a non-iterable right operand (``None``,
an int, a bool), but also cases like an unhashable ``left`` against a set.
In every such case the membership relation is undefined, so treat it as
``False`` (``not in`` as ``True``) rather than leaking the error out of the
evaluator and crashing the whole workflow. Mirrors the graceful
``TypeError`` handling in ``_safe_compare`` for the ordering operators, and
generalizes the previous ``right is not None`` guard to any operand pair
that can't be membership-tested.
"""
try:
contained = left in right
except TypeError:
contained = False
return not contained if negate else contained
def _safe_compare(left: Any, right: Any, op: str) -> bool: def _safe_compare(left: Any, right: Any, op: str) -> bool:
"""Compare two values for ordering, coercing numeric strings when possible. """Compare two values for ordering, coercing numeric strings when possible.
@@ -31,20 +31,6 @@ class CommandStep(StepBase):
def execute(self, config: dict[str, Any], context: StepContext) -> StepResult: def execute(self, config: dict[str, Any], context: StepContext) -> StepResult:
command = config.get("command", "") command = config.get("command", "")
input_data = config.get("input", {}) input_data = config.get("input", {})
# validate() rejects a non-mapping input, but the engine does not
# auto-validate before execute(); a workflow that skipped validation can
# still reach here. Fail the step with the same contract error rather
# than silently coercing to {} and dispatching with empty args — that
# would change the command's meaning, hide the config error, and report
# COMPLETED, defeating the per-step FAILED / continue_on_error behavior.
if not isinstance(input_data, dict):
return StepResult(
status=StepStatus.FAILED,
error=(
f"Command step {config.get('id', '?')!r}: 'input' must be a "
f"mapping, got {type(input_data).__name__}."
),
)
# Resolve expressions in input # Resolve expressions in input
resolved_input: dict[str, Any] = {} resolved_input: dict[str, Any] = {}
@@ -64,18 +50,8 @@ class CommandStep(StepBase):
# Merge options (workflow defaults ← step overrides) # Merge options (workflow defaults ← step overrides)
options = dict(context.default_options) options = dict(context.default_options)
step_options = config.get("options", {}) step_options = config.get("options", {})
# Same rationale as 'input': a malformed options fails the step rather if step_options:
# than being silently ignored (which would let an invalid step run and options.update(step_options)
# apparently complete).
if not isinstance(step_options, dict):
return StepResult(
status=StepStatus.FAILED,
error=(
f"Command step {config.get('id', '?')!r}: 'options' must be a "
f"mapping, got {type(step_options).__name__}."
),
)
options.update(step_options)
# Attempt CLI dispatch # Attempt CLI dispatch
args_str = str(resolved_input.get("args", "")) args_str = str(resolved_input.get("args", ""))
@@ -179,16 +155,4 @@ class CommandStep(StepBase):
errors.append( errors.append(
f"Command step {config.get('id', '?')!r} is missing 'command' field." f"Command step {config.get('id', '?')!r} is missing 'command' field."
) )
# execute() iterates input.items() and options.update(step_options); a
# non-mapping here would raise at run time. Validate the shape like the
# sibling steps (switch 'cases', fan-out 'step') so it is reported, not
# crashed on.
if "input" in config and not isinstance(config["input"], dict):
errors.append(
f"Command step {config.get('id', '?')!r}: 'input' must be a mapping."
)
if "options" in config and not isinstance(config["options"], dict):
errors.append(
f"Command step {config.get('id', '?')!r}: 'options' must be a mapping."
)
return errors return errors
@@ -27,30 +27,6 @@ class DoWhileStep(StepBase):
nested_steps = config.get("steps", []) nested_steps = config.get("steps", [])
condition = config.get("condition", "false") condition = config.get("condition", "false")
# The engine does not auto-validate step config (see
# ``WorkflowEngine.load_workflow``) and feeds ``next_steps`` straight
# into ``_execute_steps``, which iterates them as step mappings. A
# non-list ``steps`` (a single mapping or scalar authoring mistake)
# would otherwise be iterated element-wise — a dict yields its string
# keys, a str its characters — and crash the whole run with
# AttributeError on ``.get()``. ``validate`` already rejects a non-list
# ``steps``; fail this step loudly on an unvalidated run instead,
# mirroring the if/switch/fan-out steps. The body always runs on the
# first call, so unlike the while step this guard is unconditional.
if not isinstance(nested_steps, list):
return StepResult(
status=StepStatus.FAILED,
output={
"condition": condition,
"max_iterations": max_iterations,
"loop_type": "do-while",
},
error=(
f"Do-while step {config.get('id', '?')!r}: 'steps' must be "
f"a list of steps, got {type(nested_steps).__name__}."
),
)
# Always execute body at least once; the engine layer evaluates # Always execute body at least once; the engine layer evaluates
# `condition` after each iteration to decide whether to loop. # `condition` after each iteration to decide whether to loop.
return StepResult( return StepResult(
@@ -24,24 +24,6 @@ class FanInStep(StepBase):
if not isinstance(output_config, dict): if not isinstance(output_config, dict):
output_config = {} output_config = {}
# The engine does not auto-validate step config, so an unvalidated run
# with a non-list ``wait_for`` reaches here raw. Iterating it then
# either crashes the whole run (a scalar like an int or None raises
# TypeError) or, worse, silently iterates a string's characters and
# yields a bogus join of empty results with a COMPLETED status — the
# exact "silent empty result + COMPLETED" wiring bug the engine's
# fan-in validation guards against. Fail this step loudly instead,
# mirroring the fan-out step's non-list ``items`` handling.
if not isinstance(wait_for, list):
return StepResult(
status=StepStatus.FAILED,
error=(
f"Fan-in step {config.get('id', '?')!r}: 'wait_for' must be "
f"a list of step IDs, got {type(wait_for).__name__}."
),
output={"results": []},
)
# Collect results from referenced steps # Collect results from referenced steps
results = [] results = []
for step_id in wait_for: for step_id in wait_for:
@@ -47,8 +47,8 @@ class IfThenStep(StepBase):
errors.append( errors.append(
f"If step {config.get('id', '?')!r}: 'then' must be a list of steps." f"If step {config.get('id', '?')!r}: 'then' must be a list of steps."
) )
else_branch = config.get("else") else_branch = config.get("else", [])
if else_branch is not None and not isinstance(else_branch, list): if else_branch and not isinstance(else_branch, list):
errors.append( errors.append(
f"If step {config.get('id', '?')!r}: 'else' must be a list of steps." f"If step {config.get('id', '?')!r}: 'else' must be a list of steps."
) )
@@ -3,8 +3,6 @@
from __future__ import annotations from __future__ import annotations
import json import json
import math
import os
import subprocess import subprocess
from typing import Any from typing import Any
@@ -27,26 +25,6 @@ class ShellStep(StepBase):
run_cmd = str(run_cmd) run_cmd = str(run_cmd)
cwd = context.project_root or "." cwd = context.project_root or "."
# Per-step execution timeout in seconds; defaults to 300 for backward
# compatibility. The engine does not auto-validate step config, so
# validate here as well — a caller that skips WorkflowEngine.validate()
# must fail the step cleanly rather than crash subprocess.run() with a
# TypeError (or silently coerce ``timeout: true`` to a 1s duration,
# since bool is an int subclass).
timeout = config.get("timeout", 300)
timeout_error = self._timeout_error(config)
if timeout_error is not None:
return StepResult(
status=StepStatus.FAILED,
error=timeout_error,
output={"exit_code": -1, "stdout": "", "stderr": "invalid timeout"},
)
env = {**os.environ}
if context.workflow_dir:
env["SPECKIT_WORKFLOW_DIR"] = context.workflow_dir
else:
env.pop("SPECKIT_WORKFLOW_DIR", None)
# NOTE: shell=True is required to support pipes, redirects, and # NOTE: shell=True is required to support pipes, redirects, and
# multi-command expressions in workflow YAML. Workflow authors # multi-command expressions in workflow YAML. Workflow authors
@@ -59,8 +37,7 @@ class ShellStep(StepBase):
capture_output=True, capture_output=True,
text=True, text=True,
cwd=cwd, cwd=cwd,
env=env, timeout=300,
timeout=timeout,
) )
output = { output = {
"exit_code": proc.returncode, "exit_code": proc.returncode,
@@ -97,7 +74,7 @@ class ShellStep(StepBase):
except subprocess.TimeoutExpired: except subprocess.TimeoutExpired:
return StepResult( return StepResult(
status=StepStatus.FAILED, status=StepStatus.FAILED,
error=f"Shell command timed out after {timeout} seconds.", error="Shell command timed out after 300 seconds.",
output={"exit_code": -1, "stdout": "", "stderr": "timeout"}, output={"exit_code": -1, "stdout": "", "stderr": "timeout"},
) )
except OSError as exc: except OSError as exc:
@@ -107,32 +84,6 @@ class ShellStep(StepBase):
output={"exit_code": -1, "stdout": "", "stderr": str(exc)}, output={"exit_code": -1, "stdout": "", "stderr": str(exc)},
) )
@staticmethod
def _timeout_error(config: dict[str, Any]) -> str | None:
"""Return an error message if ``config['timeout']`` is invalid, else None.
Shared by execute() and validate() so both paths reject the same
values with the same message. An absent ``timeout`` is valid (the
default is used). bool is a subclass of int, but ``timeout: true`` is a
config error rather than a duration, so it is rejected explicitly.
Non-finite floats (YAML ``.inf``/``.nan``) pass a plain ``> 0`` check
but would raise in subprocess.run(), so they are rejected too.
"""
if "timeout" not in config:
return None
timeout = config["timeout"]
if (
isinstance(timeout, bool)
or not isinstance(timeout, (int, float))
or not math.isfinite(timeout)
or timeout <= 0
):
return (
f"Shell step {config.get('id', '?')!r}: 'timeout' must be a "
f"positive number of seconds, got {timeout!r}."
)
return None
def validate(self, config: dict[str, Any]) -> list[str]: def validate(self, config: dict[str, Any]) -> list[str]:
errors = super().validate(config) errors = super().validate(config)
if "run" not in config: if "run" not in config:
@@ -155,7 +106,4 @@ class ShellStep(StepBase):
f"Shell step {config.get('id', '?')!r}: 'output_format' must " f"Shell step {config.get('id', '?')!r}: 'output_format' must "
f"be 'json' when present, got {output_format!r}." f"be 'json' when present, got {output_format!r}."
) )
timeout_error = self._timeout_error(config)
if timeout_error is not None:
errors.append(timeout_error)
return errors return errors
@@ -26,20 +26,6 @@ class SwitchStep(StepBase):
str_value = str(value) if value is not None else "" str_value = str(value) if value is not None else ""
cases = config.get("cases", {}) cases = config.get("cases", {})
if not isinstance(cases, dict):
# The engine does not auto-validate step config, so an unvalidated
# run with a non-mapping ``cases`` (a list/scalar authoring mistake)
# would otherwise raise AttributeError from ``.items()`` below and
# crash the whole run. Fail this step loudly instead, mirroring the
# fan-out step's non-list ``items`` handling.
return StepResult(
status=StepStatus.FAILED,
error=(
f"Switch step {config.get('id', '?')!r}: 'cases' must be a "
f"mapping, got {type(cases).__name__}."
),
output={"matched_case": None, "expression_value": value},
)
for case_key, case_steps in cases.items(): for case_key, case_steps in cases.items():
if str(case_key) == str_value: if str(case_key) == str_value:
return StepResult( return StepResult(
@@ -26,32 +26,6 @@ class WhileStep(StepBase):
nested_steps = config.get("steps", []) nested_steps = config.get("steps", [])
result = evaluate_condition(condition, context) result = evaluate_condition(condition, context)
# The engine does not auto-validate step config (see
# ``WorkflowEngine.load_workflow``) and feeds ``next_steps`` straight
# into ``_execute_steps``, which iterates them as step mappings. A
# non-list ``steps`` (a single mapping or scalar authoring mistake)
# would otherwise be iterated element-wise — a dict yields its string
# keys, a str its characters — and crash the whole run with
# AttributeError on ``.get()``. ``validate`` already rejects a non-list
# ``steps``; fail this step loudly on an unvalidated run instead,
# mirroring the if/switch/fan-out steps. The guard fires only when the
# body would actually be dispatched (condition truthy). The condition is
# still evaluated first, so its result is surfaced for downstream context.
if result and not isinstance(nested_steps, list):
return StepResult(
status=StepStatus.FAILED,
output={
"condition_result": True,
"max_iterations": max_iterations,
"loop_type": "while",
},
error=(
f"While step {config.get('id', '?')!r}: 'steps' must be a "
f"list of steps, got {type(nested_steps).__name__}."
),
)
if result: if result:
return StepResult( return StepResult(
status=StepStatus.COMPLETED, status=StepStatus.COMPLETED,
+3 -3
View File
@@ -6,16 +6,16 @@
**Note**: This checklist is generated by the `__SPECKIT_COMMAND_CHECKLIST__` command based on feature context and requirements. **Note**: This checklist is generated by the `__SPECKIT_COMMAND_CHECKLIST__` command based on feature context and requirements.
<!-- <!--
============================================================================ ============================================================================
IMPORTANT: The checklist items below are SAMPLE ITEMS for illustration only. IMPORTANT: The checklist items below are SAMPLE ITEMS for illustration only.
The __SPECKIT_COMMAND_CHECKLIST__ command MUST replace these with actual items based on: The __SPECKIT_COMMAND_CHECKLIST__ command MUST replace these with actual items based on:
- User's specific checklist request - User's specific checklist request
- Feature requirements from spec.md - Feature requirements from spec.md
- Technical context from plan.md - Technical context from plan.md
- Implementation details from tasks.md - Implementation details from tasks.md
DO NOT keep these sample items in the generated checklist file. DO NOT keep these sample items in the generated checklist file.
============================================================================ ============================================================================
--> -->
-1
View File
@@ -3,7 +3,6 @@ description: Perform a non-destructive cross-artifact consistency and quality an
scripts: scripts:
sh: scripts/bash/check-prerequisites.sh --json --require-tasks --include-tasks sh: scripts/bash/check-prerequisites.sh --json --require-tasks --include-tasks
ps: scripts/powershell/check-prerequisites.ps1 -Json -RequireTasks -IncludeTasks ps: scripts/powershell/check-prerequisites.ps1 -Json -RequireTasks -IncludeTasks
py: scripts/python/check_prerequisites.py --json --require-tasks --include-tasks
--- ---
## User Input ## User Input
-1
View File
@@ -3,7 +3,6 @@ description: Generate a custom checklist for the current feature based on user r
scripts: scripts:
sh: scripts/bash/check-prerequisites.sh --json sh: scripts/bash/check-prerequisites.sh --json
ps: scripts/powershell/check-prerequisites.ps1 -Json ps: scripts/powershell/check-prerequisites.ps1 -Json
py: scripts/python/check_prerequisites.py --json
--- ---
## Checklist Purpose: "Unit Tests for English" ## Checklist Purpose: "Unit Tests for English"
+1 -2
View File
@@ -1,13 +1,12 @@
--- ---
description: Identify underspecified areas in the current feature spec by asking up to 5 highly targeted clarification questions and encoding answers back into the spec. description: Identify underspecified areas in the current feature spec by asking up to 5 highly targeted clarification questions and encoding answers back into the spec.
handoffs: handoffs:
- label: Build Technical Plan - label: Build Technical Plan
agent: speckit.plan agent: speckit.plan
prompt: Create a plan for the spec. I am building with... prompt: Create a plan for the spec. I am building with...
scripts: scripts:
sh: scripts/bash/check-prerequisites.sh --json --paths-only sh: scripts/bash/check-prerequisites.sh --json --paths-only
ps: scripts/powershell/check-prerequisites.ps1 -Json -PathsOnly ps: scripts/powershell/check-prerequisites.ps1 -Json -PathsOnly
py: scripts/python/check_prerequisites.py --json --paths-only
--- ---
## User Input ## User Input
+2 -2
View File
@@ -1,6 +1,6 @@
--- ---
description: Create or update the project constitution from interactive or provided principle inputs, ensuring all dependent templates stay in sync. description: Create or update the project constitution from interactive or provided principle inputs, ensuring all dependent templates stay in sync.
handoffs: handoffs:
- label: Build Specification - label: Build Specification
agent: speckit.specify agent: speckit.specify
prompt: Implement the feature specification based on the updated constitution. I want to build... prompt: Implement the feature specification based on the updated constitution. I want to build...
@@ -81,7 +81,7 @@ Follow this execution flow:
- Read `.specify/templates/plan-template.md` and ensure any "Constitution Check" or rules align with updated principles. - Read `.specify/templates/plan-template.md` and ensure any "Constitution Check" or rules align with updated principles.
- Read `.specify/templates/spec-template.md` for scope/requirements alignment—update if constitution adds/removes mandatory sections or constraints. - Read `.specify/templates/spec-template.md` for scope/requirements alignment—update if constitution adds/removes mandatory sections or constraints.
- Read `.specify/templates/tasks-template.md` and ensure task categorization reflects new or removed principle-driven task types (e.g., observability, versioning, testing discipline). - Read `.specify/templates/tasks-template.md` and ensure task categorization reflects new or removed principle-driven task types (e.g., observability, versioning, testing discipline).
- Read each installed Spec Kit command file for your agent (including this one) — named `speckit.*` or `speckit-*` (dot or hyphen depending on the agent), or laid out as `speckit-<name>/SKILL.md` for skills-based integrations, e.g. in `.github/agents/`, `.github/skills/`, `.claude/skills/`, or your agent's equivalent commands directory — to verify no outdated references (CLAUDE-only or other agent-specific names) remain when generic guidance is required. - Read each command file in `.specify/templates/commands/*.md` (including this one) to verify no outdated references (agent-specific names like CLAUDE only) remain when generic guidance is required.
- Read any runtime guidance docs (e.g., `README.md`, `docs/quickstart.md`, or agent-specific guidance files if present). Update references to principles changed. - Read any runtime guidance docs (e.g., `README.md`, `docs/quickstart.md`, or agent-specific guidance files if present). Update references to principles changed.
5. Produce a Sync Impact Report (prepend as an HTML comment at top of the constitution file after update): 5. Produce a Sync Impact Report (prepend as an HTML comment at top of the constitution file after update):
-1
View File
@@ -3,7 +3,6 @@ description: Assess the current codebase against the feature's spec, plan, and t
scripts: scripts:
sh: scripts/bash/check-prerequisites.sh --json --require-tasks --include-tasks sh: scripts/bash/check-prerequisites.sh --json --require-tasks --include-tasks
ps: scripts/powershell/check-prerequisites.ps1 -Json -RequireTasks -IncludeTasks ps: scripts/powershell/check-prerequisites.ps1 -Json -RequireTasks -IncludeTasks
py: scripts/python/check_prerequisites.py --json --require-tasks --include-tasks
--- ---
## User Input ## User Input
+2 -3
View File
@@ -3,7 +3,6 @@ description: Execute the implementation plan by processing and executing all tas
scripts: scripts:
sh: scripts/bash/check-prerequisites.sh --json --require-tasks --include-tasks sh: scripts/bash/check-prerequisites.sh --json --require-tasks --include-tasks
ps: scripts/powershell/check-prerequisites.ps1 -Json -RequireTasks -IncludeTasks ps: scripts/powershell/check-prerequisites.ps1 -Json -RequireTasks -IncludeTasks
py: scripts/python/check_prerequisites.py --json --require-tasks --include-tasks
--- ---
## User Input ## User Input
@@ -43,7 +42,7 @@ You **MUST** consider the user input before proceeding (if not empty).
**Automatic Pre-Hook**: {extension} **Automatic Pre-Hook**: {extension}
Executing: `/{command}` Executing: `/{command}`
EXECUTE_COMMAND: {command} EXECUTE_COMMAND: {command}
Wait for the result of the hook command before proceeding to the Outline. Wait for the result of the hook command before proceeding to the Outline.
``` ```
After emitting the block above you MUST actually invoke the hook and wait for it to finish before continuing. Run it the same way you would run the command yourself in this agent/session (the invocation may differ from the literal `{command}` id shown above, e.g. a skills-mode agent runs it as `/skill:speckit-...` or `$speckit-...`). Emitting the block alone does not run the hook. After emitting the block above you MUST actually invoke the hook and wait for it to finish before continuing. Run it the same way you would run the command yourself in this agent/session (the invocation may differ from the literal `{command}` id shown above, e.g. a skills-mode agent runs it as `/skill:speckit-...` or `$speckit-...`). Emitting the block alone does not run the hook.
@@ -145,7 +144,7 @@ You **MUST** consider the user input before proceeding (if not empty).
6. Execute implementation following the task plan: 6. Execute implementation following the task plan:
- **Phase-by-phase execution**: Complete each phase before moving to the next - **Phase-by-phase execution**: Complete each phase before moving to the next
- **Respect dependencies**: Run sequential tasks in order, parallel tasks [P] can run together - **Respect dependencies**: Run sequential tasks in order, parallel tasks [P] can run together
- **Follow TDD approach**: Execute test tasks before their corresponding implementation tasks - **Follow TDD approach**: Execute test tasks before their corresponding implementation tasks
- **File-based coordination**: Tasks affecting the same files must run sequentially - **File-based coordination**: Tasks affecting the same files must run sequentially
- **Validation checkpoints**: Verify each phase completion before proceeding - **Validation checkpoints**: Verify each phase completion before proceeding
+3 -2
View File
@@ -1,6 +1,6 @@
--- ---
description: Execute the implementation planning workflow using the plan template to generate design artifacts. description: Execute the implementation planning workflow using the plan template to generate design artifacts.
handoffs: handoffs:
- label: Create Tasks - label: Create Tasks
agent: speckit.tasks agent: speckit.tasks
prompt: Break the plan into tasks prompt: Break the plan into tasks
@@ -68,6 +68,7 @@ You **MUST** consider the user input before proceeding (if not empty).
- Evaluate gates (ERROR if violations unjustified) - Evaluate gates (ERROR if violations unjustified)
- Phase 0: Generate research.md (resolve all NEEDS CLARIFICATION) - Phase 0: Generate research.md (resolve all NEEDS CLARIFICATION)
- Phase 1: Generate data-model.md, contracts/, quickstart.md - Phase 1: Generate data-model.md, contracts/, quickstart.md
- Phase 1: Update agent context by running the agent script
- Re-evaluate Constitution Check post-design - Re-evaluate Constitution Check post-design
## Mandatory Post-Execution Hooks ## Mandatory Post-Execution Hooks
@@ -106,7 +107,7 @@ Check if `.specify/extensions.yml` exists in the project root.
## Completion Report ## Completion Report
Command ends after Phase 1 design. Report branch, IMPL_PLAN path, and generated artifacts. Command ends after Phase 2 planning. Report branch, IMPL_PLAN path, and generated artifacts.
## Phases ## Phases
+15 -15
View File
@@ -1,6 +1,6 @@
--- ---
description: Create or update the feature specification from a natural language feature description. description: Create or update the feature specification from a natural language feature description.
handoffs: handoffs:
- label: Build Technical Plan - label: Build Technical Plan
agent: speckit.plan agent: speckit.plan
prompt: Create a plan for the spec. I am building with... prompt: Create a plan for the spec. I am building with...
@@ -147,20 +147,20 @@ Given that feature description, do this:
```markdown ```markdown
# Specification Quality Checklist: [FEATURE NAME] # Specification Quality Checklist: [FEATURE NAME]
**Purpose**: Validate specification completeness and quality before proceeding to planning **Purpose**: Validate specification completeness and quality before proceeding to planning
**Created**: [DATE] **Created**: [DATE]
**Feature**: [Link to spec.md] **Feature**: [Link to spec.md]
## Content Quality ## Content Quality
- [ ] No implementation details (languages, frameworks, APIs) - [ ] No implementation details (languages, frameworks, APIs)
- [ ] Focused on user value and business needs - [ ] Focused on user value and business needs
- [ ] Written for non-technical stakeholders - [ ] Written for non-technical stakeholders
- [ ] All mandatory sections completed - [ ] All mandatory sections completed
## Requirement Completeness ## Requirement Completeness
- [ ] No [NEEDS CLARIFICATION] markers remain - [ ] No [NEEDS CLARIFICATION] markers remain
- [ ] Requirements are testable and unambiguous - [ ] Requirements are testable and unambiguous
- [ ] Success criteria are measurable - [ ] Success criteria are measurable
@@ -169,16 +169,16 @@ Given that feature description, do this:
- [ ] Edge cases are identified - [ ] Edge cases are identified
- [ ] Scope is clearly bounded - [ ] Scope is clearly bounded
- [ ] Dependencies and assumptions identified - [ ] Dependencies and assumptions identified
## Feature Readiness ## Feature Readiness
- [ ] All functional requirements have clear acceptance criteria - [ ] All functional requirements have clear acceptance criteria
- [ ] User scenarios cover primary flows - [ ] User scenarios cover primary flows
- [ ] Feature meets measurable outcomes defined in Success Criteria - [ ] Feature meets measurable outcomes defined in Success Criteria
- [ ] No implementation details leak into specification - [ ] No implementation details leak into specification
## Notes ## Notes
- Items marked incomplete require spec updates before `__SPECKIT_COMMAND_CLARIFY__` or `__SPECKIT_COMMAND_PLAN__` - Items marked incomplete require spec updates before `__SPECKIT_COMMAND_CLARIFY__` or `__SPECKIT_COMMAND_PLAN__`
``` ```
@@ -203,20 +203,20 @@ Given that feature description, do this:
```markdown ```markdown
## Question [N]: [Topic] ## Question [N]: [Topic]
**Context**: [Quote relevant spec section] **Context**: [Quote relevant spec section]
**What we need to know**: [Specific question from NEEDS CLARIFICATION marker] **What we need to know**: [Specific question from NEEDS CLARIFICATION marker]
**Suggested Answers**: **Suggested Answers**:
| Option | Answer | Implications | | Option | Answer | Implications |
|--------|--------|--------------| |--------|--------|--------------|
| A | [First suggested answer] | [What this means for the feature] | | A | [First suggested answer] | [What this means for the feature] |
| B | [Second suggested answer] | [What this means for the feature] | | B | [Second suggested answer] | [What this means for the feature] |
| C | [Third suggested answer] | [What this means for the feature] | | C | [Third suggested answer] | [What this means for the feature] |
| Custom | Provide your own answer | [Explain how to provide custom input] | | Custom | Provide your own answer | [Explain how to provide custom input] |
**Your choice**: _[Wait for user response]_ **Your choice**: _[Wait for user response]_
``` ```
+3 -3
View File
@@ -1,6 +1,6 @@
--- ---
description: Generate an actionable, dependency-ordered tasks.md for the feature based on available design artifacts. description: Generate an actionable, dependency-ordered tasks.md for the feature based on available design artifacts.
handoffs: handoffs:
- label: Analyze For Consistency - label: Analyze For Consistency
agent: speckit.analyze agent: speckit.analyze
prompt: Run a project analysis for consistency prompt: Run a project analysis for consistency
@@ -51,7 +51,7 @@ You **MUST** consider the user input before proceeding (if not empty).
**Automatic Pre-Hook**: {extension} **Automatic Pre-Hook**: {extension}
Executing: `/{command}` Executing: `/{command}`
EXECUTE_COMMAND: {command} EXECUTE_COMMAND: {command}
Wait for the result of the hook command before proceeding to the Outline. Wait for the result of the hook command before proceeding to the Outline.
``` ```
After emitting the block above you MUST actually invoke the hook and wait for it to finish before continuing. Run it the same way you would run the command yourself in this agent/session (the invocation may differ from the literal `{command}` id shown above, e.g. a skills-mode agent runs it as `/skill:speckit-...` or `$speckit-...`). Emitting the block alone does not run the hook. After emitting the block above you MUST actually invoke the hook and wait for it to finish before continuing. Run it the same way you would run the command yourself in this agent/session (the invocation may differ from the literal `{command}` id shown above, e.g. a skills-mode agent runs it as `/skill:speckit-...` or `$speckit-...`). Emitting the block alone does not run the hook.
@@ -161,7 +161,7 @@ Every task MUST strictly follow this format:
4. **[Story] label**: REQUIRED for user story phase tasks only 4. **[Story] label**: REQUIRED for user story phase tasks only
- Format: [US1], [US2], [US3], etc. (maps to user stories from spec.md) - Format: [US1], [US2], [US3], etc. (maps to user stories from spec.md)
- Setup phase: NO story label - Setup phase: NO story label
- Foundational phase: NO story label - Foundational phase: NO story label
- User Story phases: MUST have story label - User Story phases: MUST have story label
- Polish phase: NO story label - Polish phase: NO story label
5. **Description**: Clear action with exact file path 5. **Description**: Clear action with exact file path
-1
View File
@@ -4,7 +4,6 @@ tools: ['github/github-mcp-server/list_issues', 'github/github-mcp-server/issue_
scripts: scripts:
sh: scripts/bash/check-prerequisites.sh --json --require-tasks --include-tasks sh: scripts/bash/check-prerequisites.sh --json --require-tasks --include-tasks
ps: scripts/powershell/check-prerequisites.ps1 -Json -RequireTasks -IncludeTasks ps: scripts/powershell/check-prerequisites.ps1 -Json -RequireTasks -IncludeTasks
py: scripts/python/check_prerequisites.py --json --require-tasks --include-tasks
--- ---
## User Input ## User Input
+1 -1
View File
@@ -4,7 +4,7 @@
**Input**: Feature specification from `/specs/[###-feature-name]/spec.md` **Input**: Feature specification from `/specs/[###-feature-name]/spec.md`
**Note**: This template is filled in by the `__SPECKIT_COMMAND_PLAN__` command; its definition describes the execution workflow. **Note**: This template is filled in by the `__SPECKIT_COMMAND_PLAN__` command. See `.specify/templates/plan-template.md` for the execution workflow.
## Summary ## Summary
+1
View File
@@ -11,3 +11,4 @@
".specify/scripts/powershell/": true ".specify/scripts/powershell/": true
} }
} }
+18 -68
View File
@@ -6,7 +6,6 @@ contracts/cli-commands.md (offline, discovery-only refusal, not-a-project error)
""" """
from __future__ import annotations from __future__ import annotations
import io
import json import json
from pathlib import Path from pathlib import Path
from unittest.mock import patch from unittest.mock import patch
@@ -64,42 +63,6 @@ def test_commands_outside_project_fail_with_guidance(tmp_path: Path, monkeypatch
assert "Spec Kit project" in result.output assert "Spec Kit project" in result.output
def test_remove_reports_clean_error_when_primitive_raises_raw_exception(
project: Path,
):
"""A raw exception from a primitive installer (e.g. an OSError from an
unreadable workflow registry surfacing through _WorkflowKindManager's
fail-closed construction) must not propagate uncaught through
`specify bundle remove` -- the command only catches BundlerError, so
without a conversion at the remove_bundle boundary this would exit
with an unhandled exception and empty/raw output instead of a clean,
actionable message, and no removal side effects should occur either."""
from specify_cli.bundler.models.manifest import BundleManifest
from specify_cli.bundler.models.records import load_records
from specify_cli.bundler.services.adapters import DefaultPrimitiveInstaller
from specify_cli.bundler.services.installer import install_bundle
from specify_cli.bundler.services.resolver import resolve_install_plan
from tests.bundler_helpers import FakeInstaller
manifest = BundleManifest.from_dict(valid_manifest_dict())
plan = resolve_install_plan(
manifest, speckit_version="0.11.2", active_integration="copilot"
)
install_bundle(project, plan, FakeInstaller(), manifest=manifest)
def boom(self, project_root, component):
raise OSError("workflow registry unreadable")
with pytest.MonkeyPatch.context() as mp:
mp.setattr(DefaultPrimitiveInstaller, "is_installed", boom)
result = runner.invoke(app, ["bundle", "remove", "demo-bundle"])
assert result.exit_code != 0
assert result.output.strip() != ""
assert result.exception is None or isinstance(result.exception, SystemExit)
assert {r.bundle_id for r in load_records(project)} == {"demo-bundle"}
def test_fail_writes_error_to_stderr_not_stdout(capsys): def test_fail_writes_error_to_stderr_not_stdout(capsys):
"""_fail must write to stderr, not stdout: every bundle command routes errors """_fail must write to stderr, not stdout: every bundle command routes errors
through it, and under --json the error would otherwise corrupt the JSON payload through it, and under --json the error would otherwise corrupt the JSON payload
@@ -212,23 +175,7 @@ def test_build_produces_artifact(project: Path):
assert len(artifacts) == 1 assert len(artifacts) == 1
def _mock_manifest_download(monkeypatch, source_path: Path) -> None: def test_info_expands_full_component_set(project: Path):
"""Mock the HTTPS manifest fetch to return a locally-authored manifest.
Catalog ``download_url``s are HTTPS-only, so ``info`` tests can no longer
point one at a local file. Patch ``_download_manifest`` to return the
manifest parsed from *source_path* (a bundle.yml or a .zip artifact),
exercising ``info``'s expansion without a network call.
"""
from specify_cli.commands.bundle import _local_manifest_source
monkeypatch.setattr(
"specify_cli.commands.bundle._download_manifest",
lambda resolved, *, offline: _local_manifest_source(str(source_path)),
)
def test_info_expands_full_component_set(project: Path, monkeypatch):
bundle_dir = project / "src-bundle" bundle_dir = project / "src-bundle"
bundle_dir.mkdir() bundle_dir.mkdir()
(bundle_dir / "bundle.yml").write_text( (bundle_dir / "bundle.yml").write_text(
@@ -236,14 +183,13 @@ def test_info_expands_full_component_set(project: Path, monkeypatch):
) )
catalog = project / "local-catalog.json" catalog = project / "local-catalog.json"
entry = catalog_entry_dict( entry = catalog_entry_dict(
"demo-bundle", download_url="https://example.com/demo-bundle.zip" "demo-bundle", download_url=str(bundle_dir / "bundle.yml")
) )
write_catalog_file(catalog, {"demo-bundle": entry}) write_catalog_file(catalog, {"demo-bundle": entry})
added = runner.invoke( added = runner.invoke(
app, ["bundle", "catalog", "add", str(catalog), "--id", "local"] app, ["bundle", "catalog", "add", str(catalog), "--id", "local"]
) )
assert added.exit_code == 0, added.output assert added.exit_code == 0, added.output
_mock_manifest_download(monkeypatch, bundle_dir / "bundle.yml")
result = runner.invoke(app, ["bundle", "info", "demo-bundle", "--json", "--offline"]) result = runner.invoke(app, ["bundle", "info", "demo-bundle", "--json", "--offline"])
assert result.exit_code == 0, result.output assert result.exit_code == 0, result.output
@@ -261,7 +207,7 @@ def test_info_expands_full_component_set(project: Path, monkeypatch):
assert "Trust" in text.output assert "Trust" in text.output
def test_info_expands_discovery_only_bundle(project: Path, monkeypatch): def test_info_expands_discovery_only_bundle(project: Path):
# Discovery-only bundles must still be fully inspectable via `info`; # Discovery-only bundles must still be fully inspectable via `info`;
# only `install` is refused for them. # only `install` is refused for them.
bundle_dir = project / "disc-bundle" bundle_dir = project / "disc-bundle"
@@ -271,7 +217,7 @@ def test_info_expands_discovery_only_bundle(project: Path, monkeypatch):
) )
catalog = project / "disc-catalog.json" catalog = project / "disc-catalog.json"
entry = catalog_entry_dict( entry = catalog_entry_dict(
"demo-bundle", download_url="https://example.com/demo-bundle.zip" "demo-bundle", download_url=str(bundle_dir / "bundle.yml")
) )
write_catalog_file(catalog, {"demo-bundle": entry}) write_catalog_file(catalog, {"demo-bundle": entry})
config = { config = {
@@ -284,7 +230,6 @@ def test_info_expands_discovery_only_bundle(project: Path, monkeypatch):
(project / ".specify" / "bundle-catalogs.yml").write_text( (project / ".specify" / "bundle-catalogs.yml").write_text(
yaml.safe_dump(config), encoding="utf-8" yaml.safe_dump(config), encoding="utf-8"
) )
_mock_manifest_download(monkeypatch, bundle_dir / "bundle.yml")
result = runner.invoke(app, ["bundle", "info", "demo-bundle", "--json", "--offline"]) result = runner.invoke(app, ["bundle", "info", "demo-bundle", "--json", "--offline"])
assert result.exit_code == 0, result.output assert result.exit_code == 0, result.output
payload = json.loads(result.output) payload = json.loads(result.output)
@@ -292,9 +237,8 @@ def test_info_expands_discovery_only_bundle(project: Path, monkeypatch):
assert ("extensions", "ext-a") in components assert ("extensions", "ext-a") in components
def test_info_expands_zip_sourced_bundle(project: Path, monkeypatch): def test_info_resolves_local_zip_download_url(project: Path):
# A .zip artifact is extracted to read bundle.yml; info expands it. (The # A local .zip artifact as download_url is extracted to read bundle.yml.
# download itself is HTTPS-only now and mocked here — see contract note.)
bundle_dir = project / "zip-src" bundle_dir = project / "zip-src"
bundle_dir.mkdir() bundle_dir.mkdir()
(bundle_dir / "bundle.yml").write_text( (bundle_dir / "bundle.yml").write_text(
@@ -305,15 +249,12 @@ def test_info_expands_zip_sourced_bundle(project: Path, monkeypatch):
catalog = project / "zip-catalog.json" catalog = project / "zip-catalog.json"
write_catalog_file( write_catalog_file(
catalog, catalog,
{"demo-bundle": catalog_entry_dict( {"demo-bundle": catalog_entry_dict("demo-bundle", download_url=str(artifact))},
"demo-bundle", download_url="https://example.com/demo-bundle.zip"
)},
) )
added = runner.invoke( added = runner.invoke(
app, ["bundle", "catalog", "add", str(catalog), "--id", "local"] app, ["bundle", "catalog", "add", str(catalog), "--id", "local"]
) )
assert added.exit_code == 0, added.output assert added.exit_code == 0, added.output
_mock_manifest_download(monkeypatch, artifact)
result = runner.invoke(app, ["bundle", "info", "demo-bundle", "--json", "--offline"]) result = runner.invoke(app, ["bundle", "info", "demo-bundle", "--json", "--offline"])
assert result.exit_code == 0, result.output assert result.exit_code == 0, result.output
payload = json.loads(result.output) payload = json.loads(result.output)
@@ -469,16 +410,25 @@ def test_install_integration_override_cannot_bypass_clash_guard(project: Path):
# ===== Private GitHub release asset URL resolution ===== # ===== Private GitHub release asset URL resolution =====
class FakeBundleResponse(io.BytesIO): class FakeBundleResponse:
"""Minimal context-manager response stub for open_url fakes.""" """Minimal context-manager response stub for open_url fakes."""
def __init__(self, data: bytes, url: str = "https://api.github.com/repos/org/repo/releases/assets/99"): def __init__(self, data: bytes, url: str = "https://api.github.com/repos/org/repo/releases/assets/99"):
super().__init__(data) self._data = data
self._url = url self._url = url
def read(self) -> bytes:
return self._data
def geturl(self) -> str: def geturl(self) -> str:
return self._url return self._url
def __enter__(self):
return self
def __exit__(self, *_):
return False
def _make_catalog_config(catalog_path: Path, project: Path) -> None: def _make_catalog_config(catalog_path: Path, project: Path) -> None:
"""Write a bundle-catalogs.yml pointing at *catalog_path* in *project*.""" """Write a bundle-catalogs.yml pointing at *catalog_path* in *project*."""

Some files were not shown because too many files have changed in this diff Show More