mirror of
https://github.com/github/spec-kit.git
synced 2026-07-03 20:36:23 +08:00
Compare commits
22 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
25c1ce48bb | ||
|
|
86709f6089 | ||
|
|
c47dd2b812 | ||
|
|
844c73685b | ||
|
|
20f430686c | ||
|
|
9c691e57b9 | ||
|
|
ada293e203 | ||
|
|
5f440a8e20 | ||
|
|
28a38af6c1 | ||
|
|
8215f3308b | ||
|
|
cb7c36c95b | ||
|
|
8025481eca | ||
|
|
4038d370bf | ||
|
|
ea1827769a | ||
|
|
00f6a80201 | ||
|
|
4badf3b5b1 | ||
|
|
9dfef8629e | ||
|
|
5a29e4b659 | ||
|
|
b1bd9180ca | ||
|
|
804e7329b8 | ||
|
|
c5fb3dc86f | ||
|
|
5a7d84311b |
2
.github/ISSUE_TEMPLATE/agent_request.yml
vendored
2
.github/ISSUE_TEMPLATE/agent_request.yml
vendored
@@ -8,7 +8,7 @@ body:
|
||||
value: |
|
||||
Thanks for requesting a new agent! Before submitting, please check if the agent is already supported.
|
||||
|
||||
**Currently supported agents**: 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, iFlow CLI, Junie, Kilo Code, Kimi Code, Kiro CLI, Lingma, Mistral Vibe, Oh My Pi, opencode, Pi Coding Agent, Qoder CLI, Qwen Code, Roo Code, RovoDev ACLI, SHAI, Tabnine CLI, Trae, Windsurf, 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, Roo Code, RovoDev ACLI, SHAI, Tabnine CLI, Trae, ZCode, Zed
|
||||
|
||||
- type: input
|
||||
id: agent-name
|
||||
|
||||
2
.github/ISSUE_TEMPLATE/bug_report.yml
vendored
2
.github/ISSUE_TEMPLATE/bug_report.yml
vendored
@@ -78,7 +78,6 @@ body:
|
||||
- Goose
|
||||
- Hermes Agent
|
||||
- IBM Bob
|
||||
- iFlow CLI
|
||||
- Junie
|
||||
- Kilo Code
|
||||
- Kimi Code
|
||||
@@ -95,7 +94,6 @@ body:
|
||||
- SHAI
|
||||
- Tabnine CLI
|
||||
- Trae
|
||||
- Windsurf
|
||||
- ZCode
|
||||
- Zed
|
||||
- Not applicable
|
||||
|
||||
2
.github/ISSUE_TEMPLATE/feature_request.yml
vendored
2
.github/ISSUE_TEMPLATE/feature_request.yml
vendored
@@ -72,7 +72,6 @@ body:
|
||||
- Goose
|
||||
- Hermes Agent
|
||||
- IBM Bob
|
||||
- iFlow CLI
|
||||
- Junie
|
||||
- Kilo Code
|
||||
- Kimi Code
|
||||
@@ -89,7 +88,6 @@ body:
|
||||
- SHAI
|
||||
- Tabnine CLI
|
||||
- Trae
|
||||
- Windsurf
|
||||
- ZCode
|
||||
- Zed
|
||||
- Not applicable
|
||||
|
||||
13
.github/workflows/lint.yml
vendored
13
.github/workflows/lint.yml
vendored
@@ -54,3 +54,16 @@ jobs:
|
||||
# (notably SC2155). Tighten in a follow-up after cleanup.
|
||||
- name: Run shellcheck on shell scripts
|
||||
run: git ls-files -z -- '*.sh' | xargs -0 shellcheck --severity=error
|
||||
|
||||
# macOS ships bash 3.2, where bash 4+ case-modification parameter
|
||||
# expansions error with "bad substitution". shellcheck assumes bash 4+
|
||||
# from the shebang and cannot flag these, so guard explicitly; use tr
|
||||
# for portable case conversion.
|
||||
- name: Reject bash 4+ case-modification expansions
|
||||
run: |
|
||||
matches=$(git ls-files -z -- '*.sh' | xargs -0 grep -nE '\$\{[A-Za-z_][A-Za-z0-9_]*(\[[^]]*\])?(\^\^?|,,?|~~?|@[UuLl])[^}]*\}' || true)
|
||||
if [ -n "$matches" ]; then
|
||||
echo "Found bash 4+ case-modification expansion(s); use tr for portability (macOS ships bash 3.2):"
|
||||
echo "$matches"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
6
.github/workflows/test.yml
vendored
6
.github/workflows/test.yml
vendored
@@ -21,7 +21,7 @@ jobs:
|
||||
- name: Set up Python
|
||||
uses: actions/setup-python@ece7cb06caefa5fff74198d8649806c4678c61a1 # v6
|
||||
with:
|
||||
python-version: "3.13"
|
||||
python-version: "3.14"
|
||||
|
||||
- name: Run ruff check
|
||||
run: uvx ruff check src/
|
||||
@@ -30,8 +30,8 @@ jobs:
|
||||
runs-on: ${{ matrix.os }}
|
||||
strategy:
|
||||
matrix:
|
||||
os: [ubuntu-latest, windows-latest]
|
||||
python-version: ["3.11", "3.12", "3.13"]
|
||||
os: [ubuntu-latest, windows-latest, macos-latest]
|
||||
python-version: ["3.13", "3.14"]
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
|
||||
22
AGENTS.md
22
AGENTS.md
@@ -23,7 +23,7 @@ src/specify_cli/integrations/
|
||||
│ └── __init__.py # ClaudeIntegration class
|
||||
├── gemini/ # Example: TomlIntegration subclass
|
||||
│ └── __init__.py
|
||||
├── windsurf/ # Example: MarkdownIntegration subclass
|
||||
├── kilocode/ # Example: MarkdownIntegration subclass
|
||||
│ └── __init__.py
|
||||
├── copilot/ # Example: IntegrationBase subclass (custom setup)
|
||||
│ └── __init__.py
|
||||
@@ -52,25 +52,25 @@ Most agents only need `MarkdownIntegration` — a minimal subclass with zero met
|
||||
|
||||
Create `src/specify_cli/integrations/<package_dir>/__init__.py`, where `<package_dir>` is the Python-safe directory name derived from `<key>`: use the key as-is when it contains no hyphens (e.g., key `"gemini"` → `gemini/`), or replace hyphens with underscores when it does (e.g., key `"kiro-cli"` → `kiro_cli/`). The `IntegrationBase.key` class attribute always retains the original hyphenated value, since that is what the CLI and registry use. For CLI-based integrations (`requires_cli: True`), the `key` should match the actual CLI tool name (the executable users install and run) so CLI checks can resolve it correctly. For IDE-based integrations (`requires_cli: False`), use the canonical integration identifier instead.
|
||||
|
||||
**Minimal example — Markdown agent (Windsurf):**
|
||||
**Minimal example — Markdown agent (Kilo Code):**
|
||||
|
||||
```python
|
||||
"""Windsurf IDE integration."""
|
||||
"""Kilo Code IDE integration."""
|
||||
|
||||
from ..base import MarkdownIntegration
|
||||
|
||||
|
||||
class WindsurfIntegration(MarkdownIntegration):
|
||||
key = "windsurf"
|
||||
class KilocodeIntegration(MarkdownIntegration):
|
||||
key = "kilocode"
|
||||
config = {
|
||||
"name": "Windsurf",
|
||||
"folder": ".windsurf/",
|
||||
"name": "Kilo Code",
|
||||
"folder": ".kilocode/",
|
||||
"commands_subdir": "workflows",
|
||||
"install_url": None,
|
||||
"requires_cli": False,
|
||||
}
|
||||
registrar_config = {
|
||||
"dir": ".windsurf/workflows",
|
||||
"dir": ".kilocode/workflows",
|
||||
"format": "markdown",
|
||||
"args": "$ARGUMENTS",
|
||||
"extension": ".md",
|
||||
@@ -148,7 +148,7 @@ class CodexIntegration(SkillsIntegration):
|
||||
| `config` | Class attribute (dict) | Agent metadata: `name`, `folder`, `commands_subdir`, `install_url`, `requires_cli` |
|
||||
| `registrar_config` | Class attribute (dict) | Command output config: `dir`, `format`, `args` placeholder, file `extension` |
|
||||
|
||||
**Key design rule:** For CLI-based integrations (`requires_cli: True`), `key` must be the actual executable name (e.g., `"cursor-agent"` not `"cursor"`). This ensures `shutil.which(key)` works for CLI-tool checks without special-case mappings. IDE-based integrations (`requires_cli: False`) should use their canonical identifier (e.g., `"windsurf"`, `"copilot"`).
|
||||
**Key design rule:** For CLI-based integrations (`requires_cli: True`), `key` must be the actual executable name (e.g., `"cursor-agent"` not `"cursor"`). This ensures `shutil.which(key)` works for CLI-tool checks without special-case mappings. IDE-based integrations (`requires_cli: False`) should use their canonical identifier (e.g., `"kilocode"`, `"copilot"`).
|
||||
|
||||
### 3. Register it
|
||||
|
||||
@@ -201,8 +201,8 @@ Only add custom setup logic when the agent needs non-standard behavior. Integrat
|
||||
specify init my-project --integration <key>
|
||||
|
||||
# Verify files were created in the commands directory configured by
|
||||
# config["folder"] + config["commands_subdir"] (for example, .windsurf/workflows/)
|
||||
ls -R my-project/.windsurf/workflows/
|
||||
# config["folder"] + config["commands_subdir"] (for example, .kilocode/workflows/)
|
||||
ls -R my-project/.kilocode/workflows/
|
||||
|
||||
# Uninstall cleanly
|
||||
cd my-project && specify integration uninstall <key>
|
||||
|
||||
46
CHANGELOG.md
46
CHANGELOG.md
@@ -2,6 +2,52 @@
|
||||
|
||||
<!-- insert new changelog below this comment -->
|
||||
|
||||
## [0.12.2] - 2026-06-30
|
||||
|
||||
### Changed
|
||||
|
||||
- fix(scripts): portable uppercase for branch-name acronym retention (bash 3.2) (#3192)
|
||||
- chore: retire Windsurf integration — absorbed into Cognition Devin (#3168) (#3213)
|
||||
- [extension] Update Intake extension to v0.1.3 (#3254)
|
||||
- feat(workflows): honor max_concurrency in fan-out via a bounded thread pool (#3224)
|
||||
- Update Architecture Workflow extension to v1.2.2 (#3255)
|
||||
- Add Repository Governance extension to community catalog (#3252)
|
||||
- Update Workflow Preset to v1.3.11 (#3251)
|
||||
- chore: retire iflow integration — product discontinued (#3166) (#3211)
|
||||
- docs(codebuddy): fix dead install links and CodeBuddy capitalization (#3172) (#3216)
|
||||
- fix: reject host-less catalog URLs in base and preset validators (#3209) (#3227)
|
||||
- chore: release 0.12.1, begin 0.12.2.dev0 development (#3253)
|
||||
|
||||
## [0.12.1] - 2026-06-30
|
||||
|
||||
### Changed
|
||||
|
||||
- chore: align CI Python matrix with devguide lifecycle + fix bash 3.2 portability (#3244)
|
||||
- fix: stop check-prerequisites --paths-only from writing feature.json (#3025) (#3190)
|
||||
- docs: document integration catalog subcommands (#3206)
|
||||
- fix(scripts): use ASCII [OK] marker in initialize-repo.sh (parity with PowerShell twin) (#3231)
|
||||
- docs: document integration `search`/`info`/`scaffold` subcommands (#3174) (#3194)
|
||||
- docs: remove Cursor from `specify check` agent list (#3178) (#3193)
|
||||
- fix(goose): repoint install_url and docs to goose-docs.ai (#3171) (#3215)
|
||||
- fix(scripts): route 'Plan template not found' per --json in setup-plan.ps1 (parity with bash) (#3241)
|
||||
- fix(bundle): send command errors to stderr so --json stdout stays parseable (#3235)
|
||||
- chore: release 0.12.0, begin 0.12.1.dev0 development (#3243)
|
||||
|
||||
## [0.12.0] - 2026-06-29
|
||||
|
||||
### Changed
|
||||
|
||||
- feat: make agent-context extension a full opt-in (#3097)
|
||||
- docs(workflows): add the built-in 'init' step type to the Step Types table (#3234)
|
||||
- fix(workflows): gate validate() must not crash on non-string options (#3233)
|
||||
- fix(workflows): make pipe-filter detection quote-aware in expressions (#3232)
|
||||
- fix(workflows): reject a fan-in wait_for that names an unknown step at validation (#3225)
|
||||
- fix(scripts): warn when spec template is missing in create-new-feature.ps1 (parity with bash) (#3230)
|
||||
- fix(scripts): count subdirectory-only dirs as non-empty in PowerShell (parity with bash) (#3137)
|
||||
- fix(scripts): drop HAS_GIT from PowerShell git-extension output (parity with bash) (#3195)
|
||||
- Update Product Spec Extension to v1.0.1 (#3226)
|
||||
- chore: release 0.11.10, begin 0.11.11.dev0 development (#3240)
|
||||
|
||||
## [0.11.10] - 2026-06-29
|
||||
|
||||
### Changed
|
||||
|
||||
@@ -406,7 +406,7 @@ specify init . --force --integration copilot
|
||||
specify init --here --force --integration copilot
|
||||
```
|
||||
|
||||
The CLI will check if you have Claude Code, Gemini CLI, Cursor CLI, Qwen CLI, opencode, Codex CLI, Qoder CLI, Tabnine CLI, Kiro CLI, Pi, Oh My Pi, Forge, Goose, Mistral Vibe, or ZCode installed. If you do not, 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
|
||||
specify init <project_name> --integration copilot --ignore-agent-tools
|
||||
|
||||
@@ -31,7 +31,7 @@ The following community-contributed extensions are available in [`catalog.commun
|
||||
| API Evolve | Managed API contract evolution — breaking-change detection, semver enforcement, deprecation orchestration, and lifecycle gates across REST, GraphQL, and gRPC | `process` | Read+Write | [spec-kit-api-evolve](https://github.com/Quratulain-bilal/spec-kit-api-evolve) |
|
||||
| Architect Impact Previewer | Predicts architectural impact, complexity, and risks of proposed changes before implementation. | `visibility` | Read-only | [spec-kit-architect-preview](https://github.com/UmmeHabiba1312/spec-kit-architect-preview) |
|
||||
| Architecture Guard | Framework-agnostic architecture review extension for validating implementation against governance and architecture constitutions, detecting architectural drift, and generating non-blocking refactor tasks | `process` | Read+Write | [spec-kit-architecture-guard](https://github.com/DyanGalih/spec-kit-architecture-guard) |
|
||||
| Architecture Workflow | Generate or reverse project-level 4+1 architecture views as separate commands | `docs` | Read+Write | [spec-kit-arch](https://github.com/bigsmartben/spec-kit-arch) |
|
||||
| Architecture Workflow | Generate or reverse project-level 4+1 architecture views with per-view and full-workflow commands | `docs` | Read+Write | [spec-kit-arch](https://github.com/bigsmartben/spec-kit-arch) |
|
||||
| Archive Extension | Archive merged features into main project memory. | `docs` | Read+Write | [spec-kit-archive](https://github.com/stn1slv/spec-kit-archive) |
|
||||
| Azure DevOps Integration | Sync user stories and tasks to Azure DevOps work items using OAuth authentication | `integration` | Read+Write | [spec-kit-azure-devops](https://github.com/pragya247/spec-kit-azure-devops) |
|
||||
| Blueprint | Stay code-literate in AI-driven development: review a complete code blueprint for every task from spec artifacts before /speckit.implement runs | `docs` | Read+Write | [spec-kit-blueprint](https://github.com/chordpli/spec-kit-blueprint) |
|
||||
@@ -58,7 +58,7 @@ The following community-contributed extensions are available in [`catalog.commun
|
||||
| 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 | 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) |
|
||||
| Intake | Normalize PRD, design, 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) |
|
||||
| Iterate | Iterate on spec documents with a two-phase define-and-apply workflow — refine specs mid-implementation and go straight back to building | `docs` | Read+Write | [spec-kit-iterate](https://github.com/imviancagrace/spec-kit-iterate) |
|
||||
| Jira Integration | Create Jira Epics, Stories, and Issues from spec-kit specifications and task breakdowns with configurable hierarchy and custom field support | `integration` | Read+Write | [spec-kit-jira](https://github.com/mbachorik/spec-kit-jira) |
|
||||
@@ -98,6 +98,7 @@ The following community-contributed extensions are available in [`catalog.commun
|
||||
| Reconcile Extension | Reconcile implementation drift by surgically updating feature artifacts. | `docs` | Read+Write | [spec-kit-reconcile](https://github.com/stn1slv/spec-kit-reconcile) |
|
||||
| Red Team | Adversarial review of specs before /speckit.plan — parallel lens agents surface risks that clarify/analyze structurally can't (prompt injection, integrity gaps, cross-spec drift, silent failures). Produces a structured findings report; no auto-edits to specs. | `docs` | Read+Write | [spec-kit-red-team](https://github.com/ashbrener/spec-kit-red-team) |
|
||||
| Research Harness | State-externalizing research harness: budgeted exploration, evidence curation, and claim verification for spec-driven development | `process` | Read+Write | [spec-kit-harness](https://github.com/formin/spec-kit-harness) |
|
||||
| Repository Governance | Generate project-governance projections from Spec Kit metadata | `process` | Read+Write | [spec-kit-agent-governance](https://github.com/bigsmartben/spec-kit-agent-governance) |
|
||||
| Repository Index | Generate index for existing repo for overview, architecture and module level. | `docs` | Read-only | [spec-kit-repoindex](https://github.com/liuyiyu/spec-kit-repoindex) |
|
||||
| Reqnroll BDD | Adds Reqnroll BDD planning, Gherkin generation, traceability, safe task injection, handoff, and verification to Spec Kit | `process` | Read+Write | [spec-kit-reqnroll-bdd](https://github.com/LoogacyStudio/spec-kit-reqnroll-bdd) |
|
||||
| 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) |
|
||||
|
||||
@@ -31,7 +31,7 @@ Define what to build before building it. Rich templates, quality checklists, and
|
||||
|
||||
### Use any coding agent
|
||||
|
||||
<span class="pillar-stat">30+ integrations</span> — Copilot, Gemini, Codex, Windsurf, Zed, Claude, Forge, Kiro, and more. Switch freely between agents with a single command. No lock-in.
|
||||
<span class="pillar-stat">30+ integrations</span> — Copilot, Gemini, Codex, Kilo Code, Zed, Claude, Forge, Kiro, and more. Switch freely between agents with a single command. No lock-in.
|
||||
|
||||
Run `specify init` with your agent of choice and Spec Kit sets up the right command files, context rules, and directory structures automatically. If your agent isn't listed, the `generic` integration is an escape hatch for any tool.
|
||||
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
## Prerequisites
|
||||
|
||||
- **Linux/macOS** (or Windows; PowerShell scripts now supported without WSL)
|
||||
- AI coding agent: [Claude Code](https://www.anthropic.com/claude-code), [GitHub Copilot](https://code.visualstudio.com/), [Codebuddy CLI](https://www.codebuddy.ai/cli), [Gemini CLI](https://github.com/google-gemini/gemini-cli), [Pi Coding Agent](https://pi.dev), or [Oh My Pi](https://www.npmjs.com/package/@oh-my-pi/pi-coding-agent)
|
||||
- AI coding agent: [Claude Code](https://www.anthropic.com/claude-code), [GitHub Copilot](https://code.visualstudio.com/), [CodeBuddy CLI](https://www.codebuddy.cn/docs/cli/installation), [Gemini CLI](https://github.com/google-gemini/gemini-cli), [Pi Coding Agent](https://pi.dev), or [Oh My Pi](https://www.npmjs.com/package/@oh-my-pi/pi-coding-agent)
|
||||
- [uv](https://docs.astral.sh/uv/) for package management (recommended) or [pipx](https://pipx.pypa.io/) for persistent installation
|
||||
- [Python 3.11+](https://www.python.org/downloads/)
|
||||
- [Git](https://git-scm.com/downloads) _(optional — required only when the git extension is enabled)_
|
||||
|
||||
@@ -11,7 +11,7 @@ The Specify CLI supports a wide range of AI coding agents. When you run `specify
|
||||
| [Auggie CLI](https://docs.augmentcode.com/cli/overview) | `auggie` | |
|
||||
| [Claude Code](https://www.anthropic.com/claude-code) | `claude` | Skills-based integration; installs skills in `.claude/skills` |
|
||||
| [Cline](https://github.com/cline/cline) | `cline` | IDE-based agent |
|
||||
| [CodeBuddy CLI](https://www.codebuddy.ai/cli) | `codebuddy` | |
|
||||
| [CodeBuddy CLI](https://www.codebuddy.cn/docs/cli/installation) | `codebuddy` | |
|
||||
| [Codex CLI](https://github.com/openai/codex) | `codex` | Skills-based integration; installs skills into `.agents/skills` and invokes them as `$speckit-<command>` |
|
||||
| [Cursor](https://cursor.sh/) | `cursor-agent` | |
|
||||
| [Devin for Terminal](https://cli.devin.ai/docs) | `devin` | Skills-based integration; installs skills into `.devin/skills/` and invokes them as `/speckit-<command>` |
|
||||
@@ -19,10 +19,9 @@ The Specify CLI supports a wide range of AI coding agents. When you run `specify
|
||||
| [Forge](https://forgecode.dev/) | `forge` | |
|
||||
| [Gemini CLI](https://github.com/google-gemini/gemini-cli) | `gemini` | |
|
||||
| [GitHub Copilot](https://code.visualstudio.com/) | `copilot` | |
|
||||
| [Goose](https://block.github.io/goose/) | `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/` |
|
||||
| [IBM Bob](https://www.ibm.com/products/bob) | `bob` | IDE-based agent |
|
||||
| [iFlow CLI](https://docs.iflow.cn/en/cli/quickstart) | `iflow` | |
|
||||
| [Junie](https://junie.jetbrains.com/) | `junie` | |
|
||||
| [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, and (when the `agent-context` extension is enabled) migrates `KIMI.md` context into `AGENTS.md` |
|
||||
@@ -39,7 +38,6 @@ The Specify CLI supports a wide range of AI coding agents. When you run `specify
|
||||
| [SHAI (OVHcloud)](https://github.com/ovh/shai) | `shai` | |
|
||||
| [Tabnine CLI](https://docs.tabnine.com/main/getting-started/tabnine-cli) | `tabnine` | |
|
||||
| [Trae](https://www.trae.ai/) | `trae` | Skills-based integration; skills are installed automatically |
|
||||
| [Windsurf](https://windsurf.com/) | `windsurf` | |
|
||||
| [ZCode](https://zcode.z.ai/) | `zcode` | Skills-based integration; installs skills into `.zcode/skills/` and invokes them as `$speckit-<command>` |
|
||||
| [Zed](https://zed.dev/) | `zed` | Skills-based integration; installs skills into `.agents/skills` and invokes them as `/speckit-<command>` |
|
||||
| Generic | `generic` | Bring your own agent — use `--integration generic --integration-options="--commands-dir <path>"` for AI coding agents not listed above |
|
||||
@@ -54,6 +52,27 @@ Shows all available integrations, which one is currently installed, and whether
|
||||
When multiple integrations are installed, the list marks the default integration separately from the other installed integrations.
|
||||
The list also shows whether each built-in integration is declared multi-install safe.
|
||||
|
||||
## Search Available Integrations
|
||||
|
||||
```bash
|
||||
specify integration search [query]
|
||||
```
|
||||
|
||||
| Option | Description |
|
||||
| ---------- | ------------------ |
|
||||
| `--tag` | Filter by tag |
|
||||
| `--author` | Filter by author |
|
||||
|
||||
Searches the active catalog stack for integrations matching the query. Without a query, lists all available integrations. Must be run inside a Spec Kit project.
|
||||
|
||||
## Integration Info
|
||||
|
||||
```bash
|
||||
specify integration info <integration_id>
|
||||
```
|
||||
|
||||
Shows catalog details for a single integration, including its description, author, license, tags, source catalog, repository (when available), and whether it is currently active. Must be run inside a Spec Kit project.
|
||||
|
||||
## Install an Integration
|
||||
|
||||
```bash
|
||||
@@ -152,6 +171,47 @@ is `null` when no installed integration set can be evaluated, such as when the
|
||||
integration state is missing, unreadable, lacks a valid recorded integration
|
||||
list, or records no installed integrations.
|
||||
|
||||
## Catalog Management
|
||||
|
||||
Integration catalogs control where the discovery commands (`search` and `info`) look for integrations. Catalogs are checked in priority order.
|
||||
|
||||
### List Catalogs
|
||||
|
||||
```bash
|
||||
specify integration catalog list
|
||||
```
|
||||
|
||||
Shows the active catalog sources. Project-level sources (when configured) are removable by index; otherwise the active sources are shown as non-removable.
|
||||
|
||||
### Add a Catalog
|
||||
|
||||
```bash
|
||||
specify integration catalog add <url>
|
||||
```
|
||||
|
||||
| Option | Description |
|
||||
| --------------- | ----------------------------- |
|
||||
| `--name <name>` | Optional name for the catalog |
|
||||
|
||||
Adds a custom catalog URL to the project's `.specify/integration-catalogs.yml`. The URL must use HTTPS (except `http://localhost`, `http://127.0.0.1`, or `http://[::1]` for local testing).
|
||||
|
||||
### Remove a Catalog
|
||||
|
||||
```bash
|
||||
specify integration catalog remove <index>
|
||||
```
|
||||
|
||||
Removes a project catalog source by its 0-based index in `catalog list`.
|
||||
|
||||
### Catalog Resolution Order
|
||||
|
||||
Catalogs are resolved in this order (first match wins):
|
||||
|
||||
1. **Environment variable** — `SPECKIT_INTEGRATION_CATALOG_URL` overrides all catalogs
|
||||
2. **Project config** — `.specify/integration-catalogs.yml`
|
||||
3. **User config** — `~/.specify/integration-catalogs.yml`
|
||||
4. **Built-in defaults** — official catalog + community catalog
|
||||
|
||||
## Integration-Specific Options
|
||||
|
||||
Some integrations accept additional options via `--integration-options`:
|
||||
@@ -167,6 +227,18 @@ Example:
|
||||
specify integration install generic --integration-options="--commands-dir .myagent/cmds"
|
||||
```
|
||||
|
||||
## Scaffold a New Integration
|
||||
|
||||
```bash
|
||||
specify integration scaffold <key>
|
||||
```
|
||||
|
||||
Creates a minimal built-in integration package and a matching test skeleton in the Spec Kit repository, then prints the next steps for wiring it up. Run this command from the Spec Kit repository root. The `<key>` must be lowercase kebab-case (for example, `my-agent`).
|
||||
|
||||
| Option | Description |
|
||||
| -------- | ---------------------------------------------------------------- |
|
||||
| `--type` | Scaffold template to use: `markdown` (default), `skills`, `toml`, or `yaml` |
|
||||
|
||||
## FAQ
|
||||
|
||||
### Can I install multiple integrations in the same project?
|
||||
@@ -191,7 +263,6 @@ The currently declared multi-install safe integrations are:
|
||||
| `cursor-agent` | `.cursor/skills`, `.cursor/rules/specify-rules.mdc` |
|
||||
| `firebender` | `.firebender/commands`, `.firebender/rules/specify-rules.mdc` |
|
||||
| `gemini` | `.gemini/commands`, `GEMINI.md` |
|
||||
| `iflow` | `.iflow/commands`, `IFLOW.md` |
|
||||
| `junie` | `.junie/commands`, `.junie/AGENTS.md` |
|
||||
| `kilocode` | `.kilocode/workflows`, `.kilocode/rules/specify-rules.md` |
|
||||
| `qodercli` | `.qoder/commands`, `QODER.md` |
|
||||
@@ -200,7 +271,6 @@ The currently declared multi-install safe integrations are:
|
||||
| `shai` | `.shai/commands`, `SHAI.md` |
|
||||
| `tabnine` | `.tabnine/agent/commands`, `TABNINE.md` |
|
||||
| `trae` | `.trae/skills`, `.trae/rules/project_rules.md` |
|
||||
| `windsurf` | `.windsurf/workflows`, `.windsurf/rules/specify-rules.md` |
|
||||
| `zcode` | `.zcode/skills`, `ZCODE.md` |
|
||||
|
||||
Integrations that share a context file or command directory with another integration, require dynamic install paths such as `--commands-dir`, or merge shared tool settings are not declared safe by default. They can still be installed alongside another integration with `--force`.
|
||||
@@ -215,7 +285,7 @@ Run `specify integration list` to see all available integrations with their keys
|
||||
|
||||
### Do I need the AI coding agent installed to use an integration?
|
||||
|
||||
CLI-based integrations (like Claude Code, Gemini CLI) require the tool to be installed. IDE-based integrations (like Windsurf, Cursor) work through the IDE itself. Some agents like GitHub Copilot support both IDE and CLI usage. `specify integration list` shows which type each integration is.
|
||||
CLI-based integrations (like Claude Code, Gemini CLI) require the tool to be installed. IDE-based integrations (like Cursor) work through the IDE itself. Some agents like GitHub Copilot support both IDE and CLI usage. `specify integration list` shows which type each integration is.
|
||||
|
||||
### When should I use `upgrade` vs `switch`?
|
||||
|
||||
|
||||
@@ -185,7 +185,7 @@ cp -r .specify/scripts .specify/scripts-backup
|
||||
|
||||
### 3. Duplicate slash commands (IDE-based agents)
|
||||
|
||||
Some IDE-based agents (like Kilo Code, Windsurf) may show **duplicate slash commands** after upgrading—both old and new versions appear.
|
||||
Some IDE-based agents (like Kilo Code, Roo Code) may show **duplicate slash commands** after upgrading—both old and new versions appear.
|
||||
|
||||
**Solution:** Manually delete the old command files from your agent's folder.
|
||||
|
||||
@@ -193,7 +193,7 @@ Some IDE-based agents (like Kilo Code, Windsurf) may show **duplicate slash comm
|
||||
|
||||
```bash
|
||||
# Navigate to the agent's commands folder
|
||||
cd .kilocode/rules/
|
||||
cd .kilocode/workflows/
|
||||
|
||||
# List files and identify duplicates
|
||||
ls -la
|
||||
@@ -242,11 +242,11 @@ mv /tmp/constitution-backup.md .specify/memory/constitution.md
|
||||
|
||||
### Scenario 3: "I see duplicate slash commands in my IDE"
|
||||
|
||||
This happens with IDE-based agents (Kilo Code, Windsurf, Roo Code, etc.).
|
||||
This happens with IDE-based agents (Kilo Code, Roo Code, Cline, etc.).
|
||||
|
||||
```bash
|
||||
# Find the agent folder (example: .kilocode/rules/)
|
||||
cd .kilocode/rules/
|
||||
# Find the agent folder (example: .kilocode/workflows/)
|
||||
cd .kilocode/workflows/
|
||||
|
||||
# List all files
|
||||
ls -la
|
||||
|
||||
@@ -18,7 +18,6 @@
|
||||
"generic": "AGENTS.md",
|
||||
"goose": "AGENTS.md",
|
||||
"hermes": "AGENTS.md",
|
||||
"iflow": "IFLOW.md",
|
||||
"junie": ".junie/AGENTS.md",
|
||||
"kilocode": ".kilocode/rules/specify-rules.md",
|
||||
"kimi": "AGENTS.md",
|
||||
|
||||
@@ -59,6 +59,13 @@ case "$(uname -s 2>/dev/null || true)" in
|
||||
esac
|
||||
|
||||
# Parse extension config once; emit context files as JSON, followed by marker strings.
|
||||
#
|
||||
# NOTE (bash 3.2 / macOS portability): the embedded Python heredocs below run
|
||||
# inside $(...) command substitution. bash 3.2 (the system /bin/bash on macOS)
|
||||
# mis-parses a single-quote/apostrophe in a heredoc body nested in $(...),
|
||||
# failing with "unexpected EOF while looking for matching `''". Keep these
|
||||
# $(...)-nested heredoc bodies free of apostrophes (use double quotes in Python
|
||||
# string literals and avoid contractions in comments).
|
||||
if ! _raw_opts="$("$_python" - "$EXT_CONFIG" "$_case_insensitive_context_files" "$PROJECT_ROOT" <<'PY'
|
||||
import json
|
||||
import sys
|
||||
@@ -113,11 +120,11 @@ if isinstance(raw_files, list):
|
||||
if not context_files:
|
||||
add_context_file(get_str(data, "context_file"))
|
||||
if not context_files:
|
||||
# Self-seed: the agent-context extension owns its lifecycle, so when its
|
||||
# own config declares no target it derives one from the active integration
|
||||
# recorded in init-options.json, using the extension's OWN bundled mapping
|
||||
# (agent-context-defaults.json). This is independent of the Specify CLI by
|
||||
# design — nothing here imports specify_cli.
|
||||
# Self-seed: the agent-context extension manages its own lifecycle, so when
|
||||
# its config declares no target, it derives one from the active integration
|
||||
# recorded in init-options.json, mapped through the bundled
|
||||
# agent-context-defaults.json file. This is independent of the Specify CLI
|
||||
# by design; nothing here imports specify_cli.
|
||||
project_root = sys.argv[3] if len(sys.argv) > 3 else "."
|
||||
integration_key = ""
|
||||
try:
|
||||
@@ -144,7 +151,7 @@ if not context_files:
|
||||
except Exception:
|
||||
print(
|
||||
"agent-context: unable to read %s; cannot self-seed the context "
|
||||
"file. Set 'context_file' in the extension config." % defaults_path,
|
||||
"file. Set context_file in the extension config." % defaults_path,
|
||||
file=sys.stderr,
|
||||
)
|
||||
mapping = {}
|
||||
@@ -152,7 +159,7 @@ if not context_files:
|
||||
if not context_files:
|
||||
print(
|
||||
"agent-context: no default context file is known for integration "
|
||||
"'%s'. Set 'context_file' in the extension config to choose one."
|
||||
"%s. Set context_file in the extension config to choose one."
|
||||
% integration_key,
|
||||
file=sys.stderr,
|
||||
)
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"schema_version": "1.0",
|
||||
"updated_at": "2026-06-29T00:00:00Z",
|
||||
"updated_at": "2026-06-30T00:00:00Z",
|
||||
"catalog_url": "https://raw.githubusercontent.com/github/spec-kit/main/extensions/catalog.community.json",
|
||||
"extensions": {
|
||||
"aide": {
|
||||
@@ -187,10 +187,10 @@
|
||||
"arch": {
|
||||
"name": "Architecture Workflow",
|
||||
"id": "arch",
|
||||
"description": "Generate or reverse project-level 4+1 architecture views as separate commands",
|
||||
"description": "Generate or reverse project-level 4+1 architecture views with per-view and full-workflow commands",
|
||||
"author": "bigsmartben",
|
||||
"version": "1.2.1",
|
||||
"download_url": "https://github.com/bigsmartben/spec-kit-arch/archive/refs/tags/v1.2.1.zip",
|
||||
"version": "1.2.2",
|
||||
"download_url": "https://github.com/bigsmartben/spec-kit-arch/archive/refs/tags/v1.2.2.zip",
|
||||
"repository": "https://github.com/bigsmartben/spec-kit-arch",
|
||||
"homepage": "https://github.com/bigsmartben/spec-kit-arch",
|
||||
"documentation": "https://github.com/bigsmartben/spec-kit-arch/blob/main/README.md",
|
||||
@@ -202,7 +202,7 @@
|
||||
"speckit_version": ">=0.8.10.dev0"
|
||||
},
|
||||
"provides": {
|
||||
"commands": 10,
|
||||
"commands": 12,
|
||||
"hooks": 0
|
||||
},
|
||||
"tags": [
|
||||
@@ -215,7 +215,7 @@
|
||||
"downloads": 0,
|
||||
"stars": 0,
|
||||
"created_at": "2026-05-14T00:00:00Z",
|
||||
"updated_at": "2026-06-23T00:00:00Z"
|
||||
"updated_at": "2026-06-30T00:00:00Z"
|
||||
},
|
||||
"architect-preview": {
|
||||
"name": "Architect Impact Previewer",
|
||||
@@ -1440,10 +1440,10 @@
|
||||
"intake": {
|
||||
"name": "Intake",
|
||||
"id": "intake",
|
||||
"description": "Normalize PRD, design, and test-case evidence into SDD-ready intake artifacts.",
|
||||
"description": "Normalize PRD, design, HTML SSOT, and test-case evidence into SDD-ready intake artifacts.",
|
||||
"author": "bigsmartben",
|
||||
"version": "0.1.2",
|
||||
"download_url": "https://github.com/bigsmartben/spec-kit-intake/archive/refs/tags/v0.1.2.zip",
|
||||
"version": "0.1.3",
|
||||
"download_url": "https://github.com/bigsmartben/spec-kit-intake/archive/refs/tags/v0.1.3.zip",
|
||||
"repository": "https://github.com/bigsmartben/spec-kit-intake",
|
||||
"homepage": "https://github.com/bigsmartben/spec-kit-intake",
|
||||
"documentation": "https://github.com/bigsmartben/spec-kit-intake/blob/main/README.md",
|
||||
@@ -1461,7 +1461,7 @@
|
||||
]
|
||||
},
|
||||
"provides": {
|
||||
"commands": 3,
|
||||
"commands": 4,
|
||||
"hooks": 1
|
||||
},
|
||||
"tags": [
|
||||
@@ -1475,7 +1475,7 @@
|
||||
"downloads": 0,
|
||||
"stars": 0,
|
||||
"created_at": "2026-06-23T00:00:00Z",
|
||||
"updated_at": "2026-06-23T00:00:00Z"
|
||||
"updated_at": "2026-06-30T00:00:00Z"
|
||||
},
|
||||
"issue": {
|
||||
"name": "GitHub Issues Integration 2",
|
||||
@@ -2828,6 +2828,46 @@
|
||||
"created_at": "2026-03-23T13:30:00Z",
|
||||
"updated_at": "2026-03-23T13:30:00Z"
|
||||
},
|
||||
"repository-governance": {
|
||||
"name": "Repository Governance",
|
||||
"id": "repository-governance",
|
||||
"description": "Generate project-governance projections from Spec Kit metadata",
|
||||
"author": "bigben",
|
||||
"version": "3.0.1",
|
||||
"download_url": "https://github.com/bigsmartben/spec-kit-agent-governance/releases/download/v3.0.1/repository-governance-v3.0.1.zip",
|
||||
"repository": "https://github.com/bigsmartben/spec-kit-agent-governance",
|
||||
"homepage": "https://github.com/bigsmartben/spec-kit-agent-governance",
|
||||
"documentation": "https://github.com/bigsmartben/spec-kit-agent-governance/blob/main/README.md",
|
||||
"changelog": "https://github.com/bigsmartben/spec-kit-agent-governance/blob/main/CHANGELOG.md",
|
||||
"license": "MIT",
|
||||
"category": "process",
|
||||
"effect": "read-write",
|
||||
"requires": {
|
||||
"speckit_version": ">=0.8.0",
|
||||
"tools": [
|
||||
{
|
||||
"name": "uv",
|
||||
"required": true
|
||||
}
|
||||
]
|
||||
},
|
||||
"provides": {
|
||||
"commands": 1,
|
||||
"hooks": 3
|
||||
},
|
||||
"tags": [
|
||||
"governance",
|
||||
"repository",
|
||||
"agents",
|
||||
"memory",
|
||||
"context"
|
||||
],
|
||||
"verified": false,
|
||||
"downloads": 0,
|
||||
"stars": 0,
|
||||
"created_at": "2026-06-30T00:00:00Z",
|
||||
"updated_at": "2026-06-30T00:00:00Z"
|
||||
},
|
||||
"reqnroll-bdd": {
|
||||
"name": "Reqnroll BDD",
|
||||
"id": "reqnroll-bdd",
|
||||
|
||||
@@ -280,7 +280,7 @@ generate_branch_name() {
|
||||
|
||||
local stop_words="^(i|a|an|the|to|for|of|in|on|at|by|with|from|is|are|was|were|be|been|being|have|has|had|do|does|did|will|would|should|could|can|may|might|must|shall|this|that|these|those|my|your|our|their|want|need|add|get|set)$"
|
||||
|
||||
local clean_name=$(echo "$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')
|
||||
|
||||
local meaningful_words=()
|
||||
for word in $clean_name; do
|
||||
@@ -288,7 +288,9 @@ generate_branch_name() {
|
||||
if ! echo "$word" | grep -qiE "$stop_words"; then
|
||||
if [ ${#word} -ge 3 ]; then
|
||||
meaningful_words+=("$word")
|
||||
elif echo "$description" | grep -qw -- "${word^^}"; then
|
||||
# Uppercase via tr (portable) rather than bash's 4+ "^^" case
|
||||
# expansion, which breaks on macOS's default bash 3.2 (bad substitution).
|
||||
elif printf '%s' "$description" | grep -qw -- "$(printf '%s' "$word" | tr '[:lower:]' '[:upper:]')"; then
|
||||
meaningful_words+=("$word")
|
||||
fi
|
||||
fi
|
||||
|
||||
@@ -51,4 +51,4 @@ _git_out=$(git init -q 2>&1) || { echo "[specify] Error: git init failed: $_git_
|
||||
_git_out=$(git add . 2>&1) || { echo "[specify] Error: git add failed: $_git_out" >&2; exit 1; }
|
||||
_git_out=$(git commit --allow-empty -q -m "$COMMIT_MSG" 2>&1) || { echo "[specify] Error: git commit failed: $_git_out" >&2; exit 1; }
|
||||
|
||||
echo "✓ Git repository initialized" >&2
|
||||
echo "[OK] Git repository initialized" >&2
|
||||
|
||||
@@ -253,9 +253,10 @@ function Get-BranchName {
|
||||
if ($word.Length -ge 3) {
|
||||
$meaningfulWords += $word
|
||||
} elseif ($Description -cmatch "\b$($word.ToUpper())\b") {
|
||||
# Case-sensitive (-cmatch) to mirror the bash twin's `grep -qw -- "${word^^}"`:
|
||||
# keep a short word only when its UPPERCASE form appears in the original
|
||||
# (an acronym). -match is case-insensitive and would keep every short word.
|
||||
# Case-sensitive (-cmatch) to mirror the bash twin's case-sensitive
|
||||
# whole-word acronym match: keep a short word only when its UPPERCASE
|
||||
# form appears in the original (an acronym). -match is case-insensitive
|
||||
# and would keep every short word.
|
||||
$meaningfulWords += $word
|
||||
}
|
||||
}
|
||||
|
||||
@@ -48,15 +48,6 @@
|
||||
"repository": "https://github.com/github/spec-kit",
|
||||
"tags": ["ide"]
|
||||
},
|
||||
"windsurf": {
|
||||
"id": "windsurf",
|
||||
"name": "Windsurf",
|
||||
"version": "1.0.0",
|
||||
"description": "Windsurf IDE workflow integration",
|
||||
"author": "spec-kit-core",
|
||||
"repository": "https://github.com/github/spec-kit",
|
||||
"tags": ["ide"]
|
||||
},
|
||||
"amp": {
|
||||
"id": "amp",
|
||||
"name": "Amp",
|
||||
@@ -264,15 +255,6 @@
|
||||
"repository": "https://github.com/github/spec-kit",
|
||||
"tags": ["cli"]
|
||||
},
|
||||
"iflow": {
|
||||
"id": "iflow",
|
||||
"name": "iFlow CLI",
|
||||
"version": "1.0.0",
|
||||
"description": "iFlow CLI integration by iflow-ai",
|
||||
"author": "spec-kit-core",
|
||||
"repository": "https://github.com/github/spec-kit",
|
||||
"tags": ["cli"]
|
||||
},
|
||||
"vibe": {
|
||||
"id": "vibe",
|
||||
"name": "Mistral Vibe",
|
||||
|
||||
@@ -99,7 +99,7 @@ The `CommandRegistrar` renders commands differently per agent:
|
||||
|
||||
| Agent | Format | Extension | Arg placeholder |
|
||||
|-------|--------|-----------|-----------------|
|
||||
| Claude, Cursor, opencode, Windsurf, etc. | Markdown | `.md` | `$ARGUMENTS` |
|
||||
| Claude, Kilo Code, opencode, Roo Code, etc. | Markdown | `.md` | `$ARGUMENTS` |
|
||||
| Copilot | Markdown | `.agent.md` + `.prompt.md` | `$ARGUMENTS` |
|
||||
| Gemini, Qwen, Tabnine | TOML | `.toml` | `{{args}}` |
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"schema_version": "1.0",
|
||||
"updated_at": "2026-06-25T00:00:00Z",
|
||||
"updated_at": "2026-06-30T00:00:00Z",
|
||||
"catalog_url": "https://raw.githubusercontent.com/github/spec-kit/main/presets/catalog.community.json",
|
||||
"presets": {
|
||||
"a11y-governance": {
|
||||
@@ -670,11 +670,11 @@
|
||||
"workflow-preset": {
|
||||
"name": "Workflow Preset",
|
||||
"id": "workflow-preset",
|
||||
"version": "1.3.2",
|
||||
"description": "Behavior-first specification, design artifacts, and agent-native handoff orchestration.",
|
||||
"version": "1.3.11",
|
||||
"description": "Behavior-first specification, design artifacts, and agent-native handoff orchestration",
|
||||
"author": "bigsmartben",
|
||||
"repository": "https://github.com/bigsmartben/spec-kit-workflow-preset",
|
||||
"download_url": "https://github.com/bigsmartben/spec-kit-workflow-preset/releases/download/v1.3.2/spec-kit-workflow-preset-v1.3.2.zip",
|
||||
"download_url": "https://github.com/bigsmartben/spec-kit-workflow-preset/releases/download/v1.3.11/spec-kit-workflow-preset-v1.3.11.zip",
|
||||
"homepage": "https://github.com/bigsmartben/spec-kit-workflow-preset",
|
||||
"documentation": "https://github.com/bigsmartben/spec-kit-workflow-preset/blob/main/README.md",
|
||||
"license": "MIT",
|
||||
@@ -693,7 +693,7 @@
|
||||
"handoff"
|
||||
],
|
||||
"created_at": "2026-05-27T00:00:00Z",
|
||||
"updated_at": "2026-06-03T00:00:00Z"
|
||||
"updated_at": "2026-06-30T00:00:00Z"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
[project]
|
||||
name = "specify-cli"
|
||||
version = "0.11.11.dev0"
|
||||
version = "0.12.2"
|
||||
description = "Specify CLI, part of GitHub Spec Kit. A tool to bootstrap your projects for Spec-Driven Development (SDD)."
|
||||
readme = "README.md"
|
||||
requires-python = ">=3.11"
|
||||
|
||||
@@ -78,8 +78,14 @@ done
|
||||
SCRIPT_DIR="$(CDPATH="" cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
source "$SCRIPT_DIR/common.sh"
|
||||
|
||||
# Get feature paths
|
||||
_paths_output=$(get_feature_paths) || { echo "ERROR: Failed to resolve feature paths" >&2; exit 1; }
|
||||
# Get feature paths.
|
||||
# In --paths-only mode this is pure resolution, so pass --no-persist to opt out
|
||||
# of the feature.json write side effect (issue #3025).
|
||||
if $PATHS_ONLY; then
|
||||
_paths_output=$(get_feature_paths --no-persist) || { echo "ERROR: Failed to resolve feature paths" >&2; exit 1; }
|
||||
else
|
||||
_paths_output=$(get_feature_paths) || { echo "ERROR: Failed to resolve feature paths" >&2; exit 1; }
|
||||
fi
|
||||
eval "$_paths_output"
|
||||
unset _paths_output
|
||||
|
||||
|
||||
@@ -152,6 +152,15 @@ _persist_feature_json() {
|
||||
}
|
||||
|
||||
get_feature_paths() {
|
||||
# Read-only callers (e.g. check-prerequisites.sh --paths-only) pass
|
||||
# --no-persist so pure path resolution never writes .specify/feature.json,
|
||||
# which would dirty the working tree or overwrite a pinned value (issue #3025).
|
||||
local no_persist=false
|
||||
if [[ "${1:-}" == "--no-persist" ]]; then
|
||||
no_persist=true
|
||||
shift
|
||||
fi
|
||||
|
||||
# Split decl/assignment so a SPECIFY_INIT_DIR validation failure in
|
||||
# get_repo_root propagates as a hard error instead of being masked by `local`.
|
||||
local repo_root
|
||||
@@ -168,8 +177,11 @@ get_feature_paths() {
|
||||
feature_dir="$SPECIFY_FEATURE_DIRECTORY"
|
||||
# Normalize relative paths to absolute under repo root
|
||||
[[ "$feature_dir" != /* ]] && feature_dir="$repo_root/$feature_dir"
|
||||
# Persist to feature.json so future sessions without the env var still work
|
||||
_persist_feature_json "$repo_root" "$SPECIFY_FEATURE_DIRECTORY"
|
||||
# Persist to feature.json so future sessions without the env var still
|
||||
# work — unless the caller opted out for read-only resolution (#3025).
|
||||
if [[ "$no_persist" != true ]]; then
|
||||
_persist_feature_json "$repo_root" "$SPECIFY_FEATURE_DIRECTORY"
|
||||
fi
|
||||
elif [[ -f "$repo_root/.specify/feature.json" ]]; then
|
||||
local _fd
|
||||
_fd=$(read_feature_json_feature_directory "$repo_root")
|
||||
|
||||
@@ -140,7 +140,7 @@ generate_branch_name() {
|
||||
local stop_words="^(i|a|an|the|to|for|of|in|on|at|by|with|from|is|are|was|were|be|been|being|have|has|had|do|does|did|will|would|should|could|can|may|might|must|shall|this|that|these|those|my|your|our|their|want|need|add|get|set)$"
|
||||
|
||||
# Convert to lowercase and split into words
|
||||
local clean_name=$(echo "$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)
|
||||
local meaningful_words=()
|
||||
@@ -152,8 +152,10 @@ generate_branch_name() {
|
||||
if ! echo "$word" | grep -qiE "$stop_words"; then
|
||||
if [ ${#word} -ge 3 ]; then
|
||||
meaningful_words+=("$word")
|
||||
elif echo "$description" | grep -q "\b${word^^}\b"; then
|
||||
# Keep short words if they appear as uppercase in original (likely acronyms)
|
||||
# Keep short words that appear as an uppercase acronym in the original.
|
||||
# Uppercase via tr and match with grep -w (both portable) rather than
|
||||
# bash's 4+ "^^" case expansion (breaks on macOS bash 3.2) and \b (non-POSIX).
|
||||
elif printf '%s' "$description" | grep -qw -- "$(printf '%s' "$word" | tr '[:lower:]' '[:upper:]')"; then
|
||||
meaningful_words+=("$word")
|
||||
fi
|
||||
fi
|
||||
|
||||
@@ -56,8 +56,14 @@ EXAMPLES:
|
||||
# Source common functions
|
||||
. "$PSScriptRoot/common.ps1"
|
||||
|
||||
# Get feature paths
|
||||
$paths = Get-FeaturePathsEnv
|
||||
# Get feature paths.
|
||||
# In -PathsOnly mode this is pure resolution, so pass -NoPersist to opt out of
|
||||
# the feature.json write side effect (issue #3025).
|
||||
if ($PathsOnly) {
|
||||
$paths = Get-FeaturePathsEnv -NoPersist
|
||||
} else {
|
||||
$paths = Get-FeaturePathsEnv
|
||||
}
|
||||
|
||||
# If paths-only mode, output paths and exit (no validation)
|
||||
if ($PathsOnly) {
|
||||
|
||||
@@ -143,6 +143,13 @@ function Save-FeatureJson {
|
||||
}
|
||||
|
||||
function Get-FeaturePathsEnv {
|
||||
# Read-only callers (e.g. check-prerequisites.ps1 -PathsOnly) pass -NoPersist
|
||||
# so pure path resolution never writes .specify/feature.json, which would
|
||||
# dirty the working tree or overwrite a pinned value (issue #3025).
|
||||
param(
|
||||
[switch]$NoPersist
|
||||
)
|
||||
|
||||
$repoRoot = Get-RepoRoot
|
||||
$currentBranch = Get-CurrentBranch
|
||||
|
||||
@@ -157,8 +164,11 @@ function Get-FeaturePathsEnv {
|
||||
if (-not [System.IO.Path]::IsPathRooted($featureDir)) {
|
||||
$featureDir = Join-Path $repoRoot $featureDir
|
||||
}
|
||||
# Persist to feature.json so future sessions without the env var still work
|
||||
Save-FeatureJson -RepoRoot $repoRoot -FeatureDirectory $env:SPECIFY_FEATURE_DIRECTORY
|
||||
# Persist to feature.json so future sessions without the env var still
|
||||
# work - unless the caller opted out for read-only resolution (#3025).
|
||||
if (-not $NoPersist) {
|
||||
Save-FeatureJson -RepoRoot $repoRoot -FeatureDirectory $env:SPECIFY_FEATURE_DIRECTORY
|
||||
}
|
||||
} elseif (Test-Path $featureJson) {
|
||||
$featureJsonRaw = Get-Content -LiteralPath $featureJson -Raw
|
||||
try {
|
||||
|
||||
@@ -48,7 +48,14 @@ if (Test-Path $paths.IMPL_PLAN -PathType Leaf) {
|
||||
Write-Output "Copied plan template to $($paths.IMPL_PLAN)"
|
||||
}
|
||||
} else {
|
||||
Write-Warning "Plan template not found"
|
||||
# Match the bash twin's wording and stream routing (stderr in -Json so
|
||||
# stdout stays pure JSON, stdout otherwise), consistent with the sibling
|
||||
# "Copied plan template" message above.
|
||||
if ($Json) {
|
||||
[Console]::Error.WriteLine("Warning: Plan template not found")
|
||||
} else {
|
||||
Write-Output "Warning: Plan template not found"
|
||||
}
|
||||
# Create a basic plan file if template doesn't exist
|
||||
New-Item -ItemType File -Path $paths.IMPL_PLAN -Force | Out-Null
|
||||
}
|
||||
|
||||
@@ -34,6 +34,10 @@ TAGLINE = "GitHub Spec Kit - Spec-Driven Development Toolkit"
|
||||
|
||||
console = Console(highlight=False)
|
||||
|
||||
# Stderr-bound console for error/diagnostic output, so human-facing messages
|
||||
# never contaminate stdout (which carries machine-readable ``--json`` payloads).
|
||||
err_console = Console(stderr=True, highlight=False)
|
||||
|
||||
class StepTracker:
|
||||
"""Track and render hierarchical steps without emojis, similar to Claude Code tree output.
|
||||
Supports live auto-refresh via an attached refresh callback.
|
||||
|
||||
@@ -80,7 +80,7 @@ class CatalogStackBase:
|
||||
)
|
||||
# Check hostname, not netloc: netloc is truthy for host-less URLs like
|
||||
# "https://:8080" or "https://user@", so the host guarantee this error
|
||||
# promises would not actually hold. hostname is None in those cases.
|
||||
# promises would not actually hold. hostname is None in those cases (#3209).
|
||||
if not parsed.hostname:
|
||||
raise cls._error("Catalog URL must be a valid URL with a host.")
|
||||
|
||||
|
||||
@@ -13,7 +13,7 @@ from pathlib import Path
|
||||
|
||||
import typer
|
||||
|
||||
from ..._console import console
|
||||
from ..._console import console, err_console
|
||||
from ...bundler import BundlerError
|
||||
from ...bundler.lib.project import (
|
||||
active_integration,
|
||||
@@ -41,7 +41,9 @@ bundle_app.add_typer(bundle_catalog_app, name="catalog")
|
||||
|
||||
def _fail(message: str) -> None:
|
||||
"""Print an actionable error to stderr and exit non-zero."""
|
||||
console.print(f"[red]Error:[/red] {message}", style=None)
|
||||
# Use the stderr console so the error never lands on stdout, which under
|
||||
# ``--json`` carries the machine-readable payload and must stay parseable.
|
||||
err_console.print(f"[red]Error:[/red] {message}", style=None)
|
||||
raise typer.Exit(code=1)
|
||||
|
||||
|
||||
|
||||
@@ -64,7 +64,6 @@ def _register_builtins() -> None:
|
||||
from .generic import GenericIntegration
|
||||
from .goose import GooseIntegration
|
||||
from .hermes import HermesIntegration
|
||||
from .iflow import IflowIntegration
|
||||
from .junie import JunieIntegration
|
||||
from .kilocode import KilocodeIntegration
|
||||
from .kimi import KimiIntegration
|
||||
@@ -81,7 +80,6 @@ def _register_builtins() -> None:
|
||||
from .tabnine import TabnineIntegration
|
||||
from .trae import TraeIntegration
|
||||
from .vibe import VibeIntegration
|
||||
from .windsurf import WindsurfIntegration
|
||||
from .zcode import ZcodeIntegration
|
||||
from .zed import ZedIntegration
|
||||
|
||||
@@ -103,7 +101,6 @@ def _register_builtins() -> None:
|
||||
_register(GenericIntegration())
|
||||
_register(GooseIntegration())
|
||||
_register(HermesIntegration())
|
||||
_register(IflowIntegration())
|
||||
_register(JunieIntegration())
|
||||
_register(KilocodeIntegration())
|
||||
_register(KimiIntegration())
|
||||
@@ -120,7 +117,6 @@ def _register_builtins() -> None:
|
||||
_register(TabnineIntegration())
|
||||
_register(TraeIntegration())
|
||||
_register(VibeIntegration())
|
||||
_register(WindsurfIntegration())
|
||||
_register(ZcodeIntegration())
|
||||
_register(ZedIntegration())
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
"""Goose integration — Block's open source AI agent."""
|
||||
"""Goose integration — open source AI agent (Agentic AI Foundation)."""
|
||||
|
||||
from ..base import YamlIntegration
|
||||
|
||||
@@ -9,7 +9,7 @@ class GooseIntegration(YamlIntegration):
|
||||
"name": "Goose",
|
||||
"folder": ".goose/",
|
||||
"commands_subdir": "recipes",
|
||||
"install_url": "https://block.github.io/goose/docs/getting-started/installation",
|
||||
"install_url": "https://goose-docs.ai/docs/getting-started/installation",
|
||||
"requires_cli": True,
|
||||
}
|
||||
registrar_config = {
|
||||
|
||||
@@ -1,21 +0,0 @@
|
||||
"""iFlow CLI integration."""
|
||||
|
||||
from ..base import MarkdownIntegration
|
||||
|
||||
|
||||
class IflowIntegration(MarkdownIntegration):
|
||||
key = "iflow"
|
||||
config = {
|
||||
"name": "iFlow CLI",
|
||||
"folder": ".iflow/",
|
||||
"commands_subdir": "commands",
|
||||
"install_url": "https://docs.iflow.cn/en/cli/quickstart",
|
||||
"requires_cli": True,
|
||||
}
|
||||
registrar_config = {
|
||||
"dir": ".iflow/commands",
|
||||
"format": "markdown",
|
||||
"args": "$ARGUMENTS",
|
||||
"extension": ".md",
|
||||
}
|
||||
multi_install_safe = True
|
||||
@@ -1,21 +0,0 @@
|
||||
"""Windsurf IDE integration."""
|
||||
|
||||
from ..base import MarkdownIntegration
|
||||
|
||||
|
||||
class WindsurfIntegration(MarkdownIntegration):
|
||||
key = "windsurf"
|
||||
config = {
|
||||
"name": "Windsurf",
|
||||
"folder": ".windsurf/",
|
||||
"commands_subdir": "workflows",
|
||||
"install_url": None,
|
||||
"requires_cli": False,
|
||||
}
|
||||
registrar_config = {
|
||||
"dir": ".windsurf/workflows",
|
||||
"format": "markdown",
|
||||
"args": "$ARGUMENTS",
|
||||
"extension": ".md",
|
||||
}
|
||||
multi_install_safe = True
|
||||
@@ -1863,7 +1863,7 @@ class PresetCatalog:
|
||||
)
|
||||
# Check hostname, not netloc: netloc is truthy for host-less URLs like
|
||||
# "https://:8080" or "https://user@", so the host guarantee this error
|
||||
# promises would not actually hold. hostname is None in those cases.
|
||||
# promises would not actually hold. hostname is None in those cases (#3209).
|
||||
if not parsed.hostname:
|
||||
raise PresetValidationError(
|
||||
"Catalog URL must be a valid URL with a host."
|
||||
|
||||
@@ -97,6 +97,13 @@ class StepBase(ABC):
|
||||
|
||||
Every step type — built-in or extension-provided — implements this
|
||||
interface and registers in ``STEP_REGISTRY``.
|
||||
|
||||
Thread-safety: ``STEP_REGISTRY`` holds a single shared instance per type, so
|
||||
a concurrent ``fan-out`` (``max_concurrency > 1``) can invoke ``execute`` on
|
||||
the same instance from several threads at once. Implementations must be
|
||||
stateless / thread-safe — derive all per-run state from the ``config`` and
|
||||
``context`` arguments and never mutate ``self`` in ``execute``. The built-in
|
||||
steps follow this rule.
|
||||
"""
|
||||
|
||||
#: Matches the ``type:`` value in workflow YAML.
|
||||
|
||||
@@ -10,10 +10,14 @@ The engine is the orchestrator that:
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import dataclasses
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
import tempfile
|
||||
import threading
|
||||
import uuid
|
||||
from concurrent.futures import Future, ThreadPoolExecutor
|
||||
from datetime import datetime, timezone
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
@@ -412,6 +416,15 @@ class RunState:
|
||||
self.current_step_index = 0
|
||||
self.current_step_id: str | None = None
|
||||
self.step_results: dict[str, dict[str, Any]] = {}
|
||||
# Guards step_results mutation and save() so a concurrent fan-out cannot
|
||||
# mutate the dict while save() is serializing it (which would raise
|
||||
# "dictionary changed size during iteration").
|
||||
self._lock = threading.Lock()
|
||||
# Serializes append_log's list append + log.jsonl write so concurrent
|
||||
# fan-out workers cannot interleave or corrupt log lines. Kept separate
|
||||
# from _lock so frequent logging never contends with state saves; since
|
||||
# append_log is never called while _lock is held, the two never nest.
|
||||
self._log_lock = threading.Lock()
|
||||
self.inputs: dict[str, Any] = {}
|
||||
self.created_at = datetime.now(timezone.utc).isoformat()
|
||||
self.updated_at = self.created_at
|
||||
@@ -421,28 +434,72 @@ class RunState:
|
||||
def runs_dir(self) -> Path:
|
||||
return self.project_root / ".specify" / "workflows" / "runs" / self.run_id
|
||||
|
||||
def record_step_result(self, step_id: str, data: dict[str, Any]) -> None:
|
||||
"""Record one step's result under the run lock.
|
||||
|
||||
Routing the mutation through the lock keeps it from racing a concurrent
|
||||
``save()`` that is iterating ``step_results`` (e.g. during a concurrent
|
||||
fan-out). For a sequential run this is an uncontended lock.
|
||||
"""
|
||||
with self._lock:
|
||||
self.step_results[step_id] = data
|
||||
|
||||
def set_step_output(self, step_id: str, output: Any) -> None:
|
||||
"""Replace an already-recorded step's ``output`` under the run lock.
|
||||
|
||||
Fan-out updates its parent step's output after the items have run;
|
||||
routing that nested mutation through the lock keeps it from racing a
|
||||
``save()`` serializing ``step_results`` — the same invariant
|
||||
``record_step_result`` provides for the top-level assignment.
|
||||
"""
|
||||
with self._lock:
|
||||
if step_id in self.step_results:
|
||||
self.step_results[step_id]["output"] = output
|
||||
|
||||
def save(self) -> None:
|
||||
"""Persist current state to disk."""
|
||||
self.updated_at = datetime.now(timezone.utc).isoformat()
|
||||
"""Persist current state to disk.
|
||||
|
||||
Held under the run lock and written atomically (temp file + ``os.replace``)
|
||||
so a concurrent fan-out can neither mutate ``step_results`` mid-serialization
|
||||
nor leave a reader observing a half-written file. Racing writers only
|
||||
contend to be last; they never corrupt.
|
||||
"""
|
||||
runs_dir = self.runs_dir
|
||||
runs_dir.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
state_data = {
|
||||
"run_id": self.run_id,
|
||||
"workflow_id": self.workflow_id,
|
||||
"status": self.status.value,
|
||||
"current_step_index": self.current_step_index,
|
||||
"current_step_id": self.current_step_id,
|
||||
"step_results": self.step_results,
|
||||
"created_at": self.created_at,
|
||||
"updated_at": self.updated_at,
|
||||
}
|
||||
with open(runs_dir / "state.json", "w", encoding="utf-8") as f:
|
||||
json.dump(state_data, f, indent=2)
|
||||
with self._lock:
|
||||
# Stamp updated_at inside the lock so the timestamp matches the
|
||||
# snapshot this thread serializes (concurrent savers don't race it).
|
||||
self.updated_at = datetime.now(timezone.utc).isoformat()
|
||||
state_data = {
|
||||
"run_id": self.run_id,
|
||||
"workflow_id": self.workflow_id,
|
||||
"status": self.status.value,
|
||||
"current_step_index": self.current_step_index,
|
||||
"current_step_id": self.current_step_id,
|
||||
"step_results": self.step_results,
|
||||
"created_at": self.created_at,
|
||||
"updated_at": self.updated_at,
|
||||
}
|
||||
self._atomic_write_json(runs_dir / "state.json", state_data)
|
||||
self._atomic_write_json(runs_dir / "inputs.json", {"inputs": self.inputs})
|
||||
|
||||
inputs_data = {"inputs": self.inputs}
|
||||
with open(runs_dir / "inputs.json", "w", encoding="utf-8") as f:
|
||||
json.dump(inputs_data, f, indent=2)
|
||||
@staticmethod
|
||||
def _atomic_write_json(path: Path, data: dict[str, Any]) -> None:
|
||||
"""Write *data* as indented JSON to *path* atomically (temp + ``os.replace``)."""
|
||||
fd, tmp = tempfile.mkstemp(
|
||||
dir=str(path.parent), prefix=f".{path.name}.", suffix=".tmp"
|
||||
)
|
||||
try:
|
||||
with os.fdopen(fd, "w", encoding="utf-8") as f:
|
||||
json.dump(data, f, indent=2)
|
||||
os.replace(tmp, path)
|
||||
except BaseException:
|
||||
try:
|
||||
os.unlink(tmp)
|
||||
except OSError:
|
||||
pass
|
||||
raise
|
||||
|
||||
@classmethod
|
||||
def load(cls, run_id: str, project_root: Path) -> RunState:
|
||||
@@ -490,14 +547,18 @@ class RunState:
|
||||
return state
|
||||
|
||||
def append_log(self, entry: dict[str, Any]) -> None:
|
||||
"""Append a log entry to the run log."""
|
||||
entry["timestamp"] = datetime.now(timezone.utc).isoformat()
|
||||
self.log_entries.append(entry)
|
||||
"""Append a log entry to the run log.
|
||||
|
||||
Held under ``_log_lock`` so concurrent fan-out workers serialize their
|
||||
list append and ``log.jsonl`` write rather than interleaving lines.
|
||||
"""
|
||||
entry["timestamp"] = datetime.now(timezone.utc).isoformat()
|
||||
runs_dir = self.runs_dir
|
||||
runs_dir.mkdir(parents=True, exist_ok=True)
|
||||
with open(runs_dir / "log.jsonl", "a", encoding="utf-8") as f:
|
||||
f.write(json.dumps(entry) + "\n")
|
||||
with self._log_lock:
|
||||
self.log_entries.append(entry)
|
||||
with open(runs_dir / "log.jsonl", "a", encoding="utf-8") as f:
|
||||
f.write(json.dumps(entry) + "\n")
|
||||
|
||||
|
||||
# -- Workflow Engine ------------------------------------------------------
|
||||
@@ -509,6 +570,10 @@ class WorkflowEngine:
|
||||
def __init__(self, project_root: Path | None = None) -> None:
|
||||
self.project_root = project_root or Path(".")
|
||||
self.on_step_start: Any = None # Callable[[str, str], None] | None
|
||||
# Serializes on_step_start so a concurrent fan-out can't interleave the
|
||||
# callback's output (the CLI sets it to a console.print lambda). Uncontended
|
||||
# for sequential runs.
|
||||
self._callback_lock = threading.Lock()
|
||||
|
||||
def load_workflow(self, source: str | Path) -> WorkflowDefinition:
|
||||
"""Load a workflow from an installed ID or a local YAML path.
|
||||
@@ -712,6 +777,22 @@ class WorkflowEngine:
|
||||
state.save()
|
||||
return state
|
||||
|
||||
@staticmethod
|
||||
def _record_result(
|
||||
context: StepContext, state: RunState, step_id: str, data: dict[str, Any]
|
||||
) -> None:
|
||||
"""Record a step result into both the live context and persistent state.
|
||||
|
||||
``record_step_result`` writes ``state.step_results`` under the run lock.
|
||||
On a resume run ``context.steps`` *is* that same dict, so that locked
|
||||
write is the only one needed; mirror into ``context.steps`` separately
|
||||
only when it is a distinct object (a fresh run), to avoid an unlocked
|
||||
mutation of the shared dict that could race a concurrent ``save()``.
|
||||
"""
|
||||
if context.steps is not state.step_results:
|
||||
context.steps[step_id] = data
|
||||
state.record_step_result(step_id, data)
|
||||
|
||||
def _execute_steps(
|
||||
self,
|
||||
steps: list[dict[str, Any]],
|
||||
@@ -739,7 +820,8 @@ class WorkflowEngine:
|
||||
# otherwise stay silent (library-safe default).
|
||||
label = step_config.get("command", "") or step_type
|
||||
if self.on_step_start is not None:
|
||||
self.on_step_start(step_id, label)
|
||||
with self._callback_lock:
|
||||
self.on_step_start(step_id, label)
|
||||
|
||||
step_impl = registry.get(step_type)
|
||||
if not step_impl:
|
||||
@@ -772,8 +854,7 @@ class WorkflowEngine:
|
||||
"output": result.output,
|
||||
"status": result.status.value,
|
||||
}
|
||||
context.steps[step_id] = step_data
|
||||
state.step_results[step_id] = step_data
|
||||
self._record_result(context, state, step_id, step_data)
|
||||
|
||||
state.append_log(
|
||||
{
|
||||
@@ -900,40 +981,32 @@ class WorkflowEngine:
|
||||
):
|
||||
return
|
||||
if orig and ns_copy["id"] in context.steps:
|
||||
context.steps[orig] = context.steps[ns_copy["id"]]
|
||||
state.step_results[orig] = context.steps[ns_copy["id"]]
|
||||
self._record_result(
|
||||
context, state, orig,
|
||||
context.steps[ns_copy["id"]],
|
||||
)
|
||||
|
||||
# Fan-out: execute nested step template per item with unique IDs
|
||||
# Fan-out: execute the nested step template once per item. Honors
|
||||
# max_concurrency — <=1 runs sequentially (default, historical
|
||||
# behavior); >1 runs up to that many items concurrently. Either way
|
||||
# results are assembled in item order under the
|
||||
# parentId:templateId:index id grammar.
|
||||
if step_type == "fan-out":
|
||||
items = result.output.get("items", [])
|
||||
template = result.output.get("step_template", {})
|
||||
if template and items:
|
||||
fan_out_results = []
|
||||
for item_idx, item_val in enumerate(result.output["items"]):
|
||||
context.item = item_val
|
||||
# Per-item ID: parentId:templateId:index
|
||||
item_step = dict(template)
|
||||
base_id = item_step.get("id", "item")
|
||||
item_step["id"] = f"{step_id}:{base_id}:{item_idx}"
|
||||
self._execute_steps(
|
||||
[item_step], context, state, registry,
|
||||
step_offset=-1,
|
||||
)
|
||||
# Collect per-item result for fan-in
|
||||
item_result = context.steps.get(item_step["id"], {})
|
||||
fan_out_results.append(item_result.get("output", {}))
|
||||
if state.status in (
|
||||
RunStatus.PAUSED,
|
||||
RunStatus.FAILED,
|
||||
RunStatus.ABORTED,
|
||||
):
|
||||
break
|
||||
fan_out_results = self._run_fan_out(
|
||||
items, template, step_id, context, state, registry,
|
||||
result.output.get("max_concurrency", 1),
|
||||
)
|
||||
context.item = None
|
||||
# Preserve original output and add collected results
|
||||
fan_out_output = dict(result.output)
|
||||
fan_out_output["results"] = fan_out_results
|
||||
context.steps[step_id]["output"] = fan_out_output
|
||||
state.step_results[step_id]["output"] = fan_out_output
|
||||
# set_step_output updates the recorded dict under the run lock;
|
||||
# context.steps[step_id] is that same object, so it reflects the
|
||||
# change too — no separate (unlocked) context mutation needed.
|
||||
state.set_step_output(step_id, fan_out_output)
|
||||
if state.status in (
|
||||
RunStatus.PAUSED,
|
||||
RunStatus.FAILED,
|
||||
@@ -943,8 +1016,170 @@ class WorkflowEngine:
|
||||
else:
|
||||
# Empty items or no template — normalize output
|
||||
result.output["results"] = []
|
||||
context.steps[step_id]["output"] = result.output
|
||||
state.step_results[step_id]["output"] = result.output
|
||||
state.set_step_output(step_id, result.output)
|
||||
|
||||
def _run_fan_out(
|
||||
self,
|
||||
items: list[Any],
|
||||
template: dict[str, Any],
|
||||
step_id: str,
|
||||
context: StepContext,
|
||||
state: RunState,
|
||||
registry: dict[str, Any],
|
||||
max_concurrency: Any,
|
||||
) -> list[Any]:
|
||||
"""Run a fan-out template once per item; return per-item outputs in item order.
|
||||
|
||||
``max_concurrency`` <= 1 (the default) runs items sequentially, identical
|
||||
to the historical fan-out behavior. ``max_concurrency`` > 1 runs items on a
|
||||
bounded thread pool using a sliding submission window of that size: at most
|
||||
that many items are ever in flight, and no new item is launched once the run
|
||||
has reached a halting status, so a halt cannot keep starting queued work.
|
||||
|
||||
Results are always returned in item order (never completion order). On a
|
||||
halt (PAUSED/FAILED/ABORTED) the returned prefix is the items up to and
|
||||
including the first item *in item order* whose own execution halted the run
|
||||
— identical to the sequential path. Later items that have not yet started
|
||||
are cancelled; any already running are allowed to finish but their outputs
|
||||
are ignored. Halt is attributed per item from that item's recorded result
|
||||
(not the shared run status, which a concurrently-running later item may have
|
||||
already flipped), so the prefix never drops the actual halting item.
|
||||
|
||||
``max_concurrency`` is coerced with ``int()``; a value that cannot be
|
||||
coerced (``None``, a non-numeric string, …) or that coerces to <= 1 runs
|
||||
sequentially, while a numeric string like ``"4"`` or a float like ``4.0``
|
||||
is honored.
|
||||
"""
|
||||
if not items:
|
||||
return []
|
||||
|
||||
halting = (RunStatus.PAUSED, RunStatus.FAILED, RunStatus.ABORTED)
|
||||
try:
|
||||
workers = max(1, int(max_concurrency))
|
||||
except (TypeError, ValueError):
|
||||
workers = 1
|
||||
# Never spin up more workers than there is work — bounds a user-controlled
|
||||
# max_concurrency from over-allocating threads.
|
||||
workers = min(workers, len(items))
|
||||
|
||||
base_id = template.get("id", "item")
|
||||
|
||||
def item_id(idx: int) -> str:
|
||||
# Per-item ID grammar: parentId:templateId:index.
|
||||
return f"{step_id}:{base_id}:{idx}"
|
||||
|
||||
def run_item(idx: int, item_ctx: StepContext) -> Any:
|
||||
item_step = dict(template)
|
||||
item_step["id"] = item_id(idx)
|
||||
self._execute_steps(
|
||||
[item_step], item_ctx, state, registry, step_offset=-1,
|
||||
)
|
||||
# Read back through the context that was actually executed against,
|
||||
# not the outer closure — clearer and robust if StepContext copying
|
||||
# ever stops sharing the steps dict by reference.
|
||||
return item_ctx.steps.get(item_step["id"], {}).get("output", {})
|
||||
|
||||
# Sequential path — identical to the historical behavior.
|
||||
if workers <= 1:
|
||||
results: list[Any] = []
|
||||
for item_idx, item_val in enumerate(items):
|
||||
context.item = item_val
|
||||
results.append(run_item(item_idx, context))
|
||||
if state.status in halting:
|
||||
break
|
||||
return results
|
||||
|
||||
# Concurrent path — bounded sliding window; results assembled in item order.
|
||||
n = len(items)
|
||||
slots: list[Any] = [None] * n
|
||||
|
||||
def run_isolated(idx: int) -> Any:
|
||||
# Each item runs against its own context copy so context.item is not
|
||||
# clobbered across threads; the shared steps dict is written only on the
|
||||
# disjoint parentId:templateId:index key (GIL-safe on distinct keys).
|
||||
return run_item(idx, dataclasses.replace(context, item=items[idx]))
|
||||
|
||||
def item_halt_status(idx: int) -> RunStatus | None:
|
||||
# If THIS item's own execution halted the run, return the resulting run
|
||||
# status; else None. Decided from the item's own recorded result, not
|
||||
# the shared run status, so a later item's concurrent halt is never
|
||||
# misattributed here. Mirrors the sequential mapping: PAUSED -> PAUSED;
|
||||
# FAILED -> ABORTED when aborted, else FAILED, unless continue_on_error
|
||||
# routes around it.
|
||||
rec = context.steps.get(item_id(idx))
|
||||
if rec is None:
|
||||
# Ran but recorded nothing — only when the item failed before
|
||||
# record_step_result (e.g. an unknown step type returns early).
|
||||
# Every item runs the same template, so the shared run status is
|
||||
# this item's own outcome; attribute the halt to it.
|
||||
return state.status if state.status in halting else None
|
||||
status = rec.get("status")
|
||||
if status == StepStatus.PAUSED.value:
|
||||
return RunStatus.PAUSED
|
||||
if status == StepStatus.FAILED.value:
|
||||
out = rec.get("output") or {}
|
||||
if out.get("aborted"):
|
||||
return RunStatus.ABORTED
|
||||
if template.get("continue_on_error") is not True:
|
||||
return RunStatus.FAILED
|
||||
return None
|
||||
|
||||
# (halting item index, its run status) once a halt is attributed.
|
||||
halt: tuple[int, RunStatus] | None = None
|
||||
collected = 0
|
||||
with ThreadPoolExecutor(max_workers=workers) as pool:
|
||||
futures: dict[int, Future] = {}
|
||||
next_submit = 0
|
||||
for idx in range(n):
|
||||
# Refill the window: keep <= workers in flight, and stop launching
|
||||
# new items once the run is halting so a halt cannot keep starting
|
||||
# queued work. Already-submitted futures are still collected in
|
||||
# item order below.
|
||||
while (
|
||||
next_submit < n
|
||||
and len(futures) < workers
|
||||
and state.status not in halting
|
||||
):
|
||||
futures[next_submit] = pool.submit(run_isolated, next_submit)
|
||||
next_submit += 1
|
||||
|
||||
fut = futures.pop(idx, None)
|
||||
if fut is None:
|
||||
# Safety net: the window submits indices in order and the loop
|
||||
# breaks at the first halting item, so every collected index has
|
||||
# an in-flight future. Stop cleanly rather than raise if a future
|
||||
# change ever breaks that invariant.
|
||||
break
|
||||
try:
|
||||
slots[idx] = fut.result()
|
||||
except Exception:
|
||||
# A genuine exception escaping a step (not a normal step
|
||||
# FAILED, which sets state.status) must not be masked: cancel
|
||||
# outstanding work and re-raise — with a bare ``raise`` so the
|
||||
# original traceback is preserved — so the engine marks the run
|
||||
# failed instead of reporting a vacuous completion. The pool's
|
||||
# __exit__ still joins any already-running workers.
|
||||
for other in futures.values():
|
||||
other.cancel()
|
||||
raise
|
||||
collected = idx + 1
|
||||
halt_status = item_halt_status(idx)
|
||||
if halt_status is not None:
|
||||
# First halting item in item order: include it (slots[idx] is
|
||||
# already set), record its status, and cancel everything pending.
|
||||
halt = (idx, halt_status)
|
||||
for other in futures.values():
|
||||
other.cancel()
|
||||
break
|
||||
|
||||
if halt is not None:
|
||||
halted_at, halted_status = halt
|
||||
# A later in-flight item may have overwritten state.status before the
|
||||
# pool joined; restore the halting item's own outcome so the final run
|
||||
# status matches the sequential semantics.
|
||||
state.status = halted_status
|
||||
return slots[: halted_at + 1]
|
||||
return slots[:collected]
|
||||
|
||||
def _resolve_inputs(
|
||||
self,
|
||||
|
||||
@@ -62,6 +62,21 @@ def test_commands_outside_project_fail_with_guidance(tmp_path: Path, monkeypatch
|
||||
assert "Spec Kit project" in result.output
|
||||
|
||||
|
||||
def test_fail_writes_error_to_stderr_not_stdout(capsys):
|
||||
"""_fail must write to stderr, not stdout: every bundle command routes errors
|
||||
through it, and under --json the error would otherwise corrupt the JSON payload
|
||||
that consumers read from stdout."""
|
||||
import typer
|
||||
|
||||
from specify_cli.commands.bundle import _fail
|
||||
|
||||
with pytest.raises(typer.Exit):
|
||||
_fail("something broke")
|
||||
captured = capsys.readouterr()
|
||||
assert "something broke" in captured.err
|
||||
assert "something broke" not in captured.out
|
||||
|
||||
|
||||
def test_search_works_without_a_project(tmp_path: Path, monkeypatch):
|
||||
# Discovery commands fall back to the built-in/user catalog stack and must
|
||||
# not require a Spec Kit project (matches README/quickstart examples).
|
||||
|
||||
@@ -233,6 +233,10 @@ class TestInitializeRepoBash:
|
||||
result = _run_bash("initialize-repo.sh", project)
|
||||
assert result.returncode == 0, result.stderr
|
||||
|
||||
# Success marker is the full ASCII "[OK] ..." line (matching the PowerShell
|
||||
# twin and the sibling auto-commit scripts), not a Unicode checkmark.
|
||||
assert "[OK] Git repository initialized" in result.stderr, result.stderr
|
||||
|
||||
# Verify git repo exists
|
||||
assert (project / ".git").exists()
|
||||
|
||||
|
||||
@@ -70,16 +70,17 @@ class TestCatalogURLValidation:
|
||||
@pytest.mark.parametrize(
|
||||
"url",
|
||||
[
|
||||
"https://:8080", # port only, no host
|
||||
"https://:0", # port only, no host
|
||||
"https://user@", # userinfo only, no host
|
||||
"https://user:pw@", # userinfo only, no host
|
||||
"https://:8080", # port only, no host
|
||||
"https://:8080/catalog.json", # port only, with path
|
||||
"https://:0", # port only, no host
|
||||
"https://user@", # userinfo only, no host
|
||||
"https://user:pass@", # userinfo only, no host
|
||||
],
|
||||
)
|
||||
def test_hostless_url_with_truthy_netloc_rejected(self, url):
|
||||
# These have a truthy netloc (":8080", "user@") but no actual host,
|
||||
# so a netloc-based check would wrongly accept them despite the
|
||||
# "valid URL with a host" promise. hostname is None for all of them.
|
||||
# "valid URL with a host" promise. hostname is None for all of them (#3209).
|
||||
with pytest.raises(IntegrationCatalogError, match="valid URL"):
|
||||
IntegrationCatalog._validate_catalog_url(url)
|
||||
|
||||
|
||||
@@ -28,7 +28,7 @@ class TestDevinBuildExecArgs:
|
||||
assert args is not None, (
|
||||
"DevinIntegration.build_exec_args must not return None. "
|
||||
"None is the codebase sentinel for IDE-only integrations "
|
||||
"(see WindsurfIntegration); Devin is dispatchable via 'devin -p'."
|
||||
"(see KilocodeIntegration); Devin is dispatchable via 'devin -p'."
|
||||
)
|
||||
assert args[:3] == ["devin", "-p", "test prompt"]
|
||||
|
||||
|
||||
@@ -403,7 +403,7 @@ class TestForgeCommandRegistrar:
|
||||
encoding="utf-8"
|
||||
)
|
||||
|
||||
# Register with Windsurf (standard markdown agent without inject_name)
|
||||
# Register with Kilo Code (standard markdown agent without inject_name)
|
||||
registrar = CommandRegistrar()
|
||||
commands = [
|
||||
{
|
||||
@@ -413,22 +413,22 @@ class TestForgeCommandRegistrar:
|
||||
]
|
||||
|
||||
registrar.register_commands(
|
||||
"windsurf",
|
||||
"kilocode",
|
||||
commands,
|
||||
"test-extension",
|
||||
ext_dir,
|
||||
tmp_path
|
||||
)
|
||||
|
||||
# Windsurf uses standard markdown format without name injection.
|
||||
# Kilo Code uses standard markdown format without name injection.
|
||||
# The format_name callback should not be invoked for non-Forge agents.
|
||||
windsurf_cmd = tmp_path / ".windsurf" / "workflows" / "speckit.my-extension.example.md"
|
||||
assert windsurf_cmd.exists()
|
||||
kilocode_cmd = tmp_path / ".kilocode" / "workflows" / "speckit.my-extension.example.md"
|
||||
assert kilocode_cmd.exists()
|
||||
|
||||
content = windsurf_cmd.read_text(encoding="utf-8")
|
||||
# Windsurf should NOT have a name field injected
|
||||
content = kilocode_cmd.read_text(encoding="utf-8")
|
||||
# Kilo Code should NOT have a name field injected
|
||||
assert "name:" not in content, (
|
||||
"Windsurf should not inject name field - format_name callback should be Forge-only"
|
||||
"Kilo Code should not inject name field - format_name callback should be Forge-only"
|
||||
)
|
||||
|
||||
def test_git_extension_command_uses_hyphen_notation(self, tmp_path):
|
||||
|
||||
@@ -1,10 +0,0 @@
|
||||
"""Tests for IflowIntegration."""
|
||||
|
||||
from .test_integration_base_markdown import MarkdownIntegrationTests
|
||||
|
||||
|
||||
class TestIflowIntegration(MarkdownIntegrationTests):
|
||||
KEY = "iflow"
|
||||
FOLDER = ".iflow/"
|
||||
COMMANDS_SUBDIR = "commands"
|
||||
REGISTRAR_DIR = ".iflow/commands"
|
||||
@@ -1,10 +0,0 @@
|
||||
"""Tests for WindsurfIntegration."""
|
||||
|
||||
from .test_integration_base_markdown import MarkdownIntegrationTests
|
||||
|
||||
|
||||
class TestWindsurfIntegration(MarkdownIntegrationTests):
|
||||
KEY = "windsurf"
|
||||
FOLDER = ".windsurf/"
|
||||
COMMANDS_SUBDIR = "workflows"
|
||||
REGISTRAR_DIR = ".windsurf/workflows"
|
||||
@@ -23,7 +23,7 @@ ALL_INTEGRATION_KEYS = [
|
||||
# Stage 3 — standard markdown integrations
|
||||
"claude", "qwen", "opencode", "junie", "kilocode", "auggie",
|
||||
"roo", "rovodev", "codebuddy", "qodercli", "amp", "shai", "bob", "trae",
|
||||
"pi", "iflow", "kiro-cli", "windsurf", "vibe", "cursor-agent", "firebender",
|
||||
"pi", "kiro-cli", "vibe", "cursor-agent", "firebender",
|
||||
# Stage 4 — TOML integrations
|
||||
"gemini", "tabnine",
|
||||
# Stage 5 — skills, generic & option-driven integrations
|
||||
|
||||
@@ -27,7 +27,6 @@ ISSUE_TEMPLATE_AGENT_KEYS = [
|
||||
"goose",
|
||||
"hermes",
|
||||
"bob",
|
||||
"iflow",
|
||||
"junie",
|
||||
"kilocode",
|
||||
"kimi",
|
||||
@@ -44,7 +43,6 @@ ISSUE_TEMPLATE_AGENT_KEYS = [
|
||||
"shai",
|
||||
"tabnine",
|
||||
"trae",
|
||||
"windsurf",
|
||||
"zcode",
|
||||
"zed",
|
||||
]
|
||||
@@ -292,28 +290,6 @@ class TestAgentConfigConsistency:
|
||||
"""AGENT_CONFIG should include pi."""
|
||||
assert "pi" in AGENT_CONFIG
|
||||
|
||||
# --- iFlow CLI consistency checks ---
|
||||
|
||||
def test_iflow_in_agent_config(self):
|
||||
"""AGENT_CONFIG should include iflow with correct folder and commands_subdir."""
|
||||
assert "iflow" in AGENT_CONFIG
|
||||
assert AGENT_CONFIG["iflow"]["folder"] == ".iflow/"
|
||||
assert AGENT_CONFIG["iflow"]["commands_subdir"] == "commands"
|
||||
assert AGENT_CONFIG["iflow"]["requires_cli"] is True
|
||||
|
||||
def test_iflow_in_extension_registrar(self):
|
||||
"""Extension command registrar should include iflow targeting .iflow/commands."""
|
||||
cfg = CommandRegistrar.AGENT_CONFIGS
|
||||
|
||||
assert "iflow" in cfg
|
||||
assert cfg["iflow"]["dir"] == ".iflow/commands"
|
||||
assert cfg["iflow"]["format"] == "markdown"
|
||||
assert cfg["iflow"]["args"] == "$ARGUMENTS"
|
||||
|
||||
def test_agent_config_includes_iflow(self):
|
||||
"""AGENT_CONFIG should include iflow."""
|
||||
assert "iflow" in AGENT_CONFIG
|
||||
|
||||
# --- Goose consistency checks ---
|
||||
|
||||
def test_goose_in_agent_config(self):
|
||||
|
||||
@@ -163,6 +163,66 @@ def test_normal_mode_still_validates_branch(prereq_repo: Path) -> None:
|
||||
assert result.stdout.strip() == ""
|
||||
|
||||
|
||||
@requires_bash
|
||||
def test_paths_only_does_not_persist_feature_json(prereq_repo: Path) -> None:
|
||||
"""--paths-only must not rewrite feature.json even when the env override
|
||||
differs from the pinned value (#3025).
|
||||
|
||||
Path resolution is read-only, so it must never dirty the working tree or
|
||||
overwrite the persisted feature directory.
|
||||
"""
|
||||
pinned = "specs/001-my-feature"
|
||||
(prereq_repo / "specs" / "001-my-feature").mkdir(parents=True, exist_ok=True)
|
||||
(prereq_repo / "specs" / "002-other").mkdir(parents=True, exist_ok=True)
|
||||
_write_feature_json(prereq_repo, pinned)
|
||||
fj = prereq_repo / ".specify" / "feature.json"
|
||||
before = fj.read_text(encoding="utf-8")
|
||||
|
||||
script = prereq_repo / ".specify" / "scripts" / "bash" / "check-prerequisites.sh"
|
||||
env = _clean_env()
|
||||
env["SPECIFY_FEATURE_DIRECTORY"] = "specs/002-other"
|
||||
result = subprocess.run(
|
||||
["bash", str(script), "--json", "--paths-only"],
|
||||
cwd=prereq_repo,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
check=False,
|
||||
env=env,
|
||||
)
|
||||
assert result.returncode == 0, result.stderr
|
||||
# The override is honored in the output...
|
||||
data = json.loads(result.stdout)
|
||||
assert "002-other" in data["FEATURE_DIR"]
|
||||
# ...but the pinned file on disk is untouched.
|
||||
assert fj.read_text(encoding="utf-8") == before
|
||||
|
||||
|
||||
@requires_bash
|
||||
def test_normal_mode_still_persists_feature_json(prereq_repo: Path) -> None:
|
||||
"""Without --paths-only, the env override is still persisted to feature.json,
|
||||
so the --no-persist opt-out does not regress normal write behavior (#3025)."""
|
||||
(prereq_repo / "specs" / "001-my-feature").mkdir(parents=True, exist_ok=True)
|
||||
feat = prereq_repo / "specs" / "002-other"
|
||||
feat.mkdir(parents=True, exist_ok=True)
|
||||
(feat / "plan.md").write_text("# plan\n", encoding="utf-8")
|
||||
_write_feature_json(prereq_repo, "specs/001-my-feature")
|
||||
fj = prereq_repo / ".specify" / "feature.json"
|
||||
|
||||
script = prereq_repo / ".specify" / "scripts" / "bash" / "check-prerequisites.sh"
|
||||
env = _clean_env()
|
||||
env["SPECIFY_FEATURE_DIRECTORY"] = "specs/002-other"
|
||||
result = subprocess.run(
|
||||
["bash", str(script), "--json"],
|
||||
cwd=prereq_repo,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
check=False,
|
||||
env=env,
|
||||
)
|
||||
assert result.returncode == 0, result.stderr
|
||||
assert json.loads(fj.read_text(encoding="utf-8"))["feature_directory"] == "specs/002-other"
|
||||
|
||||
|
||||
# ── PowerShell tests ──────────────────────────────────────────────────────
|
||||
|
||||
|
||||
@@ -283,3 +343,64 @@ def test_ps_missing_tasks_error_goes_to_stderr(prereq_repo: Path) -> None:
|
||||
assert "tasks.md not found" in result.stderr
|
||||
assert "tasks.md not found" not in result.stdout
|
||||
assert result.stdout.strip() == ""
|
||||
|
||||
|
||||
@pytest.mark.skipif(not (HAS_PWSH or _WINDOWS_POWERSHELL), reason="no PowerShell available")
|
||||
def test_ps_paths_only_does_not_persist_feature_json(prereq_repo: Path) -> None:
|
||||
"""-PathsOnly must not rewrite feature.json even when the env override
|
||||
differs from the pinned value (#3025)."""
|
||||
pinned = "specs/001-my-feature"
|
||||
(prereq_repo / "specs" / "001-my-feature").mkdir(parents=True, exist_ok=True)
|
||||
(prereq_repo / "specs" / "002-other").mkdir(parents=True, exist_ok=True)
|
||||
_write_feature_json(prereq_repo, pinned)
|
||||
fj = prereq_repo / ".specify" / "feature.json"
|
||||
before = fj.read_text(encoding="utf-8")
|
||||
|
||||
script = prereq_repo / ".specify" / "scripts" / "powershell" / "check-prerequisites.ps1"
|
||||
exe = "pwsh" if HAS_PWSH else _WINDOWS_POWERSHELL
|
||||
env = _clean_env()
|
||||
env["SPECIFY_FEATURE_DIRECTORY"] = "specs/002-other"
|
||||
result = subprocess.run(
|
||||
[exe, "-NoProfile", "-File", str(script), "-Json", "-PathsOnly"],
|
||||
cwd=prereq_repo,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
check=False,
|
||||
env=env,
|
||||
)
|
||||
assert result.returncode == 0, result.stderr
|
||||
data = json.loads(result.stdout)
|
||||
assert "002-other" in data["FEATURE_DIR"]
|
||||
assert fj.read_text(encoding="utf-8") == before
|
||||
|
||||
|
||||
@pytest.mark.skipif(not (HAS_PWSH or _WINDOWS_POWERSHELL), reason="no PowerShell available")
|
||||
def test_ps_normal_mode_still_persists_feature_json(prereq_repo: Path) -> None:
|
||||
"""Without -PathsOnly, the env override is still persisted to feature.json,
|
||||
so the -NoPersist opt-out does not regress normal write behavior (#3025).
|
||||
|
||||
Symmetric to the bash test_normal_mode_still_persists_feature_json guard:
|
||||
asserts the default path still persists and that -NoPersist is not passed
|
||||
unconditionally.
|
||||
"""
|
||||
(prereq_repo / "specs" / "001-my-feature").mkdir(parents=True, exist_ok=True)
|
||||
feat = prereq_repo / "specs" / "002-other"
|
||||
feat.mkdir(parents=True, exist_ok=True)
|
||||
(feat / "plan.md").write_text("# plan\n", encoding="utf-8")
|
||||
_write_feature_json(prereq_repo, "specs/001-my-feature")
|
||||
fj = prereq_repo / ".specify" / "feature.json"
|
||||
|
||||
script = prereq_repo / ".specify" / "scripts" / "powershell" / "check-prerequisites.ps1"
|
||||
exe = "pwsh" if HAS_PWSH else _WINDOWS_POWERSHELL
|
||||
env = _clean_env()
|
||||
env["SPECIFY_FEATURE_DIRECTORY"] = "specs/002-other"
|
||||
result = subprocess.run(
|
||||
[exe, "-NoProfile", "-File", str(script), "-Json"],
|
||||
cwd=prereq_repo,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
check=False,
|
||||
env=env,
|
||||
)
|
||||
assert result.returncode == 0, result.stderr
|
||||
assert json.loads(fj.read_text(encoding="utf-8"))["feature_directory"] == "specs/002-other"
|
||||
|
||||
@@ -1427,14 +1427,15 @@ class TestPresetCatalog:
|
||||
@pytest.mark.parametrize(
|
||||
"url",
|
||||
[
|
||||
"https://:8080", # port only, no host
|
||||
"https://:0", # port only, no host
|
||||
"https://user@", # userinfo only, no host
|
||||
"https://user:pw@", # userinfo only, no host
|
||||
"https://:8080", # port only, no host
|
||||
"https://:8080/catalog.json", # port only, with path
|
||||
"https://:0", # port only, no host
|
||||
"https://user@", # userinfo only, no host
|
||||
"https://user:pass@", # userinfo only, no host
|
||||
],
|
||||
)
|
||||
def test_validate_catalog_url_hostless_rejected(self, project_dir, url):
|
||||
"""Reject host-less URLs whose netloc is truthy but hostname is None.
|
||||
"""Reject host-less URLs whose netloc is truthy but hostname is None (#3209).
|
||||
|
||||
``urlparse('https://:8080').netloc`` is ``':8080'`` (truthy) but its
|
||||
``hostname`` is ``None``, so a netloc-based check would accept a URL
|
||||
|
||||
@@ -246,3 +246,27 @@ def test_ps_setup_plan_copied_message_on_stderr_in_json_mode(plan_repo: Path) ->
|
||||
data = json.loads(result.stdout)
|
||||
assert "IMPL_PLAN" in data
|
||||
assert "Copied plan template" in result.stderr
|
||||
|
||||
|
||||
@pytest.mark.skipif(not (HAS_PWSH or _WINDOWS_POWERSHELL), reason="no PowerShell available")
|
||||
def test_ps_setup_plan_template_not_found_warning_matches_bash(plan_repo: Path) -> None:
|
||||
"""When no plan template resolves, -Json mode must emit 'Warning: Plan template
|
||||
not found' on stderr (matching the bash twin's wording and stream routing) while
|
||||
keeping stdout pure JSON. Before the fix the PowerShell script used Write-Warning,
|
||||
producing a different 'WARNING:' prefix on the warning stream instead."""
|
||||
# Remove the template the fixture installs so resolution finds nothing.
|
||||
(plan_repo / ".specify" / "templates" / "plan-template.md").unlink()
|
||||
script = plan_repo / ".specify" / "scripts" / "powershell" / "setup-plan.ps1"
|
||||
exe = "pwsh" if HAS_PWSH else _WINDOWS_POWERSHELL
|
||||
result = subprocess.run(
|
||||
[exe, "-NoProfile", "-File", str(script), "-Json"],
|
||||
cwd=plan_repo,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
check=False,
|
||||
env=_clean_env(),
|
||||
)
|
||||
assert result.returncode == 0, result.stderr
|
||||
data = json.loads(result.stdout)
|
||||
assert "IMPL_PLAN" in data
|
||||
assert "Warning: Plan template not found" in result.stderr
|
||||
|
||||
@@ -650,8 +650,8 @@ class TestBuildExecArgs:
|
||||
assert "--yolo" in args
|
||||
|
||||
def test_ide_only_returns_none(self):
|
||||
from specify_cli.integrations.windsurf import WindsurfIntegration
|
||||
impl = WindsurfIntegration()
|
||||
from specify_cli.integrations.kilocode import KilocodeIntegration
|
||||
impl = KilocodeIntegration()
|
||||
assert impl.build_exec_args("test") is None
|
||||
|
||||
def test_no_model_omits_flag(self):
|
||||
@@ -2045,6 +2045,210 @@ class TestFanInStep:
|
||||
assert any("non-empty list" in e for e in errors)
|
||||
|
||||
|
||||
class TestFanOutConcurrency:
|
||||
"""Fan-out honors max_concurrency (WorkflowEngine._run_fan_out)."""
|
||||
|
||||
@staticmethod
|
||||
def _build(tmp_path, on_item=None):
|
||||
"""Wire an engine + run state to a probe step that echoes context.item.
|
||||
|
||||
Per-item output is ``{"seen": <item>}`` so order and per-thread item
|
||||
isolation are checkable. ``on_item(item)`` may run a side effect and
|
||||
optionally return a StepStatus to override COMPLETED (or raise).
|
||||
"""
|
||||
from specify_cli.workflows.base import (
|
||||
RunStatus,
|
||||
StepBase,
|
||||
StepContext,
|
||||
StepResult,
|
||||
StepStatus,
|
||||
)
|
||||
from specify_cli.workflows.engine import RunState, WorkflowEngine
|
||||
|
||||
class _ProbeStep(StepBase):
|
||||
type_key = "probe"
|
||||
|
||||
def execute(self, config, context):
|
||||
status = StepStatus.COMPLETED
|
||||
if on_item is not None:
|
||||
override = on_item(context.item)
|
||||
if override is not None:
|
||||
status = override
|
||||
return StepResult(status=status, output={"seen": context.item})
|
||||
|
||||
engine = WorkflowEngine(project_root=tmp_path)
|
||||
context = StepContext()
|
||||
state = RunState(run_id="r", workflow_id="w", project_root=tmp_path)
|
||||
state.status = RunStatus.RUNNING
|
||||
template = {"id": "impl", "type": "probe"}
|
||||
return engine, context, state, {"probe": _ProbeStep()}, template
|
||||
|
||||
def _run(self, tmp_path, items, max_concurrency, on_item=None):
|
||||
engine, context, state, registry, template = self._build(tmp_path, on_item)
|
||||
results = engine._run_fan_out(
|
||||
items, template, "fan", context, state, registry, max_concurrency
|
||||
)
|
||||
return results, state
|
||||
|
||||
def test_sequential_default_preserves_order(self, tmp_path):
|
||||
results, _ = self._run(tmp_path, list(range(5)), 1)
|
||||
assert results == [{"seen": i} for i in range(5)]
|
||||
|
||||
def test_concurrent_runs_all_items_in_item_order(self, tmp_path):
|
||||
results, _ = self._run(tmp_path, list(range(10)), 4)
|
||||
assert results == [{"seen": i} for i in range(10)]
|
||||
|
||||
def test_sequential_and_concurrent_agree(self, tmp_path):
|
||||
items = [{"n": i} for i in range(8)]
|
||||
seq, _ = self._run(tmp_path, items, 1)
|
||||
con, _ = self._run(tmp_path, items, 4)
|
||||
assert seq == con == [{"seen": {"n": i}} for i in range(8)]
|
||||
|
||||
def test_shuffled_completion_preserves_item_order(self, tmp_path):
|
||||
# Determinism keystone: completion order is forced to the exact REVERSE of
|
||||
# item order by an event chain (no sleeps) — item i blocks until item i+1
|
||||
# has finished, so item 0 completes LAST — yet results must still be in
|
||||
# item order. K == len(items) so all workers are in flight together.
|
||||
import threading
|
||||
|
||||
n = 4
|
||||
done = [threading.Event() for _ in range(n)]
|
||||
completion: list[int] = []
|
||||
clock = threading.Lock()
|
||||
|
||||
def on_item(item):
|
||||
if item + 1 < n:
|
||||
assert done[item + 1].wait(2.0), f"item {item + 1} never finished"
|
||||
with clock:
|
||||
completion.append(item)
|
||||
done[item].set()
|
||||
return None
|
||||
|
||||
results, _ = self._run(tmp_path, list(range(n)), n, on_item)
|
||||
assert results == [{"seen": i} for i in range(n)]
|
||||
assert completion == list(reversed(range(n)))
|
||||
|
||||
def test_concurrency_is_real(self, tmp_path):
|
||||
import threading
|
||||
|
||||
# Deterministic proof of real parallelism (no wall-clock threshold to
|
||||
# tune or flake): every item must reach the barrier before any may pass.
|
||||
# Sequential execution would block the first item forever — the barrier
|
||||
# times out, raises BrokenBarrierError, and fails the test.
|
||||
n = 4
|
||||
barrier = threading.Barrier(n, timeout=5)
|
||||
|
||||
def on_item(item):
|
||||
barrier.wait()
|
||||
return None
|
||||
|
||||
results, _ = self._run(tmp_path, list(range(n)), n, on_item)
|
||||
assert results == [{"seen": i} for i in range(n)]
|
||||
|
||||
@pytest.mark.parametrize("bad", [0, -1, None, "abc", 1.0])
|
||||
def test_invalid_max_concurrency_coerces_to_sequential(self, tmp_path, bad):
|
||||
results, _ = self._run(tmp_path, list(range(4)), bad)
|
||||
assert results == [{"seen": i} for i in range(4)]
|
||||
|
||||
def test_string_max_concurrency_is_honored(self, tmp_path):
|
||||
results, _ = self._run(tmp_path, list(range(4)), "2")
|
||||
assert results == [{"seen": i} for i in range(4)]
|
||||
|
||||
def test_context_item_isolation_across_threads(self, tmp_path):
|
||||
items = [{"id": f"x{i}"} for i in range(6)]
|
||||
results, _ = self._run(tmp_path, items, 6)
|
||||
assert [r["seen"]["id"] for r in results] == [f"x{i}" for i in range(6)]
|
||||
|
||||
def test_empty_items(self, tmp_path):
|
||||
results, _ = self._run(tmp_path, [], 4)
|
||||
assert results == []
|
||||
|
||||
def test_concurrent_halt_status_not_clobbered_by_later_item(self, tmp_path):
|
||||
# Item 1 PAUSES (first halting item in order); item 3 FAILS while in
|
||||
# flight. The final run status must be the halting item's (PAUSED), never
|
||||
# a later item's (FAILED) that raced after it — matching sequential.
|
||||
from specify_cli.workflows.base import RunStatus, StepStatus
|
||||
|
||||
def on_item(item):
|
||||
if item == 1:
|
||||
return StepStatus.PAUSED
|
||||
if item == 3:
|
||||
return StepStatus.FAILED
|
||||
return None
|
||||
|
||||
results, state = self._run(tmp_path, list(range(4)), 4, on_item)
|
||||
assert results == [{"seen": 0}, {"seen": 1}]
|
||||
assert state.status == RunStatus.PAUSED
|
||||
|
||||
def test_halt_on_failure_sequential_returns_prefix(self, tmp_path):
|
||||
from specify_cli.workflows.base import RunStatus, StepStatus
|
||||
|
||||
def on_item(item):
|
||||
return StepStatus.FAILED if item == 2 else None
|
||||
|
||||
results, state = self._run(tmp_path, list(range(5)), 1, on_item)
|
||||
assert len(results) == 3 # items 0,1,2 ran; 3,4 never dispatched
|
||||
assert results[2] == {"seen": 2}
|
||||
assert state.status == RunStatus.FAILED
|
||||
|
||||
def test_halt_on_failure_concurrent_includes_halting_item(self, tmp_path):
|
||||
# The concurrent prefix must match the sequential one: items up to and
|
||||
# INCLUDING the failing item (2), never a short prefix that drops it just
|
||||
# because a later in-flight item flipped the shared run status first.
|
||||
from specify_cli.workflows.base import RunStatus, StepStatus
|
||||
|
||||
def on_item(item):
|
||||
return StepStatus.FAILED if item == 2 else None
|
||||
|
||||
results, state = self._run(tmp_path, list(range(6)), 4, on_item)
|
||||
assert results == [{"seen": 0}, {"seen": 1}, {"seen": 2}]
|
||||
assert state.status == RunStatus.FAILED
|
||||
|
||||
def test_continue_on_error_item_does_not_halt_concurrent(self, tmp_path):
|
||||
# A failing item whose template sets continue_on_error must NOT truncate
|
||||
# the fan-out: every item still runs and is returned in order.
|
||||
from specify_cli.workflows.base import StepStatus
|
||||
|
||||
def on_item(item):
|
||||
return StepStatus.FAILED if item == 2 else None
|
||||
|
||||
engine, context, state, registry, template = self._build(tmp_path, on_item)
|
||||
template["continue_on_error"] = True
|
||||
results = engine._run_fan_out(
|
||||
list(range(5)), template, "fan", context, state, registry, 4
|
||||
)
|
||||
assert results == [{"seen": i} for i in range(5)]
|
||||
|
||||
def test_unknown_template_type_halts_concurrent_like_sequential(self, tmp_path):
|
||||
# A template whose type isn't registered fails fast and records no result;
|
||||
# the concurrent path must still attribute the halt to the first item and
|
||||
# return the same prefix as sequential — never run on as if completed.
|
||||
from specify_cli.workflows.base import RunStatus, StepContext
|
||||
from specify_cli.workflows.engine import RunState, WorkflowEngine
|
||||
|
||||
def fresh():
|
||||
state = RunState(run_id="r", workflow_id="w", project_root=tmp_path)
|
||||
state.status = RunStatus.RUNNING
|
||||
return WorkflowEngine(project_root=tmp_path), StepContext(), state
|
||||
|
||||
template = {"id": "impl", "type": "does-not-exist"}
|
||||
e1, c1, s1 = fresh()
|
||||
seq = e1._run_fan_out(list(range(5)), template, "fan", c1, s1, {}, 1)
|
||||
e2, c2, s2 = fresh()
|
||||
con = e2._run_fan_out(list(range(5)), template, "fan", c2, s2, {}, 4)
|
||||
assert seq == con == [{}] # halted at the first item; rest never returned
|
||||
assert s1.status == s2.status == RunStatus.FAILED
|
||||
|
||||
def test_first_exception_cancels_and_reraises(self, tmp_path):
|
||||
def on_item(item):
|
||||
if item == 0:
|
||||
raise ValueError("boom")
|
||||
return None
|
||||
|
||||
with pytest.raises(ValueError, match="boom"):
|
||||
self._run(tmp_path, list(range(4)), 2, on_item)
|
||||
|
||||
|
||||
class TestFanInWaitForValidation:
|
||||
"""fan-in wait_for must reference a declared step (no silent empty join)."""
|
||||
|
||||
|
||||
Reference in New Issue
Block a user