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 01/44] 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 02/44] [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 03/44] 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 04/44] 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 05/44] 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 06/44] 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 07/44] 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 08/44] 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 09/44] 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 af8f5a49ddb451c25af4dcd4fe5e186730132076 Mon Sep 17 00:00:00 2001 From: Yash-Chindam <108891350+Yash-Chindam@users.noreply.github.com> Date: Thu, 3 Sep 2026 19:57:39 +0530 Subject: [PATCH 10/44] fix(scripts): name setup-plan's feature directory key FEATURE_DIR (#4397) * fix(scripts): name setup-plan's feature directory key FEATURE_DIR setup-plan emitted a key called SPECS_DIR holding $FEATURE_DIR -- the per-feature subdirectory, not the specs root. The name is already taken elsewhere with the other meaning: create-new-feature.sh sets SPECS_DIR="$REPO_ROOT/specs" and derives FEATURE_DIR="$SPECS_DIR/$BRANCH_NAME". setup-plan was also the only script in the suite using it. setup-tasks and both check-prerequisites payloads already emit FEATURE_DIR for exactly this value, so this brings setup-plan in line rather than inventing a convention. Renamed in all three ports so the payloads stay identical, and in templates/commands/plan.md, which is the only consumer -- it parses the key by name, so it has to move in the same commit. Verified the bash, PowerShell, and Python variants all emit ['BRANCH','FEATURE_DIR','FEATURE_SPEC','IMPL_PLAN']. Fixes #4017 * test(scripts): pin setup-plan's FEATURE_DIR output contract Addresses review feedback. The existing setup-plan tests compare the ports against each other, so all three could regress to SPECS_DIR together and still pass. This asserts the contract absolutely, in JSON and text mode and across bash/Python/PowerShell: the key is FEATURE_DIR, it carries the feature directory rather than the specs root, and SPECS_DIR is absent. The value is matched by suffix rather than full path because the ports legitimately differ in path flavour -- under MSYS bash reports /tmp/... where the Python and PowerShell ports report C:\... . The suffix still separates specs/001-my-feature from a bare specs, which is the regression being guarded; verified it rejects both /tmp/proj/specs and C:\proj\specs. --- scripts/bash/setup-plan.sh | 8 ++-- scripts/powershell/setup-plan.ps1 | 4 +- scripts/python/setup_plan.py | 4 +- templates/commands/plan.md | 2 +- tests/test_setup_plan_python_parity.py | 54 ++++++++++++++++++++++++++ 5 files changed, 63 insertions(+), 9 deletions(-) diff --git a/scripts/bash/setup-plan.sh b/scripts/bash/setup-plan.sh index f3edb3d9f8..aa394183cf 100644 --- a/scripts/bash/setup-plan.sh +++ b/scripts/bash/setup-plan.sh @@ -70,16 +70,16 @@ if $JSON_MODE; then jq -cn \ --arg feature_spec "$FEATURE_SPEC" \ --arg impl_plan "$IMPL_PLAN" \ - --arg specs_dir "$FEATURE_DIR" \ + --arg feature_dir "$FEATURE_DIR" \ --arg branch "$CURRENT_BRANCH" \ - '{FEATURE_SPEC:$feature_spec,IMPL_PLAN:$impl_plan,SPECS_DIR:$specs_dir,BRANCH:$branch}' + '{FEATURE_SPEC:$feature_spec,IMPL_PLAN:$impl_plan,FEATURE_DIR:$feature_dir,BRANCH:$branch}' else - printf '{"FEATURE_SPEC":"%s","IMPL_PLAN":"%s","SPECS_DIR":"%s","BRANCH":"%s"}\n' \ + printf '{"FEATURE_SPEC":"%s","IMPL_PLAN":"%s","FEATURE_DIR":"%s","BRANCH":"%s"}\n' \ "$(json_escape "$FEATURE_SPEC")" "$(json_escape "$IMPL_PLAN")" "$(json_escape "$FEATURE_DIR")" "$(json_escape "$CURRENT_BRANCH")" fi else echo "FEATURE_SPEC: $FEATURE_SPEC" echo "IMPL_PLAN: $IMPL_PLAN" - echo "SPECS_DIR: $FEATURE_DIR" + echo "FEATURE_DIR: $FEATURE_DIR" echo "BRANCH: $CURRENT_BRANCH" fi diff --git a/scripts/powershell/setup-plan.ps1 b/scripts/powershell/setup-plan.ps1 index 300582d5eb..036a54e4d1 100644 --- a/scripts/powershell/setup-plan.ps1 +++ b/scripts/powershell/setup-plan.ps1 @@ -76,13 +76,13 @@ if ($Json) { $result = [PSCustomObject]@{ FEATURE_SPEC = $paths.FEATURE_SPEC IMPL_PLAN = $paths.IMPL_PLAN - SPECS_DIR = $paths.FEATURE_DIR + FEATURE_DIR = $paths.FEATURE_DIR BRANCH = $paths.CURRENT_BRANCH } $result | ConvertTo-Json -Compress } else { Write-Output "FEATURE_SPEC: $($paths.FEATURE_SPEC)" Write-Output "IMPL_PLAN: $($paths.IMPL_PLAN)" - Write-Output "SPECS_DIR: $($paths.FEATURE_DIR)" + Write-Output "FEATURE_DIR: $($paths.FEATURE_DIR)" Write-Output "BRANCH: $($paths.CURRENT_BRANCH)" } diff --git a/scripts/python/setup_plan.py b/scripts/python/setup_plan.py index 3b8acc4fd4..721eed8f39 100644 --- a/scripts/python/setup_plan.py +++ b/scripts/python/setup_plan.py @@ -82,7 +82,7 @@ def main(argv: list[str] | None = None) -> int: { "FEATURE_SPEC": str(paths.feature_spec), "IMPL_PLAN": str(paths.impl_plan), - "SPECS_DIR": str(paths.feature_dir), + "FEATURE_DIR": str(paths.feature_dir), "BRANCH": paths.current_branch, } ) @@ -90,7 +90,7 @@ def main(argv: list[str] | None = None) -> int: else: print(f"FEATURE_SPEC: {paths.feature_spec}") print(f"IMPL_PLAN: {paths.impl_plan}") - print(f"SPECS_DIR: {paths.feature_dir}") + print(f"FEATURE_DIR: {paths.feature_dir}") print(f"BRANCH: {paths.current_branch}") return 0 diff --git a/templates/commands/plan.md b/templates/commands/plan.md index 664f428114..836e25070c 100644 --- a/templates/commands/plan.md +++ b/templates/commands/plan.md @@ -59,7 +59,7 @@ You **MUST** consider the user input before proceeding (if not empty). ## Outline -1. **Setup**: Run `{SCRIPT}` from repo root and parse JSON for FEATURE_SPEC, IMPL_PLAN, SPECS_DIR, BRANCH. For single quotes in args like "I'm Groot", use escape syntax: e.g 'I'\''m Groot' (or double-quote if possible: "I'm Groot"). +1. **Setup**: Run `{SCRIPT}` from repo root and parse JSON for FEATURE_SPEC, IMPL_PLAN, FEATURE_DIR, BRANCH. For single quotes in args like "I'm Groot", use escape syntax: e.g 'I'\''m Groot' (or double-quote if possible: "I'm Groot"). 2. **Load context**: Read FEATURE_SPEC and `/memory/constitution.md`. Load IMPL_PLAN template (already copied). diff --git a/tests/test_setup_plan_python_parity.py b/tests/test_setup_plan_python_parity.py index d66c7083b3..09356a142f 100644 --- a/tests/test_setup_plan_python_parity.py +++ b/tests/test_setup_plan_python_parity.py @@ -374,3 +374,57 @@ def test_python_json_output_matches_powershell(repo: Path) -> None: assert py.returncode == ps.returncode == 0 assert json_stdout(py) == json_stdout(ps) + + +@requires_bash +@pytest.mark.parametrize("args", [("--json",), ()], ids=["json", "text"]) +def test_all_variants_emit_feature_dir_not_specs_dir( + repo: Path, args: tuple[str, ...] +) -> None: + r"""Pin the output key name, not just cross-port agreement. + + The other tests here compare the ports against each other, so all three + could regress to ``SPECS_DIR`` together and still pass. This asserts the + contract absolutely: the key is ``FEATURE_DIR``, it carries the feature + directory rather than the specs root, and the old name is gone. + ``SPECS_DIR`` means the specs root in ``create-new-feature.sh``, so + re-emitting it here would reintroduce one name for two paths. + + The value is matched by suffix because the ports legitimately differ in + path flavour -- under MSYS bash reports ``/tmp/...`` where the Python and + PowerShell ports report ``C:\...``. The suffix still separates + ``specs/001-my-feature`` from a bare ``specs``, which is the regression + this guards. + """ + json_mode = args == ("--json",) + suffix = ("specs", "001-my-feature") + + commands = [bash_cmd(repo, SCRIPT, *args), py_cmd(repo, SCRIPT, *args)] + if HAS_POWERSHELL: + commands.append(ps_cmd(repo, SCRIPT, *(("-Json",) if json_mode else ()))) + + for cmd in commands: + result = run(cmd, repo) + assert result.returncode == 0, result.stderr + assert "SPECS_DIR" not in result.stdout + + if json_mode: + payload = json_stdout(result) + assert isinstance(payload, dict) + assert sorted(payload) == [ + "BRANCH", + "FEATURE_DIR", + "FEATURE_SPEC", + "IMPL_PLAN", + ] + value = payload["FEATURE_DIR"] + else: + lines = dict( + line.split(": ", 1) + for line in result.stdout.splitlines() + if ": " in line + ) + assert "FEATURE_DIR" in lines + value = lines["FEATURE_DIR"] + + assert tuple(value.replace("\\", "/").rstrip("/").split("/")[-2:]) == suffix From d11eb9ce3e1bc4e32500bcaafe2c148e27e7f1b7 Mon Sep 17 00:00:00 2001 From: Yash-Chindam <108891350+Yash-Chindam@users.noreply.github.com> Date: Thu, 3 Sep 2026 20:23:59 +0530 Subject: [PATCH 11/44] docs(workflows): sync the reference copy with the shipped workflow (#4424) docs/reference/workflows.md introduces its YAML block as the workflow that ships with Spec Kit, so a reader is entitled to treat it as the real definition. It had drifted on four points: version 1.0.0 -> 1.0.1 speckit_version >=0.7.2 -> >=0.8.5 integrations.any copilot, claude, gemini -> also alquimia, opencode integration default "copilot" -> default "auto" The last is the most user-visible: the guide stated the default integration was copilot, when it is auto, resolved from the project's initialized integration. Someone reading the guide to learn what they get by default was being told the wrong thing. Adds a guard so this cannot drift again. It compares parsed YAML rather than text, so the guide stays free to format lists however reads best and only the content has to agree. Verified it fails against the pre-sync copy, reporting all four differences, and passes after. Follow-up to #4384 / #4398, at the maintainer's suggestion. --- docs/reference/workflows.md | 15 ++++++--- .../test_bundled_speckit_workflow.py | 32 +++++++++++++++++-- 2 files changed, 39 insertions(+), 8 deletions(-) diff --git a/docs/reference/workflows.md b/docs/reference/workflows.md index 41a890ba60..e2deaf181c 100644 --- a/docs/reference/workflows.md +++ b/docs/reference/workflows.md @@ -431,14 +431,19 @@ 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" requires: - speckit_version: ">=0.7.2" + speckit_version: ">=0.8.5" integrations: - any: ["copilot", "claude", "gemini"] + any: + - "alquimia" + - "claude" + - "copilot" + - "gemini" + - "opencode" inputs: spec: @@ -447,8 +452,8 @@ inputs: prompt: "Describe what you want to build" integration: type: string - default: "copilot" - prompt: "Integration to use (e.g. claude, copilot, gemini)" + default: "auto" + prompt: "Integration to use (e.g. claude, copilot, gemini; 'auto' uses the project's initialized integration)" steps: - id: specify diff --git a/tests/workflows/test_bundled_speckit_workflow.py b/tests/workflows/test_bundled_speckit_workflow.py index 77351ff017..50b53fa5dd 100644 --- a/tests/workflows/test_bundled_speckit_workflow.py +++ b/tests/workflows/test_bundled_speckit_workflow.py @@ -8,9 +8,19 @@ from specify_cli.workflows.engine import WorkflowDefinition, validate_workflow -BUNDLED = ( - Path(__file__).resolve().parents[2] / "workflows" / "speckit" / "workflow.yml" -) +REPO_ROOT = Path(__file__).resolve().parents[2] +BUNDLED = REPO_ROOT / "workflows" / "speckit" / "workflow.yml" +REFERENCE_DOC = REPO_ROOT / "docs" / "reference" / "workflows.md" +DOC_INTRO = "Here is the built-in **Full SDD Cycle** workflow that ships with Spec Kit:" + + +def _documented_workflow() -> object: + """Return the workflow YAML the reference guide claims is the shipped one.""" + text = REFERENCE_DOC.read_text(encoding="utf-8") + intro = text.index(DOC_INTRO) + start = text.index("```yaml", intro) + len("```yaml") + end = text.index("```", start) + return yaml.safe_load(text[start:end]) def test_bundled_speckit_workflow_has_no_unused_scope_input() -> None: @@ -30,3 +40,19 @@ def test_bundled_speckit_workflow_has_no_unused_scope_input() -> None: if args is None: continue assert "inputs.scope" not in str(args) + + +def test_reference_doc_matches_the_shipped_workflow() -> None: + """The reference guide reproduces this workflow, so it must not drift from it. + + ``docs/reference/workflows.md`` introduces its YAML block as the workflow + that ships with Spec Kit, so a reader is entitled to treat it as the real + definition. It had drifted on four points -- a stale ``version`` and + ``speckit_version``, a short ``integrations.any`` list, and an + ``integration`` default of ``copilot`` where the shipped default is + ``auto`` -- which is exactly the sort of thing nothing else would catch. + + The comparison is on parsed YAML, not text, so the guide stays free to + format lists however reads best; only the content has to agree. + """ + assert _documented_workflow() == yaml.safe_load(BUNDLED.read_text(encoding="utf-8")) From cca91e4ce85e176b2f02338bb67d6d6ee2171cb1 Mon Sep 17 00:00:00 2001 From: Noor ul ain Date: Thu, 3 Sep 2026 21:21:52 +0500 Subject: [PATCH 12/44] fix(presets): reject falsy non-mapping catalog config shapes (#4320) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix(presets): reject falsy non-mapping catalog config shapes `PresetCatalog._load_catalog_config` had two "shape check runs after an emptiness check" bugs, both masking a corrupted preset-catalogs.yml as an empty/no-op config instead of raising: - Top level: `yaml.safe_load(...) or {}` coerced a FALSY non-mapping document (`[]`, `false`, `0`, `''`) to `{}` before the `isinstance(data, dict)` guard ran, so it was silently treated as "no config" — while a TRUTHY non-mapping (a bare string) already raised "expected a mapping at root". - One level down: `catalogs_data = data.get("catalogs", [])` followed by `if not catalogs_data: return None` ran the emptiness check *before* the `isinstance(catalogs_data, list)` check, so a FALSY non-list `catalogs:` value (`{}`, `''`, `0`, `false`) was silently swallowed as "no catalogs" — while a TRUTHY non-list (`catalogs: "not-a-list"`) already raised "must be a list". `WorkflowCatalog._load_catalog_config` and `StepCatalog._load_catalog_config` (workflows/catalog.py) already guard against both cases correctly, with the same explanatory comments reused here. This preset sibling was missed. Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_01FW9fAYsCBCAgdKWovtSyqt * Potential fix for pull request finding Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> * Fix indentation in test for catalog config loading Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --------- Co-authored-by: Claude Sonnet 5 Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --- src/specify_cli/presets/__init__.py | 17 +++++++++++++++-- tests/test_presets.py | 12 ++++++++++++ 2 files changed, 27 insertions(+), 2 deletions(-) diff --git a/src/specify_cli/presets/__init__.py b/src/specify_cli/presets/__init__.py index 6b80b4fe1f..abc63299c2 100644 --- a/src/specify_cli/presets/__init__.py +++ b/src/specify_cli/presets/__init__.py @@ -4583,19 +4583,32 @@ def _load_catalog_config(self, config_path: Path) -> Optional[List[PresetCatalog raise PresetValidationError( f"Failed to read catalog config {config_path}: {e}" ) + # Do NOT coerce with ``or {}`` here: that also turns a FALSY + # non-mapping top level (``[]``, ``false``, ``0``, ``''``) into ``{}`` + # and silently swallows it, while a TRUTHY non-mapping (``5``, a bare + # list) correctly raises below. Only an empty document/explicit + # ``null`` means "no document". if data is None: return None if not isinstance(data, dict): raise PresetValidationError( f"Invalid catalog config {config_path}: expected a mapping at root, got {type(data).__name__}" ) - catalogs_data = data.get("catalogs", []) - if not catalogs_data: + # Same asymmetry one nesting level down: the shape check has to run + # BEFORE the emptiness check, or a FALSY non-list ``catalogs`` value + # (``{}``, ``''``, ``0``, ``false``) is silently swallowed as "no + # catalogs" while a TRUTHY non-list (``catalogs: "not-a-list"``) + # correctly raises. An absent key or an explicit ``catalogs: null`` + # both keep their existing "nothing configured here" behavior. + catalogs_data = data.get("catalogs") + if catalogs_data is None: return None if not isinstance(catalogs_data, list): raise PresetValidationError( f"Invalid catalog config: 'catalogs' must be a list, got {type(catalogs_data).__name__}" ) + if not catalogs_data: + return None entries: List[PresetCatalogEntry] = [] for idx, item in enumerate(catalogs_data): if not isinstance(item, dict): diff --git a/tests/test_presets.py b/tests/test_presets.py index 12f81fb9ec..57a70b4192 100644 --- a/tests/test_presets.py +++ b/tests/test_presets.py @@ -4057,6 +4057,18 @@ def test_load_catalog_config_not_a_list(self, project_dir): with pytest.raises(PresetValidationError, match="must be a list"): catalog._load_catalog_config(config_path) + @pytest.mark.parametrize("body", ["catalogs: {}\n", "catalogs: ''\n", "catalogs: 0\n", "catalogs: false\n"]) + def test_load_catalog_config_rejects_falsy_non_list_catalogs(self, project_dir, body): + """A FALSY non-list ``catalogs:`` value must raise, like a truthy one + (``catalogs: "not-a-list"``) already does. The shape check sat behind + the emptiness check, so these were silently swallowed as "no catalogs".""" + config_path = project_dir / ".specify" / "preset-catalogs.yml" + config_path.write_text(body, encoding="utf-8") + + catalog = PresetCatalog(project_dir) + with pytest.raises(PresetValidationError, match="must be a list"): + catalog._load_catalog_config(config_path) + def test_load_catalog_config_invalid_entry(self, project_dir): """Test that non-dict entry raises error.""" config_path = project_dir / ".specify" / "preset-catalogs.yml" From df6b3187022ce986759bd854467e8a4bb56bb0f4 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Thu, 3 Sep 2026 12:13:29 -0500 Subject: [PATCH 13/44] Update Linear Integration extension to v0.8.0 (#4428) Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com> --- docs/community/extensions.md | 2 +- extensions/catalog.community.json | 8 ++++---- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/docs/community/extensions.md b/docs/community/extensions.md index b8ef03cd7a..69119631e0 100644 --- a/docs/community/extensions.md +++ b/docs/community/extensions.md @@ -86,7 +86,7 @@ The following community-contributed extensions are available in [`catalog.commun | Jira Mirror | Spec Kit ↔ Jira bridge for team-managed and company-managed projects: configurable workflows & hierarchies (Scrum/SAFe), multi-project, idempotent and fail-closed. macOS/Linux/Windows. | `integration` | Read+Write | [spec-kit-jira-mirror](https://github.com/Fyloss/spec-kit-jira-mirror) | | Keel Discovery | Evidence-backed discovery upstream of /speckit.specify, plus round-trip drift auditing after implementation | `process` | Read+Write | [spec-kit-keel](https://github.com/keeldiscovery/spec-kit-keel) | | Learning Extension | Generate educational guides from implementations and enhance clarifications with mentoring context | `docs` | Read+Write | [spec-kit-learn](https://github.com/imviancagrace/spec-kit-learn) | -| Linear Integration | Mirror spec-kit feature directories into Linear (filesystem → Linear, reconcile-based, unidirectional). | `integration` | Read+Write | [spec-kit-linear-sync](https://github.com/ashbrener/spec-kit-linear-sync) | +| Linear Integration | Automatically mirror your spec-kit specs into Linear — one issue per spec, a sub-issue per task phase, kept in sync as you work. | `integration` | Read+Write | [spec-kit-linear-sync](https://github.com/ashbrener/spec-kit-linear-sync) | | Linear Weave | Weave Spec Kit into Linear: pull requirements, mirror tasks.md into sub-issues, sync statuses | `integration` | Read+Write | [spec-kit-linear-weave](https://github.com/tonydwoodhouse/spec-kit-linear-weave) | | LLM Wiki | LLM-maintained compounding project wiki: source ingestion, cited answers, and consistency linting | `docs` | Read+Write | [spec-kit-wiki](https://github.com/formin/spec-kit-wiki) | | Loop Engineering | Engineer safe autonomous agent loops for spec-driven development: a maker/checker split, externalized loop state, and stay-the-engineer guardrails against comprehension debt and cognitive surrender | `process` | Read+Write | [spec-kit-loop](https://github.com/formin/spec-kit-loop) | diff --git a/extensions/catalog.community.json b/extensions/catalog.community.json index a78e30aa45..282127c7de 100644 --- a/extensions/catalog.community.json +++ b/extensions/catalog.community.json @@ -2680,10 +2680,10 @@ "linear": { "name": "Linear Integration", "id": "linear", - "description": "Mirror spec-kit feature directories into Linear (filesystem → Linear, reconcile-based, unidirectional).", + "description": "Automatically mirror your spec-kit specs into Linear — one issue per spec, a sub-issue per task phase, kept in sync as you work.", "author": "Ash Brener", - "version": "0.7.0", - "download_url": "https://github.com/ashbrener/spec-kit-linear-sync/archive/refs/tags/v0.7.0.zip", + "version": "0.8.0", + "download_url": "https://github.com/ashbrener/spec-kit-linear-sync/archive/refs/tags/v0.8.0.zip", "repository": "https://github.com/ashbrener/spec-kit-linear-sync", "homepage": "https://github.com/ashbrener/spec-kit-linear-sync", "documentation": "https://github.com/ashbrener/spec-kit-linear-sync/blob/main/README.md", @@ -2710,7 +2710,7 @@ "downloads": 0, "stars": 0, "created_at": "2026-06-01T00:00:00Z", - "updated_at": "2026-06-22T00:00:00Z" + "updated_at": "2026-09-03T00:00:00Z" }, "linear-weave": { "name": "Linear Weave", From 4a7341a93d944d6efe153b71da4a1adb9c2b578c Mon Sep 17 00:00:00 2001 From: Manfred Riem <15701806+mnriem@users.noreply.github.com> Date: Fri, 4 Sep 2026 06:51:20 -0500 Subject: [PATCH 14/44] Add the August 2026 newsletter (#4442) --- newsletters/2026-August.md | 130 +++++++++++++++++++++++++++++++++++++ 1 file changed, 130 insertions(+) create mode 100644 newsletters/2026-August.md diff --git a/newsletters/2026-August.md b/newsletters/2026-August.md new file mode 100644 index 0000000000..ba6ae17484 --- /dev/null +++ b/newsletters/2026-August.md @@ -0,0 +1,130 @@ +# Spec Kit - August 2026 Newsletter + +This edition covers Spec Kit activity in August 2026 — the month the project reached **1.0.0**. Ten releases shipped (v0.15.2 through v1.0.2), running out the 0.16 patch line before crossing the milestone: on **August 21, one year after its first commit, Spec Kit released v1.0.0**, followed the same day by v1.0.1 and, on August 31, by v1.0.2. The headline is less a feature than a framing — 1.0.0 marks the point where the project's **five primitives** (integrations, extensions, presets, workflows, and workflow steps), the catalog/governance layer beneath them, and the closed `specify → … → converge` core loop cohere into one system, rather than any single new capability. Around it, three currents ran through the month: the **Copilot skills default finally flipped** (`specify init --integration copilot` now installs skills), a **`feature-assess` agentic workflow** taught the project to triage incoming feature requests by running *itself*, and the **security-and-robustness campaign** from July continued as routine — bounded reads, TOCTOU elimination, URL-port validation, event-hook path confinement, and a broad non-UTF-8 resilience sweep. Externally, coverage pivoted to the milestone: a marquee "how Spec Kit became five primitives" 1.0.0 retrospective, a wave of multi-framework field guides, and — notably — the **companion tooling** that formed around Spec Kit in July began **entering the official catalog**. A summary is in the table below, followed by details. + +| **Spec Kit Core (Aug 2026)** | **Community & Content** | **SDD Ecosystem & Next** | +| --- | --- | --- | +| Ten releases shipped (v0.15.2–v1.0.2), reaching the **v1.0.0** milestone on August 21 — one year after the project's first commit — with v1.0.1 the same day and v1.0.2 on August 31. 1.0.0 frames coherence across the **five primitives** rather than a feature drop. Headlines: the **Copilot skills default flip** (skills, not markdown commands, at `init`), a **`feature-assess` agentic workflow** that installs and runs Spec Kit to triage feature requests, manifest **`provides.templates`/`provides.scripts`** and command-time constitution templates, and a continued **security-hardening** wave. The built-in integrations catalog reached **38** with the new Command Code agent. The repo grew from ~124,655 to **~132,000 stars**. [\[github.com\]](https://github.com/github/spec-kit/releases) | The community extension catalog grew from 144 to **162 entries**; presets reached **34**, community workflows held at **2**, bundles doubled to **2**. Coverage centered on the 1.0.0 milestone — a deep "one pipeline to five primitives" retrospective — plus multi-framework field guides and intensifying "is it too heavy / who verifies the spec?" critiques. **~270 contributors** now listed. | July's **companion tools began entering the catalog** — SpecJudge (model right-sizing) and the SpecAssay suite (a multi-artifact extension + preset + bundle) were cataloged, evidence the third-party layer is consolidating into the official ecosystem. The 1.0.0 "coherence, not permanence" framing — the README still files goals under "Experimental" — reframes the competitive question from "which tool" to "which platform." | + +*** + +> **From One Pipeline to Five Primitives.** If July was consolidation, August was punctuation. The month's substance was cumulative — ten releases of hardening, packaging, composition, and integration work — but its meaning was the number on the box. v1.0.0 landed a year to the day after the first commit, and the maintainer was unusually direct that it is *not* a feature release and *not* a stability promise: the README still files the project's ambitions under "Experimental Goals," deliberately unrenamed. What 1.0.0 marks is coherence. A tool that began February as a linear `specify → plan → tasks → implement` pipeline now stands on five composable primitives — integrations, extensions, presets, workflows, and workflow steps — with a priority-ordered catalog system beneath all of them and a closed core loop that runs `converge` to ask "is this actually done?" The rest of August pushed the same direction: the Copilot integration flipped to skills by default, a `feature-assess` workflow put the project to work triaging its own backlog, and the security campaign hardened every new surface the primitives opened. Meanwhile the companion tools that sprang up around Spec Kit in July started arriving *inside* the catalog. None of this happens without the community — the contributors, extension and preset authors, bundle builders, agent-integration maintainers, and practitioners writing in more than 20 languages. Thank you. + +## Spec Kit Project Updates + +### Releases Overview + +**v0.15.2–v0.16.5** (August 3–19) ran the month's patch cadence before the milestone. Feature work concentrated on **installation, onboarding, and composition**: `specify init` grew an **`--extension` flag** for opting into extensions at init time (#3914), extensions **scaffold their config templates** on add/enable (#2000), and a **managed `.specify/.gitignore`** is scaffolded at init (#4000). Two integration-selection changes landed together — the default init integration became **overridable via `SPECKIT_INTEGRATION_DEFAULT`** (#3952) and, the month's quiet headline, the **Copilot integration now defaults to skills** (#3976), completing the skills-default rollout flagged since July. The composition layer matured: extension manifests now accept **`provides.templates` and `provides.scripts`** (#4012), presets **resolve constitution templates at command time** (#3984), and `specify` **lists presets in resolution/precedence order** (#4104). Underneath, a deep robustness pass hardened non-UTF-8 and unreadable inputs across the registries, manifests, bundler, and workflow engine. [\[github.com\]](https://github.com/github/spec-kit/releases) + +**v1.0.0–v1.0.2** (August 21–31) crossed the milestone. **v1.0.0** (#4246) shipped on August 21 — one year after the project's first commit — with **v1.0.1** the same day and **v1.0.2** on August 31. The releases carried the fortnight's workflow, catalog, bundler, cross-platform, and integration fixes into a published 1.0.0 line, alongside a documentation set built for the moment: a **first-anniversary marker** (#4260), a **project history page** (#4262) that traces the arc from linear pipeline to five primitives, a **branding refresh** replacing the DocFX theme with the Spec Kit logo (#4264), **workflow quickstart guides** (#4258), and operational guidance for **existing-project adoption** (#4263). [\[github.com\]](https://github.com/github/spec-kit/releases) + +### The 1.0.0 Milestone: Coherence, Not Permanence + +August's headline was **v1.0.0** (#4246), released on August 21 — one year to the day after Spec Kit's first commit — and immediately followed by v1.0.1. The framing matters more than the number: 1.0.0 is explicitly **not a feature release and not a stability promise**. The argument, laid out in the project's new [history page](https://github.com/github/spec-kit/blob/main/docs/history.md), is that a major version used to be insurance against the cost of a breaking change — and that agents have collapsed that cost, since you can point an agent at a diff and it updates the call sites. The version instead marks **coherence between the five primitives**, not permanence; the README still files the project's ambitions under "Experimental Goals," deliberately unrenamed. + +What cohered is the shape of the tool. Spec Kit began February 2026 as a linear pipeline — `/speckit.specify`, `/speckit.plan`, `/speckit.tasks`, `/speckit.implement` — with the coding agent chosen by a flag. By 1.0.0 it stands on **five composable primitives**: **integrations** (agents became registry-backed plugins that write hash-tracked files), **extensions** (commands, templates, scripts, and hooks via an `extension.yml` manifest), **presets** (prepend/append/wrap/replace composition over existing core content), **workflows** (the specify-to-implement sequence is now a replaceable YAML definition, not hard-coded control flow), and **workflow steps** (a small interface any community step type can implement). Beneath them sits a single priority-ordered **catalog** system — environment variable → project config → user config → built-in — that governs discovery and install policy uniformly for all five, and the built-in `community` catalog is discovery-only by design: the maintainers verify an entry is well-formed, not that its code is safe. Above the primitives, the core loop closed in June with `/speckit.converge`, which assesses shipped code against the spec, plan, and tasks and appends the missing work — the answer to the tool's most-cited critique, "who verifies the spec?" The 1.0.0 core sequence is now constitution → specify → clarify → plan → checklist → tasks → analyze → implement → converge, with clarify/checklist/analyze as optional gates. [\[medium.com\]](https://stn1slv.medium.com/spec-kit-reaches-1-0-from-one-pipeline-to-five-primitives-f7bb6359e501) + +### The Copilot Skills Default Flip + +The month's quiet structural change was the completion of the **Copilot skills-default rollout**. `specify init --integration copilot` now installs **`speckit-*` skills by default** (#3976) rather than the legacy markdown-command layout, and the default init integration is **overridable via `SPECKIT_INTEGRATION_DEFAULT`** (#3952) so teams can pin a different default without touching flags. This is the flip that July's release notes warned was coming: agents that support skills install skills, the markdown-command layout becomes the legacy path, and both layouts continue to flow through the same manifest system. It lands the project's Copilot surface squarely on the native skills model that the wider integration layer has been converging toward all year. [\[github.com\]](https://github.com/github/spec-kit/releases) + +### Spec Kit Triages Itself: the `feature-assess` Workflow + +August's most on-brand feature was the **`feature-assess` agentic workflow** (#4186), which **installs and runs Spec Kit to assess an incoming feature request end to end**. Rather than a human reading each new request cold, the workflow provisions `uv` and Python (#4193), stands up the Spec Kit CLI and the `assess` extension (#4195), and runs the July `assess` "Idea Assessment Pipeline" against the request — capture → evidence → refine → design → decision — before a maintainer touches it. The initial landing was quickly followed by the provisioning and daily-credit-budget fixes needed to make it run reliably in CI (#4193, #4195, #4222). It is the clearest instance yet of the project **dogfooding its own primitives**: the tool that helps teams decide "should we build this?" is now wired into Spec Kit's own triage, running the `assess` pipeline on a labeled feature-request issue and posting each stage back. [\[github.com\]](https://github.com/github/spec-kit/releases) + +### The Composition Layer Matures + +Beyond the milestone, August's engineering pushed the **composition primitives** toward everyday practicality. Extension manifests now accept **`provides.templates` and `provides.scripts`** (#4012), letting an extension ship template and script overrides the same way it ships commands, with duplicate `provides` names rejected up front (#4016, #4191). Presets **resolve their constitution templates at command time** rather than eagerly (#3984), inherit `argument-hint` from the core template when wrapping so a wrap no longer silently drops it (#3996), and `specify` now **lists presets in resolution/precedence order** so overrides are visible at a glance (#4104). Namespaced preset commands are scaffolded self-contained (#4082), and the bundler now reads the authoritative `default_integration` field instead of only its legacy aliases (#3880). The through-line is the same as the 1.0.0 story: the core is increasingly a set of **named, overridable slots** that presets and extensions compose against, rather than a monolith to replace. [\[github.com\]](https://github.com/github/spec-kit/releases) + +### The Security-and-Robustness Campaign Continues + +July's hardening wave carried into August as standing discipline. **Bounded I/O** extended to the extension-catalog/download path (#3775), the integration-catalog fetch (#3812, #3818), and bundle downloads (#3764), and the **stdin read was capped at 1 MiB** to close a DoS path (#3857). **Race elimination** removed TOCTOU windows in file-unlink (#3819), zip packaging (#3855), and `RunState.load` (#3839). **URL and host hardening** taught the auth layer to treat exact host patterns literally (#4108) and, later in the month, to validate URL **ports** as well as hostnames across preset catalogs and credential matching, while **event-hook script paths are now confined to the project tree** (#4133) and the community submission workflow's output allowlists were tightened (#4103). + +Running alongside was a broad **non-UTF-8 and "fail-loudly" resilience sweep**: preset and extension registries, manifests, legacy commands, events, hook `config.toml`, resolver layers, and catalog responses all now **degrade gracefully instead of throwing** on malformed, unreadable, or wrong-encoding input (#3896, #3955, #3959, #3900, #3998, #3960, #3962, #3834, #3897, #3957, #3963, #3943, #3980, #3902, #4011, #3958). Workflow validation grew stricter in step — non-string step types, falsy non-mapping overlays and integration descriptors, empty condition blocks, and unvalidated dispatch defaults are now rejected with clear errors (#4111, #3884, #4187, #4182, #4181) — and a user-visible fix stopped `specify init` from **hanging on arrow-key pickers** in non-interactive agent harnesses (#4178). The hardening is prevention, arriving as the primitives open new surface. [\[github.com\]](https://github.com/github/spec-kit/releases) + +### Agent Integrations + +The agent portfolio kept growing. The built-in integrations catalog (`integrations/catalog.json`) grew from **37 to 38**, adding one new agent — a **Command Code** integration (#4019). Alongside it, integration *implementation* work landed for existing catalog entries: the **Junie** integration module was implemented with dot-to-hyphen command formatting (#4073), the **Mistral Vibe** (`vibe`) integration was brought to Claude parity (#4075), and the **Qoder CLI integration migrated to a skills-based layout** for Qoder IDE 1.24+ (#4205). Existing integrations were further refined: Claude and Alquimia argument-hint injection became **fold-aware** for long, folded descriptions (#4045, #4063), goose commands now dispatch via `goose run` (#3781, closing the 300-day #2416), and Kimi preserves non-UTF-8 user skills (#3895). The 1.0.0 documentation lists **38 integrations**, and the pattern from prior months holds — the surviving integrations keep getting more native to each agent, not merely more numerous. [\[github.com\]](https://github.com/github/spec-kit/releases) + +### The Extension, Preset & Bundle Ecosystem + +The community extension catalog grew from 144 to **162 entries** during August — eighteen net additions. Community presets grew from 29 to **34**, community workflows held at **2**, and community **bundles doubled from 1 to 2**. + +The month's most telling signal was **July's companion tooling entering the official catalog**. **SpecJudge** — the CLI that reads Spec Kit's constitution/spec/tasks artifacts to recommend a right-sized model, profiled in July's newsletter — was cataloged as an extension (#4079). And **SpecAssay** arrived as a **multi-artifact suite**: an extension (SpecAssay Check, #4113), a preset (#4123), and a **bundle** (#4125) — the second cataloged community bundle. The third-party layer that formed *around* Spec Kit in July is consolidating *into* the ecosystem. + +Notable new extensions by category: + +- **Verification, review & governance**: Architecture Governance, SpecAssay Check, Taco Review (human review packaging), adrkit (ADR authoring) +- **Requirements & intake**: SpecKit Grill Me (a more thorough clarification skill), Pre-Spec Cards, Charter (updated) +- **Knowledge, memory & inventory**: DUBSAR Memory, Spec Inventory, spec-kit-atlas, Keel Discovery +- **External trackers & bridges**: Jira Mirror, AgentDocx, AgentPay x402 (spend controls), Azure Cosmos DB code-gen +- **Model routing & sizing**: SpecJudge, Model Routing Governance (preset), Closed Vocabulary Check (preset) + +The catalog also showed heavy maintenance: **Archive** (to v1.3.0), **Reconcile** (v1.2.1), **Security Review** (v2.0.0), **Architecture Guard** (v2.3.6), **MAQA**, **Superspec**, and the **Spec Kit Figma** bridge all iterated, and a large **governance-preset** family — Security, Architecture, iSAQB, A11Y, Cross-Platform, Agent-Parity, and the Intake and Autonomous-Run suites — pushed coordinated version bumps. [\[github.com\]](https://github.github.io/spec-kit/community/extensions.html) + +### Documentation & Docs Site + +August's documentation was built around the milestone. The **first-anniversary marker** (#4260), a **project history page** (#4262) tracing the pipeline-to-primitives arc, and a **branding refresh** to the Spec Kit logo (#4264) framed the 1.0.0 release. Practical guidance expanded: **workflow quickstart guides** (#4258) and **Python init-script** documentation (#4331) for workflows and an **existing-project adoption** guide (#4263). The **extension catalog trust model** was clarified across docs, help, and messaging (#4177), reinforcing the discovery-only nature of the community catalog, and installing `specify-cli` from a **custom package index** was documented (#4032). [\[github.com\]](https://github.com/github/spec-kit/releases) + +## Community & Content + +### Press and Industry Coverage + +August's coverage centered on the 1.0.0 milestone, with the mix continuing July's shift toward comparison pieces, field guides, and pointed "is it too heavy?" critiques. No first-party Microsoft or GitHub (non-maintainer) post appeared in August; the nearest remained June's Microsoft Developer Blog piece. + +**Stanislav Deviatov** (Medium, August 24) published the month's marquee article, titled *"Spec Kit Reaches 1.0: From One Pipeline to Five Primitives"* — a deep, well-sourced retrospective (Solution Architect at EPAM) tracing how Spec Kit evolved from a linear pipeline into five primitives plus catalogs and the `/speckit.converge` loop. It endorses the direction while qualifying the "1.0.0 = stable" claim, the real migration cost beyond mechanical call-site edits, and the unenforced trust boundary. It is the clearest external articulation of what the milestone means. [\[medium.com\]](https://stn1slv.medium.com/spec-kit-reaches-1-0-from-one-pipeline-to-five-primitives-f7bb6359e501) + +**Roan Brasil Monteiro** (Medium, August 20) published a 21-minute *field guide to BMAD, Spec Kit, OpenSpec, and Kiro*, framed around "too much process burns money, too little burns more," positioning Spec Kit as the thorough, heavier option among four SDD frameworks. [\[medium.com\]](https://medium.com/@roanmonteiro/bmad-spec-kit-openspec-kiro-a-field-guide-to-getting-real-work-out-of-ai-coding-agents-b17833c24b3f) + +**百度百家号** (AI钉子铺, August 23) ran a half-year growth-rate review of five SDD frameworks that credits Spec Kit's GitHub/Microsoft brand pull and 14+ agent support but amplifies Martin Fowler's "8+ markdown files per spec" critique, arguing its growth is partly "brand premium." **Andrew** (DEV Community, August 28), reviewing OpenSpec, engaged Spec Kit substantively as the main comparison — citing 131,957 stars, phase gates, the Python requirement, and the larger extension catalog. [\[baijiahao.baidu.com\]](https://baijiahao.baidu.com/s?id=1874316315966110702) + +### Developer Articles and Field Reports + +August's articles skewed toward honest, use-it-in-anger critique, with several first-hand field reports and a strong multilingual current in Japanese, Korean, Chinese, Spanish, and Thai. + +Notable articles: + +- **Lusivision** (DEV Community, August 28) — *"Spec-Driven Development: The New AI Coding Workflow,"* an SDD overview built around Spec Kit's four gated phases as the central vehicle, honest about where the overhead pays back. [\[dev.to\]](https://dev.to/lusivision/spec-driven-development-the-new-ai-coding-workflow-24lh) +- **chae_eun_ini** (velog, August 26, Korean) — a candid field report where 13 features grew `specs/` to 12,931 lines (73% of the 17,590-line `src/`); concludes the real problem was that finished spec docs left "nothing to decide, only to approve," and documents **moving off Spec Kit to a self-built human-first harness**. [\[velog.io\]](https://velog.io/@chae_eun_ini/Spec-Kit%EC%97%90%EC%84%9C-%EC%9E%90%EC%B2%B4-%EC%A0%9C%EC%9E%91-%ED%95%98%EB%84%A4%EC%8A%A4%EB%A1%9C-%EA%B7%B8-%EC%82%AC%EC%9D%B4-%EA%B3%BC%EB%8F%84%EA%B8%B0%EC%9D%98-%EA%B8%B0%EB%A1%9D) +- **ta_kawano** (note.com, August 10, Japanese) — a consolidated eight-part continuation of the "要求AI" series that rigorously measures Spec Kit's cost/tokens/time when adding a requirement (~$31, ~34 min; 301→331 tests), concluding SDD covers "spec→code" but leaves **requirements elicitation outside its scope**. [\[note.com\]](https://note.com/takawano/n/nd01c18c93580) +- **New2026** (Medium, August 19) — *"AI Coding Frameworks Explained,"* a five-layer "stack" mental model positioning Spec Kit as one control layer among ~8 frameworks ("they are not competing; they constrain different parts"). [\[medium.com\]](https://new2026.medium.com/ai-coding-frameworks-explained-superpowers-gsd-gstack-ralph-spec-kit-and-the-agentic-3dd92e559636) +- **Katsumata** (Zenn, August 24, Japanese) — a designer, inspired by Spec Kit, builds a spec-driven *design* system (YAML component specs → React/CSS/tests/Storybook via CI gates), candid about where spec→production quality breaks down. [\[zenn.dev\]](https://zenn.dev/katsumata/articles/014affaeb272d00aeae6) +- **guillermodelpino.com** (Guillermo del Pino, August 15, Spanish) — a positive analytical review walking the `constitution → … → implement → converge` flow, singling out `converge`'s "is this really done?" step and arguing the discipline is valuable even for non-programmers. [\[guillermodelpino.com\]](https://guillermodelpino.com/repos/spec-kit-github-desarrollo-guiado-por-especificacion) + +Additional coverage appeared on DEV Community (TekMag, Jeffrey Bakker), Naver/velog/Tistory (Korean), Qiita/Zenn (Japanese), CSDN and 百家号 (Chinese), and Vibe Coding Thailand (Thai) — including several head-to-head OpenSpec-vs-Spec-Kit comparisons and recurring documentation-proliferation critiques. [\[dev.to\]](https://dev.to/tekmag/githubs-spec-kit-the-open-source-toolkit-bringing-spec-driven-development-to-ai-coding-agents-ede) + +### Community Growth by the Numbers + +| Metric | Start of August | End of August | Change | +| --- | --- | --- | --- | +| GitHub stars | 124,655 | ~132,000 | +~7,300 (+6%) | +| Forks | 11,125 | ~11,900 | +~775 | +| Contributors | ~258 | ~270 | +~12 | +| Releases (total) | 205 | 215 | +10 (v0.15.2–v1.0.2) | +| Community extensions | 144 | 162 | +18 | +| Community presets | 29 | 34 | +5 | +| Community workflows | 2 | 2 | steady | +| Community bundles | 1 | 2 | +1 | +| Agent integrations (catalog) | 37 | 38 | +1 (Command Code) | +| Discussions (total) | ~474 | ~482 | +~8 | + +## SDD Ecosystem & Industry Trends + +### From Companion Tools to Catalog Entries + +July's clearest ecosystem signal was a *pattern* — independent developers building tools on top of Spec Kit's artifacts. August's signal was that pattern **consolidating**: those companion tools began arriving in the official catalog. **SpecJudge** (model right-sizing) was cataloged as an extension, and **SpecAssay** landed as a full multi-artifact suite — extension, preset, and the ecosystem's second bundle. The loudest theme across the now-162 cataloged extensions remains verification and quality (gate, review, validate, drift, evidence, sync), and 1.0.0's catalog design — discovery-only community listings, priority-ordered promotion into an organization's own vetted catalog — turns that demand into a governable pipeline rather than an unmanaged sprawl. The community proposes, the catalog measures what grows, and the core promotes the winners; `/speckit.converge` (drift → core loop) was the template, and August's companion-tool intake is the pattern repeating. [\[github.com\]](https://github.github.io/spec-kit/community/extensions.html) + +### Competitive Landscape + +The "which SDD tool?" genre stayed dominant, but 1.0.0 shifted its framing. Where earlier months ran tool-vs-tool feature bake-offs, August's most substantive pieces — Deviatov's retrospective, Monteiro's field guide, the 百家号 growth review — increasingly argue the frameworks are **converging on the same primitives**, which moves the question from "which tool wins" to "**which platform and governance model.**" On that axis, Spec Kit's five-primitive surface, its uniform catalog/trust model, its agent-neutrality, and the companion layer now consolidating into the catalog are the differentiators. The recurring counter-critique held steady and, if anything, sharpened around the milestone: documentation proliferation, cognitive load, and "who verifies the spec, and who reads all this?" remain the consistent trade-off — with the most pointed field report of the month (chae_eun_ini) documenting a team leaving Spec Kit for a lighter self-built harness. [\[medium.com\]](https://stn1slv.medium.com/spec-kit-reaches-1-0-from-one-pipeline-to-five-primitives-f7bb6359e501) + +## Roadmap + +Areas under discussion or in progress for future development: + +- **After 1.0.0, the trust boundary is the headline work** — the community catalog is discovery-only, and the maintainer calls making the guidance-vs-enforcement boundary *legible* the most valuable unfinished work. Expect continued investment in organization-owned catalogs, promotion pipelines, and provenance so "what is installable" can quietly become "what we approved." [\[medium.com\]](https://stn1slv.medium.com/spec-kit-reaches-1-0-from-one-pipeline-to-five-primitives-f7bb6359e501) +- **Deeper composition primitives** — August's `provides.templates`/`provides.scripts`, command-time template resolution, and precedence-ordered presets point toward finer-grained, named composition points; early post-milestone signals include reusable **workflow slots** and letting a **preset declare a required extension**, tightening the algebra by which one layer reshapes another. [\[github.com\]](https://github.com/github/spec-kit/releases) +- **Agentic self-service** — `feature-assess` joins an existing set of Copilot-engine agentic workflows the project runs on itself: the `bug-assess → bug-test → bug-fix` triage pipeline and the `add-community-extension`/`-preset`/`-bundle` catalog-submission automations. Each is label-triggered and installs/runs Spec Kit or its tooling to do real maintenance work; expect these pipelines to deepen as the project dogfoods its own primitives. [\[github.com\]](https://github.com/github/spec-kit/releases) +- **The Copilot skills surface** — with the skills default now flipped and `SPECKIT_INTEGRATION_DEFAULT` overridable, the markdown-command layout becomes the legacy path. The first-party [`github/spec-kit-copilot`](https://github.com/github/spec-kit-copilot) plugin continues to explore a **visual, Copilot-driven surface** — a Spec Kit Wizard canvas with live boot progress — turning the CLI's flows into an interactive layer. [\[github.com\]](https://github.com/github/spec-kit-copilot) +- **Security and robustness as routine** — the bounded-read / TOCTOU / URL-port / non-UTF-8 / fail-loudly campaign is now standing discipline rather than a wave. Sustaining the no-unbounded-read and graceful-degradation invariants as the surface (bundles, workflows, catalogs, events, integrations) keeps growing is the ongoing work. [\[github.com\]](https://github.com/github/spec-kit/releases) +- **Experience simplification** — documentation proliferation and cognitive load remain the single most-cited concern across August's balanced reviews (chae_eun_ini, ta_kawano, the 百家号 review). The `assess` upstream gate, lean presets, `/speckit.converge`, and the consolidating companion-tooling layer all provide answers; surfacing them so new users feel 1.0.0 as *coherence* rather than *weight* is the persistent opportunity. [\[medium.com\]](https://stn1slv.medium.com/spec-kit-reaches-1-0-from-one-pipeline-to-five-primitives-f7bb6359e501) From c6ab21441b290629b23fbd6db6102a1d075371e2 Mon Sep 17 00:00:00 2001 From: Ali jawwad <33836051+jawwad-ali@users.noreply.github.com> Date: Tue, 8 Sep 2026 18:04:32 +0500 Subject: [PATCH 15/44] fix(cline): stop unrelated prose from suppressing the hook command note (#4150) `ClineIntegration._inject_hook_command_note` guarded idempotency with a whole-document substring scan -- `if "replace dots" in content: return content` -- instead of the per-instruction check the shared `SkillsIntegration._inject_hook_command_note` helper uses (base.py:1637-1642), which compares only the line immediately above each match. Two consequences, both verified on main: A. unrelated prose -> note injected? False (base: True) B. two hook sections, one already noted -> notes: 1 (want 2) (a) Any command or extension markdown whose prose happens to contain the phrase "replace dots" loses the note entirely, so the generated workflow tells the agent to emit `/speckit.git.commit` -- a dotted command Cline never registers, since Cline installs `speckit-git-commit.md`. (b) A document with one already-noted hook section never gets a note on a second, un-noted one -- exactly what the base helper was changed to handle. Also aligns the capture group with the base helper: `^([ \t]*)` rather than `^(\s*)`. Because `\s` matches newlines the captured "indent" could swallow a preceding blank line, which was then re-emitted between the note and the instruction: '## Hooks\n\n \n\n - For each executable hook, ...' The per-instruction check cannot line up until this is fixed. Co-authored-by: Claude Opus 5 (1M context) --- .../integrations/cline/__init__.py | 26 +++++++-- tests/integrations/test_integration_cline.py | 53 +++++++++++++++++++ 2 files changed, 74 insertions(+), 5 deletions(-) diff --git a/src/specify_cli/integrations/cline/__init__.py b/src/specify_cli/integrations/cline/__init__.py index c3ea3cc409..c48a4dd5f8 100644 --- a/src/specify_cli/integrations/cline/__init__.py +++ b/src/specify_cli/integrations/cline/__init__.py @@ -101,14 +101,25 @@ def _inject_hook_command_note(content: str) -> str: Targets the line ``- For each executable hook, output the following`` and inserts the note on the line before it, matching its indentation. - Skips if the note is already present. + Skips individual instructions that already have the note immediately + above them. """ - if "replace dots" in content: - return content + note = _HOOK_COMMAND_NOTE.rstrip("\n") def repl(m: re.Match[str]) -> str: indent = m.group(1) instruction = m.group(2) + # Check the line immediately above this instruction, mirroring the + # shared ``SkillsIntegration`` helper. The previous whole-document + # ``if "replace dots" in content`` scan meant any command or + # extension markdown whose prose merely contained that phrase lost + # the note entirely -- leaving the agent told to emit a dotted + # ``/speckit.git.commit``, which Cline never registers -- and a + # document with one already-noted section never got a note on a + # second, un-noted one. + previous_lines = content[:m.start()].splitlines() + if previous_lines and previous_lines[-1] == indent + note: + return m.group(0) # ``eol`` is empty when the regex matched via ``$`` because the # instruction was the final line of a file with no trailing # newline. Default to ``\n`` so the note never collapses onto @@ -116,15 +127,20 @@ def repl(m: re.Match[str]) -> str: eol = m.group(3) or "\n" return ( indent - + _HOOK_COMMAND_NOTE.rstrip("\n") + + note + eol + indent + instruction + eol ) + # ``[ \t]*`` rather than ``\s*``: ``\s`` matches newlines, so the + # captured "indent" could swallow a preceding blank line and the note + # was then emitted with a spurious blank line between it and the + # instruction. This also matches the shared base helper, without which + # the per-instruction check above cannot line up. return re.sub( - r"(?m)^(\s*)(- For each executable hook, output the following[^\r\n]*)(\r\n|\n|$)", + r"(?m)^([ \t]*)(- For each executable hook, output the following[^\r\n]*)(\r\n|\n|$)", repl, content, ) diff --git a/tests/integrations/test_integration_cline.py b/tests/integrations/test_integration_cline.py index 3c813e8300..d7350a9633 100644 --- a/tests/integrations/test_integration_cline.py +++ b/tests/integrations/test_integration_cline.py @@ -106,6 +106,59 @@ def test_cline_hook_instruction_injection_no_trailing_newline(self): # Instruction stays on its own line rather than being mashed onto the note. assert "\n- For each executable hook, output the following:" in injected + def test_cline_hook_note_not_suppressed_by_unrelated_prose(self): + """Unrelated prose must not suppress the note for a real instruction. + + The idempotency guard used to be a whole-document substring scan + (``if "replace dots" in content``), so any command or extension + markdown whose prose merely contained that phrase lost the note + entirely -- leaving the agent told to emit ``/speckit.git.commit``, + a dotted command Cline never registers. + """ + cline = get_integration("cline") + content = ( + "# DB extension command\n\n" + "When normalizing table names, replace dots with underscores.\n\n" + "## Pre-Execution Hooks\n" + "- For each executable hook, output the following:\n" + ) + injected = cline._inject_hook_command_note(content) + assert "`/speckit-git-commit`" in injected, injected + # The user's own prose is untouched. + assert "replace dots with underscores" in injected + + def test_cline_hook_note_added_to_every_un_noted_instruction(self): + """A second, un-noted hook section must still get its own note.""" + cline = get_integration("cline") + instruction = "- For each executable hook, output the following:\n" + # Section 1 already carries the note; section 2 does not. + first = cline._inject_hook_command_note("## Hooks A\n" + instruction) + content = first + "\n## Hooks B\n" + instruction + + injected = cline._inject_hook_command_note(content) + assert injected.count("replace dots (`.`) with hyphens (`-`)") == 2, injected + # Still idempotent: re-running adds nothing. + assert cline._inject_hook_command_note(injected) == injected + + def test_cline_hook_note_sits_directly_above_indented_instruction(self): + """No blank line may be inserted between the note and the instruction. + + The regex captured indentation with ``\\s*``, which matches newlines, + so a preceding blank line was swallowed into the "indent" and re-emitted + between the note and the instruction. + """ + cline = get_integration("cline") + content = "## Hooks\n\n - For each executable hook, output the following:\n" + injected = cline._inject_hook_command_note(content) + lines = injected.splitlines() + instruction_idx = next( + i for i, line in enumerate(lines) if "For each executable hook" in line + ) + assert "replace dots" in lines[instruction_idx - 1], injected + # Indentation is preserved on both lines. + assert lines[instruction_idx].startswith(" - For each") + assert lines[instruction_idx - 1].startswith(" - When constructing") + # -- Overrides for MarkdownIntegrationTests --------------------------- def test_setup_creates_files(self, tmp_path): From d00176e2f6eca8b108e44615dfd941176e37cb53 Mon Sep 17 00:00:00 2001 From: Noor ul ain Date: Tue, 8 Sep 2026 18:22:59 +0500 Subject: [PATCH 16/44] fix(workflows): reject falsy non-mapping step.yml in step add (#4321) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix(workflows): reject falsy non-mapping step.yml in step add `workflow_step_add` parses a fetched `step.yml` with `_yaml.safe_load(...) or {}`, which coerces a FALSY non-mapping top-level document (`[]`, `false`, `0`, `''`) to `{}` before the `isinstance(meta, dict)` shape check runs. The command then proceeds with `meta = {}`, derives `step_meta = {}` and `type_key = ""`, and reports the unrelated "step.yml missing 'step.type_key' field" instead of the real problem: "step.yml must be a YAML mapping". A TRUTHY non-mapping document (a bare string) already reported the correct error — this was an inconsistency. Same falsy-or-coerce shape as the catalog-config bugs fixed elsewhere in workflows/catalog.py, presets/__init__.py, and integrations (#4187) this cycle. Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_01FW9fAYsCBCAgdKWovtSyqt * fix(workflows): distinguish explicit YAML null from empty step.yml An explicit null document (null/~/NULL) parses to the same None as a genuinely empty document, so it was silently coerced to {} and misreported as the unrelated "missing step.type_key" error instead of the mapping-shape error, per Copilot review on PR #4321. Use yaml.compose to tell the two apart, matching the sibling loaders (yamlio.py, integrations/catalog.py, overlays/layer_sources.py). Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_0147sii7uC56YzAu2Ep9qH94 * Potential fix for pull request finding Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --------- Co-authored-by: Claude Sonnet 5 Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --- src/specify_cli/workflows/_commands.py | 23 +++++++- tests/test_workflows.py | 73 ++++++++++++++++++++++++++ 2 files changed, 94 insertions(+), 2 deletions(-) diff --git a/src/specify_cli/workflows/_commands.py b/src/specify_cli/workflows/_commands.py index 716b6c8a19..f275bfe09a 100644 --- a/src/specify_cli/workflows/_commands.py +++ b/src/specify_cli/workflows/_commands.py @@ -3364,12 +3364,31 @@ def _safe_fetch(url: str) -> bytes: try: import yaml as _yaml - meta = _yaml.safe_load(step_yml_content.decode("utf-8")) or {} + step_yml_text = step_yml_content.decode("utf-8") + # ``safe_load`` returns None for BOTH an empty document and an + # explicit null scalar (``null``, ``~``, ``NULL``), so it cannot + # tell them apart on its own. ``compose`` yields no node only for + # a genuinely empty document. + node = _yaml.compose(step_yml_text) + meta = _yaml.safe_load(step_yml_text) + is_empty_document = node is None or ( + meta is None + and isinstance(node, _yaml.nodes.ScalarNode) + and node.value == "" + and node.start_mark.index == node.end_mark.index + ) except Exception as exc: console.print(f"[red]Error:[/red] Invalid step.yml: {exc}") raise typer.Exit(1) - if not isinstance(meta, dict): + # Do NOT coerce with ``or {}`` here: that also turns a FALSY non-mapping + # (top-level ``[]``, ``false``, ``0``, ``''``, or an explicit ``null``) + # into ``{}`` and silently bypasses this shape check, surfacing the + # unrelated "missing 'step.type_key'" error below instead of the real + # problem. Only a genuinely empty document defaults to ``{}``. + if meta is None and is_empty_document: + meta = {} + elif not isinstance(meta, dict): console.print("[red]Error:[/red] step.yml must be a YAML mapping") raise typer.Exit(1) diff --git a/tests/test_workflows.py b/tests/test_workflows.py index 9b1c17881e..980cc21a6e 100644 --- a/tests/test_workflows.py +++ b/tests/test_workflows.py @@ -10671,6 +10671,79 @@ def read(self, size=-1): project_dir / ".specify" / "workflows" / "steps" / "my-step" ).exists() + @pytest.mark.parametrize( + "step_yml_body", [b"[]", b"false", b"0", b"''", b"null", b"~", b"NULL"] + ) + def test_add_rejects_falsy_non_mapping_step_yml( + self, project_dir, monkeypatch, step_yml_body + ): + """A FALSY non-mapping step.yml document ([], false, 0, '') must be + reported as "step.yml must be a YAML mapping", not silently coerced by + ``or {}`` into {} and then misreported as the unrelated "missing + 'step.type_key'" error — matching how a TRUTHY non-mapping document + (e.g. a bare string) already reports the mapping-shape error. An + explicit null scalar (null/~/NULL) parses to the same ``None`` as a + genuinely empty document, so it must be distinguished (via + ``yaml.compose``) and rejected too, rather than defaulting to {}.""" + from typer.testing import CliRunner + from specify_cli import app + from specify_cli.workflows.catalog import StepCatalog + from specify_cli.authentication import http as auth_http + + monkeypatch.chdir(project_dir) + monkeypatch.setattr( + StepCatalog, + "get_step_info", + lambda self, step_id: { + "id": step_id, + "name": "Test Step", + "url": "https://example.com/step.yml", + "init_url": "https://example.com/__init__.py", + "_install_allowed": True, + }, + ) + + class _FakeResponse: + def __init__(self, url): + self.url = url + self.body = step_yml_body if url.endswith("step.yml") else b"" + self.offset = 0 + + def __enter__(self): + return self + + def __exit__(self, exc_type, exc, tb): + return False + + def getheader(self, name): + return None + + def geturl(self): + return self.url + + def read(self, size=-1): + if size < 0: + size = len(self.body) - self.offset + chunk = self.body[self.offset : self.offset + size] + self.offset += len(chunk) + return chunk + + monkeypatch.setattr( + auth_http, + "open_url", + lambda url, timeout=30, redirect_validator=None: _FakeResponse(url), + ) + + result = CliRunner().invoke( + app, ["workflow", "step", "add", "my-step"] + ) + + assert result.exit_code != 0 + assert "step.yml must be a YAML mapping" in result.output + assert not ( + project_dir / ".specify" / "workflows" / "steps" / "my-step" + ).exists() + @pytest.mark.parametrize( ("catalog_fields", "expected"), [ From 072ab333cfd499ea594eeccab7696e5a2f6b140f Mon Sep 17 00:00:00 2001 From: "Jialong(Bruce) Li" Date: Tue, 8 Sep 2026 21:39:33 +0800 Subject: [PATCH 17/44] fix(tasks): require field constraints from data-model.md in generated tasks (#4430) * fix(tasks): require field constraints from data-model.md in generated tasks /speckit.tasks mapped data-model.md entities to user stories but never told the agent to carry field-level constraints (max length, nullable, enum values, validation rules) into the task text. Left to discretion, the implementing agent can silently invent its own value instead of the one recorded in data-model.md. Fixes #4383 * test(tasks): tighten constraint regression assertion to match verbatim wording Addresses Copilot review feedback that the prior assertion only checked for the word 'constraint', so it would still pass if the rule's meaning were reversed (e.g. constraints permitted to be omitted). --- templates/commands/tasks.md | 1 + tests/test_tasks_template_constraints.py | 29 ++++++++++++++++++++++++ 2 files changed, 30 insertions(+) create mode 100644 tests/test_tasks_template_constraints.py diff --git a/templates/commands/tasks.md b/templates/commands/tasks.md index 64146a35aa..99e276fa7a 100644 --- a/templates/commands/tasks.md +++ b/templates/commands/tasks.md @@ -197,6 +197,7 @@ Every task MUST strictly follow this format: - Map each entity to the user story(ies) that need it - If entity serves multiple stories: Put in earliest story or Setup phase - Relationships → service layer tasks in appropriate story phase + - For each field with constraints in data-model.md (max length, nullable/required, enum values, validation rules), quote the constraint verbatim in the task description so it is not left to implementation-time discretion 4. **From Setup/Infrastructure**: - Shared infrastructure → Setup phase (Phase 1) diff --git a/tests/test_tasks_template_constraints.py b/tests/test_tasks_template_constraints.py new file mode 100644 index 0000000000..a76cfb050b --- /dev/null +++ b/tests/test_tasks_template_constraints.py @@ -0,0 +1,29 @@ +"""Regression test for #4383: tasks.md loses data-model field constraints. + +The /speckit.tasks command template maps data-model.md entities to task +descriptions but did not require that field-level constraints (max length, +nullable/required, enum values, validation rules) be carried into the +generated task text verbatim. Without an explicit instruction, the +implementing agent falls back to its own defaults instead of the value +recorded in data-model.md. +""" + +from pathlib import Path + +REPO_ROOT = Path(__file__).parent.parent +TASKS_TEMPLATE = REPO_ROOT / "templates" / "commands" / "tasks.md" + + +def test_data_model_section_requires_verbatim_field_constraints(): + content = TASKS_TEMPLATE.read_text(encoding="utf-8") + + from_data_model_start = content.index("**From Data Model**") + next_section_start = content.index("**From Setup/Infrastructure**") + section = content[from_data_model_start:next_section_start] + + assert "quote the constraint verbatim in the task description" in section.lower(), ( + "The 'From Data Model' task-organization rules must instruct the " + "agent to quote field constraints (max length, nullable, enum, " + "validation rules) from data-model.md verbatim in task descriptions, " + "not merely mention that constraints exist." + ) From 3a19a6ba900e34a9f5e02848fb8737d1c364a04b Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Tue, 8 Sep 2026 10:56:29 -0500 Subject: [PATCH 18/44] chore(deps): bump the codeql-action group with 2 updates (#4436) Bumps the codeql-action group with 2 updates: [github/codeql-action/init](https://github.com/github/codeql-action) and [github/codeql-action/analyze](https://github.com/github/codeql-action). Updates `github/codeql-action/init` from 4.37.8 to 4.37.9 - [Release notes](https://github.com/github/codeql-action/releases) - [Changelog](https://github.com/github/codeql-action/blob/main/CHANGELOG.md) - [Commits](https://github.com/github/codeql-action/compare/db488ddef3bf6cb639b32c2e9a7c0a7ea8271d28...cdf488f595d80d6e07e03d4674febd5ab45fa938) Updates `github/codeql-action/analyze` from 4.37.8 to 4.37.9 - [Release notes](https://github.com/github/codeql-action/releases) - [Changelog](https://github.com/github/codeql-action/blob/main/CHANGELOG.md) - [Commits](https://github.com/github/codeql-action/compare/db488ddef3bf6cb639b32c2e9a7c0a7ea8271d28...cdf488f595d80d6e07e03d4674febd5ab45fa938) --- updated-dependencies: - dependency-name: github/codeql-action/init dependency-version: 4.37.9 dependency-type: direct:production update-type: version-update:semver-patch dependency-group: codeql-action - dependency-name: github/codeql-action/analyze dependency-version: 4.37.9 dependency-type: direct:production update-type: version-update:semver-patch dependency-group: codeql-action ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- .github/workflows/codeql.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/codeql.yml b/.github/workflows/codeql.yml index 2cc123238c..2c6364f104 100644 --- a/.github/workflows/codeql.yml +++ b/.github/workflows/codeql.yml @@ -22,11 +22,11 @@ jobs: uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - name: Initialize CodeQL - uses: github/codeql-action/init@db488ddef3bf6cb639b32c2e9a7c0a7ea8271d28 # v4 + uses: github/codeql-action/init@cdf488f595d80d6e07e03d4674febd5ab45fa938 # v4 with: languages: ${{ matrix.language }} - name: Perform CodeQL Analysis - uses: github/codeql-action/analyze@db488ddef3bf6cb639b32c2e9a7c0a7ea8271d28 # v4 + uses: github/codeql-action/analyze@cdf488f595d80d6e07e03d4674febd5ab45fa938 # v4 with: category: "/language:${{ matrix.language }}" From 7a121f1ffa1df903d8acfbba806675c13df47615 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Tue, 8 Sep 2026 14:36:39 -0500 Subject: [PATCH 19/44] Update BrownKit extension to v1.2.0 (#4474) Update brownkit extension submitted by @MaksimShevtsov: - extensions/catalog.community.json (version, download_url, metadata) - docs/community/extensions.md community extensions table Closes #4435 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 | 2 +- extensions/catalog.community.json | 23 +++++++++++++++-------- 2 files changed, 16 insertions(+), 9 deletions(-) diff --git a/docs/community/extensions.md b/docs/community/extensions.md index 69119631e0..f3a1e61c77 100644 --- a/docs/community/extensions.md +++ b/docs/community/extensions.md @@ -49,7 +49,7 @@ The following community-contributed extensions are available in [`catalog.commun | Blueprint Index — Living Architecture Map | A living architecture map for spec-driven projects, kept honest by a deterministic, low-friction, machine-first CI gate (JSON, self-healable) that blocks only when the map contradicts the specs or code. Brownfield or greenfield. | `process` | Read+Write | [spec-kit-blueprint](https://github.com/ogil109/spec-kit-blueprint) | | Branch Convention | Configurable branch and folder naming conventions for /specify with presets and custom patterns | `process` | Read+Write | [spec-kit-branch-convention](https://github.com/Quratulain-bilal/spec-kit-branch-convention) | | Brownfield Bootstrap | Bootstrap spec-kit for existing codebases — auto-discover architecture and adopt SDD incrementally | `process` | Read+Write | [spec-kit-brownfield](https://github.com/Quratulain-bilal/spec-kit-brownfield) | -| BrownKit | Evidence-driven capability discovery, security and QA risk assessment for existing codebases | `process` | Read+Write | [BrownKit](https://github.com/MaksimShevtsov/BrownKit) | +| BrownKit — Brownfield Discovery for Spec-Kit | Evidence-driven capability discovery, security and QA risk assessment for existing codebases. | `process` | Read+Write | [BrownKit](https://github.com/MaksimShevtsov/BrownKit) | | 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) | diff --git a/extensions/catalog.community.json b/extensions/catalog.community.json index 282127c7de..232814ed07 100644 --- a/extensions/catalog.community.json +++ b/extensions/catalog.community.json @@ -1,6 +1,6 @@ { "schema_version": "1.0", - "updated_at": "2026-09-03T00:00:00Z", + "updated_at": "2026-09-08T00:00:00Z", "catalog_url": "https://raw.githubusercontent.com/github/spec-kit/main/extensions/catalog.community.json", "extensions": { "adrkit": { @@ -906,20 +906,27 @@ "id": "brownkit", "description": "Evidence-driven capability discovery, security and QA risk assessment for existing codebases.", "author": "Maksim Shautsou", - "version": "1.0.1", - "download_url": "https://github.com/MaksimShevtsov/BrownKit/archive/refs/tags/v1.0.1.zip", + "version": "1.2.0", + "download_url": "https://github.com/MaksimShevtsov/BrownKit/archive/refs/tags/v1.2.0.zip", "repository": "https://github.com/MaksimShevtsov/BrownKit", - "homepage": "https://github.com/MaksimShevtsov/BrownKit", - "documentation": "https://github.com/MaksimShevtsov/BrownKit/blob/main/README.md", + "homepage": "https://github.com/MaksimShevtsov/BrownKit/blob/main/README.md", + "documentation": "https://github.com/MaksimShevtsov/BrownKit/blob/main/docs/methodology.md", "changelog": "https://github.com/MaksimShevtsov/BrownKit/blob/main/CHANGELOG.md", "license": "MIT", "category": "process", "effect": "read-write", "requires": { - "speckit_version": ">=0.1.0" + "speckit_version": ">=0.1.0", + "tools": [ + { + "name": "python", + "version": ">=3.9", + "required": false + } + ] }, "provides": { - "commands": 10, + "commands": 11, "hooks": 5 }, "tags": [ @@ -933,7 +940,7 @@ "downloads": 0, "stars": 0, "created_at": "2026-05-10T00:00:00Z", - "updated_at": "2026-05-10T00:00:00Z" + "updated_at": "2026-09-08T00:00:00Z" }, "bugfix": { "name": "Bugfix Workflow", From 0d1a1bda1496adce91d3ab53e51f1cae53c6f747 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Tue, 8 Sep 2026 15:52:40 -0500 Subject: [PATCH 20/44] Update OKF Knowledge Bundle Generator to v0.5.0 (#4475) Update okf extension submitted by @alexcpn:\n- extensions/catalog.community.json (version, download_url, metadata, and tool requirements)\n- docs/community/extensions.md community extensions table\n\nCloses #4439\n\nAssisted-by: GitHub Copilot (model: gpt-5.2-codex, autonomous)\nCo-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com> --- docs/community/extensions.md | 2 +- extensions/catalog.community.json | 33 ++++++++++++++++++++++--------- 2 files changed, 25 insertions(+), 10 deletions(-) diff --git a/docs/community/extensions.md b/docs/community/extensions.md index f3a1e61c77..0c17cfcbb7 100644 --- a/docs/community/extensions.md +++ b/docs/community/extensions.md @@ -107,7 +107,7 @@ The following community-contributed extensions are available in [`catalog.commun | Multi-Repo Branch Sync | Creates the feature branch in affected sub-repositories and git submodules via plan/tasks hooks | `process` | Read+Write | [multi-repo-sync](https://github.com/fyloss/spec-kit-multi-repo-sync) | | Multi-Sites Spec Kit | Multi-site aware specify command with per-site spec folders, auto-increment, and Drupal support | `process` | Read+Write | [spec-kit-multi-sites](https://github.com/teeyo/spec-kit-multi-sites) | | .NET Framework to Modern .NET Migration | Orchestrate end-to-end .NET Framework to modern .NET migration across 7 phases, with SDD lifecycle integration | `process` | Read+Write | [spec-kit-fx-to-net](https://github.com/RogerBestMsft/spec-kit-FxToNet) | -| OKF Knowledge Bundle Generator | Generates and maintains an Open Knowledge Format (OKF v0.1) knowledge bundle from a source-code repository, mining git history for significance and rationale, and resolving open questions with the user | `docs` | Read+Write | [speckit_ofk](https://github.com/alexcpn/speckit_ofk) | +| OKF Knowledge Bundle Generator | Generates and maintains an Open Knowledge Format (OKF v0.1) knowledge bundle from a source-code repository, mining git history for rationale and parking what it cannot verify as open questions | `docs` | Read+Write | [speckit_okf](https://github.com/alexcpn/speckit_okf) | | Onboard | Contextual onboarding and progressive growth for developers new to spec-kit projects. Explains specs, maps dependencies, validates understanding, and guides the next step | `process` | Read+Write | [spec-kit-onboard](https://github.com/dmux/spec-kit-onboard) | | Optimize | Audit and optimize AI governance for context efficiency — token budgets, rule health, interpretability, compression, coherence, and echo detection | `process` | Read+Write | [spec-kit-optimize](https://github.com/sakitA/spec-kit-optimize) | | Orchestration Task Context Management | Adds subagent work-unit orchestration to generated Spec Kit task files | `process` | Read+Write | [spec-kit-orchestration-task-context-management](https://github.com/benizzio/spec-kit-orchestration-task-context-management) | diff --git a/extensions/catalog.community.json b/extensions/catalog.community.json index 232814ed07..56c01407c3 100644 --- a/extensions/catalog.community.json +++ b/extensions/catalog.community.json @@ -3419,19 +3419,34 @@ "okf": { "name": "OKF Knowledge Bundle Generator", "id": "okf", - "description": "Generates and maintains an Open Knowledge Format (OKF v0.1) knowledge bundle from a source-code repository, mining git history for significance and rationale, and resolving open questions with the user.", + "description": "Generates and maintains an Open Knowledge Format (OKF v0.1) knowledge bundle from a source-code repository, mining git history for rationale and parking what it cannot verify as open questions.", "author": "Alex Punnen", - "version": "0.3.0", - "download_url": "https://github.com/alexcpn/speckit_ofk/archive/refs/tags/v0.3.0.zip", - "repository": "https://github.com/alexcpn/speckit_ofk", - "homepage": "https://github.com/alexcpn/speckit_ofk", - "documentation": "https://github.com/alexcpn/speckit_ofk/blob/main/README.md", - "changelog": "https://github.com/alexcpn/speckit_ofk/blob/main/CHANGELOG.md", + "version": "0.5.0", + "download_url": "https://github.com/alexcpn/speckit_okf/archive/refs/tags/v0.5.0.zip", + "repository": "https://github.com/alexcpn/speckit_okf", + "homepage": "https://github.com/alexcpn/speckit_okf", + "documentation": "https://github.com/alexcpn/speckit_okf/blob/main/README.md", + "changelog": "https://github.com/alexcpn/speckit_okf/blob/main/CHANGELOG.md", "license": "MIT", "category": "docs", "effect": "read-write", "requires": { - "speckit_version": ">=0.12.0" + "speckit_version": ">=0.12.0", + "tools": [ + { + "name": "bash", + "required": true + }, + { + "name": "python3", + "version": ">=3.9", + "required": true + }, + { + "name": "git", + "required": false + } + ] }, "provides": { "commands": 4, @@ -3448,7 +3463,7 @@ "downloads": 0, "stars": 0, "created_at": "2026-07-17T00:00:00Z", - "updated_at": "2026-07-21T00:00:00Z" + "updated_at": "2026-09-08T00:00:00Z" }, "onboard": { "name": "Onboard", From ec88f8ca703ccdb549c336996a84170688d8c5f7 Mon Sep 17 00:00:00 2001 From: Manfred Riem <15701806+mnriem@users.noreply.github.com> Date: Tue, 8 Sep 2026 16:04:10 -0500 Subject: [PATCH 21/44] chore: release 1.0.5, begin 1.0.6.dev0 development (#4479) * chore: bump version to 1.0.5 * chore: begin 1.0.6.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 bfa0e2ed0b..b657824be4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,31 @@ +## [1.0.5] - 2026-09-08 + +### Changed + +- Update OKF Knowledge Bundle Generator to v0.5.0 (#4475) +- Update BrownKit extension to v1.2.0 (#4474) +- chore(deps): bump the codeql-action group with 2 updates (#4436) +- fix(tasks): require field constraints from data-model.md in generated tasks (#4430) +- fix(workflows): reject falsy non-mapping step.yml in step add (#4321) +- fix(cline): stop unrelated prose from suppressing the hook command note (#4150) +- Add the August 2026 newsletter (#4442) +- Update Linear Integration extension to v0.8.0 (#4428) +- fix(presets): reject falsy non-mapping catalog config shapes (#4320) +- docs(workflows): sync the reference copy with the shipped workflow (#4424) +- fix(scripts): name setup-plan's feature directory key FEATURE_DIR (#4397) +- fix(workflows): require a 'steps' body on while and do-while loops (#4149) +- Add Evaluator Contract extension to community catalog (#4420) +- fix: remove unused scope input from bundled speckit workflow (#4401) +- feat(integrations): add Muse Code (muse) agent integration (#4413) +- feat(workflows): add workflow slots (#4352) +- fix(scripts): make bash branch-name sanitizing match the Python and PowerShell twins (#4286) +- allow hyphen in command ref token names (#4356) +- [extension] Add Axi Extension to community catalog (#4412) +- chore: release 1.0.4, begin 1.0.5.dev0 development (#4411) + ## [1.0.4] - 2026-09-02 ### Changed diff --git a/pyproject.toml b/pyproject.toml index e43ab9a1d8..06e8f5df56 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "specify-cli" -version = "1.0.5.dev0" +version = "1.0.6.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 5c4531268653f492d92c3665403bb8c114232cd0 Mon Sep 17 00:00:00 2001 From: Gyanu Mayank Date: Wed, 9 Sep 2026 03:08:01 +0530 Subject: [PATCH 22/44] docs: resolve assess clarifications by editing artifacts in place (#4402) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * docs: resolve assess clarifications by editing artifacts in place The assess README and decide command told people to re-run an earlier stage when decision.md said needs-clarification. That fights the sequential intake → research → define → shape → decide flow: each command normally runs once and leaves an editable Markdown file. Document direct edits and free-form agent updates as the default path, including refining decision.md itself, and keep stage re-runs as the exception for bad or discarded drafts. * Clarify needs-clarification as in-place artifact refinement. Route the assess diagram through a refine step back to decide, and list decision.md among the artifacts that path can update. * Narrow assess clarification docs to user-editable artifacts Keep speckit.assess.decide's existing output template. Clarifications are filled in the Markdown after the command finishes, not by changing what the command writes. --------- Co-authored-by: Gyanu --- extensions/assess/README.md | 24 +++++++++++++++++++++++- 1 file changed, 23 insertions(+), 1 deletion(-) diff --git a/extensions/assess/README.md b/extensions/assess/README.md index b6af9a2289..857bb42211 100644 --- a/extensions/assess/README.md +++ b/extensions/assess/README.md @@ -26,7 +26,8 @@ flowchart LR A[intake] --> R[research] --> D[define] --> S[shape] --> C{decide} C -->|go| SPEC[/speckit.specify/] C -->|kill| X[closed, recorded] - C -.->|needs-clarification: revisit the named earlier stage| A + C -.->|needs-clarification| F[refine named artifact in place] + F -.->|then revise decision.md| C ``` ## Commands @@ -45,6 +46,27 @@ Stages are meant to run in order but are not rigidly gated: - `shape` requires `problem.md`. - `decide` requires `problem.md`; a `go` verdict expects `concept.md` (otherwise it is downgraded to `needs-clarification`). +## Resolving clarifications + +The normal process is sequential and each command usually runs **once**: + +```text +intake → research → define → shape → decide +``` + +Each stage writes a Markdown artifact under `.specify/assessments//`. Those files stay editable. The commands keep their existing output templates: they do not rewrite an earlier artifact or perform that refinement themselves. `[NEEDS CLARIFICATION: …]` markers are gaps in the artifact, not a signal to regenerate the whole stage from scratch. + +Resolve them by refining the existing file: + +1. **Edit the Markdown directly** (fill in the missing metric, owner, constraint, and so on), or +2. **Ask the agent in free-form chat** to incorporate the missing information into that artifact. + +Then ask whether the new information clears the blocker and to update any downstream wording that depended on it. That includes `decision.md`: you can supply the missing facts and ask the agent to revise the scorecard, rationale, verdict, or handoff. That is artifact refinement, not command iteration. + +When you add evidence, keep the source and confidence tags the research stage already uses (`ASSUMPTION` vs cited claims). Do not invent citations. + +Rerunning an earlier `speckit.assess.*` command is the exception (for example after a wrong slug or a discarded draft), not the default path for answering clarification markers. + ## Slug Conventions A *slug* is the per-idea directory name under `.specify/assessments/`. It is the handle all five commands share. From 0c8e31ff0a98c362696c2edb6a1bb25a37f68544 Mon Sep 17 00:00:00 2001 From: Manfred Riem <15701806+mnriem@users.noreply.github.com> Date: Tue, 8 Sep 2026 17:00:12 -0500 Subject: [PATCH 23/44] Fix August newsletter review findings (#4444) Assisted-by: GitHub Copilot (model: GPT-5.6 Sol, autonomous) Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- newsletters/2026-August.md | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/newsletters/2026-August.md b/newsletters/2026-August.md index ba6ae17484..43df410224 100644 --- a/newsletters/2026-August.md +++ b/newsletters/2026-August.md @@ -30,7 +30,7 @@ The month's quiet structural change was the completion of the **Copilot skills-d ### Spec Kit Triages Itself: the `feature-assess` Workflow -August's most on-brand feature was the **`feature-assess` agentic workflow** (#4186), which **installs and runs Spec Kit to assess an incoming feature request end to end**. Rather than a human reading each new request cold, the workflow provisions `uv` and Python (#4193), stands up the Spec Kit CLI and the `assess` extension (#4195), and runs the July `assess` "Idea Assessment Pipeline" against the request — capture → evidence → refine → design → decision — before a maintainer touches it. The initial landing was quickly followed by the provisioning and daily-credit-budget fixes needed to make it run reliably in CI (#4193, #4195, #4222). It is the clearest instance yet of the project **dogfooding its own primitives**: the tool that helps teams decide "should we build this?" is now wired into Spec Kit's own triage, running the `assess` pipeline on a labeled feature-request issue and posting each stage back. [\[github.com\]](https://github.com/github/spec-kit/releases) +August's most on-brand feature was the **`feature-assess` agentic workflow** (#4186), which **installs and runs Spec Kit to assess an incoming feature request end to end**. Rather than a human reading each new request cold, the workflow provisions `uv` and Python (#4193), stands up the Spec Kit CLI and the `assess` extension (#4195), and runs the July `assess` "Idea Assessment Pipeline" against the request — intake → research → define → shape → decide — before a maintainer touches it. The initial landing was quickly followed by the provisioning and daily-credit-budget fixes needed to make it run reliably in CI (#4193, #4195, #4222). It is the clearest instance yet of the project **dogfooding its own primitives**: the tool that helps teams decide "should we build this?" is now wired into Spec Kit's own triage, running the `assess` pipeline on a labeled feature-request issue and posting each stage back. [\[github.com\]](https://github.com/github/spec-kit/releases) ### The Composition Layer Matures @@ -52,7 +52,7 @@ The community extension catalog grew from 144 to **162 entries** during August The month's most telling signal was **July's companion tooling entering the official catalog**. **SpecJudge** — the CLI that reads Spec Kit's constitution/spec/tasks artifacts to recommend a right-sized model, profiled in July's newsletter — was cataloged as an extension (#4079). And **SpecAssay** arrived as a **multi-artifact suite**: an extension (SpecAssay Check, #4113), a preset (#4123), and a **bundle** (#4125) — the second cataloged community bundle. The third-party layer that formed *around* Spec Kit in July is consolidating *into* the ecosystem. -Notable new extensions by category: +Notable catalog additions and updates by category: - **Verification, review & governance**: Architecture Governance, SpecAssay Check, Taco Review (human review packaging), adrkit (ADR authoring) - **Requirements & intake**: SpecKit Grill Me (a more thorough clarification skill), Pre-Spec Cards, Charter (updated) @@ -76,7 +76,7 @@ August's coverage centered on the 1.0.0 milestone, with the mix continuing July' **Roan Brasil Monteiro** (Medium, August 20) published a 21-minute *field guide to BMAD, Spec Kit, OpenSpec, and Kiro*, framed around "too much process burns money, too little burns more," positioning Spec Kit as the thorough, heavier option among four SDD frameworks. [\[medium.com\]](https://medium.com/@roanmonteiro/bmad-spec-kit-openspec-kiro-a-field-guide-to-getting-real-work-out-of-ai-coding-agents-b17833c24b3f) -**百度百家号** (AI钉子铺, August 23) ran a half-year growth-rate review of five SDD frameworks that credits Spec Kit's GitHub/Microsoft brand pull and 14+ agent support but amplifies Martin Fowler's "8+ markdown files per spec" critique, arguing its growth is partly "brand premium." **Andrew** (DEV Community, August 28), reviewing OpenSpec, engaged Spec Kit substantively as the main comparison — citing 131,957 stars, phase gates, the Python requirement, and the larger extension catalog. [\[baijiahao.baidu.com\]](https://baijiahao.baidu.com/s?id=1874316315966110702) +**百度百家号** (AI钉子铺, August 23) ran a half-year growth-rate review of five SDD frameworks that credits Spec Kit's GitHub/Microsoft brand pull and 14+ agent support but amplifies Martin Fowler's "8+ markdown files per spec" critique, arguing its growth is partly "brand premium." [\[baijiahao.baidu.com\]](https://baijiahao.baidu.com/s?id=1874316315966110702) **Andrew** (DEV Community, August 28), reviewing OpenSpec, engaged Spec Kit substantively as the main comparison — citing 131,957 stars, phase gates, the Python requirement, and the larger extension catalog. [\[dev.to\]](https://dev.to/andrew-ooo/openspec-review-spec-driven-dev-without-the-ceremony-8jb) ### Developer Articles and Field Reports From 8ef20f176b7120b1bb3f90fb81d1673e18099c1f Mon Sep 17 00:00:00 2001 From: Ali jawwad <33836051+jawwad-ali@users.noreply.github.com> Date: Wed, 9 Sep 2026 17:22:45 +0500 Subject: [PATCH 24/44] fix(bundler): re-read the step registry when rolling back a failed step refresh (#4139) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix(bundler): re-read the step registry when rolling back a failed refresh `_StepKindManager.refresh` documents that it keeps a backup and restores it "if the remove+reinstall path fails". The package half of that rollback works; the registry half was unreachable. `StepRegistry.__init__` snapshots the file once (`self.data = self._load()`) and `is_installed` consults only that snapshot. Measured: snapshot at construction: is_installed('my-step') = True after the entry is deleted on disk: same object = True <-- stale a fresh StepRegistry: = False By rollback time `self.remove()` has already deleted the entry from disk, but `self._registry`'s snapshot still contains it — so `not self._registry.is_installed(...)` was always False and the restore never ran, in exactly the failure case it was written for. The user was left with the step package back on disk but unregistered: `workflow step list` no longer shows it, the engine cannot resolve it, and a later `workflow step add ` refuses with "Step directory already exists". Read the registry fresh at rollback time. Co-Authored-By: Claude Opus 5 (1M context) * fix(bundler): restore the step registry entry verbatim on refresh rollback Addresses review feedback: the rollback used `StepRegistry.add()`, which does not restore the saved metadata verbatim. The rollback deliberately constructs a *fresh* `StepRegistry` after `self.remove()` has deleted the entry from disk (that re-read is this PR's actual fix). `add()` therefore finds no existing record: metadata_to_store["installed_at"] = existing.get( "installed_at", datetime.now(timezone.utc).isoformat() ) metadata_to_store["updated_at"] = datetime.now(timezone.utc).isoformat() `existing` is `{}`, so `installed_at` falls through to `now`, and `updated_at` is overwritten unconditionally. A failed refresh still mutated the installation metadata instead of rolling it back: seeded : installed_at 2020-01-01..., updated_at 2020-02-02... after add() : installed_at 2026-08-27..., updated_at 2026-08-27... verbatim via add() -> False verbatim via direct restore -> True Restore the entry directly and save, matching the existing rollback in `workflow_step_remove`, whose comment names this very hazard: "Restore the original registry entry verbatim (bypass add() which would overwrite timestamps)." The regression test now seeds distinctive past timestamps and asserts the restored entry equals the seeded one, rather than only asserting presence. Co-Authored-By: Claude Opus 5 (1M context) --------- Co-authored-by: Claude Opus 5 (1M context) --- .../bundler/services/primitives.py | 26 ++++++- tests/unit/test_bundler_primitives.py | 72 +++++++++++++++++++ 2 files changed, 96 insertions(+), 2 deletions(-) diff --git a/src/specify_cli/bundler/services/primitives.py b/src/specify_cli/bundler/services/primitives.py index 01fa14769e..68a9c47632 100644 --- a/src/specify_cli/bundler/services/primitives.py +++ b/src/specify_cli/bundler/services/primitives.py @@ -430,8 +430,30 @@ def refresh(self, component: ComponentRef) -> None: except BundlerError: if backup_dir.exists(): shutil.copytree(backup_dir, step_dir, dirs_exist_ok=True) - if metadata is not None and not self._registry.is_installed(component.id): - self._registry.add(component.id, metadata) + # Re-read the registry: ``StepRegistry`` snapshots the file once + # in ``__init__`` (``self.data = self._load()``) and + # ``is_installed`` only consults that snapshot. ``self.remove()`` + # above has already deleted the entry from disk, but + # ``self._registry``'s snapshot still contains it -- so the + # guard was always False here and the restore never ran, in + # exactly the failure case it was written for. The step package + # came back but stayed unregistered: ``workflow step list`` + # stopped showing it and ``workflow step add`` then refused with + # "Step directory already exists". + from ...workflows.catalog import StepRegistry + + current = StepRegistry(self._root) + if metadata is not None and not current.is_installed(component.id): + # Restore the saved entry verbatim rather than via ``add()``, + # which would rewrite the metadata it is meant to roll back: + # this registry is freshly constructed *after* + # ``self.remove()`` deleted the entry, so ``add()`` sees no + # existing record and stamps ``installed_at`` with + # ``datetime.now()`` (it also overwrites ``updated_at`` + # unconditionally). ``workflow_step_remove`` bypasses + # ``add()`` for exactly this reason. + current.data["steps"][component.id] = metadata + current.save() raise finally: shutil.rmtree(backup_dir.parent, ignore_errors=True) diff --git a/tests/unit/test_bundler_primitives.py b/tests/unit/test_bundler_primitives.py index bbbac1133b..a0fcb3c635 100644 --- a/tests/unit/test_bundler_primitives.py +++ b/tests/unit/test_bundler_primitives.py @@ -433,3 +433,75 @@ def _plan(manifest): effective_integration=None, components=components, ) + + +def test_step_refresh_restores_registry_entry_when_reinstall_fails( + tmp_path: Path, monkeypatch +): + """A failed step refresh must leave the registry entry restored. + + ``refresh`` keeps a backup and restores it "if the remove+reinstall path + fails", but the registry half of that rollback was unreachable: + ``StepRegistry`` snapshots the file once in ``__init__`` and + ``is_installed`` reads only that snapshot, so after ``self.remove()`` + deleted the entry from disk the stale snapshot still reported it as + installed and ``not ...is_installed(...)`` was always False. + + The step package came back but stayed unregistered — ``workflow step + list`` stopped showing it, and ``workflow step add`` then refused with + "Step directory already exists". + """ + import json + + import specify_cli + from specify_cli.workflows.catalog import StepRegistry + + steps_dir = tmp_path / ".specify" / "workflows" / "steps" + (steps_dir / "my-step").mkdir(parents=True) + (steps_dir / "my-step" / "step.yml").write_text( + "step:\n type_key: my-step\n", encoding="utf-8" + ) + (steps_dir / "my-step" / "__init__.py").write_text("", encoding="utf-8") + (steps_dir / StepRegistry.REGISTRY_FILE).write_text( + json.dumps( + { + "schema_version": "1.0", + "steps": { + "my-step": { + "name": "My Step", + "version": "1.0.0", + "type_key": "my-step", + # Distinctive past timestamps: a rollback must put the + # entry back verbatim, and ``StepRegistry.add()`` would + # silently replace both of these with ``now``. + "installed_at": "2020-01-01T00:00:00+00:00", + "updated_at": "2020-02-02T00:00:00+00:00", + } + }, + } + ), + encoding="utf-8", + ) + + seeded = StepRegistry(tmp_path).get("my-step") + assert StepRegistry(tmp_path).is_installed("my-step") + + # Removal succeeds (real code path); only the re-install fails, which is + # what a catalog 404 / size-limit / type_key mismatch produces. + def _boom(step_id, *args, **kwargs): + raise BundlerError(f"Failed to install step '{step_id}'.") + + monkeypatch.setattr(specify_cli, "workflow_step_add", _boom) + + manager = primitive_manager("steps", tmp_path, allow_network=True) + with pytest.raises(BundlerError): + manager.refresh(_component("steps", "my-step")) + + # Read the registry fresh from disk — the point of the fix. + restored = StepRegistry(tmp_path) + assert restored.is_installed("my-step"), ( + steps_dir / StepRegistry.REGISTRY_FILE + ).read_text(encoding="utf-8") + # A rollback must be a rollback: the entry comes back byte-for-byte, not + # re-registered with fresh ``installed_at`` / ``updated_at`` stamps. + assert restored.get("my-step") == seeded From 00a47de4e2b71251881811b59152c04f1f568f47 Mon Sep 17 00:00:00 2001 From: "Jialong(Bruce) Li" Date: Wed, 9 Sep 2026 22:05:48 +0800 Subject: [PATCH 25/44] docs(templates): clarify /constitution's Sync Impact Report is temporary, review-only material (#4431) (#4432) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix(templates): stop /constitution from stacking Sync Impact Reports (#4431) Step 4 of the constitution command only said to prepend the Sync Impact Report as an HTML comment, with no instruction to remove a previous one. Each run therefore added another report block on top of the last, growing the raw file (and the token cost of reading it) without bound. Now the step explicitly requires removing any existing report comment before adding the new one. Assisted-by: Claude (model: claude-sonnet-5, autonomous) * docs(templates): state the Sync Impact Report's temporary lifecycle Per review on #4432/#4431: the growth this fixed isn't a functional bug in the intended workflow — the report is scratch material for human review and is expected to be removed before the constitution file is committed. Step 4 now says so explicitly, alongside the existing replace-not-stack instruction for runs where a prior report was left in place. * docs(templates): drop the Sync Impact Report de-dupe instruction Per review, Step 4 should stay documentation-only: the report is temporary, review-only material removed before commit, so /constitution shouldn't auto-strip a pre-existing HTML comment (which could delete unrelated leading content). Retarget the regression test to pin the documented lifecycle instead of the removed de-dupe behavior. --- templates/commands/constitution.md | 4 ++- .../test_constitution_template_sync_report.py | 25 +++++++++++++++++++ 2 files changed, 28 insertions(+), 1 deletion(-) create mode 100644 tests/test_constitution_template_sync_report.py diff --git a/templates/commands/constitution.md b/templates/commands/constitution.md index 7b2f3684fb..0eff198043 100644 --- a/templates/commands/constitution.md +++ b/templates/commands/constitution.md @@ -109,7 +109,9 @@ Follow this execution flow: - Ensure each Principle section: succinct name line, paragraph (or bullet list) capturing non‑negotiable rules, explicit rationale if not obvious. - Ensure Governance section lists amendment procedure, versioning policy, and compliance review expectations. -4. Produce a Sync Impact Report (prepend as an HTML comment at top of the constitution file after update): +4. Produce a Sync Impact Report as an HTML comment at the top of the constitution file after update. + This report is temporary scratch material for human review of the amendment, not governance + content; it is expected to be removed before the amended constitution file is committed. - Version change: old → new - List of modified principles (old title → new title if renamed) - Added sections diff --git a/tests/test_constitution_template_sync_report.py b/tests/test_constitution_template_sync_report.py new file mode 100644 index 0000000000..6900eb1c07 --- /dev/null +++ b/tests/test_constitution_template_sync_report.py @@ -0,0 +1,25 @@ +"""Covers #4431: /constitution's Sync Impact Report must be documented as +temporary, review-only material rather than committed governance content. + +The Outline step that produces the Sync Impact Report must state that it is +scratch material for human review and is expected to be removed before the +amended constitution file is committed. +""" + +from pathlib import Path + +REPO_ROOT = Path(__file__).parent.parent +CONSTITUTION_TEMPLATE = REPO_ROOT / "templates" / "commands" / "constitution.md" + + +def test_sync_impact_report_step_documents_temporary_lifecycle(): + content = CONSTITUTION_TEMPLATE.read_text(encoding="utf-8") + step = content.split("Produce a Sync Impact Report", 1)[1].split("\n\n", 1)[0] + assert "temporary" in step.lower(), ( + "Step 4 must document that the Sync Impact Report is temporary, " + "review-only material, not governance content" + ) + assert "removed before" in step.lower() and "committed" in step.lower(), ( + "Step 4 must state the report is expected to be removed before the " + "amended constitution file is committed" + ) From 4dd2402ea6d644ee655b78213a1f6d679fc88b0b Mon Sep 17 00:00:00 2001 From: Ken Schlobohm Date: Wed, 9 Sep 2026 09:14:28 -0500 Subject: [PATCH 26/44] Add concise code review skill (#4471) * Add concise code review skill Add repository-specific guidance for positive and negative tests, regression evidence, and terminology alignment while exempting community-authored catalog content. Assisted-by: GitHub Copilot (model: gpt-5.6-luna, autonomous) Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: 60c0e36f-daf2-4c62-87ee-4da8fb1eaf12 * Clarify community content exception Exclude community-authored catalog content and generated documentation from terminology alignment reviews. Assisted-by: GitHub Copilot (model: gpt-5.6-luna, autonomous) Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: 60c0e36f-daf2-4c62-87ee-4da8fb1eaf12 * Clarify test coverage principle Require test cases to verify intended behavior and what each code change should prevent. Assisted-by: GitHub Copilot (model: gpt-5.6-luna, autonomous) Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: 60c0e36f-daf2-4c62-87ee-4da8fb1eaf12 --------- Copilot-Session: 60c0e36f-daf2-4c62-87ee-4da8fb1eaf12 --- .github/skills/code-review/SKILL.md | 11 +++++++++++ 1 file changed, 11 insertions(+) create mode 100644 .github/skills/code-review/SKILL.md diff --git a/.github/skills/code-review/SKILL.md b/.github/skills/code-review/SKILL.md new file mode 100644 index 0000000000..8a038c1f3b --- /dev/null +++ b/.github/skills/code-review/SKILL.md @@ -0,0 +1,11 @@ +--- +name: code-review +description: Reviews Spec Kit code changes for positive and negative test coverage, regression evidence for bug fixes, and consistent repository terminology. Use when reviewing a diff or pull request. Do not use for implementing changes or posting GitHub review actions. +argument-hint: 'Diff or pull request to review' +--- + +# Code Review + +1. Ensure each code change has test cases that verify what the code should do and what it should prevent. +2. Ensure bug-fix pull requests include a regression test that demonstrates the bug was reproducible before the change and is fixed afterward; if the reviewer cannot run the comparison, use available evidence and state that limitation. +3. Ensure wording changes use repository-consistent terms; exclude community-authored catalog content and its generated documentation from this check. From b60057692cd726ea56331ec47f9ebc3e8877d8f7 Mon Sep 17 00:00:00 2001 From: Avp9 Date: Wed, 9 Sep 2026 17:42:37 +0200 Subject: [PATCH 27/44] fix(templates): report an unreadable extensions.yml instead of skipping hooks silently (#4456) * fix(templates): report an unreadable extensions.yml instead of skipping hooks silently All ten core command templates told the agent: "If the YAML cannot be parsed or is invalid, skip hook checking silently and continue normally". A `.specify/extensions.yml` with one malformed line was therefore treated exactly like a manifest with no hooks, and a mandatory hook (`optional: false`, as registered by the bundled git extension) was disabled without anything saying so. The agent now tells the user that the manifest could not be read (with the parser error) and that no hooks were checked, then continues as before. Control flow is unchanged; only the silence is removed. Adds tests/test_command_template_hooks.py, which pins the wording at both hook sites of every template that reads extensions.yml. * test(templates): pin every clause of the parse-failure instruction The regression test only pinned two of the five clauses the new instruction carries. Dropping the parser error, the mandatory-hook warning or the continuation clause from every template would have left it green. It now pins all five: the manifest could not be read, the parser error is included, no hooks were checked, mandatory (optional: false) hooks are named, and the command then continues. Checked by mutation: removing any one clause from all ten templates fails the ten parametrized cases. --- templates/commands/analyze.md | 4 +- templates/commands/checklist.md | 4 +- templates/commands/clarify.md | 4 +- templates/commands/constitution.md | 4 +- templates/commands/converge.md | 4 +- templates/commands/implement.md | 4 +- templates/commands/plan.md | 4 +- templates/commands/specify.md | 4 +- templates/commands/tasks.md | 4 +- templates/commands/taskstoissues.md | 4 +- tests/test_command_template_hooks.py | 80 ++++++++++++++++++++++++++++ 11 files changed, 100 insertions(+), 20 deletions(-) create mode 100644 tests/test_command_template_hooks.py diff --git a/templates/commands/analyze.md b/templates/commands/analyze.md index 2e13af58ae..7de2d28f0d 100644 --- a/templates/commands/analyze.md +++ b/templates/commands/analyze.md @@ -19,7 +19,7 @@ You **MUST** consider the user input before proceeding (if not empty). **Check for extension hooks (before analysis)**: - Check if `.specify/extensions.yml` exists in the project root. - If it exists, read it and look for entries under the `hooks.before_analyze` key -- If the YAML cannot be parsed or is invalid, skip hook checking silently and continue normally +- If the YAML cannot be parsed or is invalid, do not skip silently: tell the user that `.specify/extensions.yml` could not be read (include the parser error) and that no hooks were checked, including any mandatory (`optional: false`) hooks registered there, then continue normally - Filter out hooks where `enabled` is explicitly `false`. Treat hooks without an `enabled` field as enabled by default. - For each remaining hook, do **not** attempt to interpret or evaluate hook `condition` expressions: - If the hook has no `condition` field, or it is null/empty, treat the hook as executable @@ -205,7 +205,7 @@ Ask the user: "Would you like me to suggest concrete remediation edits for the t After reporting, check if `.specify/extensions.yml` exists in the project root. - If it exists, read it and look for entries under the `hooks.after_analyze` key -- If the YAML cannot be parsed or is invalid, skip hook checking silently and continue normally +- If the YAML cannot be parsed or is invalid, do not skip silently: tell the user that `.specify/extensions.yml` could not be read (include the parser error) and that no hooks were checked, including any mandatory (`optional: false`) hooks registered there, then continue normally - Filter out hooks where `enabled` is explicitly `false`. Treat hooks without an `enabled` field as enabled by default. - For each remaining hook, do **not** attempt to interpret or evaluate hook `condition` expressions: - If the hook has no `condition` field, or it is null/empty, treat the hook as executable diff --git a/templates/commands/checklist.md b/templates/commands/checklist.md index 5ee239d309..6e33f5d422 100644 --- a/templates/commands/checklist.md +++ b/templates/commands/checklist.md @@ -49,7 +49,7 @@ You **MUST** consider the user input before proceeding (if not empty). **Check for extension hooks (before checklist generation)**: - Check if `.specify/extensions.yml` exists in the project root. - If it exists, read it and look for entries under the `hooks.before_checklist` key -- If the YAML cannot be parsed or is invalid, skip hook checking silently and continue normally +- If the YAML cannot be parsed or is invalid, do not skip silently: tell the user that `.specify/extensions.yml` could not be read (include the parser error) and that no hooks were checked, including any mandatory (`optional: false`) hooks registered there, then continue normally - Filter out hooks where `enabled` is explicitly `false`. Treat hooks without an `enabled` field as enabled by default. - For each remaining hook, do **not** attempt to interpret or evaluate hook `condition` expressions: - If the hook has no `condition` field, or it is null/empty, treat the hook as executable @@ -350,7 +350,7 @@ Sample items: **Check for extension hooks (after checklist generation)**: Check if `.specify/extensions.yml` exists in the project root. - If it exists, read it and look for entries under the `hooks.after_checklist` key -- If the YAML cannot be parsed or is invalid, skip hook checking silently and continue normally +- If the YAML cannot be parsed or is invalid, do not skip silently: tell the user that `.specify/extensions.yml` could not be read (include the parser error) and that no hooks were checked, including any mandatory (`optional: false`) hooks registered there, then continue normally - Filter out hooks where `enabled` is explicitly `false`. Treat hooks without an `enabled` field as enabled by default. - For each remaining hook, do **not** attempt to interpret or evaluate hook `condition` expressions: - If the hook has no `condition` field, or it is null/empty, treat the hook as executable diff --git a/templates/commands/clarify.md b/templates/commands/clarify.md index ea2f20d519..8663b6908b 100644 --- a/templates/commands/clarify.md +++ b/templates/commands/clarify.md @@ -23,7 +23,7 @@ You **MUST** consider the user input before proceeding (if not empty). **Check for extension hooks (before clarification)**: - Check if `.specify/extensions.yml` exists in the project root. - If it exists, read it and look for entries under the `hooks.before_clarify` key -- If the YAML cannot be parsed or is invalid, skip hook checking silently and continue normally +- If the YAML cannot be parsed or is invalid, do not skip silently: tell the user that `.specify/extensions.yml` could not be read (include the parser error) and that no hooks were checked, including any mandatory (`optional: false`) hooks registered there, then continue normally - Filter out hooks where `enabled` is explicitly `false`. Treat hooks without an `enabled` field as enabled by default. - For each remaining hook, do **not** attempt to interpret or evaluate hook `condition` expressions: - If the hook has no `condition` field, or it is null/empty, treat the hook as executable @@ -245,7 +245,7 @@ Context for prioritization: {ARGS} Check if `.specify/extensions.yml` exists in the project root. - If it does not exist, or no hooks are registered under `hooks.after_clarify`, skip to the Completion Report. - If it exists, read it and look for entries under the `hooks.after_clarify` key. -- If the YAML cannot be parsed or is invalid, skip hook checking silently and continue to the Completion Report. +- If the YAML cannot be parsed or is invalid, do not skip silently: tell the user that `.specify/extensions.yml` could not be read (include the parser error) and that no hooks were checked, including any mandatory (`optional: false`) hooks registered there, then continue to the Completion Report. - Filter out hooks where `enabled` is explicitly `false`. Treat hooks without an `enabled` field as enabled by default. - For each remaining hook, do **not** attempt to interpret or evaluate hook `condition` expressions: - If the hook has no `condition` field, or it is null/empty, treat the hook as executable diff --git a/templates/commands/constitution.md b/templates/commands/constitution.md index 0eff198043..f2c107a158 100644 --- a/templates/commands/constitution.md +++ b/templates/commands/constitution.md @@ -42,7 +42,7 @@ and commands read the constitution at runtime and are not modified here. **Check for extension hooks (before constitution update)**: - Check if `.specify/extensions.yml` exists in the project root. - If it exists, read it and look for entries under the `hooks.before_constitution` key -- If the YAML cannot be parsed or is invalid, skip hook checking silently and continue normally +- If the YAML cannot be parsed or is invalid, do not skip silently: tell the user that `.specify/extensions.yml` could not be read (include the parser error) and that no hooks were checked, including any mandatory (`optional: false`) hooks registered there, then continue normally - Filter out hooks where `enabled` is explicitly `false`. Treat hooks without an `enabled` field as enabled by default. - For each remaining hook, do **not** attempt to interpret or evaluate hook `condition` expressions: - If the hook has no `condition` field, or it is null/empty, treat the hook as executable @@ -150,7 +150,7 @@ Write only `.specify/memory/constitution.md`; do not create or modify template s **Check for extension hooks (after constitution update)**: Check if `.specify/extensions.yml` exists in the project root. - If it exists, read it and look for entries under the `hooks.after_constitution` key -- If the YAML cannot be parsed or is invalid, skip hook checking silently and continue normally +- If the YAML cannot be parsed or is invalid, do not skip silently: tell the user that `.specify/extensions.yml` could not be read (include the parser error) and that no hooks were checked, including any mandatory (`optional: false`) hooks registered there, then continue normally - Filter out hooks where `enabled` is explicitly `false`. Treat hooks without an `enabled` field as enabled by default. - For each remaining hook, do **not** attempt to interpret or evaluate hook `condition` expressions: - If the hook has no `condition` field, or it is null/empty, treat the hook as executable diff --git a/templates/commands/converge.md b/templates/commands/converge.md index a177c31371..5d29b74db6 100644 --- a/templates/commands/converge.md +++ b/templates/commands/converge.md @@ -20,7 +20,7 @@ You **MUST** consider the user input before proceeding (if not empty). - Check if `.specify/extensions.yml` exists in the project root. - If it exists, read it and look for entries under the `hooks.before_converge` key -- If the YAML cannot be parsed or is invalid, skip hook checking silently and continue normally +- If the YAML cannot be parsed or is invalid, do not skip silently: tell the user that `.specify/extensions.yml` could not be read (include the parser error) and that no hooks were checked, including any mandatory (`optional: false`) hooks registered there, then continue normally - Filter out hooks where `enabled` is explicitly `false`. Treat hooks without an `enabled` field as enabled by default. - For each remaining hook, do **not** attempt to interpret or evaluate hook `condition` expressions: - If the hook has no `condition` field, or it is null/empty, treat the hook as executable @@ -238,7 +238,7 @@ Append to the **end** of `tasks.md`, per the append contract: After producing the result, check if `.specify/extensions.yml` exists in the project root. - If it exists, read it and look for entries under the `hooks.after_converge` key -- If the YAML cannot be parsed or is invalid, skip hook checking silently and continue normally +- If the YAML cannot be parsed or is invalid, do not skip silently: tell the user that `.specify/extensions.yml` could not be read (include the parser error) and that no hooks were checked, including any mandatory (`optional: false`) hooks registered there, then continue normally - Filter out hooks where `enabled` is explicitly `false`. Treat hooks without an `enabled` field as enabled by default. - For each remaining hook, do **not** attempt to interpret or evaluate hook `condition` expressions: - If the hook has no `condition` field, or it is null/empty, treat the hook as executable diff --git a/templates/commands/implement.md b/templates/commands/implement.md index 742c45e185..f98ba525de 100644 --- a/templates/commands/implement.md +++ b/templates/commands/implement.md @@ -19,7 +19,7 @@ You **MUST** consider the user input before proceeding (if not empty). **Check for extension hooks (before implementation)**: - Check if `.specify/extensions.yml` exists in the project root. - If it exists, read it and look for entries under the `hooks.before_implement` key -- If the YAML cannot be parsed or is invalid, skip hook checking silently and continue normally +- If the YAML cannot be parsed or is invalid, do not skip silently: tell the user that `.specify/extensions.yml` could not be read (include the parser error) and that no hooks were checked, including any mandatory (`optional: false`) hooks registered there, then continue normally - Filter out hooks where `enabled` is explicitly `false`. Treat hooks without an `enabled` field as enabled by default. - For each remaining hook, do **not** attempt to interpret or evaluate hook `condition` expressions: - If the hook has no `condition` field, or it is null/empty, treat the hook as executable @@ -183,7 +183,7 @@ Note: This command assumes a complete task breakdown exists in tasks.md. If task Check if `.specify/extensions.yml` exists in the project root. - If it does not exist, or no hooks are registered under `hooks.after_implement`, skip to the Completion Report. - If it exists, read it and look for entries under the `hooks.after_implement` key. -- If the YAML cannot be parsed or is invalid, skip hook checking silently and continue to the Completion Report. +- If the YAML cannot be parsed or is invalid, do not skip silently: tell the user that `.specify/extensions.yml` could not be read (include the parser error) and that no hooks were checked, including any mandatory (`optional: false`) hooks registered there, then continue to the Completion Report. - Filter out hooks where `enabled` is explicitly `false`. Treat hooks without an `enabled` field as enabled by default. - For each remaining hook, do **not** attempt to interpret or evaluate hook `condition` expressions: - If the hook has no `condition` field, or it is null/empty, treat the hook as executable diff --git a/templates/commands/plan.md b/templates/commands/plan.md index 836e25070c..d559e517ae 100644 --- a/templates/commands/plan.md +++ b/templates/commands/plan.md @@ -27,7 +27,7 @@ You **MUST** consider the user input before proceeding (if not empty). **Check for extension hooks (before planning)**: - Check if `.specify/extensions.yml` exists in the project root. - If it exists, read it and look for entries under the `hooks.before_plan` key -- If the YAML cannot be parsed or is invalid, skip hook checking silently and continue normally +- If the YAML cannot be parsed or is invalid, do not skip silently: tell the user that `.specify/extensions.yml` could not be read (include the parser error) and that no hooks were checked, including any mandatory (`optional: false`) hooks registered there, then continue normally - Filter out hooks where `enabled` is explicitly `false`. Treat hooks without an `enabled` field as enabled by default. - For each remaining hook, do **not** attempt to interpret or evaluate hook `condition` expressions: - If the hook has no `condition` field, or it is null/empty, treat the hook as executable @@ -78,7 +78,7 @@ You **MUST** consider the user input before proceeding (if not empty). Check if `.specify/extensions.yml` exists in the project root. - If it does not exist, or no hooks are registered under `hooks.after_plan`, skip to the Completion Report. - If it exists, read it and look for entries under the `hooks.after_plan` key. -- If the YAML cannot be parsed or is invalid, skip hook checking silently and continue to the Completion Report. +- If the YAML cannot be parsed or is invalid, do not skip silently: tell the user that `.specify/extensions.yml` could not be read (include the parser error) and that no hooks were checked, including any mandatory (`optional: false`) hooks registered there, then continue to the Completion Report. - Filter out hooks where `enabled` is explicitly `false`. Treat hooks without an `enabled` field as enabled by default. - For each remaining hook, do **not** attempt to interpret or evaluate hook `condition` expressions: - If the hook has no `condition` field, or it is null/empty, treat the hook as executable diff --git a/templates/commands/specify.md b/templates/commands/specify.md index 54151e8b42..52b2b24565 100644 --- a/templates/commands/specify.md +++ b/templates/commands/specify.md @@ -23,7 +23,7 @@ You **MUST** consider the user input before proceeding (if not empty). **Check for extension hooks (before specification)**: - Check if `.specify/extensions.yml` exists in the project root. - If it exists, read it and look for entries under the `hooks.before_specify` key -- If the YAML cannot be parsed or is invalid, skip hook checking silently and continue normally +- If the YAML cannot be parsed or is invalid, do not skip silently: tell the user that `.specify/extensions.yml` could not be read (include the parser error) and that no hooks were checked, including any mandatory (`optional: false`) hooks registered there, then continue normally - Filter out hooks where `enabled` is explicitly `false`. Treat hooks without an `enabled` field as enabled by default. - For each remaining hook, do **not** attempt to interpret or evaluate hook `condition` expressions: - If the hook has no `condition` field, or it is null/empty, treat the hook as executable @@ -240,7 +240,7 @@ Given that feature description, do this: Check if `.specify/extensions.yml` exists in the project root. - If it does not exist, or no hooks are registered under `hooks.after_specify`, skip to the Completion Report. - If it exists, read it and look for entries under the `hooks.after_specify` key. -- If the YAML cannot be parsed or is invalid, skip hook checking silently and continue to the Completion Report. +- If the YAML cannot be parsed or is invalid, do not skip silently: tell the user that `.specify/extensions.yml` could not be read (include the parser error) and that no hooks were checked, including any mandatory (`optional: false`) hooks registered there, then continue to the Completion Report. - Filter out hooks where `enabled` is explicitly `false`. Treat hooks without an `enabled` field as enabled by default. - For each remaining hook, do **not** attempt to interpret or evaluate hook `condition` expressions: - If the hook has no `condition` field, or it is null/empty, treat the hook as executable diff --git a/templates/commands/tasks.md b/templates/commands/tasks.md index 99e276fa7a..1b9c4255fe 100644 --- a/templates/commands/tasks.md +++ b/templates/commands/tasks.md @@ -28,7 +28,7 @@ You **MUST** consider the user input before proceeding (if not empty). **Check for extension hooks (before tasks generation)**: - Check if `.specify/extensions.yml` exists in the project root. - If it exists, read it and look for entries under the `hooks.before_tasks` key -- If the YAML cannot be parsed or is invalid, skip hook checking silently and continue normally +- If the YAML cannot be parsed or is invalid, do not skip silently: tell the user that `.specify/extensions.yml` could not be read (include the parser error) and that no hooks were checked, including any mandatory (`optional: false`) hooks registered there, then continue normally - Filter out hooks where `enabled` is explicitly `false`. Treat hooks without an `enabled` field as enabled by default. - For each remaining hook, do **not** attempt to interpret or evaluate hook `condition` expressions: - If the hook has no `condition` field, or it is null/empty, treat the hook as executable @@ -99,7 +99,7 @@ You **MUST** consider the user input before proceeding (if not empty). Check if `.specify/extensions.yml` exists in the project root. - If it does not exist, or no hooks are registered under `hooks.after_tasks`, skip to the Completion Report. - If it exists, read it and look for entries under the `hooks.after_tasks` key. -- If the YAML cannot be parsed or is invalid, skip hook checking silently and continue to the Completion Report. +- If the YAML cannot be parsed or is invalid, do not skip silently: tell the user that `.specify/extensions.yml` could not be read (include the parser error) and that no hooks were checked, including any mandatory (`optional: false`) hooks registered there, then continue to the Completion Report. - Filter out hooks where `enabled` is explicitly `false`. Treat hooks without an `enabled` field as enabled by default. - For each remaining hook, do **not** attempt to interpret or evaluate hook `condition` expressions: - If the hook has no `condition` field, or it is null/empty, treat the hook as executable diff --git a/templates/commands/taskstoissues.md b/templates/commands/taskstoissues.md index 36c12316e5..f982448906 100644 --- a/templates/commands/taskstoissues.md +++ b/templates/commands/taskstoissues.md @@ -20,7 +20,7 @@ You **MUST** consider the user input before proceeding (if not empty). **Check for extension hooks (before tasks-to-issues conversion)**: - Check if `.specify/extensions.yml` exists in the project root. - If it exists, read it and look for entries under the `hooks.before_taskstoissues` key -- If the YAML cannot be parsed or is invalid, skip hook checking silently and continue normally +- If the YAML cannot be parsed or is invalid, do not skip silently: tell the user that `.specify/extensions.yml` could not be read (include the parser error) and that no hooks were checked, including any mandatory (`optional: false`) hooks registered there, then continue normally - Filter out hooks where `enabled` is explicitly `false`. Treat hooks without an `enabled` field as enabled by default. - For each remaining hook, do **not** attempt to interpret or evaluate hook `condition` expressions: - If the hook has no `condition` field, or it is null/empty, treat the hook as executable @@ -77,7 +77,7 @@ git config --get remote.origin.url **Check for extension hooks (after tasks-to-issues conversion)**: Check if `.specify/extensions.yml` exists in the project root. - If it exists, read it and look for entries under the `hooks.after_taskstoissues` key -- If the YAML cannot be parsed or is invalid, skip hook checking silently and continue normally +- If the YAML cannot be parsed or is invalid, do not skip silently: tell the user that `.specify/extensions.yml` could not be read (include the parser error) and that no hooks were checked, including any mandatory (`optional: false`) hooks registered there, then continue normally - Filter out hooks where `enabled` is explicitly `false`. Treat hooks without an `enabled` field as enabled by default. - For each remaining hook, do **not** attempt to interpret or evaluate hook `condition` expressions: - If the hook has no `condition` field, or it is null/empty, treat the hook as executable diff --git a/tests/test_command_template_hooks.py b/tests/test_command_template_hooks.py new file mode 100644 index 0000000000..d1c7677bae --- /dev/null +++ b/tests/test_command_template_hooks.py @@ -0,0 +1,80 @@ +"""Command templates must not tell the agent to skip hook checking silently. + +Every core command template reads ``.specify/extensions.yml`` before and after +its main work, looking for ``hooks.before_*`` / ``hooks.after_*`` entries. A +manifest that could not be parsed used to be treated exactly like a manifest +with no hooks: the agent was told to "skip hook checking silently and continue +normally". A mandatory hook (``optional: false``, the kind the bundled ``git`` +extension registers) could therefore be disabled by a single malformed line, +and nothing would say so. + +These tests pin the replacement wording: an unreadable manifest is reported to +the user (the parser error, and the fact that no hooks were checked) before +the command continues. They read the templates as text on purpose: the +behaviour lives in the prompt, so the prompt is what must be checked. +""" + +import re +from pathlib import Path + +import pytest + +REPO_ROOT = Path(__file__).parent.parent +TEMPLATES_DIR = REPO_ROOT / "templates" / "commands" + +_HOOK_KEY = re.compile(r"`hooks\.(before|after)_[a-z_]+`") +_PARSE_FAILURE_LINE = re.compile( + r"^.*If the YAML cannot be parsed or is invalid.*$", re.MULTILINE +) +_SILENT = "skip hook checking silently" +# Every clause of the replacement instruction, so that dropping any one of +# them from the templates fails the test: the manifest could not be read, the +# parser error is shown, no hooks were checked, mandatory hooks are named, and +# the command still continues afterwards. +_REPORTED = ( + "could not be read", + "include the parser error", + "no hooks were checked", + "including any mandatory (`optional: false`) hooks", + "then continue", +) + +HOOK_TEMPLATES = sorted( + p.name + for p in TEMPLATES_DIR.glob("*.md") + if _HOOK_KEY.search(p.read_text(encoding="utf-8")) +) + + +def test_hook_templates_discovered(): + # Guard: the glob must find the templates that read extensions.yml, + # otherwise the parametrized tests below would pass by vacuity. + assert {"specify.md", "plan.md", "tasks.md", "implement.md"} <= set( + HOOK_TEMPLATES + ) + + +@pytest.mark.parametrize("name", HOOK_TEMPLATES) +def test_unreadable_manifest_is_never_skipped_silently(name: str): + text = (TEMPLATES_DIR / name).read_text(encoding="utf-8") + assert _SILENT not in text, ( + f"{name}: an unreadable .specify/extensions.yml may still be skipped " + "silently, which disables mandatory hooks without saying so" + ) + + +@pytest.mark.parametrize("name", HOOK_TEMPLATES) +def test_every_parse_failure_line_reports_before_continuing(name: str): + text = (TEMPLATES_DIR / name).read_text(encoding="utf-8") + lines = _PARSE_FAILURE_LINE.findall(text) + # One line for the before-hook check, one for the after-hook check. + assert len(lines) >= 2, ( + f"{name}: expected a parse-failure instruction at both hook sites, " + f"found {len(lines)}" + ) + for line in lines: + for phrase in _REPORTED: + assert phrase in line, ( + f"{name}: parse-failure instruction does not tell the user " + f"{phrase!r}: {line.strip()}" + ) From 13b760a068c7787b20b960e93f277cf0ef7b84f8 Mon Sep 17 00:00:00 2001 From: CrazyBaran Date: Wed, 9 Sep 2026 18:35:55 +0200 Subject: [PATCH 28/44] Fix #4345 (3/4): CI guard requiring version bumps on bundled extension changes (#4395) * ci(extensions): guard bundled extension changes behind a version bump Bundled extensions only reach existing installs through a version bump: `specify extension update` compares the semver in extensions/catalog.json against the installed copy and reports "Up to date" whenever they match. Content changes shipped without a bump go silently stale on every project that already installed the extension (#4345). This guard turns "please remember to bump" into a merge requirement. - `.github/scripts/check_extension_version_bump.py` fails a PR that changes files under `extensions//` for a catalog-listed extension without increasing that extension's `extension.yml` version (PEP 440 comparison, the same semantics `extension update` uses), and requires `extensions/catalog.json` to stay in sync with each manifest. Non-catalog extensions (the `selftest` fixture and the `template` scaffold) are exempt. - `extension-version-guard.yml` runs the check on pull requests touching `extensions/**`, diffing the PR base against HEAD. - Contract tests pin the working-tree half of the invariant (catalog/manifest version sync, bundled entries shipping a directory); guard-script tests pin the failure behavior against real throwaway git repositories so a parsing change cannot silently disable the guard while CI stays green. Split out of #4351 (part 3 of the series requested in review); refs #4345. Assisted-by: Claude Code (model: claude-fable-5) Co-Authored-By: Claude Fable 5 * fix(ci): make the extension version guard usable as a required check and quoting-proof Address Copilot review round 1 on #4395. Workflow: drop the `paths: extensions/**` filter. A required status check that is skipped by path filtering stays in "Expected" state and blocks every PR that does not touch extensions/**, which defeats the point of making the guard a merge requirement. The job now runs on every pull request; the script already reports success when nothing under extensions/ changed, so unrelated PRs pass in one short job. Script: read the changed-path list with `git diff --name-only -z`. With git's default core.quotePath, a path containing non-ASCII or control characters is C-quoted with the quotes included (`"extensions/demo/caf\303\251.txt"`), so its first component was no longer `extensions` and an unbumped change to such a file escaped the guard. NUL-delimited output is emitted verbatim; paths are decoded with surrogateescape so an undecodable byte cannot crash the check, and only the ASCII `extensions//` prefix is ever interpreted. Tests: pin both behaviors. The non-ASCII case fails against the previous script and passes now; the no-extension-changes case backs the workflow change. core.quotePath is pinned to true in the fixture repo so the regression exercises the quoting path even where a developer's global config disables it. Refs #4345 Assisted-by: Claude Code (model: claude-fable-5-1) Co-Authored-By: Claude Fable 5.1 * fix(ci): validate a new extension's version even without a base to compare Address @mnriem's review on #4395. For a brand-new extension the guard returned early at "no base manifest" before ever parsing the head version, and the catalog check only tests string equality. A new extension shipped with e.g. `not-a-version` in both extension.yml and catalog.json therefore passed the guard, although ExtensionManifest rejects a version packaging cannot parse (ValidationError "Invalid version") and `extension update` skips catalog entries whose version is invalid - the extension would be uninstallable and never updatable. Parse the head version before the new-extension early return and fail closed with a dedicated message when it is not PEP 440. The base version is now parsed separately, so an unparseable base still fails with the "could not compare versions" message. Tests: add test_new_extension_with_unparseable_version_fails (fails against the previous script, passes now) and point the existing unparseable-version test at the new head-version message, which fires first for that scenario. Refs #4345 Assisted-by: Claude Code (model: claude-fable-5-1) Co-Authored-By: Claude Fable 5.1 * fix(ci): diff the extension version guard against the merge commit's first parent The guard diffed github.event.pull_request.base.sha against the checked-out pull-request merge commit. Those two are not built from the same base: the payload SHA is the base-branch tip from when the PR was opened and is not refreshed when the base moves, while refs/pull/N/merge is rebuilt against the current tip. Any base-branch drift between the two is then attributed to the PR. Observed on #4395 itself: base.sha was main as of Sep 1 (3eec154), the merge commit was built on main as of Sep 3 (db64869), and two unbumped bundled-extension changes merged in between (#4413 touching agent-context, #4286 touching git) failed the guard on a PR that never touched either. Fetch the merge commit with depth 2 so both parents are available and diff against HEAD^1, which is by construction the base the merge was built on. Fail loudly if HEAD is not a merge commit. The script's CLI is unchanged; a regression test reproduces the trap in a throwaway repo (the stale base blames the drift on the PR, HEAD^1 does not). Refs #4345 Assisted-by: Claude Code (model: claude-fable-5-1) Co-Authored-By: Claude Fable 5.1 * fix(ci): validate every catalog version, not only those of changed extensions Address the "needs a closer look" items from the Copilot reviews on #4395. Invariant 2 skipped catalog entries without an in-repo directory before parsing their version, and never parsed the in-repo manifest version at all - PEP 440 validation lived only in Invariant 1, which sees just the IDs with changed files under extensions//. Two gaps followed: - a hosted (catalog-only) entry could carry `"version": "not-a-version"` and pass, although `extension update` skips exactly such entries when packaging.Version fails; - promoting an existing uncataloged directory by adding only its catalog entry never entered Invariant 1, so a matching invalid string in manifest and catalog passed the plain equality check. Invariant 2 now runs over every catalog entry: the catalog version must be a non-empty string that parses as PEP 440, and for entries with an in-repo directory the manifest version must parse as well before the equality check. The catalog-side parse alone closes the promotion gap (a valid catalog string plus equality forces a valid manifest string); the manifest parse makes the failure name the manifest precisely. Tests: `_run_guard` now omits the head argument by default, matching the workflow's one-argument invocation, with a separate test for the optional HEAD_REF; new cases cover the hosted-entry and both promotion shapes (the two invalid ones fail against the previous script) plus a valid promotion that must keep passing. The working-tree contract test gains the matching every-catalog-version-parses check. Refs #4345 Assisted-by: Claude Code (model: claude-fable-5-1) Co-Authored-By: Claude Fable 5.1 --------- Co-authored-by: Jakub Baranowski Co-authored-by: Claude Fable 5 --- .../scripts/check_extension_version_bump.py | 232 ++++++++++++ .github/workflows/extension-version-guard.yml | 57 +++ extensions/EXTENSION-DEVELOPMENT-GUIDE.md | 7 + .../test_bundled_extension_versions.py | 83 +++++ .../test_extension_version_guard_script.py | 352 ++++++++++++++++++ 5 files changed, 731 insertions(+) create mode 100644 .github/scripts/check_extension_version_bump.py create mode 100644 .github/workflows/extension-version-guard.yml create mode 100644 tests/contract/test_bundled_extension_versions.py create mode 100644 tests/contract/test_extension_version_guard_script.py diff --git a/.github/scripts/check_extension_version_bump.py b/.github/scripts/check_extension_version_bump.py new file mode 100644 index 0000000000..4972c87784 --- /dev/null +++ b/.github/scripts/check_extension_version_bump.py @@ -0,0 +1,232 @@ +#!/usr/bin/env python3 +"""Fail a PR that changes bundled extension content without a version bump. + +Update offers from `specify extension update` are version-driven: an +extension is offered (and installed) only when the semver in +`extensions/catalog.json` exceeds the installed copy's registered +version. A content change shipped without a version bump is therefore +never delivered automatically (#4345) — a bump is what makes a change +actually reach existing installs, and this guard is what makes the bump +non-optional. + +This check enforces two invariants on the extensions listed in +`extensions/catalog.json`: + +1. Any change to a file under `extensions//` must increase the + `version:` in that extension's `extension.yml` (PEP 440 comparison, + the same semantics `extension update` uses), and the resulting + version must itself parse as PEP 440 — including for a brand-new + extension, since the CLI rejects a manifest whose version it cannot + parse. +2. Every `version` in `extensions/catalog.json` must parse as PEP 440 + (`extension update` skips entries it cannot parse), and for entries + with an in-repo directory it must equal a likewise-valid + `extension.version` in the manifest (the catalog is what update + checks compare against, and the update preflight rejects a manifest + whose version differs from the catalog's). This runs over every + catalog entry, so catalog-only (hosted) entries and catalog-only + promotions of existing directories are covered too. + +Usage: + check_extension_version_bump.py BASE_REF [HEAD_REF] + +BASE_REF is a git ref/SHA for the PR base (must be fetchable with +`git show`). HEAD_REF defaults to the working tree's HEAD. Exits 0 when +all invariants hold, 1 otherwise, printing one line per violation. + +Extensions under `extensions/` that are not in the catalog (the +`selftest` fixture and the `template` scaffold) are exempt: no update +flow is driven by their versions. +""" + +from __future__ import annotations + +import json +import subprocess +import sys +from pathlib import PurePosixPath + +import yaml +from packaging.version import InvalidVersion, Version + +EXTENSIONS_ROOT = "extensions" +CATALOG_PATH = f"{EXTENSIONS_ROOT}/catalog.json" + + +def _changed_paths(base_ref: str, head_ref: str) -> list[str]: + """Paths under extensions/ that differ between *base_ref* and *head_ref*. + + Uses NUL-delimited output (``-z``): without it git C-quotes any path + containing non-ASCII or control characters (``"extensions/x/caf\\303\\251"``, + quotes included), so the leading component would no longer equal + ``extensions`` and that change would silently escape the guard. Paths + are decoded with surrogateescape so an undecodable byte can never crash + the check; only the ASCII ``extensions//`` prefix is interpreted. + """ + raw = subprocess.run( + [ + "git", "diff", "--name-only", "-z", "--no-renames", + base_ref, head_ref, "--", EXTENSIONS_ROOT, + ], + check=True, + capture_output=True, + ).stdout + return [ + chunk.decode("utf-8", errors="surrogateescape") + for chunk in raw.split(b"\0") + if chunk + ] + + +def _show(ref: str, path: str) -> str | None: + """Return the file's content at *ref*, or None when absent there.""" + result = subprocess.run( + ["git", "show", f"{ref}:{path}"], capture_output=True, text=True + ) + return result.stdout if result.returncode == 0 else None + + +def _manifest_version(manifest_text: str, origin: str) -> str: + data = yaml.safe_load(manifest_text) + if not isinstance(data, dict) or not isinstance(data.get("extension"), dict): + raise ValueError(f"{origin}: manifest is not a mapping with an 'extension' block") + version = data["extension"].get("version") + if not isinstance(version, str) or not version.strip(): + raise ValueError(f"{origin}: extension.version is missing or not a string") + return version.strip() + + +def main(argv: list[str]) -> int: + if len(argv) < 2 or len(argv) > 3: + print(__doc__, file=sys.stderr) + return 2 + base_ref = argv[1] + head_ref = argv[2] if len(argv) == 3 else "HEAD" + + catalog_text = _show(head_ref, CATALOG_PATH) + if catalog_text is None: + print(f"::error::{CATALOG_PATH} is missing at {head_ref}") + return 1 + catalog = json.loads(catalog_text) + catalog_entries = catalog.get("extensions", {}) + + errors: list[str] = [] + + # -- Invariant 1: content change requires a version bump --------------- + changed_ids = { + parts[1] + for path in _changed_paths(base_ref, head_ref) + if len(parts := PurePosixPath(path).parts) >= 3 and parts[0] == EXTENSIONS_ROOT + } + + for ext_id in sorted(changed_ids): + if ext_id not in catalog_entries: + continue # not driven by `extension update` (selftest, template) + manifest_path = f"{EXTENSIONS_ROOT}/{ext_id}/extension.yml" + head_manifest = _show(head_ref, manifest_path) + if head_manifest is None: + continue # extension removed in this PR + try: + head_version = _manifest_version(head_manifest, f"{head_ref}:{manifest_path}") + except ValueError as exc: + errors.append(str(exc)) + continue + + # Parse the head version before the new-extension early return: the + # CLI's ExtensionManifest rejects a version packaging cannot parse and + # `extension update` skips catalog entries whose version is invalid, + # so a new extension shipped with e.g. "not-a-version" in both places + # would be uninstallable even though the catalog check below (plain + # string equality) passes. Same PEP 440 semantics as the CLI, so + # prereleases and other accepted forms are handled identically. + try: + head_parsed = Version(head_version) + except InvalidVersion as exc: + errors.append( + f"{manifest_path}: extension.version {head_version!r} is not a valid " + f"PEP 440 version ({exc}); the CLI rejects this manifest." + ) + continue + + base_manifest = _show(base_ref, manifest_path) + if base_manifest is None: + continue # new extension; any valid initial version is fine + try: + base_version = _manifest_version(base_manifest, f"{base_ref}:{manifest_path}") + except ValueError as exc: + errors.append(str(exc)) + continue + + # Compare with the same PEP 440 semantics the extension update and + # install code use, so prereleases and other accepted forms cannot + # bypass the guard (e.g. 2.0.0 -> 1.0.0rc1 is a downgrade). An + # unparseable base version fails closed. + try: + base_parsed = Version(base_version) + except InvalidVersion as exc: + errors.append( + f"{manifest_path}: could not compare versions " + f"{base_version!r} -> {head_version!r}: {exc}" + ) + continue + if head_parsed <= base_parsed: + errors.append( + f"{manifest_path}: files under {EXTENSIONS_ROOT}/{ext_id}/ changed but " + f"extension.version did not increase ({base_version} -> {head_version}). " + f"Installed copies only receive changes when the version is bumped." + ) + + # -- Invariant 2: catalog versions are valid and match the manifests ---- + # Runs over every catalog entry, changed or not: a catalog-only entry + # (hosted elsewhere) never has files under extensions//, and a + # catalog-only promotion of an existing uncataloged directory never + # enters Invariant 1, so neither would otherwise have its version parsed. + for ext_id, entry in sorted(catalog_entries.items()): + catalog_version = entry.get("version") if isinstance(entry, dict) else None + if not isinstance(catalog_version, str) or not catalog_version.strip(): + errors.append(f"{CATALOG_PATH}: entry '{ext_id}' has no string 'version'") + continue + # `extension update` skips a catalog entry whose version packaging + # cannot parse, so an invalid catalog version is never offered. + try: + Version(catalog_version) + except InvalidVersion as exc: + errors.append( + f"{CATALOG_PATH}: entry '{ext_id}' version {catalog_version!r} is not a " + f"valid PEP 440 version ({exc}); `extension update` skips such entries." + ) + continue + + manifest_path = f"{EXTENSIONS_ROOT}/{ext_id}/extension.yml" + head_manifest = _show(head_ref, manifest_path) + if head_manifest is None: + continue # catalog-only entry (e.g. hosted elsewhere); version checked above + try: + manifest_version = _manifest_version(head_manifest, f"{head_ref}:{manifest_path}") + except ValueError as exc: + errors.append(str(exc)) + continue + try: + Version(manifest_version) + except InvalidVersion as exc: + errors.append( + f"{manifest_path}: extension.version {manifest_version!r} is not a valid " + f"PEP 440 version ({exc}); the CLI rejects this manifest." + ) + continue + if catalog_version != manifest_version: + errors.append( + f"{CATALOG_PATH}: entry '{ext_id}' has version {catalog_version!r} but " + f"{manifest_path} declares {manifest_version!r}. `extension update` " + f"compares against the catalog, so the two must move together." + ) + + for error in errors: + print(f"::error::{error}") + if not errors: + print("Extension version guard: all invariants hold.") + return 1 if errors else 0 + + +if __name__ == "__main__": + sys.exit(main(sys.argv)) diff --git a/.github/workflows/extension-version-guard.yml b/.github/workflows/extension-version-guard.yml new file mode 100644 index 0000000000..9dcb6c095b --- /dev/null +++ b/.github/workflows/extension-version-guard.yml @@ -0,0 +1,57 @@ +name: Extension Version Guard + +permissions: + contents: read + +# Bundled extensions only reach existing installs through a version bump: +# `specify extension update` compares the semver in extensions/catalog.json +# against the installed copy and reports "Up to date" whenever they match. +# Content changes shipped without a bump go silently stale on every +# project that already installed the extension (#4345). This guard turns +# "please remember to bump" into a merge requirement. +# +# Deliberately no `paths:` filter: a required status check that is skipped +# by path filtering stays in "Expected" state and blocks every PR that does +# not touch extensions/**. The check runs on every pull request instead and +# the script reports success when nothing under extensions/ changed. +on: + pull_request: + +jobs: + version-bump: + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + # The pull_request checkout is GitHub's merge of the PR head into + # the base tip. Depth 2 also fetches both parents of that merge + # commit, so HEAD^1 - the base the merge was actually built on - + # is available to diff against. + fetch-depth: 2 + + - name: Set up Python + uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0 + with: + python-version: "3.14" + + - name: Install check dependencies + run: python -m pip install --quiet pyyaml packaging + + # Diff against the merge commit's first parent, NOT against + # github.event.pull_request.base.sha. The payload SHA is the base tip + # from when the PR was opened and is not refreshed when the base branch + # moves, while refs/pull/N/merge is rebuilt against the current tip. + # Diffing the stale SHA against the fresh merge commit attributes + # unrelated base-branch drift to the PR (observed on #4395: two + # unbumped extension changes merged to main were blamed on a PR that + # never touched them). HEAD^1 is by construction the base the merge + # was built on. + - name: Check bundled extension version bumps + run: | + set -euo pipefail + if ! git rev-parse --verify --quiet HEAD^2 >/dev/null; then + echo "::error::HEAD is not the pull request merge commit; cannot determine the PR base" + exit 1 + fi + python .github/scripts/check_extension_version_bump.py HEAD^1 diff --git a/extensions/EXTENSION-DEVELOPMENT-GUIDE.md b/extensions/EXTENSION-DEVELOPMENT-GUIDE.md index ff0e90c234..96d7efbcd3 100644 --- a/extensions/EXTENSION-DEVELOPMENT-GUIDE.md +++ b/extensions/EXTENSION-DEVELOPMENT-GUIDE.md @@ -625,6 +625,13 @@ See the [Extension Publishing Guide](EXTENSION-PUBLISHING-GUIDE.md) for detailed - **MAJOR**: Breaking changes - **MINOR**: New features - **PATCH**: Bug fixes +- **Bump on every content change**: update offers from `specify extension + update` are version-driven, so a content change shipped without a + version bump is never delivered automatically to already-installed + copies. For the bundled extensions in this repository the bump is + enforced by CI (`extension-version-guard.yml`): a PR that changes + files under `extensions//` must also bump that extension's + `extension.yml` version and keep `extensions/catalog.json` in sync. ### Security diff --git a/tests/contract/test_bundled_extension_versions.py b/tests/contract/test_bundled_extension_versions.py new file mode 100644 index 0000000000..2d7f0c6c0e --- /dev/null +++ b/tests/contract/test_bundled_extension_versions.py @@ -0,0 +1,83 @@ +"""Contract tests: bundled extension versions must stay in sync with the catalog. + +``specify extension update`` decides whether an installed extension needs +updating by comparing the semver in ``extensions/catalog.json`` against the +installed copy's registered version, and its preflight rejects a manifest +whose version differs from the catalog's. A catalog entry that drifts from +its ``extension.yml`` therefore either hides updates from every installed +copy or makes every offered update fail validation (#4345). + +The companion "content change requires a version bump" rule needs the git +diff of a PR and lives in CI +(``.github/scripts/check_extension_version_bump.py`` via the +``extension-version-guard.yml`` workflow); this test enforces the half that +is checkable from a plain working tree. +""" + +from __future__ import annotations + +import json +from pathlib import Path + +import pytest +import yaml +from packaging.version import InvalidVersion, Version + +REPO_ROOT = Path(__file__).parents[2] +EXTENSIONS_ROOT = REPO_ROOT / "extensions" + + +def _catalog_entries() -> dict[str, dict]: + catalog = json.loads((EXTENSIONS_ROOT / "catalog.json").read_text(encoding="utf-8")) + return catalog["extensions"] + + +def _manifest_version(ext_id: str) -> str: + manifest_path = EXTENSIONS_ROOT / ext_id / "extension.yml" + data = yaml.safe_load(manifest_path.read_text(encoding="utf-8")) + return data["extension"]["version"] + + +def test_catalog_lists_extensions(): + assert _catalog_entries(), "expected at least one extension in extensions/catalog.json" + + +@pytest.mark.parametrize("ext_id", sorted(_catalog_entries())) +def test_catalog_version_matches_manifest(ext_id: str): + entry = _catalog_entries()[ext_id] + manifest_path = EXTENSIONS_ROOT / ext_id / "extension.yml" + if not manifest_path.is_file(): + pytest.skip(f"'{ext_id}' has no in-repo extension directory") + assert entry.get("version") == _manifest_version(ext_id), ( + f"extensions/catalog.json entry '{ext_id}' and {manifest_path.relative_to(REPO_ROOT)} " + f"declare different versions - `specify extension update` compares against the " + f"catalog, so the two must move together" + ) + + +@pytest.mark.parametrize("ext_id", sorted(_catalog_entries())) +def test_bundled_entries_ship_an_extension_directory(ext_id: str): + entry = _catalog_entries()[ext_id] + if not entry.get("bundled"): + pytest.skip(f"'{ext_id}' is not marked bundled") + assert (EXTENSIONS_ROOT / ext_id / "extension.yml").is_file(), ( + f"catalog marks '{ext_id}' as bundled but extensions/{ext_id}/extension.yml is missing" + ) + + +@pytest.mark.parametrize("ext_id", sorted(_catalog_entries())) +def test_catalog_version_is_valid_pep440(ext_id: str): + """`extension update` parses each catalog version with packaging and skips + entries it cannot parse, so every entry - bundled or hosted - must carry + a valid version, independent of whether it has an in-repo directory.""" + version = _catalog_entries()[ext_id].get("version") + assert isinstance(version, str) and version.strip(), ( + f"extensions/catalog.json entry '{ext_id}' has no string version" + ) + try: + Version(version) + except InvalidVersion as exc: + pytest.fail( + f"extensions/catalog.json entry '{ext_id}' version {version!r} is not a valid " + f"PEP 440 version ({exc}); `extension update` would skip it" + ) diff --git a/tests/contract/test_extension_version_guard_script.py b/tests/contract/test_extension_version_guard_script.py new file mode 100644 index 0000000000..6f31d7a142 --- /dev/null +++ b/tests/contract/test_extension_version_guard_script.py @@ -0,0 +1,352 @@ +"""Tests for the extension version-bump CI guard script (#4345). + +The guard (`.github/scripts/check_extension_version_bump.py`) is the +primary regression prevention for bundled-extension version staleness, so +its failure behavior must be pinned by tests: each scenario builds a real +throwaway git repository and invokes the script against base/head SHAs, +exactly as the `extension-version-guard.yml` workflow does. Without this, +a change to the script's diff or parsing logic could silently disable the +guard while CI stays green. +""" + +from __future__ import annotations + +import json +import subprocess +import sys +from pathlib import Path + +import pytest + +REPO_ROOT = Path(__file__).parents[2] +SCRIPT = REPO_ROOT / ".github" / "scripts" / "check_extension_version_bump.py" + + +def _git(repo: Path, *args: str) -> str: + result = subprocess.run( + ["git", "-C", str(repo), *args], + check=True, + capture_output=True, + text=True, + ) + return result.stdout.strip() + + +def _write_extension(repo: Path, ext_id: str, version: str, script_line: str) -> None: + ext_dir = repo / "extensions" / ext_id + ext_dir.mkdir(parents=True, exist_ok=True) + (ext_dir / "extension.yml").write_text( + 'schema_version: "1.0"\n' + "\n" + "extension:\n" + f" id: {ext_id}\n" + f' version: "{version}"\n', + encoding="utf-8", + ) + (ext_dir / "script.sh").write_text(f"{script_line}\n", encoding="utf-8") + + +def _write_catalog(repo: Path, versions: dict[str, str | dict]) -> None: + """Write catalog.json; a value is either a bundled entry's version string + or a full entry dict (for e.g. hosted, non-bundled entries).""" + (repo / "extensions").mkdir(exist_ok=True) + payload = { + "schema_version": "1.0", + "extensions": { + ext_id: ( + {"id": ext_id, **spec} + if isinstance(spec, dict) + else {"id": ext_id, "version": spec, "bundled": True} + ) + for ext_id, spec in versions.items() + }, + } + (repo / "extensions" / "catalog.json").write_text( + json.dumps(payload, indent=2), encoding="utf-8" + ) + + +def _commit_all(repo: Path, message: str) -> str: + _git(repo, "add", "-A") + _git(repo, "commit", "-q", "-m", message) + return _git(repo, "rev-parse", "HEAD") + + +@pytest.fixture +def guard_repo(tmp_path: Path) -> tuple[Path, str]: + """A git repo with one cataloged and one uncataloged extension at base.""" + repo = tmp_path / "repo" + repo.mkdir() + _git(repo, "init", "-q") + _git(repo, "config", "user.email", "guard-tests@example.com") + _git(repo, "config", "user.name", "Guard Tests") + _git(repo, "config", "commit.gpgsign", "false") + # git's default; pinned so the non-ASCII regression below exercises the + # C-quoting code path even on machines whose global config disables it. + _git(repo, "config", "core.quotePath", "true") + + _write_extension(repo, "demo", "1.0.0", "echo base") + _write_extension(repo, "scratch", "1.0.0", "echo base") # not in catalog + _write_catalog(repo, {"demo": "1.0.0"}) + base_sha = _commit_all(repo, "base") + return repo, base_sha + + +def _run_guard( + repo: Path, base: str, head: str | None = None +) -> subprocess.CompletedProcess: + """Invoke the guard as the workflow does: base ref only, head defaulting + to HEAD inside the script. Pass *head* explicitly only to test the + optional second argument.""" + return subprocess.run( + [sys.executable, str(SCRIPT), base, *([head] if head else [])], + cwd=repo, + capture_output=True, + text=True, + ) + + +def test_valid_bump_passes(guard_repo): + repo, base = guard_repo + _write_extension(repo, "demo", "1.1.0", "echo changed") + _write_catalog(repo, {"demo": "1.1.0"}) + _commit_all(repo, "content change with bump") + + result = _run_guard(repo, base) + assert result.returncode == 0, result.stdout + result.stderr + assert "all invariants hold" in result.stdout + + +def test_unbumped_content_change_fails(guard_repo): + repo, base = guard_repo + _write_extension(repo, "demo", "1.0.0", "echo changed") + _commit_all(repo, "content change without bump") + + result = _run_guard(repo, base) + assert result.returncode == 1, result.stdout + result.stderr + assert "did not increase" in result.stdout + assert "extensions/demo/extension.yml" in result.stdout + + +def test_unbumped_non_ascii_filename_fails(guard_repo): + """With core.quotePath (git's default) `git diff --name-only` C-quotes a + path like extensions/demo/café.txt, quotes included, so a line-based + parser no longer sees `extensions` as the first component and the + change escapes the guard. The NUL-delimited diff must still catch it.""" + repo, base = guard_repo + (repo / "extensions" / "demo" / "café.txt").write_text("new\n", encoding="utf-8") + _commit_all(repo, "add non-ascii file without bump") + + result = _run_guard(repo, base) + assert result.returncode == 1, result.stdout + result.stderr + assert "did not increase" in result.stdout + assert "extensions/demo/extension.yml" in result.stdout + + +def test_merge_commit_first_parent_ignores_base_branch_drift(guard_repo): + """The workflow diffs against HEAD^1 of GitHub's pull-request merge commit, + not github.event.pull_request.base.sha. The payload SHA is the base tip + from when the PR was opened and is never refreshed, while the merge ref + is rebuilt against the current base tip; diffing the stale SHA against + the fresh merge commit blames base-branch drift on the PR (seen on + #4395). The merge commit's first parent is the base it was built on.""" + repo, stale_base = guard_repo + _git(repo, "checkout", "-q", "-b", "pr") + (repo / "README.md").write_text("pr change\n", encoding="utf-8") + _commit_all(repo, "unrelated PR change") + + # Base branch moves on after the PR branched off: an unbumped extension + # change lands there. GitHub then rebuilds refs/pull/N/merge on top of it. + _git(repo, "checkout", "-q", "-") + _write_extension(repo, "demo", "1.0.0", "echo drifted on base") + _commit_all(repo, "unbumped change on base after PR branched") + _git(repo, "merge", "-q", "--no-ff", "--no-edit", "pr") + + stale = _run_guard(repo, stale_base) + assert stale.returncode == 1, stale.stdout + stale.stderr + assert "did not increase" in stale.stdout # the trap: drift blamed on the PR + + fresh = _run_guard(repo, "HEAD^1") + assert fresh.returncode == 0, fresh.stdout + fresh.stderr + assert "all invariants hold" in fresh.stdout + + +def test_no_extension_changes_passes(guard_repo): + """The workflow runs on every pull request (a path-filtered required check + would block PRs that skip it), so a PR touching nothing under extensions/ + must pass rather than be reported as a violation.""" + repo, base = guard_repo + (repo / "README.md").write_text("docs only\n", encoding="utf-8") + _commit_all(repo, "unrelated change") + + result = _run_guard(repo, base) + assert result.returncode == 0, result.stdout + result.stderr + assert "all invariants hold" in result.stdout + + +def test_downgrade_fails(guard_repo): + repo, base = guard_repo + _write_extension(repo, "demo", "0.9.0", "echo changed") + _write_catalog(repo, {"demo": "0.9.0"}) + _commit_all(repo, "downgrade") + + result = _run_guard(repo, base) + assert result.returncode == 1, result.stdout + result.stderr + assert "did not increase" in result.stdout + + +def test_prerelease_downgrade_fails(guard_repo): + """PEP 440 semantics: 1.0.0rc1 is lower than 1.0.0, and it must not slip + through as a plain string inequality.""" + repo, base = guard_repo + _write_extension(repo, "demo", "1.0.0rc1", "echo changed") + _write_catalog(repo, {"demo": "1.0.0rc1"}) + _commit_all(repo, "prerelease downgrade") + + result = _run_guard(repo, base) + assert result.returncode == 1, result.stdout + result.stderr + assert "did not increase" in result.stdout + + +def test_manifest_bump_without_catalog_sync_fails(guard_repo): + repo, base = guard_repo + _write_extension(repo, "demo", "1.1.0", "echo changed") + _commit_all(repo, "bump without catalog sync") + + result = _run_guard(repo, base) + assert result.returncode == 1, result.stdout + result.stderr + assert "must move together" in result.stdout + assert "catalog.json" in result.stdout + + +def test_uncataloged_extension_change_is_exempt(guard_repo): + repo, base = guard_repo + _write_extension(repo, "scratch", "1.0.0", "echo changed") + _commit_all(repo, "uncataloged change without bump") + + result = _run_guard(repo, base) + assert result.returncode == 0, result.stdout + result.stderr + assert "all invariants hold" in result.stdout + + +def test_new_cataloged_extension_passes_without_base_version(guard_repo): + repo, base = guard_repo + _write_extension(repo, "fresh", "0.1.0", "echo new") + _write_catalog(repo, {"demo": "1.0.0", "fresh": "0.1.0"}) + _commit_all(repo, "add new extension") + + result = _run_guard(repo, base) + assert result.returncode == 0, result.stdout + result.stderr + assert "all invariants hold" in result.stdout + + +def test_explicit_head_argument_is_honored(guard_repo): + """The optional HEAD_REF argument must select the head to check: pointing + it at the base commit yields an empty diff even though HEAD has an + unbumped change.""" + repo, base = guard_repo + _write_extension(repo, "demo", "1.0.0", "echo changed") + _commit_all(repo, "content change without bump") + + result = _run_guard(repo, base, head=base) + assert result.returncode == 0, result.stdout + result.stderr + assert "all invariants hold" in result.stdout + + +def test_catalog_only_entry_with_unparseable_version_fails(guard_repo): + """A hosted (non-bundled) entry has no in-repo directory, so nothing under + extensions/ changes and Invariant 1 never sees it; its catalog version + must still parse because `extension update` skips entries it cannot.""" + repo, base = guard_repo + _write_catalog( + repo, + { + "demo": "1.0.0", + "hosted": { + "version": "not-a-version", + "bundled": False, + "download_url": "https://example.com/hosted-1.0.0.zip", + }, + }, + ) + _commit_all(repo, "add hosted entry with invalid version") + + result = _run_guard(repo, base) + assert result.returncode == 1, result.stdout + result.stderr + assert "entry 'hosted'" in result.stdout + assert "is not a valid PEP 440 version" in result.stdout + + +def test_catalog_only_promotion_with_unparseable_version_fails(guard_repo): + """Promoting an existing uncataloged directory by adding only its catalog + entry changes nothing under extensions//, so Invariant 1 skips it; + matching invalid strings must still fail because the CLI cannot use them. + The catalog-version parse rejects the entry first.""" + repo, _ = guard_repo + _write_extension(repo, "draft", "not-a-version", "echo draft") + base = _commit_all(repo, "uncataloged draft with invalid version on base") + + _write_catalog(repo, {"demo": "1.0.0", "draft": "not-a-version"}) + _commit_all(repo, "promote draft via catalog only") + + result = _run_guard(repo, base) + assert result.returncode == 1, result.stdout + result.stderr + assert "entry 'draft'" in result.stdout + assert "is not a valid PEP 440 version" in result.stdout + + +def test_catalog_only_promotion_with_invalid_manifest_version_fails(guard_repo): + """Same promotion, but the catalog carries a valid version while the + untouched in-repo manifest does not: Invariant 2 must parse the manifest + itself (not only compare strings) and name the manifest in the error.""" + repo, _ = guard_repo + _write_extension(repo, "draft", "not-a-version", "echo draft") + base = _commit_all(repo, "uncataloged draft with invalid version on base") + + _write_catalog(repo, {"demo": "1.0.0", "draft": "1.0.0"}) + _commit_all(repo, "promote draft with a valid catalog version only") + + result = _run_guard(repo, base) + assert result.returncode == 1, result.stdout + result.stderr + assert "extensions/draft/extension.yml" in result.stdout + assert "is not a valid PEP 440 version" in result.stdout + + +def test_catalog_only_promotion_with_valid_version_passes(guard_repo): + """The uncataloged `scratch` fixture carries a valid 1.0.0; cataloging it + without touching its directory is a legitimate promotion.""" + repo, base = guard_repo + _write_catalog(repo, {"demo": "1.0.0", "scratch": "1.0.0"}) + _commit_all(repo, "promote scratch via catalog only") + + result = _run_guard(repo, base) + assert result.returncode == 0, result.stdout + result.stderr + assert "all invariants hold" in result.stdout + + +def test_unparseable_version_fails_closed(guard_repo): + repo, base = guard_repo + _write_extension(repo, "demo", "not-a-version", "echo changed") + _write_catalog(repo, {"demo": "not-a-version"}) + _commit_all(repo, "unparseable version") + + result = _run_guard(repo, base) + assert result.returncode == 1, result.stdout + result.stderr + assert "is not a valid PEP 440 version" in result.stdout + assert "extensions/demo/extension.yml" in result.stdout + + +def test_new_extension_with_unparseable_version_fails(guard_repo): + """A brand-new extension has no base manifest to compare against, but its + version must still parse: ExtensionManifest rejects a version packaging + cannot parse and `extension update` skips such catalog entries. Matching + strings in manifest and catalog must not let it through.""" + repo, base = guard_repo + _write_extension(repo, "fresh", "not-a-version", "echo new") + _write_catalog(repo, {"demo": "1.0.0", "fresh": "not-a-version"}) + _commit_all(repo, "add new extension with invalid version") + + result = _run_guard(repo, base) + assert result.returncode == 1, result.stdout + result.stderr + assert "is not a valid PEP 440 version" in result.stdout + assert "extensions/fresh/extension.yml" in result.stdout From acdfb189a72cc45a4be5589547eddbca1b5bbc56 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Wed, 9 Sep 2026 11:52:56 -0500 Subject: [PATCH 29/44] Update Figma Starter extension to v1.1.0 (#4490) Update figma-starter extension submitted by @vibhus:\n- extensions/catalog.community.json (version, download_url, metadata)\n- docs/community/extensions.md community extensions table\n\nCloses #4463\n\nCo-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>\nAssisted-by: GitHub Copilot (model: gpt-5.2-codex, autonomous) Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com> --- docs/community/extensions.md | 2 +- extensions/catalog.community.json | 27 +++++++++++++++------------ 2 files changed, 16 insertions(+), 13 deletions(-) diff --git a/docs/community/extensions.md b/docs/community/extensions.md index 0c17cfcbb7..e525dd0c2f 100644 --- a/docs/community/extensions.md +++ b/docs/community/extensions.md @@ -69,7 +69,7 @@ The following community-contributed extensions are available in [`catalog.commun | 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) | +| Figma Starter | Turns a Figma section's screens into Figma-derived specs, and an OpenAPI/Swagger document into an entity model plus per-page UI-to-API bindings, then hands off to /speckit.specify | `docs` | Read+Write | [spec-kit-figma-starter](https://github.com/OmneWave/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) | | FixIt Extension | Spec-aware bug fixing — maps bugs to spec artifacts, proposes a plan, applies minimal changes | `code` | Read+Write | [spec-kit-fixit](https://github.com/speckit-community/spec-kit-fixit) | | Fleet Orchestrator | Orchestrate a full feature lifecycle with human-in-the-loop gates across all SpecKit phases | `process` | Read+Write | [spec-kit-fleet](https://github.com/sharathsatish/spec-kit-fleet) | diff --git a/extensions/catalog.community.json b/extensions/catalog.community.json index 56c01407c3..936f58cb37 100644 --- a/extensions/catalog.community.json +++ b/extensions/catalog.community.json @@ -1,6 +1,6 @@ { "schema_version": "1.0", - "updated_at": "2026-09-08T00:00:00Z", + "updated_at": "2026-09-09T00:00:00Z", "catalog_url": "https://raw.githubusercontent.com/github/spec-kit/main/extensions/catalog.community.json", "extensions": { "adrkit": { @@ -1918,16 +1918,16 @@ "figma-starter": { "name": "Figma Starter", "id": "figma-starter", - "description": "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.", + "description": "Turns a Figma section's screens into Figma-derived specs — per-screen spec.md files, app-level user-stories.md, and a build-order.md — and turns an OpenAPI/Swagger document into an entity model plus per-page UI-to-API bindings, then hands off to /speckit.specify.", "author": "WaveMaker", - "version": "1.0.0", - "download_url": "https://github.com/wavemaker/spec-kit-figma-starter/archive/refs/tags/v1.0.0.zip", - "repository": "https://github.com/wavemaker/spec-kit-figma-starter", - "homepage": "https://github.com/wavemaker/spec-kit-figma-starter", - "documentation": "https://github.com/wavemaker/spec-kit-figma-starter/blob/main/README.md", - "changelog": "https://github.com/wavemaker/spec-kit-figma-starter/blob/main/CHANGELOG.md", + "version": "1.1.0", + "download_url": "https://github.com/OmneWave/spec-kit-figma-starter/archive/refs/tags/v1.1.0.zip", + "repository": "https://github.com/OmneWave/spec-kit-figma-starter", + "homepage": "https://github.com/OmneWave/spec-kit-figma-starter", + "documentation": "https://github.com/OmneWave/spec-kit-figma-starter/blob/main/README.md", + "changelog": "https://github.com/OmneWave/spec-kit-figma-starter/blob/main/CHANGELOG.md", "license": "MIT", - "category": "integration", + "category": "docs", "effect": "read-write", "requires": { "speckit_version": ">=0.1.0", @@ -1940,7 +1940,7 @@ ] }, "provides": { - "commands": 1, + "commands": 2, "hooks": 1 }, "tags": [ @@ -1948,13 +1948,16 @@ "design", "design-to-spec", "ui", - "frontend" + "frontend", + "openapi", + "swagger", + "api" ], "verified": false, "downloads": 0, "stars": 0, "created_at": "2026-07-15T00:00:00Z", - "updated_at": "2026-07-15T00:00:00Z" + "updated_at": "2026-09-09T00:00:00Z" }, "fix-findings": { "name": "Fix Findings", From f21acc4a25ce3aa53ff8653357b49b9aafcf8026 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Wed, 9 Sep 2026 12:23:49 -0500 Subject: [PATCH 30/44] Update AgentPay x402 extension to v1.1.0 (#4482) Update pay-x402 extension submitted by @shawn93676: - extensions/catalog.community.json (version, download_url, description, commands, tags) - docs/community/extensions.md community extensions table Closes #4446 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 | 2 +- extensions/catalog.community.json | 13 +++++++------ 2 files changed, 8 insertions(+), 7 deletions(-) diff --git a/docs/community/extensions.md b/docs/community/extensions.md index e525dd0c2f..f7c4697ed1 100644 --- a/docs/community/extensions.md +++ b/docs/community/extensions.md @@ -30,7 +30,7 @@ The following community-contributed extensions are available in [`catalog.commun | Agent Governance | Generate agent-platform repository governance files from Spec Kit metadata | `process` | Read+Write | [spec-kit-agent-governance](https://github.com/bigsmartben/spec-kit-agent-governance) | | AgentDocx | Full-stack multi-agent specification pipeline with VS Code extension control, automated Kanban/Jira sync, and React monitoring dashboard | `integration` | Read+Write | [extension-github-spec-kit](https://github.com/abir-ommezzine/extension-github-spec-kit) | | AgentDocx SpecKit V2 | AgentDocx evolved: same pipeline + far more autonomous Ticket Manager (5 CLI, per-project Kanban, bulk sync, auto-switch, Auditor) | `integration` | Read+Write | [Extension_GithubSpecKit](https://github.com/ahmed200346/Extension_GithubSpecKit) | -| AgentPay x402 — Spend Controls for Spec Kit Agents | Set USDC spending caps and execute x402 payments to paid APIs during spec implementation. Zero platform fee on Base L2 | `integration` | Read+Write | [spec-kit-pay-x402](https://github.com/shawnhvac/spec-kit-pay-x402) | +| AgentPay x402 — Spend Controls for Spec Kit Agents | Set USDC spending caps, check a counterparty's credit before paying it, and pay x402-enabled APIs during spec implementation. Settles on Base or Solana. | `integration` | Read+Write | [spec-kit-pay-x402](https://github.com/shawnhvac/spec-kit-pay-x402) | | AI-Driven Engineering (AIDE) | A structured 7-step workflow for building new projects from scratch with AI assistants — from vision through implementation | `process` | Read+Write | [aide](https://github.com/mnriem/spec-kit-extensions/tree/main/aide) | | Analytics | Measure what your AI builds, and how much time it saves you | `visibility` | Read+Write | [spec-kit-analytics](https://github.com/Fyloss/spec-kit-analytics) | | API Evolve | Managed API contract evolution — breaking-change detection, semver enforcement, deprecation orchestration, and lifecycle gates across REST, GraphQL, and gRPC | `process` | Read+Write | [spec-kit-api-evolve](https://github.com/Quratulain-bilal/spec-kit-api-evolve) | diff --git a/extensions/catalog.community.json b/extensions/catalog.community.json index 936f58cb37..9cdfeb72a5 100644 --- a/extensions/catalog.community.json +++ b/extensions/catalog.community.json @@ -3647,10 +3647,10 @@ "pay-x402": { "name": "AgentPay x402 — Spend Controls for Spec Kit Agents", "id": "pay-x402", - "description": "Set USDC spending caps and execute x402 payments to paid APIs during spec implementation. Zero platform fee on Base L2.", + "description": "Set USDC spending caps, check a counterparty's credit before paying it, and pay x402-enabled APIs during spec implementation. Settles on Base or Solana.", "author": "AgentPay Team", - "version": "1.0.0", - "download_url": "https://github.com/shawnhvac/spec-kit-pay-x402/archive/refs/tags/v1.0.0.zip", + "version": "1.1.0", + "download_url": "https://github.com/shawnhvac/spec-kit-pay-x402/archive/refs/tags/v1.1.0.zip", "repository": "https://github.com/shawnhvac/spec-kit-pay-x402", "homepage": "https://x402-agent-pay.com", "documentation": "https://github.com/shawnhvac/spec-kit-pay-x402#readme", @@ -3662,7 +3662,7 @@ "speckit_version": ">=0.1.0" }, "provides": { - "commands": 2, + "commands": 3, "hooks": 1 }, "tags": [ @@ -3670,13 +3670,14 @@ "x402", "budget", "usdc", - "api" + "api", + "solana" ], "verified": false, "downloads": 0, "stars": 0, "created_at": "2026-08-17T00:00:00Z", - "updated_at": "2026-08-17T00:00:00Z" + "updated_at": "2026-09-08T00:00:00Z" }, "plan-review-gate": { "name": "Plan Review Gate", From f1b8d5da996a63c0005a67af17ca851e0825812a Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Wed, 9 Sep 2026 12:44:42 -0500 Subject: [PATCH 31/44] Add ProductShape PRODUCT workflows extension to community catalog (#4485) Add pdac-product extension submitted by @juangcarmona to: - extensions/catalog.community.json (alphabetical order) - docs/community/extensions.md community extensions table Closes #4453 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 | 40 +++++++++++++++++++++++++++++++ 2 files changed, 41 insertions(+) diff --git a/docs/community/extensions.md b/docs/community/extensions.md index f7c4697ed1..274bd1f414 100644 --- a/docs/community/extensions.md +++ b/docs/community/extensions.md @@ -119,6 +119,7 @@ The following community-contributed extensions are available in [`catalog.commun | Presetify | Create and validate presets and preset catalogs | `process` | Read+Write | [presetify](https://github.com/mnriem/spec-kit-extensions/tree/main/presetify) | | Product Forge | Full product-lifecycle orchestrator for Spec Kit: research → product-spec → plan → tasks → implement → verify → test → release-readiness, across express/lite/standard/v-model modes with human-in-the-loop gates. | `process` | Read+Write | [speckit-product-forge](https://github.com/VaiYav/speckit-product-forge) | | Product Spec Extension | Generates PRFAQ, Lean PRD, stakeholder summaries, and technical designs from engineering specs | `docs` | Read+Write | [spec-kit-product](https://github.com/d0whc3r/spec-kit-product) | +| ProductShape PRODUCT workflows | Author, refine, validate, apply, archive and recover ProductShape product meaning in a separate Spec Kit lane. | `process` | Read+Write | [productshape](https://github.com/juangcarmona/productshape) | | Project Health Check | Diagnose a Spec Kit project and report health issues across structure, agents, features, scripts, extensions, and git | `visibility` | Read-only | [spec-kit-doctor](https://github.com/KhawarHabibKhan/spec-kit-doctor) | | Project Status | Show current SDD workflow progress — active feature, artifact status, task completion, workflow phase, and extensions summary | `visibility` | Read-only | [spec-kit-status](https://github.com/KhawarHabibKhan/spec-kit-status) | | QA Testing Extension | Systematic QA testing with browser-driven or CLI-based validation of acceptance criteria from spec | `code` | Read-only | [spec-kit-qa](https://github.com/arunt14/spec-kit-qa) | diff --git a/extensions/catalog.community.json b/extensions/catalog.community.json index 9cdfeb72a5..5444838c6a 100644 --- a/extensions/catalog.community.json +++ b/extensions/catalog.community.json @@ -3679,6 +3679,46 @@ "created_at": "2026-08-17T00:00:00Z", "updated_at": "2026-09-08T00:00:00Z" }, + "pdac-product": { + "name": "ProductShape PRODUCT workflows", + "id": "pdac-product", + "description": "Author, refine, validate, apply, archive and recover ProductShape product meaning in a separate Spec Kit lane.", + "author": "Juan G. Carmona (@juangcarmona)", + "version": "0.1.0", + "download_url": "https://github.com/juangcarmona/productshape/releases/download/speckit-pdac-product-v0.1.0/speckit-pdac-product.zip", + "repository": "https://github.com/juangcarmona/productshape", + "homepage": "https://pdac.dev", + "documentation": "https://github.com/juangcarmona/productshape/blob/main/extensions/speckit-pdac-product/README.md", + "changelog": "https://github.com/juangcarmona/productshape/blob/main/extensions/speckit-pdac-product/CHANGELOG.md", + "license": "Apache-2.0", + "category": "process", + "effect": "read-write", + "requires": { + "speckit_version": ">=1.0.4", + "tools": [ + { + "name": "prodshape", + "version": ">=0.19.0", + "required": true + } + ] + }, + "provides": { + "commands": 6, + "hooks": 0 + }, + "tags": [ + "product", + "product-change", + "recovery", + "pdac" + ], + "verified": false, + "downloads": 0, + "stars": 0, + "created_at": "2026-09-08T00:00:00Z", + "updated_at": "2026-09-08T00:00:00Z" + }, "plan-review-gate": { "name": "Plan Review Gate", "id": "plan-review-gate", From f98d87dd49ad089b34b025c94e6eb05c2f3a96e9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=B0=8F=E7=94=BB=E5=AE=B6?= Date: Thu, 10 Sep 2026 02:01:31 +0800 Subject: [PATCH 32/44] fix: preserve extension authors in generated skills (#4459) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix: preserve extension authors in generated skills Carry extension author metadata through skill registration, aliases, and restoration after preset removal. Keep the existing default for core skills and extensions without an author. Assisted-by: OpenAI Codex (model: GPT-6, autonomous) * test: accept author in extension registrar stub Assisted-by: OpenAI Codex (model: GPT-6, autonomous) * docs: place author parameter description on registrar Assisted-by: OpenAI Codex (model: GPT-6, autonomous) * fix: normalize extension skill authors Assisted-by: OpenAI Codex (model: GPT-5, autonomous) --------- Co-authored-by: 胥寅 --- src/specify_cli/agents.py | 16 ++++++++- src/specify_cli/extensions/__init__.py | 3 ++ src/specify_cli/presets/__init__.py | 2 ++ tests/test_extension_skills.py | 46 ++++++++++++++++++++++++++ tests/test_extensions.py | 1 + tests/test_presets.py | 3 ++ 6 files changed, 70 insertions(+), 1 deletion(-) diff --git a/src/specify_cli/agents.py b/src/specify_cli/agents.py index dede50e0b1..b5f018b89a 100644 --- a/src/specify_cli/agents.py +++ b/src/specify_cli/agents.py @@ -402,6 +402,7 @@ def render_skill_command( source_file: str, project_root: Path, extension_id: Optional[str] = None, + author: object = "github-spec-kit", ) -> str: """Render a command override as a SKILL.md file. @@ -432,6 +433,7 @@ def render_skill_command( skill_name, description, f"{source_id}:{source_file}", + author=author, ) return self.render_frontmatter(skill_frontmatter) + "\n" + body @@ -441,14 +443,18 @@ def build_skill_frontmatter( skill_name: str, description: str, source: str, + author: object = "github-spec-kit", ) -> dict: """Build consistent SKILL.md frontmatter across all skill generators.""" + normalized_author = ( + "github-spec-kit" if author is None or author == "" else str(author) + ) skill_frontmatter = { "name": skill_name, "description": description, "compatibility": "Requires spec-kit project structure with .specify/ directory", "metadata": { - "author": "github-spec-kit", + "author": normalized_author, "source": source, }, } @@ -618,6 +624,7 @@ def register_commands( _resolved_dir: Optional[Path] = None, link_outputs: bool = False, extension_id: Optional[str] = None, + author: object = "github-spec-kit", ) -> List[str]: """Register commands for a specific agent. @@ -636,6 +643,7 @@ def register_commands( dev cache and symlink the agent command file to it. Falls back to a normal file write when symlinks are unavailable. extension_id: Extension id when rendering extension-owned commands. + author: Author attributed in generated skill metadata. Returns: List of registered command names @@ -802,6 +810,7 @@ def register_commands( cmd_file, project_root, extension_id=extension_id, + author=author, ) elif agent_config["format"] == "markdown": body = self.resolve_skill_placeholders( @@ -888,6 +897,7 @@ def register_commands( cmd_file, project_root, extension_id=extension_id, + author=author, ) elif agent_config["format"] == "markdown": alias_output = self.render_markdown_command( @@ -921,6 +931,7 @@ def register_commands( cmd_file, project_root, extension_id=extension_id, + author=author, ) alias_file = ( @@ -1060,6 +1071,7 @@ def register_commands_for_all_agents( create_missing_active_skills_dir: bool = False, extension_id: Optional[str] = None, only_agent: Optional[str] = None, + author: object = "github-spec-kit", ) -> Dict[str, List[str]]: """Register commands for all detected agents in the project. @@ -1077,6 +1089,7 @@ def register_commands_for_all_agents( skills directory) and is skipped when safe resolution or creation fails. extension_id: Extension id when rendering extension-owned commands. + author: Author attributed in generated skill metadata. only_agent: If set, restrict registration to this single agent while keeping all detection and recovery safeguards (#2948). @@ -1184,6 +1197,7 @@ def register_commands_for_all_agents( _resolved_dir=agent_dir, link_outputs=link_outputs, extension_id=extension_id, + author=author, ) if registered: results[agent_name] = registered diff --git a/src/specify_cli/extensions/__init__.py b/src/specify_cli/extensions/__init__.py index a440b6da9b..cb78f0ea88 100644 --- a/src/specify_cli/extensions/__init__.py +++ b/src/specify_cli/extensions/__init__.py @@ -1687,6 +1687,7 @@ def _replacement(match: re.Match[str]) -> str: skill_name, description, f"extension:{manifest.id}", + author=manifest.data["extension"].get("author"), ) # Preserve the command's argument-hint in the generated skill, # mirroring the core template path (ClaudeIntegration.setup injects @@ -3615,6 +3616,7 @@ def register_commands_for_agent( context_note=context_note, link_outputs=link_outputs, extension_id=manifest.id, + author=manifest.data["extension"].get("author"), ) def register_commands_for_all_agents( @@ -3638,6 +3640,7 @@ def register_commands_for_all_agents( create_missing_active_skills_dir=create_missing_active_skills_dir, only_agent=only_agent, extension_id=manifest.id, + author=manifest.data["extension"].get("author"), ) def unregister_commands( diff --git a/src/specify_cli/presets/__init__.py b/src/specify_cli/presets/__init__.py index abc63299c2..2c6470be52 100644 --- a/src/specify_cli/presets/__init__.py +++ b/src/specify_cli/presets/__init__.py @@ -2894,6 +2894,7 @@ def _build_extension_skill_restore_index(self) -> Dict[str, Dict[str, Any]]: "command_name": cmd_name, "source_file": source_file, "source": f"extension:{manifest.id}", + "author": manifest.data["extension"].get("author"), "extension_id": manifest.id, "extension_dir": ext_root, } @@ -3805,6 +3806,7 @@ def _unregister_skills_in_dir( skill_name, frontmatter.get("description", f"Extension command: {command_name}"), extension_restore["source"], + author=extension_restore.get("author", "github-spec-kit"), ) registrar.apply_argument_hint(frontmatter, frontmatter_data, integration) frontmatter_text = dump_frontmatter(frontmatter_data) diff --git a/tests/test_extension_skills.py b/tests/test_extension_skills.py index a0a32f4d29..3622fd994e 100644 --- a/tests/test_extension_skills.py +++ b/tests/test_extension_skills.py @@ -445,6 +445,52 @@ def test_skill_md_content_correct(self, skills_project, extension_dir): assert "compatibility:" in content assert "Run this to say hello." in content + @pytest.mark.parametrize("register_commands", [False, True]) + @pytest.mark.parametrize("link_commands", [False, True]) + @pytest.mark.parametrize( + ("author", "expected_author"), + [ + ("acme-corp", "acme-corp"), + ('Acme: "Platform"\nTeam', 'Acme: "Platform"\nTeam'), + (None, "github-spec-kit"), + ("", "github-spec-kit"), + (123, "123"), + (0, "0"), + (False, "False"), + ], + ) + def test_extension_author_preserved( + self, + skills_project, + extension_dir, + register_commands, + link_commands, + author, + expected_author, + ): + """Both skill generators retain attribution, including dev output and aliases.""" + project_dir, skills_dir = skills_project + manifest_path = extension_dir / "extension.yml" + data = yaml.safe_load(manifest_path.read_text()) + if author is not None: + data["extension"]["author"] = author + data["provides"]["commands"][0]["aliases"] = ["speckit.test-ext.greet"] + manifest_path.write_text(yaml.safe_dump(data)) + + ExtensionManager(project_dir).install_from_directory( + extension_dir, "0.1.0", + register_commands=register_commands, link_commands=link_commands, + ) + + names = ["hello", "world"] + if register_commands: + names.append("greet") + for name in names: + content = (skills_dir / f"speckit-test-ext-{name}" / "SKILL.md").read_text() + frontmatter = yaml.safe_load(content.split("---", 2)[1]) + assert frontmatter["metadata"]["author"] == expected_author + assert "test-ext" in frontmatter["metadata"]["source"] + def test_skill_md_has_parseable_yaml(self, skills_project, extension_dir): """Generated SKILL.md should contain valid, parseable YAML frontmatter.""" project_dir, skills_dir = skills_project diff --git a/tests/test_extensions.py b/tests/test_extensions.py index aec32dc4ba..b38554cb3c 100644 --- a/tests/test_extensions.py +++ b/tests/test_extensions.py @@ -1746,6 +1746,7 @@ def fake_register_all( create_missing_active_skills_dir=False, extension_id=None, only_agent=None, + author="github-spec-kit", ): captured["create_missing_active_skills_dir"] = ( create_missing_active_skills_dir diff --git a/tests/test_presets.py b/tests/test_presets.py index 57a70b4192..33e74d2c2d 100644 --- a/tests/test_presets.py +++ b/tests/test_presets.py @@ -6328,6 +6328,7 @@ def test_extension_skill_restored_on_preset_remove(self, project_dir, temp_dir): "extension": { "id": "fakeext", "name": "Fake Extension", + "author": "acme-corp", "version": "1.0.0", "description": "Test", }, @@ -6394,6 +6395,8 @@ def test_extension_skill_restored_on_preset_remove(self, project_dir, temp_dir): assert "Read agents/control" not in content assert "# Fakeext Cmd Skill" in content + assert yaml.safe_load(content.split("---", 2)[1])["metadata"]["author"] == "acme-corp" + def test_skill_composed_over_extension_base_rewrites_subdir_paths( self, project_dir, temp_dir ): From f43768d2f843c7073c68ca127c9ae1cc32735af2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=B0=8F=E7=94=BB=E5=AE=B6?= Date: Thu, 10 Sep 2026 02:16:07 +0800 Subject: [PATCH 33/44] feat(workflows): add per-step integration configuration (#4425) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat(workflows): add per-step integration configuration Assisted-by: OpenAI Codex (model: GPT-5, autonomous) * fix(workflows): address per-step integration review feedback Assisted-by: OpenAI Codex (model: GPT-5, autonomous) * fix(workflows): let per-step Docker Agent args override legacy config Assisted-by: OpenAI Codex (model: GPT-6, autonomous) --------- Co-authored-by: 胥寅 --- docs/reference/workflows.md | 38 ++ src/specify_cli/integrations/agy/__init__.py | 4 + src/specify_cli/integrations/base.py | 51 ++- .../integrations/codex/__init__.py | 6 + .../integrations/copilot/__init__.py | 9 + .../integrations/cursor_agent/__init__.py | 6 + .../integrations/devin/__init__.py | 6 + .../integrations/docker_agent/__init__.py | 118 +++++- .../integrations/droid/__init__.py | 6 + src/specify_cli/integrations/dsh/__init__.py | 6 + .../integrations/goose/__init__.py | 6 + src/specify_cli/integrations/grok/__init__.py | 6 + .../integrations/hermes/__init__.py | 4 + src/specify_cli/integrations/muse/__init__.py | 6 + src/specify_cli/integrations/omp/__init__.py | 6 + .../integrations/opencode/__init__.py | 6 + .../integrations/rovodev/__init__.py | 4 + src/specify_cli/workflows/base.py | 3 + src/specify_cli/workflows/engine.py | 6 + .../workflows/steps/command/__init__.py | 115 +++++- tests/integrations/test_base.py | 32 ++ .../test_integration_docker_agent.py | 145 ++++++++ tests/test_workflows.py | 341 ++++++++++++++++++ workflows/README.md | 33 ++ 24 files changed, 936 insertions(+), 27 deletions(-) diff --git a/docs/reference/workflows.md b/docs/reference/workflows.md index e2deaf181c..a547a10e42 100644 --- a/docs/reference/workflows.md +++ b/docs/reference/workflows.md @@ -540,6 +540,44 @@ specify workflow run speckit -i spec="Build a kanban board with drag-and-drop ta > **Security note:** a `shell` step runs a local command with **your** privileges. There is no capability sandbox — `requires` is an advisory pre-condition block (spec-kit version, integrations), not a runtime gate, so it does **not** restrict what a step can do. In particular there is no `requires.permissions` capability gate: it is rejected by validation precisely because it would imply a sandbox that does not exist. Review any catalog or downloaded workflow before running it, and use a `gate` step to require explicit approval before sensitive or destructive shell commands. +### Per-Step Integration Configuration + +Command steps may pass structured runtime configuration to integrations that +support it: + +```yaml +- id: implement-with-docker-agent + type: command + command: speckit.implement + integration: docker-agent + integration_args: + - "{{ inputs.agent_config }}" + integration_options: + agent: root + safety: balanced + model: "openai/gpt-5" + input: + args: "{{ inputs.spec }}" +``` + +`integration_args` is an ordered list of strings. `integration_options` is a +mapping with string keys. Values in both fields are resolved with the workflow +expression mechanism and validated by the selected integration; unsupported, +unknown, or malformed values fail with an actionable error. Docker Agent uses +its single positional argument as the agent configuration reference and accepts +`agent` and `safety` as named integration options. Configure its model through +the command step's top-level `model` field. + +When Docker Agent `integration_args` supplies an agent reference for a command +step, it takes precedence over `SPECKIT_INTEGRATION_DOCKER_AGENT_EXTRA_ARGS`; +the entire legacy environment value is ignored for that step. Without a per-step +agent reference, the legacy environment behavior is unchanged. + +Resolved runtime configuration is recorded in workflow run state. When a failed +or paused command is resumed, the complete dispatch configuration is re-resolved +from the current inputs, so values supplied with `workflow resume --input` take +effect consistently. A resume without updated inputs reproduces the same values. + ## Expressions Steps can reference inputs and previous step outputs using `{{ expression }}` syntax: diff --git a/src/specify_cli/integrations/agy/__init__.py b/src/specify_cli/integrations/agy/__init__.py index a07415856e..958f922c12 100644 --- a/src/specify_cli/integrations/agy/__init__.py +++ b/src/specify_cli/integrations/agy/__init__.py @@ -6,6 +6,7 @@ from __future__ import annotations import re +from collections.abc import Mapping, Sequence from pathlib import Path from typing import TYPE_CHECKING, Any @@ -87,8 +88,11 @@ def build_exec_args( *, model: str | None = None, output_json: bool = True, + integration_args: Sequence[str] | None = None, + integration_options: Mapping[str, Any] | None = None, ) -> list[str] | None: # agy does not support --model or JSON output; both params are ignored + self.validate_runtime_config(integration_args, integration_options) args = [self._resolve_executable(), "--print", prompt] # Honor SPECKIT_INTEGRATION_AGY_EXTRA_ARGS (operator-supplied flags), # appended after the positional prompt like the devin integration. diff --git a/src/specify_cli/integrations/base.py b/src/specify_cli/integrations/base.py index e58d231d36..529803922e 100644 --- a/src/specify_cli/integrations/base.py +++ b/src/specify_cli/integrations/base.py @@ -21,6 +21,7 @@ import subprocess import sys from abc import ABC +from collections.abc import Mapping, Sequence from dataclasses import dataclass from pathlib import Path from typing import TYPE_CHECKING, Any @@ -245,6 +246,8 @@ def build_exec_args( *, model: str | None = None, output_json: bool = True, + integration_args: Sequence[str] | None = None, + integration_options: Mapping[str, Any] | None = None, ) -> list[str] | None: """Build CLI arguments for non-interactive execution. @@ -254,8 +257,38 @@ def build_exec_args( Subclasses for CLI-based integrations should override this. """ + self.validate_runtime_config(integration_args, integration_options) return None + def validate_runtime_config( + self, + integration_args: Sequence[str] | None = None, + integration_options: Mapping[str, Any] | None = None, + ) -> None: + """Validate per-step CLI configuration for this integration. + + Runtime configuration is deliberately separate from :meth:`options`, + which describes install-time ``--integration-options`` accepted by + ``specify init`` and the integration management commands. Integrations + opt in by overriding this hook and translating the validated values in + :meth:`build_exec_args` (or a custom :meth:`dispatch_command`). + + The default accepts empty configuration for backward compatibility and + rejects non-empty values instead of silently ignoring a misspelled or + unsupported runtime option. + """ + if integration_args: + raise ValueError( + f"Integration {self.key!r} does not support per-step " + "'integration_args'." + ) + if integration_options: + option_names = ", ".join(sorted(str(key) for key in integration_options)) + raise ValueError( + f"Integration {self.key!r} does not support per-step " + f"'integration_options' ({option_names})." + ) + def _resolve_executable(self) -> str: """Return the executable for this integration's CLI tool. @@ -345,6 +378,8 @@ def dispatch_command( model: str | None = None, timeout: int = 600, stream: bool = True, + integration_args: Sequence[str] | None = None, + integration_options: Mapping[str, Any] | None = None, ) -> dict[str, Any]: """Dispatch a Spec Kit command through this integration's CLI. @@ -365,11 +400,16 @@ def dispatch_command( """ import subprocess + self.validate_runtime_config(integration_args, integration_options) prompt = self.build_command_invocation(command_name, args) # When streaming to the terminal, request text output so the # user sees readable output instead of raw JSONL events. exec_args = self.build_exec_args( - prompt, model=model, output_json=not stream + prompt, + model=model, + output_json=not stream, + integration_args=integration_args, + integration_options=integration_options, ) if exec_args is None: @@ -1025,7 +1065,10 @@ def build_exec_args( *, model: str | None = None, output_json: bool = True, + integration_args: Sequence[str] | None = None, + integration_options: Mapping[str, Any] | None = None, ) -> list[str] | None: + self.validate_runtime_config(integration_args, integration_options) if not self.config or not self.config.get("requires_cli"): return None args = [self._resolve_executable(), "-p", prompt] @@ -1116,7 +1159,10 @@ def build_exec_args( *, model: str | None = None, output_json: bool = True, + integration_args: Sequence[str] | None = None, + integration_options: Mapping[str, Any] | None = None, ) -> list[str] | None: + self.validate_runtime_config(integration_args, integration_options) if not self.config or not self.config.get("requires_cli"): return None args = [self._resolve_executable(), "-p", prompt] @@ -1585,7 +1631,10 @@ def build_exec_args( *, model: str | None = None, output_json: bool = True, + integration_args: Sequence[str] | None = None, + integration_options: Mapping[str, Any] | None = None, ) -> list[str] | None: + self.validate_runtime_config(integration_args, integration_options) if not self.config or not self.config.get("requires_cli"): return None args = [self._resolve_executable(), "-p", prompt] diff --git a/src/specify_cli/integrations/codex/__init__.py b/src/specify_cli/integrations/codex/__init__.py index 2ffa59ca4b..8d80e0537f 100644 --- a/src/specify_cli/integrations/codex/__init__.py +++ b/src/specify_cli/integrations/codex/__init__.py @@ -6,6 +6,9 @@ from __future__ import annotations +from collections.abc import Mapping, Sequence +from typing import Any + from ..base import IntegrationOption, SkillsIntegration @@ -46,10 +49,13 @@ def build_exec_args( *, model: str | None = None, output_json: bool = True, + integration_args: Sequence[str] | None = None, + integration_options: Mapping[str, Any] | None = None, ) -> list[str] | None: # Codex uses ``codex exec "prompt"`` for non-interactive mode. # Resolve argv[0] via the shared executable resolver so operators can # override the binary with SPECKIT_INTEGRATION_CODEX_EXECUTABLE. + self.validate_runtime_config(integration_args, integration_options) args: list[str] = [self._resolve_executable(), "exec", prompt] self._apply_extra_args_env_var(args) if model: diff --git a/src/specify_cli/integrations/copilot/__init__.py b/src/specify_cli/integrations/copilot/__init__.py index 0a9b4e1591..74c6589bb8 100644 --- a/src/specify_cli/integrations/copilot/__init__.py +++ b/src/specify_cli/integrations/copilot/__init__.py @@ -18,6 +18,8 @@ from __future__ import annotations +from collections.abc import Mapping, Sequence + import json import os import shutil @@ -305,7 +307,10 @@ def build_exec_args( *, model: str | None = None, output_json: bool = True, + integration_args: Sequence[str] | None = None, + integration_options: Mapping[str, Any] | None = None, ) -> list[str] | None: + self.validate_runtime_config(integration_args, integration_options) # GitHub Copilot CLI uses ``copilot -p "prompt"`` for # non-interactive mode. --yolo enables all permissions # (tools, paths, and URLs) so the agent can perform file @@ -348,6 +353,8 @@ def dispatch_command( model: str | None = None, timeout: int = 600, stream: bool = True, + integration_args: Sequence[str] | None = None, + integration_options: Mapping[str, Any] | None = None, ) -> dict[str, Any]: """Dispatch via ``--agent speckit.`` instead of slash-commands. @@ -360,6 +367,8 @@ def dispatch_command( """ import subprocess + self.validate_runtime_config(integration_args, integration_options) + stem = command_name if stem.startswith("speckit."): stem = stem[len("speckit."):] diff --git a/src/specify_cli/integrations/cursor_agent/__init__.py b/src/specify_cli/integrations/cursor_agent/__init__.py index 45c5522a08..eb28286a9a 100644 --- a/src/specify_cli/integrations/cursor_agent/__init__.py +++ b/src/specify_cli/integrations/cursor_agent/__init__.py @@ -12,6 +12,9 @@ from __future__ import annotations +from collections.abc import Mapping, Sequence +from typing import Any + from ..base import IntegrationOption, SkillsIntegration @@ -63,6 +66,8 @@ def build_exec_args( *, model: str | None = None, output_json: bool = True, + integration_args: Sequence[str] | None = None, + integration_options: Mapping[str, Any] | None = None, ) -> list[str] | None: """Build CLI arguments for non-interactive ``cursor-agent`` execution. @@ -94,6 +99,7 @@ def build_exec_args( either drops tool calls or exits non-zero on the first approval prompt. """ + self.validate_runtime_config(integration_args, integration_options) args = [ self._resolve_executable(), "-p", diff --git a/src/specify_cli/integrations/devin/__init__.py b/src/specify_cli/integrations/devin/__init__.py index 4807365346..7391aaea05 100644 --- a/src/specify_cli/integrations/devin/__init__.py +++ b/src/specify_cli/integrations/devin/__init__.py @@ -10,6 +10,9 @@ from __future__ import annotations +from collections.abc import Mapping, Sequence +from typing import Any + from ..base import IntegrationOption, SkillsIntegration @@ -58,6 +61,8 @@ def build_exec_args( *, model: str | None = None, output_json: bool = True, + integration_args: Sequence[str] | None = None, + integration_options: Mapping[str, Any] | None = None, ) -> list[str] | None: """Build non-interactive CLI args for Devin for Terminal. @@ -68,6 +73,7 @@ def build_exec_args( stdout instead of structured JSON. ``requires_cli=True`` is kept on the integration for tool detection. """ + self.validate_runtime_config(integration_args, integration_options) args = [self._resolve_executable(), "-p", prompt] self._apply_extra_args_env_var(args) if model: diff --git a/src/specify_cli/integrations/docker_agent/__init__.py b/src/specify_cli/integrations/docker_agent/__init__.py index d962f1f994..c823dc96a7 100644 --- a/src/specify_cli/integrations/docker_agent/__init__.py +++ b/src/specify_cli/integrations/docker_agent/__init__.py @@ -9,6 +9,8 @@ import os import shlex +from collections.abc import Mapping, Sequence +from typing import Any from specify_cli._utils import docker_agent_command @@ -37,6 +39,12 @@ class DockerAgentIntegration(SkillsIntegration): # Keep co-installation opt-in until shared manifest ownership is supported. multi_install_safe = False + _RUNTIME_OPTION_FLAGS = { + "agent": "--agent", + "safety": "--safety", + } + _SAFETY_MODES = {"strict", "balanced", "restricted", "autonomous"} + # Docker Agent hooks are configured in the selected agent YAML under # ``agents..hooks``. Spec Kit does not edit that user-owned file, so # hooks are intentionally not exposed through the integration event system. @@ -56,7 +64,6 @@ def _agent_command(self) -> list[str]: return [executable, "run"] return command - @classmethod def options(cls) -> list[IntegrationOption]: opts = super().options() @@ -76,39 +83,55 @@ def build_exec_args( *, model: str | None = None, output_json: bool = True, + integration_args: Sequence[str] | None = None, + integration_options: Mapping[str, Any] | None = None, ) -> list[str] | None: """Build a headless Docker Agent invocation with an agent config.""" + self.validate_runtime_config(integration_args, integration_options) + runtime_args = list(integration_args or ()) extra_env_name = "SPECKIT_INTEGRATION_DOCKER_AGENT_EXTRA_ARGS" extra_args = os.environ.get(extra_env_name, "").strip() - if not extra_args: + if not runtime_args and not extra_args: raise ValueError( "Docker Agent requires an agent configuration reference. " - f"Set {extra_env_name}, for example: " - f"{extra_env_name}=./agent.yaml" + "Set per-step 'integration_args', for example " + "integration_args: ['./agent.yaml'], or use the legacy " + f"{extra_env_name}=./agent.yaml environment variable." ) # Validate only the argument shape here: require a first positional # agent reference and reject malformed quoting or a leading option. # The reference may be a local file or a registry reference, so its # existence and validity are intentionally left to Docker Agent. - try: - first_arg = shlex.split(extra_args)[0] - except (IndexError, ValueError) as exc: - raise ValueError( - f"{extra_env_name} must start with an agent configuration reference, " - "for example ./agent.yaml" - ) from exc - if first_arg.startswith("-"): - raise ValueError( - f"{extra_env_name} must start with an agent configuration reference, " - "for example ./agent.yaml" - ) + legacy_args: list[str] = [] + # Per-step arguments are authoritative; ignore the entire legacy value, + # including malformed quoting, when an agent reference is supplied. + if not runtime_args: + try: + legacy_args = shlex.split(extra_args) + except ValueError as exc: + raise ValueError( + f"{extra_env_name} is not parseable as a POSIX-quoted " + f"command line (value: {extra_args!r})." + ) from exc + + if not legacy_args: + raise ValueError( + f"{extra_env_name} must start with an agent configuration " + "reference, for example ./agent.yaml" + ) + first_arg = legacy_args[0] + if not first_arg or first_arg.startswith("-"): + raise ValueError( + f"{extra_env_name} must start with an agent configuration " + "reference, for example ./agent.yaml" + ) args = [*self._agent_command(), "--exec"] - # Extra args carry the required agent source (for example - # ``./agent.yaml``) and any Docker Agent CLI flags. The shared helper - # also preserves shell-style quoting when splitting multiple args. - self._apply_extra_args_env_var(args) + args.extend(runtime_args or legacy_args) + + for option, value in (integration_options or {}).items(): + args.extend([self._RUNTIME_OPTION_FLAGS[option], value]) if output_json: args.append("--json") @@ -124,3 +147,58 @@ def build_exec_args( args.extend(["--", prompt]) return args + + def validate_runtime_config( + self, + integration_args: Sequence[str] | None = None, + integration_options: Mapping[str, Any] | None = None, + ) -> None: + """Validate Docker Agent's per-step agent reference and CLI options.""" + runtime_args = list(integration_args or ()) + if not all(isinstance(value, str) and value.strip() for value in runtime_args): + raise ValueError( + "Docker Agent 'integration_args' values must be non-empty strings." + ) + if len(runtime_args) > 1: + raise ValueError( + "Docker Agent accepts at most one per-step 'integration_args' " + "value: the agent configuration reference." + ) + if runtime_args and runtime_args[0].startswith("-"): + raise ValueError( + "Docker Agent 'integration_args' must start with an agent " + "configuration reference, for example ./agent.yaml." + ) + + options = integration_options or {} + if not all(isinstance(name, str) for name in options): + raise ValueError( + "Docker Agent 'integration_options' keys must be strings." + ) + if "model" in options: + raise ValueError( + "Docker Agent model selection must use the command-step " + "'model' field, not 'integration_options.model'." + ) + unknown = sorted(set(options) - self._RUNTIME_OPTION_FLAGS.keys()) + if unknown: + names = ", ".join(repr(name) for name in unknown) + allowed = ", ".join(sorted(self._RUNTIME_OPTION_FLAGS)) + raise ValueError( + f"Docker Agent received unknown integration option(s): {names}. " + f"Supported options: {allowed}." + ) + + for name, value in options.items(): + if not isinstance(value, str) or not value.strip(): + raise ValueError( + f"Docker Agent integration option {name!r} must be a " + "non-empty string." + ) + safety = options.get("safety") + if safety is not None and safety not in self._SAFETY_MODES: + allowed = ", ".join(sorted(self._SAFETY_MODES)) + raise ValueError( + f"Docker Agent integration option 'safety' must be one of: " + f"{allowed}." + ) diff --git a/src/specify_cli/integrations/droid/__init__.py b/src/specify_cli/integrations/droid/__init__.py index d6a5c084ae..7e41d76b02 100644 --- a/src/specify_cli/integrations/droid/__init__.py +++ b/src/specify_cli/integrations/droid/__init__.py @@ -10,6 +10,9 @@ from __future__ import annotations +from collections.abc import Mapping, Sequence +from typing import Any + from ..base import SkillsIntegration @@ -90,6 +93,8 @@ def build_exec_args( *, model: str | None = None, output_json: bool = True, + integration_args: Sequence[str] | None = None, + integration_options: Mapping[str, Any] | None = None, ) -> list[str] | None: """Build CLI arguments for non-interactive ``droid`` execution. @@ -109,6 +114,7 @@ def build_exec_args( canonical one, so operators can still override ``--model`` or ``--output-format``. """ + self.validate_runtime_config(integration_args, integration_options) if not self.config or not self.config.get("requires_cli"): return None args = [ diff --git a/src/specify_cli/integrations/dsh/__init__.py b/src/specify_cli/integrations/dsh/__init__.py index 9b533dec99..381d4c08ce 100644 --- a/src/specify_cli/integrations/dsh/__init__.py +++ b/src/specify_cli/integrations/dsh/__init__.py @@ -16,6 +16,9 @@ from __future__ import annotations +from collections.abc import Mapping, Sequence +from typing import Any + from ..base import SkillsIntegration @@ -46,6 +49,8 @@ def build_exec_args( *, model: str | None = None, output_json: bool = True, + integration_args: Sequence[str] | None = None, + integration_options: Mapping[str, Any] | None = None, ) -> list[str] | None: """Build non-interactive CLI args for DSH. @@ -58,6 +63,7 @@ def build_exec_args( an interactive session would. The CLI has no structured-JSON output flag, so ``output_json`` and ``model`` are ignored. """ + self.validate_runtime_config(integration_args, integration_options) args = [self._resolve_executable(), "--profile", "headless"] self._apply_extra_args_env_var(args) args.append(prompt) diff --git a/src/specify_cli/integrations/goose/__init__.py b/src/specify_cli/integrations/goose/__init__.py index caed191b9e..d02537f29a 100644 --- a/src/specify_cli/integrations/goose/__init__.py +++ b/src/specify_cli/integrations/goose/__init__.py @@ -2,6 +2,9 @@ from __future__ import annotations +from collections.abc import Mapping, Sequence +from typing import Any + from ..base import YamlIntegration @@ -27,6 +30,8 @@ def build_exec_args( *, model: str | None = None, output_json: bool = True, + integration_args: Sequence[str] | None = None, + integration_options: Mapping[str, Any] | None = None, ) -> list[str] | None: """Build CLI arguments for non-interactive ``goose`` execution. @@ -50,6 +55,7 @@ def build_exec_args( the agent's own resolver. Any other prompt -- including Goose's own session commands such as ``/help`` or ``/plan`` -- goes to ``-t``. """ + self.validate_runtime_config(integration_args, integration_options) args = [self._resolve_executable(), "run"] # Extra args are applied first, matching the opencode / codex / # cursor-agent ordering. Positional parity only, NOT precedence: diff --git a/src/specify_cli/integrations/grok/__init__.py b/src/specify_cli/integrations/grok/__init__.py index 781e17298e..ca078a30d3 100644 --- a/src/specify_cli/integrations/grok/__init__.py +++ b/src/specify_cli/integrations/grok/__init__.py @@ -7,6 +7,9 @@ from __future__ import annotations +from collections.abc import Mapping, Sequence +from typing import Any + from ..base import SkillsIntegration @@ -35,6 +38,8 @@ def build_exec_args( *, model: str | None = None, output_json: bool = True, + integration_args: Sequence[str] | None = None, + integration_options: Mapping[str, Any] | None = None, ) -> list[str] | None: """Build CLI arguments for non-interactive ``grok`` execution. @@ -44,6 +49,7 @@ def build_exec_args( dispatch and ``dispatch_command()`` are not blocked at permission gates (same role as Cursor's ``--force`` / Copilot's ``--yolo``). """ + self.validate_runtime_config(integration_args, integration_options) if not self.config or not self.config.get("requires_cli"): return None args = [ diff --git a/src/specify_cli/integrations/hermes/__init__.py b/src/specify_cli/integrations/hermes/__init__.py index a82eb6fd4d..1954d968f8 100644 --- a/src/specify_cli/integrations/hermes/__init__.py +++ b/src/specify_cli/integrations/hermes/__init__.py @@ -12,6 +12,7 @@ from __future__ import annotations +from collections.abc import Mapping, Sequence from pathlib import Path from shutil import rmtree from typing import Any @@ -272,6 +273,8 @@ def build_exec_args( *, model: str | None = None, output_json: bool = True, + integration_args: Sequence[str] | None = None, + integration_options: Mapping[str, Any] | None = None, ) -> list[str] | None: """Build Hermes CLI invocation for programmatic dispatch. @@ -279,6 +282,7 @@ def build_exec_args( mapping slash-command invocations to the appropriate skill-based dispatch. """ + self.validate_runtime_config(integration_args, integration_options) args = [self._resolve_executable(), "chat", "-Q"] # Operator-supplied SPECKIT_INTEGRATION_HERMES_EXTRA_ARGS go here — diff --git a/src/specify_cli/integrations/muse/__init__.py b/src/specify_cli/integrations/muse/__init__.py index e812ae386b..14d9d53da0 100644 --- a/src/specify_cli/integrations/muse/__init__.py +++ b/src/specify_cli/integrations/muse/__init__.py @@ -9,6 +9,9 @@ from __future__ import annotations +from collections.abc import Mapping, Sequence +from typing import Any + from ..base import IntegrationOption, SkillsIntegration @@ -50,10 +53,13 @@ def build_exec_args( *, model: str | None = None, output_json: bool = True, + integration_args: Sequence[str] | None = None, + integration_options: Mapping[str, Any] | None = None, ) -> 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. + self.validate_runtime_config(integration_args, integration_options) args: list[str] = [self._resolve_executable(), "exec", prompt] self._apply_extra_args_env_var(args) if model: diff --git a/src/specify_cli/integrations/omp/__init__.py b/src/specify_cli/integrations/omp/__init__.py index 0a93237d4f..2cf2f7b03f 100644 --- a/src/specify_cli/integrations/omp/__init__.py +++ b/src/specify_cli/integrations/omp/__init__.py @@ -2,6 +2,9 @@ from __future__ import annotations +from collections.abc import Mapping, Sequence +from typing import Any + from ..base import MarkdownIntegration @@ -28,6 +31,8 @@ def build_exec_args( *, model: str | None = None, output_json: bool = True, + integration_args: Sequence[str] | None = None, + integration_options: Mapping[str, Any] | None = None, ) -> list[str] | None: # Diverges from MarkdownIntegration.build_exec_args because OMP's # CLI parser treats `-p`/`--print` as a boolean (one-shot mode) and @@ -35,6 +40,7 @@ def build_exec_args( # can1357/oh-my-pi. JSON output is selected via `--mode json`. if not self.config or not self.config.get("requires_cli"): return None + self.validate_runtime_config(integration_args, integration_options) args = [self._resolve_executable(), "--print"] self._apply_extra_args_env_var(args) if model: diff --git a/src/specify_cli/integrations/opencode/__init__.py b/src/specify_cli/integrations/opencode/__init__.py index 007c1187bb..cf7041865b 100644 --- a/src/specify_cli/integrations/opencode/__init__.py +++ b/src/specify_cli/integrations/opencode/__init__.py @@ -1,5 +1,8 @@ """opencode integration.""" +from collections.abc import Mapping, Sequence +from typing import Any + from ..base import MarkdownIntegration @@ -43,7 +46,10 @@ def build_exec_args( *, model: str | None = None, output_json: bool = True, + integration_args: Sequence[str] | None = None, + integration_options: Mapping[str, Any] | None = None, ) -> list[str] | None: + self.validate_runtime_config(integration_args, integration_options) args = [self._resolve_executable(), "run"] # Apply operator-injected extra args before the prompt-derived # --command and the canonical --format/-m flags so Spec Kit's diff --git a/src/specify_cli/integrations/rovodev/__init__.py b/src/specify_cli/integrations/rovodev/__init__.py index fe0fcb30b6..f8ba459100 100644 --- a/src/specify_cli/integrations/rovodev/__init__.py +++ b/src/specify_cli/integrations/rovodev/__init__.py @@ -8,6 +8,7 @@ from __future__ import annotations import os +from collections.abc import Mapping, Sequence from pathlib import Path from typing import Any @@ -63,6 +64,8 @@ def build_exec_args( *, model: str | None = None, output_json: bool = True, + integration_args: Sequence[str] | None = None, + integration_options: Mapping[str, Any] | None = None, ) -> list[str] | None: """Build non-interactive ACLI args for RovoDev. @@ -79,6 +82,7 @@ def build_exec_args( - ``SPECKIT_INTEGRATION_ROVODEV_EXTRA_ARGS`` injects extra CLI flags """ _ = model + self.validate_runtime_config(integration_args, integration_options) args = [self._resolve_executable(), "rovodev", "run", prompt] self._apply_extra_args_env_var(args) if output_json: diff --git a/src/specify_cli/workflows/base.py b/src/specify_cli/workflows/base.py index 2466db8b1f..59d8319d4d 100644 --- a/src/specify_cli/workflows/base.py +++ b/src/specify_cli/workflows/base.py @@ -77,6 +77,9 @@ class StepContext: #: Current run ID. run_id: str | None = None + #: Whether the engine is re-executing the current step during resume. + is_resume: bool = False + #: Source directory of the workflow definition file. workflow_dir: str | None = None diff --git a/src/specify_cli/workflows/engine.py b/src/specify_cli/workflows/engine.py index 411c1aac8a..5abcf45d5b 100644 --- a/src/specify_cli/workflows/engine.py +++ b/src/specify_cli/workflows/engine.py @@ -1121,6 +1121,7 @@ def resume( default_options=definition.default_options, project_root=str(self.project_root), run_id=state.run_id, + is_resume=True, workflow_dir=state.workflow_dir, ) @@ -1237,6 +1238,11 @@ def _execute_steps( "status": result.status.value, "error": result.error, } + if step_type == "command" and "integration_args" in result.output: + step_data["integration_args"] = result.output["integration_args"] + step_data["integration_options"] = result.output[ + "integration_options" + ] self._record_result(context, state, step_id, step_data) state.append_log( diff --git a/src/specify_cli/workflows/steps/command/__init__.py b/src/specify_cli/workflows/steps/command/__init__.py index 8ab7770894..f242c74a7d 100644 --- a/src/specify_cli/workflows/steps/command/__init__.py +++ b/src/specify_cli/workflows/steps/command/__init__.py @@ -128,20 +128,44 @@ def execute(self, config: dict[str, Any], context: StepContext) -> StepResult: ) options.update(step_options) - # Attempt CLI dispatch - args_str = str(resolved_input.get("args", "")) - dispatch_result = self._try_dispatch( - command, integration, model, args_str, context - ) + runtime_config = self._resolve_runtime_config(config, context) + if isinstance(runtime_config, str): + return StepResult(status=StepStatus.FAILED, error=runtime_config) + integration_args, integration_options = runtime_config + args_str = str(resolved_input.get("args", "")) output: dict[str, Any] = { "command": command, "integration": integration, "model": model, "options": options, "input": resolved_input, + "integration_args": integration_args, + "integration_options": integration_options, } + # Attempt CLI dispatch. Integration-specific runtime validation occurs + # before executable detection so malformed options remain actionable + # even when the selected CLI is not installed. + try: + dispatch_result = self._try_dispatch( + command, + integration, + model, + args_str, + context, + integration_args, + integration_options, + ) + except ValueError as exc: + output["exit_code"] = 1 + output["dispatched"] = False + return StepResult( + status=StepStatus.FAILED, + output=output, + error=f"Command step {config.get('id', '?')!r}: {exc}", + ) + if dispatch_result is not None: output["exit_code"] = dispatch_result["exit_code"] output["stdout"] = dispatch_result["stdout"] @@ -177,6 +201,8 @@ def _try_dispatch( model: str | None, args: str, context: StepContext, + integration_args: list[str], + integration_options: dict[str, Any], ) -> dict[str, Any] | None: """Invoke *command* by name through the integration CLI. @@ -205,8 +231,14 @@ def _try_dispatch( if impl is None: return None + impl.validate_runtime_config(integration_args, integration_options) + # Build sample args for fallback executable detection when impl.key is not executable. - exec_args = impl.build_exec_args("test") + exec_args = impl.build_exec_args( + "test", + integration_args=integration_args, + integration_options=integration_options, + ) # Check if the CLI tool is actually installed. # Try the integration key first (covers most agents), then fall back @@ -224,10 +256,53 @@ def _try_dispatch( args=args, project_root=project_root, model=model, + integration_args=integration_args, + integration_options=integration_options, ) except (NotImplementedError, OSError): return None + @staticmethod + def _resolve_runtime_config( + config: dict[str, Any], context: StepContext + ) -> tuple[list[str], dict[str, Any]] | str: + """Resolve and validate this step's per-integration runtime config.""" + step_id = config.get("id", "?") + + raw_args = config.get("integration_args", []) + if not isinstance(raw_args, list): + return ( + f"Command step {step_id!r}: 'integration_args' must be a list." + ) + + resolved_args: list[str] = [] + for index, value in enumerate(raw_args): + resolved = evaluate_expression(value, context) + if not isinstance(resolved, str): + return ( + f"Command step {step_id!r}: 'integration_args[{index}]' " + f"must resolve to a string, got {type(resolved).__name__}." + ) + resolved_args.append(resolved) + + raw_options = config.get("integration_options", {}) + if not isinstance(raw_options, dict): + return ( + f"Command step {step_id!r}: 'integration_options' must be a " + "mapping." + ) + if not all(isinstance(key, str) for key in raw_options): + return ( + f"Command step {step_id!r}: 'integration_options' keys must be " + "strings." + ) + + resolved_options = { + key: evaluate_expression(value, context) + for key, value in raw_options.items() + } + return resolved_args, resolved_options + def validate(self, config: dict[str, Any]) -> list[str]: errors = super().validate(config) if "command" not in config: @@ -257,6 +332,34 @@ def validate(self, config: dict[str, Any]) -> list[str]: errors.append( f"Command step {config.get('id', '?')!r}: 'options' must be a mapping." ) + if "integration_args" in config and not isinstance( + config["integration_args"], list + ): + errors.append( + f"Command step {config.get('id', '?')!r}: 'integration_args' " + "must be a list." + ) + elif isinstance(config.get("integration_args"), list): + for index, value in enumerate(config["integration_args"]): + if not isinstance(value, str): + errors.append( + f"Command step {config.get('id', '?')!r}: " + f"'integration_args[{index}]' must be a string." + ) + if "integration_options" in config and not isinstance( + config["integration_options"], dict + ): + errors.append( + f"Command step {config.get('id', '?')!r}: " + "'integration_options' must be a mapping." + ) + elif isinstance(config.get("integration_options"), dict) and not all( + isinstance(key, str) for key in config["integration_options"] + ): + errors.append( + f"Command step {config.get('id', '?')!r}: " + "'integration_options' keys must be strings." + ) # execute() passes 'integration' to get_integration(), which uses it as a # dict key — a non-string (list/dict) raises a raw TypeError (unhashable), # even on a validated run — and feeds 'model' into the CLI argv. Reject a diff --git a/tests/integrations/test_base.py b/tests/integrations/test_base.py index 713cf75523..30938dd7b7 100644 --- a/tests/integrations/test_base.py +++ b/tests/integrations/test_base.py @@ -1,5 +1,6 @@ """Tests for IntegrationOption, IntegrationBase, MarkdownIntegration, and primitives.""" +import inspect import shlex import sys from types import SimpleNamespace @@ -51,6 +52,37 @@ def test_key_and_config(self): def test_options_default_empty(self): assert StubIntegration.options() == [] + def test_runtime_config_default_accepts_empty_and_rejects_non_empty(self): + integration = StubIntegration() + + integration.validate_runtime_config([], {}) + with pytest.raises(ValueError, match="integration_args"): + integration.validate_runtime_config(["./agent.yaml"], {}) + with pytest.raises(ValueError, match="integration_options.*unknown"): + integration.validate_runtime_config([], {"unknown": "value"}) + + def test_all_builtin_exec_builders_accept_runtime_config(self): + from specify_cli.integrations import INTEGRATION_REGISTRY + + for key, integration in INTEGRATION_REGISTRY.items(): + parameters = inspect.signature(integration.build_exec_args).parameters + assert "integration_args" in parameters, key + assert "integration_options" in parameters, key + + def test_unsupported_exec_builders_reject_runtime_config_directly(self): + from specify_cli.integrations import INTEGRATION_REGISTRY + + for integration in INTEGRATION_REGISTRY.values(): + if ( + type(integration).validate_runtime_config + is not IntegrationBase.validate_runtime_config + ): + continue + with pytest.raises(ValueError, match="integration_args"): + integration.build_exec_args( + "prompt", integration_args=["unexpected"] + ) + def test_shared_commands_dir(self): i = StubIntegration() cmd_dir = i.shared_commands_dir() diff --git a/tests/integrations/test_integration_docker_agent.py b/tests/integrations/test_integration_docker_agent.py index d962ba978f..fa2207927f 100644 --- a/tests/integrations/test_integration_docker_agent.py +++ b/tests/integrations/test_integration_docker_agent.py @@ -45,6 +45,144 @@ def test_extra_args_are_applied_to_build_exec_args(monkeypatch): ] +def test_per_step_runtime_config_builds_ordered_argv(monkeypatch): + monkeypatch.delenv("SPECKIT_INTEGRATION_DOCKER_AGENT_EXTRA_ARGS", raising=False) + monkeypatch.setattr( + "shutil.which", + lambda name: "/usr/bin/docker-agent" if name == "docker-agent" else None, + ) + + args = DockerAgentIntegration().build_exec_args( + "prompt", + output_json=False, + integration_args=["./agent.yaml"], + integration_options={ + "agent": "root", + "safety": "balanced", + }, + model="openai/gpt-5", + ) + + assert args == [ + "docker-agent", + "run", + "--exec", + "./agent.yaml", + "--agent", + "root", + "--safety", + "balanced", + "--model", + "openai/gpt-5", + "--", + "prompt", + ] + + +def test_per_step_args_ignore_legacy_extra_flags(monkeypatch): + monkeypatch.setenv( + "SPECKIT_INTEGRATION_DOCKER_AGENT_EXTRA_ARGS", "--hide-tool-results" + ) + monkeypatch.setattr("shutil.which", lambda name: None) + + args = DockerAgentIntegration().build_exec_args( + "prompt", + output_json=False, + integration_args=["./agent.yaml"], + ) + + assert args == [ + "docker-agent", + "run", + "--exec", + "./agent.yaml", + "--", + "prompt", + ] + + +@pytest.mark.parametrize("legacy_value", ["./global.yaml --hide-tool-results", '"broken']) +def test_per_step_args_override_legacy_extra_args(monkeypatch, legacy_value): + monkeypatch.setenv( + "SPECKIT_INTEGRATION_DOCKER_AGENT_EXTRA_ARGS", + legacy_value, + ) + monkeypatch.setattr("shutil.which", lambda name: None) + + args = DockerAgentIntegration().build_exec_args( + "prompt", + output_json=False, + integration_args=["./step.yaml"], + ) + + assert args == [ + "docker-agent", + "run", + "--exec", + "./step.yaml", + "--", + "prompt", + ] + + +def test_command_step_model_is_the_only_model_source(monkeypatch): + monkeypatch.delenv("SPECKIT_INTEGRATION_DOCKER_AGENT_EXTRA_ARGS", raising=False) + monkeypatch.setattr("shutil.which", lambda name: None) + + args = DockerAgentIntegration().build_exec_args( + "prompt", + output_json=False, + model="openai/gpt-5", + integration_args=["./agent.yaml"], + ) + + assert args.count("--model") == 1 + assert args[args.index("--model") + 1] == "openai/gpt-5" + + +def test_integration_options_model_is_rejected(): + with pytest.raises(ValueError, match="command-step 'model' field"): + DockerAgentIntegration().build_exec_args( + "prompt", + integration_args=["./agent.yaml"], + integration_options={"model": "openai/gpt-5"}, + ) + + +@pytest.mark.parametrize( + ("options", "message"), + [ + ({"unknown": "value"}, "unknown integration option"), + ({"agent": ""}, "must be a non-empty string"), + ({"safety": "unsafe"}, "must be one of"), + ], +) +def test_per_step_runtime_options_are_validated(options, message): + with pytest.raises(ValueError, match=message): + DockerAgentIntegration().build_exec_args( + "prompt", + integration_args=["./agent.yaml"], + integration_options=options, + ) + + +def test_per_step_runtime_args_reject_multiple_agent_references(): + with pytest.raises(ValueError, match="at most one"): + DockerAgentIntegration().build_exec_args( + "prompt", + integration_args=["./first.yaml", "./second.yaml"], + ) + + +@pytest.mark.parametrize("integration_args", [[""], [42]]) +def test_per_step_runtime_args_reject_malformed_values(integration_args): + with pytest.raises(ValueError, match="non-empty strings"): + DockerAgentIntegration().build_exec_args( + "prompt", + integration_args=integration_args, + ) + + def test_prompt_is_passed_after_agent_config(monkeypatch): monkeypatch.setenv( "SPECKIT_INTEGRATION_DOCKER_AGENT_EXTRA_ARGS", "./agent.yaml" @@ -85,6 +223,13 @@ def test_requires_agent_config(monkeypatch): DockerAgentIntegration().build_exec_args("prompt", output_json=False) +def test_legacy_agent_config_cannot_resolve_to_empty_token_list(monkeypatch): + monkeypatch.setenv("SPECKIT_INTEGRATION_DOCKER_AGENT_EXTRA_ARGS", '""') + + with pytest.raises(ValueError, match="must start with an agent configuration"): + DockerAgentIntegration().build_exec_args("prompt", output_json=False) + + def test_uses_standalone_executable(monkeypatch): monkeypatch.setenv("SPECKIT_INTEGRATION_DOCKER_AGENT_EXTRA_ARGS", "./agent.yaml") monkeypatch.setattr( diff --git a/tests/test_workflows.py b/tests/test_workflows.py index 980cc21a6e..2c7141e954 100644 --- a/tests/test_workflows.py +++ b/tests/test_workflows.py @@ -1098,6 +1098,200 @@ def test_execute_basic(self): assert result.output["integration"] == "claude" assert result.output["input"]["args"] == "login" + def test_per_step_integration_config_is_resolved_and_isolated( + self, tmp_path, monkeypatch + ): + from specify_cli.workflows.base import StepContext, StepStatus + from specify_cli.workflows.steps.command import CommandStep + + calls = [] + + def fake_run(args, **kwargs): + calls.append((args, kwargs)) + return type("Result", (), {"returncode": 0})() + + monkeypatch.delenv( + "SPECKIT_INTEGRATION_DOCKER_AGENT_EXTRA_ARGS", raising=False + ) + monkeypatch.setattr( + "shutil.which", + lambda name: ( + "/usr/bin/docker-agent" + if name in {"docker-agent", "/usr/bin/docker-agent"} + else None + ), + ) + monkeypatch.setattr("subprocess.run", fake_run) + + step = CommandStep() + context = StepContext( + inputs={"config": "./review.yaml", "agent": "reviewer"}, + project_root=str(tmp_path), + ) + first = step.execute( + { + "id": "review", + "command": "speckit.plan", + "integration": "docker-agent", + "integration_args": ["{{ inputs.config }}"], + "integration_options": { + "agent": "{{ inputs.agent }}", + "safety": "balanced", + }, + }, + context, + ) + second = step.execute( + { + "id": "implement", + "command": "speckit.implement", + "integration": "docker-agent", + "integration_args": ["./implement.yaml"], + "integration_options": {"agent": "implementer"}, + }, + context, + ) + + assert first.status is StepStatus.COMPLETED + assert second.status is StepStatus.COMPLETED + assert first.output["integration_args"] == ["./review.yaml"] + assert first.output["integration_options"] == { + "agent": "reviewer", + "safety": "balanced", + } + assert calls[0][0] == [ + "/usr/bin/docker-agent", + "run", + "--exec", + "./review.yaml", + "--agent", + "reviewer", + "--safety", + "balanced", + "--", + "/speckit-plan", + ] + assert calls[1][0] == [ + "/usr/bin/docker-agent", + "run", + "--exec", + "./implement.yaml", + "--agent", + "implementer", + "--", + "/speckit-implement", + ] + assert calls[0][1]["cwd"] == str(tmp_path) + assert calls[1][1]["cwd"] == str(tmp_path) + + @pytest.mark.parametrize( + ("step_model", "default_model", "expected_model"), + [ + ("step/model", "workflow/model", "step/model"), + (None, "workflow/model", "workflow/model"), + ], + ) + def test_docker_agent_uses_one_effective_model( + self, + tmp_path, + monkeypatch, + step_model, + default_model, + expected_model, + ): + from specify_cli.workflows.base import StepContext, StepStatus + from specify_cli.workflows.steps.command import CommandStep + + calls = [] + + def fake_run(args, **kwargs): + calls.append(args) + return type("Result", (), {"returncode": 0})() + + monkeypatch.delenv( + "SPECKIT_INTEGRATION_DOCKER_AGENT_EXTRA_ARGS", raising=False + ) + monkeypatch.setattr( + "shutil.which", + lambda name: ( + "/usr/bin/docker-agent" + if name in {"docker-agent", "/usr/bin/docker-agent"} + else None + ), + ) + monkeypatch.setattr("subprocess.run", fake_run) + + config = { + "id": "implement", + "command": "speckit.implement", + "integration": "docker-agent", + "integration_args": ["./agent.yaml"], + } + if step_model is not None: + config["model"] = step_model + + result = CommandStep().execute( + config, + StepContext( + default_model=default_model, + project_root=str(tmp_path), + ), + ) + + assert result.status is StepStatus.COMPLETED + assert calls[0].count("--model") == 1 + assert calls[0][calls[0].index("--model") + 1] == expected_model + + def test_unsupported_runtime_config_fails_with_actionable_error(self): + from specify_cli.workflows.base import StepContext, StepStatus + from specify_cli.workflows.steps.command import CommandStep + + result = CommandStep().execute( + { + "id": "review", + "command": "speckit.plan", + "integration": "claude", + "integration_options": {"saftey": "balanced"}, + }, + StepContext(), + ) + + assert result.status is StepStatus.FAILED + assert result.output["dispatched"] is False + assert "does not support per-step 'integration_options'" in ( + result.error or "" + ) + assert "saftey" in (result.error or "") + + def test_validate_rejects_malformed_runtime_config(self): + from specify_cli.workflows.steps.command import CommandStep + + step = CommandStep() + errors = step.validate( + { + "id": "review", + "command": "speckit.plan", + "integration_args": "./agent.yaml", + "integration_options": ["agent", "root"], + } + ) + + assert any("'integration_args' must be a list" in error for error in errors) + assert any( + "'integration_options' must be a mapping" in error for error in errors + ) + + errors = step.validate( + { + "id": "review", + "command": "speckit.plan", + "integration_args": [42], + "integration_options": {1: "root"}, + } + ) + assert any("'integration_args[0]' must be a string" in error for error in errors) + assert any("keys must be strings" in error for error in errors) + def test_try_dispatch_resolves_rovodev_via_acli(self, tmp_path): """When acli is installed, rovodev dispatch succeeds via acli.""" from unittest.mock import patch, MagicMock @@ -11432,6 +11626,57 @@ class TestResumeWithInputs: verdict_input: spec_verdict """ + _WF_RUNTIME_CONFIG = """ +schema_version: "1.0" +workflow: + id: "resume-runtime-config-wf" + name: "Resume Runtime Config WF" + version: "1.0.0" +inputs: + config: + type: string + default: "./initial-agent.yaml" + agent_name: + type: string + default: "initial-root" + model_name: + type: string + default: "initial/model" +steps: + - id: implement + type: command + command: speckit.implement + integration: docker-agent + integration_args: + - "{{ inputs.config }}" + integration_options: + agent: "{{ inputs.agent_name }}" + safety: balanced + model: "{{ inputs.model_name }}" +""" + + _WF_DYNAMIC_INTEGRATION = """ +schema_version: "1.0" +workflow: + id: "resume-dynamic-integration-wf" + name: "Resume Dynamic Integration WF" + version: "1.0.0" +inputs: + integration: + type: string + default: docker-agent + config: + type: string + default: "./initial-agent.yaml" +steps: + - id: implement + type: command + command: speckit.implement + integration: "{{ inputs.integration }}" + integration_args: + - "{{ inputs.config }}" +""" + def _engine(self, project_dir): from specify_cli.workflows.engine import WorkflowEngine return WorkflowEngine(project_dir) @@ -11464,6 +11709,102 @@ def test_resume_without_input_preserves_inputs(self, project_dir): assert resumed.status == RunStatus.FAILED # still "exit 1" assert resumed.inputs["cmd"] == "exit 1" + def test_resume_re_resolves_complete_integration_config( + self, project_dir, monkeypatch + ): + from specify_cli.workflows.base import RunStatus + from specify_cli.workflows.engine import WorkflowDefinition + + calls = [] + return_codes = iter((1, 0)) + + def fake_run(args, **kwargs): + calls.append(args) + return type("Result", (), {"returncode": next(return_codes)})() + + monkeypatch.delenv( + "SPECKIT_INTEGRATION_DOCKER_AGENT_EXTRA_ARGS", raising=False + ) + monkeypatch.setattr( + "shutil.which", + lambda name: ( + "/usr/bin/docker-agent" + if name in {"docker-agent", "/usr/bin/docker-agent"} + else None + ), + ) + monkeypatch.setattr("subprocess.run", fake_run) + + definition = WorkflowDefinition.from_string(self._WF_RUNTIME_CONFIG) + engine = self._engine(project_dir) + state = engine.execute(definition) + + assert state.status is RunStatus.FAILED + assert state.step_results["implement"]["integration_args"] == [ + "./initial-agent.yaml" + ] + assert state.step_results["implement"]["integration_options"] == { + "agent": "initial-root", + "safety": "balanced", + } + + resumed = engine.resume( + state.run_id, + { + "config": "./changed-on-resume.yaml", + "agent_name": "changed-root", + "model_name": "changed/model", + }, + ) + + assert resumed.status is RunStatus.COMPLETED + assert resumed.inputs["config"] == "./changed-on-resume.yaml" + assert len(calls) == 2 + assert "./initial-agent.yaml" in calls[0] + assert "./changed-on-resume.yaml" in calls[1] + assert "./initial-agent.yaml" not in calls[1] + assert calls[1][calls[1].index("--agent") + 1] == "changed-root" + assert calls[1][calls[1].index("--model") + 1] == "changed/model" + + def test_resume_does_not_mix_new_integration_with_old_runtime_config( + self, project_dir, monkeypatch + ): + from specify_cli.workflows.base import RunStatus + from specify_cli.workflows.engine import WorkflowDefinition + + monkeypatch.delenv( + "SPECKIT_INTEGRATION_DOCKER_AGENT_EXTRA_ARGS", raising=False + ) + monkeypatch.setattr( + "shutil.which", + lambda name: ( + "/usr/bin/docker-agent" + if name in {"docker-agent", "/usr/bin/docker-agent"} + else None + ), + ) + monkeypatch.setattr( + "subprocess.run", + lambda *args, **kwargs: type("Result", (), {"returncode": 1})(), + ) + + definition = WorkflowDefinition.from_string(self._WF_DYNAMIC_INTEGRATION) + engine = self._engine(project_dir) + state = engine.execute(definition) + assert state.status is RunStatus.FAILED + + resumed = engine.resume( + state.run_id, + {"integration": "claude", "config": "./changed-on-resume.yaml"}, + ) + + assert resumed.status is RunStatus.FAILED + step_result = resumed.step_results["implement"] + assert step_result["integration"] == "claude" + assert step_result["integration_args"] == ["./changed-on-resume.yaml"] + assert "Integration 'claude'" in (step_result["error"] or "") + assert "./initial-agent.yaml" not in str(step_result) + def test_resume_merges_and_coerces_typed_input(self, project_dir): import json as _json from specify_cli.workflows.engine import WorkflowDefinition diff --git a/workflows/README.md b/workflows/README.md index 676ea6faf0..2c1a9f2bb7 100644 --- a/workflows/README.md +++ b/workflows/README.md @@ -100,6 +100,39 @@ Invoke an installed Spec Kit command by name via the integration CLI: model: "claude-sonnet-4-20250514" # Optional: override model ``` +CLI integrations can expose per-step positional arguments and named runtime +options. For example, Docker Agent accepts an agent reference plus validated +`agent` and `safety` options. Use the command step's top-level `model` field for +model selection: + +```yaml +- id: specify-with-docker-agent + command: speckit.specify + integration: docker-agent + integration_args: + - "{{ inputs.agent_config }}" + integration_options: + agent: root + safety: balanced + model: "openai/gpt-5" + input: + args: "{{ inputs.spec }}" +``` + +`integration_args` must be an ordered list of strings. Expressions are resolved +one element at a time. `integration_options` must be a mapping with string keys; +its values are likewise expression-resolved and validated by the selected +integration. Non-empty runtime configuration is rejected when an integration +does not support it. Resolved values are stored in workflow run state for audit +and recovery. On resume, the complete dispatch configuration (`integration`, +`model`, `integration_args`, and `integration_options`) is re-resolved from the +current workflow inputs; without updated inputs this reproduces the prior values. + +When Docker Agent `integration_args` supplies an agent reference for a command +step, it takes precedence over `SPECKIT_INTEGRATION_DOCKER_AGENT_EXTRA_ARGS`; +the entire legacy environment value is ignored for that step. Without a per-step +agent reference, the legacy environment behavior is unchanged. + ### Prompt Steps Send an arbitrary inline prompt to an integration CLI (no command file needed): From 37c9667957b288ef16302edfa1df3528828a2ee6 Mon Sep 17 00:00:00 2001 From: Manfred Riem <15701806+mnriem@users.noreply.github.com> Date: Wed, 9 Sep 2026 13:45:27 -0500 Subject: [PATCH 34/44] docs: document contribution evidence gate and label taxonomy (#4478) * docs: document contribution evidence gate and label taxonomy Add an "Evidence gate" subsection, a high-level "Review rubric" (D1-D7, scored 0-2 each), and a "Triage and author labels" reference to CONTRIBUTING.md so the acceptance bar is discoverable before contributing. Cross-links the existing AI-contributions section and three-open-PR cap rather than restating them. Assisted-by: GitHub Copilot (model: Claude Opus 4.8, autonomous) Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> * docs: expand author labels, catalog channel, and close expectations Add author-needs-proof and author-needs-rebase to the author-actions table, a "Community catalog submissions" subsection pointing catalog changes at the submission-issue flow, and a note on when items are closed as triage-out-of-scope. Fix the author-awaiting phrasing ("handed off to"). author-needs-rescope is intentionally omitted: it does not yet exist on github/spec-kit, so it is flagged for the maintainer rather than documented as a live label. Assisted-by: GitHub Copilot (model: Claude Opus 4.8, autonomous) Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> * docs: add author-needs-rescope to author labels table Assisted-by: GitHub Copilot (model: Claude Opus 4.8, autonomous) Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> * Fix typo in CONTRIBUTING.md regarding AI-sourced contributions Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --------- Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --- CONTRIBUTING.md | 78 +++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 78 insertions(+) diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 3d1f2f229c..53d320e766 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -57,6 +57,84 @@ Here are a few things you can do that will increase the likelihood of your pull Accounts with three open pull requests may continue submitting changes, but additional submissions may be placed behind contributions from other authors in the review queue. Coding agents should disclose this possibility and obtain the filer's confirmation before opening another pull request. +### Evidence gate + +A contribution is evaluated on the evidence it carries, not on how plausible its reasoning sounds. The same bar applies to everyone — human and AI-assisted contributions are judged identically. + +A valid, in-scope change that arrives without evidence is not rejected outright. It may be labeled [`triage-can-wait`](#triage-and-author-labels) and held behind proven work until evidence is added, at which point it can be reprioritized. + +What counts as evidence: + +- **A reproduction or a linked real-world report** — a failing case, a stack trace, or a link to an issue where the problem actually occurred. "This could theoretically fail" reasoning on its own does not clear the gate. +- **A regression test that fails on `main` and passes with your change** — this proves both that the problem is real and that your change fixes it. +- **Scope discipline** — one concern per pull request. Split unrelated changes into separate PRs (see the [focused-change guidance](#submitting-a-pull-request) above); sprawling batch diffs are hard to review and slow to land. +- **Disclosed AI assistance** — if any AI tooling was involved, disclose it and its extent per [AI contributions in Spec Kit](#ai-contributions-in-spec-kit). + +Speculative hardening is welcome, but it sits behind proven, evidence-backed work. If you can attach a reproduction and a failing test, your change moves to the front; if you can't yet, say so, and it will be queued rather than closed. + +### Review rubric + +Triaged items are weighed across seven dimensions, each scored `0`–`2` (`0` absent, `1` partial, `2` clearly demonstrated), for a maximum of 14. The score guides **prioritization** — it is not a hard pass/fail gate. The one firm rule is the [evidence gate](#evidence-gate) above: theoretical-only changes with no evidence are deprioritized. + +| Dimension | What it measures | +|---|---| +| D1 — Real-world evidence | A reproduction or linked report, versus theory alone | +| D2 — Reachability / severity | Whether the issue can actually be hit, and how bad it is | +| D3 — Scope discipline | One focused concern per PR, no unrelated changes | +| D4 — Test evidence | A regression test that fails on `main` and passes with the change | +| D5 — Disclosure / understanding | AI use disclosed, and the author understands the change | +| D6 — Cost vs. benefit | Value delivered against added complexity and maintenance cost | +| D7 — Roadmap alignment | Fit with Spec Kit's goals and direction | + +### Triage and author labels + +Every triaged item receives one **verdict** label recording where it stands. **Author** labels signal the specific action needed to move an item forward. + +Verdict (one per item): + +| Label | Meaning | +|---|---| +| `triage-must-have` | Verdict: high-value, important work for Spec Kit — do first | +| `triage-nice-to-have` | Verdict: evidence-backed fix or greenlit feature — land after review | +| `triage-can-wait` | Verdict: valid and in-scope but deprioritized; held behind the evidence gate | +| `triage-out-of-scope` | Verdict: won't land in core — invalid, duplicate, off-mission, or redirected to an extension | + +Author actions: + +| Label | Meaning | +|---|---| +| `author-needs-proof` | The problem isn't demonstrated yet — supply a reproduction or a test that fails on `main` and passes with the change | +| `author-needs-tests` | Real change but missing a regression test — add one that fails before / passes after | +| `author-needs-rescope` | Sprawling or batched diff — split into one focused, single-concern PR | +| `author-needs-disclosure` | AI assistance not disclosed — disclose AI use per CONTRIBUTING | +| `author-needs-info` | Missing detail needed to assess — supply requested info | +| `author-needs-rebase` | Branch conflicts with `main` — rebase and resolve before it can be merged | +| `author-over-cap` | Over the 3-open-PR cap or repetitive batch submissions — please consolidate | +| `author-awaiting` | Waiting on author response (handed off to the existing stale workflow) | + +Some pull requests are closed as `triage-out-of-scope` rather than merged — most commonly +when the same change is already in `main`, when a request is better served as a community +extension, when an existing feature already covers it, or when a catalog change came in as +a direct edit instead of a submission issue. A close always comes with a comment explaining +why and, where relevant, where to go instead. + +For further reading on the thinking behind this gate, see [one maintainer's perspective on AI-sourced contributions](https://blog.manorrock.com/blog/2026/09/08/spec_kit_ai_source.html). That piece is a personal viewpoint, not project policy — the policy is what's documented here. + +### Community catalog submissions + +To add or update a community extension, preset, or bundle in the catalog, **open an +`[Extension]` / `[Preset]` / `[Bundle]` submission issue** — do not edit +`extensions/catalog.community.json` (or the preset/bundle catalogs) directly in a pull +request. The submission issue triggers an automated workflow that validates the release, +verifies the pinned `download_url` and digests, and generates the catalog PR for you. +A hand-edited catalog PR bypasses that validation and will be closed with a pointer back +to the issue flow. + +This applies to **new entries, version updates, and repairs alike** — a version bump or a +fix to a broken entry is still an update and needs the same validation. Always pin +`download_url` to a release tag (e.g. `.../releases/download//...` or +`.../archive/refs/tags/.zip`); never use `releases/latest/`. + ### Branch naming We recommend naming branches as `/-`, where `` is the issue or PR number (whichever comes first) and `` is one of: From 33e19205837853a4afa27a6fd8caac7d11531292 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Wed, 9 Sep 2026 14:02:19 -0500 Subject: [PATCH 35/44] Update Spec Inventory extension to v0.1.1 (#4496) Update speckit-inventory extension submitted by @Yash-Chindam:\n- extensions/catalog.community.json (version, download_url, sha256, etc.)\n- docs/community/extensions.md community extensions table\n\nCloses #4486\n\nAssisted-by: GitHub Copilot (model: gpt-5.2-codex, autonomous)\nCo-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com> --- extensions/catalog.community.json | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/extensions/catalog.community.json b/extensions/catalog.community.json index 5444838c6a..5b3c26494f 100644 --- a/extensions/catalog.community.json +++ b/extensions/catalog.community.json @@ -4856,9 +4856,9 @@ "id": "speckit-inventory", "description": "Read-only inventory of live requirement and task IDs, with focused per-task context packs instead of whole-file dumps.", "author": "Yash Chindam", - "version": "0.1.0", - "download_url": "https://github.com/Yash-Chindam/spec-kit-inventory-alignment/releases/download/v0.1.0/speckit-inventory.zip", - "sha256": "9ebf004ef6494323f6dccfab2554a04898c9e92bc7f25e0638b5aee916566e7f", + "version": "0.1.1", + "download_url": "https://github.com/Yash-Chindam/spec-kit-inventory-alignment/releases/download/v0.1.1/speckit-inventory.zip", + "sha256": "df735fe7c7ca7afe1ac279ffe5768061a29710a9d61956f90ee2cf2d66da04be", "repository": "https://github.com/Yash-Chindam/spec-kit-inventory-alignment", "homepage": "https://github.com/Yash-Chindam/spec-kit-inventory-alignment", "documentation": "https://github.com/Yash-Chindam/spec-kit-inventory-alignment/blob/main/speckit-inventory/README.md", @@ -4884,7 +4884,7 @@ "downloads": 0, "stars": 0, "created_at": "2026-08-20T00:00:00Z", - "updated_at": "2026-08-20T00:00:00Z" + "updated_at": "2026-09-09T00:00:00Z" }, "speckit-superpowers-bridge": { "name": "Superpowers Implementation Bridge", From 86b7a01420a42b479f55296554030c8962744759 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Wed, 9 Sep 2026 14:27:32 -0500 Subject: [PATCH 36/44] [preset] Update Inventory Alignment preset to v0.1.1 (#4494) * Update Inventory Alignment preset to v0.1.1 Update inventory-alignment preset submitted by @Yash-Chindam:\n- presets/catalog.community.json (version, download_url, sha256, requires, timestamps)\n- docs/community/presets.md community presets table (no row changes required)\n\nCloses #4487\n\nAssisted-by: GitHub Copilot (model: gpt-5.2-codex, autonomous)\nCo-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * Fix Inventory Alignment catalog version Assisted-by: GitHub Copilot (model: GPT-5.4, autonomous) Co-authored-by: mnriem <15701806+mnriem@users.noreply.github.com> * fix: revert unrelated canon-core preset version bump Keep canon-core at 0.1.0 so its version matches its release asset. Limit the preset update to inventory-alignment. Assisted-by: GitHub Copilot (model: gpt-6-astra, autonomous) Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --------- Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com> Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com> Co-authored-by: mnriem <15701806+mnriem@users.noreply.github.com> Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- presets/catalog.community.json | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/presets/catalog.community.json b/presets/catalog.community.json index 3f608378a0..6ec74f80fa 100644 --- a/presets/catalog.community.json +++ b/presets/catalog.community.json @@ -1,6 +1,6 @@ { "schema_version": "1.0", - "updated_at": "2026-08-26T00:00:00Z", + "updated_at": "2026-09-09T00:00:00Z", "catalog_url": "https://raw.githubusercontent.com/github/spec-kit/main/presets/catalog.community.json", "presets": { "a11y-governance": { @@ -488,17 +488,17 @@ "inventory-alignment": { "name": "Inventory Alignment", "id": "inventory-alignment", - "version": "0.1.0", + "version": "0.1.1", "description": "Classifies each requirement against a read-only inventory of live IDs before writing, so reworded requirements are updated instead of duplicated.", "author": "Yash Chindam", "repository": "https://github.com/Yash-Chindam/spec-kit-inventory-alignment", - "download_url": "https://github.com/Yash-Chindam/spec-kit-inventory-alignment/releases/download/v0.1.0/inventory-alignment.zip", - "sha256": "8ea62813aeb88d85001f54d91d8eceb011f5fb872bc764d5ea83e8e7ab92a2c1", + "download_url": "https://github.com/Yash-Chindam/spec-kit-inventory-alignment/releases/download/v0.1.1/inventory-alignment.zip", + "sha256": "227db99a4c0626e8310f0116153929923a3f832b1b2d849231caeaff958b65a6", "homepage": "https://github.com/Yash-Chindam/spec-kit-inventory-alignment", "documentation": "https://github.com/Yash-Chindam/spec-kit-inventory-alignment/blob/main/inventory-alignment/README.md", "license": "MIT", "requires": { - "speckit_version": ">=0.9.0", + "speckit_version": ">=1.0.4", "extensions": [ "speckit-inventory" ] @@ -515,7 +515,7 @@ "workflow" ], "created_at": "2026-08-20T00:00:00Z", - "updated_at": "2026-08-20T00:00:00Z" + "updated_at": "2026-09-09T00:00:00Z" }, "isaqb-architecture-governance": { "name": "iSAQB Architecture Governance", From 890c6d455bc6ba25c54008861e61396d8be6c0e6 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Wed, 9 Sep 2026 20:13:56 -0500 Subject: [PATCH 37/44] [extension] Update Spec Kit Schedule extension to v0.7.4 (#4498) * Update Spec Kit Schedule extension to v0.7.4 Update schedule extension submitted by @jfranc38:\n- extensions/catalog.community.json (version, download_url, metadata)\n- docs/community/extensions.md community extensions table\n\nCloses #4457\n\nCo-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>\nAssisted-by: GitHub Copilot (model: gpt-5.2-codex, autonomous) * Apply suggestion from @KSchlobohm --------- Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com> Co-authored-by: Ken Schlobohm --- docs/community/extensions.md | 2 +- extensions/catalog.community.json | 35 ++++++++++++++++++++----------- 2 files changed, 24 insertions(+), 13 deletions(-) diff --git a/docs/community/extensions.md b/docs/community/extensions.md index 274bd1f414..aff583da3d 100644 --- a/docs/community/extensions.md +++ b/docs/community/extensions.md @@ -148,7 +148,7 @@ The following community-contributed extensions are available in [`catalog.commun | Spec Kit Figma | Grounds SpecKit spec/plan/tasks in Figma design context via REST or MCP, on macOS/Linux/Windows. | `integration` | Read+Write | [spec-kit-figma](https://github.com/Fyloss/spec-kit-figma) | | Spec Kit Memory | Recalls prior specs and decisions from configurable memory tools (e.g. memsearch) before SDLC stages, so planning and specification start from what the project already knows | `docs` | Read+Write | [spec-kit-memory](https://github.com/zaytsevand/spec-kit-memory) | | Spec Kit Preview | Generate evidence-backed low, mid, or high fidelity previews from Spec Kit artifacts as Markdown or self-contained HTML | `docs` | Read+Write | [spec-kit-preview](https://github.com/bigsmartben/spec-kit-preview) | -| Spec Kit Schedule | Optimal multi-agent task scheduling via CP-SAT — DAG precedence, hallucination-aware caps, file-conflict avoidance, stochastic durations, replanning, and interactive HTML output | `process` | Read+Write | [spec-kit-schedule](https://github.com/jfranc38/spec-kit-schedule) | +| Spec Kit Schedule — parallel subagent rounds | Zero-config planner that turns tasks.md into optimal parallel rounds for subagents (CP-SAT, critical-path aware, file-conflict safe) and an implement command that runs them round by round with tasks.md checkboxes as the single source of progress. | `process` | Read+Write | [spec-kit-schedule](https://github.com/jfranc38/spec-kit-schedule) | | Spec Kit TLDR | Render a feature's spec.md / plan.md into a review-oriented TLDR (self-contained HTML dashboard + PR-native Markdown) that surfaces risks for faster PR review. | `visibility` | Read+Write | [speckit-tldr](https://github.com/qurore/speckit-tldr) | | Spec Orchestrator | Cross-feature orchestration — track state, select tasks, and detect conflicts across parallel specs | `process` | Read-only | [spec-kit-orchestrator](https://github.com/Quratulain-bilal/spec-kit-orchestrator) | | Spec Reference Loader | Reads the ## References section from the feature spec and loads only the listed docs into context | `docs` | Read-only | [spec-kit-spec-reference-loader](https://github.com/KevinBrown5280/spec-kit-spec-reference-loader) | diff --git a/extensions/catalog.community.json b/extensions/catalog.community.json index 5b3c26494f..75e7057a98 100644 --- a/extensions/catalog.community.json +++ b/extensions/catalog.community.json @@ -4481,38 +4481,49 @@ "updated_at": "2026-06-24T00:00:00Z" }, "schedule": { - "name": "Spec Kit Schedule — CP-SAT Agent Orchestrator", + "name": "Spec Kit Schedule — parallel subagent rounds", "id": "schedule", - "description": "Optimal multi-agent task scheduling via CP-SAT solver with DAG precedence, hallucination-aware caps, file-conflict avoidance, stochastic durations, replanning, and interactive HTML output", + "description": "Zero-config planner that turns tasks.md into optimal parallel rounds for subagents (CP-SAT, critical-path aware, file-conflict safe) and runs them round by round.", "author": "Julio César Franco Ardila", - "version": "0.6.2", - "download_url": "https://github.com/jfranc38/spec-kit-schedule/archive/refs/tags/v0.6.2.zip", + "version": "0.7.4", + "download_url": "https://github.com/jfranc38/spec-kit-schedule/archive/refs/tags/v0.7.4.zip", "repository": "https://github.com/jfranc38/spec-kit-schedule", "homepage": "https://github.com/jfranc38/spec-kit-schedule", - "documentation": "https://github.com/jfranc38/spec-kit-schedule/blob/main/README.md", - "changelog": "https://github.com/jfranc38/spec-kit-schedule/blob/main/CHANGELOG.md", + "documentation": "https://github.com/jfranc38/spec-kit-schedule/blob/master/README.md", + "changelog": "https://github.com/jfranc38/spec-kit-schedule/blob/master/CHANGELOG.md", "license": "MIT", "category": "process", "effect": "read-write", "requires": { - "speckit_version": ">=0.4.0" + "speckit_version": ">=0.4.0", + "tools": [ + { + "name": "python3", + "version": ">=3.10,<3.13", + "required": true + }, + { + "name": "uv", + "required": false + } + ] }, "provides": { - "commands": 5, - "hooks": 1 + "commands": 3, + "hooks": 2 }, "tags": [ "scheduling", - "optimization", "multi-agent", + "subagents", "cp-sat", - "operations-research" + "orchestration" ], "verified": false, "downloads": 0, "stars": 0, "created_at": "2026-05-06T22:35:00Z", - "updated_at": "2026-05-07T17:25:00Z" + "updated_at": "2026-09-09T00:00:00Z" }, "scope": { "name": "Spec Scope", From 03a79d14ec785626db1dc300444f5b9154606e82 Mon Sep 17 00:00:00 2001 From: Manfred Riem <15701806+mnriem@users.noreply.github.com> Date: Wed, 9 Sep 2026 22:20:41 -0500 Subject: [PATCH 38/44] chore: shorten stale timeline to 60 days stale, 30 days to close (#4503) Update the stale workflow so issues and PRs are marked stale after 60 days of inactivity and closed 30 days later (90 days total), down from 150/30. Messages updated to match. Assisted-by: GitHub Copilot (model: Claude Opus 4.8, autonomous) Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- .github/workflows/stale.yml | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/.github/workflows/stale.yml b/.github/workflows/stale.yml index cc32e4462e..c75aa258db 100644 --- a/.github/workflows/stale.yml +++ b/.github/workflows/stale.yml @@ -17,18 +17,18 @@ jobs: - uses: actions/stale@4391f3da665fdf50b6810c1a66712fb9ba21aa93 # v11.0.0 with: # Days of inactivity before an issue or PR becomes stale - days-before-stale: 150 + days-before-stale: 60 # Days of inactivity before a stale issue or PR is closed (after being marked stale) days-before-close: 30 # Stale issue settings - stale-issue-message: 'This issue has been automatically marked as stale because it has not had any activity for 150 days. It will be closed in 30 days if no further activity occurs.' - close-issue-message: 'This issue has been automatically closed due to inactivity (180 days total). If you believe this issue is still relevant, please reopen it or create a new issue.' + stale-issue-message: 'This issue has been automatically marked as stale because it has not had any activity for 60 days. It will be closed in 30 days if no further activity occurs.' + close-issue-message: 'This issue has been automatically closed due to inactivity (90 days total). If you believe this issue is still relevant, please reopen it or create a new issue.' stale-issue-label: 'stale' # Stale PR settings - stale-pr-message: 'This pull request has been automatically marked as stale because it has not had any activity for 150 days. It will be closed in 30 days if no further activity occurs.' - close-pr-message: 'This pull request has been automatically closed due to inactivity (180 days total). If you believe this PR is still relevant, please reopen it or create a new PR.' + stale-pr-message: 'This pull request has been automatically marked as stale because it has not had any activity for 60 days. It will be closed in 30 days if no further activity occurs.' + close-pr-message: 'This pull request has been automatically closed due to inactivity (90 days total). If you believe this PR is still relevant, please reopen it or create a new PR.' stale-pr-label: 'stale' # Exempt issues and PRs with these labels from being marked as stale From e4842a03c830155e4c53315e4b8e32ef118d3f6e Mon Sep 17 00:00:00 2001 From: Ali jawwad <33836051+jawwad-ali@users.noreply.github.com> Date: Thu, 10 Sep 2026 17:50:37 +0500 Subject: [PATCH 39/44] docs(core): SPECIFY_FEATURE sets the feature label, not the feature directory (#3786) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * docs(core): SPECIFY_FEATURE sets the feature label, not the feature directory docs/reference/core.md told users to set SPECIFY_FEATURE "to the feature directory name ... to work on a specific feature when not using Git branches". That does not work: SPECIFY_FEATURE only feeds get_current_branch / Get-CurrentBranch (the feature *label*). The directory comes from SPECIFY_FEATURE_DIRECTORY or .specify/feature.json. Verified on main with the real helper -- with ONLY SPECIFY_FEATURE set: $ SPECIFY_FEATURE=001-photo-albums ... get_feature_paths ERROR: Feature directory not found. Set SPECIFY_FEATURE_DIRECTORY or run the specify command to create .specify/feature.json. exit=1 $ SPECIFY_FEATURE_DIRECTORY=specs/001-photo-albums ... get_feature_paths FEATURE_DIR -> CURRENT_BRANCH -> 001-photo-albums The code's own error message points at the other variable, and the doc's own "Two resolution axes" note directly below already says the feature is selected by SPECIFY_FEATURE_DIRECTORY / .specify/feature.json -- so the table row contradicted both the code and the paragraph under it. Describe what the variable actually does, note that /speckit.specify and the Git extension normally set it, and point at the directory axis. Docs only. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 5 (1M context) * docs(core): describe SPECIFY_FEATURE as an explicit label override The row still misstated when and how the label is applied: * "when there is no Git branch context" — get_current_branch and Get-CurrentBranch never inspect Git at all. They return the variable verbatim when set, and otherwise fall back to the basename of the resolved feature directory. * "Normally set for you by /speckit.specify" — specify.md persists feature_directory to .specify/feature.json and never sets this variable. * The Bash and Python feature scripts can only *print* a commented export hint, because a child process cannot change its parent's environment. The PowerShell scripts do assign $env:SPECIFY_FEATURE, but only reach the caller when run inside the current session. Rewrite it as an explicit user-set label override, and distinguish the printed persistence hint from actually setting the caller's environment. Co-Authored-By: Claude Opus 5 (1M context) * docs(core): attribute the label fallback to get_feature_paths, not get_current_branch The row said the label "falls back to the basename of the resolved feature directory" when SPECIFY_FEATURE is unset, and attributed that to get_current_branch / Get-CurrentBranch. Those helpers return an EMPTY string when the variable is unset — scripts/bash/common.sh:87 says so outright ("Return empty to signal 'unknown'") and scripts/python/common.py is `return os.environ.get("SPECIFY_FEATURE", "")`. The basename substitution happens later, in get_feature_paths / Get-FeaturePaths, after the feature directory has been resolved (scripts/python/common.py:168-169). Measured: get_current_branch (unset) -> [] get_current_branch (set) -> [my-label] get_feature_paths CURRENT_BRANCH -> [001-photo-albums] So a caller invoking the named helpers directly does not get the fallback. Distinguish the two behaviours. Co-Authored-By: Claude Opus 5 (1M context) * docs(core): name the PowerShell helper Get-FeaturePathsEnv The doc cited `Get-FeaturePaths`, which does not exist. The PowerShell twin of `get_feature_paths` is `Get-FeaturePathsEnv` (scripts/powershell/common.ps1:152); there is no bare `Get-FeaturePaths` anywhere in the tree. Verified every function name the entry cites now resolves against the scripts: get_current_branch, Get-CurrentBranch, get_feature_paths, Get-FeaturePathsEnv. The quoted resolution error is verbatim from scripts/bash/common.sh:206. Co-Authored-By: Claude Opus 5 (1M context) --------- Co-authored-by: Claude Opus 5 (1M context) --- docs/reference/core.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/reference/core.md b/docs/reference/core.md index fdf0b80e7f..b70aebe236 100644 --- a/docs/reference/core.md +++ b/docs/reference/core.md @@ -53,7 +53,7 @@ specify init my-project --integration copilot --preset compliance | `SPECKIT_INTEGRATION_DEFAULT` | Override the fallback integration used by `specify init` when `--integration` is omitted (interactive prompt default and non-interactive fallback). Set it to any registered integration key (e.g. `gemini`, `claude`). An unrecognized value is ignored with a warning and the built-in default (`copilot`) is used. An explicit `--integration ` always takes precedence. | | `SPECIFY_INIT_DIR` | Target a member project from outside its directory (e.g. a monorepo root) without `cd`, for non-interactive / CI use. Set it to the **project root** — the directory *containing* `.specify/` (relative paths resolve against the current directory). The path must exist and contain `.specify/`, otherwise the command errors and does **not** fall back to the current directory. Resolved once in the core root helper (`get_repo_root` in Bash, `Get-RepoRoot` in PowerShell), so it is honored by the core feature scripts (`/speckit.plan`, `/speckit.tasks`, …) and the Git extension's feature-branch creation, which inherit it. The `specify` CLI applies the **same** validation rules to every project-scoped subcommand (`specify integration …`, `specify extension …`, `specify workflow …`, `specify preset …`, and the rest that operate on a `.specify/` project), so those can target a member project too. When unset, Bash/PowerShell helpers keep their existing upward search; the `specify` CLI keeps its project-scoped resolver cwd-only unless a command explicitly defines broader detection (for example, bundle commands). | | `SPECIFY_FEATURE_DIRECTORY` | Override the active feature directory *within* the resolved project (takes precedence over `.specify/feature.json`). Relative paths resolve under the project root. Combine with `SPECIFY_INIT_DIR` to pick both the project and the feature non-interactively. | -| `SPECIFY_FEATURE` | Override feature detection for non-Git repositories. Set to the feature directory name (e.g., `001-photo-albums`) to work on a specific feature when not using Git branches. Must be set in the context of the agent prior to using `/speckit.plan` or follow-up commands. | +| `SPECIFY_FEATURE` | Explicitly override the active feature **label** (e.g. `001-photo-albums`) — the identifier the core helpers report as the current feature/branch (`get_current_branch` in Bash, `Get-CurrentBranch` in PowerShell). Those helpers never inspect Git: when the variable is set they return it verbatim, and when it is unset they return an empty string. The basename fallback happens later — `get_feature_paths` / `Get-FeaturePathsEnv` substitute the resolved feature directory's basename so the reported label is still usable — so calling the named helpers directly does **not** give you that fallback. You set it yourself: the Bash and Python feature scripts only **print** a commented `export SPECIFY_FEATURE=…` / `$env:SPECIFY_FEATURE = …` hint for you to run, because a child process cannot change its parent's environment, and `/speckit.specify` persists `feature_directory` to `.specify/feature.json` instead of setting this variable. (The PowerShell feature scripts do assign `$env:SPECIFY_FEATURE`, but that only reaches you when the script runs inside your current PowerShell session.) It does **not** locate the feature directory: with only `SPECIFY_FEATURE` set, `get_feature_paths` fails with *"Feature directory not found. Set `SPECIFY_FEATURE_DIRECTORY` or run the specify command to create `.specify/feature.json`."* Use `SPECIFY_FEATURE_DIRECTORY` (above) or `.specify/feature.json` to select the directory. | > **Two resolution axes.** `SPECIFY_INIT_DIR` selects the **project** (which directory contains `.specify/`); `SPECIFY_FEATURE_DIRECTORY` / `.specify/feature.json` select the **feature** within that project. They are independent — project first, then feature. From 42153451e97247a4c24e15d7fb380b2a673af19e Mon Sep 17 00:00:00 2001 From: Noor ul ain Date: Thu, 10 Sep 2026 18:12:05 +0500 Subject: [PATCH 40/44] fix(events): cap stdin in the generated dispatcher, not just the CLI command (#4337) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix(events): cap stdin in the generated dispatcher, not just the CLI command The #3857 fix capped stdin at 1 MiB in `specify event run` (src/specify_cli/commands/event.py), but that command is not the code path native hooks actually invoke. Every installed integration writes a self-contained `.specify/events.py` dispatcher (the `_EVENTS_DISPATCHER_TEMPLATE` string in src/specify_cli/events.py) that native hook configs call directly, and its `main()` did: payload = sys.stdin.read() if not sys.stdin.isatty() else "{}" with no size cap at all — the exact DoS #3857 was meant to close, wide open on the primary invocation path. `specify event run` is a secondary/manual entry point; the generated dispatcher is what actually runs on every session_start/pre_tool_use/etc. hook fire in real usage. Fix: apply the same byte-capped read (from the binary buffer, so the cap counts encoded bytes rather than decoded characters — matching the just-merged fix for the CLI command) inside the dispatcher template, so every newly-installed or refreshed dispatcher enforces the limit. ## Test plan - Added 3 tests in tests/integrations/test_events.py::TestCommandRunner: an oversized payload exits 1 with the limit message instead of running unbounded, a multibyte payload (~300k emoji, ~1.14 MiB UTF-8 but only 300k characters) is still rejected by the byte-based cap, and a normal under-the-cap payload still reaches the handler script unchanged. - Verified both new failing-without-fix tests via test-the-test (stashed the src fix): the oversized-payload test failed because the dispatcher silently accepted the full payload and returned "not found" instead of exiting 1 with the limit message — reproducing the exact bug. - Ran the full tests/integrations/test_events.py suite (124/128 pass; the remaining 4 are the pre-existing Windows symlink-elevation failures unrelated to this change). Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_01PJHJ2dHP2RVCNncHqN8Qm9 * fix(events): pin utf-8 encoding on the handler subprocess in both dispatch paths Addresses Copilot review feedback on PR #4337: Both `_run_inline` (the generated dispatcher's stdlib fallback) and `resolve_and_run_event_command` (the delegated/CLI-native path) decode stdin explicitly as utf-8, then pass that string to the handler via `subprocess.run(..., text=True)` with no explicit `encoding=`. Without one, `text=True` re-encodes the payload for the child's stdin using `locale.getpreferredencoding()` — on Windows that's commonly the ANSI codepage, not UTF-8 — so a non-ASCII payload byte (e.g. "é") reaches the handler as the wrong byte, corrupting JSON for handlers that expect UTF-8. Pin `encoding="utf-8"` on both subprocess.run calls so the decode and re-encode agree. Also rewrote `test_dispatcher_underlimit_stdin_still_runs` (previously skipped entirely on Windows via a POSIX-only `sh` handler) to use a cross-platform Python handler and assert byte-for-byte fidelity of a non-ASCII payload, and added test_dispatcher_inline_fallback_preserves_non_ascii_payload, which forces the `_run_inline` fallback (never reached in a dev environment where specify_cli is importable, since the dispatcher always delegates first) so that path's fix is independently verified too. Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_01NhR6g8xT8at5pPMhkrC3e2 --------- Co-authored-by: Claude Sonnet 5 --- src/specify_cli/events.py | 31 ++++- tests/integrations/test_events.py | 192 ++++++++++++++++++++++++++++++ 2 files changed, 222 insertions(+), 1 deletion(-) diff --git a/src/specify_cli/events.py b/src/specify_cli/events.py index ba0a4f6363..17a9d7ffbe 100644 --- a/src/specify_cli/events.py +++ b/src/specify_cli/events.py @@ -280,11 +280,18 @@ def _run_inline(command_name, payload, project_root, timeout, envelope="plain", if not argv: return 0 try: + # ``payload`` is decoded above from the binary buffer with an explicit + # ``utf-8``. Without ``encoding=`` here, ``text=True`` re-encodes it + # for the child's stdin using ``locale.getpreferredencoding()`` — on + # Windows that is commonly the ANSI codepage, not UTF-8, so a non-ASCII + # payload (e.g. ``é``) would reach the handler as the wrong bytes. + # Pin both directions to utf-8 so decode and re-encode agree. result = subprocess.run( argv, input=payload, capture_output=True, text=True, + encoding="utf-8", timeout=timeout, cwd=str(project_root), ) @@ -375,7 +382,21 @@ def main(): # hookEventName field (required by Qwen's hooks spec; included by # Gemini/Tabnine/Devin which derive from the same protocol). native_event = sys.argv[5] if len(sys.argv) >= 6 else "" - payload = sys.stdin.read() if not sys.stdin.isatty() else "{}" + # Cap piped stdin at 1 MiB to prevent a DoS (mirrors the same guard on the + # `specify event run` CLI command). Read from the binary buffer so the cap + # counts encoded bytes, not decoded characters. + MAX_STDIN_BYTES = 1 * 1024 * 1024 + if not sys.stdin.isatty(): + raw = sys.stdin.buffer.read(MAX_STDIN_BYTES + 1) + if len(raw) > MAX_STDIN_BYTES: + print( + "stdin payload exceeds 1 MiB limit; truncate or pipe a smaller payload", + file=sys.stderr, + ) + sys.exit(1) + payload = raw.decode("utf-8") + else: + payload = "{}" project_root = Path(__file__).parent.parent.resolve() # Preferred path: specify_cli is importable (durable install) — delegate to @@ -759,11 +780,19 @@ def resolve_and_run_event_command( logger.warning("No script found for event command '%s'", command_name) return 0 try: + # ``payload`` reaches here already decoded from stdin's binary buffer + # with an explicit ``utf-8`` (see event_run/dispatcher main()). Without + # ``encoding=`` here, ``text=True`` re-encodes it for the child's + # stdin using ``locale.getpreferredencoding()`` — on Windows that is + # commonly the ANSI codepage, not UTF-8, so a non-ASCII payload (e.g. + # ``é``) would reach the handler as the wrong bytes. Pin both + # directions to utf-8 so decode and re-encode agree. result = subprocess.run( argv, input=payload, capture_output=True, text=True, + encoding="utf-8", timeout=timeout, cwd=str(project_root), ) diff --git a/tests/integrations/test_events.py b/tests/integrations/test_events.py index f5159c1d41..0168f4302c 100644 --- a/tests/integrations/test_events.py +++ b/tests/integrations/test_events.py @@ -1550,6 +1550,198 @@ def test_dispatcher_ignores_stale_specify_cli_without_confinement(self, tmp_path ) assert not ran.exists(), f"stale package ran; stderr={result.stderr!r}" + def test_dispatcher_rejects_oversized_stdin(self, tmp_path): + """The generated dispatcher — the actual script native hooks invoke — + must enforce the same 1 MiB stdin cap as `specify event run`. The + #3857 DoS guard previously only applied to the CLI command; the + template's own `sys.stdin.read()` had no cap at all.""" + import subprocess as _sp + import sys as _sys + + integration = ClaudeIntegration() + manifest = MagicMock(spec=IntegrationManifest) + manifest.files = {} + manifest.record_file = MagicMock() + manifest.record_existing = MagicMock() + install_integration_events( + integration, tmp_path, manifest, + {"session_start": [{"command": "speckit.boot"}]}, + ) + dispatcher = tmp_path / EVENTS_DISPATCHER_REL + + oversized = "x" * (1 * 1024 * 1024 + 10) + result = _sp.run( + [_sys.executable, str(dispatcher), "speckit.boot", "session_start", "60"], + input=oversized, + capture_output=True, + text=True, + encoding="utf-8", + cwd=str(tmp_path), + ) + assert result.returncode == 1, f"stdout={result.stdout!r} stderr={result.stderr!r}" + assert "1 MiB limit" in result.stderr + + def test_dispatcher_stdin_cap_counts_bytes_not_characters(self, tmp_path): + """~300k emoji is ~1.14 MiB of UTF-8 but only 300k *characters* — + comfortably under a text-mode `sys.stdin.read(N)` character cap. The + dispatcher must still reject it by reading from the binary buffer.""" + import subprocess as _sp + import sys as _sys + + integration = ClaudeIntegration() + manifest = MagicMock(spec=IntegrationManifest) + manifest.files = {} + manifest.record_file = MagicMock() + manifest.record_existing = MagicMock() + install_integration_events( + integration, tmp_path, manifest, + {"session_start": [{"command": "speckit.boot"}]}, + ) + dispatcher = tmp_path / EVENTS_DISPATCHER_REL + + oversized = "\U0001F600" * 300_000 # 4 bytes each in UTF-8 + assert len(oversized) < 1 * 1024 * 1024 # under a character-based cap + result = _sp.run( + [_sys.executable, str(dispatcher), "speckit.boot", "session_start", "60"], + input=oversized, + capture_output=True, + text=True, + encoding="utf-8", + cwd=str(tmp_path), + ) + assert result.returncode == 1, f"stdout={result.stdout!r} stderr={result.stderr!r}" + assert "1 MiB limit" in result.stderr + + def test_dispatcher_underlimit_stdin_still_runs(self, tmp_path): + """A normal, under-the-cap piped payload must still reach the handler + byte-for-byte, including non-ASCII content -- verifying the explicit + utf-8 decode of stdin and the explicit utf-8 encode of the handler's + subprocess input agree end-to-end (regression guard against both an + over-eager cap check and a locale-dependent re-encode: without an + explicit ``encoding=`` on the inner subprocess.run, ``text=True`` + falls back to ``locale.getpreferredencoding()`` for the child's + stdin, which on Windows is commonly not UTF-8, corrupting non-ASCII + payloads even though the dispatcher's own stdin decode is UTF-8). + + Uses a Python handler (unlike the previous POSIX-shell-only version) + so this test actually runs on Windows, where that mismatch occurs. + """ + import subprocess as _sp + import sys as _sys + + integration = ClaudeIntegration() + manifest = MagicMock(spec=IntegrationManifest) + manifest.files = {} + manifest.record_file = MagicMock() + manifest.record_existing = MagicMock() + install_integration_events( + integration, tmp_path, manifest, + {"session_start": [{"command": "speckit.boot"}]}, + ) + dispatcher = tmp_path / EVENTS_DISPATCHER_REL + + cmd_dir = tmp_path / ".specify" / "templates" / "commands" + cmd_dir.mkdir(parents=True) + out_file = tmp_path / "payload.out" + (cmd_dir / "boot.md").write_text( + "---\ndescription: \"Boot\"\nscripts:\n py: scripts/boot.py\n---\nBody\n", + encoding="utf-8", + ) + script_dir = tmp_path / ".specify" / "scripts" + script_dir.mkdir(parents=True) + script = script_dir / "boot.py" + # Read the handler's own stdin as raw bytes (not text mode) so this + # script's own decoding can't mask a mismatch introduced upstream. + script.write_text( + "import sys\n" + f"open({str(out_file)!r}, 'wb').write(sys.stdin.buffer.read())\n", + encoding="utf-8", + ) + + payload = '{"key": "café"}' # non-ASCII exercises the utf-8 round trip + result = _sp.run( + [_sys.executable, str(dispatcher), "speckit.boot", "session_start", "60"], + input=payload, + capture_output=True, + text=True, + encoding="utf-8", + cwd=str(tmp_path), + ) + assert result.returncode == 0, f"stdout={result.stdout!r} stderr={result.stderr!r}" + assert out_file.read_bytes() == payload.encode("utf-8") + + def test_dispatcher_inline_fallback_preserves_non_ascii_payload(self, tmp_path): + """The self-contained stdlib fallback (``_run_inline`` — used when + ``specify_cli`` is not importable, e.g. a one-time ``uvx`` init) must + preserve a non-ASCII payload byte-for-byte too, not just the + preferred delegated path. + + In a dev environment where ``specify_cli`` IS importable, the + dispatcher always delegates to the installed + ``resolve_and_run_event_command`` and ``_run_inline`` is never + reached, so a bug isolated to ``_run_inline`` alone would not be + caught by ``test_dispatcher_underlimit_stdin_still_runs``. This test + forces the fallback the same way + ``test_dispatcher_ignores_stale_specify_cli_without_confinement`` + does: a stale shadow package on PYTHONPATH lacking + EVENT_SCRIPT_PATH_CONFINEMENT, so the confinement check ImportErrors + out of delegation. + """ + import subprocess as _sp + import sys as _sys + + integration = ClaudeIntegration() + manifest = MagicMock(spec=IntegrationManifest) + manifest.files = {} + manifest.record_file = MagicMock() + manifest.record_existing = MagicMock() + install_integration_events( + integration, tmp_path, manifest, + {"session_start": [{"command": "speckit.boot"}]}, + ) + dispatcher = tmp_path / EVENTS_DISPATCHER_REL + + cmd_dir = tmp_path / ".specify" / "templates" / "commands" + cmd_dir.mkdir(parents=True) + out_file = tmp_path / "payload.out" + (cmd_dir / "boot.md").write_text( + "---\ndescription: \"Boot\"\nscripts:\n py: scripts/boot.py\n---\nBody\n", + encoding="utf-8", + ) + script_dir = tmp_path / ".specify" / "scripts" + script_dir.mkdir(parents=True) + script = script_dir / "boot.py" + script.write_text( + "import sys\n" + f"open({str(out_file)!r}, 'wb').write(sys.stdin.buffer.read())\n", + encoding="utf-8", + ) + + fake_dir = tmp_path / "_stale_pkg" + pkg = fake_dir / "specify_cli" + pkg.mkdir(parents=True) + (pkg / "__init__.py").write_text("", encoding="utf-8") + (pkg / "events.py").write_text( + "def resolve_and_run_event_command(*_a, **_k):\n" + " raise AssertionError('delegated path must not run')\n", + encoding="utf-8", + ) + env = dict(os.environ) + env["PYTHONPATH"] = str(fake_dir) + + payload = '{"key": "café"}' # non-ASCII exercises the utf-8 round trip + result = _sp.run( + [_sys.executable, str(dispatcher), "speckit.boot", "session_start", "60"], + input=payload, + capture_output=True, + text=True, + encoding="utf-8", + env=env, + cwd=str(tmp_path), + ) + assert result.returncode == 0, f"stdout={result.stdout!r} stderr={result.stderr!r}" + assert out_file.read_bytes() == payload.encode("utf-8") + def test_dispatcher_threads_per_handler_timeout(self, tmp_path): """S4: the generated dispatcher reads an optional 4th timeout arg and uses it for the inner subprocess, instead of a fixed 120s cap that From 96c9bd657bfd5de0d651a6165084932b7304ac99 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Thu, 10 Sep 2026 13:26:45 +0000 Subject: [PATCH 41/44] chore: bump version to 1.0.6 --- CHANGELOG.md | 25 +++++++++++++++++++++++++ pyproject.toml | 2 +- 2 files changed, 26 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index b657824be4..54f581ab04 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,31 @@ +## [1.0.6] - 2026-09-10 + +### Changed + +- fix(events): cap stdin in the generated dispatcher, not just the CLI command (#4337) +- docs(core): SPECIFY_FEATURE sets the feature label, not the feature directory (#3786) +- chore: shorten stale timeline to 60 days stale, 30 days to close (#4503) +- [extension] Update Spec Kit Schedule extension to v0.7.4 (#4498) +- [preset] Update Inventory Alignment preset to v0.1.1 (#4494) +- Update Spec Inventory extension to v0.1.1 (#4496) +- docs: document contribution evidence gate and label taxonomy (#4478) +- feat(workflows): add per-step integration configuration (#4425) +- fix: preserve extension authors in generated skills (#4459) +- Add ProductShape PRODUCT workflows extension to community catalog (#4485) +- Update AgentPay x402 extension to v1.1.0 (#4482) +- Update Figma Starter extension to v1.1.0 (#4490) +- Fix #4345 (3/4): CI guard requiring version bumps on bundled extension changes (#4395) +- fix(templates): report an unreadable extensions.yml instead of skipping hooks silently (#4456) +- Add concise code review skill (#4471) +- docs(templates): clarify /constitution's Sync Impact Report is temporary, review-only material (#4431) (#4432) +- fix(bundler): re-read the step registry when rolling back a failed step refresh (#4139) +- Fix August newsletter review findings (#4444) +- docs: resolve assess clarifications by editing artifacts in place (#4402) +- chore: release 1.0.5, begin 1.0.6.dev0 development (#4479) + ## [1.0.5] - 2026-09-08 ### Changed diff --git a/pyproject.toml b/pyproject.toml index 06e8f5df56..962bc01165 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "specify-cli" -version = "1.0.6.dev0" +version = "1.0.6" 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 af6abbbfbc681aafa5c0e1386b32434663622c71 Mon Sep 17 00:00:00 2001 From: Michael Wegener Date: Tue, 15 Sep 2026 05:53:30 +0200 Subject: [PATCH 42/44] chore(ipadp): bump metadata to v1.0.6, fork_version satware-v1.0.6 Aligns version, fork_version, and project_version fields with upstream release v1.0.6 following the sync/upstream-v1.0.4 pattern (#116). Assisted-by: Jane Alesi (GLM, autonomous) --- specs/metadata.json | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/specs/metadata.json b/specs/metadata.json index 9e9ca01024..060c3bdb99 100644 --- a/specs/metadata.json +++ b/specs/metadata.json @@ -1,7 +1,7 @@ { "name": "spec-kit", - "version": "1.0.4", - "fork_version": "satware-v1.0.4", + "version": "1.0.6", + "fork_version": "satware-v1.0.6", "sdd_source": "https://github.com/satwareAG/spec-kit", "forge": "github", "visibility": "public", @@ -77,5 +77,5 @@ ], "symlink_script": "$SATWARE_HARNESS/scripts/env-setup-symlinks.sh" }, - "project_version": "1.0.4" + "project_version": "1.0.6" } From f819371b239274fe1b83a8768eedb6459658cb2e Mon Sep 17 00:00:00 2001 From: Michael Wegener Date: Tue, 15 Sep 2026 06:32:53 +0200 Subject: [PATCH 43/44] chore(extensions): bump agent-context, assess, git to 1.0.1 after upstream content changes Upstream v1.0.5/v1.0.6 changed files under these bundled extensions without version bumps (the CI guard landed mid-cycle in v1.0.6, after those changes). The fork sync PR trips the new check_extension_version_bump guard; bumping satisfies it and - per the guard's own rationale - makes the changed content reach installed copies via 'specify extension update'. Assisted-by: Jane Alesi (GLM, autonomous) --- extensions/agent-context/extension.yml | 2 +- extensions/assess/extension.yml | 2 +- extensions/catalog.json | 6 +++--- extensions/git/extension.yml | 2 +- 4 files changed, 6 insertions(+), 6 deletions(-) diff --git a/extensions/agent-context/extension.yml b/extensions/agent-context/extension.yml index 191069e32c..080265c92e 100644 --- a/extensions/agent-context/extension.yml +++ b/extensions/agent-context/extension.yml @@ -3,7 +3,7 @@ schema_version: "1.0" extension: id: agent-context name: "Coding Agent Context" - version: "1.0.0" + version: "1.0.1" description: "Manages coding agent context/instruction files (e.g., CLAUDE.md, copilot-instructions.md) with project-specific plan references and configurable markers" author: spec-kit-core repository: https://github.com/github/spec-kit diff --git a/extensions/assess/extension.yml b/extensions/assess/extension.yml index 9161b268fb..42b281b8ee 100644 --- a/extensions/assess/extension.yml +++ b/extensions/assess/extension.yml @@ -3,7 +3,7 @@ schema_version: "1.0" extension: id: assess name: "Idea Assessment Pipeline" - version: "1.0.0" + version: "1.0.1" description: "Assess an idea before Spec-Driven Development via intake, research, define, shape, and decide. A go verdict hands off to /speckit.specify; a kill closes it. Lives under .specify/assessments//" category: "process" effect: "read-write" diff --git a/extensions/catalog.json b/extensions/catalog.json index d05c48e0e5..f5961f4bb2 100644 --- a/extensions/catalog.json +++ b/extensions/catalog.json @@ -6,7 +6,7 @@ "agent-context": { "name": "Coding Agent Context", "id": "agent-context", - "version": "1.0.0", + "version": "1.0.1", "description": "Manages coding agent context/instruction files (e.g., CLAUDE.md, copilot-instructions.md) with project-specific plan references and configurable markers", "author": "spec-kit-core", "repository": "https://github.com/github/spec-kit", @@ -20,7 +20,7 @@ "assess": { "name": "Idea Assessment Pipeline", "id": "assess", - "version": "1.0.0", + "version": "1.0.1", "description": "Assess an idea before Spec-Driven Development via intake, research, define, shape, and decide. A go verdict hands off to /speckit.specify; a kill closes it. Lives under .specify/assessments//", "author": "spec-kit-core", "repository": "https://github.com/github/spec-kit", @@ -51,7 +51,7 @@ "git": { "name": "Git Branching Workflow", "id": "git", - "version": "1.0.0", + "version": "1.0.1", "description": "Feature branch creation, numbering (sequential/timestamp), validation, and Git remote detection", "author": "spec-kit-core", "repository": "https://github.com/github/spec-kit", diff --git a/extensions/git/extension.yml b/extensions/git/extension.yml index c92322d8b1..90a7e56769 100644 --- a/extensions/git/extension.yml +++ b/extensions/git/extension.yml @@ -3,7 +3,7 @@ schema_version: "1.0" extension: id: git name: "Git Branching Workflow" - version: "1.0.0" + version: "1.0.1" description: "Feature branch creation, numbering (sequential/timestamp), templating, validation, and Git remote detection" author: spec-kit-core repository: https://github.com/github/spec-kit From 0136897c821b683a414b8c6d0c539d559138c598 Mon Sep 17 00:00:00 2001 From: Michael Wegener Date: Tue, 15 Sep 2026 06:42:29 +0200 Subject: [PATCH 44/44] test(extensions): align hardcoded version expectations with 1.0.1 bumps - git extension manifest test asserted the literal 1.0.0 - bundler init/local-install fixtures pinned agent-context at 1.0.0; the install preflight compares bundle pins against catalog.json and now rejects the stale pin Assisted-by: Jane Alesi (GLM, autonomous) --- tests/extensions/git/test_git_extension.py | 2 +- tests/integration/test_bundler_init_install.py | 2 +- tests/integration/test_bundler_local_install.py | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/tests/extensions/git/test_git_extension.py b/tests/extensions/git/test_git_extension.py index f6be51caf6..1760e496d1 100644 --- a/tests/extensions/git/test_git_extension.py +++ b/tests/extensions/git/test_git_extension.py @@ -145,7 +145,7 @@ def test_manifest_validates(self): m = ExtensionManifest(EXT_DIR / "extension.yml") assert m.id == "git" - assert m.version == "1.0.0" + assert m.version == "1.0.1" def test_manifest_commands(self): """Manifest declares expected commands.""" diff --git a/tests/integration/test_bundler_init_install.py b/tests/integration/test_bundler_init_install.py index a13def5ff8..b5e5b36aa1 100644 --- a/tests/integration/test_bundler_init_install.py +++ b/tests/integration/test_bundler_init_install.py @@ -75,7 +75,7 @@ def _build_mini(tmp_path: Path) -> Path: "license": "MIT", }, "requires": {"speckit_version": ">=0.1.0"}, - "provides": {"extensions": [{"id": "agent-context", "version": "1.0.0"}]}, + "provides": {"extensions": [{"id": "agent-context", "version": "1.0.1"}]}, } ), encoding="utf-8", diff --git a/tests/integration/test_bundler_local_install.py b/tests/integration/test_bundler_local_install.py index 630c981a73..a7fe9953db 100644 --- a/tests/integration/test_bundler_local_install.py +++ b/tests/integration/test_bundler_local_install.py @@ -116,7 +116,7 @@ def test_install_bundled_extension_from_zip_offline(tmp_path: Path): }, "requires": {"speckit_version": ">=0.1.0"}, "provides": { - "extensions": [{"id": "agent-context", "version": "1.0.0"}] + "extensions": [{"id": "agent-context", "version": "1.0.1"}] }, } ),