diff --git a/.github/ISSUE_TEMPLATE/agent_request.yml b/.github/ISSUE_TEMPLATE/agent_request.yml index 344b1c9031..4e98030af6 100644 --- a/.github/ISSUE_TEMPLATE/agent_request.yml +++ b/.github/ISSUE_TEMPLATE/agent_request.yml @@ -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**: Alquimia AI, Amp, Antigravity, Auggie CLI, Claude Code, Cline, CodeBuddy, Codex CLI, Command Code, Cursor, Devin for Terminal, Docker Agent, Factory Droid, DeepSeek Harness, Firebender, Forge, Gemini CLI, GitHub Copilot, Goose, Grok Build, Hermes Agent, IBM Bob, Junie, Kilo Code, Kimi Code, Kiro CLI, Lingma, Mistral Vibe, Oh My Pi, opencode, Pi Coding Agent, Qoder CLI, Qwen Code, RovoDev ACLI, SHAI, Tabnine CLI, Trae, ZCode, Zed + **Currently supported agents**: Alquimia AI, Amp, Antigravity, Auggie CLI, Claude Code, Cline, CodeBuddy, Codex CLI, Command Code, Cursor, Devin for Terminal, Docker Agent, Factory Droid, DeepSeek Harness, Firebender, Forge, Gemini CLI, GitHub Copilot, Goose, Grok Build, Hermes Agent, IBM Bob, Junie, Kilo Code, Kimi Code, Kiro CLI, Lingma, Mistral Vibe, Muse Code, Oh My Pi, opencode, Pi Coding Agent, Qoder CLI, Qwen Code, RovoDev ACLI, SHAI, Tabnine CLI, Trae, ZCode, Zed - type: input id: agent-name diff --git a/.github/ISSUE_TEMPLATE/bug_report.yml b/.github/ISSUE_TEMPLATE/bug_report.yml index a89e441d30..ca386d644c 100644 --- a/.github/ISSUE_TEMPLATE/bug_report.yml +++ b/.github/ISSUE_TEMPLATE/bug_report.yml @@ -90,6 +90,7 @@ body: - Kiro CLI - Lingma - Mistral Vibe + - Muse Code - Oh My Pi - opencode - Pi Coding Agent diff --git a/.github/ISSUE_TEMPLATE/feature_request.yml b/.github/ISSUE_TEMPLATE/feature_request.yml index f80040e334..149ce45c19 100644 --- a/.github/ISSUE_TEMPLATE/feature_request.yml +++ b/.github/ISSUE_TEMPLATE/feature_request.yml @@ -84,6 +84,7 @@ body: - Kiro CLI - Lingma - Mistral Vibe + - Muse Code - Oh My Pi - opencode - Pi Coding Agent diff --git a/AGENTS.md b/AGENTS.md index 50923c8f1d..2f3214a965 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -377,6 +377,7 @@ Require a command-line tool to be installed: - **Forge**: `forge` CLI - **DeepSeek Harness**: `dsh` CLI - **Docker Agent**: `docker-agent` CLI +- **Muse Code**: `muse` CLI ### IDE-Based Agents diff --git a/CHANGELOG.md b/CHANGELOG.md index 7070da5284..54b9062c0f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,20 @@ All notable changes to the Specify CLI and templates are documented here. +# [1.0.4+adlc1] - 2026-09-03 + +### Changed + +- **Upstream merge**: Synced with github/spec-kit through `1.0.4` + 7 post-release commits (11 commits, base 1.0.3+adlc1 → 1.0.4+adlc1; upstream HEAD `db648699`). + - New integration: `muse` (Muse Code, skills-based, #4413) + - New workflow feature: `slot` step type (#4352) — named output slots for inter-step data passing + - Script parity fix: bash branch-name sanitizing now matches Python/PowerShell twins (LC_ALL=C, printf, POSIX `--*` sed) across core + git-extension twins (#4286) + - Command ref: hyphen allowed in `__SPECKIT_COMMAND_*__` token names (#4356) — `agent-context.update` resolves correctly + - Workflow fixes: require `steps` body on while/do-while loops (#4149), remove unused scope input from bundled speckit workflow (#4401, bumps workflow catalog to 1.0.1) + - Community catalog: Axi, Evaluator Contract, Charter v0.6.1 +- **5 conflicts resolved**: `pyproject.toml` (version → `1.0.4+adlc1`), `scripts/bash/create-new-feature.sh` (adopted upstream #4286 `clean_branch_name`/`generate_branch_name` parity fix; removed fork's old duplicate; kept all fork customizations), `extensions/git/scripts/powershell/create-new-feature-branch.ps1` (applied upstream's ASCII word-boundary acronym regex to fork's `Get-BranchName`; kept fork's `Invoke-WorktreeDelegation` intact — conflict was a merge-alignment collision), `src/specify_cli/integrations/base.py` (kept fork `resolve_command_refs` signature with `project_root` param; adopted upstream's hyphen-allowing regex `[A-Z0-9_-]*` + docstring), `workflows/catalog.json` (took upstream `updated_at` + speckit workflow 1.0.1; kept fork's `feature-squad` + `impl-converge-loop` entries) +- **Test adaptation**: `test_integration_muse.py` — `/speckit-constitution` next-steps assertion → `/spec-constitution` (fork prefix) + # [1.0.3+adlc1] - 2026-09-02 ### Changed @@ -4360,6 +4374,31 @@ This release migrates fork-specific customizations to a preset system to reduce The following entries are from the upstream spec-kit project and are included for reference. +## [1.0.4] - 2026-09-02 + +### Changed + +- fix(scripts): stop wrap composition looping on a token in core content (#4396) +- [extension] Update Charter extension to v0.6.1 (#4409) +- fix(workflows): keep non-ASCII text readable in written overlay files (#4148) +- fix(workflows): report overlay operation keys in declaration order (#4146) +- fix: skip corrupted state.json in list_runs() instead of aborting (#3904) +- fix(rovodev): guard non-string prompt names when merging prompts.yml (#4145) +- fix: narrow bare except Exception in preset command reconciliation (#3842) +- fix(workflows): refuse a filter mixed with a comparison operator instead of silently mis-binding it (#3894) +- fix: escape Rich markup in workflow error output (#3837) +- fix: add JSON error handling to auth config loader (#3836) +- fix: use missing_ok=True in extension ZIP cleanup (#3870) +- feat(presets): let a preset declare a required extension (#4250) +- fix(bundler): reject unsupported catalog payload versions (#4090) +- fix(extensions): install bundled extension updates from the local package (#4351) +- docs: clarify autonomous PR handling (#4392) +- fix(workflows): reject malformed step config on add (#4087) +- fix(powershell): stop create-new-feature crashing on a non-Latin description (#4138) +- fix(bundler): treat an explicit-null records field as missing, not "None" (#4136) +- Add DeepSeek Harness (DSH) integration (#4336) +- chore: release 1.0.3, begin 1.0.4.dev0 development (#4391) + ## [1.0.3] - 2026-09-01 ### Changed diff --git a/FORK.md b/FORK.md index 2a78a2b8ea..4563e9902c 100644 --- a/FORK.md +++ b/FORK.md @@ -72,6 +72,7 @@ When a fork release changes only bundled extension behavior, keep the CLI versio | Version | Date | Base Upstream | Changes | |---------|------|---------------|---------| +| 1.0.4+adlc1 | 2026-09-03 | 1.0.4 (`ab3d2fa8`) + 7 post-release commits (`db648699`) | Upstream merge (11 commits, 1.0.3+adlc1 → 1.0.4+adlc1). New integration: `muse` (Muse Code, #4413). New workflow `slot` step type (#4352). Script parity: bash branch-name sanitizing matches Python/PowerShell twins (LC_ALL=C, printf, POSIX `--*` sed) across core + git-extension twins (#4286). Command ref: hyphen allowed in `__SPECKIT_COMMAND_*__` tokens (#4356). Workflow fixes: require `steps` body on while/do-while (#4149), remove unused scope input from speckit workflow (#4401, catalog → 1.0.1). Community catalog: Axi, Evaluator Contract, Charter v0.6.1. **5 conflicts resolved**: `pyproject.toml` (version → `1.0.4+adlc1`), `scripts/bash/create-new-feature.sh` (adopted #4286 parity `clean_branch_name`/`generate_branch_name`, removed fork's old duplicate, kept fork issue-template/isolation/JSON customizations), `extensions/git/scripts/powershell/create-new-feature-branch.ps1` (applied upstream ASCII word-boundary acronym regex to fork's `Get-BranchName`; conflict was a merge-alignment collision with fork's `Invoke-WorktreeDelegation`), `src/specify_cli/integrations/base.py` (kept fork `resolve_command_refs` `project_root` param + adopted upstream hyphen-allowing regex), `workflows/catalog.json` (took upstream `updated_at`/1.0.1, kept fork's `feature-squad`/`impl-converge-loop`). Test adaptation: `test_integration_muse.py` `/speckit-constitution`→`/spec-constitution` (fork prefix). All hotspots (muse registration, agent-context-defaults muse entry, git bash twin LC_ALL=C, slot step registration, fork scripts `extract_constitution_rules`/`replace_date_placeholders`, `_core_fork`) auto-merged/verified clean. Ruff clean. Stacked on `chore/upstream-merge-1.0.3` (PR #114). | | 1.0.3+adlc1 | 2026-09-02 | 1.0.3 (`6906bc58`) + 18 post-release commits (`0053c3a3`) | Upstream merge (149 commits, 0.16.2+adlc1 → 1.0.3+adlc1). Releases: 0.16.3, 0.16.4, 0.16.5, 1.0.0, 1.0.1, 1.0.2, 1.0.3 + post-1.0.3 main. New integrations: `dsh` (DeepSeek Harness, #4336), `docker-agent` (#4302); upstream `vibe` rewrite adopted (#4075) — fork dropped vibe's `_core_fork._inject_frontmatter_flag` delegation in favor of upstream's class staticmethod (matches claude/droid/alquimia); Junie dot-to-hyphen + command formatting (#4073); qodercli → SkillsIntegration (#4205); goose `goose run` dispatch (#3781). New preset features: required-extension declaration (#4250), resolution/precedence-ordered listing (#4104), self-contained namespaced preset commands (#4082), fail-closed unreadable provenance (#4092). Extensions: unified archive pipeline for bundled + remote updates (#4351 — supersedes fork's `install_from_directory` bundled path); scaffold extension config via bundler (#4285). Init: stop arrow-key picker hang in agent harnesses (#4178). Commands/scripts: `--require-spec` in check-prerequisites (#4367, ported to `adlc.spec.analyze`/`adlc.spec.converge` presets); taskstoissues regex `\bT\d{3,}\b` (#4101); PowerShell `Out-Null`/non-Latin fixes (#4188/#3891/#4138); feature-assess workflow (#4186). Hardening: auth URL ports, Rich markup escaping, `missing_ok` ZIP cleanup (#3870), TOCTOU RunState.load (#3839), non-UTF-8 tolerance, bounded HTTP, malformed overlay/step rejection. **9 conflicts resolved**: `pyproject.toml` (version → `1.0.3+adlc1`, kept fork name/description/httpx/force-include), `docs/docfx.json` (fork branding + upstream logo/favicon), `commands/init.py` (fork `accent()` theming + upstream `--non-interactive` error #4178), `extensions/_commands.py` (adopted upstream unified archive-update #4351 + `missing_ok` #3870; kept fork `accent()`, `_command_safe_id`, preserve-metadata/`restore()`; fixed `safe_id`→`cmd_id` undefined-var), `presets/__init__.py` (kept fork `replaced_commands` on upstream `command_templates` loop), `presets/_commands.py` (fork `accent()` + upstream resolution-order sort #4104), `integrations/vibe/__init__.py` (adopted upstream expanded version), `tests/integrations/test_integration_subcommand.py` (kept fork restructured file), `tests/test_presets.py` (kept fork whitespace-normalized assertion). Test adaptations: `test_integration_dsh.py` (spec/speckit prefix split), `test_integration_junie.py` (`_expected_files` `project` kwarg + fork file-scan, `/speckit-plan`→`/spec-plan`). All semantic hotspots (`integrations/__init__.py`, `events.py`, `extensions/__init__.py` catalog URLs, fork scripts `extract_constitution_rules`/`replace_date_placeholders`, `_core_fork`) auto-merged cleanly. Ruff clean. | | 0.16.2+adlc1 | 2026-08-12 | 0.16.2 (`bd595cf8`) | Upstream merge (50 commits, 0.16.0+adlc3 → 0.16.2). New upstream features: `command-code` integration (Command Code CLI, skills-based, `$speckit-*` invocation, registered alphabetically in `_register_builtins()`); `feat(presets): resolve constitution templates at command time` (#3984 — constitution template resolved via `resolve-template` script at command time); `feat(extensions): accept provides.templates and provides.scripts in manifest` (#4012); checklist ownership semantics (reviewer-owned markers, read-only gate in implement, `--template checklist-template` flag); template content resolution (`TASKS_TEMPLATE_CONTENT`/`TEMPLATE_CONTENT` replace path-based loading); `scripts:` frontmatter added to `constitution` command. Upstream fixes: bounded response read in integration catalog fetch (#3818), non-UTF-8 catalog response wrapping (#4011), Rich markup escaping in bundle CLI (#4023), workflow condition/overlay hardening (#3881, #3883), duplicate provides.templates/scripts rejection (#4016), preset remove unreadable restore source (#4020), extension cache clear missing_ok (#3845), integration JSON removal missing_ok (#3846), preset catalog config read failure details (#3840), bug-test Python dependency provisioning (#4030). Community catalog: Keel Discovery, SpecKit Grill Me, Model Routing Governance; updates: Reconcile v1.1.0, Architecture Governance v0.5.2, Security Governance v0.6.2, Archive v1.2.2. **9 conflicts resolved**: `pyproject.toml` (version → `0.16.2+adlc1`), `scripts/bash/common.sh` (kept fork `extract_constitution_rules`/`load_team_directives_config` + adopted upstream `_python3_command`/`_sorted_extension_ids`/`resolve_template_content`), `scripts/bash/create-new-feature.sh` (adopted upstream content-based template write + kept fork `replace_date_placeholders` + restored missing `SPEC_FILE` definition), `scripts/powershell/create-new-feature.ps1` (parallel merge + restored `$specFile`), `scripts/python/create_new_feature.py` (union imports), `src/specify_cli/commands/bundle/__init__.py` (kept fork `accent()` theming + adopted upstream `_escape_markup` for user-supplied values across 9 console.print sites), `presets/catalog.json` (updated_at), `extensions/EXTENSION-API-REFERENCE.md` (union: fork `runtime_hooks` + upstream `templates`/`scripts`), `README.md` (fork `/spec.*` prefix + upstream Command Code mention). **Test adaptations**: `test_integration_catalog.py` (took upstream version — fork's `adbb0146` `open_url` mock workaround obsolete with upstream's `route_opener_open_through_urlopen` fixture), `test_integration_command_code.py` (fork `PKG_NAMES` prefix adaptation for `$spec-constitution` vs `$speckit-constitution`). **Template-to-preset alignment**: ported upstream changes to `adlc.spec.checklist.md` (`--template` flag, ownership section, TEMPLATE_CONTENT), `adlc.spec.constitution.md` (`scripts:` frontmatter for resolve-template), `adlc.spec.implement.md` (checklist marker semantics: completed→checked, read-only gate), `adlc.spec.tasks.md` (TASKS_TEMPLATE_CONTENT), `checklist-template.md` (Review Ownership + Marker Semantics). Ruff clean. 3760+ tests pass. | | 0.16.0+adlc3 | 2026-08-11 | 0.16.0 (`adb2413a`) | `agentic-change` preset no longer preinstalled. `presets/catalog.json` flips `agentic-change.preinstall` from `true` to `false`; the preset's 4 commands (`/change.specify`, `/change.implement`, `/change.converge`, `/change.levelup`) are no longer auto-scaffolded by `specify init`. The preset remains `bundled: true` (shipped in the wheel) and installable on demand via `specify preset add agentic-change`. `agentic-sdlc` and `agentic-quick` presets are unaffected. No Python source changed — the install scaffold (`_init_fork.py:339-369`) already honors the catalog `preinstall` flag, so the flip is purely data-driven. Doc consistency: `presets/agentic-change/README.md` Installation section rewritten to declare opt-in status; `README.md` bundled-presets blurb (line 33) and table (line 284-294) split bundled-vs-preinstalled and mark `agentic-change` as ❌ opt-in. | diff --git a/docs/community/extensions.md b/docs/community/extensions.md index 4c995b0f52..b8ef03cd7a 100644 --- a/docs/community/extensions.md +++ b/docs/community/extensions.md @@ -41,6 +41,7 @@ The following community-contributed extensions are available in [`catalog.commun | Archive Extension | Archive merged features into main project memory, resolving gaps and conflicts. | `docs` | Read+Write | [spec-kit-archive](https://github.com/stn1slv/spec-kit-archive) | | ASCII Diagram Renderer | Renders hand-drawn ASCII/Unicode diagrams (state machine, architecture, flow, coverage map) of what spec/plan/tasks/analyze already say — plain text, no Mermaid renderer needed | `docs` | Read+Write | [spec-kit-ascii-diagram](https://github.com/MRZHUH/spec-kit-ascii-diagram) | | Atlas | Synthesize spec-kit specs into faithful, interactive architecture storybooks & doc portals. | `docs` | Read-only | [spec-kit-atlas](https://github.com/ashbrener/spec-kit-atlas) | +| Axi Extension | A Spec Kit extension that renders a feature's markdown in a local browser review surface for human annotation, then returns the queued notes to the agent to apply. | `docs` | Read+Write | [spec-kit-axi](https://github.com/d0whc3r/spec-kit-axi) | | Azure Cosmos DB | Best-practice Azure Cosmos DB code generation and review for any AI coding agent | `code` | Read+Write | [spec-kit-cosmosdb](https://github.com/AzureCosmosDB/spec-kit-cosmosdb) | | 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) | | BDD | Convert specs to Gherkin scenarios, scaffold step definitions, and verify acceptance test coverage | `process` | Read+Write | [spec-kit-bdd](https://github.com/RSginer/spec-kit-bdd) | @@ -52,7 +53,7 @@ The following community-contributed extensions are available in [`catalog.commun | Bugfix Workflow | Structured bugfix workflow — capture bugs, trace to spec artifacts, and patch specs surgically | `process` | Read+Write | [spec-kit-bugfix](https://github.com/Quratulain-bilal/spec-kit-bugfix) | | Canon | Adds canon-driven (baseline-driven) workflows: spec-first, code-first, spec-drift. Requires Canon Core preset installation. | `process` | Read+Write | [spec-kit-canon](https://github.com/maximiliamus/spec-kit-canon/tree/master/extension) | | Catalog CI | Automated validation for spec-kit community catalog entries — structure, URLs, diffs, and linting | `process` | Read-only | [spec-kit-catalog-ci](https://github.com/Quratulain-bilal/spec-kit-catalog-ci) | -| Charter | Compose modular project constitutions from shared fragment registries. Centralize governance rules, select per-project fragments, track upstream changes, and keep multi-project setups consistent. | `process` | Read+Write | [spec-kit-charter](https://github.com/Fyloss/spec-kit-charter) | +| Charter | Compose project constitutions from shared fragment registries | `process` | Read+Write | [spec-kit-charter](https://github.com/Fyloss/spec-kit-charter) | | CI Guard | Spec compliance gates for CI/CD — verify specs exist, check drift, and block merges on gaps | `process` | Read-only | [spec-kit-ci-guard](https://github.com/Quratulain-bilal/spec-kit-ci-guard) | | Checkpoint Extension | Commit the changes made during the middle of the implementation, so you don't end up with just one very large commit at the end | `code` | Read+Write | [spec-kit-checkpoint](https://github.com/aaronrsun/spec-kit-checkpoint) | | Cleanup Extension | Post-implementation quality gate that reviews changes, fixes small issues (scout rule), creates tasks for medium issues, and generates analysis for large issues | `code` | Read+Write | [spec-kit-cleanup](https://github.com/dsrednicki/spec-kit-cleanup) | @@ -66,6 +67,7 @@ The following community-contributed extensions are available in [`catalog.commun | Dotdog | Import GitHub Spec Kit artifacts into local knowledge graphs for validation, analysis, search, and MCP queries. | `docs` | Read+Write | [dotdog](https://github.com/specdog/dotdog) | | DUBSAR Memory | Local project memory for Spec Kit with explicit checkpoints, cross-session resume, and SHA-256 freshness for recorded specification, plan, and task references. | `visibility` | Read+Write | [dubsar-memory](https://github.com/kotnisofiane-bit/dubsar-memory) | | EARS Requirements Syntax | Author, lint, and convert requirements using EARS - the five industry-standard sentence patterns for unambiguous, testable requirements | `docs` | Read+Write | [spec-kit-ears](https://github.com/dhruv-15-03/spec-kit-ears) | +| Evaluator Contract | Provider-neutral evaluator contract for evidence, provenance, uncertainty, and recovery across Spec-Driven Development phases. | `process` | Read+Write | [spec-kit-evaluator](https://github.com/electrohire/spec-kit-evaluator) | | Extensify | Create and validate extensions and extension catalogs | `process` | Read+Write | [extensify](https://github.com/mnriem/spec-kit-extensions/tree/main/extensify) | | Figma Starter | Turns a Figma section's screens into per-screen spec.md files, an app-level user-stories.md, and a build-order.md, then hands off to /speckit.specify | `integration` | Read+Write | [spec-kit-figma-starter](https://github.com/wavemaker/spec-kit-figma-starter) | | Fix Findings | Automated analyze-fix-reanalyze loop that resolves spec findings until clean | `code` | Read+Write | [spec-kit-fix-findings](https://github.com/Quratulain-bilal/spec-kit-fix-findings) | diff --git a/docs/reference/integrations.md b/docs/reference/integrations.md index ac9e2978b7..551f73c97e 100644 --- a/docs/reference/integrations.md +++ b/docs/reference/integrations.md @@ -34,6 +34,7 @@ The Specify CLI supports a wide range of AI coding agents. When you run `specify | [Kiro CLI](https://kiro.dev/docs/cli/) | `kiro-cli` | Kiro CLI does not substitute `$ARGUMENTS` in file-based prompts, so Spec Kit ships a prose fallback at render time (see [Manage prompts](https://kiro.dev/docs/cli/chat/manage-prompts/) and issue [#1926](https://github.com/github/spec-kit/issues/1926)). Alias: `--integration kiro` | | [Lingma](https://lingma.aliyun.com/) | `lingma` | Skills-based integration; skills are installed automatically | | [Mistral Vibe](https://github.com/mistralai/mistral-vibe) | `vibe` | | +| [Muse Code](https://dev.meta.ai/docs/muse-code) | `muse` | Skills-based integration; installs skills into `.agents/skills` and invokes them as `/speckit-` | | [Oh My Pi](https://www.npmjs.com/package/@oh-my-pi/pi-coding-agent) | `omp` | Installs slash commands into `.omp/commands` | | [opencode](https://opencode.ai/) | `opencode` | | | [Pi Coding Agent](https://pi.dev) | `pi` | Pi doesn't have MCP support out of the box, so `taskstoissues` won't work as intended. MCP support can be added via [extensions](https://github.com/badlogic/pi-mono/tree/main/packages/coding-agent#extensions) | diff --git a/docs/reference/workflows.md b/docs/reference/workflows.md index 3b838b7227..41a890ba60 100644 --- a/docs/reference/workflows.md +++ b/docs/reference/workflows.md @@ -18,7 +18,7 @@ Runs a workflow from a catalog ID, URL, or local file path. Inputs declared by t Example: ```bash -specify workflow run speckit -i spec="Build a kanban board with drag-and-drop task management" -i scope=full +specify workflow run speckit -i spec="Build a kanban board with drag-and-drop task management" ``` With `--json`, a single machine-readable object is printed instead of formatted text (the default output is unchanged when the flag is omitted): @@ -286,6 +286,40 @@ edits: Lower priority values have higher precedence. Change this overlay to `priority: 5` if it must win a conflict with the `add-lint` overlay above. It replaces the `review-plan` gate with a non-interactive command. +### Workflow slots (upstream extension points) + +Workflow authors can declare a named, no-op workflow slot with `type: slot`: + +```yaml +- id: post-implement + type: slot + name: "Post-implementation checks" +``` + +The step `id` is the unique overlay anchor; `name` is a required non-blank, +human-readable label only. An unfilled slot completes as a `skipped` step with +`output: {slot: }`, so subsequent steps continue normally. + +Fill a slot with a schema-valid overlay `replace` edit anchored on the step +`id`, not its `name`: + +```yaml +id: fill-post-implement +extends: my-workflow +edits: + - replace: post-implement + step: + id: post-implement + type: shell + run: "echo Run project-specific checks" +``` + +Reuse the slot's `id` when later expressions or `fan-in.wait_for` refer to it. +The replacement must also preserve every output key those later steps consume: +an unfilled workflow slot supplies only `steps..output.slot`. Slot steps are +not supported inside `fan-out.step` templates because runtime-multiplied +templates cannot be overlay anchors. + ### Interaction with Bundles and Updates `specify workflow add ` installs the complete local workflow @@ -415,10 +449,6 @@ inputs: type: string default: "copilot" prompt: "Integration to use (e.g. claude, copilot, gemini)" - scope: - type: string - default: "full" - enum: ["full", "backend-only", "frontend-only"] steps: - id: specify @@ -494,6 +524,7 @@ specify workflow run speckit -i spec="Build a kanban board with drag-and-drop ta | `prompt` | Send an arbitrary prompt to the AI coding agent | | `shell` | Execute a shell command and capture output | | `init` | Bootstrap a project (like `specify init`) | +| `slot` | Named workflow slot; skipped when unfilled | | `gate` | Pause for human approval before continuing | | `if` | Conditional branching (then/else) | | `switch` | Multi-branch dispatch on an expression | diff --git a/extensions/EXTENSION-DEVELOPMENT-GUIDE.md b/extensions/EXTENSION-DEVELOPMENT-GUIDE.md index 2b7599b828..6e901077d1 100644 --- a/extensions/EXTENSION-DEVELOPMENT-GUIDE.md +++ b/extensions/EXTENSION-DEVELOPMENT-GUIDE.md @@ -350,15 +350,16 @@ A command body is a *template* that Spec Kit renders once per agent. Different a Instead use the agent-neutral token `__SPECKIT_COMMAND___`. Spec Kit resolves it to a `/speckit...` invocation using the active integration's `invoke_separator` (and integrations may post-process that further in skills output). -Encode the command name in upper case, dropping the `speckit.` prefix and turning each dotted segment separator into an underscore: +Encode the command name in upper case, dropping the `speckit.` prefix and turning each dotted segment separator into an underscore. A hyphen inside a segment is kept as a hyphen: | Command file | Token | | --- | --- | | `speckit.plan.md` | `__SPECKIT_COMMAND_PLAN__` | | `speckit.bug.fix.md` | `__SPECKIT_COMMAND_BUG_FIX__` | | `speckit.git.commit.md` | `__SPECKIT_COMMAND_GIT_COMMIT__` | +| `speckit.agent-context.update.md` | `__SPECKIT_COMMAND_AGENT-CONTEXT_UPDATE__` | -The resolver maps each underscore back to the active agent's separator, so use tokens to reference commands whose name segments are single words. (Command names are dotted segments like `git.commit`; the token scheme rebuilds those dots and does not carry hyphens within a segment.) +The resolver maps each underscore back to the active agent's separator. An underscore separates segments and a hyphen belongs to the segment it sits in, so `AGENT-CONTEXT_UPDATE` is the two segments `agent-context` and `update` rather than three. **Example** — a command body that points the user at the next step: @@ -368,15 +369,19 @@ Once the assessment exists, the next step is `__SPECKIT_COMMAND_BUG_FIX__ slug=< This renders as `/speckit.bug.fix slug=` for a slash-based agent, `/speckit-bug-fix slug=` for a skills-based agent, and so on — the author writes it once and it stays portable. The first-party `bug` and `git` extensions use this token exclusively; see `extensions/bug/commands/` for working examples. -> **Current limitation — skills mode.** Token resolution runs in the -> command-rendering path (`CommandRegistrar`), so it applies when an extension -> installs *command files*. It does **not** yet run when an extension is -> registered as *skills* for a skills-based agent: `_register_extension_skills` -> resolves placeholders and post-processes content but never calls -> `resolve_command_refs`, so a `__SPECKIT_COMMAND___` token reaches -> agents such as Codex, ZCode, and Kimi verbatim in that mode. Until that -> rendering step lands, prefer the token for command-file extensions and avoid -> relying on it inside skill bodies destined for skills-based agents. +> **Skills mode.** Token resolution runs in both paths, so the token is safe to +> use either way. Command files go through the command-rendering path +> (`CommandRegistrar`). Skill bodies go through `_resolve_command_ref_tokens` in +> `_register_extension_skills`, which resolves the same token shape against the +> active skill style, so `__SPECKIT_COMMAND_BUG_FIX__` renders as: +> +> | Agent | Rendered | +> | --- | --- | +> | Codex, ZCode, Command Code | `$speckit-bug-fix` | +> | Claude, Copilot, Cursor, Devin, Droid, Grok and the other slash agents | `/speckit-bug-fix` | +> | Kimi | `/skill:speckit-bug-fix` | +> +> Anything else falls through to the integration's own `build_command_invocation`. ### Script Path Rewriting diff --git a/extensions/agent-context/agent-context-defaults.json b/extensions/agent-context/agent-context-defaults.json index b50c10d69e..8c5f781ab9 100644 --- a/extensions/agent-context/agent-context-defaults.json +++ b/extensions/agent-context/agent-context-defaults.json @@ -25,6 +25,7 @@ "kimi": "AGENTS.md", "kiro-cli": "AGENTS.md", "lingma": ".lingma/rules/specify-rules.md", + "muse": "AGENTS.md", "omp": "AGENTS.md", "opencode": "AGENTS.md", "pi": "AGENTS.md", diff --git a/extensions/catalog.community.json b/extensions/catalog.community.json index 6044a2ee02..a78e30aa45 100644 --- a/extensions/catalog.community.json +++ b/extensions/catalog.community.json @@ -1,6 +1,6 @@ { "schema_version": "1.0", - "updated_at": "2026-09-01T00:00:00Z", + "updated_at": "2026-09-03T00:00:00Z", "catalog_url": "https://raw.githubusercontent.com/github/spec-kit/main/extensions/catalog.community.json", "extensions": { "adrkit": { @@ -614,6 +614,44 @@ "created_at": "2026-08-13T00:00:00Z", "updated_at": "2026-08-19T00:00:00Z" }, + "axi": { + "name": "Axi Extension", + "id": "axi", + "description": "A Spec Kit extension that renders a feature's markdown in a local browser review surface for human annotation, then returns the queued notes to the agent to apply.", + "author": "d0whc3r", + "version": "1.1.4", + "download_url": "https://github.com/d0whc3r/spec-kit-axi/releases/download/v1.1.4/axi-1.1.4.zip", + "repository": "https://github.com/d0whc3r/spec-kit-axi", + "homepage": "https://d0whc3r.github.io/spec-kit-axi/", + "documentation": "https://github.com/d0whc3r/spec-kit-axi/wiki", + "changelog": "https://github.com/d0whc3r/spec-kit-axi/blob/main/CHANGELOG.md", + "license": "MIT", + "category": "docs", + "effect": "read-write", + "requires": { + "speckit_version": ">=0.2.0", + "tools": [ + { + "name": "node", + "required": true + } + ] + }, + "provides": { + "commands": 1, + "hooks": 0 + }, + "tags": [ + "axi", + "spec-kit", + "spec-kit-extension" + ], + "verified": false, + "downloads": 0, + "stars": 0, + "created_at": "2026-09-02T00:00:00Z", + "updated_at": "2026-09-02T00:00:00Z" + }, "azure-devops": { "name": "Azure DevOps Integration", "id": "azure-devops", @@ -1039,13 +1077,13 @@ "charter": { "name": "Charter", "id": "charter", - "description": "Compose modular project constitutions from shared fragment registries. Centralize governance rules, select per-project fragments, track upstream changes, and keep multi-project setups consistent.", + "description": "Compose project constitutions from shared fragment registries", "author": "Fyloss", - "version": "0.5.1", - "download_url": "https://github.com/Fyloss/spec-kit-charter/archive/refs/tags/v0.5.1.zip", + "version": "0.6.1", + "download_url": "https://github.com/Fyloss/spec-kit-charter/archive/refs/tags/v0.6.1.zip", "repository": "https://github.com/Fyloss/spec-kit-charter", "homepage": "https://github.com/Fyloss/spec-kit-charter", - "documentation": "https://github.com/Fyloss/spec-kit-charter/tree/master/docs", + "documentation": "https://github.com/Fyloss/spec-kit-charter/blob/master/README.md", "changelog": "https://github.com/Fyloss/spec-kit-charter/blob/master/CHANGELOG.md", "license": "MIT", "category": "process", @@ -1066,15 +1104,15 @@ "tags": [ "constitution", "governance", - "modular", - "fragments", - "registry" + "multi-repo", + "composition", + "fragments" ], "verified": false, "downloads": 0, "stars": 0, "created_at": "2026-07-06T00:00:00Z", - "updated_at": "2026-08-04T00:00:00Z" + "updated_at": "2026-09-02T00:00:00Z" }, "ci-guard": { "name": "CI Guard", @@ -1747,6 +1785,40 @@ "created_at": "2026-07-08T00:00:00Z", "updated_at": "2026-07-08T00:00:00Z" }, + "evaluator": { + "name": "Evaluator Contract", + "id": "evaluator", + "description": "Provider-neutral evaluator contract for evidence, provenance, uncertainty, and recovery across Spec-Driven Development phases.", + "author": "ElectroHire", + "version": "1.0.0", + "download_url": "https://github.com/electrohire/spec-kit-evaluator/archive/refs/tags/v1.0.0.zip", + "repository": "https://github.com/electrohire/spec-kit-evaluator", + "homepage": "https://github.com/electrohire/spec-kit-evaluator", + "documentation": "https://github.com/electrohire/spec-kit-evaluator/blob/main/README.md", + "changelog": "https://github.com/electrohire/spec-kit-evaluator/blob/main/CHANGELOG.md", + "license": "MIT", + "category": "process", + "effect": "read-write", + "requires": { + "speckit_version": ">=1.0.0" + }, + "provides": { + "commands": 4, + "hooks": 4 + }, + "tags": [ + "evaluator", + "evidence", + "provenance", + "quality", + "governance" + ], + "verified": false, + "downloads": 0, + "stars": 0, + "created_at": "2026-09-03T00:00:00Z", + "updated_at": "2026-09-03T00:00:00Z" + }, "extensify": { "name": "Extensify", "id": "extensify", diff --git a/extensions/git/scripts/bash/create-new-feature-branch.sh b/extensions/git/scripts/bash/create-new-feature-branch.sh index 66544e4dbf..2e0b13ebe3 100755 --- a/extensions/git/scripts/bash/create-new-feature-branch.sh +++ b/extensions/git/scripts/bash/create-new-feature-branch.sh @@ -273,9 +273,18 @@ check_existing_branches() { } # Function to clean and format a branch name +# +# Three details keep this byte-identical to the Python and PowerShell twins: +# * LC_ALL=C -- in a UTF-8 locale glibc resolves the a-z *range* through +# collation, so [^a-z0-9] keeps accented lowercase letters that +# re.sub(r"[^a-z0-9]", ...) and .NET's -replace both strip. +# * `--*` instead of the GNU-only `\+`, which POSIX/BSD sed reads as a literal +# '+', leaving repeated separators uncollapsed on macOS. +# * printf instead of echo, so a name of "-n"/"-e"/"-E" is text, not options. clean_branch_name() { local name="$1" - echo "$name" | tr '[:upper:]' '[:lower:]' | sed 's/[^a-z0-9]/-/g' | sed 's/-\+/-/g' | sed 's/^-//' | sed 's/-$//' + local -x LC_ALL=C + printf '%s\n' "$name" | tr '[:upper:]' '[:lower:]' | sed 's/[^a-z0-9]/-/g' | sed 's/--*/-/g' | sed 's/^-//' | sed 's/-$//' } # --------------------------------------------------------------------------- @@ -651,6 +660,10 @@ 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)$" + # LC_ALL=C for the same collation reason documented on clean_branch_name, + # and so the `grep -qw` acronym probe below uses ASCII word boundaries like + # the Python twin's (? str: if len(word) >= 3: meaningful_words.append(word) # Keep short words only when they appear uppercased in the original - # description (acronyms like "API" or "DB"). - elif re.search(rf"\b{re.escape(word.upper())}\b", description): + # description (acronyms like "API" or "DB"). The boundaries are spelled + # out as ASCII rather than using \b: \b is Unicode-aware on str, so + # "\u00e9DB\u00e9 cache" would hide the acronym behind a non-ASCII word + # character, while the bash twin's `grep -qw` runs under LC_ALL=C and + # sees a boundary there. + elif re.search( + rf"(?=3.11" diff --git a/scripts/bash/common.sh b/scripts/bash/common.sh index b696bf5386..c0fcc33fb6 100644 --- a/scripts/bash/common.sh +++ b/scripts/bash/common.sh @@ -1447,12 +1447,20 @@ except Exception as exc: *'{CORE_TEMPLATE}'*) ;; *) echo "Error: wrap strategy missing {CORE_TEMPLATE} placeholder" >&2; return 2 ;; esac - while [[ "$layer_content" == *'{CORE_TEMPLATE}'* ]]; do - local before="${layer_content%%\{CORE_TEMPLATE\}*}" - local after="${layer_content#*\{CORE_TEMPLATE\}}" - layer_content="${before}${content}${after}" + # Consume the wrapper left to right instead of rewriting it in + # place. Rewriting re-scanned the string just modified, so base + # content holding a literal {CORE_TEMPLATE} reintroduced the + # token every pass and the loop never terminated. Advancing over + # ``rest`` bounds the work by the tokens in the original wrapper + # and leaves inserted content untouched, matching the single-pass + # semantics of .Replace()/.replace() in the PowerShell and Python + # ports. + local wrapped="" rest="$layer_content" + while [[ "$rest" == *'{CORE_TEMPLATE}'* ]]; do + wrapped="${wrapped}${rest%%\{CORE_TEMPLATE\}*}${content}" + rest="${rest#*\{CORE_TEMPLATE\}}" done - content="$layer_content" + content="${wrapped}${rest}" ;; *) echo "Error: unknown strategy '$strat'" >&2; return 2 ;; esac diff --git a/scripts/bash/create-new-feature.sh b/scripts/bash/create-new-feature.sh index 8373996ba9..3d775d6b2b 100755 --- a/scripts/bash/create-new-feature.sh +++ b/scripts/bash/create-new-feature.sh @@ -154,6 +154,21 @@ spec_prefix_exists() { return 1 } +# Function to clean and format a branch name +# +# Three details keep this byte-identical to the Python and PowerShell twins: +# * LC_ALL=C -- in a UTF-8 locale glibc resolves the a-z *range* through +# collation, so [^a-z0-9] keeps accented lowercase letters that +# re.sub(r"[^a-z0-9]", ...) and .NET's -replace both strip. +# * `--*` instead of the GNU-only `\+`, which POSIX/BSD sed reads as a literal +# '+', leaving repeated separators uncollapsed on macOS. +# * printf instead of echo, so a name of "-n"/"-e"/"-E" is text, not options. +clean_branch_name() { + local name="$1" + local -x LC_ALL=C + printf '%s\n' "$name" | tr '[:upper:]' '[:lower:]' | sed 's/[^a-z0-9]/-/g' | sed 's/--*/-/g' | sed 's/^-//' | sed 's/-$//' +} + # Fit a feature prefix and suffix within GitHub's branch-name limit. fit_branch_name() { local feature_num="$1" @@ -171,12 +186,6 @@ fit_branch_name() { printf '%s' "$branch_name" } -# Function to clean and format a branch name -clean_branch_name() { - local name="$1" - echo "$name" | tr '[:upper:]' '[:lower:]' | sed 's/[^a-z0-9]/-/g' | sed 's/-\+/-/g' | sed 's/^-//' | sed 's/-$//' -} - # Escape a string for safe embedding in a JSON value (fallback when jq is unavailable). json_escape() { local s="$1" @@ -233,7 +242,11 @@ generate_branch_name() { # Common stop words to filter out local stop_words="^(i|a|an|the|to|for|of|in|on|at|by|with|from|is|are|was|were|be|been|being|have|has|had|do|does|did|will|would|should|could|can|may|might|must|shall|this|that|these|those|my|your|our|their|want|need|add|get|set)$" - # Convert to lowercase and split into words + # Convert to lowercase and split into words. LC_ALL=C for the same + # collation reason documented on clean_branch_name, and so the `grep -qw` + # acronym probe below uses ASCII word boundaries like the Python twin's + # (?= 3 OR appear as uppercase in original (likely acronyms) if ($word.Length -ge 3) { $meaningfulWords += $word - } elseif ($Description -cmatch "\b$($word.ToUpper())\b") { + } elseif ($Description -cmatch "(?, regardless of ai_skills. ALWAYS_SLASH_AGENTS: frozenset[str] = frozenset( - {"devin", "droid", "dsh", "grok", "qodercli", "trae", "zed"} + {"devin", "droid", "dsh", "grok", "muse", "qodercli", "trae", "zed"} ) # Agents that render /speckit- only when ai_skills is enabled. diff --git a/src/specify_cli/commands/init.py b/src/specify_cli/commands/init.py index 73f0880e55..f58b20e045 100644 --- a/src/specify_cli/commands/init.py +++ b/src/specify_cli/commands/init.py @@ -1094,6 +1094,7 @@ def init( copilot_skill_mode = selected_ai == "copilot" and _is_skills_integration devin_skill_mode = selected_ai == "devin" zed_skill_mode = selected_ai == "zed" and _is_skills_integration + muse_skill_mode = selected_ai == "muse" and _is_skills_integration grok_skill_mode = selected_ai == "grok" and _is_skills_integration dsh_skill_mode = selected_ai == "dsh" and _is_skills_integration cline_skill_mode = selected_ai == "cline" @@ -1110,6 +1111,7 @@ def init( or copilot_skill_mode or devin_skill_mode or zed_skill_mode + or muse_skill_mode or grok_skill_mode or dsh_skill_mode or bob_skill_mode @@ -1133,6 +1135,11 @@ def init( if zed_skill_mode: steps_lines.append(f"{step_num}. Start Zed in this project directory; spec-kit skills were installed to {accent('.agents/skills')}") step_num += 1 + if muse_skill_mode: + steps_lines.append( + f"{step_num}. Start Muse Code in this project directory; spec-kit skills were installed to [cyan].agents/skills[/cyan]" + ) + step_num += 1 if grok_skill_mode: steps_lines.append( f"{step_num}. Start Grok Build in this project directory; spec-kit skills were installed to [cyan].grok/skills[/cyan]" diff --git a/src/specify_cli/extensions/__init__.py b/src/specify_cli/extensions/__init__.py index a314b89e3b..0a3fe513a3 100644 --- a/src/specify_cli/extensions/__init__.py +++ b/src/specify_cli/extensions/__init__.py @@ -1616,7 +1616,7 @@ def _replacement(match: re.Match[str]) -> str: ) return re.sub( - r"__SPECKIT_COMMAND_([A-Z][A-Z0-9_]*)__", _replacement, body + r"__SPECKIT_COMMAND_([A-Z][A-Z0-9_-]*)__", _replacement, body ) for cmd_info in manifest.commands: diff --git a/src/specify_cli/integrations/__init__.py b/src/specify_cli/integrations/__init__.py index d3e58c963f..ad38366938 100644 --- a/src/specify_cli/integrations/__init__.py +++ b/src/specify_cli/integrations/__init__.py @@ -75,6 +75,7 @@ def _register_builtins() -> None: from .kimi import KimiIntegration from .kiro_cli import KiroCliIntegration from .lingma import LingmaIntegration + from .muse import MuseIntegration from .omp import OmpIntegration from .opencode import OpencodeIntegration from .pi import PiIntegration @@ -117,6 +118,7 @@ def _register_builtins() -> None: _register(KimiIntegration()) _register(KiroCliIntegration()) _register(LingmaIntegration()) + _register(MuseIntegration()) _register(OmpIntegration()) _register(OpencodeIntegration()) _register(PiIntegration()) diff --git a/src/specify_cli/integrations/base.py b/src/specify_cli/integrations/base.py index 11bee04665..198309e8e0 100644 --- a/src/specify_cli/integrations/base.py +++ b/src/specify_cli/integrations/base.py @@ -837,6 +837,10 @@ def resolve_command_refs( custom placeholders such as ``__SPECKIT_COMMAND_CHANGE_IMPLEMENT__`` are resolved to the preset's alias (e.g. ``/change.implement``). + A hyphen belongs to the segment it sits in rather than separating + segments, so ``__SPECKIT_COMMAND_AGENT-CONTEXT_UPDATE__`` resolves to + ``/spec.agent-context.update``. + *prefix* defaults to ``"/"`` but may be ``"$"`` or ``"/skill:"`` for agents whose native skills invocation uses a different chat prefix. It only applies to the non-preset fallback path. @@ -858,7 +862,7 @@ def _replace(m: re.Match[str]) -> str: + placeholder.lower().replace("_", separator) ) - return re.sub(r"__SPECKIT_COMMAND_([A-Z][A-Z0-9_]*)__", _replace, content) + return re.sub(r"__SPECKIT_COMMAND_([A-Z][A-Z0-9_-]*)__", _replace, content) @staticmethod def resolve_python_interpreter(project_root: Path | None = None) -> str: diff --git a/src/specify_cli/integrations/muse/__init__.py b/src/specify_cli/integrations/muse/__init__.py new file mode 100644 index 0000000000..e812ae386b --- /dev/null +++ b/src/specify_cli/integrations/muse/__init__.py @@ -0,0 +1,63 @@ +"""Muse Code integration — skills-based agent (Meta). + +Muse Code discovers project skills from +``.agents/skills/speckit-/SKILL.md`` and invokes them via their +slash shortcut (``/speckit-``). + +See: https://dev.meta.ai/docs/muse-code +""" + +from __future__ import annotations + +from ..base import IntegrationOption, SkillsIntegration + + +class MuseIntegration(SkillsIntegration): + """Integration for Muse Code CLI.""" + + key = "muse" + config = { + "name": "Muse Code", + "folder": ".agents/", + "commands_subdir": "skills", + "install_url": "https://dev.meta.ai/docs/muse-code", + "requires_cli": True, + } + registrar_config = { + "dir": ".agents/skills", + "format": "markdown", + "args": "$ARGUMENTS", + "extension": "/SKILL.md", + } + # Muse Code shares the ``.agents/skills`` layout with Codex and Zed. + # Keep co-installation opt-in until shared manifest ownership is supported. + multi_install_safe = False + + @classmethod + def options(cls) -> list[IntegrationOption]: + return [ + IntegrationOption( + "--skills", + is_flag=True, + default=True, + help="Install as agent skills (default for Muse Code)", + ), + ] + + def build_exec_args( + self, + prompt: str, + *, + model: str | None = None, + output_json: bool = True, + ) -> list[str] | None: + # Muse Code uses ``muse exec ""`` for non-interactive mode. + # Resolve argv[0] via the shared executable resolver so operators can + # override the binary with SPECKIT_INTEGRATION_MUSE_EXECUTABLE. + args: list[str] = [self._resolve_executable(), "exec", prompt] + self._apply_extra_args_env_var(args) + if model: + args.extend(["--model", model]) + if output_json: + args.append("--json") + return args diff --git a/src/specify_cli/workflows/__init__.py b/src/specify_cli/workflows/__init__.py index 0d1e101a9e..1e608ca168 100644 --- a/src/specify_cli/workflows/__init__.py +++ b/src/specify_cli/workflows/__init__.py @@ -53,6 +53,7 @@ def _register_builtin_steps() -> None: from .steps.init import InitStep from .steps.prompt import PromptStep from .steps.shell import ShellStep + from .steps.slot import SlotStep from .steps.switch import SwitchStep from .steps.while_loop import WhileStep @@ -65,6 +66,7 @@ def _register_builtin_steps() -> None: _register_step(InitStep()) _register_step(PromptStep()) _register_step(ShellStep()) + _register_step(SlotStep()) _register_step(SwitchStep()) _register_step(WhileStep()) diff --git a/src/specify_cli/workflows/engine.py b/src/specify_cli/workflows/engine.py index d17513cc0b..411c1aac8a 100644 --- a/src/specify_cli/workflows/engine.py +++ b/src/specify_cli/workflows/engine.py @@ -139,7 +139,7 @@ def _get_valid_step_types() -> set[str]: if STEP_REGISTRY: return set(STEP_REGISTRY.keys()) return { - "command", "shell", "prompt", "gate", "if", "init", + "command", "shell", "prompt", "gate", "if", "init", "slot", "switch", "while", "do-while", "fan-out", "fan-in", } @@ -432,6 +432,13 @@ def _validate_steps( step_errors = step_impl.validate(step_config) errors.extend(step_errors) + if step_type == "slot" and inside_fan_out: + errors.append( + f"Slot step {step_id!r} is not supported inside fan-out " + "templates because overlays cannot address runtime-multiplied " + "templates." + ) + # Validate optional `continue_on_error` field. The engine honours # this on any step that returns StepStatus.FAILED so the pipeline can route # around the failure via a downstream `if` or `switch` (or a diff --git a/src/specify_cli/workflows/steps/do_while/__init__.py b/src/specify_cli/workflows/steps/do_while/__init__.py index 09c5763a5e..926e47b512 100644 --- a/src/specify_cli/workflows/steps/do_while/__init__.py +++ b/src/specify_cli/workflows/steps/do_while/__init__.py @@ -147,6 +147,14 @@ def validate(self, config: dict[str, Any]) -> list[str]: f"Do-while step {config.get('id', '?')!r}: " f"'max_iterations' must be an integer >= 1." ) + if "steps" not in config: + # This step's own docstring promises "The first invocation always + # returns the nested steps for execution" -- with no body it + # validated clean and then returned none, so the loop never ran even + # once. See the matching guard in the ``while`` step. + errors.append( + f"Do-while step {config.get('id', '?')!r} is missing 'steps' field." + ) nested = config.get("steps", []) if not isinstance(nested, list): errors.append( diff --git a/src/specify_cli/workflows/steps/slot/__init__.py b/src/specify_cli/workflows/steps/slot/__init__.py new file mode 100644 index 0000000000..bd784a7b00 --- /dev/null +++ b/src/specify_cli/workflows/steps/slot/__init__.py @@ -0,0 +1,67 @@ +"""Slot step — a named, no-op workflow slot. + +An upstream workflow declares a slot at the position where a downstream +project may extend it. The step ``id`` is the overlay anchor; ``name`` is only +the human-readable slot label. A project overlay fills the slot with the +standard ``replace`` operation on the slot step's ``id``. Unfilled slots are +skipped when the workflow runs. + +Example YAML:: + + # Upstream workflow + - id: post-implement + type: slot + name: post-implement + + # .specify/workflows/overlays/my-workflow/fill-post-implement.yml + id: fill-post-implement + extends: my-workflow + edits: + - replace: post-implement + step: + id: post-implement + type: shell + run: echo "Run project-specific checks" +""" + +from __future__ import annotations + +from typing import Any + +from specify_cli.workflows.base import StepBase, StepContext, StepResult, StepStatus + + +class SlotStep(StepBase): + """Provide a named workflow slot that skips when unfilled.""" + + type_key = "slot" + + def execute(self, config: dict[str, Any], context: StepContext) -> StepResult: + if context.inside_fan_out: + return StepResult( + status=StepStatus.FAILED, + error=( + f"Slot step {config.get('id', '?')!r} is not supported " + "inside fan-out templates because overlays cannot address " + "runtime-multiplied templates." + ), + ) + return StepResult( + status=StepStatus.SKIPPED, + output={"slot": config.get("name")}, + ) + + def validate(self, config: dict[str, Any]) -> list[str]: + errors = super().validate(config) + name = config.get("name") + if name is None: + errors.append( + f"Slot step {config.get('id', '?')!r} requires a 'name' field " + "(the slot label)." + ) + elif not isinstance(name, str) or not name.strip(): + errors.append( + f"Slot step {config.get('id', '?')!r}: 'name' must be a " + "non-blank string." + ) + return errors diff --git a/src/specify_cli/workflows/steps/while_loop/__init__.py b/src/specify_cli/workflows/steps/while_loop/__init__.py index 8238917320..cf6499a455 100644 --- a/src/specify_cli/workflows/steps/while_loop/__init__.py +++ b/src/specify_cli/workflows/steps/while_loop/__init__.py @@ -156,6 +156,18 @@ def validate(self, config: dict[str, Any]) -> list[str]: f"While step {config.get('id', '?')!r}: " f"'max_iterations' must be an integer >= 1." ) + if "steps" not in config: + # A loop with no body is never what the author meant, but it used to + # validate clean and then report COMPLETED at run time while + # returning no next_steps -- so the engine's ``if result.next_steps:`` + # block never fired and the loop the workflow is built around never + # ran once. The mistype is easy: fan-out's payload key is the + # singular ``step:``, so writing ``step:`` on a ``while`` produced a + # silent no-op. ``if`` already requires ``then`` and fan-out already + # requires both ``items`` and ``step``; require a body here too. + errors.append( + f"While step {config.get('id', '?')!r} is missing 'steps' field." + ) nested = config.get("steps", []) if not isinstance(nested, list): errors.append( diff --git a/tests/extensions/git/test_git_extension_python_parity.py b/tests/extensions/git/test_git_extension_python_parity.py index 7c4b66300b..3dc707ec60 100644 --- a/tests/extensions/git/test_git_extension_python_parity.py +++ b/tests/extensions/git/test_git_extension_python_parity.py @@ -37,10 +37,13 @@ import pytest from tests.conftest import requires_bash +from tests.parity_helpers import collation_range_locale from tests.extensions.git.test_git_extension import ( _GIT_ENV, + HAS_PWSH, _init_git, _run_bash, + _run_pwsh, _setup_project, _write_config, ) @@ -159,20 +162,144 @@ def test_slug_generation_stop_words_and_acronyms(self, tmp_path: Path): p = _run_py("create-new-feature-branch", py_proj, "--json", "--dry-run", description) _assert_parity(b, p) - def test_short_name_cleaning(self, tmp_path: Path): + @pytest.mark.parametrize( + "description", + [ + "Fix \u00e9DB\u00e9 sync", + "Tune the \u00fcUI\u00fc layer", + ], + ids=["db_between_accents", "ui_between_accents"], + ) + def test_acronym_adjacent_to_non_ascii_matches_python( + self, tmp_path: Path, description: str + ): + """A short acronym touching an accented letter is kept by both twins. + + The bash twin probes for acronyms with `grep -qw` under LC_ALL=C, where + an accented letter is a non-word byte and therefore a word boundary. + Python's \\b and .NET's \\b are Unicode-aware and saw no boundary there, + so the twins disagreed on whether the acronym survived. + """ + bash_proj, py_proj = _twin_projects(tmp_path) + b = _run_bash( + "create-new-feature-branch.sh", bash_proj, + "--json", "--dry-run", description, + ) + p = _run_py( + "create-new-feature-branch", py_proj, + "--json", "--dry-run", description, + ) + _assert_parity(b, p) + + if not HAS_PWSH: + pytest.skip("pwsh not available") + ps_proj = _setup_project(tmp_path / "ps" / "proj") + ps = _run_pwsh( + "create-new-feature-branch.ps1", ps_proj, + "-Json", "-DryRun", description, + ) + assert ps.returncode == 0, ps.stderr + assert json.loads(ps.stdout) == json.loads(b.stdout) + + @pytest.mark.parametrize( + ("short_name", "expected"), + [ + ("User_Auth!", "001-user-auth"), + ("User__Auth!!", "001-user-auth"), + ("auth -- v2", "001-auth-v2"), + ], + ids=["single_separators", "repeated_separators", "separator_run"], + ) + def test_short_name_cleaning( + self, tmp_path: Path, short_name: str, expected: str + ): + # Repeated separators included: the bash twin used to collapse them with + # sed 's/-\+/-/g', a GNU-ism that POSIX/BSD sed reads as a literal '+', + # so the runs survived on macOS. bash_proj, py_proj = _twin_projects(tmp_path) - # Single separator runs only: the bash twin's collapse step - # (sed 's/-\+/-/g') is a GNU-ism that BSD sed treats literally. b = _run_bash( "create-new-feature-branch.sh", bash_proj, - "--json", "--dry-run", "--short-name", "User_Auth!", "desc", + "--json", "--dry-run", "--short-name", short_name, "desc", ) p = _run_py( "create-new-feature-branch", py_proj, - "--json", "--dry-run", "--short-name", "User_Auth!", "desc", + "--json", "--dry-run", "--short-name", short_name, "desc", ) _assert_parity(b, p) - assert json.loads(p.stdout)["BRANCH_NAME"] == "001-user-auth" + assert json.loads(p.stdout)["BRANCH_NAME"] == expected + + @pytest.mark.parametrize( + "description", + [ + "Añadir autenticación de usuario", + "Ajouter la réservation hôtelière", + ], + ids=["spanish", "french"], + ) + def test_branch_name_ignores_locale_collation( + self, tmp_path: Path, description: str + ): + """The created git branch must not depend on the caller's locale. + + The bash twin sanitizes with sed 's/[^a-z0-9]/-/g'. Under a locale whose + a-z range is collation-ordered that class keeps accented lowercase + letters, so bash checked out 001-ajouter-réservation-hôtelière where + the Python twin checks out 001-ajouter-servation-teli. + """ + locale_name = collation_range_locale() + if locale_name is None: + pytest.skip("no locale with collation-ordered [a-z] ranges available") + env_extra = {"LC_ALL": locale_name, "LANG": locale_name} + + bash_proj, py_proj = _twin_projects(tmp_path) + b = _run_bash( + "create-new-feature-branch.sh", bash_proj, + "--json", "--dry-run", description, env_extra=env_extra, + ) + p = _run_py( + "create-new-feature-branch", py_proj, + "--json", "--dry-run", description, env_extra=env_extra, + ) + _assert_parity(b, p) + branch = json.loads(b.stdout)["BRANCH_NAME"] + assert branch.isascii(), branch + + # The run above reaches generate_branch_name. --short-name reaches + # clean_branch_name, a separate function carrying its own LC_ALL=C, so + # exercise the accented value through both: neither copy can then + # regress on its own without a failure here. + sn_bash_proj, sn_py_proj = _twin_projects(tmp_path / "short") + sb = _run_bash( + "create-new-feature-branch.sh", sn_bash_proj, + "--json", "--dry-run", "--short-name", description, "desc", + env_extra=env_extra, + ) + sp = _run_py( + "create-new-feature-branch", sn_py_proj, + "--json", "--dry-run", "--short-name", description, "desc", + env_extra=env_extra, + ) + _assert_parity(sb, sp) + short_branch = json.loads(sb.stdout)["BRANCH_NAME"] + assert short_branch.isascii(), short_branch + + @pytest.mark.parametrize("short_name", ["-n", "-e", "-E"], ids=["n", "e", "E"]) + def test_dash_prefixed_short_name(self, tmp_path: Path, short_name: str): + """A short name that looks like an ``echo`` option is still text. + + clean_branch_name piped the raw value through `echo "$name"`, so bash + consumed -n/-e/-E as options and emitted nothing, yielding a suffix-less + 001- where Python yields 001-n. This extension duplicates the + implementation, so it needs its own regression case: the core script's + test cannot catch a revert to `echo` here. + """ + bash_proj, py_proj = _twin_projects(tmp_path) + args = ("--json", "--dry-run", "--short-name", short_name, "desc") + b = _run_bash("create-new-feature-branch.sh", bash_proj, *args) + p = _run_py("create-new-feature-branch", py_proj, *args) + _assert_parity(b, p) + expected = f"001-{short_name.lstrip('-').lower()}" + assert json.loads(b.stdout)["BRANCH_NAME"] == expected def test_numbering_from_specs_and_branches(self, tmp_path: Path): bash_proj, py_proj = _twin_projects(tmp_path) diff --git a/tests/integrations/test_base.py b/tests/integrations/test_base.py index 6b2a2263c6..05bbee0892 100644 --- a/tests/integrations/test_base.py +++ b/tests/integrations/test_base.py @@ -311,6 +311,18 @@ def test_extension_command_hyphen(self, tmp_path): prefix = self._get_prefix() assert result == f"Run /{prefix}-git-commit to commit." + def test_hyphenated_command_dot(self): + text = "Run __SPECKIT_COMMAND_AGENT-CONTEXT_UPDATE__ to refresh." + result = IntegrationBase.resolve_command_refs(text, ".") + prefix = self._get_prefix() + assert result == f"Run /{prefix}.agent-context.update to refresh." + + def test_hyphenated_command_hyphen(self): + text = "Run __SPECKIT_COMMAND_AGENT-CONTEXT_UPDATE__ to refresh." + result = IntegrationBase.resolve_command_refs(text, "-") + prefix = self._get_prefix() + assert result == f"Run /{prefix}-agent-context-update to refresh." + def test_no_placeholders_unchanged(self): text = "No placeholders here." assert IntegrationBase.resolve_command_refs(text, ".") == text @@ -361,6 +373,10 @@ def test_lowercase_placeholder_not_matched(self): text = "Run __SPECKIT_COMMAND_plan__ to plan." assert IntegrationBase.resolve_command_refs(text, ".") == text + def test_leading_hyphen_not_matched(self): + text = "Run __SPECKIT_COMMAND_-PLAN__ to plan." + assert IntegrationBase.resolve_command_refs(text, ".") == text + def test_placeholder_adjacent_to_text(self): text = "foo__SPECKIT_COMMAND_PLAN__bar" result = IntegrationBase.resolve_command_refs(text, ".") diff --git a/tests/integrations/test_integration_muse.py b/tests/integrations/test_integration_muse.py new file mode 100644 index 0000000000..9e7aae61a4 --- /dev/null +++ b/tests/integrations/test_integration_muse.py @@ -0,0 +1,80 @@ +"""Tests for MuseIntegration — skills-based integration (Meta Muse Code).""" + +from .test_integration_base_skills import SkillsIntegrationTests + + +class TestMuseIntegration(SkillsIntegrationTests): + KEY = "muse" + FOLDER = ".agents/" + COMMANDS_SUBDIR = "skills" + REGISTRAR_DIR = ".agents/skills" + + +class TestMuseMetadata: + """Muse Code-specific metadata and headless dispatch.""" + + def test_display_name(self): + from specify_cli.integrations import get_integration + + assert get_integration("muse").config["name"] == "Muse Code" + + def test_requires_cli(self): + from specify_cli.integrations import get_integration + + assert get_integration("muse").config["requires_cli"] is True + + def test_install_url_points_to_muse_docs(self): + from specify_cli.integrations import get_integration + + assert ( + get_integration("muse").config["install_url"] + == "https://dev.meta.ai/docs/muse-code" + ) + + def test_not_multi_install_safe(self): + """Muse Code shares ``.agents/skills`` with Codex/Zed, so co-install + stays opt-in (same policy as the docker-agent integration).""" + from specify_cli.integrations import get_integration + + assert get_integration("muse").multi_install_safe is False + + def test_build_exec_args_uses_muse_exec(self): + from specify_cli.integrations import get_integration + + args = get_integration("muse").build_exec_args("do the thing") + assert args[:3] == ["muse", "exec", "do the thing"] + assert args[-1] == "--json" + + def test_build_exec_args_model_flag(self): + from specify_cli.integrations import get_integration + + args = get_integration("muse").build_exec_args( + "do the thing", model="muse-spark-1.2", output_json=False + ) + assert "--model" in args + assert args[args.index("--model") + 1] == "muse-spark-1.2" + assert "--json" not in args + + def test_next_steps_show_slash_skill_invocation(self, tmp_path): + """Muse Code next-steps guidance should display /spec-* usage.""" + import os + from typer.testing import CliRunner + from specify_cli import app + + project = tmp_path / "muse-next-steps" + project.mkdir() + old_cwd = os.getcwd() + try: + os.chdir(project) + runner = CliRunner() + result = runner.invoke(app, [ + "init", "--here", "--integration", "muse", + "--ignore-agent-tools", "--script", "sh", + ], catch_exceptions=False) + finally: + os.chdir(old_cwd) + + assert result.exit_code == 0 + assert "/spec-constitution" in result.output + assert "/speckit.constitution" not in result.output + assert "Muse Code" in result.output diff --git a/tests/integrations/test_registry.py b/tests/integrations/test_registry.py index df8357cae6..119baa0709 100644 --- a/tests/integrations/test_registry.py +++ b/tests/integrations/test_registry.py @@ -28,7 +28,7 @@ "gemini", "tabnine", # Stage 5 — skills, generic & option-driven integrations "codex", "kimi", "agy", "zed", "generic", - "droid", "command-code", "dsh", + "droid", "command-code", "dsh", "muse", ] diff --git a/tests/parity_helpers.py b/tests/parity_helpers.py index 939813ebcd..5f969cc5fc 100644 --- a/tests/parity_helpers.py +++ b/tests/parity_helpers.py @@ -87,9 +87,49 @@ def clean_env() -> dict[str, str]: return env +def collation_range_locale() -> str | None: + """A locale whose ``[a-z]`` bracket range is collation-ordered, or ``None``. + + glibc resolves a bracket-expression *range* through the locale's collation + table, so under ``en_US.UTF-8`` ``[^a-z0-9]`` leaves accented lowercase + letters alone while ``C.UTF-8`` and the POSIX locale strip them. Probe + ``sed`` directly rather than trusting a locale name: the environments where + the divergence cannot be reproduced (no such locale installed, a non-glibc + libc, Git-for-Windows) are exactly the ones where the probe comes back + clean, so the caller can skip. + """ + for name in ("en_US.UTF-8", "en_US.utf8"): + env = clean_env() + env["LC_ALL"] = name + env["LANG"] = name + try: + probe = subprocess.run( + ["sed", "s/[^a-z0-9]/-/g"], + input="é\n", + capture_output=True, + text=True, + check=False, + env=env, + ) + except OSError: # pragma: no cover - sed missing entirely + return None + if probe.returncode == 0 and "é" in probe.stdout: + return name + return None + + def run( - cmd: list[str], repo: Path, env: dict[str, str] | None = None + cmd: list[str], + repo: Path, + env: dict[str, str] | None = None, + timeout: float | None = None, ) -> subprocess.CompletedProcess[str]: + """Run a script variant. + + ``timeout`` guards cases whose regression mode is a hang rather than a bad + value; without it such a failure would stall the suite instead of failing + it. ``subprocess.TimeoutExpired`` propagates so the test reports the hang. + """ return subprocess.run( cmd, cwd=repo, @@ -97,6 +137,7 @@ def run( text=True, check=False, env=env if env is not None else clean_env(), + timeout=timeout, ) diff --git a/tests/test_agent_config_consistency.py b/tests/test_agent_config_consistency.py index 9280e17505..56578c3feb 100644 --- a/tests/test_agent_config_consistency.py +++ b/tests/test_agent_config_consistency.py @@ -41,6 +41,7 @@ "kiro-cli", "lingma", "vibe", + "muse", "omp", "opencode", "pi", diff --git a/tests/test_create_new_feature_python_parity.py b/tests/test_create_new_feature_python_parity.py index 06f3a4fb4f..fb70e14a01 100644 --- a/tests/test_create_new_feature_python_parity.py +++ b/tests/test_create_new_feature_python_parity.py @@ -14,6 +14,8 @@ HAS_POWERSHELL, bash_cmd, break_wrap_layer, + clean_env, + collation_range_locale, install_composition_stack, install_scripts, json_stdout, @@ -118,8 +120,18 @@ def deny_listing(_path: Path): "I want to add the new API rate limiting feature for users", "Fix UI for DB sync", "a to the of", + # An acronym touching an accented letter: bash probes with `grep -qw` + # under LC_ALL=C, where the accent is a word boundary, so the Python + # twin must use explicit ASCII lookarounds rather than a Unicode \b. + "Fix \u00e9DB\u00e9 sync", + ], + ids=[ + "plain", + "stop_words", + "acronyms", + "all_stop_words_fallback", + "acronym_next_to_non_ascii", ], - ids=["plain", "stop_words", "acronyms", "all_stop_words_fallback"], ) def test_python_branch_name_generation_matches_bash( repo: Path, description: str @@ -132,6 +144,26 @@ def test_python_branch_name_generation_matches_bash( assert json_stdout(py) == json_stdout(bash) +@requires_bash +@pytest.mark.skipif(not HAS_POWERSHELL, reason="no PowerShell available") +def test_all_variants_keep_acronym_next_to_non_ascii(repo: Path) -> None: + """An acronym touching an accented letter survives in all three twins. + + bash probes for acronyms with `grep -qw` under LC_ALL=C, where an accented + letter is a non-word byte and therefore a boundary. Python's \\b and .NET's + \\b are Unicode-aware and saw "\u00e9DB\u00e9" as a single word, dropping the + acronym; all three now spell the boundary out as ASCII. + """ + description = "Fix \u00e9DB\u00e9 sync" + bash = run(bash_cmd(repo, SCRIPT, "--json", "--dry-run", description), repo) + py = run(py_cmd(repo, SCRIPT, "--json", "--dry-run", description), repo) + ps = run(ps_cmd(repo, SCRIPT, "-Json", "-DryRun", description), repo) + + assert bash.returncode == py.returncode == ps.returncode == 0 + assert json_stdout(py) == json_stdout(bash) == json_stdout(ps) + assert json_stdout(ps)["BRANCH_NAME"] == "001-fix-db-sync" + + @requires_bash @pytest.mark.parametrize( "args", @@ -1068,6 +1100,111 @@ def test_all_variants_corrected_prefix_skips_timestamp_collision(repo: Path) -> assert "using 20260320 instead" in result.stderr +@requires_bash +@pytest.mark.parametrize( + "description", + [ + "Añadir autenticación de usuario", + "Prüfung für Benutzer anlegen", + "Ajouter la réservation hôtelière", + ], + ids=["spanish", "german", "french"], +) +def test_bash_branch_name_ignores_locale_collation( + repo: Path, description: str +) -> None: + """Branch naming must not depend on the caller's locale. + + ``clean_branch_name``/``generate_branch_name`` sanitize with + ``sed 's/[^a-z0-9]/-/g'``. Run under a collation-ordered locale that class + keeps accented lowercase letters, so bash produced + ``001-ajouter-réservation-hôtelière`` where the Python and PowerShell twins + produce ``001-ajouter-servation-teli``: the same description yielded a + different ``specs/`` directory on two machines that differ only in ``LANG``. + """ + locale_name = collation_range_locale() + if locale_name is None: + pytest.skip("no locale with collation-ordered [a-z] ranges available") + + env = clean_env() + env["LC_ALL"] = locale_name + env["LANG"] = locale_name + + bash = run(bash_cmd(repo, SCRIPT, "--json", "--dry-run", description), repo, env) + py = run(py_cmd(repo, SCRIPT, "--json", "--dry-run", description), repo, env) + + assert py.returncode == bash.returncode == 0 + assert json_stdout(py) == json_stdout(bash) + branch = json_stdout(bash)["BRANCH_NAME"] + assert isinstance(branch, str) and branch.isascii(), branch + + # The run above reaches generate_branch_name. --short-name reaches + # clean_branch_name, a separate function carrying its own LC_ALL=C, so + # exercise the accented value through both: neither copy can then regress + # on its own without a failure here. + short_args = ("--json", "--dry-run", "--short-name", description, "x") + bash_short = run(bash_cmd(repo, SCRIPT, *short_args), repo, env) + py_short = run(py_cmd(repo, SCRIPT, *short_args), repo, env) + + assert py_short.returncode == bash_short.returncode == 0 + assert json_stdout(py_short) == json_stdout(bash_short) + short_branch = json_stdout(bash_short)["BRANCH_NAME"] + assert isinstance(short_branch, str) and short_branch.isascii(), short_branch + + +@requires_bash +@pytest.mark.parametrize( + ("short_name", "expected"), + [ + ("My Fancy!! Name", "001-my-fancy-name"), + ("auth -- v2", "001-auth-v2"), + ], + ids=["punctuation_run", "separator_run"], +) +def test_bash_collapses_repeated_separators( + repo: Path, short_name: str, expected: str +) -> None: + """Runs of non-alphanumeric characters collapse to a single hyphen. + + The bash twin squeezed them with ``sed 's/-\\+/-/g'``. ``\\+`` is a GNU + extension, not POSIX BRE: BSD ``sed`` (macOS) reads it as a literal ``+``, + so nothing collapsed and the branch became ``001-my-fancy---name``. + """ + bash = run( + bash_cmd(repo, SCRIPT, "--json", "--dry-run", "--short-name", short_name, "x"), + repo, + ) + py = run( + py_cmd(repo, SCRIPT, "--json", "--dry-run", "--short-name", short_name, "x"), + repo, + ) + + assert bash.returncode == py.returncode == 0 + assert json_stdout(bash) == json_stdout(py) + assert json_stdout(bash)["BRANCH_NAME"] == expected + + +@requires_bash +@pytest.mark.parametrize("short_name", ["-n", "-e", "-E"], ids=["n", "e", "E"]) +def test_python_dash_prefixed_short_name_matches_bash( + repo: Path, short_name: str +) -> None: + """A short name that looks like an ``echo`` option is still text. + + ``clean_branch_name`` piped the raw value through ``echo "$name"``, so bash + consumed ``-n``/``-e``/``-E`` as options and emitted nothing, yielding the + suffix-less ``001-`` where Python yields ``001-n``. + """ + args = ("--json", "--dry-run", "--short-name", short_name, "x") + bash = run(bash_cmd(repo, SCRIPT, *args), repo) + py = run(py_cmd(repo, SCRIPT, *args), repo) + + assert py.returncode == bash.returncode == 0 + assert json_stdout(py) == json_stdout(bash) + expected = f"001-{short_name.lstrip('-').lower()}" + assert json_stdout(bash)["BRANCH_NAME"] == expected + + @pytest.mark.skipif(not HAS_POWERSHELL, reason="no PowerShell available") @pytest.mark.parametrize( "description", diff --git a/tests/test_extension_skills.py b/tests/test_extension_skills.py index 0c8335c7a1..416e5a0417 100644 --- a/tests/test_extension_skills.py +++ b/tests/test_extension_skills.py @@ -1165,6 +1165,52 @@ def test_skill_registration_resolves_command_ref_tokens( assert "__SPECKIT_COMMAND_PLAN__" not in content assert expected_invocation in content + def test_skill_registration_resolves_hyphenated_command_ref_tokens( + self, project_dir, temp_dir + ): + """Command names containing a hyphen resolve like any other name.""" + _create_init_options(project_dir, ai="claude", ai_skills=True) + skills_dir = _create_skills_dir(project_dir, ai="claude") + + ext_dir = temp_dir / "hyphen-ref-ext" + ext_dir.mkdir() + manifest_data = { + "schema_version": "1.0", + "extension": { + "id": "hyphen-ref-ext", + "name": "Hyphen Ref Extension", + "version": "1.0.0", + "description": "Test", + }, + "requires": {"speckit_version": ">=0.1.0"}, + "provides": { + "commands": [ + { + "name": "speckit.hyphen-ref-ext.run", + "file": "commands/run.md", + "description": "Run command", + } + ] + }, + } + with open(ext_dir / "extension.yml", "w") as f: + yaml.safe_dump(manifest_data, f) + + (ext_dir / "commands").mkdir() + (ext_dir / "commands" / "run.md").write_text( + "---\n" + "description: Run command\n" + "---\n\n" + "Use __SPECKIT_COMMAND_AGENT-CONTEXT_UPDATE__ before proceeding.\n" + ) + + manager = ExtensionManager(project_dir) + manager.install_from_directory(ext_dir, "0.1.0", register_commands=False) + + content = (skills_dir / "speckit-hyphen-ref-ext-run" / "SKILL.md").read_text() + assert "__SPECKIT_COMMAND_AGENT-CONTEXT_UPDATE__" not in content + assert "/speckit-agent-context-update" in content + def test_skill_registration_does_not_rewrite_literal_speckit_text( self, project_dir, temp_dir ): diff --git a/tests/test_resolve_template_python_parity.py b/tests/test_resolve_template_python_parity.py index 9af5554b44..2bf9977e14 100644 --- a/tests/test_resolve_template_python_parity.py +++ b/tests/test_resolve_template_python_parity.py @@ -91,6 +91,40 @@ def test_all_variants_preserve_composition_parity( ) +@requires_bash +def test_all_variants_treat_core_token_in_core_content_as_literal( + tmp_path: Path, +) -> None: + """Core content holding a literal ``{CORE_TEMPLATE}`` must not be re-expanded. + + The wrap strategy fills the placeholders present in the *wrapper*. A token + that arrives as part of the composed core content is data, not a slot, so it + survives into the output untouched. Rescanning the substituted string instead + reintroduces a token on every pass and never terminates, so the regression + mode here is a hang rather than a wrong value -- hence the timeout, without + which a reintroduced bug would stall the suite instead of failing it. + """ + repo = make_repo(tmp_path) + install_scripts(repo, SCRIPT) + expected = install_composition_stack(repo, TEMPLATE, "# Core {CORE_TEMPLATE}\n") + + results = [ + run(bash_cmd(repo, SCRIPT, TEMPLATE, "--json"), repo, timeout=30), + run(py_cmd(repo, SCRIPT, TEMPLATE, "--json"), repo, timeout=30), + ] + if HAS_POWERSHELL: + results.append(run(ps_cmd(repo, SCRIPT, TEMPLATE, "-Json"), repo, timeout=30)) + + assert all(result.returncode == 0 for result in results) + assert all(result.stderr == "" for result in results) + # The wrapper contributes exactly one placeholder, so exactly one literal + # token -- the one carried in by the core content -- remains in the output. + assert expected.count("{CORE_TEMPLATE}") == 1 + assert all( + json_stdout(result)["TEMPLATE_CONTENT"] == expected for result in results + ) + + @requires_bash def test_all_variants_read_utf8_registry_under_ascii_locale( tmp_path: Path, diff --git a/tests/test_workflows.py b/tests/test_workflows.py index 7719208434..ba7ab3427c 100644 --- a/tests/test_workflows.py +++ b/tests/test_workflows.py @@ -4,7 +4,7 @@ - Step registry & auto-discovery - Base classes (StepBase, StepContext, StepResult) - Expression engine -- All 10 built-in step types +- All 12 built-in step types - Workflow definition loading & validation - Workflow engine execution & state persistence - Workflow catalog & registry @@ -118,7 +118,7 @@ def test_all_step_types_registered(self): expected = { "command", "shell", "prompt", "gate", "if", "switch", - "while", "do-while", "fan-out", "fan-in", "init", + "while", "do-while", "fan-out", "fan-in", "init", "slot", } assert expected.issubset(set(STEP_REGISTRY.keys())) @@ -3638,6 +3638,33 @@ def test_validate_missing_fields(self): assert any("missing 'condition'" in e for e in errors) # max_iterations is optional (defaults to 10) + def test_validate_requires_steps_body(self): + """A while loop with no body must be rejected, not silently a no-op. + + Without this, ``step:`` written instead of ``steps:`` -- an easy slip, + since fan-out's payload key really is the singular ``step:`` -- passed + ``specify workflow validate`` with zero errors, and then reported + COMPLETED at run time while returning no ``next_steps``, so the loop + never ran even once. + """ + from specify_cli.workflows.base import StepContext, StepStatus + from specify_cli.workflows.steps.while_loop import WhileStep + + step = WhileStep() + config = { + "id": "retry", + "condition": "true", + # The mistype: singular 'step' instead of 'steps'. + "step": {"id": "x", "type": "command", "command": "echo"}, + } + errors = step.validate(config) + assert errors == ["While step 'retry' is missing 'steps' field."], errors + + # Demonstrates why it matters: execution is a silent no-op. + result = step.execute(config, StepContext()) + assert result.status == StepStatus.COMPLETED + assert result.next_steps == [] + @pytest.mark.parametrize("bad", [["a", "b"], {"k": "v"}, 5, 1.5]) def test_validate_rejects_non_string_non_bool_condition(self, bad): from specify_cli.workflows.steps.while_loop import WhileStep @@ -3770,6 +3797,25 @@ def test_validate_missing_fields(self): assert any("missing 'condition'" in e for e in errors) # max_iterations is optional (defaults to 10) + def test_validate_requires_steps_body(self): + """A do-while with no body must be rejected, not silently a no-op. + + The step's own docstring promises "The first invocation always returns + the nested steps for execution" -- with no body it validated clean and + then returned none, so the loop never ran even once. + """ + from specify_cli.workflows.base import StepContext, StepStatus + from specify_cli.workflows.steps.do_while import DoWhileStep + + step = DoWhileStep() + config = {"id": "refine", "condition": "true", "max_iterations": 3} + errors = step.validate(config) + assert errors == ["Do-while step 'refine' is missing 'steps' field."], errors + + result = step.execute(config, StepContext()) + assert result.status == StepStatus.COMPLETED + assert result.next_steps == [] + @pytest.mark.parametrize("bad", [["a", "b"], {"k": "v"}, 5, 1.5]) def test_validate_rejects_non_string_non_bool_condition(self, bad): from specify_cli.workflows.steps.do_while import DoWhileStep diff --git a/tests/unit/test_bundler_references.py b/tests/unit/test_bundler_references.py index b9ad426660..b910a93e99 100644 --- a/tests/unit/test_bundler_references.py +++ b/tests/unit/test_bundler_references.py @@ -27,7 +27,7 @@ def test_bundled_extension_resolves(tmp_path: Path): def test_builtin_step_type_resolves(tmp_path: Path): """A built-in step type must resolve, like a bundled extension. - Spec Kit ships 11 step types as built-ins registered in ``STEP_REGISTRY`` + Spec Kit ships 12 step types as built-ins registered in ``STEP_REGISTRY`` rather than as on-disk asset directories, so there is no ``_locate_bundled_step``. The ``steps`` branch of ``_resolved_locally`` only asked ``StepRegistry(root).is_installed()``, which tracks *community* step @@ -40,7 +40,7 @@ def test_builtin_step_type_resolves(tmp_path: Path): warnings: list[str] = [] check = make_reference_checker(root, allow_network=True, warnings=warnings) - for step_id in ("shell", "gate", "command", "if"): + for step_id in ("shell", "gate", "command", "if", "slot"): assert step_id in BUILTIN_STEP_TYPES, step_id assert check(_ref("steps", step_id)) is None, step_id assert warnings == [] diff --git a/tests/workflows/test_bundled_speckit_workflow.py b/tests/workflows/test_bundled_speckit_workflow.py new file mode 100644 index 0000000000..77351ff017 --- /dev/null +++ b/tests/workflows/test_bundled_speckit_workflow.py @@ -0,0 +1,32 @@ +"""Guards for the bundled Full SDD Cycle workflow.""" + +from __future__ import annotations + +from pathlib import Path + +import yaml + +from specify_cli.workflows.engine import WorkflowDefinition, validate_workflow + +BUNDLED = ( + Path(__file__).resolve().parents[2] / "workflows" / "speckit" / "workflow.yml" +) + + +def test_bundled_speckit_workflow_has_no_unused_scope_input() -> None: + """Every declared input must be referenced; scope was a dead prompt (#4384).""" + text = BUNDLED.read_text(encoding="utf-8") + definition = WorkflowDefinition.from_string(text) + assert validate_workflow(definition) == [] + assert "scope" not in definition.inputs + assert "spec" in definition.inputs + + raw = yaml.safe_load(text) + assert "scope" not in raw.get("inputs", {}) + assert "inputs.scope" not in text + + for step in raw["steps"]: + args = (step.get("input") or {}).get("args") + if args is None: + continue + assert "inputs.scope" not in str(args) diff --git a/tests/workflows/test_slot_step.py b/tests/workflows/test_slot_step.py new file mode 100644 index 0000000000..50416586a7 --- /dev/null +++ b/tests/workflows/test_slot_step.py @@ -0,0 +1,242 @@ +"""Tests for the workflow slot step.""" + +from __future__ import annotations + +import json +from pathlib import Path + +import pytest +import yaml + +from specify_cli.workflows import BUILTIN_STEP_TYPES, get_step_type +from specify_cli.workflows.base import RunStatus, StepContext, StepStatus +from specify_cli.workflows.engine import WorkflowDefinition, WorkflowEngine, validate_workflow +from specify_cli.workflows.overlays import WorkflowResolver +from specify_cli.workflows.steps.slot import SlotStep + + +def _workflow_data(steps: list[dict[str, object]]) -> dict[str, object]: + return { + "schema_version": "1.0", + "workflow": {"id": "slot-workflow", "name": "Slot Workflow", "version": "1.0.0"}, + "steps": steps, + } + + +def _write_workflow(project_root: Path, data: dict[str, object]) -> None: + workflow_dir = project_root / ".specify" / "workflows" / "slot-workflow" + workflow_dir.mkdir(parents=True, exist_ok=True) + (workflow_dir / "workflow.yml").write_text( + yaml.safe_dump(data), encoding="utf-8" + ) + + +def _write_overlay(project_root: Path, data: dict[str, object]) -> None: + overlay_dir = ( + project_root / ".specify" / "workflows" / "overlays" / "slot-workflow" + ) + overlay_dir.mkdir(parents=True, exist_ok=True) + (overlay_dir / "fill-slot.yml").write_text(yaml.safe_dump(data), encoding="utf-8") + + +def test_slot_step_is_registered_as_builtin(): + step = get_step_type("slot") + + assert isinstance(step, SlotStep) + assert step.type_key == "slot" + assert "slot" in BUILTIN_STEP_TYPES + + +def test_slot_step_validate_returns_errors_for_malformed_names(): + step = SlotStep() + + assert any("missing required 'id'" in error for error in step.validate({})) + assert "requires a 'name' field" in step.validate({"id": "slot"})[0] + assert "requires a 'name' field" in step.validate({"id": "slot", "name": None})[0] + for name in ("", " ", 123): + errors = step.validate({"id": "slot", "name": name}) + assert len(errors) == 1 + assert "non-blank string" in errors[0] + assert step.validate({"id": "slot", "name": "lint"}) == [] + + +@pytest.mark.parametrize( + ("name", "expected_error"), + [ + (None, "requires a 'name' field"), + ("", "non-blank string"), + (" ", "non-blank string"), + (123, "non-blank string"), + ], +) +def test_slot_step_errors_are_reported_through_workflow_validation( + name: object, expected_error: str +): + definition = WorkflowDefinition( + _workflow_data([{"id": "slot", "type": "slot", "name": name}]) + ) + + errors = validate_workflow(definition) + + assert any("Slot step 'slot'" in error for error in errors) + assert any(expected_error in error for error in errors) + + +def test_addressable_nested_slot_step_validates_cleanly(): + definition = WorkflowDefinition( + _workflow_data( + [ + { + "id": "conditional", + "type": "if", + "condition": "true", + "then": [{"id": "slot", "type": "slot", "name": "lint"}], + } + ] + ) + ) + + assert validate_workflow(definition) == [] + + +def test_slot_step_skips_without_mutating_the_shared_instance(): + step = SlotStep() + before = vars(step).copy() + + result = step.execute({"id": "slot", "name": "lint"}, StepContext()) + + assert result.status is StepStatus.SKIPPED + assert result.output == {"slot": "lint"} + assert vars(step) == before + + +def test_slot_step_fails_when_executed_inside_fan_out(): + step = SlotStep() + + result = step.execute( + {"id": "slot", "name": "per-item"}, + StepContext(inside_fan_out=True), + ) + + assert result.status is StepStatus.FAILED + assert "not supported inside fan-out" in result.error + assert result.output == {} + + +def test_unfilled_slot_is_persisted_and_does_not_halt_workflow(project_dir): + _write_workflow( + project_dir, + _workflow_data( + [ + {"id": "slot", "type": "slot", "name": "post-implement"}, + {"id": "marker", "type": "shell", "run": "echo marker"}, + ] + ), + ) + engine = WorkflowEngine(project_dir) + + definition = engine.load_workflow("slot-workflow") + assert engine.validate(definition) == [] + state = engine.execute(definition, run_id="slot-run") + + assert state.status is RunStatus.COMPLETED + state_data = json.loads((state.runs_dir / "state.json").read_text(encoding="utf-8")) + assert state_data["step_results"]["slot"]["status"] == "skipped" + assert state_data["step_results"]["slot"]["output"] == {"slot": "post-implement"} + assert state_data["step_results"]["marker"]["status"] == "completed" + + log_entries = [ + json.loads(line) + for line in (state.runs_dir / "log.jsonl").read_text(encoding="utf-8").splitlines() + ] + skipped_events = [ + entry + for entry in log_entries + if entry["event"] == "step_completed" and entry["step_id"] == "slot" + ] + assert len(skipped_events) == 1 + assert skipped_events[0]["status"] == "skipped" + + +def test_overlay_replaces_slot_and_attributes_it_to_the_overlay(project_dir): + _write_workflow( + project_dir, + _workflow_data( + [ + {"id": "before", "type": "shell", "run": "echo before"}, + {"id": "slot", "type": "slot", "name": "post-implement"}, + {"id": "after", "type": "shell", "run": "echo after"}, + ] + ), + ) + _write_overlay( + project_dir, + { + "id": "fill-slot", + "extends": "slot-workflow", + "edits": [ + { + "replace": "slot", + "step": {"id": "slot", "type": "shell", "run": "echo filled"}, + } + ], + }, + ) + engine = WorkflowEngine(project_dir) + + definition = engine.load_workflow("slot-workflow") + assert [step["id"] for step in definition.steps] == ["before", "slot", "after"] + assert definition.steps[1]["type"] == "shell" + assert engine.validate(definition) == [] + state = engine.execute(definition, run_id="filled-slot-run") + assert state.status is RunStatus.COMPLETED + assert "filled" in state.step_results["slot"]["output"]["stdout"] + + _definition, _layers, attribution = WorkflowResolver(project_dir).resolve_with_layers( + "slot-workflow" + ) + sources = {step.step_id: step.source for step in attribution} + assert sources == { + "before": "base", + "slot": "project:fill-slot", + "after": "base", + } + + +def test_slot_steps_are_rejected_inside_fan_out_templates(): + definition = WorkflowDefinition( + _workflow_data( + [ + { + "id": "fan", + "type": "fan-out", + "items": [], + "step": {"id": "slot", "type": "slot", "name": "per-item"}, + } + ] + ) + ) + + errors = validate_workflow(definition) + + assert any( + "Slot step 'slot' is not supported inside fan-out templates" in error + for error in errors + ) + + +def test_non_slot_fan_out_templates_remain_valid(): + definition = WorkflowDefinition( + _workflow_data( + [ + { + "id": "fan", + "type": "fan-out", + "items": [], + "step": {"id": "template", "type": "shell", "run": "echo item"}, + } + ] + ) + ) + + assert validate_workflow(definition) == [] diff --git a/workflows/ARCHITECTURE.md b/workflows/ARCHITECTURE.md index 477c0968ae..680cfc4237 100644 --- a/workflows/ARCHITECTURE.md +++ b/workflows/ARCHITECTURE.md @@ -19,6 +19,7 @@ flowchart TD G --> H{Step type?} H -- command --> I["CommandStep.execute()"] H -- shell --> J["ShellStep.execute()"] + H -- slot --> V["SlotStep.execute()"] H -- gate --> K["GateStep.execute()"] H -- "if" --> L["IfThenStep.execute()"] H -- switch --> M["SwitchStep.execute()"] @@ -27,12 +28,13 @@ flowchart TD I --> P{Result status?} J --> P + V --> P K --> P L --> P M --> P N --> P O --> P - P -- COMPLETED --> Q{Has next_steps?} + P -- "COMPLETED / SKIPPED" --> Q{Has next_steps?} P -- PAUSED --> R["Save state → exit"] P -- FAILED --> S["Log error → exit"] Q -- Yes --> G @@ -77,7 +79,7 @@ When a `gate` step pauses execution, the engine persists `current_step_index` an ## Step Types -The engine ships with 11 built-in step types, each in its own subpackage under `src/specify_cli/workflows/steps/`: +The engine ships with 12 built-in step types, each in its own subpackage under `src/specify_cli/workflows/steps/`: | Type Key | Class | Purpose | Returns `next_steps`? | |----------|-------|---------|-----------------------| @@ -85,6 +87,7 @@ The engine ships with 11 built-in step types, each in its own subpackage under ` | `prompt` | `PromptStep` | Send an arbitrary inline prompt to integration CLI | No | | `shell` | `ShellStep` | Run a shell command, capture output | No | | `init` | `InitStep` | Bootstrap a project (equivalent to `specify init`) | No | +| `slot` | `SlotStep` | Named workflow slot; skipped when unfilled | No | | `gate` | `GateStep` | Interactive human review/approval | No (pauses in CI) | | `if` | `IfThenStep` | Conditional branching (then/else) | Yes | | `switch` | `SwitchStep` | Multi-branch dispatch on expression | Yes | @@ -200,6 +203,7 @@ src/specify_cli/ │ ├── command/ # Dispatch command to AI integration │ ├── shell/ # Run shell command │ ├── init/ # Bootstrap a project (specify init) +│ ├── slot/ # Named workflow slot; skipped when unfilled │ ├── gate/ # Human review checkpoint │ ├── if_then/ # Conditional branching │ ├── prompt/ # Arbitrary inline prompts diff --git a/workflows/PUBLISHING.md b/workflows/PUBLISHING.md index 2caf55d810..4e26fdfb25 100644 --- a/workflows/PUBLISHING.md +++ b/workflows/PUBLISHING.md @@ -90,7 +90,7 @@ steps: - ✅ `version` follows semantic versioning (X.Y.Z) - ✅ `description` is concise - ✅ All step IDs are unique -- ✅ Step types are valid: `command`, `prompt`, `shell`, `gate`, `if`, `switch`, `while`, `do-while`, `fan-out`, `fan-in` +- ✅ Step types are valid: `command`, `prompt`, `shell`, `init`, `slot`, `gate`, `if`, `switch`, `while`, `do-while`, `fan-out`, `fan-in` - ✅ Required fields present per step type (e.g., `condition` for `if`, `expression` for `switch`) - ✅ Input types are valid: `string`, `number`, `boolean` - ✅ Step IDs do not contain `:` (reserved for engine-generated nested IDs like `parentId:childId`) diff --git a/workflows/README.md b/workflows/README.md index d5569541a2..676ea6faf0 100644 --- a/workflows/README.md +++ b/workflows/README.md @@ -70,15 +70,22 @@ specify workflow run ./my-workflow.yml --input spec="Build a user authentication ### Multiple Inputs +When a workflow declares more than one input, pass each with a separate +`--input` flag. For example, a custom workflow that gates steps on a +`scope` selector: + ```bash -specify workflow run speckit \ +specify workflow run ./my-workflow.yml \ --input spec="Build a user authentication system with OAuth support" \ --input scope="backend-only" ``` +The bundled `speckit` workflow only declares `spec` (and optional +`integration`); it does not take a `scope` input. + ## Step Types -Workflows support 11 built-in step types: +Workflows support 12 built-in step types: ### Command Steps (default) @@ -150,6 +157,24 @@ and resolves the integration from the step config or the workflow default: preset: healthcare-compliance # Optional preset ID ``` +### Workflow Slots + +Declare a named workflow slot that downstream projects can fill with a +workflow overlay. The slot is skipped when unfilled; its `id` is the overlay +anchor and `name` is a required human-readable label: + +```yaml +- id: post-implement + type: slot + name: "Post-implementation checks" +``` + +Use an overlay `replace` edit anchored on `post-implement` to fill the slot. +Keep the same `id` when downstream expressions or fan-in steps reference it, +and preserve any output keys they consume. Slot steps are invalid inside +`fan-out.step` templates because those runtime-multiplied templates cannot be +targeted by overlays. + ### Gate Steps Pause for human review. The workflow resumes when `specify workflow resume` is called: diff --git a/workflows/catalog.json b/workflows/catalog.json index d9985fb1ae..2ae2d59ecc 100644 --- a/workflows/catalog.json +++ b/workflows/catalog.json @@ -1,6 +1,6 @@ { "schema_version": "1.0", - "updated_at": "2026-07-02T00:00:00Z", + "updated_at": "2026-09-03T00:00:00Z", "catalog_url": "https://raw.githubusercontent.com/github/spec-kit/main/workflows/catalog.json", "workflows": { "speckit": { @@ -8,7 +8,7 @@ "name": "Full SDD Cycle", "description": "Runs specify \u2192 plan \u2192 tasks \u2192 implement with review gates", "author": "GitHub", - "version": "1.0.0", + "version": "1.0.1", "url": "https://raw.githubusercontent.com/github/spec-kit/main/workflows/speckit/workflow.yml", "tags": ["sdd", "full-cycle"] }, diff --git a/workflows/speckit/workflow.yml b/workflows/speckit/workflow.yml index d52d00587a..bb25ab68cb 100644 --- a/workflows/speckit/workflow.yml +++ b/workflows/speckit/workflow.yml @@ -2,7 +2,7 @@ schema_version: "1.0" workflow: id: "speckit" name: "Full SDD Cycle" - version: "1.0.0" + version: "1.0.1" author: "GitHub" description: "Runs specify → plan → tasks → implement with review gates" @@ -35,10 +35,6 @@ inputs: type: string default: "auto" prompt: "Integration to use (e.g. claude, copilot, gemini; 'auto' uses the project's initialized integration)" - scope: - type: string - default: "full" - enum: ["full", "backend-only", "frontend-only"] steps: - id: specify