From 8da51c1b0b27731d9f53ad2ad66819a46bc530f7 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Wed, 2 Sep 2026 14:44:10 -0500 Subject: [PATCH 01/12] [extension] Update Charter extension to v0.6.1 (#4409) * Update Charter extension to v0.6.1 Update charter extension submitted by @Huljo: - extensions/catalog.community.json (version, download_url, etc.) - docs/community/extensions.md community extensions table Closes #4404 Assisted-by: GitHub Copilot (model: gpt-5.2-codex, autonomous) Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * Fix fragments entry in catalog community JSON Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --------- Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com> Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Co-authored-by: Ken Schlobohm Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --- docs/community/extensions.md | 2 +- extensions/catalog.community.json | 18 +++++++++--------- 2 files changed, 10 insertions(+), 10 deletions(-) diff --git a/docs/community/extensions.md b/docs/community/extensions.md index 4c995b0f52..072601fb39 100644 --- a/docs/community/extensions.md +++ b/docs/community/extensions.md @@ -52,7 +52,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) | diff --git a/extensions/catalog.community.json b/extensions/catalog.community.json index 6044a2ee02..0f918c1d9f 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-02T00:00:00Z", "catalog_url": "https://raw.githubusercontent.com/github/spec-kit/main/extensions/catalog.community.json", "extensions": { "adrkit": { @@ -1039,13 +1039,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 +1066,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", From 6bfeea6b110f3ca1d42a107d2355d8654e0fca63 Mon Sep 17 00:00:00 2001 From: Yash-Chindam <108891350+Yash-Chindam@users.noreply.github.com> Date: Thu, 3 Sep 2026 02:34:38 +0530 Subject: [PATCH 02/12] fix(scripts): stop wrap composition looping on a token in core content (#4396) The bash wrap strategy rewrote layer_content in place and then re-tested the string it had just modified. When the resolved core content held a literal {CORE_TEMPLATE}, every pass reintroduced the token and the loop never terminated. Consume the wrapper left to right instead, appending each segment and the core content to an accumulator. Work is bounded by the placeholders in the original wrapper and inserted content is never re-examined, matching the single-pass semantics the PowerShell (.Replace) and Python (.replace) ports already have -- so this aligns bash with the other two rather than introducing new behaviour. The regression mode is a hang rather than a wrong value, so the new parity test passes a timeout; run() grows an optional timeout parameter for that. Without it a reintroduced bug would stall the suite instead of failing it. Fixes #4385 --- scripts/bash/common.sh | 18 ++++++++--- tests/parity_helpers.py | 12 ++++++- tests/test_resolve_template_python_parity.py | 34 ++++++++++++++++++++ 3 files changed, 58 insertions(+), 6 deletions(-) diff --git a/scripts/bash/common.sh b/scripts/bash/common.sh index 33f90b8dbb..9efcfad5e6 100644 --- a/scripts/bash/common.sh +++ b/scripts/bash/common.sh @@ -902,12 +902,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/tests/parity_helpers.py b/tests/parity_helpers.py index 27627dab5b..3a3878de8d 100644 --- a/tests/parity_helpers.py +++ b/tests/parity_helpers.py @@ -83,8 +83,17 @@ def clean_env() -> dict[str, str]: 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, @@ -92,6 +101,7 @@ def run( text=True, check=False, env=env if env is not None else clean_env(), + timeout=timeout, ) 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, From ab3d2fa8a47393fa0bda7d44671a3f824504e4cd Mon Sep 17 00:00:00 2001 From: Manfred Riem <15701806+mnriem@users.noreply.github.com> Date: Wed, 2 Sep 2026 16:08:23 -0500 Subject: [PATCH 03/12] chore: release 1.0.4, begin 1.0.5.dev0 development (#4411) * chore: bump version to 1.0.4 * chore: begin 1.0.5.dev0 development --------- Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com> --- CHANGELOG.md | 25 +++++++++++++++++++++++++ pyproject.toml | 2 +- 2 files changed, 26 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 3948a9f520..bfa0e2ed0b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,31 @@ +## [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/pyproject.toml b/pyproject.toml index 72e8134ceb..e43ab9a1d8 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "specify-cli" -version = "1.0.4.dev0" +version = "1.0.5.dev0" description = "Specify CLI, part of GitHub Spec Kit. A tool to bootstrap your projects for Spec-Driven Development (SDD)." readme = "README.md" requires-python = ">=3.11" From a369c5c27f05ec51fe1667051cfe106f424975b5 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Wed, 2 Sep 2026 17:28:16 -0500 Subject: [PATCH 04/12] [extension] Add Axi Extension to community catalog (#4412) * Add Axi Extension to community catalog Add axi extension submitted by @d0whc3r to:\n- extensions/catalog.community.json (alphabetical order)\n- docs/community/extensions.md community extensions table\n\nCloses #3948\n\nCo-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>\nAssisted-by: GitHub Copilot (model: gpt-5.2-codex, autonomous) * Add tools requirement to catalog community JSON Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --------- Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com> Co-authored-by: Ken Schlobohm Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --- docs/community/extensions.md | 1 + extensions/catalog.community.json | 38 +++++++++++++++++++++++++++++++ 2 files changed, 39 insertions(+) diff --git a/docs/community/extensions.md b/docs/community/extensions.md index 072601fb39..2fc09903eb 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) | diff --git a/extensions/catalog.community.json b/extensions/catalog.community.json index 0f918c1d9f..047d0d4525 100644 --- a/extensions/catalog.community.json +++ b/extensions/catalog.community.json @@ -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", From 34f194d571dc5731bce79c801720cec57bee514c Mon Sep 17 00:00:00 2001 From: Huzaifa Iftikhar <98877942+HuzaifaChaudary@users.noreply.github.com> Date: Thu, 3 Sep 2026 16:13:56 +0500 Subject: [PATCH 05/12] allow hyphen in command ref token names (#4356) * allow hyphen in command ref token names the token pattern was matching only A-Z 0-9 and underscore so a command name like speckit.agent-context.update can not be written as a token and the token stays in the output as plain text now the character class allows a hyphen also in both places that resolve the token * document that a hyphen stays inside a segment the guide still said the token scheme does not carry hyphens, which is the opposite of what this branch does. added the real bundled command as the example since speckit.agent-context.update is the one that was unreachable before this. * replace the skills mode limitation with what skills mode actually does the callout said a command ref token reaches codex zcode and kimi verbatim. it does not. _resolve_command_ref_tokens inside _register_extension_skills resolves the same token shape against the active skill style. the callout was right that resolve_command_refs is never called there and wrong about what follows from it. * name the right invocation for each skills agent the callout said kimi renders the bare slash form. it does not, kimi is in SKILL_COLON_AGENTS only and falls through both branches to its own build_command_invocation which returns /skill:speckit-. --- extensions/EXTENSION-DEVELOPMENT-GUIDE.md | 27 +++++++------ src/specify_cli/extensions/__init__.py | 2 +- src/specify_cli/integrations/base.py | 6 ++- tests/integrations/test_base.py | 14 +++++++ tests/test_extension_skills.py | 46 +++++++++++++++++++++++ 5 files changed, 82 insertions(+), 13 deletions(-) diff --git a/extensions/EXTENSION-DEVELOPMENT-GUIDE.md b/extensions/EXTENSION-DEVELOPMENT-GUIDE.md index ac78029f2a..ff0e90c234 100644 --- a/extensions/EXTENSION-DEVELOPMENT-GUIDE.md +++ b/extensions/EXTENSION-DEVELOPMENT-GUIDE.md @@ -296,15 +296,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: @@ -314,15 +315,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/src/specify_cli/extensions/__init__.py b/src/specify_cli/extensions/__init__.py index 3968e4fcbe..a440b6da9b 100644 --- a/src/specify_cli/extensions/__init__.py +++ b/src/specify_cli/extensions/__init__.py @@ -1596,7 +1596,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/base.py b/src/specify_cli/integrations/base.py index 27c43582b0..e58d231d36 100644 --- a/src/specify_cli/integrations/base.py +++ b/src/specify_cli/integrations/base.py @@ -636,11 +636,15 @@ def resolve_command_refs( * ``separator="."`` → ``/speckit.plan``, ``/speckit.git.commit`` * ``separator="-"`` → ``/speckit-plan``, ``/speckit-git-commit`` + A hyphen belongs to the segment it sits in rather than separating + segments, so ``__SPECKIT_COMMAND_AGENT-CONTEXT_UPDATE__`` resolves to + ``/speckit.agent-context.update``. + *prefix* defaults to ``"/"`` but may be ``"$"`` for agents whose native skills invocation uses dollar-prefixed chat commands. """ return re.sub( - r"__SPECKIT_COMMAND_([A-Z][A-Z0-9_]*)__", + r"__SPECKIT_COMMAND_([A-Z][A-Z0-9_-]*)__", lambda m: prefix + "speckit" + separator diff --git a/tests/integrations/test_base.py b/tests/integrations/test_base.py index 5f99961804..713cf75523 100644 --- a/tests/integrations/test_base.py +++ b/tests/integrations/test_base.py @@ -353,6 +353,16 @@ def test_extension_command_hyphen(self): result = IntegrationBase.resolve_command_refs(text, "-") assert result == "Run /speckit-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, ".") + assert result == "Run /speckit.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, "-") + assert result == "Run /speckit-agent-context-update to refresh." + def test_no_placeholders_unchanged(self): text = "No placeholders here." assert IntegrationBase.resolve_command_refs(text, ".") == text @@ -400,6 +410,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/test_extension_skills.py b/tests/test_extension_skills.py index 6eec5e7b47..a0a32f4d29 100644 --- a/tests/test_extension_skills.py +++ b/tests/test_extension_skills.py @@ -1166,6 +1166,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 ): From 18ea13fdc51ac45cde9df583281433050efc81a2 Mon Sep 17 00:00:00 2001 From: Sepuri Sai Krishna Date: Thu, 3 Sep 2026 17:07:48 +0530 Subject: [PATCH 06/12] fix(scripts): make bash branch-name sanitizing match the Python and PowerShell twins (#4286) * fix(scripts): make bash branch-name sanitizing match the Python and PowerShell twins * fix(scripts): use ASCII acronym boundaries in the Python and PowerShell twins * test(scripts): cover the ASCII acronym boundary in the PowerShell twins The ASCII-lookaround fix in scripts/powershell/create-new-feature.ps1 and extensions/git/scripts/powershell/create-new-feature-branch.ps1 had no PowerShell regression coverage: the accented-acronym parity cases invoked only bash and Python, and the existing PowerShell acronym tests used ASCII-separated words, so a regression there would have passed CI. Adds a three-way bash/Python/pwsh assertion for 'Fix eDBe sync' in the core parity suite and a pwsh arm to the extension parity test, both asserting 001-fix-db-sync. * test(scripts): cover clean_branch_name independently of generate_branch_name --- .../scripts/bash/create-new-feature-branch.sh | 15 +- .../powershell/create-new-feature-branch.ps1 | 6 +- .../python/create_new_feature_branch.py | 11 +- scripts/bash/create-new-feature.sh | 17 ++- scripts/powershell/create-new-feature.ps1 | 6 +- .../git/test_git_extension_python_parity.py | 139 +++++++++++++++++- tests/parity_helpers.py | 31 ++++ .../test_create_new_feature_python_parity.py | 139 +++++++++++++++++- 8 files changed, 348 insertions(+), 16 deletions(-) diff --git a/extensions/git/scripts/bash/create-new-feature-branch.sh b/extensions/git/scripts/bash/create-new-feature-branch.sh index 856cb0bec4..5303bb97c9 100755 --- a/extensions/git/scripts/bash/create-new-feature-branch.sh +++ b/extensions/git/scripts/bash/create-new-feature-branch.sh @@ -206,9 +206,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/-$//' } # --------------------------------------------------------------------------- @@ -444,6 +453,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 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 "(? 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, diff --git a/tests/test_create_new_feature_python_parity.py b/tests/test_create_new_feature_python_parity.py index 74d071ad5f..6cc50d80eb 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, @@ -117,8 +119,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 @@ -131,6 +143,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", @@ -1067,6 +1099,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", From 878f0316d7a977305dcb8b0e0d9bb109d86ccfb4 Mon Sep 17 00:00:00 2001 From: Markus Wondrak Date: Thu, 3 Sep 2026 13:43:55 +0200 Subject: [PATCH 07/12] feat(workflows): add workflow slots (#4352) * feat(workflows): add plugin slots Assisted-by: GitHub Copilot (model: gpt-5.6-terra, autonomous) * fix(workflows): add runtime fan-out guard to PluginStep Mirror GateStep's inside_fan_out check so plugin slots inside fan-out templates fail at execution time, not only at static validation. This closes the gap when WorkflowEngine.execute() is called without prior validation. Assisted-by: opencode (model: qwen3.7-max, supervised) * refactor(workflows): rename plugin slots to workflow slots The feature reserves a no-op position replaced through a workflow overlay; it does not register or resolve plugins. Rename per maintainer feedback so 'plugin' stays available for a future genuine plugin mechanism and avoid confusion with Spec Kit extensions: - type: plugin -> type: slot - PluginStep -> SlotStep - 'plugin step/slot' -> 'workflow slot' in prose and error messages - steps/plugin/ -> steps/slot/ (git mv) - test_plugin_step.py -> test_slot_step.py (git mv) Assisted-by: opencode (model: glm-5.3-flash, autonomous) --------- Co-authored-by: Markus --- docs/reference/workflows.md | 35 +++ src/specify_cli/workflows/__init__.py | 2 + src/specify_cli/workflows/engine.py | 9 +- .../workflows/steps/slot/__init__.py | 67 +++++ tests/test_workflows.py | 4 +- tests/unit/test_bundler_references.py | 4 +- tests/workflows/test_slot_step.py | 242 ++++++++++++++++++ workflows/ARCHITECTURE.md | 8 +- workflows/PUBLISHING.md | 2 +- workflows/README.md | 20 +- 10 files changed, 384 insertions(+), 9 deletions(-) create mode 100644 src/specify_cli/workflows/steps/slot/__init__.py create mode 100644 tests/workflows/test_slot_step.py diff --git a/docs/reference/workflows.md b/docs/reference/workflows.md index 3b838b7227..df634ffe72 100644 --- a/docs/reference/workflows.md +++ b/docs/reference/workflows.md @@ -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 @@ -494,6 +528,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/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/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/tests/test_workflows.py b/tests/test_workflows.py index 2299752854..ca502ef4c1 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 @@ -108,7 +108,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())) 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_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..70a34bc538 100644 --- a/workflows/README.md +++ b/workflows/README.md @@ -78,7 +78,7 @@ specify workflow run speckit \ ## Step Types -Workflows support 11 built-in step types: +Workflows support 12 built-in step types: ### Command Steps (default) @@ -150,6 +150,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: From 4dd8afa37ba7410a65ec2cf739b843ab75000a09 Mon Sep 17 00:00:00 2001 From: Gabriele Berti <108946753+G4bar@users.noreply.github.com> Date: Thu, 3 Sep 2026 13:45:14 +0200 Subject: [PATCH 08/12] feat(integrations): add Muse Code (muse) agent integration (#4413) Muse Code is Meta's terminal coding agent (binary: muse). It discovers project skills at .agents/skills//SKILL.md and invokes them via the /speckit- slash shortcut, so wire it up as a SkillsIntegration sharing the .agents/skills layout with Codex/Zed (multi_install_safe=False, same policy as docker-agent). Includes registry wiring, invocation-style mapping, init next-steps, discovery catalog, integrations doc, agent-context default (AGENTS.md), issue templates, and a dedicated test module. --- .github/ISSUE_TEMPLATE/agent_request.yml | 2 +- .github/ISSUE_TEMPLATE/bug_report.yml | 1 + .github/ISSUE_TEMPLATE/feature_request.yml | 1 + docs/reference/integrations.md | 1 + .../agent-context/agent-context-defaults.json | 1 + integrations/catalog.json | 9 +++ src/specify_cli/_invocation_style.py | 2 +- src/specify_cli/commands/init.py | 7 ++ src/specify_cli/integrations/__init__.py | 2 + src/specify_cli/integrations/muse/__init__.py | 63 +++++++++++++++ tests/integrations/test_integration_muse.py | 80 +++++++++++++++++++ tests/integrations/test_registry.py | 2 +- tests/test_agent_config_consistency.py | 1 + 13 files changed, 169 insertions(+), 3 deletions(-) create mode 100644 src/specify_cli/integrations/muse/__init__.py create mode 100644 tests/integrations/test_integration_muse.py 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/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/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/integrations/catalog.json b/integrations/catalog.json index d4dbb168d2..55b4ec1fa1 100644 --- a/integrations/catalog.json +++ b/integrations/catalog.json @@ -362,6 +362,15 @@ "author": "spec-kit-core", "repository": "https://github.com/github/spec-kit", "tags": ["ide", "skills"] + }, + "muse": { + "id": "muse", + "name": "Muse Code", + "version": "1.0.0", + "description": "Meta Muse Code CLI skills-based integration", + "author": "spec-kit-core", + "repository": "https://github.com/github/spec-kit", + "tags": ["cli", "skills"] } } } diff --git a/src/specify_cli/_invocation_style.py b/src/specify_cli/_invocation_style.py index 3233a6bab4..9fa115212f 100644 --- a/src/specify_cli/_invocation_style.py +++ b/src/specify_cli/_invocation_style.py @@ -13,7 +13,7 @@ # Agents that always render /speckit-, 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 2f686e2fa9..dfce1b3d10 100644 --- a/src/specify_cli/commands/init.py +++ b/src/specify_cli/commands/init.py @@ -1012,6 +1012,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" @@ -1028,6 +1029,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 @@ -1063,6 +1065,11 @@ def init( f"{step_num}. Start Zed in this project directory; spec-kit skills were installed to [cyan].agents/skills[/cyan]" ) 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/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/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/tests/integrations/test_integration_muse.py b/tests/integrations/test_integration_muse.py new file mode 100644 index 0000000000..eb6f3d9087 --- /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 /speckit-* 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 "/speckit-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 87b30a48d3..590b9edf92 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/test_agent_config_consistency.py b/tests/test_agent_config_consistency.py index 16dbcae815..6ca4de9c6d 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", From da2b0ae744c501c9aa92e2a25cd44e3850466140 Mon Sep 17 00:00:00 2001 From: Gyanu Mayank <81113356+gyanu2507@users.noreply.github.com> Date: Thu, 3 Sep 2026 17:21:15 +0530 Subject: [PATCH 09/12] fix: remove unused scope input from bundled speckit workflow (#4401) * fix: drop unused scope input from the bundled speckit workflow The Full SDD Cycle workflow prompted for full / backend-only / frontend-only, but none of the steps read inputs.scope. Every command only received inputs.spec, so the three choices behaved the same. Remove the dead input from the shipped workflow and align the docs that showed `specify workflow run speckit ... -i scope=...`. Keep scope as an example in the generic input-typing docs for authors who do wire it up. * Bump bundled speckit workflow to 1.0.1 after dropping scope. Catalog installs only see the dead input removed when the published version advances past what they already have. --------- Co-authored-by: Gyanu --- docs/reference/workflows.md | 6 +--- .../test_bundled_speckit_workflow.py | 32 +++++++++++++++++++ workflows/README.md | 9 +++++- workflows/catalog.json | 4 +-- workflows/speckit/workflow.yml | 6 +--- 5 files changed, 44 insertions(+), 13 deletions(-) create mode 100644 tests/workflows/test_bundled_speckit_workflow.py diff --git a/docs/reference/workflows.md b/docs/reference/workflows.md index df634ffe72..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): @@ -449,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 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/workflows/README.md b/workflows/README.md index 70a34bc538..676ea6faf0 100644 --- a/workflows/README.md +++ b/workflows/README.md @@ -70,12 +70,19 @@ 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 12 built-in step types: diff --git a/workflows/catalog.json b/workflows/catalog.json index 967120afb0..c26a3230a1 100644 --- a/workflows/catalog.json +++ b/workflows/catalog.json @@ -1,6 +1,6 @@ { "schema_version": "1.0", - "updated_at": "2026-04-13T00: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 230675b327..571318af4d 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 From c256ceab70d550bd52312063e4ef80fc181bbb09 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Thu, 3 Sep 2026 07:28:52 -0500 Subject: [PATCH 10/12] Add Evaluator Contract extension to community catalog (#4420) Add evaluator extension submitted by @tbitcs to: - extensions/catalog.community.json (alphabetical order) - docs/community/extensions.md community extensions table Closes #4414 Assisted-by: GitHub Copilot (model: gpt-5.2-codex, autonomous) Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com> Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- docs/community/extensions.md | 1 + extensions/catalog.community.json | 36 ++++++++++++++++++++++++++++++- 2 files changed, 36 insertions(+), 1 deletion(-) diff --git a/docs/community/extensions.md b/docs/community/extensions.md index 2fc09903eb..b8ef03cd7a 100644 --- a/docs/community/extensions.md +++ b/docs/community/extensions.md @@ -67,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/extensions/catalog.community.json b/extensions/catalog.community.json index 047d0d4525..a78e30aa45 100644 --- a/extensions/catalog.community.json +++ b/extensions/catalog.community.json @@ -1,6 +1,6 @@ { "schema_version": "1.0", - "updated_at": "2026-09-02T00: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": { @@ -1785,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", From db64869985644df296827a5bbbaa0181a58a058e Mon Sep 17 00:00:00 2001 From: Ali jawwad <33836051+jawwad-ali@users.noreply.github.com> Date: Thu, 3 Sep 2026 18:15:39 +0500 Subject: [PATCH 11/12] fix(workflows): require a 'steps' body on while and do-while loops (#4149) Both loop steps type-check `steps` ("must be a list") but never require it to be present, so an absent body silently becomes `[]`. `if` already requires `then`, and `fan-out` already requires both `items` and `step`. The mistype is unusually easy here because the fan-out step's own payload key is the singular `step:` while the loops use `steps:`. Writing `step:` on a `while` passed `specify workflow validate` with zero errors: A. while, body key typo'd as singular step: validate: [] execute : StepStatus.COMPLETED | next_steps = [] B. do-while, no steps at all: validate: [] execute : StepStatus.COMPLETED | next_steps = [] At run time the step reports COMPLETED while returning no `next_steps`, so the engine's `if result.next_steps:` block never fires and the loop the workflow is built around never runs even once. `DoWhileStep`'s own docstring promises "The first invocation always returns the nested steps for execution". Co-authored-by: Claude Opus 5 (1M context) --- .../workflows/steps/do_while/__init__.py | 8 ++++ .../workflows/steps/while_loop/__init__.py | 12 +++++ tests/test_workflows.py | 46 +++++++++++++++++++ 3 files changed, 66 insertions(+) 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/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/test_workflows.py b/tests/test_workflows.py index ca502ef4c1..9b1c17881e 100644 --- a/tests/test_workflows.py +++ b/tests/test_workflows.py @@ -3560,6 +3560,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 @@ -3692,6 +3719,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 From ab192ad53838db7f75e792d878109741e2817ce5 Mon Sep 17 00:00:00 2001 From: Lior Kanfi Date: Fri, 4 Sep 2026 07:43:06 +0300 Subject: [PATCH 12/12] fix(tests): use _get_prefix() in hyphenated command-ref tests MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The two new #4356 tests hardcoded /speckit.* — use the fork's _get_prefix() helper (returns "spec") like the neighboring separator tests so they pass on the fork prefix. Assisted-by: opencode (model: glm-5.2, supervised) --- tests/integrations/test_base.py | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/tests/integrations/test_base.py b/tests/integrations/test_base.py index 2d777759da..05bbee0892 100644 --- a/tests/integrations/test_base.py +++ b/tests/integrations/test_base.py @@ -314,12 +314,14 @@ def test_extension_command_hyphen(self, tmp_path): def test_hyphenated_command_dot(self): text = "Run __SPECKIT_COMMAND_AGENT-CONTEXT_UPDATE__ to refresh." result = IntegrationBase.resolve_command_refs(text, ".") - assert result == "Run /speckit.agent-context.update to refresh." + 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, "-") - assert result == "Run /speckit-agent-context-update to refresh." + prefix = self._get_prefix() + assert result == f"Run /{prefix}-agent-context-update to refresh." def test_no_placeholders_unchanged(self): text = "No placeholders here."