Compare commits

..

1 Commits

Author SHA1 Message Date
github-actions[bot]
7511c8c24c chore: bump version to 0.12.4 2026-07-02 10:56:52 +00:00
160 changed files with 1070 additions and 16028 deletions

1
.github/CODEOWNERS vendored
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

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

View File

@@ -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
``` ```

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

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"): -->

View File

@@ -35,7 +35,7 @@ jobs:
fetch-depth: 0 # Fetch all history for git info fetch-depth: 0 # Fetch all history for git info
- name: Setup .NET - name: Setup .NET
uses: actions/setup-dotnet@26b0ec14cb23fa6904739307f278c14f94c95bf1 # v5.4.0 uses: actions/setup-dotnet@9a946fdbd5fb07b82b2f5a4466058b876ab72bb2 # v5.3.0
with: with:
dotnet-version: '8.x' dotnet-version: '8.x'

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'

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

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

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

View File

@@ -26,4 +26,4 @@
"ignores": [ "ignores": [
".genreleases/" ".genreleases/"
] ]
} }

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$

View File

@@ -2,172 +2,6 @@
<!-- insert new changelog below this comment --> <!-- insert new changelog below this comment -->
## [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
### Changed
- [extension] Add LLM Wiki extension to community catalog (#3361)
- Docs: Document missing CLI flags and integrations (#3182)
- Docs: Remove Cursor from CLI check list in README (#3184)
- feat(extensions): port update-agent-context to Python (#3387)
- fix(scripts): fall through to grep/sed when python3 is a broken stub in feature.json parser (#3312)
- fix(toml): escape control characters so generated command files parse (#3341)
- fix(cli): exit cleanly on malformed IPv6 URLs in `extension`/`preset`/`workflow add` (#3369)
- fix(github-http): return None on malformed GHES port instead of raising (#3379)
- fix(integrations): guard _sha256 against unreadable managed files (#3376)
- chore: release 0.12.7, begin 0.12.8.dev0 development (#3398)
## [0.12.7] - 2026-07-07
### Changed
- fix(bundler): bundle update uninstalls components dropped by new version (#3353)
- fix(workflows): route run/resume errors to stderr under --json (#3352)
- fix(workflows): fan-in validate() rejects non-mapping output (#3349)
- fix(workflows): shell step validate() rejects non-string run (#3348)
- fix(integrations): agy honors SPECKIT_INTEGRATION_AGY_EXTRA_ARGS (#3347)
- Add Orchestration Task Context Management extension to community catalog (#3372)
- Update DocGuard — CDD Enforcement extension to v0.30.0 (#3371)
- Update Ripple extension to v1.1.0 (#3370)
- feat(integrations): generalize post-processing to all format types (#3311)
- chore: release 0.12.6, begin 0.12.7.dev0 development (#3393)
## [0.12.6] - 2026-07-07
### Changed
- fix(bundler): validate catalog URLs in `catalog add` (HTTPS-only, require host) (#3367)
- Update Ralph Loop extension to v1.2.1 (#3365)
- fix extension-local script path rewriting (#3364)
- Add Charter extension to community catalog (#3363)
- feat(scripts): add Python check-prerequisites PoC (#3302)
- test: reduce registry manifest test repetition (#3146)
- fix(integrations): hermes honors SPECKIT_INTEGRATION_HERMES_EXTRA_ARGS (#3346)
- fix(extensions): coerce non-mapping YAML config roots to {} in ConfigManager (#3345)
- fix(yaml): pin goose recipe prompt block-scalar indentation (#3343)
- chore: release 0.12.5, begin 0.12.6.dev0 development (#3381)
## [0.12.5] - 2026-07-06
### Changed
- fix(workflows): match gate reject option case-insensitively (#3335)
- fix(bundler): reject host-less catalog URLs in adapters (use hostname, not netloc) (#3333)
- fix(bundler): resolve catalog search at highest-precedence source before filtering (#3331)
- fix(workflows): compare non-numeric strings lexicographically instead of returning False (#3323)
- fix(workflows): quote-aware interpolation so a literal }} in a filter arg doesn't break multi-expression templates (#3307)
- Support namespaced git feature branch templates (#3293)
- chore(deps): bump actions/setup-dotnet from 5.3.0 to 5.4.0 (#3315)
- fix(integrations): cursor-agent honors executable/extra-args env overrides (#3265)
- docs: drop stale kimi KIMI.md->AGENTS.md migration note (#3291)
- chore: release 0.12.4, begin 0.12.5.dev0 development (#3305)
## [0.12.4] - 2026-07-02 ## [0.12.4] - 2026-07-02
### Changed ### Changed

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.

View File

@@ -45,7 +45,7 @@ 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
@@ -406,7 +406,7 @@ specify init . --force --integration copilot
specify init --here --force --integration copilot specify init --here --force --integration copilot
``` ```
The CLI checks that the selected integration's required CLI tool is installed on your machine when that integration has `requires_cli: True`. If you do not have the required tool installed, or you prefer to get the templates without checking for the right tools, use `--ignore-agent-tools` with your command: The CLI will check that your selected agent's CLI tool is installed (for integrations that require a CLI), such as Claude Code, Gemini CLI, Qwen Code, opencode, Codex CLI, Qoder CLI, Tabnine CLI, Kiro CLI, Pi Coding Agent, Oh My Pi, Forge, Goose, Mistral Vibe, or ZCode. If you don't have the required tool installed, or you prefer to get the templates without checking for the right tools, use `--ignore-agent-tools` with your command:
```bash ```bash
specify init <project_name> --integration copilot --ignore-agent-tools specify init <project_name> --integration copilot --ignore-agent-tools

1
docs/.gitignore vendored
View File

@@ -6,3 +6,4 @@ obj/
# Temporary files # Temporary files
*.tmp *.tmp
*.log *.log

View File

@@ -42,7 +42,6 @@ The following community-contributed extensions are available in [`catalog.commun
| Bugfix Workflow | Structured bugfix workflow — capture bugs, trace to spec artifacts, and patch specs surgically | `process` | Read+Write | [spec-kit-bugfix](https://github.com/Quratulain-bilal/spec-kit-bugfix) | | Bugfix Workflow | Structured bugfix workflow — capture bugs, trace to spec artifacts, and patch specs surgically | `process` | Read+Write | [spec-kit-bugfix](https://github.com/Quratulain-bilal/spec-kit-bugfix) |
| Canon | Adds canon-driven (baseline-driven) workflows: spec-first, code-first, spec-drift. Requires Canon Core preset installation. | `process` | Read+Write | [spec-kit-canon](https://github.com/maximiliamus/spec-kit-canon/tree/master/extension) | | Canon | Adds canon-driven (baseline-driven) workflows: spec-first, code-first, spec-drift. Requires Canon Core preset installation. | `process` | Read+Write | [spec-kit-canon](https://github.com/maximiliamus/spec-kit-canon/tree/master/extension) |
| Catalog CI | Automated validation for spec-kit community catalog entries — structure, URLs, diffs, and linting | `process` | Read-only | [spec-kit-catalog-ci](https://github.com/Quratulain-bilal/spec-kit-catalog-ci) | | Catalog CI | Automated validation for spec-kit community catalog entries — structure, URLs, diffs, and linting | `process` | Read-only | [spec-kit-catalog-ci](https://github.com/Quratulain-bilal/spec-kit-catalog-ci) |
| Charter | Compose modular project constitutions from shared fragment registries. Centralize governance rules, select per-project fragments, track upstream changes, and keep multi-project setups consistent. | `process` | Read+Write | [spec-kit-charter](https://github.com/Fyloss/spec-kit-charter) |
| CI Guard | Spec compliance gates for CI/CD — verify specs exist, check drift, and block merges on gaps | `process` | Read-only | [spec-kit-ci-guard](https://github.com/Quratulain-bilal/spec-kit-ci-guard) | | CI Guard | Spec compliance gates for CI/CD — verify specs exist, check drift, and block merges on gaps | `process` | Read-only | [spec-kit-ci-guard](https://github.com/Quratulain-bilal/spec-kit-ci-guard) |
| Checkpoint Extension | Commit the changes made during the middle of the implementation, so you don't end up with just one very large commit at the end | `code` | Read+Write | [spec-kit-checkpoint](https://github.com/aaronrsun/spec-kit-checkpoint) | | Checkpoint Extension | Commit the changes made during the middle of the implementation, so you don't end up with just one very large commit at the end | `code` | Read+Write | [spec-kit-checkpoint](https://github.com/aaronrsun/spec-kit-checkpoint) |
| Cleanup Extension | Post-implementation quality gate that reviews changes, fixes small issues (scout rule), creates tasks for medium issues, and generates analysis for large issues | `code` | Read+Write | [spec-kit-cleanup](https://github.com/dsrednicki/spec-kit-cleanup) | | Cleanup Extension | Post-implementation quality gate that reviews changes, fixes small issues (scout rule), creates tasks for medium issues, and generates analysis for large issues | `code` | Read+Write | [spec-kit-cleanup](https://github.com/dsrednicki/spec-kit-cleanup) |
@@ -51,15 +50,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 | Canonical-Driven Development enforcement. Validates, scores, and traces project documentation with automated checks, AI-driven workflows, and spec-kit hooks. One pinned runtime dependency; pure Node.js otherwise. | `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) |
@@ -68,7 +66,6 @@ The following community-contributed extensions are available in [`catalog.commun
| Jira Integration (Sync Engine) | Idempotent, drift-aware, fail-closed reconcile engine mirroring spec-kit specs into Jira (Epic per repo, Story per spec, Subtask per phase) | `integration` | Read+Write | [spec-kit-jira-sync](https://github.com/ashbrener/spec-kit-jira-sync) | | Jira Integration (Sync Engine) | Idempotent, drift-aware, fail-closed reconcile engine mirroring spec-kit specs into Jira (Epic per repo, Story per spec, Subtask per phase) | `integration` | Read+Write | [spec-kit-jira-sync](https://github.com/ashbrener/spec-kit-jira-sync) |
| Learning Extension | Generate educational guides from implementations and enhance clarifications with mentoring context | `docs` | Read+Write | [spec-kit-learn](https://github.com/imviancagrace/spec-kit-learn) | | Learning Extension | Generate educational guides from implementations and enhance clarifications with mentoring context | `docs` | Read+Write | [spec-kit-learn](https://github.com/imviancagrace/spec-kit-learn) |
| Linear Integration | Mirror spec-kit feature directories into Linear (filesystem → Linear, reconcile-based, unidirectional). | `integration` | Read+Write | [spec-kit-linear-sync](https://github.com/ashbrener/spec-kit-linear-sync) | | Linear Integration | Mirror spec-kit feature directories into Linear (filesystem → Linear, reconcile-based, unidirectional). | `integration` | Read+Write | [spec-kit-linear-sync](https://github.com/ashbrener/spec-kit-linear-sync) |
| LLM Wiki | LLM-maintained compounding project wiki: source ingestion, cited answers, and consistency linting | `docs` | Read+Write | [spec-kit-wiki](https://github.com/formin/spec-kit-wiki) |
| Loop Engineering | Engineer safe autonomous agent loops for spec-driven development: a maker/checker split, externalized loop state, and stay-the-engineer guardrails against comprehension debt and cognitive surrender | `process` | Read+Write | [spec-kit-loop](https://github.com/formin/spec-kit-loop) | | Loop Engineering | Engineer safe autonomous agent loops for spec-driven development: a maker/checker split, externalized loop state, and stay-the-engineer guardrails against comprehension debt and cognitive surrender | `process` | Read+Write | [spec-kit-loop](https://github.com/formin/spec-kit-loop) |
| MAQA — Multi-Agent & Quality Assurance | Coordinator → feature → QA agent workflow with parallel worktree-based implementation. Language-agnostic. Auto-detects installed board plugins. Optional CI gate. | `process` | Read+Write | [spec-kit-maqa-ext](https://github.com/GenieRobot/spec-kit-maqa-ext) | | MAQA — Multi-Agent & Quality Assurance | Coordinator → feature → QA agent workflow with parallel worktree-based implementation. Language-agnostic. Auto-detects installed board plugins. Optional CI gate. | `process` | Read+Write | [spec-kit-maqa-ext](https://github.com/GenieRobot/spec-kit-maqa-ext) |
| MAQA Azure DevOps Integration | Azure DevOps Boards integration for MAQA — syncs User Stories and Task children as features progress | `integration` | Read+Write | [spec-kit-maqa-azure-devops](https://github.com/GenieRobot/spec-kit-maqa-azure-devops) | | MAQA Azure DevOps Integration | Azure DevOps Boards integration for MAQA — syncs User Stories and Task children as features progress | `integration` | Read+Write | [spec-kit-maqa-azure-devops](https://github.com/GenieRobot/spec-kit-maqa-azure-devops) |
@@ -84,12 +81,10 @@ 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) |
| 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) |
| 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-) |
@@ -99,7 +94,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) |
@@ -111,7 +105,7 @@ The following community-contributed extensions are available in [`catalog.commun
| Retro Extension | Sprint retrospective analysis with metrics, spec accuracy assessment, and improvement suggestions | `process` | Read+Write | [spec-kit-retro](https://github.com/arunt14/spec-kit-retro) | | Retro Extension | Sprint retrospective analysis with metrics, spec accuracy assessment, and improvement suggestions | `process` | Read+Write | [spec-kit-retro](https://github.com/arunt14/spec-kit-retro) |
| Retrospective Extension | Post-implementation retrospective with spec adherence scoring, drift analysis, and human-gated spec updates | `docs` | Read+Write | [spec-kit-retrospective](https://github.com/emi-dm/spec-kit-retrospective) | | Retrospective Extension | Post-implementation retrospective with spec adherence scoring, drift analysis, and human-gated spec updates | `docs` | Read+Write | [spec-kit-retrospective](https://github.com/emi-dm/spec-kit-retrospective) |
| Review Extension | Post-implementation comprehensive code review with specialized agents for code quality, comments, tests, error handling, type design, and simplification | `code` | Read-only | [spec-kit-review](https://github.com/ismaelJimenez/spec-kit-review) | | Review Extension | Post-implementation comprehensive code review with specialized agents for code quality, comments, tests, error handling, type design, and simplification | `code` | Read-only | [spec-kit-review](https://github.com/ismaelJimenez/spec-kit-review) |
| Ripple | Detect side effects that tests can't catch after implementation — surface hidden ripple effects across 9 analysis categories | `code` | Read+Write | [spec-kit-ripple](https://github.com/chordpli/spec-kit-ripple) | | Ripple | Detect side effects that tests can't catch after implementation — delta-anchored analysis across 9 domain-agnostic categories | `code` | Read+Write | [spec-kit-ripple](https://github.com/chordpli/spec-kit-ripple) |
| SDD Utilities | Resume interrupted workflows, validate project health, and verify spec-to-task traceability | `process` | Read+Write | [speckit-utils](https://github.com/mvanhorn/speckit-utils) | | SDD Utilities | Resume interrupted workflows, validate project health, and verify spec-to-task traceability | `process` | Read+Write | [speckit-utils](https://github.com/mvanhorn/speckit-utils) |
| Security Review | Full-project secure-by-design security audits plus staged, branch/PR, plan, task, follow-up, and apply reviews | `code` | Read+Write | [spec-kit-security-review](https://github.com/DyanGalih/spec-kit-security-review) | | Security Review | Full-project secure-by-design security audits plus staged, branch/PR, plan, task, follow-up, and apply reviews | `code` | Read+Write | [spec-kit-security-review](https://github.com/DyanGalih/spec-kit-security-review) |
| SFSpeckit | Enterprise Salesforce SDLC with 18 commands for the full SDD lifecycle. | `process` | Read+Write | [spec-kit-sf](https://github.com/ysumanth06/spec-kit-sf) | | SFSpeckit | Enterprise Salesforce SDLC with 18 commands for the full SDD lifecycle. | `process` | Read+Write | [spec-kit-sf](https://github.com/ysumanth06/spec-kit-sf) |
@@ -120,8 +114,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) |
@@ -151,7 +143,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) |

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.

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) |

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

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)

View File

@@ -15,7 +15,7 @@
### Persistent Installation (Recommended) ### 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`): Install once and use everywhere. Replace `vX.Y.Z` with a tag from [Releases](https://github.com/github/spec-kit/releases):
> [!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).

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.

View File

@@ -26,7 +26,7 @@ specify extension add <name>
| --------------- | -------------------------------------------------------- | | --------------- | -------------------------------------------------------- |
| `--dev` | Install from a local directory (for development) | | `--dev` | Install from a local directory (for development) |
| `--from <url>` | Install from a custom URL instead of the catalog | | `--from <url>` | Install from a custom URL instead of the catalog |
| `--force` | Overwrite if the extension is already installed | | `--force` | Overwrite if already installed |
| `--priority <N>`| Resolution priority (default: 10; lower = higher precedence) | | `--priority <N>`| Resolution priority (default: 10; lower = higher precedence) |
Installs an extension from the catalog, a URL, or a local directory. Extension commands are automatically registered with the currently installed AI coding agent integration. Installs an extension from the catalog, a URL, or a local directory. Extension commands are automatically registered with the currently installed AI coding agent integration.

View File

@@ -18,13 +18,13 @@ 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 |
| [Junie](https://junie.jetbrains.com/) | `junie` | | | [Junie](https://junie.jetbrains.com/) | `junie` | |
| [Kilo Code](https://github.com/Kilo-Org/kilocode) | `kilocode` | | | [Kilo Code](https://github.com/Kilo-Org/kilocode) | `kilocode` | |
| [Kimi Code](https://code.kimi.com/) | `kimi` | Skills-based integration; installs into `.kimi-code/skills/`. `--migrate-legacy` moves old `.kimi/skills/` installs to the new paths | | [Kimi Code](https://code.kimi.com/) | `kimi` | Skills-based integration; installs into `.kimi-code/skills/`. `--migrate-legacy` moves old `.kimi/skills/` installs to the new paths, and (when the `agent-context` extension is enabled) migrates `KIMI.md` context into `AGENTS.md` |
| [Kiro CLI](https://kiro.dev/docs/cli/) | `kiro-cli` | Kiro CLI does not substitute `$ARGUMENTS` in file-based prompts, so Spec Kit ships a prose fallback at render time (see [Manage prompts](https://kiro.dev/docs/cli/chat/manage-prompts/) and issue [#1926](https://github.com/github/spec-kit/issues/1926)). Alias: `--integration kiro` | | [Kiro CLI](https://kiro.dev/docs/cli/) | `kiro-cli` | Kiro CLI does not substitute `$ARGUMENTS` in file-based prompts, so Spec Kit ships a prose fallback at render time (see [Manage prompts](https://kiro.dev/docs/cli/chat/manage-prompts/) and issue [#1926](https://github.com/github/spec-kit/issues/1926)). Alias: `--integration kiro` |
| [Lingma](https://lingma.aliyun.com/) | `lingma` | Skills-based integration; skills are installed automatically | | [Lingma](https://lingma.aliyun.com/) | `lingma` | Skills-based integration; skills are installed automatically |
| [Mistral Vibe](https://github.com/mistralai/mistral-vibe) | `vibe` | | | [Mistral Vibe](https://github.com/mistralai/mistral-vibe) | `vibe` | |
@@ -218,8 +218,7 @@ Some integrations accept additional options via `--integration-options`:
| Integration | Option | Description | | Integration | Option | Description |
| ----------- | ------------------- | -------------------------------------------------------------- | | ----------- | ------------------- | -------------------------------------------------------------- |
| `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 directory names); when the `agent-context` extension is enabled, also migrates `KIMI.md` to `AGENTS.md` |
| `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:

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.

View File

@@ -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`.

View File

@@ -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

View File

@@ -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) {

View File

@@ -1,352 +0,0 @@
#!/usr/bin/env python3
"""Refresh the managed Spec Kit section in the coding agent's context file(s).
Python port of ``update-agent-context.sh`` / ``update-agent-context.ps1``.
Reads ``context_files`` or ``context_file``, plus ``context_markers.{start,end}``,
from the agent-context extension config:
.specify/extensions/agent-context/agent-context-config.yml
Usage: update_agent_context.py [plan_path]
When ``plan_path`` is omitted, the script derives it from
``.specify/feature.json`` (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.
"""
from __future__ import annotations
import json
import os
import re
import sys
from pathlib import Path
DEFAULT_START = "<!-- SPECKIT START -->"
DEFAULT_END = "<!-- SPECKIT END -->"
def _err(message: str) -> None:
print(message, file=sys.stderr)
def _get_str(obj: object, *keys: str) -> str:
node = obj
for key in keys:
if isinstance(node, dict) and key in node:
node = node[key]
else:
return ""
return node if isinstance(node, str) else ""
def _collect_context_files(data: dict, project_root: str) -> list[str]:
"""Resolve the managed context files from config, mirroring the bash logic."""
context_files: list[str] = []
seen: set[str] = set()
case_insensitive = sys.platform.startswith(("win32", "cygwin", "msys"))
def add(value: object) -> None:
if not isinstance(value, str):
return
candidate = value.strip()
if not candidate:
return
key = candidate.casefold() if case_insensitive else candidate
if key in seen:
return
context_files.append(candidate)
seen.add(key)
raw_files = data.get("context_files")
if isinstance(raw_files, list):
for value in raw_files:
add(value)
if not context_files:
add(_get_str(data, "context_file"))
if not context_files:
# Self-seed: when the config declares no target, derive one from the
# active integration recorded in init-options.json, mapped through the
# bundled agent-context-defaults.json file. Independent of the Specify
# CLI by design.
integration_key = ""
try:
with open(
f"{project_root}/.specify/init-options.json", "r", encoding="utf-8"
) as fh:
opts = json.load(fh)
if isinstance(opts, dict):
value = opts.get("integration") or opts.get("ai") or ""
integration_key = value if isinstance(value, str) else ""
except Exception:
integration_key = ""
if integration_key:
defaults_path = (
f"{project_root}/.specify/extensions/agent-context/"
"agent-context-defaults.json"
)
mapping = {}
try:
with open(defaults_path, "r", encoding="utf-8") as fh:
loaded = json.load(fh)
agents = loaded.get("agents", {}) if isinstance(loaded, dict) else {}
mapping = agents if isinstance(agents, dict) else {}
except Exception:
_err(
"agent-context: unable to read %s; cannot self-seed the context "
"file. Set context_file in the extension config." % defaults_path
)
mapping = {}
add(mapping.get(integration_key, "") or "")
if not context_files:
_err(
"agent-context: no default context file is known for integration "
"%s. Set context_file in the extension config to choose one."
% integration_key
)
return context_files
def _validate_context_file(project_root: str, context_file: str) -> str | None:
"""Return an error message when the path escapes the project root."""
if context_file.startswith("/") or re.match(r"^[A-Za-z]:", context_file):
return (
"agent-context: context files must be project-relative paths; "
f"got '{context_file}'."
)
if "\\" in context_file:
return (
"agent-context: context files must not contain backslash separators; "
f"got '{context_file}'."
)
if ".." in context_file.split("/"):
return (
"agent-context: context files must not contain '..' path segments; "
f"got '{context_file}'."
)
root = Path(project_root).resolve()
target = (root / context_file).resolve()
try:
target.relative_to(root)
except ValueError:
return (
"agent-context: context file path resolves outside the project root; "
f"got '{context_file}'."
)
return None
def _resolve_plan_path(project_root: str) -> str:
"""Derive the plan path: feature.json first, then the mtime fallback."""
plan_path = ""
feature_json = Path(project_root) / ".specify" / "feature.json"
if feature_json.is_file():
feature_dir = ""
try:
with open(feature_json, "r", encoding="utf-8") as fh:
data = json.load(fh)
value = data.get("feature_directory", "")
feature_dir = value if isinstance(value, str) else ""
except Exception:
feature_dir = ""
# Normalize backslashes (written by PS on Windows) before path ops.
feature_dir = feature_dir.replace("\\", "/").rstrip("/")
if feature_dir:
# feature_directory may be relative or absolute (absolute paths
# outside the project root are preserved as-is), including
# drive-qualified paths (C:/...) written by PowerShell on Windows.
if feature_dir.startswith("/") or re.match(r"^[A-Za-z]:/", feature_dir):
candidate = Path(feature_dir) / "plan.md"
else:
candidate = Path(project_root) / feature_dir / "plan.md"
if candidate.is_file():
# Resolve symlinks before comparing so paths like /var/… vs
# /private/var/… (macOS) are treated as equivalent.
root = Path(project_root).resolve()
resolved = candidate.resolve()
try:
plan_path = resolved.relative_to(root).as_posix()
except ValueError:
plan_path = resolved.as_posix()
if not plan_path:
root = Path(project_root).resolve()
plans = sorted(
(root / "specs").glob("*/plan.md"),
key=lambda p: p.stat().st_mtime,
reverse=True,
)
if plans:
try:
plan_path = plans[0].relative_to(root).as_posix()
except ValueError:
plan_path = ""
return plan_path
def _build_section(marker_start: str, marker_end: str, plan_path: str) -> str:
lines = [
marker_start,
"For additional context about technologies to be used, project structure,",
"shell commands, and other important information, read the current plan",
]
if plan_path:
lines.append(f"at {plan_path}")
lines.append(marker_end)
return "\n".join(lines) + "\n"
def ensure_mdc_frontmatter(content: str) -> str:
"""Ensure ``.mdc`` content has YAML frontmatter with ``alwaysApply: true``.
Cursor only auto-loads ``.mdc`` rule files that carry frontmatter with
``alwaysApply: true``. Prepend it when missing, or repair the value while
preserving any existing frontmatter comments/formatting.
"""
leading_ws = len(content) - len(content.lstrip())
leading = content[:leading_ws]
stripped = content[leading_ws:]
if not stripped.startswith("---"):
return "---\nalwaysApply: true\n---\n\n" + content
match = re.match(
r"^(---[ \t]*\r?\n)(.*?)(\r?\n---[ \t]*)(\r?\n|$)(.*)",
stripped,
re.DOTALL,
)
if not match:
return "---\nalwaysApply: true\n---\n\n" + content
opening, fm_text, closing, sep, rest = match.groups()
newline = "\r\n" if "\r\n" in opening else "\n"
if re.search(r"(?m)^[ \t]*alwaysApply[ \t]*:[ \t]*true[ \t]*(?:#.*)?$", fm_text):
return content
if re.search(r"(?m)^[ \t]*alwaysApply[ \t]*:", fm_text):
fm_text = re.sub(
r"(?m)^([ \t]*)alwaysApply[ \t]*:.*?([ \t]*(?:#.*)?)$",
r"\1alwaysApply: true\2",
fm_text,
count=1,
)
elif fm_text.strip():
fm_text = fm_text + newline + "alwaysApply: true"
else:
fm_text = "alwaysApply: true"
return f"{leading}{opening}{fm_text}{closing}{sep}{rest}"
def _upsert_section(
ctx_path: str, marker_start: str, marker_end: str, section: str
) -> None:
"""Insert or replace the managed section, then normalize and write."""
if os.path.exists(ctx_path):
with open(ctx_path, "r", encoding="utf-8-sig") as fh:
content = fh.read()
s = content.find(marker_start)
e = content.find(marker_end, s if s != -1 else 0)
if s != -1 and e != -1 and e > s:
end_of_marker = e + len(marker_end)
if end_of_marker < len(content) and content[end_of_marker] == "\r":
end_of_marker += 1
if end_of_marker < len(content) and content[end_of_marker] == "\n":
end_of_marker += 1
new_content = content[:s] + section + content[end_of_marker:]
elif s != -1:
new_content = content[:s] + section
elif e != -1:
end_of_marker = e + len(marker_end)
if end_of_marker < len(content) and content[end_of_marker] == "\r":
end_of_marker += 1
if end_of_marker < len(content) and content[end_of_marker] == "\n":
end_of_marker += 1
new_content = section + content[end_of_marker:]
else:
if content and not content.endswith("\n"):
content += "\n"
new_content = (content + "\n" + section) if content else section
else:
new_content = section
new_content = new_content.replace("\r\n", "\n").replace("\r", "\n")
if ctx_path.casefold().endswith(".mdc"):
new_content = ensure_mdc_frontmatter(new_content)
with open(ctx_path, "wb") as fh:
fh.write(new_content.encode("utf-8"))
def main(argv: list[str] | None = None) -> int:
args = sys.argv[1:] if argv is None else argv
project_root = os.getcwd()
ext_config = (
f"{project_root}/.specify/extensions/agent-context/agent-context-config.yml"
)
if not os.path.isfile(ext_config):
_err(f"agent-context: {ext_config} not found; nothing to do.")
return 0
try:
import yaml
except ImportError:
_err(
"agent-context: PyYAML is required to parse extension config but is "
"not available in the current Python environment.\n"
" To resolve: pip install pyyaml (or install it into the environment "
"used by python3).\n"
" Context file will not be updated until PyYAML is importable."
)
_err("agent-context: skipping update (see above for details).")
return 0
try:
with open(ext_config, "r", encoding="utf-8") as fh:
data = yaml.safe_load(fh)
except Exception as exc:
_err(
f"agent-context: unable to parse {ext_config} ({exc}); "
"cannot update context."
)
_err("agent-context: skipping update (see above for details).")
return 0
if not isinstance(data, dict):
data = {}
context_files = _collect_context_files(data, project_root)
if not context_files:
_err(
"agent-context: context_files/context_file not set in extension config; "
"nothing to do."
)
return 0
for context_file in context_files:
error = _validate_context_file(project_root, context_file)
if error:
_err(error)
return 1
marker_start = _get_str(data, "context_markers", "start") or DEFAULT_START
marker_end = _get_str(data, "context_markers", "end") or DEFAULT_END
plan_path = args[0] if args else ""
if not plan_path:
plan_path = _resolve_plan_path(project_root)
section = _build_section(marker_start, marker_end, plan_path)
for context_file in context_files:
ctx_path = os.path.join(project_root, context_file)
os.makedirs(os.path.dirname(ctx_path) or ".", exist_ok=True)
_upsert_section(ctx_path, marker_start, marker_end, section)
print(f"agent-context: updated {context_file}")
return 0
if __name__ == "__main__":
sys.exit(main())

View File

@@ -1,6 +1,6 @@
{ {
"schema_version": "1.0", "schema_version": "1.0",
"updated_at": "2026-07-13T00:00:00Z", "updated_at": "2026-07-01T00: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": {
@@ -670,40 +670,6 @@
"created_at": "2026-04-11T18:00:00Z", "created_at": "2026-04-11T18:00:00Z",
"updated_at": "2026-04-11T18:00:00Z" "updated_at": "2026-04-11T18:00:00Z"
}, },
"charter": {
"name": "Charter",
"id": "charter",
"description": "Compose modular project constitutions from shared fragment registries. Centralize governance rules, select per-project fragments, track upstream changes, and keep multi-project setups consistent.",
"author": "Fyloss",
"version": "0.3.1",
"download_url": "https://github.com/Fyloss/spec-kit-charter/archive/refs/tags/v0.3.1.zip",
"repository": "https://github.com/Fyloss/spec-kit-charter",
"homepage": "https://github.com/Fyloss/spec-kit-charter",
"documentation": "https://github.com/Fyloss/spec-kit-charter/tree/master/docs",
"changelog": "https://github.com/Fyloss/spec-kit-charter/blob/master/CHANGELOG.md",
"license": "MIT",
"category": "process",
"effect": "read-write",
"requires": {
"speckit_version": ">=0.11.9"
},
"provides": {
"commands": 5,
"hooks": 1
},
"tags": [
"constitution",
"governance",
"modular",
"fragments",
"registry"
],
"verified": false,
"downloads": 0,
"stars": 0,
"created_at": "2026-07-06T00:00:00Z",
"updated_at": "2026-07-06T00:00:00Z"
},
"ci-guard": { "ci-guard": {
"name": "CI Guard", "name": "CI Guard",
"id": "ci-guard", "id": "ci-guard",
@@ -1106,10 +1072,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": "Canonical-Driven Development enforcement. Validates, scores, and traces project documentation with automated checks, AI-driven workflows, and spec-kit hooks. One pinned runtime dependency; pure Node.js otherwise.",
"author": "raccioly", "author": "raccioly",
"version": "0.32.0", "version": "0.28.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.28.0/spec-kit-docguard-v0.28.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 +1090,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 +1111,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-06-23T00:00:00Z"
}, },
"doctor": { "doctor": {
"name": "Project Health Check", "name": "Project Health Check",
@@ -1188,39 +1146,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 +1179,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 +1319,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 +1364,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 +1378,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 +1392,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 +2106,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 +2260,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",
@@ -2610,39 +2362,6 @@
"created_at": "2026-04-03T00:00:00Z", "created_at": "2026-04-03T00:00:00Z",
"updated_at": "2026-04-03T00:00:00Z" "updated_at": "2026-04-03T00:00:00Z"
}, },
"orchestration-task-context-management": {
"name": "Orchestration Task Context Management",
"id": "orchestration-task-context-management",
"description": "Adds subagent work-unit orchestration to generated Spec Kit task files",
"author": "Igor Benicio de Mesquita",
"version": "0.0.0",
"download_url": "https://github.com/benizzio/spec-kit-orchestration-task-context-management/archive/refs/tags/v0.0.0.zip",
"repository": "https://github.com/benizzio/spec-kit-orchestration-task-context-management",
"homepage": "https://github.com/benizzio/spec-kit-orchestration-task-context-management",
"documentation": "https://github.com/benizzio/spec-kit-orchestration-task-context-management/blob/main/README.md",
"changelog": "https://github.com/benizzio/spec-kit-orchestration-task-context-management/blob/main/CHANGELOG.md",
"license": "MIT",
"category": "process",
"effect": "read-write",
"requires": {
"speckit_version": ">=0.7.2"
},
"provides": {
"commands": 2,
"hooks": 2
},
"tags": [
"agent",
"orchestration",
"tasks",
"context"
],
"verified": false,
"downloads": 0,
"stars": 0,
"created_at": "2026-07-06T00:00:00Z",
"updated_at": "2026-07-06T00:00:00Z"
},
"orchestrator": { "orchestrator": {
"name": "Spec Orchestrator", "name": "Spec Orchestrator",
"id": "orchestrator", "id": "orchestrator",
@@ -2960,8 +2679,8 @@
"id": "ralph", "id": "ralph",
"description": "Autonomous implementation loop using AI agent CLI", "description": "Autonomous implementation loop using AI agent CLI",
"author": "Rubiss", "author": "Rubiss",
"version": "1.2.1", "version": "1.1.1",
"download_url": "https://github.com/Rubiss-Projects/spec-kit-ralph/archive/refs/tags/v1.2.1.zip", "download_url": "https://github.com/Rubiss-Projects/spec-kit-ralph/archive/refs/tags/v1.1.1.zip",
"repository": "https://github.com/Rubiss-Projects/spec-kit-ralph", "repository": "https://github.com/Rubiss-Projects/spec-kit-ralph",
"homepage": "https://github.com/Rubiss-Projects/spec-kit-ralph", "homepage": "https://github.com/Rubiss-Projects/spec-kit-ralph",
"documentation": "https://github.com/Rubiss-Projects/spec-kit-ralph/blob/main/README.md", "documentation": "https://github.com/Rubiss-Projects/spec-kit-ralph/blob/main/README.md",
@@ -2970,7 +2689,7 @@
"category": "code", "category": "code",
"effect": "read-write", "effect": "read-write",
"requires": { "requires": {
"speckit_version": ">=0.8.5", "speckit_version": ">=0.1.0",
"tools": [ "tools": [
{ {
"name": "copilot", "name": "copilot",
@@ -2980,10 +2699,6 @@
"name": "codex", "name": "codex",
"required": false "required": false
}, },
{
"name": "claude",
"required": false
},
{ {
"name": "git", "name": "git",
"required": true "required": true
@@ -2999,14 +2714,13 @@
"automation", "automation",
"loop", "loop",
"copilot", "copilot",
"codex", "codex"
"claude"
], ],
"verified": false, "verified": false,
"downloads": 0, "downloads": 0,
"stars": 0, "stars": 0,
"created_at": "2026-03-09T00:00:00Z", "created_at": "2026-03-09T00:00:00Z",
"updated_at": "2026-07-06T00:00:00Z" "updated_at": "2026-06-05T03:11:06Z"
}, },
"reconcile": { "reconcile": {
"name": "Reconcile Extension", "name": "Reconcile Extension",
@@ -3333,10 +3047,10 @@
"ripple": { "ripple": {
"name": "Ripple", "name": "Ripple",
"id": "ripple", "id": "ripple",
"description": "Detect side effects that tests can't catch after implementation — surface hidden ripple effects across 9 analysis categories", "description": "Detect side effects that tests can't catch after implementation — delta-anchored analysis across 9 domain-agnostic categories with fix-induced side effect detection",
"author": "chordpli", "author": "chordpli",
"version": "1.1.0", "version": "1.0.0",
"download_url": "https://github.com/chordpli/spec-kit-ripple/archive/refs/tags/v1.1.0.zip", "download_url": "https://github.com/chordpli/spec-kit-ripple/archive/refs/tags/v1.0.0.zip",
"repository": "https://github.com/chordpli/spec-kit-ripple", "repository": "https://github.com/chordpli/spec-kit-ripple",
"homepage": "https://github.com/chordpli/spec-kit-ripple", "homepage": "https://github.com/chordpli/spec-kit-ripple",
"documentation": "https://github.com/chordpli/spec-kit-ripple/blob/main/README.md", "documentation": "https://github.com/chordpli/spec-kit-ripple/blob/main/README.md",
@@ -3345,13 +3059,7 @@
"category": "code", "category": "code",
"effect": "read-write", "effect": "read-write",
"requires": { "requires": {
"speckit_version": ">=0.2.0", "speckit_version": ">=0.2.0"
"tools": [
{
"name": "git",
"required": true
}
]
}, },
"provides": { "provides": {
"commands": 3, "commands": 3,
@@ -3368,7 +3076,7 @@
"downloads": 0, "downloads": 0,
"stars": 0, "stars": 0,
"created_at": "2026-04-20T00:00:00Z", "created_at": "2026-04-20T00:00:00Z",
"updated_at": "2026-07-06T00:00:00Z" "updated_at": "2026-04-20T00:00:00Z"
}, },
"roadmap": { "roadmap": {
"name": "Spec Roadmap", "name": "Spec Roadmap",
@@ -4493,40 +4201,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",
@@ -4623,40 +4297,6 @@
"created_at": "2026-04-13T00:00:00Z", "created_at": "2026-04-13T00:00:00Z",
"updated_at": "2026-04-13T00:00:00Z" "updated_at": "2026-04-13T00:00:00Z"
}, },
"wiki": {
"name": "LLM Wiki",
"id": "wiki",
"description": "LLM-maintained compounding project wiki: source ingestion, cited answers, and consistency linting",
"author": "formin",
"version": "1.0.0",
"download_url": "https://github.com/formin/spec-kit-wiki/archive/refs/tags/v1.0.0.zip",
"repository": "https://github.com/formin/spec-kit-wiki",
"homepage": "https://github.com/formin/spec-kit-wiki",
"documentation": "https://github.com/formin/spec-kit-wiki/blob/main/README.md",
"changelog": "https://github.com/formin/spec-kit-wiki/blob/main/CHANGELOG.md",
"license": "MIT",
"category": "docs",
"effect": "read-write",
"requires": {
"speckit_version": ">=0.2.0"
},
"provides": {
"commands": 5,
"hooks": 2
},
"tags": [
"wiki",
"knowledge-base",
"docs",
"memory",
"context-management"
],
"verified": false,
"downloads": 0,
"stars": 0,
"created_at": "2026-07-06T00:00:00Z",
"updated_at": "2026-07-06T00:00:00Z"
},
"wireframe": { "wireframe": {
"name": "Wireframe Visual Feedback Loop", "name": "Wireframe Visual Feedback Loop",
"id": "wireframe", "id": "wireframe",

View File

@@ -48,4 +48,4 @@
] ]
} }
} }
} }

View File

@@ -7,7 +7,7 @@ Git repository initialization, feature branch creation, numbering (sequential/ti
This extension provides Git operations as an optional, self-contained module. It manages: This extension provides Git operations as an optional, self-contained module. It manages:
- **Repository initialization** with configurable commit messages - **Repository initialization** with configurable commit messages
- **Feature branch creation** with sequential (`001-feature-name`) or timestamp (`20260319-143022-feature-name`) numbering and optional templates for branch namespaces - **Feature branch creation** with sequential (`001-feature-name`) or timestamp (`20260319-143022-feature-name`) numbering
- **Branch validation** to ensure branches follow naming conventions - **Branch validation** to ensure branches follow naming conventions
- **Git remote detection** for GitHub integration (e.g., issue creation) - **Git remote detection** for GitHub integration (e.g., issue creation)
- **Auto-commit** after core commands (configurable per-command with custom messages) - **Auto-commit** after core commands (configurable per-command with custom messages)
@@ -53,16 +53,6 @@ Configuration is stored in `.specify/extensions/git/git-config.yml`:
# Branch numbering strategy: "sequential" or "timestamp" # Branch numbering strategy: "sequential" or "timestamp"
branch_numbering: sequential branch_numbering: sequential
# Optional branch name template. Leave empty for the default "{number}-{slug}".
# Supported tokens: {author}, {app}, {number}, {slug}; {slug} must not appear
# before {number}, and the final path segment must start with {number}-.
# Example for monorepos: "{author}/{app}/{number}-{slug}"
branch_template: ""
# Optional shorthand namespace. Leave empty to use branch_template/default behavior.
# Example: "features/{app}" expands to "features/{app}/{number}-{slug}"
branch_prefix: ""
# Custom commit message for git init # Custom commit message for git init
init_commit_message: "[Spec Kit] Initial commit" init_commit_message: "[Spec Kit] Initial commit"
@@ -75,10 +65,6 @@ auto_commit:
message: "[Spec Kit] Add specification" message: "[Spec Kit] Add specification"
``` ```
`{author}` is derived from Git config and sanitized for branch names. `{app}` is derived from the Spec Kit init directory name. Custom templates must not put `{slug}` before `{number}`, and must put `{number}-` at the start of the final path segment so generated names remain valid feature branches. For a monorepo project at `apps/web/.specify/`, a template such as `{author}/{app}/{number}-{slug}` produces branches like `jdoe/web/008-guided-tour`.
For simple namespace-only customization, `branch_prefix` is also accepted as a shorthand and expands to `<branch_prefix>/{number}-{slug}`.
## Installation ## Installation
```bash ```bash

View File

@@ -19,7 +19,7 @@ You **MUST** consider the user input before proceeding (if not empty).
If the user explicitly provided `GIT_BRANCH_NAME` (e.g., via environment variable, argument, or in their request), pass it through to the script by setting the `GIT_BRANCH_NAME` environment variable before invoking the script. When `GIT_BRANCH_NAME` is set: If the user explicitly provided `GIT_BRANCH_NAME` (e.g., via environment variable, argument, or in their request), pass it through to the script by setting the `GIT_BRANCH_NAME` environment variable before invoking the script. When `GIT_BRANCH_NAME` is set:
- The script uses the exact value as the branch name, bypassing all prefix/suffix generation - The script uses the exact value as the branch name, bypassing all prefix/suffix generation
- `--short-name`, `--number`, and `--timestamp` flags are ignored - `--short-name`, `--number`, and `--timestamp` flags are ignored
- `FEATURE_NUM` is extracted when the final path segment starts with a numeric or timestamp feature marker (for example `042-name`, `feat/042-name`, or `jdoe/app/042-name`), otherwise set to the full branch name - `FEATURE_NUM` is extracted from the name if it starts with a numeric prefix, otherwise set to the full branch name
## Prerequisites ## Prerequisites
@@ -35,19 +35,6 @@ Determine the branch numbering strategy by checking configuration in this order:
3. Check `.specify/init-options.json` for `branch_numbering` value (deprecated, backward compatibility — will be removed in a future release) 3. Check `.specify/init-options.json` for `branch_numbering` value (deprecated, backward compatibility — will be removed in a future release)
4. Default to `sequential` if none of the above exist 4. Default to `sequential` if none of the above exist
## Branch Name Template
Check `.specify/extensions/git/git-config.yml` for an optional `branch_template` value. If it is empty or missing, use the default branch shape `{number}-{slug}`. If it is set, `{slug}` must not appear before `{number}`, its final path segment must start with `{number}-`, and the script expands these tokens:
- `{author}`: sanitized Git config author (`user.name`, falling back to the email local part)
- `{app}`: sanitized Spec Kit init directory name
- `{number}`: sequential number or timestamp
- `{slug}`: generated short branch slug
For monorepos, a template such as `{author}/{app}/{number}-{slug}` creates names like `jdoe/web/008-guided-tour` while preserving per-project feature numbering.
The script also accepts `branch_prefix` as a shorthand for simple namespaces; it expands to `<branch_prefix>/{number}-{slug}`.
## Execution ## Execution
Generate a concise short name (2-4 words) for the branch: Generate a concise short name (2-4 words) for the branch:
@@ -67,7 +54,6 @@ Run the appropriate script based on your platform:
- Always include the JSON flag (`--json` for Bash, `-Json` for PowerShell) so the output can be parsed reliably - Always include the JSON flag (`--json` for Bash, `-Json` for PowerShell) so the output can be parsed reliably
- You must only ever run this script once per feature - You must only ever run this script once per feature
- The JSON output will contain `BRANCH_NAME` and `FEATURE_NUM` - The JSON output will contain `BRANCH_NAME` and `FEATURE_NUM`
- Do not manually expand `branch_template`; the script reads the git extension config and applies it consistently
## Graceful Degradation ## Graceful Degradation
@@ -78,5 +64,5 @@ If Git is not installed or the current directory is not a Git repository:
## Output ## Output
The script outputs JSON with: The script outputs JSON with:
- `BRANCH_NAME`: The branch name (e.g., `003-user-auth`, `20260319-143022-user-auth`, or `jdoe/web/003-user-auth`) - `BRANCH_NAME`: The branch name (e.g., `003-user-auth` or `20260319-143022-user-auth`)
- `FEATURE_NUM`: The numeric or timestamp prefix used - `FEATURE_NUM`: The numeric or timestamp prefix used

View File

@@ -22,24 +22,24 @@ Get the current branch name:
git rev-parse --abbrev-ref HEAD git rev-parse --abbrev-ref HEAD
``` ```
The branch name's final path segment must start with one of these feature markers: The branch name must match one of these patterns:
1. **Sequential**: `[0-9]{3,}-` (e.g., `001-feature-name`, `042-fix-bug`, `1000-big-feature`, `jdoe/web/008-guided-tour`) 1. **Sequential**: `^[0-9]{3,}-` (e.g., `001-feature-name`, `042-fix-bug`, `1000-big-feature`)
2. **Timestamp**: `[0-9]{8}-[0-9]{6}-` (e.g., `20260319-143022-feature-name`, `jdoe/web/20260319-143022-feature-name`) 2. **Timestamp**: `^[0-9]{8}-[0-9]{6}-` (e.g., `20260319-143022-feature-name`)
## Execution ## Execution
If on a feature branch (matches either pattern): If on a feature branch (matches either pattern):
- Output: `✓ On feature branch: <branch-name>` - Output: `✓ On feature branch: <branch-name>`
- Check if the corresponding spec directory exists under `specs/`: - Check if the corresponding spec directory exists under `specs/`:
- For sequential branches, look for `specs/<prefix>-*` where prefix matches the numeric portion, regardless of branch namespace prefixes - For sequential branches, look for `specs/<prefix>-*` where prefix matches the numeric portion
- For timestamp branches, look for `specs/<prefix>-*` where prefix matches the `YYYYMMDD-HHMMSS` portion, regardless of branch namespace prefixes - For timestamp branches, look for `specs/<prefix>-*` where prefix matches the `YYYYMMDD-HHMMSS` portion
- If spec directory exists: `✓ Spec directory found: <path>` - If spec directory exists: `✓ Spec directory found: <path>`
- If spec directory missing: `⚠ No spec directory found for prefix <prefix>` - If spec directory missing: `⚠ No spec directory found for prefix <prefix>`
If NOT on a feature branch: If NOT on a feature branch:
- Output: `✗ Not on a feature branch. Current branch: <branch-name>` - Output: `✗ Not on a feature branch. Current branch: <branch-name>`
- Output: `Feature branches should be named like: 001-feature-name, 20260319-143022-feature-name, or <namespace>/001-feature-name` - Output: `Feature branches should be named like: 001-feature-name or 20260319-143022-feature-name`
## Graceful Degradation ## Graceful Degradation

View File

@@ -4,16 +4,6 @@
# Branch numbering strategy: "sequential" (001, 002, ...) or "timestamp" (YYYYMMDD-HHMMSS) # Branch numbering strategy: "sequential" (001, 002, ...) or "timestamp" (YYYYMMDD-HHMMSS)
branch_numbering: sequential branch_numbering: sequential
# Optional branch name template. Leave empty for the default "{number}-{slug}".
# Supported tokens: {author}, {app}, {number}, {slug}
# {slug} must not appear before {number}; final path segment must start with {number}-.
# Example for monorepos: "{author}/{app}/{number}-{slug}"
branch_template: ""
# Optional shorthand namespace. Leave empty to use branch_template/default behavior.
# Example: "features/{app}" expands to "features/{app}/{number}-{slug}"
branch_prefix: ""
# Commit message used by `git commit` during repository initialization # Commit message used by `git commit` during repository initialization
init_commit_message: "[Spec Kit] Initial commit" init_commit_message: "[Spec Kit] Initial commit"

View File

@@ -4,7 +4,7 @@ extension:
id: git id: git
name: "Git Branching Workflow" name: "Git Branching Workflow"
version: "1.0.0" version: "1.0.0"
description: "Feature branch creation, numbering (sequential/timestamp), templating, validation, and Git remote detection" description: "Feature branch creation, numbering (sequential/timestamp), validation, and Git remote detection"
author: spec-kit-core author: spec-kit-core
repository: https://github.com/github/spec-kit repository: https://github.com/github/spec-kit
license: MIT license: MIT
@@ -19,7 +19,7 @@ provides:
commands: commands:
- name: speckit.git.feature - name: speckit.git.feature
file: commands/speckit.git.feature.md file: commands/speckit.git.feature.md
description: "Create a feature branch with sequential or timestamp numbering and optional templates" description: "Create a feature branch with sequential or timestamp numbering"
- name: speckit.git.validate - name: speckit.git.validate
file: commands/speckit.git.validate.md file: commands/speckit.git.validate.md
description: "Validate current branch follows feature branch naming conventions" description: "Validate current branch follows feature branch naming conventions"
@@ -137,6 +137,4 @@ tags:
config: config:
defaults: defaults:
branch_numbering: sequential branch_numbering: sequential
branch_template: ""
branch_prefix: ""
init_commit_message: "[Spec Kit] Initial commit" init_commit_message: "[Spec Kit] Initial commit"

View File

@@ -4,16 +4,6 @@
# Branch numbering strategy: "sequential" (001, 002, ...) or "timestamp" (YYYYMMDD-HHMMSS) # Branch numbering strategy: "sequential" (001, 002, ...) or "timestamp" (YYYYMMDD-HHMMSS)
branch_numbering: sequential branch_numbering: sequential
# Optional branch name template. Leave empty for the default "{number}-{slug}".
# Supported tokens: {author}, {app}, {number}, {slug}
# {slug} must not appear before {number}; final path segment must start with {number}-.
# Example for monorepos: "{author}/{app}/{number}-{slug}"
branch_template: ""
# Optional shorthand namespace. Leave empty to use branch_template/default behavior.
# Example: "features/{app}" expands to "features/{app}/{number}-{slug}"
branch_prefix: ""
# Commit message used by `git commit` during repository initialization # Commit message used by `git commit` during repository initialization
init_commit_message: "[Spec Kit] Initial commit" init_commit_message: "[Spec Kit] Initial commit"

View File

@@ -75,10 +75,6 @@ while [ $i -le $# ]; do
echo "Environment variables:" echo "Environment variables:"
echo " GIT_BRANCH_NAME Use this exact branch name, bypassing all prefix/suffix generation" echo " GIT_BRANCH_NAME Use this exact branch name, bypassing all prefix/suffix generation"
echo "" echo ""
echo "Configuration:"
echo " branch_template Optional git-config.yml template with {author}, {app}, {number}, {slug}"
echo " branch_prefix Optional shorthand namespace expanded before {number}-{slug}"
echo ""
echo "Examples:" echo "Examples:"
echo " $0 'Add user authentication system' --short-name 'user-auth'" echo " $0 'Add user authentication system' --short-name 'user-auth'"
echo " $0 'Implement OAuth2 integration for API' --number 5" echo " $0 'Implement OAuth2 integration for API' --number 5"
@@ -131,28 +127,16 @@ get_highest_from_specs() {
# Function to get highest number from git branches # Function to get highest number from git branches
get_highest_from_branches() { get_highest_from_branches() {
local scope_prefix="${1:-}" git branch -a 2>/dev/null | sed -E 's/^[+*][[:space:]]+//; s/^[[:space:]]+//; s|^remotes/[^/]*/||' | _extract_highest_number
git branch -a 2>/dev/null | sed -E 's/^[+*][[:space:]]+//; s/^[[:space:]]+//; s|^remotes/[^/]*/||' | _extract_highest_number "$scope_prefix"
} }
# Extract the highest sequential feature number from a list of ref names (one per line). # Extract the highest sequential feature number from a list of ref names (one per line).
_extract_highest_number() { _extract_highest_number() {
local scope_prefix="${1:-}"
local highest=0 local highest=0
while IFS= read -r name; do while IFS= read -r name; do
[ -z "$name" ] && continue [ -z "$name" ] && continue
if [ -n "$scope_prefix" ]; then if echo "$name" | grep -Eq '^[0-9]{3,}-' && ! echo "$name" | grep -Eq '^[0-9]{8}-[0-9]{6}-'; then
case "$name" in number=$(echo "$name" | grep -Eo '^[0-9]+' || echo "0")
"$scope_prefix"*) name="${name#"$scope_prefix"}" ;;
*) continue ;;
esac
fi
name="${name##*/}"
if echo "$name" | grep -Eq '^[0-9]{3,}-' \
&& ! echo "$name" | grep -Eq '^[0-9]{8}-[0-9]{6}-' \
&& ! echo "$name" | grep -Eq '^[0-9]{7}-[0-9]{6}-' \
&& ! echo "$name" | grep -Eq '^[0-9]{7,8}-[0-9]{6}$'; then
number=$(echo "$name" | grep -Eo '^[0-9]{3,}-' | sed -E 's/-$//' || echo "0")
number=$((10#$number)) number=$((10#$number))
if [ "$number" -gt "$highest" ]; then if [ "$number" -gt "$highest" ]; then
highest=$number highest=$number
@@ -164,12 +148,11 @@ _extract_highest_number() {
# Function to get highest number from remote branches without fetching (side-effect-free) # Function to get highest number from remote branches without fetching (side-effect-free)
get_highest_from_remote_refs() { get_highest_from_remote_refs() {
local scope_prefix="${1:-}"
local highest=0 local highest=0
for remote in $(git remote 2>/dev/null); do for remote in $(git remote 2>/dev/null); do
local remote_highest local remote_highest
remote_highest=$(GIT_TERMINAL_PROMPT=0 git ls-remote --heads "$remote" 2>/dev/null | sed 's|.*refs/heads/||' | _extract_highest_number "$scope_prefix") remote_highest=$(GIT_TERMINAL_PROMPT=0 git ls-remote --heads "$remote" 2>/dev/null | sed 's|.*refs/heads/||' | _extract_highest_number)
if [ "$remote_highest" -gt "$highest" ]; then if [ "$remote_highest" -gt "$highest" ]; then
highest=$remote_highest highest=$remote_highest
fi fi
@@ -182,17 +165,16 @@ get_highest_from_remote_refs() {
check_existing_branches() { check_existing_branches() {
local specs_dir="$1" local specs_dir="$1"
local skip_fetch="${2:-false}" local skip_fetch="${2:-false}"
local scope_prefix="${3:-}"
if [ "$skip_fetch" = true ]; then if [ "$skip_fetch" = true ]; then
local highest_remote=$(get_highest_from_remote_refs "$scope_prefix") local highest_remote=$(get_highest_from_remote_refs)
local highest_branch=$(get_highest_from_branches "$scope_prefix") local highest_branch=$(get_highest_from_branches)
if [ "$highest_remote" -gt "$highest_branch" ]; then if [ "$highest_remote" -gt "$highest_branch" ]; then
highest_branch=$highest_remote highest_branch=$highest_remote
fi fi
else else
git fetch --all --prune >/dev/null 2>&1 || true git fetch --all --prune >/dev/null 2>&1 || true
local highest_branch=$(get_highest_from_branches "$scope_prefix") local highest_branch=$(get_highest_from_branches)
fi fi
local highest_spec=$(get_highest_from_specs "$specs_dir") local highest_spec=$(get_highest_from_specs "$specs_dir")
@@ -291,152 +273,6 @@ fi
cd "$REPO_ROOT" cd "$REPO_ROOT"
SPECS_DIR="$REPO_ROOT/specs" SPECS_DIR="$REPO_ROOT/specs"
CONFIG_FILE="$REPO_ROOT/.specify/extensions/git/git-config.yml"
read_git_config_value() {
local key="$1"
[ -f "$CONFIG_FILE" ] || return 0
grep -E "^[[:space:]]*${key}:" "$CONFIG_FILE" 2>/dev/null \
| head -n 1 \
| sed -E "s/^[[:space:]]*${key}:[[:space:]]*//" \
| sed -E 's/[[:space:]]+#.*$//' \
| sed -E 's/^[[:space:]]+|[[:space:]]+$//g' \
| sed -E 's/^"//; s/"$//' \
| sed -E "s/^'//; s/'$//"
}
branch_token() {
local value="$1"
local fallback="$2"
local cleaned
cleaned=$(clean_branch_name "$value")
if [ -n "$cleaned" ]; then
printf '%s\n' "$cleaned"
else
printf '%s\n' "$fallback"
fi
}
get_author_token() {
local author=""
if command -v git >/dev/null 2>&1; then
author=$(git config user.name 2>/dev/null || true)
if [ -z "$author" ]; then
author=$(git config user.email 2>/dev/null | sed 's/@.*$//' || true)
fi
fi
if [ -z "$author" ]; then
author="${USER:-unknown}"
fi
branch_token "$author" "unknown"
}
get_app_token() {
branch_token "$(basename "$REPO_ROOT")" "app"
}
resolve_branch_template() {
local template
local prefix
template=$(read_git_config_value "branch_template")
if [ -n "$template" ]; then
printf '%s\n' "$template"
return
fi
prefix=$(read_git_config_value "branch_prefix")
if [ -z "$prefix" ]; then
printf '%s\n' ""
return
fi
case "$prefix" in
*/) printf '%s%s\n' "$prefix" "{number}-{slug}" ;;
*) printf '%s/%s\n' "$prefix" "{number}-{slug}" ;;
esac
}
render_branch_template() {
local template="$1"
local feature_num="$2"
local branch_suffix="$3"
local rendered="$template"
rendered=${rendered//\{author\}/$AUTHOR_TOKEN}
rendered=${rendered//\{app\}/$APP_TOKEN}
rendered=${rendered//\{number\}/$feature_num}
rendered=${rendered//\{slug\}/$branch_suffix}
printf '%s\n' "$rendered"
}
validate_branch_template() {
local template="$1"
[ -n "$template" ] || return 0
local feature_segment
feature_segment="${template##*/}"
case "$template" in
*"{number}"*) ;;
*)
>&2 echo "Error: branch_template must include the {number} token so generated branches remain valid feature branches."
exit 1
;;
esac
case "$template" in
*"{slug}"*"{number}"*)
>&2 echo "Error: branch_template must not place {slug} before {number}; use {slug} only in the final feature segment."
exit 1
;;
esac
case "$feature_segment" in
"{number}-"*) ;;
*)
>&2 echo "Error: branch_template must put {number}- at the start of the final path segment so generated branches remain valid feature branches."
exit 1
;;
esac
}
build_branch_name() {
local feature_num="$1"
local branch_suffix="$2"
if [ -n "$BRANCH_TEMPLATE" ]; then
render_branch_template "$BRANCH_TEMPLATE" "$feature_num" "$branch_suffix"
else
printf '%s-%s\n' "$feature_num" "$branch_suffix"
fi
}
branch_scope_prefix() {
local template="$1"
local prefix="$template"
[ -n "$prefix" ] || return 0
case "$prefix" in
*"{number}"*) prefix="${prefix%%\{number\}*}" ;;
*"{slug}"*) prefix="${prefix%%\{slug\}*}" ;;
*) return 0 ;;
esac
render_branch_template "$prefix" "" "$BRANCH_SUFFIX"
}
extract_feature_num_from_branch() {
local branch_name="$1"
local feature_segment="${branch_name##*/}"
local match
match=$(printf '%s\n' "$feature_segment" | grep -Eo '^[0-9]{8}-[0-9]{6}-' | head -n 1 || true)
if [ -n "$match" ]; then
printf '%s\n' "$match" | sed -E 's/-$//'
return
fi
match=$(printf '%s\n' "$feature_segment" | grep -Eo '^[0-9]+-' | head -n 1 || true)
if [ -n "$match" ]; then
printf '%s\n' "$match" | sed -E 's/-$//'
return
fi
printf '%s\n' "$branch_name"
}
AUTHOR_TOKEN=$(get_author_token)
APP_TOKEN=$(get_app_token)
BRANCH_TEMPLATE=$(resolve_branch_template)
validate_branch_template "$BRANCH_TEMPLATE"
# Function to generate branch name with stop word filtering # Function to generate branch name with stop word filtering
generate_branch_name() { generate_branch_name() {
@@ -482,8 +318,18 @@ generate_branch_name() {
# Check for GIT_BRANCH_NAME env var override (exact branch name, no prefix/suffix) # Check for GIT_BRANCH_NAME env var override (exact branch name, no prefix/suffix)
if [ -n "${GIT_BRANCH_NAME:-}" ]; then if [ -n "${GIT_BRANCH_NAME:-}" ]; then
BRANCH_NAME="$GIT_BRANCH_NAME" BRANCH_NAME="$GIT_BRANCH_NAME"
FEATURE_NUM=$(extract_feature_num_from_branch "$BRANCH_NAME") # Extract FEATURE_NUM from the branch name if it starts with a numeric prefix
BRANCH_SUFFIX="$BRANCH_NAME" # Check timestamp pattern first (YYYYMMDD-HHMMSS-) since it also matches the simpler ^[0-9]+ pattern
if echo "$BRANCH_NAME" | grep -Eq '^[0-9]{8}-[0-9]{6}-'; then
FEATURE_NUM=$(echo "$BRANCH_NAME" | grep -Eo '^[0-9]{8}-[0-9]{6}')
BRANCH_SUFFIX="${BRANCH_NAME#${FEATURE_NUM}-}"
elif echo "$BRANCH_NAME" | grep -Eq '^[0-9]+-'; then
FEATURE_NUM=$(echo "$BRANCH_NAME" | grep -Eo '^[0-9]+')
BRANCH_SUFFIX="${BRANCH_NAME#${FEATURE_NUM}-}"
else
FEATURE_NUM="$BRANCH_NAME"
BRANCH_SUFFIX="$BRANCH_NAME"
fi
else else
# Generate branch name # Generate branch name
if [ -n "$SHORT_NAME" ]; then if [ -n "$SHORT_NAME" ]; then
@@ -501,17 +347,16 @@ else
# Determine branch prefix # Determine branch prefix
if [ "$USE_TIMESTAMP" = true ]; then if [ "$USE_TIMESTAMP" = true ]; then
FEATURE_NUM=$(date +%Y%m%d-%H%M%S) FEATURE_NUM=$(date +%Y%m%d-%H%M%S)
BRANCH_NAME=$(build_branch_name "$FEATURE_NUM" "$BRANCH_SUFFIX") BRANCH_NAME="${FEATURE_NUM}-${BRANCH_SUFFIX}"
else else
BRANCH_SCOPE_PREFIX=$(branch_scope_prefix "$BRANCH_TEMPLATE")
if [ -z "$BRANCH_NUMBER" ]; then if [ -z "$BRANCH_NUMBER" ]; then
if [ "$DRY_RUN" = true ] && [ "$HAS_GIT" = true ]; then if [ "$DRY_RUN" = true ] && [ "$HAS_GIT" = true ]; then
BRANCH_NUMBER=$(check_existing_branches "$SPECS_DIR" true "$BRANCH_SCOPE_PREFIX") BRANCH_NUMBER=$(check_existing_branches "$SPECS_DIR" true)
elif [ "$DRY_RUN" = true ]; then elif [ "$DRY_RUN" = true ]; then
HIGHEST=$(get_highest_from_specs "$SPECS_DIR") HIGHEST=$(get_highest_from_specs "$SPECS_DIR")
BRANCH_NUMBER=$((HIGHEST + 1)) BRANCH_NUMBER=$((HIGHEST + 1))
elif [ "$HAS_GIT" = true ]; then elif [ "$HAS_GIT" = true ]; then
BRANCH_NUMBER=$(check_existing_branches "$SPECS_DIR" false "$BRANCH_SCOPE_PREFIX") BRANCH_NUMBER=$(check_existing_branches "$SPECS_DIR")
else else
HIGHEST=$(get_highest_from_specs "$SPECS_DIR") HIGHEST=$(get_highest_from_specs "$SPECS_DIR")
BRANCH_NUMBER=$((HIGHEST + 1)) BRANCH_NUMBER=$((HIGHEST + 1))
@@ -519,7 +364,7 @@ else
fi fi
FEATURE_NUM=$(printf "%03d" "$((10#$BRANCH_NUMBER))") FEATURE_NUM=$(printf "%03d" "$((10#$BRANCH_NUMBER))")
BRANCH_NAME=$(build_branch_name "$FEATURE_NUM" "$BRANCH_SUFFIX") BRANCH_NAME="${FEATURE_NUM}-${BRANCH_SUFFIX}"
fi fi
fi fi
@@ -531,23 +376,18 @@ if [ -n "${GIT_BRANCH_NAME:-}" ] && [ "$BRANCH_BYTE_LEN" -gt $MAX_BRANCH_LENGTH
>&2 echo "Error: GIT_BRANCH_NAME must be 244 bytes or fewer in UTF-8. Provided value is ${BRANCH_BYTE_LEN} bytes." >&2 echo "Error: GIT_BRANCH_NAME must be 244 bytes or fewer in UTF-8. Provided value is ${BRANCH_BYTE_LEN} bytes."
exit 1 exit 1
elif [ "$BRANCH_BYTE_LEN" -gt $MAX_BRANCH_LENGTH ]; then elif [ "$BRANCH_BYTE_LEN" -gt $MAX_BRANCH_LENGTH ]; then
PREFIX_LENGTH=$(( ${#FEATURE_NUM} + 1 ))
MAX_SUFFIX_LENGTH=$((MAX_BRANCH_LENGTH - PREFIX_LENGTH))
TRUNCATED_SUFFIX=$(echo "$BRANCH_SUFFIX" | cut -c1-$MAX_SUFFIX_LENGTH)
TRUNCATED_SUFFIX=$(echo "$TRUNCATED_SUFFIX" | sed 's/-$//')
ORIGINAL_BRANCH_NAME="$BRANCH_NAME" ORIGINAL_BRANCH_NAME="$BRANCH_NAME"
TRUNCATED_SUFFIX="$BRANCH_SUFFIX" BRANCH_NAME="${FEATURE_NUM}-${TRUNCATED_SUFFIX}"
while [ "$(_byte_length "$BRANCH_NAME")" -gt "$MAX_BRANCH_LENGTH" ] && [ -n "$TRUNCATED_SUFFIX" ]; do
TRUNCATED_SUFFIX="${TRUNCATED_SUFFIX%?}"
TRUNCATED_SUFFIX="${TRUNCATED_SUFFIX%-}"
BRANCH_NAME=$(build_branch_name "$FEATURE_NUM" "$TRUNCATED_SUFFIX")
done
if [ "$(_byte_length "$BRANCH_NAME")" -gt "$MAX_BRANCH_LENGTH" ]; then
>&2 echo "Error: Branch template prefix exceeds GitHub's 244-byte branch name limit."
exit 1
fi
>&2 echo "[specify] Warning: Branch name exceeded GitHub's 244-byte limit" >&2 echo "[specify] Warning: Branch name exceeded GitHub's 244-byte limit"
ORIGINAL_BRANCH_BYTE_LEN=$(_byte_length "$ORIGINAL_BRANCH_NAME") >&2 echo "[specify] Original: $ORIGINAL_BRANCH_NAME (${#ORIGINAL_BRANCH_NAME} bytes)"
TRUNCATED_BRANCH_BYTE_LEN=$(_byte_length "$BRANCH_NAME") >&2 echo "[specify] Truncated to: $BRANCH_NAME (${#BRANCH_NAME} bytes)"
>&2 echo "[specify] Original: $ORIGINAL_BRANCH_NAME (${ORIGINAL_BRANCH_BYTE_LEN} bytes)"
>&2 echo "[specify] Truncated to: $BRANCH_NAME (${TRUNCATED_BRANCH_BYTE_LEN} bytes)"
fi fi
if [ "$DRY_RUN" != true ]; then if [ "$DRY_RUN" != true ]; then

View File

@@ -23,9 +23,8 @@ spec_kit_effective_branch_name() {
} }
# Validate that a branch name matches the expected feature branch pattern. # Validate that a branch name matches the expected feature branch pattern.
# Accepts sequential (###-* with >=3 digits) or timestamp (YYYYMMDD-HHMMSS-*) formats, # 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 scripts/bash/common.sh check_feature_branch after effective-name normalization.
# Logic aligned with the git extension's PowerShell Test-FeatureBranch twin.
check_feature_branch() { check_feature_branch() {
local raw="$1" local raw="$1"
local has_git_repo="$2" local has_git_repo="$2"
@@ -38,17 +37,16 @@ check_feature_branch() {
local branch local branch
branch=$(spec_kit_effective_branch_name "$raw") branch=$(spec_kit_effective_branch_name "$raw")
local feature_segment="${branch##*/}"
# Accept sequential prefix (3+ digits) but exclude malformed timestamps # Accept sequential prefix (3+ digits) but exclude malformed timestamps
# Malformed: 7-or-8 digit date + 6-digit time with no trailing slug (e.g. "2026031-143022" or "20260319-143022") # Malformed: 7-or-8 digit date + 6-digit time with no trailing slug (e.g. "2026031-143022" or "20260319-143022")
local is_sequential=false local is_sequential=false
if [[ "$feature_segment" =~ ^[0-9]{3,}- ]] && [[ ! "$feature_segment" =~ ^[0-9]{7}-[0-9]{6}- ]] && [[ ! "$feature_segment" =~ ^[0-9]{7,8}-[0-9]{6}$ ]]; then if [[ "$branch" =~ ^[0-9]{3,}- ]] && [[ ! "$branch" =~ ^[0-9]{7}-[0-9]{6}- ]] && [[ ! "$branch" =~ ^[0-9]{7,8}-[0-9]{6}$ ]]; then
is_sequential=true is_sequential=true
fi fi
if [[ "$is_sequential" != "true" ]] && [[ ! "$feature_segment" =~ ^[0-9]{8}-[0-9]{6}- ]]; then if [[ "$is_sequential" != "true" ]] && [[ ! "$branch" =~ ^[0-9]{8}-[0-9]{6}- ]]; then
echo "ERROR: Not on a feature branch. Current branch: $raw" >&2 echo "ERROR: Not on a feature branch. Current branch: $raw" >&2
echo "Feature branches should be named like: 001-feature-name, 1234-feature-name, 20260319-143022-feature-name, or <prefix>/001-feature-name" >&2 echo "Feature branches should be named like: 001-feature-name, 1234-feature-name, or 20260319-143022-feature-name" >&2
return 1 return 1
fi fi

View File

@@ -34,10 +34,6 @@ if ($Help) {
Write-Host "Environment variables:" Write-Host "Environment variables:"
Write-Host " GIT_BRANCH_NAME Use this exact branch name, bypassing all prefix/suffix generation" Write-Host " GIT_BRANCH_NAME Use this exact branch name, bypassing all prefix/suffix generation"
Write-Host "" Write-Host ""
Write-Host "Configuration:"
Write-Host " branch_template Optional git-config.yml template with {author}, {app}, {number}, {slug}"
Write-Host " branch_prefix Optional shorthand namespace expanded before {number}-{slug}"
Write-Host ""
exit 0 exit 0
} }
@@ -71,23 +67,11 @@ function Get-HighestNumberFromSpecs {
} }
function Get-HighestNumberFromNames { function Get-HighestNumberFromNames {
param( param([string[]]$Names)
[string[]]$Names,
[string]$ScopePrefix = ''
)
[long]$highest = 0 [long]$highest = 0
foreach ($name in $Names) { foreach ($name in $Names) {
if ($ScopePrefix -and -not $name.StartsWith($ScopePrefix, [System.StringComparison]::Ordinal)) { if ($name -match '^(\d{3,})-' -and $name -notmatch '^\d{8}-\d{6}-') {
continue
}
if ($ScopePrefix) {
$name = $name.Substring($ScopePrefix.Length)
}
$name = ($name -split '/')[-1]
$hasTimestampPrefix = $name -match '^\d{8}-\d{6}-'
$hasMalformedTimestamp = ($name -match '^\d{7}-\d{6}-') -or ($name -match '^(?:\d{7}|\d{8})-\d{6}$')
if ($name -match '^(\d{3,})-' -and -not $hasTimestampPrefix -and -not $hasMalformedTimestamp) {
[long]$num = 0 [long]$num = 0
if ([long]::TryParse($matches[1], [ref]$num) -and $num -gt $highest) { if ([long]::TryParse($matches[1], [ref]$num) -and $num -gt $highest) {
$highest = $num $highest = $num
@@ -98,7 +82,7 @@ function Get-HighestNumberFromNames {
} }
function Get-HighestNumberFromBranches { function Get-HighestNumberFromBranches {
param([string]$ScopePrefix = '') param()
try { try {
$branches = git branch -a 2>$null $branches = git branch -a 2>$null
@@ -106,7 +90,7 @@ function Get-HighestNumberFromBranches {
$cleanNames = $branches | ForEach-Object { $cleanNames = $branches | ForEach-Object {
$_.Trim() -replace '^[+*]?\s+', '' -replace '^remotes/[^/]+/', '' $_.Trim() -replace '^[+*]?\s+', '' -replace '^remotes/[^/]+/', ''
} }
return Get-HighestNumberFromNames -Names $cleanNames -ScopePrefix $ScopePrefix return Get-HighestNumberFromNames -Names $cleanNames
} }
} catch { } catch {
Write-Verbose "Could not check Git branches: $_" Write-Verbose "Could not check Git branches: $_"
@@ -115,8 +99,6 @@ function Get-HighestNumberFromBranches {
} }
function Get-HighestNumberFromRemoteRefs { function Get-HighestNumberFromRemoteRefs {
param([string]$ScopePrefix = '')
[long]$highest = 0 [long]$highest = 0
try { try {
$remotes = git remote 2>$null $remotes = git remote 2>$null
@@ -129,7 +111,7 @@ function Get-HighestNumberFromRemoteRefs {
$refNames = $refs | ForEach-Object { $refNames = $refs | ForEach-Object {
if ($_ -match 'refs/heads/(.+)$') { $matches[1] } if ($_ -match 'refs/heads/(.+)$') { $matches[1] }
} | Where-Object { $_ } } | Where-Object { $_ }
$remoteHighest = Get-HighestNumberFromNames -Names $refNames -ScopePrefix $ScopePrefix $remoteHighest = Get-HighestNumberFromNames -Names $refNames
if ($remoteHighest -gt $highest) { $highest = $remoteHighest } if ($remoteHighest -gt $highest) { $highest = $remoteHighest }
} }
} }
@@ -143,19 +125,18 @@ function Get-HighestNumberFromRemoteRefs {
function Get-NextBranchNumber { function Get-NextBranchNumber {
param( param(
[string]$SpecsDir, [string]$SpecsDir,
[switch]$SkipFetch, [switch]$SkipFetch
[string]$ScopePrefix = ''
) )
if ($SkipFetch) { if ($SkipFetch) {
$highestBranch = Get-HighestNumberFromBranches -ScopePrefix $ScopePrefix $highestBranch = Get-HighestNumberFromBranches
$highestRemote = Get-HighestNumberFromRemoteRefs -ScopePrefix $ScopePrefix $highestRemote = Get-HighestNumberFromRemoteRefs
$highestBranch = [Math]::Max($highestBranch, $highestRemote) $highestBranch = [Math]::Max($highestBranch, $highestRemote)
} else { } else {
try { try {
git fetch --all --prune 2>$null | Out-Null git fetch --all --prune 2>$null | Out-Null
} catch { } } catch { }
$highestBranch = Get-HighestNumberFromBranches -ScopePrefix $ScopePrefix $highestBranch = Get-HighestNumberFromBranches
} }
$highestSpec = Get-HighestNumberFromSpecs -SpecsDir $SpecsDir $highestSpec = Get-HighestNumberFromSpecs -SpecsDir $SpecsDir
@@ -251,145 +232,6 @@ if (Get-Command Test-HasGit -ErrorAction SilentlyContinue) {
Set-Location $repoRoot Set-Location $repoRoot
$specsDir = Join-Path $repoRoot 'specs' $specsDir = Join-Path $repoRoot 'specs'
$configFile = Join-Path $repoRoot ".specify/extensions/git/git-config.yml"
function Read-GitConfigValue {
param([string]$Key)
if (-not (Test-Path -LiteralPath $configFile -PathType Leaf)) { return '' }
$escapedKey = [regex]::Escape($Key)
foreach ($line in Get-Content -LiteralPath $configFile) {
if ($line -match "^\s*$escapedKey\s*:\s*(.*)$") {
$val = ($matches[1] -replace '\s+#.*$', '').Trim()
$val = $val -replace '^["'']', '' -replace '["'']$', ''
return $val
}
}
return ''
}
function ConvertTo-BranchToken {
param(
[string]$Value,
[string]$Fallback
)
$cleaned = ConvertTo-CleanBranchName -Name $Value
if ($cleaned) { return $cleaned }
return $Fallback
}
function Get-GitAuthorToken {
$author = ''
if (Get-Command git -ErrorAction SilentlyContinue) {
try { $author = (git config user.name 2>$null | Out-String).Trim() } catch {}
if (-not $author) {
try {
$email = (git config user.email 2>$null | Out-String).Trim()
if ($email) { $author = ($email -split '@')[0] }
} catch {}
}
}
if (-not $author) { $author = if ($env:USER) { $env:USER } elseif ($env:USERNAME) { $env:USERNAME } else { 'unknown' } }
return ConvertTo-BranchToken -Value $author -Fallback 'unknown'
}
function Get-AppToken {
return ConvertTo-BranchToken -Value (Split-Path $repoRoot -Leaf) -Fallback 'app'
}
function Resolve-BranchTemplate {
$template = Read-GitConfigValue -Key 'branch_template'
if ($template) { return $template }
$prefix = Read-GitConfigValue -Key 'branch_prefix'
if (-not $prefix) { return '' }
if ($prefix.EndsWith('/')) { return "${prefix}{number}-{slug}" }
return "$prefix/{number}-{slug}"
}
function Expand-BranchTemplate {
param(
[string]$Template,
[string]$FeatureNum,
[string]$BranchSuffix
)
$rendered = $Template.Replace('{author}', $authorToken)
$rendered = $rendered.Replace('{app}', $appToken)
$rendered = $rendered.Replace('{number}', $FeatureNum)
$rendered = $rendered.Replace('{slug}', $BranchSuffix)
return $rendered
}
function Assert-BranchTemplateValid {
param([string]$Template)
if ($Template -and -not $Template.Contains('{number}')) {
throw "branch_template must include the {number} token so generated branches remain valid feature branches."
}
if ($Template) {
$numberIndex = $Template.IndexOf('{number}', [System.StringComparison]::Ordinal)
$slugIndex = $Template.IndexOf('{slug}', [System.StringComparison]::Ordinal)
if ($slugIndex -ge 0 -and $slugIndex -lt $numberIndex) {
throw "branch_template must not place {slug} before {number}; use {slug} only in the final feature segment."
}
$featureSegment = ($Template -split '/')[-1]
if (-not $featureSegment.StartsWith('{number}-', [System.StringComparison]::Ordinal)) {
throw "branch_template must put {number}- at the start of the final path segment so generated branches remain valid feature branches."
}
}
}
function New-BranchName {
param(
[string]$FeatureNum,
[string]$BranchSuffix
)
if ($branchTemplate) {
return Expand-BranchTemplate -Template $branchTemplate -FeatureNum $FeatureNum -BranchSuffix $BranchSuffix
}
return "$FeatureNum-$BranchSuffix"
}
function Get-BranchScopePrefix {
param(
[string]$Template,
[string]$BranchSuffix
)
if (-not $Template) { return '' }
$numberIndex = $Template.IndexOf('{number}', [System.StringComparison]::Ordinal)
$slugIndex = $Template.IndexOf('{slug}', [System.StringComparison]::Ordinal)
$indexes = @($numberIndex, $slugIndex) | Where-Object { $_ -ge 0 } | Sort-Object
if (-not $indexes) { return '' }
$prefix = $Template.Substring(0, $indexes[0])
return Expand-BranchTemplate -Template $prefix -FeatureNum '' -BranchSuffix $BranchSuffix
}
function Get-FeatureNumberFromBranchName {
param([string]$BranchName)
$featureSegment = ($BranchName -split '/')[-1]
if ($featureSegment -match '^(\d{8}-\d{6})-') {
return $matches[1]
}
if ($featureSegment -match '^(\d+)-') {
return $matches[1]
}
return $BranchName
}
function Get-Utf8ByteCount {
param([string]$Value)
return [System.Text.Encoding]::UTF8.GetByteCount($Value)
}
$authorToken = Get-GitAuthorToken
$appToken = Get-AppToken
$branchTemplate = Resolve-BranchTemplate
Assert-BranchTemplateValid -Template $branchTemplate
function Get-BranchName { function Get-BranchName {
param([string]$Description) param([string]$Description)
@@ -434,11 +276,19 @@ function Get-BranchName {
if ($env:GIT_BRANCH_NAME) { if ($env:GIT_BRANCH_NAME) {
$branchName = $env:GIT_BRANCH_NAME $branchName = $env:GIT_BRANCH_NAME
# Check 244-byte limit (UTF-8) for override names # Check 244-byte limit (UTF-8) for override names
$branchNameUtf8ByteCount = Get-Utf8ByteCount -Value $branchName $branchNameUtf8ByteCount = [System.Text.Encoding]::UTF8.GetByteCount($branchName)
if ($branchNameUtf8ByteCount -gt 244) { if ($branchNameUtf8ByteCount -gt 244) {
throw "GIT_BRANCH_NAME must be 244 bytes or fewer in UTF-8. Provided value is $branchNameUtf8ByteCount bytes; please supply a shorter override branch name." throw "GIT_BRANCH_NAME must be 244 bytes or fewer in UTF-8. Provided value is $branchNameUtf8ByteCount bytes; please supply a shorter override branch name."
} }
$featureNum = Get-FeatureNumberFromBranchName -BranchName $branchName # Extract FEATURE_NUM from the branch name if it starts with a numeric prefix
# Check timestamp pattern first (YYYYMMDD-HHMMSS-) since it also matches the simpler ^\d+ pattern
if ($branchName -match '^(\d{8}-\d{6})-') {
$featureNum = $matches[1]
} elseif ($branchName -match '^(\d+)-') {
$featureNum = $matches[1]
} else {
$featureNum = $branchName
}
} else { } else {
if ($ShortName) { if ($ShortName) {
$branchSuffix = ConvertTo-CleanBranchName -Name $ShortName $branchSuffix = ConvertTo-CleanBranchName -Name $ShortName
@@ -446,54 +296,46 @@ 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
} }
if ($Timestamp) { if ($Timestamp) {
$featureNum = Get-Date -Format 'yyyyMMdd-HHmmss' $featureNum = Get-Date -Format 'yyyyMMdd-HHmmss'
$branchName = New-BranchName -FeatureNum $featureNum -BranchSuffix $branchSuffix $branchName = "$featureNum-$branchSuffix"
} else { } else {
$branchScopePrefix = Get-BranchScopePrefix -Template $branchTemplate -BranchSuffix $branchSuffix if ($Number -eq 0) {
# Auto-detect the next number only when -Number was not supplied; an
# 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
} elseif ($DryRun) { } elseif ($DryRun) {
$Number = (Get-HighestNumberFromSpecs -SpecsDir $specsDir) + 1 $Number = (Get-HighestNumberFromSpecs -SpecsDir $specsDir) + 1
} elseif ($hasGit) { } elseif ($hasGit) {
$Number = Get-NextBranchNumber -SpecsDir $specsDir -ScopePrefix $branchScopePrefix $Number = Get-NextBranchNumber -SpecsDir $specsDir
} else { } else {
$Number = (Get-HighestNumberFromSpecs -SpecsDir $specsDir) + 1 $Number = (Get-HighestNumberFromSpecs -SpecsDir $specsDir) + 1
} }
} }
$featureNum = ('{0:000}' -f $Number) $featureNum = ('{0:000}' -f $Number)
$branchName = New-BranchName -FeatureNum $featureNum -BranchSuffix $branchSuffix $branchName = "$featureNum-$branchSuffix"
} }
} }
$maxBranchLength = 244 $maxBranchLength = 244
if ((Get-Utf8ByteCount -Value $branchName) -gt $maxBranchLength) { if ($branchName.Length -gt $maxBranchLength) {
$prefixLength = $featureNum.Length + 1
$maxSuffixLength = $maxBranchLength - $prefixLength
$truncatedSuffix = $branchSuffix.Substring(0, [Math]::Min($branchSuffix.Length, $maxSuffixLength))
$truncatedSuffix = $truncatedSuffix -replace '-$', ''
$originalBranchName = $branchName $originalBranchName = $branchName
$truncatedSuffix = $branchSuffix $branchName = "$featureNum-$truncatedSuffix"
while ((Get-Utf8ByteCount -Value $branchName) -gt $maxBranchLength -and $truncatedSuffix.Length -gt 0) {
$truncatedSuffix = $truncatedSuffix.Substring(0, $truncatedSuffix.Length - 1) -replace '-$', ''
$branchName = New-BranchName -FeatureNum $featureNum -BranchSuffix $truncatedSuffix
}
if ((Get-Utf8ByteCount -Value $branchName) -gt $maxBranchLength) {
throw "Branch template prefix exceeds GitHub's 244-byte branch name limit."
}
Write-Warning "[specify] Branch name exceeded GitHub's 244-byte limit" Write-Warning "[specify] Branch name exceeded GitHub's 244-byte limit"
Write-Warning "[specify] Original: $originalBranchName ($(Get-Utf8ByteCount -Value $originalBranchName) bytes)" Write-Warning "[specify] Original: $originalBranchName ($($originalBranchName.Length) bytes)"
Write-Warning "[specify] Truncated to: $branchName ($(Get-Utf8ByteCount -Value $branchName) bytes)" Write-Warning "[specify] Truncated to: $branchName ($($branchName.Length) bytes)"
} }
if (-not $DryRun) { if (-not $DryRun) {

View File

@@ -37,15 +37,14 @@ function Test-FeatureBranch {
$raw = $Branch $raw = $Branch
$Branch = Get-SpecKitEffectiveBranchName $raw $Branch = Get-SpecKitEffectiveBranchName $raw
$featureSegment = ($Branch -split '/')[-1]
# Accept sequential prefix (3+ digits), at the start or after namespace # Accept sequential prefix (3+ digits) but exclude malformed timestamps
# segments, but exclude malformed timestamps. # Malformed: 7-or-8 digit date + 6-digit time with no trailing slug (e.g. "2026031-143022" or "20260319-143022")
$hasMalformedTimestamp = ($featureSegment -match '^[0-9]{7}-[0-9]{6}-') -or ($featureSegment -match '^(?:\d{7}|\d{8})-\d{6}$') $hasMalformedTimestamp = ($Branch -match '^[0-9]{7}-[0-9]{6}-') -or ($Branch -match '^(?:\d{7}|\d{8})-\d{6}$')
$isSequential = ($featureSegment -match '^[0-9]{3,}-') -and (-not $hasMalformedTimestamp) $isSequential = ($Branch -match '^[0-9]{3,}-') -and (-not $hasMalformedTimestamp)
if (-not $isSequential -and $featureSegment -notmatch '^\d{8}-\d{6}-') { if (-not $isSequential -and $Branch -notmatch '^\d{8}-\d{6}-') {
[Console]::Error.WriteLine("ERROR: Not on a feature branch. Current branch: $raw") [Console]::Error.WriteLine("ERROR: Not on a feature branch. Current branch: $raw")
[Console]::Error.WriteLine("Feature branches should be named like: 001-feature-name, 1234-feature-name, 20260319-143022-feature-name, or <prefix>/001-feature-name") [Console]::Error.WriteLine("Feature branches should be named like: 001-feature-name, 1234-feature-name, or 20260319-143022-feature-name")
return $false return $false
} }
return $true return $true

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:

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)

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

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",

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"

View File

@@ -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.

View File

@@ -1,6 +1,6 @@
[project] [project]
name = "specify-cli" name = "specify-cli"
version = "0.12.15" version = "0.12.4"
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
] ]

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

View File

@@ -97,26 +97,17 @@ read_feature_json_feature_directory() {
local fj="$repo_root/.specify/feature.json" local fj="$repo_root/.specify/feature.json"
[[ -f "$fj" ]] || { printf '%s' ''; return 0; } [[ -f "$fj" ]] || { printf '%s' ''; return 0; }
# Try parsers in order (jq -> python3 -> grep/sed), 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), so
# an availability-gated `elif` would pick python3, swallow its failure, and
# never reach the grep/sed fallback -- leaving feature.json unreadable even
# though it is valid (issue #3304).
local _fd='' local _fd=''
if command -v jq >/dev/null 2>&1; then if command -v jq >/dev/null 2>&1; then
if ! _fd=$(jq -r '.feature_directory // empty' "$fj" 2>/dev/null); then if ! _fd=$(jq -r '.feature_directory // empty' "$fj" 2>/dev/null); then
_fd='' _fd=''
fi fi
fi elif command -v python3 >/dev/null 2>&1; then
if [[ -z "$_fd" ]] && command -v python3 >/dev/null 2>&1; then
# Use Python so pretty-printed/multi-line JSON still parses correctly. # Use Python so pretty-printed/multi-line JSON still parses correctly.
if ! _fd=$(python3 -c "import json,sys; d=json.load(open(sys.argv[1])); v=d.get('feature_directory'); print(v if v else '')" "$fj" 2>/dev/null); then if ! _fd=$(python3 -c "import json,sys; d=json.load(open(sys.argv[1])); v=d.get('feature_directory'); print(v if v else '')" "$fj" 2>/dev/null); then
_fd='' _fd=''
fi fi
fi else
if [[ -z "$_fd" ]]; then
# Last-resort single-line grep/sed fallback. The `|| true` guards against # Last-resort single-line grep/sed fallback. The `|| true` guards against
# grep returning 1 (no match) aborting under `set -e` / `pipefail`. # grep returning 1 (no match) aborting under `set -e` / `pipefail`.
_fd=$( { grep -E '"feature_directory"[[:space:]]*:' "$fj" 2>/dev/null || true; } \ _fd=$( { grep -E '"feature_directory"[[:space:]]*:' "$fj" 2>/dev/null || true; } \
@@ -244,29 +235,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 +265,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"

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)"

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

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
} }

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

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

View File

@@ -1,207 +0,0 @@
#!/usr/bin/env python3
"""Consolidated prerequisite checking script."""
from __future__ import annotations
import json
import sys
from dataclasses import dataclass
from pathlib import Path
try:
from common import FeaturePaths, format_speckit_command, get_feature_paths
except ImportError: # pragma: no cover - direct execution from unusual cwd
sys.path.insert(0, str(Path(__file__).resolve().parent))
from common import FeaturePaths, format_speckit_command, get_feature_paths
def _json_line(payload: object) -> str:
return json.dumps(payload, ensure_ascii=False, separators=(",", ":")) + "\n"
HELP_TEXT = """Usage: check_prerequisites.py [OPTIONS]
Consolidated prerequisite checking for Spec-Driven Development workflow.
OPTIONS:
--json Output in JSON format
--require-tasks Require tasks.md to exist (for implementation phase)
--include-tasks Include tasks.md in AVAILABLE_DOCS list
--paths-only Only output path variables (no prerequisite validation)
--help, -h Show this help message
EXAMPLES:
# Check task prerequisites (plan.md required)
./check_prerequisites.py --json
# Check implementation prerequisites (plan.md + tasks.md required)
./check_prerequisites.py --json --require-tasks --include-tasks
# Get feature paths only (no validation)
./check_prerequisites.py --paths-only
"""
@dataclass(frozen=True)
class Args:
json_mode: bool = False
require_tasks: bool = False
include_tasks: bool = False
paths_only: bool = False
def _parse_args(argv: list[str]) -> Args:
json_mode = False
require_tasks = False
include_tasks = False
paths_only = False
for arg in argv:
if arg == "--json":
json_mode = True
elif arg == "--require-tasks":
require_tasks = True
elif arg == "--include-tasks":
include_tasks = True
elif arg == "--paths-only":
paths_only = True
elif arg in {"--help", "-h"}:
sys.stdout.write(HELP_TEXT)
raise SystemExit(0)
else:
print(
f"ERROR: Unknown option '{arg}'. Use --help for usage information.",
file=sys.stderr,
)
raise SystemExit(1)
return Args(
json_mode=json_mode,
require_tasks=require_tasks,
include_tasks=include_tasks,
paths_only=paths_only,
)
def _dir_has_entries(path: Path) -> bool:
try:
return path.is_dir() and any(path.iterdir())
except OSError:
return False
def _available_docs(paths: FeaturePaths, include_tasks: bool) -> list[str]:
docs: list[str] = []
if paths.research.is_file():
docs.append("research.md")
if paths.data_model.is_file():
docs.append("data-model.md")
if _dir_has_entries(paths.contracts_dir):
docs.append("contracts/")
if paths.quickstart.is_file():
docs.append("quickstart.md")
if include_tasks and paths.tasks.is_file():
docs.append("tasks.md")
return docs
def _print_paths_only(paths: FeaturePaths, json_mode: bool) -> None:
if json_mode:
sys.stdout.write(
_json_line(
{
"REPO_ROOT": str(paths.repo_root),
"BRANCH": paths.current_branch,
"FEATURE_DIR": str(paths.feature_dir),
"FEATURE_SPEC": str(paths.feature_spec),
"IMPL_PLAN": str(paths.impl_plan),
"TASKS": str(paths.tasks),
}
)
)
return
print(f"REPO_ROOT: {paths.repo_root}")
print(f"BRANCH: {paths.current_branch}")
print(f"FEATURE_DIR: {paths.feature_dir}")
print(f"FEATURE_SPEC: {paths.feature_spec}")
print(f"IMPL_PLAN: {paths.impl_plan}")
print(f"TASKS: {paths.tasks}")
def _check_file(path: Path, description: str) -> None:
marker = "" if path.is_file() else ""
print(f" {marker} {description}")
def _check_dir(path: Path, description: str) -> None:
marker = "" if _dir_has_entries(path) else ""
print(f" {marker} {description}")
def _print_text_results(paths: FeaturePaths, include_tasks: bool) -> None:
print(f"FEATURE_DIR:{paths.feature_dir}")
print("AVAILABLE_DOCS:")
_check_file(paths.research, "research.md")
_check_file(paths.data_model, "data-model.md")
_check_dir(paths.contracts_dir, "contracts/")
_check_file(paths.quickstart, "quickstart.md")
if include_tasks:
_check_file(paths.tasks, "tasks.md")
def main(argv: list[str] | None = None) -> int:
args = _parse_args(list(argv if argv is not None else sys.argv[1:]))
try:
paths = get_feature_paths(
no_persist=args.paths_only,
script_file=Path(__file__),
)
except SystemExit as exc:
if exc.code == 0:
return 0
print("ERROR: Failed to resolve feature paths", file=sys.stderr)
return int(exc.code) if isinstance(exc.code, int) else 1
if args.paths_only:
_print_paths_only(paths, args.json_mode)
return 0
if not paths.feature_dir.is_dir():
print(f"ERROR: Feature directory not found: {paths.feature_dir}", file=sys.stderr)
print(
f"Run {format_speckit_command('specify', paths.repo_root)} first to create the feature structure.",
file=sys.stderr,
)
return 1
if not paths.impl_plan.is_file():
print(f"ERROR: plan.md not found in {paths.feature_dir}", file=sys.stderr)
print(
f"Run {format_speckit_command('plan', paths.repo_root)} first to create the implementation plan.",
file=sys.stderr,
)
return 1
if args.require_tasks and not paths.tasks.is_file():
print(f"ERROR: tasks.md not found in {paths.feature_dir}", file=sys.stderr)
print(
f"Run {format_speckit_command('tasks', paths.repo_root)} first to create the task list.",
file=sys.stderr,
)
return 1
docs = _available_docs(paths, args.include_tasks)
if args.json_mode:
sys.stdout.write(
_json_line({"FEATURE_DIR": str(paths.feature_dir), "AVAILABLE_DOCS": docs})
)
else:
_print_text_results(paths, args.include_tasks)
return 0
if __name__ == "__main__":
raise SystemExit(main())

View File

@@ -1,210 +0,0 @@
"""Shared helpers for Spec Kit Python scripts."""
from __future__ import annotations
import json
import os
import sys
from dataclasses import dataclass
from pathlib import Path
def _trim_trailing_separators(value: Path) -> str:
text = str(value)
while len(text) > 1 and text.endswith((os.sep, "/")):
text = text[:-1]
return text
def find_specify_root(start_dir: Path | None = None) -> Path | None:
current = (start_dir or Path.cwd()).resolve()
while True:
if (current / ".specify").is_dir():
return current
parent = current.parent
if parent == current:
return None
current = parent
def resolve_specify_init_dir() -> Path:
raw = os.environ.get("SPECIFY_INIT_DIR", "")
candidate = Path(raw)
if not candidate.is_absolute():
candidate = Path.cwd() / candidate
try:
init_root = candidate.resolve(strict=True)
except OSError:
print(
f"ERROR: SPECIFY_INIT_DIR does not point to an existing directory: {raw}",
file=sys.stderr,
)
raise SystemExit(1)
if not init_root.is_dir():
print(
f"ERROR: SPECIFY_INIT_DIR does not point to an existing directory: {raw}",
file=sys.stderr,
)
raise SystemExit(1)
if not (init_root / ".specify").is_dir():
print(
"ERROR: SPECIFY_INIT_DIR is not a Spec Kit project "
f"(no .specify/ directory): {init_root}",
file=sys.stderr,
)
raise SystemExit(1)
return init_root
def get_repo_root(script_file: Path | None = None) -> Path:
if os.environ.get("SPECIFY_INIT_DIR"):
return resolve_specify_init_dir()
specify_root = find_specify_root()
if specify_root is not None:
return specify_root
if script_file is not None:
script_root = find_specify_root(script_file.resolve().parent)
if script_root is not None:
return script_root
# Installed scripts live at .specify/scripts/python/<script>.py.
return script_file.resolve().parents[3]
return Path.cwd().resolve()
def get_current_branch() -> str:
return os.environ.get("SPECIFY_FEATURE", "")
def read_feature_json_feature_directory(repo_root: Path) -> str:
feature_json = repo_root / ".specify" / "feature.json"
if not feature_json.is_file():
return ""
try:
data = json.loads(feature_json.read_text(encoding="utf-8"))
except (OSError, json.JSONDecodeError):
return ""
value = data.get("feature_directory") if isinstance(data, dict) else None
return value if isinstance(value, str) else ""
def _json_dump(data: dict[str, str]) -> str:
return json.dumps(data, ensure_ascii=False, separators=(",", ":")) + "\n"
def persist_feature_json(repo_root: Path, feature_dir_value: str) -> None:
value = feature_dir_value
try:
relative = Path(value)
if relative.is_absolute():
try:
value = relative.resolve().relative_to(repo_root.resolve()).as_posix()
except ValueError:
value = str(relative)
except OSError:
pass
current = read_feature_json_feature_directory(repo_root)
if current == value:
return
specify_dir = repo_root / ".specify"
specify_dir.mkdir(parents=True, exist_ok=True)
(specify_dir / "feature.json").write_text(
_json_dump({"feature_directory": value}),
encoding="utf-8",
)
@dataclass(frozen=True)
class FeaturePaths:
repo_root: Path
current_branch: str
feature_dir: Path
feature_spec: Path
impl_plan: Path
tasks: Path
research: Path
data_model: Path
quickstart: Path
contracts_dir: Path
def get_feature_paths(
*, no_persist: bool = False, script_file: Path | None = None
) -> FeaturePaths:
repo_root = get_repo_root(script_file)
current_branch = get_current_branch()
feature_dir_raw = os.environ.get("SPECIFY_FEATURE_DIRECTORY", "")
if feature_dir_raw:
feature_dir = Path(feature_dir_raw)
if not feature_dir.is_absolute():
feature_dir = repo_root / feature_dir
if not no_persist:
persist_feature_json(repo_root, feature_dir_raw)
elif (repo_root / ".specify" / "feature.json").is_file():
stored = read_feature_json_feature_directory(repo_root)
if not stored:
print(
"ERROR: Feature directory not found. Set SPECIFY_FEATURE_DIRECTORY "
"or ensure .specify/feature.json contains feature_directory.",
file=sys.stderr,
)
raise SystemExit(1)
feature_dir = Path(stored)
if not feature_dir.is_absolute():
feature_dir = repo_root / feature_dir
else:
print(
"ERROR: Feature directory not found. Set SPECIFY_FEATURE_DIRECTORY "
"or run the specify command to create .specify/feature.json.",
file=sys.stderr,
)
raise SystemExit(1)
if not current_branch:
current_branch = Path(_trim_trailing_separators(feature_dir)).name
return FeaturePaths(
repo_root=repo_root,
current_branch=current_branch,
feature_dir=feature_dir,
feature_spec=feature_dir / "spec.md",
impl_plan=feature_dir / "plan.md",
tasks=feature_dir / "tasks.md",
research=feature_dir / "research.md",
data_model=feature_dir / "data-model.md",
quickstart=feature_dir / "quickstart.md",
contracts_dir=feature_dir / "contracts",
)
def get_invoke_separator(repo_root: Path) -> str:
integration_json = repo_root / ".specify" / "integration.json"
if not integration_json.is_file():
return "."
try:
state = json.loads(integration_json.read_text(encoding="utf-8"))
key = state.get("default_integration") or state.get("integration") or ""
settings = state.get("integration_settings")
if isinstance(key, str) and isinstance(settings, dict):
entry = settings.get(key)
if isinstance(entry, dict) and entry.get("invoke_separator") in {".", "-"}:
return entry["invoke_separator"]
except (OSError, json.JSONDecodeError):
pass
return "."
def format_speckit_command(command_name: str, repo_root: Path) -> str:
separator = get_invoke_separator(repo_root)
name = command_name.lstrip("/")
if name.startswith("speckit."):
name = name[len("speckit.") :]
elif name.startswith("speckit-"):
name = name[len("speckit-") :]
name = name.replace(".", separator)
return f"/speckit{separator}{name}"

View File

@@ -5,4 +5,4 @@
} }
], ],
"settings": {} "settings": {}
} }

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*

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
@@ -132,14 +127,7 @@ def resolve_github_release_asset_api_url(
if hostname == "github.com": if hostname == "github.com":
api_base = "https://api.github.com" api_base = "https://api.github.com"
elif is_ghes: elif is_ghes:
# ``parsed.port`` raises ValueError on a malformed port (e.g. authority = hostname if parsed.port is None else f"{hostname}:{parsed.port}"
# ``host:notaport``); the function's contract is to return None for
# anything it can't resolve, not to raise.
try:
port = parsed.port
except ValueError:
return None
authority = hostname if port is None else f"{hostname}:{port}"
api_base = f"{parsed.scheme}://{authority}/api/v3" api_base = f"{parsed.scheme}://{authority}/api/v3"
else: else:
return None return None
@@ -154,33 +142,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

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",
) )

View File

@@ -1,60 +0,0 @@
"""Shared TOML string-escaping helpers.
Both TOML command renderers — ``TomlIntegration`` (gemini, tabnine) in
``specify_cli.integrations.base`` and ``CommandRegistrar.render_toml_command``
(extension/preset commands) in ``specify_cli.agents`` — need the same rules for
detecting characters TOML forbids literally and for emitting a fully-escaped
basic string. Keeping one implementation here avoids the two drifting apart if
the escaping rules change again.
"""
from __future__ import annotations
def has_illegal_toml_control(value: str) -> bool:
"""True when *value* contains a character TOML forbids literally.
TOML basic/literal strings (single- or multi-line) allow tab and, in the
multiline forms, newlines — but every other control character
(``U+0000````U+001F`` and ``U+007F``) must be ``\\u``-escaped, which only a
basic string can do. A bare carriage return counts too: a multiline basic
string treats ``\\r`` as a newline only when paired into ``\\r\\n``; a lone
``\\r`` is an illegal control character.
"""
length = len(value)
for i, ch in enumerate(value):
code = ord(ch)
if ch == "\r":
# Only a CR that is part of a CRLF newline is allowed literally.
if i + 1 < length and value[i + 1] == "\n":
continue
return True
if (code < 0x20 and ch not in ("\t", "\n")) or code == 0x7F:
return True
return False
def escape_toml_basic(value: str) -> str:
"""Render *value* as a single-line basic string, escaping everything.
Always valid TOML: backslash/quote are escaped, the common control chars
use their short escapes, and any remaining control character is emitted as
a ``\\uXXXX`` sequence.
"""
out: list[str] = []
for ch in value:
code = ord(ch)
if ch == "\\":
out.append("\\\\")
elif ch == '"':
out.append('\\"')
elif ch == "\n":
out.append("\\n")
elif ch == "\r":
out.append("\\r")
elif ch == "\t":
out.append("\\t")
elif code < 0x20 or code == 0x7F:
out.append(f"\\u{code:04x}")
else:
out.append(ch)
return '"' + "".join(out) + '"'

View File

@@ -16,8 +16,6 @@ from typing import Any, Dict, List, Optional
import yaml import yaml
from ._init_options import is_ai_skills_enabled, load_init_options from ._init_options import is_ai_skills_enabled, load_init_options
from ._toml_string import escape_toml_basic as _escape_toml_basic
from ._toml_string import has_illegal_toml_control as _has_illegal_toml_control
from ._utils import relative_extension_path_violation from ._utils import relative_extension_path_violation
@@ -150,9 +148,7 @@ class CommandRegistrar:
) )
return f"---\n{yaml_str}---\n" return f"---\n{yaml_str}---\n"
def _adjust_script_paths( def _adjust_script_paths(self, frontmatter: dict) -> dict:
self, frontmatter: dict, extension_id: Optional[str] = None
) -> dict:
"""Normalize script paths in frontmatter to generated project locations. """Normalize script paths in frontmatter to generated project locations.
Rewrites known repo-relative and top-level script paths under the Rewrites known repo-relative and top-level script paths under the
@@ -162,7 +158,6 @@ class CommandRegistrar:
Args: Args:
frontmatter: Frontmatter dictionary frontmatter: Frontmatter dictionary
extension_id: Extension id when rendering extension-owned commands.
Returns: Returns:
Modified frontmatter with normalized project paths Modified frontmatter with normalized project paths
@@ -173,15 +168,11 @@ class CommandRegistrar:
if isinstance(scripts, dict): if isinstance(scripts, dict):
for key, script_path in scripts.items(): for key, script_path in scripts.items():
if isinstance(script_path, str): if isinstance(script_path, str):
scripts[key] = self.rewrite_project_relative_paths( scripts[key] = self.rewrite_project_relative_paths(script_path)
script_path, extension_id=extension_id
)
return frontmatter return frontmatter
@staticmethod @staticmethod
def rewrite_project_relative_paths( def rewrite_project_relative_paths(text: str) -> str:
text: str, extension_id: Optional[str] = None
) -> str:
"""Rewrite repo-relative paths to their generated project locations.""" """Rewrite repo-relative paths to their generated project locations."""
if not isinstance(text, str) or not text: if not isinstance(text, str) or not text:
return text return text
@@ -193,18 +184,10 @@ class CommandRegistrar:
): ):
text = text.replace(old, new) text = text.replace(old, new)
# Only rewrite top-level style references so existing generated paths # Only rewrite top-level style references so extension-local paths like
# like ".specify/extensions/<ext>/scripts/..." remain intact. When # ".specify/extensions/<ext>/scripts/..." remain intact.
# rendering extension commands, top-level "scripts/" is extension-local.
scripts_replacement = (
f".specify/extensions/{extension_id}/scripts/"
if extension_id
else ".specify/scripts/"
)
text = re.sub(r'(^|[\s`"\'(])(?:\.?/)?memory/', r"\1.specify/memory/", text) text = re.sub(r'(^|[\s`"\'(])(?:\.?/)?memory/', r"\1.specify/memory/", text)
text = re.sub( text = re.sub(r'(^|[\s`"\'(])(?:\.?/)?scripts/', r"\1.specify/scripts/", text)
r'(^|[\s`"\'(])(?:\.?/)?scripts/', rf"\1{scripts_replacement}", text
)
text = re.sub( text = re.sub(
r'(^|[\s`"\'(])(?:\.?/)?templates/', r"\1.specify/templates/", text r'(^|[\s`"\'(])(?:\.?/)?templates/', r"\1.specify/templates/", text
) )
@@ -213,52 +196,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:
@@ -306,12 +243,7 @@ class CommandRegistrar:
# ``C:\\Users\\...`` whose ``\\U`` reads as an invalid unicode escape) would # ``C:\\Users\\...`` whose ``\\U`` reads as an invalid unicode escape) would
# produce unparseable TOML — route those to the *literal* form ('''...'''), # produce unparseable TOML — route those to the *literal* form ('''...'''),
# which does not process escapes, or to the escaped basic string. # which does not process escapes, or to the escaped basic string.
# Control characters (U+0000U+001F except tab/newline, U+007F) and a bare if '"""' not in body and "\\" not in body:
# CR are illegal in every TOML string form, so a body containing them must
# go to the escaped basic string regardless of which delimiters it uses.
if self._has_illegal_toml_control(body):
toml_lines.append(f"prompt = {self._render_basic_toml_string(body)}")
elif '"""' not in body and "\\" not in body:
toml_lines.append('prompt = """') toml_lines.append('prompt = """')
toml_lines.append(body) toml_lines.append(body)
toml_lines.append('"""') toml_lines.append('"""')
@@ -324,11 +256,17 @@ class CommandRegistrar:
return "\n".join(toml_lines) return "\n".join(toml_lines)
# Control-char detection and basic-string escaping are shared with the @staticmethod
# gemini/tabnine renderer in ``specify_cli.integrations.base`` via def _render_basic_toml_string(value: str) -> str:
# ``specify_cli._toml_string`` so the two never drift apart. """Render *value* as a TOML basic string literal."""
_has_illegal_toml_control = staticmethod(_has_illegal_toml_control) escaped = (
_render_basic_toml_string = staticmethod(_escape_toml_basic) value.replace("\\", "\\\\")
.replace('"', '\\"')
.replace("\n", "\\n")
.replace("\r", "\\r")
.replace("\t", "\\t")
)
return f'"{escaped}"'
def render_yaml_command( def render_yaml_command(
self, self,
@@ -374,7 +312,6 @@ class CommandRegistrar:
source_id: str, source_id: str,
source_file: str, source_file: str,
project_root: Path, project_root: Path,
extension_id: Optional[str] = None,
) -> str: ) -> str:
"""Render a command override as a SKILL.md file. """Render a command override as a SKILL.md file.
@@ -394,7 +331,7 @@ class CommandRegistrar:
agent_config = self.AGENT_CONFIGS.get(agent_name, {}) agent_config = self.AGENT_CONFIGS.get(agent_name, {})
if agent_config.get("extension") == "/SKILL.md": if agent_config.get("extension") == "/SKILL.md":
body = self.resolve_skill_placeholders( body = self.resolve_skill_placeholders(
agent_name, frontmatter, body, project_root, extension_id=extension_id agent_name, frontmatter, body, project_root
) )
description = frontmatter.get( description = frontmatter.get(
@@ -456,11 +393,7 @@ class CommandRegistrar:
@staticmethod @staticmethod
def resolve_skill_placeholders( def resolve_skill_placeholders(
agent_name: str, agent_name: str, frontmatter: dict, body: str, project_root: Path
frontmatter: dict,
body: str,
project_root: Path,
extension_id: Optional[str] = None,
) -> str: ) -> str:
"""Resolve script placeholders for skills-backed agents.""" """Resolve script placeholders for skills-backed agents."""
if not isinstance(frontmatter, dict): if not isinstance(frontmatter, dict):
@@ -500,9 +433,7 @@ class CommandRegistrar:
body = body.replace("{ARGS}", "$ARGUMENTS").replace("__AGENT__", agent_name) body = body.replace("{ARGS}", "$ARGUMENTS").replace("__AGENT__", agent_name)
return CommandRegistrar.rewrite_project_relative_paths( return CommandRegistrar.rewrite_project_relative_paths(body)
body, extension_id=extension_id
)
def _convert_argument_placeholder( def _convert_argument_placeholder(
self, content: str, from_placeholder: str, to_placeholder: str self, content: str, from_placeholder: str, to_placeholder: str
@@ -597,7 +528,6 @@ class CommandRegistrar:
context_note: str = None, context_note: str = None,
_resolved_dir: Path = None, _resolved_dir: Path = None,
link_outputs: bool = False, link_outputs: bool = False,
extension_id: Optional[str] = None,
) -> List[str]: ) -> List[str]:
"""Register commands for a specific agent. """Register commands for a specific agent.
@@ -615,7 +545,6 @@ class CommandRegistrar:
link_outputs: If True, write rendered output to a source-local link_outputs: If True, write rendered output to a source-local
dev cache and symlink the agent command file to it. Falls back dev cache and symlink the agent command file to it. Falls back
to a normal file write when symlinks are unavailable. to a normal file write when symlinks are unavailable.
extension_id: Extension id when rendering extension-owned commands.
Returns: Returns:
List of registered command names List of registered command names
@@ -685,12 +614,7 @@ class CommandRegistrar:
frontmatter[key] = core_frontmatter[key] frontmatter[key] = core_frontmatter[key]
frontmatter.pop("strategy", None) frontmatter.pop("strategy", None)
if extension_id: frontmatter = self._adjust_script_paths(frontmatter)
body = self.rewrite_extension_paths(body, extension_id, source_root)
frontmatter = self._adjust_script_paths(
frontmatter, extension_id=extension_id
)
for key in agent_config.get("strip_frontmatter_keys", []): for key in agent_config.get("strip_frontmatter_keys", []):
frontmatter.pop(key, None) frontmatter.pop(key, None)
@@ -729,11 +653,10 @@ class CommandRegistrar:
source_id, source_id,
cmd_file, cmd_file,
project_root, project_root,
extension_id=extension_id,
) )
elif agent_config["format"] == "markdown": elif agent_config["format"] == "markdown":
body = self.resolve_skill_placeholders( body = self.resolve_skill_placeholders(
agent_name, frontmatter, body, project_root, extension_id=extension_id agent_name, frontmatter, body, project_root
) )
body = self._convert_argument_placeholder( body = self._convert_argument_placeholder(
body, "$ARGUMENTS", agent_config["args"] body, "$ARGUMENTS", agent_config["args"]
@@ -743,36 +666,19 @@ class CommandRegistrar:
) )
elif agent_config["format"] == "toml": elif agent_config["format"] == "toml":
body = self.resolve_skill_placeholders( body = self.resolve_skill_placeholders(
agent_name, frontmatter, body, project_root, extension_id=extension_id agent_name, frontmatter, body, project_root
) )
body = self._convert_argument_placeholder( body = self._convert_argument_placeholder(
body, "$ARGUMENTS", agent_config["args"] body, "$ARGUMENTS", agent_config["args"]
) )
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
) )
else: else:
raise ValueError(f"Unsupported format: {agent_config['format']}") raise ValueError(f"Unsupported format: {agent_config['format']}")
# -- Post-process for non-skills agents -----------------------
_integration = None
if agent_config["extension"] != "/SKILL.md":
from specify_cli.integrations import ( # noqa: PLC0415
get_integration,
)
_integration = get_integration(agent_name)
if _integration is not None:
output = _integration.post_process_command_content(output)
dest_file = commands_dir / f"{output_name}{agent_config['extension']}" dest_file = commands_dir / f"{output_name}{agent_config['extension']}"
self._ensure_inside(dest_file, commands_dir) self._ensure_inside(dest_file, commands_dir)
dest_file.parent.mkdir(parents=True, exist_ok=True) dest_file.parent.mkdir(parents=True, exist_ok=True)
@@ -815,7 +721,6 @@ class CommandRegistrar:
source_id, source_id,
cmd_file, cmd_file,
project_root, project_root,
extension_id=extension_id,
) )
elif agent_config["format"] == "markdown": elif agent_config["format"] == "markdown":
alias_output = self.render_markdown_command( alias_output = self.render_markdown_command(
@@ -833,9 +738,6 @@ class CommandRegistrar:
raise ValueError( raise ValueError(
f"Unsupported format: {agent_config['format']}" f"Unsupported format: {agent_config['format']}"
) )
if agent_config["extension"] != "/SKILL.md" and _integration is not None:
alias_output = _integration.post_process_command_content(alias_output)
else: else:
# For other agents, reuse the primary output # For other agents, reuse the primary output
alias_output = output alias_output = output
@@ -848,7 +750,6 @@ class CommandRegistrar:
source_id, source_id,
cmd_file, cmd_file,
project_root, project_root,
extension_id=extension_id,
) )
alias_file = ( alias_file = (
@@ -980,7 +881,6 @@ class CommandRegistrar:
context_note: str = None, context_note: str = None,
link_outputs: bool = False, link_outputs: bool = False,
create_missing_active_skills_dir: bool = False, create_missing_active_skills_dir: bool = False,
extension_id: Optional[str] = None,
) -> Dict[str, List[str]]: ) -> Dict[str, List[str]]:
"""Register commands for all detected agents in the project. """Register commands for all detected agents in the project.
@@ -997,7 +897,6 @@ class CommandRegistrar:
Recovery requires active skills mode (or Kimi's existing native Recovery requires active skills mode (or Kimi's existing native
skills directory) and is skipped when safe resolution or skills directory) and is skipped when safe resolution or
creation fails. creation fails.
extension_id: Extension id when rendering extension-owned commands.
Returns: Returns:
Dictionary mapping agent names to list of registered commands Dictionary mapping agent names to list of registered commands
@@ -1100,7 +999,6 @@ class CommandRegistrar:
context_note=context_note, context_note=context_note,
_resolved_dir=agent_dir, _resolved_dir=agent_dir,
link_outputs=link_outputs, link_outputs=link_outputs,
extension_id=extension_id,
) )
if registered: if registered:
results[agent_name] = registered results[agent_name] = registered
@@ -1125,7 +1023,6 @@ class CommandRegistrar:
project_root: Path, project_root: Path,
context_note: Optional[str] = None, context_note: Optional[str] = None,
link_outputs: bool = False, link_outputs: bool = False,
extension_id: Optional[str] = None,
) -> Dict[str, List[str]]: ) -> Dict[str, List[str]]:
"""Register commands for all non-skill agents in the project. """Register commands for all non-skill agents in the project.
@@ -1141,7 +1038,6 @@ class CommandRegistrar:
context_note: Custom context comment for markdown output context_note: Custom context comment for markdown output
link_outputs: If True, create dev-mode symlinks for rendered link_outputs: If True, create dev-mode symlinks for rendered
command files when supported by the OS. command files when supported by the OS.
extension_id: Extension id when rendering extension-owned commands.
Returns: Returns:
Dictionary mapping agent names to list of registered commands Dictionary mapping agent names to list of registered commands
@@ -1170,7 +1066,6 @@ class CommandRegistrar:
context_note=context_note, context_note=context_note,
_resolved_dir=agent_dir, _resolved_dir=agent_dir,
link_outputs=link_outputs, link_outputs=link_outputs,
extension_id=extension_id,
) )
if registered: if registered:
results[agent_name] = registered results[agent_name] = registered

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 [

View File

@@ -73,13 +73,6 @@ class _StripAuthOnRedirect(urllib.request.HTTPRedirectHandler):
self._redirect_validator = redirect_validator self._redirect_validator = redirect_validator
def redirect_request(self, req, fp, code, msg, headers, newurl): def redirect_request(self, req, fp, code, msg, headers, newurl):
try:
new_parsed = urlparse(newurl)
except ValueError as exc:
# Malformed redirect target (e.g. unterminated IPv6 bracket).
# Surface as URLError so callers' download error handling applies.
raise urllib.error.URLError(f"malformed redirect URL: {exc}") from exc
if self._redirect_validator is not None: if self._redirect_validator is not None:
self._redirect_validator(req.full_url, newurl) self._redirect_validator(req.full_url, newurl)
@@ -90,6 +83,7 @@ class _StripAuthOnRedirect(urllib.request.HTTPRedirectHandler):
new_req = super().redirect_request(req, fp, code, msg, headers, newurl) new_req = super().redirect_request(req, fp, code, msg, headers, newurl)
if new_req is not None: if new_req is not None:
old_scheme = urlparse(req.full_url).scheme old_scheme = urlparse(req.full_url).scheme
new_parsed = urlparse(newurl)
hostname = (new_parsed.hostname or "").lower() hostname = (new_parsed.hostname or "").lower()
is_https_downgrade = old_scheme == "https" and new_parsed.scheme != "https" is_https_downgrade = old_scheme == "https" and new_parsed.scheme != "https"
if _hostname_in_hosts(hostname, self._hosts) and not is_https_downgrade: if _hostname_in_hosts(hostname, self._hosts) and not is_https_downgrade:

View File

@@ -95,11 +95,7 @@ def _is_local_path(url: str) -> bool:
"""True when *url* denotes a local filesystem path rather than a URL.""" """True when *url* denotes a local filesystem path rather than a URL."""
if _WINDOWS_DRIVE_RE.match(url): if _WINDOWS_DRIVE_RE.match(url):
return True return True
try: scheme = urlparse(url).scheme.lower()
scheme = urlparse(url).scheme.lower()
except ValueError:
# Malformed URLs (e.g. an unclosed IPv6 bracket) are not local paths.
return False
return scheme not in _REMOTE_SCHEMES return scheme not in _REMOTE_SCHEMES
@@ -141,10 +137,7 @@ def add_source(
url = url.strip() url = url.strip()
if not url: if not url:
raise BundlerError("A catalog url is required.") raise BundlerError("A catalog url is required.")
try: parsed = urlparse(url)
parsed = urlparse(url)
except ValueError as exc:
raise BundlerError(f"Invalid catalog url: '{url}'.") from exc
if not (parsed.scheme or parsed.path): if not (parsed.scheme or parsed.path):
raise BundlerError(f"Invalid catalog url: '{url}'.") raise BundlerError(f"Invalid catalog url: '{url}'.")
# Reject unsupported URL schemes (e.g. ssh://, ftp://) up front so they are # Reject unsupported URL schemes (e.g. ssh://, ftp://) up front so they are
@@ -155,20 +148,6 @@ def add_source(
f"Unsupported catalog url scheme '{parsed.scheme}://' in '{url}'. " f"Unsupported catalog url scheme '{parsed.scheme}://' in '{url}'. "
"Use http(s)://, file://, builtin://, or a local path." "Use http(s)://, file://, builtin://, or a local path."
) )
if parsed.scheme.lower() in {"http", "https"}:
# Mirror specify_cli.catalogs._validate_catalog_url (#3209/#3210):
# HTTPS only (HTTP just for localhost), and check hostname, not
# netloc — netloc is truthy for host-less URLs like "https://:8080"
# or "https://user@". Validating here keeps junk out of
# bundle-catalogs.yml instead of failing later at fetch time.
is_localhost = parsed.hostname in ("localhost", "127.0.0.1", "::1")
if parsed.scheme.lower() != "https" and not is_localhost:
raise BundlerError(
f"Catalog url must use HTTPS (got {parsed.scheme}://). "
"HTTP is only allowed for localhost."
)
if not parsed.hostname:
raise BundlerError(f"Catalog url must be a valid URL with a host: {url}")
url = _canonicalize_url(url) url = _canonicalize_url(url)
install_policy = InstallPolicy.parse(policy) install_policy = InstallPolicy.parse(policy)

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

View File

@@ -68,28 +68,14 @@ 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}://). "
"HTTP is only allowed for localhost." "HTTP is only allowed for localhost."
) )
# Check hostname, not netloc: netloc is truthy for host-less URLs like if not parsed.netloc:
# "https://:8080" or "https://user@...", so requiring netloc would let
# those through even though they carry no host. hostname is None in those
# cases. Mirrors the fix in ``specify_cli.catalogs`` (#3210).
if not 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}"
) )

View File

@@ -88,25 +88,17 @@ class CatalogStack:
Results are sorted by bundle id for deterministic output. Results are sorted by bundle id for deterministic output.
""" """
needle = query.strip().lower() needle = query.strip().lower()
# Resolve each id to its highest-precedence entry FIRST, then filter by seen: dict[str, ResolvedBundle] = {}
# the query. Claiming an id only when it matches would let a lower-
# precedence entry with the same id surface when the highest-precedence
# one doesn't match the query — but that shadowed entry is not what
# `resolve()`/install would use, so search would advertise a bundle
# (name, version, author) the user can never actually get.
resolved: dict[str, ResolvedBundle] = {}
for source in self._sources: for source in self._sources:
for bundle_id, entry in self._entries_for(source).items(): for bundle_id, entry in self._entries_for(source).items():
if bundle_id in resolved: if bundle_id in seen:
continue continue
resolved[bundle_id] = ResolvedBundle( if needle and not _matches(entry, needle):
continue
seen[bundle_id] = ResolvedBundle(
entry=entry.with_provenance(source), source=source entry=entry.with_provenance(source), source=source
) )
return [ return [seen[k] for k in sorted(seen)]
resolved[k]
for k in sorted(resolved)
if not needle or _matches(resolved[k].entry, needle)
]
def _matches(entry: CatalogEntry, needle: str) -> bool: def _matches(entry: CatalogEntry, needle: str) -> bool:

View File

@@ -130,28 +130,6 @@ def install_bundle(
done.append(component) done.append(component)
result.installed.append(component) result.installed.append(component)
contributed.append(component) contributed.append(component)
# On update (refresh), uninstall components this bundle used to own
# that the new version no longer ships. Otherwise they are dropped
# from the record below (contributed only holds plan.components) yet
# left on disk — permanently orphaned, since no bundle record can
# ever remove them. A stale component still owned by another bundle
# is kept installed and simply de-attributed here (it stays in that
# bundle's record). Mirrors remove_bundle's refcount logic.
if refresh and existing is not None:
planned = {(c.kind, c.id) for c in plan.components}
still_needed = components_still_needed(
records, exclude_bundle_id=plan.bundle_id
)
for component in existing.contributed_components:
key = (component.kind, component.id)
if key in planned:
continue
if key in still_needed:
continue
if installer.is_installed(project_root, component):
installer.remove(project_root, component)
result.uninstalled.append(component)
except BundlerError: except BundlerError:
_rollback(project_root, installer, done) _rollback(project_root, installer, done)
raise raise
@@ -187,41 +165,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

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
) )

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:

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:

View File

@@ -220,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:

View File

@@ -1075,16 +1075,9 @@ class ExtensionManager:
pass # best-effort cleanup pass # best-effort cleanup
continue continue
frontmatter, body = registrar.parse_frontmatter(content) frontmatter, body = registrar.parse_frontmatter(content)
frontmatter = registrar._adjust_script_paths( frontmatter = registrar._adjust_script_paths(frontmatter)
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
) )
original_desc = frontmatter.get("description", "") original_desc = frontmatter.get("description", "")
@@ -1965,7 +1958,6 @@ class CommandRegistrar:
project_root, project_root,
context_note=context_note, context_note=context_note,
link_outputs=link_outputs, link_outputs=link_outputs,
extension_id=manifest.id,
) )
def register_commands_for_all_agents( def register_commands_for_all_agents(
@@ -1986,7 +1978,6 @@ class CommandRegistrar:
context_note=context_note, context_note=context_note,
link_outputs=link_outputs, link_outputs=link_outputs,
create_missing_active_skills_dir=create_missing_active_skills_dir, create_missing_active_skills_dir=create_missing_active_skills_dir,
extension_id=manifest.id,
) )
def unregister_commands( def unregister_commands(
@@ -2697,12 +2688,7 @@ class ConfigManager:
return {} return {}
try: try:
data = yaml.safe_load(file_path.read_text(encoding="utf-8")) return yaml.safe_load(file_path.read_text(encoding="utf-8")) or {}
# Coerce a non-mapping root (list/scalar, or None for an empty
# file) to {} so callers that iterate/merge the result — e.g.
# _merge_configs' .items() — never crash. Mirrors the same
# non-dict-root guard in get_project_config().
return data if isinstance(data, dict) else {}
except (yaml.YAMLError, OSError, UnicodeError): except (yaml.YAMLError, OSError, UnicodeError):
return {} return {}
@@ -2737,36 +2723,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 +2742,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

View File

@@ -426,11 +426,7 @@ def extension_add(
if from_url and not dev: if from_url and not dev:
from urllib.parse import urlparse from urllib.parse import urlparse
try: parsed = urlparse(from_url)
parsed = urlparse(from_url)
except ValueError:
console.print(f"[red]Error:[/red] Invalid URL: {_escape_markup(from_url)}")
raise typer.Exit(1)
is_localhost = parsed.hostname in ("localhost", "127.0.0.1", "::1") is_localhost = parsed.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):
@@ -1566,14 +1562,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)

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))

View File

@@ -89,11 +89,7 @@ class AgyIntegration(SkillsIntegration):
output_json: bool = True, output_json: bool = True,
) -> list[str] | None: ) -> list[str] | None:
# agy does not support --model or JSON output; both params are ignored # agy does not support --model or JSON output; both params are ignored
args = [self._resolve_executable(), "--print", prompt] return [self._resolve_executable(), "--print", prompt]
# Honor SPECKIT_INTEGRATION_AGY_EXTRA_ARGS (operator-supplied flags),
# appended after the positional prompt like the devin integration.
self._apply_extra_args_env_var(args)
return args
def setup( def setup(
self, self,

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
@@ -26,9 +25,6 @@ from typing import TYPE_CHECKING, Any
import yaml import yaml
from .._toml_string import escape_toml_basic as _escape_toml_basic
from .._toml_string import has_illegal_toml_control as _has_illegal_toml_control
if TYPE_CHECKING: if TYPE_CHECKING:
from .manifest import IntegrationManifest from .manifest import IntegrationManifest
@@ -55,18 +51,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
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
@@ -139,19 +123,6 @@ class IntegrationBase(ABC):
integration that sets this flag. integration that sets this flag.
""" """
def post_process_command_content(self, content: str) -> str:
"""Transform command content after format rendering.
Called by ``register_commands()`` for non-skills format types
(Markdown, TOML, YAML) after the command has been rendered into
its target format and before writing to disk. Skills-format
agents use ``post_process_skill_content()`` instead.
Subclasses may override to inject agent-specific content.
The default implementation returns *content* unchanged.
"""
return content
# -- Public API ------------------------------------------------------- # -- Public API -------------------------------------------------------
@classmethod @classmethod
@@ -605,42 +576,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,
@@ -1001,12 +940,6 @@ class TomlIntegration(IntegrationBase):
body = "".join(lines[frontmatter_end + 1 :]) body = "".join(lines[frontmatter_end + 1 :])
return frontmatter, body return frontmatter, body
# Control-char detection and basic-string escaping are shared with the
# extension/preset renderer in ``specify_cli.agents`` via
# ``specify_cli._toml_string`` so the two never drift apart.
_has_illegal_toml_control = staticmethod(_has_illegal_toml_control)
_escape_toml_basic = staticmethod(_escape_toml_basic)
@staticmethod @staticmethod
def _render_toml_string(value: str) -> str: def _render_toml_string(value: str) -> str:
"""Render *value* as a TOML string literal. """Render *value* as a TOML string literal.
@@ -1016,12 +949,6 @@ class TomlIntegration(IntegrationBase):
literal string or escaped basic string when delimiters appear in literal string or escaped basic string when delimiters appear in
the content. the content.
""" """
# Control characters other than tab/newline (and a bare CR) cannot
# appear literally in any TOML string; route them to a fully-escaped
# basic string so the generated file stays parseable.
if TomlIntegration._has_illegal_toml_control(value):
return TomlIntegration._escape_toml_basic(value)
if "\n" not in value and "\r" not in value: if "\n" not in value and "\r" not in value:
escaped = value.replace("\\", "\\\\").replace('"', '\\"') escaped = value.replace("\\", "\\\\").replace('"', '\\"')
return f'"{escaped}"' return f'"{escaped}"'
@@ -1034,7 +961,17 @@ class TomlIntegration(IntegrationBase):
if "'''" not in value and not value.endswith("'"): if "'''" not in value and not value.endswith("'"):
return "'''\n" + value + "'''" return "'''\n" + value + "'''"
return TomlIntegration._escape_toml_basic(value) return (
'"'
+ (
value.replace("\\", "\\\\")
.replace('"', '\\"')
.replace("\n", "\\n")
.replace("\r", "\\r")
.replace("\t", "\\t")
)
+ '"'
)
@staticmethod @staticmethod
def _render_toml(description: str, body: str) -> str: def _render_toml(description: str, body: str) -> str:
@@ -1122,16 +1059,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 +1165,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,35 +1178,12 @@ class YamlIntegration(IntegrationBase):
default_flow_style=False, default_flow_style=False,
).strip() ).strip()
# YAML forbids C0 control characters (except tab and newline) and # Indent the body for YAML block scalar
# 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
# indicator ("|2") rather than a bare "|": YAML infers a plain block
# scalar's indentation from its first non-empty line, so a body whose
# first line is itself indented (e.g. a markdown code block or a nested
# list item) would make the parser expect that deeper indent for the
# whole block and reject the later, less-indented lines. Pinning the
# indent to 2 keeps the recipe parseable whatever the body looks like.
indented = "\n".join(f" {line}" for line in body.split("\n")) indented = "\n".join(f" {line}" for line in body.split("\n"))
lines = [ lines = [
header_yaml, header_yaml,
"prompt: |2", "prompt: |",
indented, indented,
"", "",
f"# Source: {source_id}", f"# Source: {source_id}",
@@ -1552,17 +1456,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}"
) )

View File

@@ -75,15 +75,7 @@ class CursorAgentIntegration(SkillsIntegration):
either drops tool calls or exits non-zero on the first approval either drops tool calls or exits non-zero on the first approval
prompt. prompt.
""" """
args = [ args = [self.key, "-p", "--trust", "--approve-mcps", "--force", prompt]
self._resolve_executable(),
"-p",
"--trust",
"--approve-mcps",
"--force",
prompt,
]
self._apply_extra_args_env_var(args)
if model: if model:
args.extend(["--model", model]) args.extend(["--model", model])
if output_json: if output_json:

View File

@@ -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)",
), ),
] ]

View File

@@ -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}"

View File

@@ -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}"
) )
@@ -251,11 +253,6 @@ class HermesIntegration(SkillsIntegration):
""" """
args = [self._resolve_executable(), "chat", "-Q"] args = [self._resolve_executable(), "chat", "-Q"]
# Operator-supplied SPECKIT_INTEGRATION_HERMES_EXTRA_ARGS go here —
# after the base command but before Spec Kit's canonical -m/--json/-s/-q
# flags — so they can't displace or clobber them (mirrors opencode).
self._apply_extra_args_env_var(args)
if model: if model:
args.extend(["-m", model]) args.extend(["-m", model])
if output_json: if output_json:

View File

@@ -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

View File

@@ -309,14 +309,7 @@ class IntegrationManifest:
if abs_path.is_symlink() or not abs_path.is_file(): if abs_path.is_symlink() or not abs_path.is_file():
modified.append(rel) modified.append(rel)
continue continue
try: if _sha256(abs_path) != expected_hash:
changed = _sha256(abs_path) != expected_hash
except OSError:
# Unreadable regular file (e.g. permission denied): treat as
# modified, consistent with the symlink / non-regular-file
# handling above, rather than letting the OSError escape.
changed = True
if changed:
modified.append(rel) modified.append(rel)
return modified return modified
@@ -365,17 +358,9 @@ class IntegrationManifest:
skipped.append(path) skipped.append(path)
continue continue
else: else:
if not force: if not force and _sha256(path) != expected_hash:
try: skipped.append(path)
matches = _sha256(path) == expected_hash continue
except OSError:
# Unreadable: can't verify it's ours, so preserve it
# (mirrors the path.unlink() OSError guard below).
skipped.append(path)
continue
if not matches:
skipped.append(path)
continue
try: try:
path.unlink() path.unlink()
except OSError: except OSError:

View File

@@ -778,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:
@@ -1200,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)
@@ -1466,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
@@ -2588,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.
@@ -2723,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}"
@@ -3011,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
@@ -3097,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")
@@ -3218,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
@@ -3266,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

View File

@@ -104,13 +104,7 @@ def preset_add(
from ipaddress import ip_address from ipaddress import ip_address
from urllib.parse import urlparse as _urlparse from urllib.parse import urlparse as _urlparse
try: _parsed = _urlparse(from_url)
_parsed = _urlparse(from_url)
except ValueError:
from rich.markup import escape as _escape_markup
console.print(f"[red]Error:[/red] Invalid URL: {_escape_markup(from_url)}")
raise typer.Exit(1)
def _is_allowed_download_url(parsed_url): def _is_allowed_download_url(parsed_url):
host = parsed_url.hostname host = parsed_url.hostname
@@ -141,9 +135,7 @@ def preset_add(
) )
raise typer.Exit(1) raise typer.Exit(1)
from rich.markup import escape as _esc console.print(f"Installing preset from [cyan]{from_url}[/cyan]...")
console.print(f"Installing preset from [cyan]{_esc(from_url)}[/cyan]...")
import urllib.error import urllib.error
import tempfile import tempfile
import shutil import shutil
@@ -469,14 +461,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)

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

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}"
) )

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
@@ -556,8 +474,6 @@ 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,
@@ -609,46 +525,11 @@ 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)
@@ -661,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
@@ -753,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.
@@ -766,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
------- -------
@@ -789,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
@@ -1095,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):

View File

@@ -183,85 +183,6 @@ def _is_single_expression(stripped: str) -> bool:
return True return True
def _interpolate_expressions(template: str, namespace: dict[str, Any]) -> str:
"""Substitute every top-level ``{{ ... }}`` block in *template*, quote-aware.
Walks the template and, for each block, finds the closing ``}}`` that lies
outside string literals -- the same quote-scanning used by
``_is_single_expression``. This keeps a literal ``}}`` inside a string
argument (e.g. ``| default('}}')``) from prematurely closing a block.
``_EXPR_PATTERN.sub`` cannot do this: its non-greedy body stops at the first
``}}`` regardless of quoting, so in a multi-expression template any block
whose argument contains a literal ``}}`` is captured truncated and mis-parsed
(raising ``ValueError`` from the filter parser). #3208/#3228 fixed exactly
this for the single-expression fast path but left the interpolation path on
the old regex.
"""
out: list[str] = []
i = 0
n = len(template)
while i < n:
start = template.find("{{", i)
if start == -1:
out.append(template[i:])
break
out.append(template[i:start])
# Scan for the block-closing ``}}`` that is outside any string literal.
j = start + 2
quote: str | None = None
close = -1
while j < n:
ch = template[j]
if quote is not None:
if ch == quote:
quote = None
elif ch in ("'", '"'):
quote = ch
elif ch == "}" and j + 1 < n and template[j + 1] == "}":
close = j
break
j += 1
if close == -1:
# No quote-aware close. Two sub-cases, both kept identical to the old
# regex so a malformed template is never silently hidden:
# * a raw ``}}`` still exists in the tail (e.g. an unbalanced quote
# in a filter arg swallowed the real delimiter) -- fall back to
# that first raw ``}}`` and evaluate, letting the parser surface
# a ValueError just as ``_EXPR_PATTERN.sub`` would have.
# * no ``}}`` at all -- a genuinely unterminated ``{{``; leave the
# tail verbatim, again matching the regex (which cannot match).
raw_close = template.find("}}", start + 2)
if raw_close == -1:
out.append(template[start:])
break
close = raw_close
val = _evaluate_simple_expression(template[start + 2:close].strip(), namespace)
out.append(str(val) if val is not None else "")
i = close + 2
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.
@@ -325,68 +246,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.
@@ -411,17 +270,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
@@ -464,9 +371,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:
@@ -501,54 +408,15 @@ def _evaluate_simple_expression(expr: str, namespace: dict[str, Any]) -> Any:
return _resolve_dot_path(namespace, expr) return _resolve_dot_path(namespace, expr)
def _coerce_number(value: Any) -> Any:
"""Return *value* as int/float if it is a numeric string, else unchanged."""
if isinstance(value, str):
try:
return float(value) if "." in value else int(value)
except ValueError:
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. """Safely compare two values, coercing types when possible."""
try:
Numeric coercion is applied only when *both* operands look numeric, so a if isinstance(left, str):
pair like ``"10"`` and ``"9"`` compares as numbers (10 > 9). When either left = float(left) if "." in left else int(left)
side is a non-numeric string, both fall back to their original values and if isinstance(right, str):
are compared directly -- so ordinary strings (dates, semver-ish tags, right = float(right) if "." in right else int(right)
names) compare lexicographically the way Python does, instead of every except (ValueError, TypeError):
such comparison silently returning ``False`` after a failed int()/float() return False
coercion. A genuinely incomparable pair (e.g. number vs non-numeric string)
raises ``TypeError`` and yields ``False``.
"""
cl, cr = _coerce_number(left), _coerce_number(right)
# Only use the coerced numbers when both converted; otherwise a numeric
# string paired with a plain string would become an int-vs-str mismatch
# (always False) rather than a lexicographic string comparison.
if isinstance(cl, (int, float)) and isinstance(cr, (int, float)):
left, right = cl, cr
try: try:
if op == ">": if op == ">":
return left > right # type: ignore[operator] return left > right # type: ignore[operator]
@@ -604,11 +472,12 @@ def evaluate_expression(template: str, context: Any) -> Any:
if _is_single_expression(stripped): if _is_single_expression(stripped):
return _evaluate_simple_expression(stripped[2:-2].strip(), namespace) return _evaluate_simple_expression(stripped[2:-2].strip(), namespace)
# Multi-expression: interpolate each block inline. Uses a quote-aware scan # Multi-expression: string interpolation
# (not ``_EXPR_PATTERN.sub``) so a literal ``}}`` inside a string argument def _replacer(m: re.Match[str]) -> str:
# in any block does not close that block early -- matching the handling the val = _evaluate_simple_expression(m.group(1).strip(), namespace)
# single-expression path above already got in #3208/#3228. return str(val) if val is not None else ""
return _interpolate_expressions(template, namespace)
return _EXPR_PATTERN.sub(_replacer, template)
def evaluate_condition(condition: str, context: Any) -> bool: def evaluate_condition(condition: str, context: Any) -> bool:

View File

@@ -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

View File

@@ -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:
@@ -76,13 +58,4 @@ class FanInStep(StepBase):
f"Fan-in step {config.get('id', '?')!r}: " f"Fan-in step {config.get('id', '?')!r}: "
f"'wait_for' must be a non-empty list of step IDs." f"'wait_for' must be a non-empty list of step IDs."
) )
output = config.get("output")
if output is not None and not isinstance(output, dict):
# execute() silently coerces a non-mapping output to {}, so the
# author's declared aggregation keys would vanish with no error.
# Reject at validation, mirroring the command-step (#3262) fix.
errors.append(
f"Fan-in step {config.get('id', '?')!r}: 'output' must be a "
f"mapping of key -> expression, got {type(output).__name__}."
)
return errors return errors

View File

@@ -73,14 +73,7 @@ class GateStep(StepBase):
choice = self._prompt(self._compose_prompt(message, show_file), options) choice = self._prompt(self._compose_prompt(message, show_file), options)
output["choice"] = choice output["choice"] = choice
# Match rejection case-insensitively. ``_prompt`` echoes the option's if choice in ("reject", "abort"):
# original casing, and ``validate`` accepts a reject option
# case-insensitively (``o.lower() in {"reject", "abort"}``), so a gate
# authored as ``options: [Approve, Reject]`` passes validation. Comparing
# ``choice`` case-sensitively here would then treat a ``Reject`` pick as
# approval and silently skip the abort — the reject path must agree with
# the check that let the option through.
if choice.lower() in ("reject", "abort"):
if on_reject == "abort": if on_reject == "abort":
output["aborted"] = True output["aborted"] = True
return StepResult( return StepResult(

View File

@@ -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."
) )

View File

@@ -3,7 +3,6 @@
from __future__ import annotations from __future__ import annotations
import json import json
import math
import subprocess import subprocess
from typing import Any from typing import Any
@@ -26,20 +25,7 @@ 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"},
)
# 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
# control commands; catalog-installed workflows should be reviewed # control commands; catalog-installed workflows should be reviewed
@@ -51,7 +37,7 @@ class ShellStep(StepBase):
capture_output=True, capture_output=True,
text=True, text=True,
cwd=cwd, cwd=cwd,
timeout=timeout, timeout=300,
) )
output = { output = {
"exit_code": proc.returncode, "exit_code": proc.returncode,
@@ -88,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:
@@ -98,55 +84,16 @@ 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:
errors.append( errors.append(
f"Shell step {config.get('id', '?')!r} is missing 'run' field." f"Shell step {config.get('id', '?')!r} is missing 'run' field."
) )
elif not isinstance(config["run"], str):
# execute() str()-coerces run and invokes it under shell=True, so a
# null or list 'run' would run the Python repr ('None', "['echo']")
# as a command. Reject non-strings at validation, mirroring the
# command-step input/options and gate options type checks. An
# expression like "{{ ... }}" is still a str, so it stays valid.
errors.append(
f"Shell step {config.get('id', '?')!r}: 'run' must be a string, "
f"got {type(config['run']).__name__}."
)
output_format = config.get("output_format") output_format = config.get("output_format")
if output_format is not None and output_format != "json": if output_format is not None and output_format != "json":
errors.append( errors.append(
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

View File

@@ -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(

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.
============================================================================ ============================================================================
--> -->

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