Compare commits

...

12 Commits

Author SHA1 Message Date
github-actions[bot]
25c1ce48bb chore: bump version to 0.12.2 2026-06-30 14:36:52 +00:00
Pascal THUET
86709f6089 fix(scripts): portable uppercase for branch-name acronym retention (bash 3.2) (#3192)
* fix(scripts): portable uppercase for branch-name acronym retention

Branch-name generation keeps short uppercase acronyms (e.g. "AI") by re-checking
the lowercased word against the original description with ${word^^}. That
parameter expansion is bash 4+ only; on macOS's default bash 3.2 it errors with
"bad substitution", so the acronym/short-word retention branch never matches and
those words are dropped ("go AI now" yields 001-now instead of 001-ai-now). Use
tr '[:lower:]' '[:upper:]' instead, which is portable.

Applies to both the core create-new-feature.sh and the git extension's
create-new-feature-branch.sh. The existing
test_branch_name_short_word_case_sensitivity / test_short_word_retention tests
cover this and now pass on bash 3.2 (CI runs on bash 4+/Linux, so they passed
there already).

(Disclosure: an AI coding agent surfaced the failure while running the suite on
macOS and pinned the root cause; fix written and reviewed by me.)

* fix(scripts): portability follow-ups from code review

- core create-new-feature.sh: match the acronym with `grep -qw` (POSIX
  whole-word) instead of `\b...\b` (GNU/BSD-only), matching the git extension
  and dropping a non-POSIX construct.
- lint: add a CI guard rejecting bash 4+ case-modification expansions in *.sh.
  shellcheck assumes bash 4+ from the shebang and can't flag them, and CI has no
  bash-3.2 lane, so this prevents silently re-shipping the macOS regression this
  PR fixes.
- update a stale PowerShell extension comment that cited the removed bash idiom.

(Disclosure: prompted by an AI code review of the PR; written and reviewed by me.)
2026-06-30 09:34:09 -05:00
Ben Buttigieg
c47dd2b812 chore: retire Windsurf integration — absorbed into Cognition Devin (#3168) (#3213)
* chore: retire windsurf integration — absorbed into Cognition Devin (#3168)

windsurf.com now permanently redirects to devin.ai/desktop following
acquisition. Remove subpackage, registry/catalog entries, docs, and tests;
re-point sample-agent test fixtures to Kilo Code.

Assisted-by: GitHub Copilot (model: Claude Opus 4.8, autonomous)
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

* docs: remove stale Windsurf support references

Assisted-by: GitHub Copilot (model: gpt-5.3-codex, autonomous)

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

* docs: fix Kilo Code command path in upgrade guide

Assisted-by: GitHub Copilot (model: gpt-5.3-codex, autonomous)

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>

* chore: align integration lists after rebase

Assisted-by: GitHub Copilot (model: gpt-5.3-codex, autonomous)

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>

* docs: align kilocode example with runtime behavior

Assisted-by: GitHub Copilot (model: gpt-5.3-codex, autonomous)

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>

---------

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-06-30 08:49:49 -05:00
github-actions[bot]
844c73685b [extension] Update Intake extension to v0.1.3 (#3254)
* Update Intake extension to v0.1.3

Update intake extension submitted by @bigsmartben to:
- extensions/catalog.community.json (version, download_url, description, provides.commands, updated_at)
- docs/community/extensions.md community extensions table

Closes #3247

Assisted-by: GitHub Copilot (model: claude-sonnet-4.6, autonomous)
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

* Revert catalog-wide formatting churn; keep intake-only changes

Addresses review feedback on PR #3254: the previous commit re-serialized
the entire community catalog (escaping Unicode punctuation like — to
\u2014 and reformatting unrelated entries). Restore the catalog to its
prior formatting and limit the diff to the intake entry (version,
download_url, description, provides.commands, updated_at).

Assisted-by: GitHub Copilot (model: claude-opus-4.8, autonomous)
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

---------

Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Co-authored-by: Manfred Riem <15701806+mnriem@users.noreply.github.com>
2026-06-30 08:36:05 -05:00
Huy Do
20f430686c feat(workflows): honor max_concurrency in fan-out via a bounded thread pool (#3224)
* feat(workflows): honor max_concurrency in fan-out via a bounded thread pool

* feat(workflows): address review — sliding-window fan-out, locked output, faithful halt

Address the reviewer feedback on the bounded fan-out concurrency:

- Sliding submission window: keep at most `workers` items in flight and stop
  launching new items once the run is halting, instead of submitting all items
  up front (which let the pool keep starting queued work after a halt).
- Faithful halt prefix: attribute a halt to the specific item whose own
  recorded result halted the run (replaying the sequential break condition,
  honoring continue_on_error/aborted), not the shared run status a later
  concurrent item may have flipped. The returned prefix now includes the actual
  halting item, matching the sequential path. An item that fails before
  recording a result (e.g. an unknown step type) is attributed too, since every
  item runs the same template.
- Lock the parent fan-out output mutation: route the post-fan-out
  step_results[...]['output'] update through a new RunState.set_step_output()
  under the run lock, so it cannot race a concurrent save().
- Docstring: describe int() coercion accurately (numeric strings / floats are
  honored; only non-coercible or <= 1 runs sequentially).

Tests: add concurrent halt-includes-halting-item, continue_on_error-does-not-
truncate, and unknown-template-type-matches-sequential coverage; make the
timing test use a monotonic clock with a looser threshold to avoid CI flakiness.

* feat(workflows): address second review pass — concurrency hardening

- append_log: serialize the log_entries append + log.jsonl write under a
  dedicated RunState._log_lock so concurrent fan-out workers can't interleave
  or corrupt log lines (kept separate from the state lock; never nested).
- _run_fan_out.run_item: read the item output back through the item_ctx it
  executed against rather than the outer context closure — clearer and robust
  if StepContext ever stops sharing the steps dict by reference.
- StepBase: document the thread-safety contract — STEP_REGISTRY holds one shared
  instance per type, so concurrent fan-out invokes execute() on the same object;
  implementations must be stateless/thread-safe (the built-ins already are).
- test_concurrency_is_real: prove parallelism deterministically with a
  threading.Barrier (sequential execution can't clear it) instead of a
  wall-clock timing assertion.

* feat(workflows): address review — stamp updated_at under lock, clarify cancel semantics

- RunState.save(): move the updated_at timestamp assignment inside the run lock
  so the timestamp matches the snapshot the thread serializes and concurrent
  savers don't race on it.
- _run_fan_out docstring: clarify that on a halt only not-yet-started items are
  cancelled; items already running finish but their outputs are ignored
  (Future.cancel() can't stop running work, and the pool joins on exit).

* feat(workflows): serialize on_step_start callback under a lock

The concurrent fan-out path invokes _execute_steps from worker threads, which
calls the engine's on_step_start callback (the CLI sets it to a console.print
lambda). Concurrent invocation could interleave/garble progress output. Guard
the call with a WorkflowEngine._callback_lock so callbacks are serialized;
the lock is uncontended for sequential runs.

* feat(workflows): re-raise worker exceptions in-place to preserve traceback

In _run_fan_out's concurrent path, a worker exception was stashed in first_exc
and re-raised after the loop. Re-raise it from within the except block with a
bare `raise` (after cancelling outstanding futures) so the original traceback is
preserved, and drop the now-unneeded first_exc variable. The ThreadPoolExecutor
__exit__ still joins any already-running workers before the exception escapes.

* feat(workflows): lock final fan-out status, drop redundant output write, bound workers

Address third review pass:

- Remove the unlocked `context.steps[step_id]["output"] = …` writes in the
  fan-out parent update. context.steps[step_id] is the same dict object that
  set_step_output() updates under the run lock, so the direct (unsynchronized)
  mutation was redundant.
- Preserve sequential halt semantics under concurrency: a later in-flight item
  could overwrite state.status after the halting item was identified. _run_fan_out
  now derives the halting item's run status (item_halt_status, replacing the bool
  item_halted) and restores it after the pool joins, so the final status is the
  first halting item's outcome.
- Bound the pool: workers = min(max_concurrency, len(items)) and early-return for
  empty items, so a user-controlled max_concurrency can't over-allocate threads.

Add coverage that an earlier PAUSED item's status wins over a later concurrent
FAILED item.

* feat(workflows): avoid unlocked context.steps writes when it aliases step_results

On a resume run, StepContext is built with steps=state.step_results, so the two
direct `context.steps[...] = ...` writes mutated the shared dict outside the run
lock and could race save(). Route both through a new _record_result helper that
mirrors into context.steps only when it is a distinct object (a fresh run) and
otherwise relies solely on record_step_result's locked write.
2026-06-30 08:23:27 -05:00
github-actions[bot]
9c691e57b9 Update Architecture Workflow extension to v1.2.2 (#3255)
Update arch extension submitted by @bigsmartben to:
- extensions/catalog.community.json (version, download_url, description, commands count)
- docs/community/extensions.md community extensions table

Closes #3246

Assisted-by: GitHub Copilot (model: claude-sonnet-4.6, autonomous)

Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-06-30 08:16:24 -05:00
github-actions[bot]
ada293e203 Add Repository Governance extension to community catalog (#3252)
Add repository-governance extension submitted by @bigsmartben to:
- extensions/catalog.community.json (alphabetical order)
- docs/community/extensions.md community extensions table

Closes #3245

Assisted-by: GitHub Copilot (model: claude-sonnet-4.6, autonomous)

Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-06-30 07:34:23 -05:00
github-actions[bot]
5f440a8e20 Update Workflow Preset to v1.3.11 (#3251)
Update workflow-preset submitted by @bigsmartben:
- presets/catalog.community.json (version, download_url, updated_at)

Closes #3248

Assisted-by: GitHub Copilot (model: claude-sonnet-4.6, autonomous)

Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-06-30 07:33:25 -05:00
Ben Buttigieg
28a38af6c1 chore: retire iflow integration — product discontinued (#3166) (#3211)
Remove the iFlow CLI integration whose product was shut down: subpackage,
registry entry, catalog entry, docs, tests, and issue-template options.

Assisted-by: GitHub Copilot (model: Claude Opus 4.8, autonomous)

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-06-30 07:30:52 -05:00
Ben Buttigieg
8215f3308b docs(codebuddy): fix dead install links and CodeBuddy capitalization (#3172) (#3216)
* fix(codebuddy): repoint install_url to codebuddy.cn (#3172)

The codebuddy.ai domain no longer resolves; CodeBuddy consolidated onto
codebuddy.cn (Tencent). Update install_url and docs links to
https://www.codebuddy.cn/cli (verified live).

Assisted-by: GitHub Copilot (model: Claude Opus 4.8, autonomous)
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

* docs: use canonical 'CodeBuddy' capitalization in installation prereqs

Address Copilot review: the link text read 'Codebuddy CLI' while the rest of
the docs and the integration metadata use 'CodeBuddy'.

Assisted-by: GitHub Copilot (model: Claude Opus 4.8, autonomous)
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

---------

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-06-30 07:29:33 -05:00
Noor ul ain
cb7c36c95b fix: reject host-less catalog URLs in base and preset validators (#3209) (#3227)
`CatalogStackBase._validate_catalog_url` (inherited by `IntegrationCatalog`)
and `PresetCatalog._validate_catalog_url` checked `parsed.netloc`, which is
truthy for host-less URLs like `https://:8080` (port only) or `https://user@`
(userinfo only). Such URLs slipped past validation despite the error message
promising "a valid URL with a host", then failed later with a confusing fetch
error.

Switch both validators to `parsed.hostname` (None for those inputs), matching
the workflow, step, and bundler catalog validators that already do this.

Add regression tests covering port-only and userinfo-only URLs for both
validators.

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-30 07:18:39 -05:00
Manfred Riem
8025481eca chore: release 0.12.1, begin 0.12.2.dev0 development (#3253)
* chore: bump version to 0.12.1

* chore: begin 0.12.2.dev0 development

---------

Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
2026-06-30 06:47:09 -05:00
36 changed files with 661 additions and 240 deletions

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -2,6 +2,37 @@
<!-- 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

View File

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

View File

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

View File

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

View File

@@ -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>` |
@@ -22,7 +22,6 @@ The Specify CLI supports a wide range of AI coding agents. When you run `specify
| [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 |
@@ -265,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` |
@@ -274,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`.
@@ -289,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`?

View File

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

View File

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

View File

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

View File

@@ -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,6 +288,8 @@ generate_branch_name() {
if ! echo "$word" | grep -qiE "$stop_words"; then
if [ ${#word} -ge 3 ]; then
meaningful_words+=("$word")
# 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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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")
# 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
# Keep short words if they appear as uppercase in original (likely acronyms)
meaningful_words+=("$word")
fi
fi

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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