From 7eee05d0ed95d2984947b30a1fc25f0e23627880 Mon Sep 17 00:00:00 2001 From: Manfred Riem <15701806+mnriem@users.noreply.github.com> Date: Wed, 19 Aug 2026 09:22:40 -0500 Subject: [PATCH 001/102] chore: release 0.16.5, begin 0.16.6.dev0 development (#4206) * chore: bump version to 0.16.5 * chore: begin 0.16.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 f1805a31d1..0ef915b936 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,31 @@ +## [0.16.5] - 2026-08-19 + +### Changed + +- fix(powershell): stop Out-Null swallowing setup-tasks AVAILABLE_DOCS lines (#4188) +- fix: provision Spec Kit CLI and assess extension in feature-assess host setup steps (#4195) +- fix: provision uv and Python for feature-assess workflow (#4193) +- feat: add feature-assess agentic workflow that installs and runs Spec Kit (#4186) +- [extension] Update Superpowers Implementation Bridge to v1.2.0 (#4183) +- fix(init): stop specify init hanging on arrow-key pickers in agent harnesses (#4178) +- [extension] Add DUBSAR Memory extension to community catalog (#4170) +- Add AgentPay x402 extension to community catalog (#4174) +- Update Keel Discovery extension to v0.2.0 (#4172) +- fix: confine event hook script paths to the project tree (#4133) +- Clarify extension catalog trust model in docs, help, and messaging (#4177) +- Add pay-x402 community extension with correct catalog-addition timestamps (#4175) +- Add ASCII Diagram Renderer extension to community catalog (#4173) +- Update Intake Review Governance preset to v0.2.1 (#4169) +- test(presets): normalize whitespace in resolve output assertion to prevent terminal line-wrap failures (#4166) +- fix(workflows): clean up download temp file on interrupt or typer.Exit (#4134) +- fix(workflows): report a falsy non-mapping overlay manifest as a shape error (#3884) +- fix(bundler): resolve built-in step types when checking bundle component references (#3885) +- Add SpecAssay bundle to community catalog (#4125) +- chore: release 0.16.4, begin 0.16.5.dev0 development (#4124) + ## [0.16.4] - 2026-08-14 ### Changed diff --git a/pyproject.toml b/pyproject.toml index e7f675f3a0..5563328245 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "specify-cli" -version = "0.16.5.dev0" +version = "0.16.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 92e8ab56b4ea4f10351c601370e6d6ae6006eb16 Mon Sep 17 00:00:00 2001 From: Noor ul ain Date: Thu, 20 Aug 2026 02:18:40 +0500 Subject: [PATCH 002/102] fix(utils): narrow bare except Exception in merge_json_files (#4189) merge_json_files's read of the existing JSON file caught bare `Exception` around `json5.load`, so a real bug there (e.g. a `TypeError`/`AttributeError`) was silently treated the same as a normal parse failure -- `None` returned, existing settings preserved untouched, nothing surfaced unless `verbose`. Only `OSError` (inaccessible file) and `ValueError` (malformed JSON5 -- json5's decode error is a `ValueError` subclass) are expected outcomes here; anything else should propagate. Same bug, same fix shape, as the caller `handle_vscode_settings`, whose own bare `except Exception` was just narrowed to `(OSError, ValueError, KeyError)` in commit 16f4577 (PR #3844) with the same rationale ("let programming errors like TypeError or AttributeError propagate"). That PR's own regression test monkeypatched `merge_json_files` to prove the caller's narrowing works; this fixes and tests the callee itself, which still had the original bare-except bug. Co-authored-by: Claude Sonnet 5 --- src/specify_cli/_utils.py | 2 +- tests/test_merge.py | 26 ++++++++++++++++++++++++++ 2 files changed, 27 insertions(+), 1 deletion(-) diff --git a/src/specify_cli/_utils.py b/src/specify_cli/_utils.py index f2364f6d43..0562ea0142 100644 --- a/src/specify_cli/_utils.py +++ b/src/specify_cli/_utils.py @@ -249,7 +249,7 @@ def merge_json_files(existing_path: Path, new_content: Any, verbose: bool = Fals except FileNotFoundError: # Handle race condition where file is deleted after exists() check exists = False - except Exception as e: + except (OSError, ValueError) as e: if verbose: console.print(f"[yellow]Warning: Could not read or parse existing JSON in {existing_path.name} ({e}).[/yellow]") # Skip merge to preserve existing file if unparseable or inaccessible (e.g. PermissionError) diff --git a/tests/test_merge.py b/tests/test_merge.py index 6b1eb1c2fc..45889ffdd7 100644 --- a/tests/test_merge.py +++ b/tests/test_merge.py @@ -212,3 +212,29 @@ def test_handle_vscode_settings_propagates_programming_errors(tmp_path): ) finally: utils_mod.merge_json_files = original_merge + + +def test_merge_json_files_propagates_programming_errors(tmp_path, monkeypatch): + """Unexpected programming errors reading the existing file must propagate. + + ``merge_json_files``'s own read of the existing JSON file caught bare + ``Exception`` around ``json5.load``, so a real bug there (e.g. a + ``TypeError``) was silently treated the same as a normal parse failure -- + ``None`` returned, existing settings preserved, nothing logged unless + ``verbose``. Only ``OSError`` (inaccessible file) and ``ValueError`` + (malformed JSON5 -- json5's decode error is a ``ValueError`` subclass) + are expected outcomes here; anything else must propagate, matching the + narrowing already applied to the caller, ``handle_vscode_settings``. + """ + existing_file = tmp_path / "settings.json" + existing_file.write_text('{"a": 1}\n', encoding="utf-8") + + import specify_cli._utils as utils_mod + + def _boom(*_a, **_kw): + raise TypeError("boom") + + monkeypatch.setattr(utils_mod.json5, "load", _boom) + + with pytest.raises(TypeError): + merge_json_files(existing_file, {"b": 2}) From b7a6a6ec45a3cd6d89e56377a75656f16e6d0427 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Wed, 19 Aug 2026 16:20:15 -0500 Subject: [PATCH 003/102] Add Closed Vocabulary Check preset to community catalog (#4201) Add closed-vocabulary preset submitted by @yunusdim to: - presets/catalog.community.json (alphabetical order) - docs/community/presets.md community presets table Closes #4192 Assisted-by: GitHub Copilot (model: claude-sonnet-4.6, autonomous) Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com> Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- docs/community/presets.md | 1 + presets/catalog.community.json | 29 ++++++++++++++++++++++++++++- 2 files changed, 29 insertions(+), 1 deletion(-) diff --git a/docs/community/presets.md b/docs/community/presets.md index 2b8f56b319..805b50bf70 100644 --- a/docs/community/presets.md +++ b/docs/community/presets.md @@ -14,6 +14,7 @@ The following community-contributed presets customize how Spec Kit behaves — o | Autonomous Run Governance | Adds permission-bounded autonomous delivery, an optional intake-review gate, and preservation of the project's learner and accessibility contract. | 13 templates, 5 commands, 4 scripts | — | [spec-kit-preset-autonomous-run-governance](https://github.com/hindermath/spec-kit-preset-autonomous-run-governance) | | Canon Core | Adapts original Spec Kit workflow to work together with Canon extension | 2 templates, 8 commands | — | [spec-kit-canon](https://github.com/maximiliamus/spec-kit-canon) | | Claude AskUserQuestion | Upgrades `/speckit.clarify` and `/speckit.checklist` on Claude Code from Markdown-table prompts to the native AskUserQuestion picker, with a recommended option and reasoning on every question | 2 commands | — | [spec-kit-preset-claude-ask-questions](https://github.com/0xrafasec/spec-kit-preset-claude-ask-questions) | +| Closed Vocabulary Check | Adds a pass to /speckit.analyze that flags closed sets of values enumerated more than once with different members, and reports its own coverage. | 1 command | — | [spec-kit-preset-closed-vocabulary](https://github.com/yunusdim/spec-kit-preset-closed-vocabulary) | | Command Density | Compacts the nine core Spec Kit command prompts while preserving scripts, handoffs, placeholders, hook output blocks, and rule structure | 9 commands | — | [spec-kit-preset-command-density](https://github.com/Xopoko/spec-kit-preset-command-density) | | Cross-Platform Governance | Adds Bash/PowerShell parity, read-only checks, path and native-override review, Unix man pages, bilingual PowerShell help, and provider-neutral model routing. | 9 templates, 3 commands | — | [spec-kit-preset-cross-platform-governance](https://github.com/hindermath/spec-kit-preset-cross-platform-governance) | | Explicit Task Dependencies | Adds explicit `(depends on T###)` dependency declarations and an Execution Wave DAG to tasks.md for parallel scheduling | 1 template, 1 command | — | [spec-kit-preset-explicit-task-dependencies](https://github.com/Quratulain-bilal/spec-kit-preset-explicit-task-dependencies) | diff --git a/presets/catalog.community.json b/presets/catalog.community.json index 788a5d78c5..53cc82f1dc 100644 --- a/presets/catalog.community.json +++ b/presets/catalog.community.json @@ -1,6 +1,6 @@ { "schema_version": "1.0", - "updated_at": "2026-08-17T00:00:00Z", + "updated_at": "2026-08-19T00:00:00Z", "catalog_url": "https://raw.githubusercontent.com/github/spec-kit/main/presets/catalog.community.json", "presets": { "a11y-governance": { @@ -197,6 +197,33 @@ "created_at": "2026-04-13T00:00:00Z", "updated_at": "2026-04-13T00:00:00Z" }, + "closed-vocabulary": { + "name": "Closed Vocabulary Check", + "id": "closed-vocabulary", + "version": "1.0.1", + "description": "Adds a pass to /speckit.analyze that flags closed sets of values enumerated more than once with different members, and reports its own coverage.", + "author": "Diego Gabriel Impieri", + "repository": "https://github.com/yunusdim/spec-kit-preset-closed-vocabulary", + "download_url": "https://github.com/yunusdim/spec-kit-preset-closed-vocabulary/archive/refs/tags/v1.0.1.zip", + "homepage": "https://github.com/yunusdim/spec-kit-preset-closed-vocabulary", + "documentation": "https://github.com/yunusdim/spec-kit-preset-closed-vocabulary/blob/main/README.md", + "license": "MIT", + "requires": { + "speckit_version": ">=0.8.0" + }, + "provides": { + "templates": 0, + "commands": 1 + }, + "tags": [ + "analysis", + "consistency", + "vocabulary", + "verification" + ], + "created_at": "2026-08-19T00:00:00Z", + "updated_at": "2026-08-19T00:00:00Z" + }, "command-density": { "name": "Command Density", "id": "command-density", From 14bbfd52e5f59cb435ca8a555929451b25b6f635 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Wed, 19 Aug 2026 16:21:11 -0500 Subject: [PATCH 004/102] Update Atlas extension display name in community catalog (#4202) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Update atlas extension submitted by @ashbrener to: - extensions/catalog.community.json (name: spec-kit-atlas → Atlas) - docs/community/extensions.md community extensions table Closes #4196 Assisted-by: GitHub Copilot (model: claude-sonnet-4.6, autonomous) Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com> Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- docs/community/extensions.md | 2 +- extensions/catalog.community.json | 6 +++--- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/docs/community/extensions.md b/docs/community/extensions.md index 1de44ad152..3c7cc37735 100644 --- a/docs/community/extensions.md +++ b/docs/community/extensions.md @@ -38,7 +38,7 @@ The following community-contributed extensions are available in [`catalog.commun | Architecture Workflow | Generate or reverse project-level 4+1 architecture views with per-view and full-workflow commands | `docs` | Read+Write | [spec-kit-arch](https://github.com/bigsmartben/spec-kit-arch) | | Archive Extension | Archive merged features into main project memory, 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) | -| spec-kit-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) | +| 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) | | Azure DevOps Integration | Sync user stories and tasks to Azure DevOps work items using OAuth authentication | `integration` | Read+Write | [spec-kit-azure-devops](https://github.com/pragya247/spec-kit-azure-devops) | | Blueprint | Stay code-literate in AI-driven development: review a complete code blueprint for every task from spec artifacts before /speckit.implement runs | `docs` | Read+Write | [spec-kit-blueprint](https://github.com/chordpli/spec-kit-blueprint) | | 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) | diff --git a/extensions/catalog.community.json b/extensions/catalog.community.json index 15174e83b3..2a5fb3aa83 100644 --- a/extensions/catalog.community.json +++ b/extensions/catalog.community.json @@ -1,6 +1,6 @@ { "schema_version": "1.0", - "updated_at": "2026-08-18T00:00:00Z", + "updated_at": "2026-08-19T00:00:00Z", "catalog_url": "https://raw.githubusercontent.com/github/spec-kit/main/extensions/catalog.community.json", "extensions": { "adrkit": { @@ -463,7 +463,7 @@ "updated_at": "2026-08-17T00:00:00Z" }, "atlas": { - "name": "spec-kit-atlas", + "name": "Atlas", "id": "atlas", "description": "Synthesize spec-kit specs into faithful, interactive architecture storybooks & doc portals.", "author": "Ash Brener", @@ -498,7 +498,7 @@ "downloads": 0, "stars": 0, "created_at": "2026-08-13T00:00:00Z", - "updated_at": "2026-08-13T00:00:00Z" + "updated_at": "2026-08-19T00:00:00Z" }, "azure-devops": { "name": "Azure DevOps Integration", From 7e48738e261afb0ca765eced1b8dc29fb60c838b Mon Sep 17 00:00:00 2001 From: WOLIKIMCHENG <35391914+WOLIKIMCHENG@users.noreply.github.com> Date: Thu, 20 Aug 2026 05:23:50 +0800 Subject: [PATCH 005/102] fix(workflows): validate dispatch defaults (#4181) * fix(workflows): validate dispatch defaults * fix(workflows): validate dispatch defaults on resume --------- Co-authored-by: root --- src/specify_cli/workflows/engine.py | 61 ++++++++- tests/test_workflows.py | 196 ++++++++++++++++++++++++++++ 2 files changed, 252 insertions(+), 5 deletions(-) diff --git a/src/specify_cli/workflows/engine.py b/src/specify_cli/workflows/engine.py index a74450ed9a..d17513cc0b 100644 --- a/src/specify_cli/workflows/engine.py +++ b/src/specify_cli/workflows/engine.py @@ -61,11 +61,15 @@ def __init__(self, data: dict[str, Any], source_path: Path | None = None) -> Non self.schema_version: str = data.get("schema_version", "1.0") # Defaults - self.default_integration: str | None = workflow.get("integration") - self.default_model: str | None = workflow.get("model") - self.default_options: dict[str, Any] = workflow.get("options") or {} - if not isinstance(self.default_options, dict): - self.default_options = {} + # Keep malformed values intact until ``validate_workflow`` can report + # them. ``None`` remains the supported "no defaults" form for options + # and retains its existing runtime representation as an empty mapping. + self.default_integration: Any = workflow.get("integration") + self.default_model: Any = workflow.get("model") + raw_default_options = workflow.get("options") + self.default_options: Any = ( + {} if raw_default_options is None else raw_default_options + ) # Advisory pre-conditions (spec-kit version / integrations a workflow # expects). Validated by ``validate_workflow`` (recognized keys only; @@ -140,6 +144,40 @@ def _get_valid_step_types() -> set[str]: } +def _dispatch_default_errors(definition: WorkflowDefinition) -> list[str]: + """Return validation errors for workflow defaults inherited by dispatch steps.""" + errors: list[str] = [] + + if ( + definition.default_integration is not None + and not isinstance(definition.default_integration, str) + ): + errors.append( + "'workflow.integration' must be a string or null, got " + f"{type(definition.default_integration).__name__} " + f"({definition.default_integration!r})." + ) + + if ( + definition.default_model is not None + and not isinstance(definition.default_model, str) + ): + errors.append( + "'workflow.model' must be a string or null, got " + f"{type(definition.default_model).__name__} " + f"({definition.default_model!r})." + ) + + if not isinstance(definition.default_options, dict): + errors.append( + "'workflow.options' must be a mapping or null, got " + f"{type(definition.default_options).__name__} " + f"({definition.default_options!r})." + ) + + return errors + + def validate_workflow(definition: WorkflowDefinition) -> list[str]: """Validate a workflow definition and return a list of error messages. @@ -197,6 +235,11 @@ def validate_workflow(definition: WorkflowDefinition) -> list[str]: f"semantic versioning (expected X.Y.Z)." ) + # Workflow-level dispatch defaults are inherited by command and prompt + # steps. Validate their shapes before an invalid value reaches dispatch, or + # (for options) is silently normalized away during construction. + errors.extend(_dispatch_default_errors(definition)) + # -- Inputs ----------------------------------------------------------- if not isinstance(definition.inputs, dict): errors.append("'inputs' must be a mapping (or omitted).") @@ -947,6 +990,10 @@ def execute( ------- The final ``RunState`` after execution completes (or pauses). """ + dispatch_default_errors = _dispatch_default_errors(definition) + if dispatch_default_errors: + raise ValueError(" ".join(dispatch_default_errors)) + from . import STEP_REGISTRY effective_run_id = run_id @@ -1048,6 +1095,10 @@ def resume( else: definition = self.load_workflow(state.workflow_id) + dispatch_default_errors = _dispatch_default_errors(definition) + if dispatch_default_errors: + raise ValueError(" ".join(dispatch_default_errors)) + # Merge any newly-supplied inputs over the persisted ones and # re-validate through the same typing path as the initial run. if inputs: diff --git a/tests/test_workflows.py b/tests/test_workflows.py index 2242daad97..60a9b9ce8b 100644 --- a/tests/test_workflows.py +++ b/tests/test_workflows.py @@ -4520,6 +4520,94 @@ def test_unquoted_schema_version_accepted(self): errors = validate_workflow(definition) assert errors == [] + @pytest.mark.parametrize( + "field, bad_value", + [ + ("integration", ["claude"]), + ("integration", {"name": "claude"}), + ("integration", False), + ("model", ["gpt-5"]), + ("model", {"name": "gpt-5"}), + ("model", 0), + ("options", ["max_tokens"]), + ("options", "max_tokens"), + ("options", False), + ], + ) + def test_rejects_invalid_workflow_dispatch_defaults(self, field, bad_value): + """Top-level dispatch defaults must retain their invalid shape for + validation instead of being passed to a step or normalized to ``{}``. + """ + from specify_cli.workflows.engine import WorkflowDefinition, validate_workflow + + definition = WorkflowDefinition( + { + "workflow": { + "id": "test", + "name": "Test", + "version": "1.0.0", + field: bad_value, + }, + "steps": [{"id": "step-one", "command": "speckit.specify"}], + } + ) + + errors = validate_workflow(definition) + + assert any(f"workflow.{field}" in error for error in errors), errors + assert any(type(bad_value).__name__ in error for error in errors), errors + if field == "options": + assert definition.default_options == bad_value + + def test_preserves_valid_workflow_dispatch_defaults(self): + """String and mapping defaults stay available unchanged to steps.""" + from specify_cli.workflows.engine import WorkflowDefinition, validate_workflow + + defaults = { + "integration": "claude", + "model": "gpt-5", + "options": {"max_tokens": 8000}, + } + definition = WorkflowDefinition( + { + "workflow": { + "id": "test", + "name": "Test", + "version": "1.0.0", + **defaults, + }, + "steps": [{"id": "step-one", "command": "speckit.specify"}], + } + ) + + assert definition.default_integration == defaults["integration"] + assert definition.default_model == defaults["model"] + assert definition.default_options == defaults["options"] + assert validate_workflow(definition) == [] + + def test_accepts_null_workflow_dispatch_defaults(self): + """Null integration/model inherit at runtime and null options stays {}.""" + from specify_cli.workflows.engine import WorkflowDefinition, validate_workflow + + definition = WorkflowDefinition( + { + "workflow": { + "id": "test", + "name": "Test", + "version": "1.0.0", + "integration": None, + "model": None, + "options": None, + }, + "steps": [{"id": "step-one", "command": "speckit.specify"}], + } + ) + + assert definition.default_integration is None + assert definition.default_model is None + assert definition.default_options == {} + assert validate_workflow(definition) == [] + def test_no_steps(self): from specify_cli.workflows.engine import WorkflowDefinition, validate_workflow @@ -5165,6 +5253,36 @@ def test_malformed_inputs_block_no_cascade(self): class TestWorkflowEngine: """Test WorkflowEngine execution.""" + @pytest.mark.parametrize( + ("field", "value"), + [ + ("integration", ["claude"]), + ("model", {"name": "gpt-5"}), + ("options", ["max_tokens"]), + ], + ) + def test_execute_rejects_invalid_workflow_dispatch_defaults( + self, project_dir, field, value + ): + from specify_cli.workflows.engine import WorkflowDefinition, WorkflowEngine + + definition = WorkflowDefinition( + { + "workflow": { + "id": "invalid-dispatch-defaults", + "name": "Invalid dispatch defaults", + "version": "1.0.0", + field: value, + }, + "steps": [], + } + ) + + with pytest.raises(ValueError, match=f"workflow.{field}"): + WorkflowEngine(project_dir).execute(definition) + + assert not (project_dir / ".specify" / "workflows" / "runs").exists() + def test_load_from_file(self, sample_workflow_file, project_dir): from specify_cli.workflows.engine import WorkflowEngine @@ -6684,6 +6802,45 @@ def test_workflow_dir_is_resolved_to_absolute(self, project_dir): # and abort the run. +class TestWorkflowDispatchDefaultExecution: + """Execution safeguards for defaults inherited by dispatch steps.""" + + @pytest.mark.parametrize( + "defaults", + [ + { + "integration": "claude", + "model": "gpt-5", + "options": {"max_tokens": 8000}, + }, + {"integration": None, "model": None, "options": None}, + ], + ) + def test_execute_accepts_valid_and_null_dispatch_defaults( + self, project_dir, defaults + ): + """Defaults with supported shapes remain executable without validation.""" + from specify_cli.workflows.base import RunStatus + from specify_cli.workflows.engine import WorkflowDefinition, WorkflowEngine + + definition = WorkflowDefinition( + { + "workflow": { + "id": "valid-defaults", + "name": "Valid Defaults", + "version": "1.0.0", + **defaults, + }, + "steps": [], + } + ) + + state = WorkflowEngine(project_dir).execute(definition) + + assert state.status == RunStatus.COMPLETED + assert state.step_results == {} + + class TestContinueOnError: """Test the `continue_on_error` step-level field.""" @@ -10962,6 +11119,45 @@ def test_resume_invalid_typed_input_raises(self, project_dir): with pytest.raises(ValueError): engine.resume(state.run_id, {"count": "not-a-number"}) + def test_resume_rejects_legacy_invalid_options_before_state_mutation( + self, project_dir, monkeypatch + ): + from specify_cli.workflows.base import RunStatus + from specify_cli.workflows.engine import RunState, WorkflowDefinition + + definition = WorkflowDefinition.from_string(self._WF_NUM) + engine = self._engine(project_dir) + state = engine.execute(definition) + assert state.status == RunStatus.PAUSED + + workflow_copy = ( + project_dir + / ".specify" + / "workflows" + / "runs" + / state.run_id + / "workflow.yml" + ) + workflow_copy.write_text( + self._WF_NUM.replace( + 'version: "1.0.0"', 'version: "1.0.0"\n options: [max_tokens]' + ), + encoding="utf-8", + ) + + def fail_step_context(*args, **kwargs): + raise AssertionError("StepContext must not be created") + + monkeypatch.setattr("specify_cli.workflows.engine.StepContext", fail_step_context) + + with pytest.raises(ValueError, match="'workflow.options' must be a mapping or null"): + engine.resume(state.run_id, {"count": "5"}) + + reloaded = RunState.load(state.run_id, project_dir) + assert reloaded.status == RunStatus.PAUSED + assert reloaded.error is None + assert reloaded.inputs["count"] == 1 + def test_retry_verdict_input_is_consumed_and_can_be_replaced(self, project_dir): import json as _json from specify_cli.workflows.engine import WorkflowDefinition From e3e6a3c87ba4f7b6138856b483becf8e69cc9610 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Wed, 19 Aug 2026 16:26:48 -0500 Subject: [PATCH 006/102] Update Autonomous Run Governance preset to v0.4.1 (#4203) Update autonomous-run-governance preset submitted by @hindermath to: - presets/catalog.community.json (version, download_url, documentation, description, provides, tags, updated_at) - docs/community/presets.md community presets table Closes #4153 Assisted-by: GitHub Copilot (model: claude-sonnet-4.6, autonomous) Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com> Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- docs/community/presets.md | 2 +- presets/catalog.community.json | 16 ++++++++-------- 2 files changed, 9 insertions(+), 9 deletions(-) diff --git a/docs/community/presets.md b/docs/community/presets.md index 805b50bf70..02d376a850 100644 --- a/docs/community/presets.md +++ b/docs/community/presets.md @@ -11,7 +11,7 @@ The following community-contributed presets customize how Spec Kit behaves — o | Agent Parity Governance | Adds shared-guidance parity, fleet-completion evidence, secret-free runner metadata, audit-ready Spec Kit evidence, and agent-neutral model routing across declared AI-agent surfaces. | 7 templates, 3 commands | — | [spec-kit-preset-agent-parity-governance](https://github.com/hindermath/spec-kit-preset-agent-parity-governance) | | AIDE In-Place Migration | Adapts the AIDE extension workflow for in-place technology migrations (X → Y pattern) — adds migration objectives, verification gates, knowledge documents, and behavioral equivalence criteria | 2 templates, 8 commands | AIDE extension | [spec-kit-presets](https://github.com/mnriem/spec-kit-presets) | | Architecture Governance | Adds secure architecture, STRIDE/CAPEC threat modeling, arc42/S-ADR guidance, Zero Trust, SAMM, BSI cloud assurance, audit evidence, and provider-neutral model routing. | 14 templates, 3 commands | — | [spec-kit-preset-architecture-governance](https://github.com/hindermath/spec-kit-preset-architecture-governance) | -| Autonomous Run Governance | Adds permission-bounded autonomous delivery, an optional intake-review gate, and preservation of the project's learner and accessibility contract. | 13 templates, 5 commands, 4 scripts | — | [spec-kit-preset-autonomous-run-governance](https://github.com/hindermath/spec-kit-preset-autonomous-run-governance) | +| Autonomous Run Governance | Adds permission-bounded autonomous delivery with validated delivery sets, semantic phase completion, and lifecycle-bound exact-head evidence. | 15 templates, 5 commands, 11 scripts | — | [spec-kit-preset-autonomous-run-governance](https://github.com/hindermath/spec-kit-preset-autonomous-run-governance) | | Canon Core | Adapts original Spec Kit workflow to work together with Canon extension | 2 templates, 8 commands | — | [spec-kit-canon](https://github.com/maximiliamus/spec-kit-canon) | | Claude AskUserQuestion | Upgrades `/speckit.clarify` and `/speckit.checklist` on Claude Code from Markdown-table prompts to the native AskUserQuestion picker, with a recommended option and reasoning on every question | 2 commands | — | [spec-kit-preset-claude-ask-questions](https://github.com/0xrafasec/spec-kit-preset-claude-ask-questions) | | Closed Vocabulary Check | Adds a pass to /speckit.analyze that flags closed sets of values enumerated more than once with different members, and reports its own coverage. | 1 command | — | [spec-kit-preset-closed-vocabulary](https://github.com/yunusdim/spec-kit-preset-closed-vocabulary) | diff --git a/presets/catalog.community.json b/presets/catalog.community.json index 53cc82f1dc..66e8521677 100644 --- a/presets/catalog.community.json +++ b/presets/catalog.community.json @@ -120,31 +120,31 @@ "autonomous-run-governance": { "name": "Autonomous Run Governance", "id": "autonomous-run-governance", - "version": "0.3.3", - "description": "Adds permission-bounded autonomous delivery, an optional intake-review gate, and preservation of the project's learner and accessibility contract.", + "version": "0.4.1", + "description": "Adds permission-bounded autonomous delivery with validated delivery sets, semantic phase completion, and lifecycle-bound exact-head evidence.", "author": "Thorsten Hindermann", "repository": "https://github.com/hindermath/spec-kit-preset-autonomous-run-governance", - "download_url": "https://github.com/hindermath/spec-kit-preset-autonomous-run-governance/archive/refs/tags/v0.3.3.zip", + "download_url": "https://github.com/hindermath/spec-kit-preset-autonomous-run-governance/archive/refs/tags/v0.4.1.zip", "homepage": "https://github.com/hindermath/spec-kit-preset-autonomous-run-governance", - "documentation": "https://github.com/hindermath/spec-kit-preset-autonomous-run-governance/blob/v0.3.3/README.md", + "documentation": "https://github.com/hindermath/spec-kit-preset-autonomous-run-governance/blob/v0.4.1/README.md", "license": "MIT", "requires": { "speckit_version": ">=0.8.3" }, "provides": { - "templates": 13, + "templates": 15, "commands": 5, - "scripts": 4 + "scripts": 11 }, "tags": [ "autonomous", "governance", "evidence", "permissions", - "accessibility" + "sdd" ], "created_at": "2026-07-13T00:00:00Z", - "updated_at": "2026-07-28T00:00:00Z" + "updated_at": "2026-08-19T00:00:00Z" }, "canon-core": { "name": "Canon Core", From ead30d9cfb99c07b3073afa73e7b40a64015f17f Mon Sep 17 00:00:00 2001 From: Noor ul ain Date: Thu, 20 Aug 2026 02:56:31 +0500 Subject: [PATCH 007/102] fix(integrations): report a falsy non-mapping integration descriptor as a shape error (#4187) * fix(integrations): report a falsy non-mapping integration descriptor as a shape error `IntegrationDescriptor._load` did `yaml.safe_load(fh) or {}`. `_validate` opens with an `isinstance(self.data, dict)` check, so a truthy non-mapping (`- a`, `hello`) is reported correctly -- but `or {}` replaced the falsy non-mappings with an empty mapping first, so those descriptors were reported as "Missing required field: schema_version" instead of the wrong shape: 'false' -> Descriptor root must be a YAML mapping, got bool '0' -> Descriptor root must be a YAML mapping, got int "''" -> Descriptor root must be a YAML mapping, got str '[]' -> Descriptor root must be a YAML mapping, got list `safe_load` also returns None for an explicit null scalar (`null`, `~`, `NULL`) as well as for an empty document, so those three hit the same masking. Use `yaml.compose`, which yields no node only for a genuinely empty document, to tell the two apart -- only an empty document still normalizes to `{}` and reports its missing fields. Same bug class just fixed in the sibling overlay-manifest loader (upstream commit 39c36c4, PR #3884); this is the unfixed twin in the integration catalog's descriptor loader. Co-Authored-By: Claude Sonnet 5 * Potential fix for pull request finding Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> * Potential fix for pull request finding Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> * 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/integrations/catalog.py | 29 +++++++++++++++--- .../integrations/test_integration_catalog.py | 30 +++++++++++++++++++ 2 files changed, 55 insertions(+), 4 deletions(-) diff --git a/src/specify_cli/integrations/catalog.py b/src/specify_cli/integrations/catalog.py index e18d30a6fa..e93dab5185 100644 --- a/src/specify_cli/integrations/catalog.py +++ b/src/specify_cli/integrations/catalog.py @@ -674,16 +674,37 @@ def __init__(self, descriptor_path: Path) -> None: @staticmethod def _load(path: Path) -> dict: try: - with open(path, "r", encoding="utf-8") as fh: - return yaml.safe_load(fh) or {} - except yaml.YAMLError as exc: - raise IntegrationDescriptorError(f"Invalid YAML in {path}: {exc}") + text = path.read_text(encoding="utf-8") except FileNotFoundError: raise IntegrationDescriptorError(f"Descriptor not found: {path}") except (OSError, UnicodeError) as exc: raise IntegrationDescriptorError( f"Unable to read descriptor {path}: {exc}" ) + try: + # ``safe_load`` returns None for BOTH an empty document and an + # explicit null scalar (``null``, ``~``, ``Null``, ``NULL``), so it + # cannot tell them apart on its own. ``compose`` yields no node + # only for a genuinely empty document. + node = yaml.compose(text) + data = yaml.safe_load(text) + is_empty_document = node is None or ( + data is None + and isinstance(node, yaml.nodes.ScalarNode) + and node.value == "" + and node.start_mark.index == node.end_mark.index + ) + except yaml.YAMLError as exc: + raise IntegrationDescriptorError(f"Invalid YAML in {path}: {exc}") + # Only a genuinely EMPTY document becomes an empty mapping, so its + # missing-field errors are reported. Every non-mapping document -- + # including an explicit ``null``/``~`` and the falsy shapes ``[]``, + # ``false``, ``0``, ``''`` that a plain ``or {}`` would mask -- must + # reach ``_validate`` unchanged so it reports the wrong descriptor + # shape, like the truthy twins (``- a``, ``hello``) already do. + if is_empty_document: + data = {} + return data # -- Validation ------------------------------------------------------- diff --git a/tests/integrations/test_integration_catalog.py b/tests/integrations/test_integration_catalog.py index 9b02632992..87ab98a4d0 100644 --- a/tests/integrations/test_integration_catalog.py +++ b/tests/integrations/test_integration_catalog.py @@ -700,6 +700,36 @@ def test_scripts_not_a_list(self, tmp_path): with pytest.raises(IntegrationDescriptorError, match="expected a list"): IntegrationDescriptor(p) + @pytest.mark.parametrize( + "content", ["[]", "false", "0", "''", "null", "~", "NULL", "- a", "hello"] + ) + def test_falsy_non_mapping_descriptor_reports_shape_error(self, tmp_path, content): + """Every non-mapping document reports the mapping-shape error. + + `_validate` opens with an `isinstance(self.data, dict)` check, so a + truthy non-mapping (`- a`, `hello`) correctly reported "Descriptor root + must be a YAML mapping". `_load`'s plain `yaml.safe_load(fh) or {}` + masked that for the falsy shapes `[]`, `false`, `0`, `''` (coerced to + an empty mapping) and for an explicit null scalar (`null`, `~`, `NULL` + -- indistinguishable from an empty document by `safe_load` alone), so + those five reported "Missing required field: schema_version" instead. + """ + p = tmp_path / "integration.yml" + p.write_text(content) + with pytest.raises( + IntegrationDescriptorError, + match="Descriptor root must be a YAML mapping", + ): + IntegrationDescriptor(p) + + @pytest.mark.parametrize("content", ["", "---"]) + def test_empty_document_still_reports_missing_fields(self, tmp_path, content): + """Empty documents are normalized to an empty mapping, so missing fields are reported.""" + p = tmp_path / "integration.yml" + p.write_text(content) + with pytest.raises(IntegrationDescriptorError, match="Missing required field: schema_version"): + IntegrationDescriptor(p) + def test_file_not_found(self, tmp_path): with pytest.raises(IntegrationDescriptorError, match="Descriptor not found"): IntegrationDescriptor(tmp_path / "nonexistent.yml") From ad057b586f654836e8c4aef7ec20b8dd45873dee Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Thu, 20 Aug 2026 07:25:17 -0500 Subject: [PATCH 008/102] [extension] Add AgentDocx extension to community catalog (#4184) * Add AgentDocx extension to community catalog Add agentdocx-speckit extension submitted by @abir-ommezzine to: - extensions/catalog.community.json (alphabetical order) - docs/community/extensions.md community extensions table Closes #4171 Assisted-by: GitHub Copilot (model: claude-sonnet-4.6, autonomous) Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * Move agentdocx-speckit catalog entry into alphabetical position Assisted-by: GitHub Copilot (model: unknown, autonomous) Co-authored-by: mnriem <15701806+mnriem@users.noreply.github.com> * Align AgentDocx category with published manifest (integration) Assisted-by: GitHub Copilot (model: GPT-5.2-Codex, autonomous) Co-authored-by: mnriem <15701806+mnriem@users.noreply.github.com> --------- Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com> Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com> Co-authored-by: mnriem <15701806+mnriem@users.noreply.github.com> --- docs/community/extensions.md | 1 + extensions/catalog.community.json | 35 +++++++++++++++++++++++++++++++ 2 files changed, 36 insertions(+) diff --git a/docs/community/extensions.md b/docs/community/extensions.md index 3c7cc37735..40f1bdcdaa 100644 --- a/docs/community/extensions.md +++ b/docs/community/extensions.md @@ -28,6 +28,7 @@ The following community-contributed extensions are available in [`catalog.commun | adrkit — decision memory for spec-driven development | Pulls the decisions governing this work into agent context, checks produced plans against them, and drafts an ADR from a plan artifact | `process` | Read+Write | [adrkit](https://github.com/mbeacom/adrkit) | | Agent Assign | Assign specialized Claude Code agents to spec-kit tasks for targeted execution | `process` | Read+Write | [spec-kit-agent-assign](https://github.com/xymelon/spec-kit-agent-assign) | | 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) | | 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) | | 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) | diff --git a/extensions/catalog.community.json b/extensions/catalog.community.json index 2a5fb3aa83..f14877c1be 100644 --- a/extensions/catalog.community.json +++ b/extensions/catalog.community.json @@ -180,6 +180,41 @@ "created_at": "2026-05-04T00:00:00Z", "updated_at": "2026-05-04T00:00:00Z" }, + "agentdocx-speckit": { + "name": "AgentDocx", + "id": "agentdocx-speckit", + "description": "Full-stack multi-agent specification pipeline with VS Code extension control, automated Kanban/Jira sync, and React monitoring dashboard.", + "author": "Abir Ommezzine and Ahmed Aziz Ammar", + "version": "0.0.3", + "download_url": "https://github.com/abir-ommezzine/extension-github-spec-kit/archive/refs/tags/v0.0.3.zip", + "repository": "https://github.com/abir-ommezzine/extension-github-spec-kit", + "homepage": "https://github.com/abir-ommezzine/extension-github-spec-kit", + "documentation": "https://github.com/abir-ommezzine/extension-github-spec-kit/blob/main/README.md", + "changelog": "", + "license": "MIT", + "category": "integration", + "effect": "read-write", + "requires": { + "speckit_version": ">=0.1.0" + }, + "provides": { + "commands": 0, + "hooks": 0 + }, + "tags": [ + "issue-tracking", + "jira", + "automation", + "workflow", + "pipeline", + "agents" + ], + "verified": false, + "downloads": 0, + "stars": 0, + "created_at": "2026-08-18T00:00:00Z", + "updated_at": "2026-08-18T00:00:00Z" + }, "analytics": { "name": "Analytics", "id": "analytics", From fe9f4587a2728d31915c090f1e3a21fddcd38746 Mon Sep 17 00:00:00 2001 From: Manfred Riem <15701806+mnriem@users.noreply.github.com> Date: Thu, 20 Aug 2026 08:05:52 -0500 Subject: [PATCH 009/102] fix: raise feature assessment credit budget (#4222) Set an explicit 20K daily AI credits guardrail for the multi-stage feature assessment workflow so normal aggregate usage does not block subsequent assessments.\n\nRefs #4216\n\nAssisted-by: GitHub Copilot (model: GPT-5.6 Sol, autonomous)\nCo-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>\nCopilot-Session: 1711a974-353d-4096-96e9-c7d6d2105355 --- .github/workflows/feature-assess.lock.yml | 7 +++---- .github/workflows/feature-assess.md | 1 + 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/.github/workflows/feature-assess.lock.yml b/.github/workflows/feature-assess.lock.yml index 1954767909..5b3adc7c7d 100644 --- a/.github/workflows/feature-assess.lock.yml +++ b/.github/workflows/feature-assess.lock.yml @@ -1,4 +1,4 @@ -# gh-aw-metadata: {"schema_version":"v4","frontmatter_hash":"d64425d4c710146adc49679a08d355977f6a9b8bc5d6f95d91861f3836f4b007","body_hash":"6d78e8c183819f6f12a07f0c9cb28a83cc2471ac20c6df6999e503a0d731da4b","compiler_version":"v0.79.8","strict":true,"agent_id":"copilot","engine_versions":{"copilot":"1.0.60"}} +# gh-aw-metadata: {"schema_version":"v4","frontmatter_hash":"d0588e989403a51f8849be4ac0ceb184d3a30f1c2e6860f8dc65fd5728592946","body_hash":"6d78e8c183819f6f12a07f0c9cb28a83cc2471ac20c6df6999e503a0d731da4b","compiler_version":"v0.79.8","strict":true,"agent_id":"copilot","engine_versions":{"copilot":"1.0.60"}} # gh-aw-manifest: {"version":1,"secrets":["COPILOT_GITHUB_TOKEN","GH_AW_GITHUB_MCP_SERVER_TOKEN","GH_AW_GITHUB_TOKEN","GITHUB_TOKEN"],"actions":[{"repo":"actions/checkout","sha":"df4cb1c069e1874edd31b4311f1884172cec0e10","version":"v6.0.3"},{"repo":"actions/download-artifact","sha":"3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c","version":"v8.0.1"},{"repo":"actions/github-script","sha":"3a2844b7e9c422d3c10d287c895573f7108da1b3","version":"v9.0.0"},{"repo":"actions/setup-node","sha":"48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e","version":"v6.4.0"},{"repo":"actions/setup-python","sha":"5fda3b95a4ea91299a34e894583c3862153e4b97","version":"5fda3b95a4ea91299a34e894583c3862153e4b97"},{"repo":"actions/upload-artifact","sha":"043fb46d1a93c77aae656e7c1c64a875d1fc6a0a","version":"v7.0.1"},{"repo":"astral-sh/setup-uv","sha":"c771a70e6277c0a99b617c7a806ffedaca235ff9","version":"c771a70e6277c0a99b617c7a806ffedaca235ff9"},{"repo":"github/gh-aw-actions/setup","sha":"c0338fef4749d08c21f8f975fb0e37efa17dda47","version":"v0.79.8"}],"containers":[{"image":"ghcr.io/github/gh-aw-firewall/agent:0.27.2","digest":"sha256:f88e5b17b6b7a600117bc121114d6ce2155c88c983c0c939c5df884f730fa1d6","pinned_image":"ghcr.io/github/gh-aw-firewall/agent:0.27.2@sha256:f88e5b17b6b7a600117bc121114d6ce2155c88c983c0c939c5df884f730fa1d6"},{"image":"ghcr.io/github/gh-aw-firewall/api-proxy:0.27.2","digest":"sha256:ee39841d980878ebbb87592903b06d31a1af500c71525c9616f7e8e2a27041a4","pinned_image":"ghcr.io/github/gh-aw-firewall/api-proxy:0.27.2@sha256:ee39841d980878ebbb87592903b06d31a1af500c71525c9616f7e8e2a27041a4"},{"image":"ghcr.io/github/gh-aw-firewall/squid:0.27.2","digest":"sha256:2e3a717e5f19a654cd9a2263beb52012b56bcb68562ec5ae2e42f9d156b49591","pinned_image":"ghcr.io/github/gh-aw-firewall/squid:0.27.2@sha256:2e3a717e5f19a654cd9a2263beb52012b56bcb68562ec5ae2e42f9d156b49591"},{"image":"ghcr.io/github/gh-aw-mcpg:v0.3.25","digest":"sha256:c10331ad17668ef89f38f5e356678788a40b0cd5fef96e8f92e1d9c1de47cbaa","pinned_image":"ghcr.io/github/gh-aw-mcpg:v0.3.25@sha256:c10331ad17668ef89f38f5e356678788a40b0cd5fef96e8f92e1d9c1de47cbaa"},{"image":"ghcr.io/github/github-mcp-server:v1.1.2","digest":"sha256:30197479d8036c7811892bc07e06f9a05c9ef3cdd79bc59f256d50647f95788c","pinned_image":"ghcr.io/github/github-mcp-server:v1.1.2@sha256:30197479d8036c7811892bc07e06f9a05c9ef3cdd79bc59f256d50647f95788c"}]} # This file was automatically generated by gh-aw (v0.79.8). DO NOT EDIT. To debug this workflow, load the skill at https://github.com/github/gh-aw/blob/main/debug.md # @@ -78,7 +78,7 @@ jobs: actions: read contents: read env: - GH_AW_MAX_DAILY_AI_CREDITS: ${{ vars.GH_AW_DEFAULT_MAX_DAILY_AI_CREDITS || '5000' }} + GH_AW_MAX_DAILY_AI_CREDITS: "20000" outputs: body: ${{ steps.sanitized.outputs.body }} comment_id: "" @@ -149,7 +149,7 @@ jobs: GH_AW_RUN_URL: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }} GH_AW_WORKFLOW_DISPATCH_AW_CONTEXT: ${{ github.event.inputs.aw_context || '' }} GH_AW_GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} - GH_AW_MAX_DAILY_AI_CREDITS: ${{ vars.GH_AW_DEFAULT_MAX_DAILY_AI_CREDITS || '5000' }} + GH_AW_MAX_DAILY_AI_CREDITS: "20000" with: github-token: ${{ secrets.GITHUB_TOKEN }} script: | @@ -1675,4 +1675,3 @@ jobs: /tmp/gh-aw/safe-output-items.jsonl /tmp/gh-aw/temporary-id-map.json if-no-files-found: ignore - diff --git a/.github/workflows/feature-assess.md b/.github/workflows/feature-assess.md index 5f6afbc633..4381d44136 100644 --- a/.github/workflows/feature-assess.md +++ b/.github/workflows/feature-assess.md @@ -9,6 +9,7 @@ on: skip-bots: [github-actions, copilot, dependabot] engine: copilot +max-daily-ai-credits: 20K tools: bash: ["echo", "cat", "head", "tail", "grep", "wc", "sort", "uniq", "python3", "pip", "pip3", "jq", "date", "ls", "find", "mkdir", "sed", "env", "which", "curl", "sh", "bash", "uv", "uvx", "specify", "git"] From 145e5e6889c444c2e877986f26cd49081b26cd79 Mon Sep 17 00:00:00 2001 From: Nguyen Thanh Dat Date: Thu, 20 Aug 2026 20:24:15 +0700 Subject: [PATCH 010/102] fix(workflows): reject a condition that has no {{ }} block (#4182) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix(workflows): reject a condition that has no {{ }} block `evaluate_condition` resolves its argument through `evaluate_expression`, which only substitutes `{{ ... }}` blocks. A string with no such block comes back unchanged and — unless it reads `true`/`false` — is then coerced by `bool()`. So a condition authored without the braces is never evaluated at all: evaluate_condition("inputs.count > 100", ctx) -> True evaluate_condition("{{ inputs.count > 100 }}", ctx) -> False with `inputs.count == 5` in both cases. An `if` step always takes `then`, and a `while`/`do-while` step always runs to `max_iterations` — ten agent invocations for a loop the author expected to stop. This is the same silent-truthiness authoring mistake the three step validators already reject for a list/dict/number condition, and it is easier to make: GitHub Actions accepts a bare expression in `if:`, so the brace-less form is a habit to bring here. Adds `condition_is_never_evaluated()` and wires it into the `if`, `while` and `do-while` validators, so the mistake surfaces at validation with the corrected form spelled out. Boolean literals, real bools, empty strings and any string containing `{{` stay valid — runtime behaviour is unchanged. * fix(workflows): flag an unterminated {{ and quote the correction safely Two gaps in the condition validator, both raised in review. An opening `{{` with no `}}` after it is never substituted either: _interpolate_expressions takes its `raw_close == -1` branch and appends the tail verbatim. So `condition: "{{ inputs.count > 100"` -- and the reversed `"}} inputs.count > 100 {{"`, whose only `{{` is last -- come back unchanged and are coerced to true exactly like a brace-less string. The helper now looks for a complete block rather than an opening one. The suggested correction was interpolated into a double-quoted scalar, so a condition containing a double quote produced YAML that does not parse: `condition: "{{ inputs.name == "zzz" }}"` raises a ParserError. format_condition_correction() now picks the quoting from the content and drops a stray delimiter instead of nesting a second one, so the message stays paste-ready. All three validators share it. Tests: 30 more cases -- the incomplete forms, and a YAML round trip over conditions holding single quotes, double quotes, both, and backslashes, asserting each correction loads back exactly and is not re-flagged. Co-Authored-By: Claude Opus 5 * fix(workflows): share the evaluator's quote-aware scan, and quote with json.dumps Both follow-up review points were right. The completeness check used a plain `find("}}")`, but the substituter closes a block with a quote-aware scan. So `condition: "{{ inputs.x == '}}'"` looked complete to the validator while `_interpolate_expressions` found no close, fell to its raw-close branch, evaluated a truncated body and left residual text (`False'`) -- a non-empty string, hence true. Rather than restate the quote rules a third time, the scan moves out of `_interpolate_expressions` into `_find_block_close`, which the validator now calls: the check and the substitution it predicts can no longer disagree. A `}}` that is genuinely inside a string argument still does not close early, so `{{ inputs.text | default('}}') }}` and `{{ inputs.x == '}}' }}` stay accepted. The correction's quoting enumerated the characters it escaped, and the enumeration was short: a condition loaded from a YAML literal block can carry a newline, which a double-quoted scalar folds, so the corrected form did not round-trip. `json.dumps` decides it instead -- every JSON string is a valid YAML double-quoted scalar and it escapes quotes, backslashes, newlines and the other control characters. `ensure_ascii=False` keeps a non-ASCII operand readable rather than expanding it into numeric escapes. Tests: 70 -> 83. The quoted-delimiter condition joins the incomplete-block set, and the round-trip set gains multiline, newline-with-quote, tab, carriage return and non-ASCII operands. All four new cases fail on the previous commit. Co-Authored-By: Claude Opus 5 * fix(workflows): flag a whitespace condition, and stop the correction nesting a block Two review findings, both reproduced against the code before changing it. **1. Non-empty whitespace was excluded, and it should not have been.** The docstring claimed a whitespace condition "coerces to False, which is a definite answer". That is true only of the empty string. Measured: evaluate_condition("") -> False evaluate_condition(" ") -> True evaluate_condition("\t\n ") -> True `evaluate_condition` strips only while testing the true/false keywords, then falls through to `bool()` on the raw string -- and `test_condition_whitespace_only_string_stays_truthy` pins that on purpose. So `condition: " "` is exactly the silent always-true this helper exists to catch, and it was sailing through. Fixed at validation time rather than in the evaluator, because that runtime behaviour is deliberate. The empty string stays excluded: it really does coerce to False. **2. The correction only removed edge delimiters, so it could nest one.** "prefix {{ inputs.ready" -> "{{ prefix {{ inputs.ready }}" The suggestion carried an unclosed inner block, and because its *outer* block was complete, `condition_is_never_evaluated` waved the corrected form straight back through. Same for a trailing `}}`. `_strip_stray_delimiters` now removes every delimiter, and is quote-aware for the reason the rest of this module is: `inputs.x == '}}'` holds a delimiter as data, and a blanket `re.sub` would eat it and change what the condition compares. `_find_top_level` could not be reused -- it counts `{`/`}` as bracket depth, so it never reports a `{{` as a token at all. "prefix {{ inputs.ready" -> "{{ prefix inputs.ready }}" "inputs.ready }} suffix" -> "{{ inputs.ready suffix }}" "{{ inputs.x == '}}'" -> "{{ inputs.x == '}}' }}" (data kept) '{{ inputs.name == "a b"' -> '{{ inputs.name == "a b" }}' (spacing kept) Whitespace collapses only where a delimiter was removed; inside a quoted operand it is untouched. Tests: the two fixtures that asserted whitespace was valid are corrected, and five cases added for interior delimiters, quoted delimiters and quoted spacing. 87 pass in tests/unit/test_condition_expression_block.py. tests/test_workflows.py is 20 failed / 903 passed both with and without this change -- all twenty are symlink tests that need Windows Developer Mode, and the counts are identical with the diff stashed. * fix(workflows): separate a malformed block from one that is never evaluated Third review finding, and like the first two it reproduces. `condition_is_never_evaluated` returned True for any `{{` the quote-aware scan could not close -- but `_interpolate_expressions` does not treat those alike. Its own comment spells out two sub-cases, and only one is "never evaluated": * no raw `}}` in the tail -> the text is emitted verbatim, so bool() makes it true. Genuinely uninterpolated. * a raw `}}` further along -> that is used as the close and the truncated body *is* evaluated. Measured: {{ inputs.count > 100 -> True (never evaluated) }} inputs.count > 100 {{ -> True (never evaluated) {{ inputs.x == '}}' -> True (raw-close path) {{ inputs.missing | default('oops }} -> raises ValueError That last one made the old message wrong on both halves: it is evaluated, and it does not end up true -- it ends the run in `_apply_filter`. Adds `condition_has_malformed_expression_block` and gives it its own branch in the three validators, because the two faults need opposite advice: one says "you forgot the braces", the other says "your delimiters or quotes do not balance". The two predicates are mutually exclusive, pinned by a test over every fixture. The malformed branch deliberately offers **no** paste-ready correction. The fault is unbalanced quoting, so the quote-aware stripper cannot tell operand from delimiter -- for `{{ inputs.missing | default('oops }}` it emits `"{{ inputs.missing | default('oops }} }}"`, which is not a fix. This is the same "avoid offering an automatic correction for malformed-block cases" the reviewer raised earlier; it applies exactly here. Also renders a blank correction as `"{{ }}"` rather than the double-spaced `"{{ }}"` that concatenation produced for a whitespace-only condition. 106 pass in tests/unit/test_condition_expression_block.py. Across tests/test_workflows.py + tests/unit the run is 22 failed / 1189 passed, and 22 failed / 1170 passed with this diff stashed -- identical failures, all Windows symlink cases, none touching conditions or expressions. * fix(workflows): scan every expression block, not just the first Both condition validators stopped at the first `{{`. A condition whose first block closes was accepted regardless of what followed, so a later unterminated block escaped validation entirely — the case Copilot raised: {{ true }} and {{ inputs.ready -> both validators returned False Interpolation leaves `and {{ inputs.ready` in the result and bool() makes the condition always true, which is exactly the silent-branching defect these validators exist to catch. The same hole applied to the malformed class: {{ inputs.name }} {{ inputs.missing | default('oops }} -> raises at run time Add `_first_unclosable_block`, which walks blocks the way `_interpolate_expressions` does — continuing past each block that closes — and reports how the first unclosable one will fail: `evaluated` when a raw `}}` follows (the fallback truncates and evaluates), `verbatim` when none does. Both validators now read from it, so they cannot disagree with the substitution they predict. Two wording fixes fall out of scanning further: - The never-evaluated message said the condition "has no complete '{{ }}' block". With an earlier complete block that is false, so it now says the condition "is not a single complete '{{ }}' block". - `condition_has_malformed_expression_block`'s docstring said the truncated body raises ValueError. It does for `default('oops`, but `{{ inputs.x == '}}'` evaluates to the residual `"False'"` instead. Measured both; the docstring now says either can happen and the error message never claimed otherwise. Verified on Python 3.11: - tests/unit/test_condition_expression_block.py 116 passed (was 106) - tests/unit + tests/test_workflows.py 1199 passed (was 1189), 22 failed before and after — all pre-existing symlink tests that need Windows elevation. Mutation-checked: restoring the stop-after-first-block behaviour fails exactly the 10 new parametrised cases and nothing else. --------- Co-authored-by: Claude Opus 5 --- src/specify_cli/workflows/expressions.py | 217 ++++++++++++- .../workflows/steps/do_while/__init__.py | 33 ++ .../workflows/steps/if_then/__init__.py | 35 ++- .../workflows/steps/while_loop/__init__.py | 35 ++- tests/unit/test_condition_expression_block.py | 292 ++++++++++++++++++ 5 files changed, 596 insertions(+), 16 deletions(-) create mode 100644 tests/unit/test_condition_expression_block.py diff --git a/src/specify_cli/workflows/expressions.py b/src/specify_cli/workflows/expressions.py index 38a29890ae..35106758bf 100644 --- a/src/specify_cli/workflows/expressions.py +++ b/src/specify_cli/workflows/expressions.py @@ -224,6 +224,59 @@ def _is_single_expression(stripped: str) -> bool: return True +def _find_block_close(text: str, start: int) -> int: + """Index of the ``}}`` closing the block opened by the ``{{`` at *start*, or -1. + + Quote-aware, so a literal ``}}`` inside a string argument + (``{{ inputs.text | default('}}') }}``) does not close the block early -- + the same rule ``_is_single_expression`` applies. Shared with + ``condition_is_never_evaluated`` so the validator cannot disagree with the + substitution it is predicting. + """ + quote: str | None = None + i = start + 2 + n = len(text) + while i < n: + ch = text[i] + if quote is not None: + if ch == quote: + quote = None + elif ch in ("'", '"'): + quote = ch + elif ch == "}" and i + 1 < n and text[i + 1] == "}": + return i + i += 1 + return -1 + + +def _first_unclosable_block(text: str) -> str | None: + """How ``_interpolate_expressions`` will fail on the first block it cannot + close with the quote-aware scan, or ``None`` when every block closes. + + Returns ``"evaluated"`` when a raw ``}}`` still follows the opener -- the + interpolator falls back to it and evaluates the truncated body, which reaches + the filter parser and raises ``ValueError``. Returns ``"verbatim"`` when no + ``}}`` follows at all -- the tail is emitted unchanged, so it survives into the + result as truthy text. + + Walks blocks exactly the way ``_interpolate_expressions`` does, continuing past + each block that *does* close. Checking only the first opener let a later + unterminated block through both validators: ``{{ true }} and {{ inputs.ready`` + closes its first block, so the scan stopped and reported no fault, while + interpolation leaves ``and {{ inputs.ready`` in the result and ``bool()`` makes + the condition always true. + """ + i = 0 + while True: + start = text.find("{{", i) + if start == -1: + return None + close = _find_block_close(text, start) + if close == -1: + return "evaluated" if text.find("}}", start + 2) != -1 else "verbatim" + i = close + 2 + + def _interpolate_expressions(template: str, namespace: dict[str, Any]) -> str: """Substitute every top-level ``{{ ... }}`` block in *template*, quote-aware. @@ -249,20 +302,7 @@ def _interpolate_expressions(template: str, namespace: dict[str, Any]) -> str: break out.append(template[i:start]) # Scan for the block-closing ``}}`` that is outside any string literal. - j = start + 2 - quote: str | None = None - close = -1 - while j < n: - ch = template[j] - if quote is not None: - if ch == quote: - quote = None - elif ch in ("'", '"'): - quote = ch - elif ch == "}" and j + 1 < n and template[j + 1] == "}": - close = j - break - j += 1 + close = _find_block_close(template, start) if close == -1: # No quote-aware close. Two sub-cases, both kept identical to the old # regex so a malformed template is never silently hidden: @@ -690,3 +730,152 @@ def evaluate_condition(condition: str, context: Any) -> bool: if lower == "true": return True return bool(result) + + +def condition_is_never_evaluated(condition: Any) -> bool: + """True when a string *condition* is silently treated as always-true text. + + ``evaluate_condition`` resolves its argument through + ``evaluate_expression``, which only substitutes ``{{ ... }}`` blocks. A + string with no such block comes back unchanged, and — unless it reads + ``true``/``false`` — is then coerced by ``bool()``. So an expression + authored without the braces, e.g. ``condition: inputs.count > 100``, is + never evaluated at all: it is a non-empty string, so the ``if`` step always + takes ``then`` and a ``while``/``do-while`` step always runs to + ``max_iterations``. + + That is the same silent-truthiness authoring mistake the step validators + already reject for a list/dict/number condition, and it is easy to write: + GitHub Actions accepts a bare expression in ``if:``. + + The empty string is excluded — it coerces to ``False``, which is a definite + answer rather than a silent always-true. Non-empty whitespace is *not* + excluded: ``bool(" ")`` is true, and ``evaluate_condition`` strips only + while testing the ``true``/``false`` keywords before falling through to + ``bool()`` on the raw string. That runtime behaviour is pinned deliberately + by ``test_condition_whitespace_only_string_stays_truthy``, so the authoring + mistake has to be caught here instead: ``condition: " "`` always takes + ``then``. + """ + if not isinstance(condition, str): + return False + if condition == "": + return False + stripped = condition.strip() + if not stripped: + return True + if stripped.lower() in ("true", "false"): + return False + if "{{" not in stripped: + return True + # An opening ``{{`` the substituter cannot close is no better than a missing + # one -- but only when the substituter really does leave it alone. + # ``_interpolate_expressions`` has two sub-cases when its quote-aware scan + # fails, and they do not behave alike: with no raw ``}}`` in the tail the + # block is emitted verbatim (never evaluated, so ``bool()`` makes it true), + # while a raw ``}}`` further along is used as the close and the truncated + # body *is* evaluated. Only the first is "never evaluated"; see + # ``condition_has_malformed_expression_block`` for the second. + return _first_unclosable_block(stripped) == "verbatim" + + +def condition_has_malformed_expression_block(condition: Any) -> bool: + """True when *condition* holds a ``{{`` block the quote-aware scan cannot close, + but which ``_interpolate_expressions`` still evaluates through its raw-close + fallback. + + This is a different fault from the one + ``condition_is_never_evaluated`` reports, and it deserves a different message. + The block is not skipped: the interpolator takes the first raw ``}}`` after the + opener and evaluates whatever it truncated, so + + {{ inputs.missing | default('oops }} + + reaches ``_apply_filter`` and raises ``ValueError`` at run time. The truncation does + not always raise -- ``{{ inputs.x == '}}'`` evaluates to the residual ``"False'"`` -- + but either way what runs is not what was written, so "never evaluated and always + true" is the wrong report. + + Kept separate from the never-evaluated check rather than folded in, because the + two need opposite advice: one says "you forgot the braces", this one says "your + delimiters or quotes do not balance". + """ + if not isinstance(condition, str): + return False + stripped = condition.strip() + if not stripped or stripped.lower() in ("true", "false"): + return False + return _first_unclosable_block(stripped) == "evaluated" + + +def _strip_stray_delimiters(text: str) -> str: + """Remove every ``{{``/``}}`` that lies outside a quoted operand. + + Quote-aware for the same reason the rest of this module is: ``inputs.x == '}}'`` + holds a delimiter as *data*, and a blanket ``re.sub`` would eat it and change + what the corrected condition compares against. Whitespace orphaned by a removed + delimiter collapses to one separator so the suggestion still reads as an + expression; whitespace inside a quoted operand is never touched. + + ``_find_top_level`` cannot serve here: it counts ``{`` and ``}`` as bracket + depth, so it never reports a ``{{`` as a top-level token at all. + """ + out: list[str] = [] + quote: str | None = None + i = 0 + n = len(text) + while i < n: + ch = text[i] + if quote is not None: + out.append(ch) + if ch == quote: + quote = None + i += 1 + continue + if ch in ("'", '"'): + quote = ch + out.append(ch) + i += 1 + continue + if text.startswith("{{", i) or text.startswith("}}", i): + i += 2 + while i < n and text[i].isspace(): + i += 1 + while out and out[-1].isspace(): + out.pop() + out.append(" ") + continue + out.append(ch) + i += 1 + return "".join(out) + +def format_condition_correction(condition: Any) -> str: + """Render *condition* wrapped in ``{{ }}`` as a quoted, paste-ready YAML scalar. + + The validators hand this back as the corrected form, so it has to survive a + round trip through a YAML parser. A plain ``"{{ ... }}"`` does not: a + condition holding a double quote (``inputs.name == "zzz"``) closes the + scalar early and the workflow file no longer loads. Quoting is therefore + chosen from the content. That enumeration was incomplete: a condition loaded + from a YAML literal block can carry a newline, which a double-quoted scalar + folds, so the correction did not round-trip. + + ``json.dumps`` decides it instead. Every JSON string is a valid YAML + double-quoted scalar, and it escapes the quotes, backslashes, newlines and + other control characters that hand-rolled quoting has to enumerate. + ``ensure_ascii=False`` keeps non-ASCII operands readable rather than + expanding them into numeric escapes. + + A stray delimiter is dropped rather than nested: ``{{ inputs.count > 100`` + corrects to ``"{{ inputs.count > 100 }}"``, not to a doubled ``{{ {{ ... }} }}``. + Every stray delimiter goes, not only the ones sitting at the edges. Trimming + just the edges left ``prefix {{ inputs.ready`` reading + ``"{{ prefix {{ inputs.ready }}"`` -- an unclosed inner block, and one whose + complete *outer* block then carried the correction straight back through + ``condition_is_never_evaluated`` as if it were valid. + """ + core = _strip_stray_delimiters(str(condition)).strip() + # A blank core has nothing to wrap; render the empty block rather than the + # double-spaced "{{ }}" that string concatenation would otherwise produce. + body = "{{ " + core + " }}" if core else "{{ }}" + return json.dumps(body, ensure_ascii=False) diff --git a/src/specify_cli/workflows/steps/do_while/__init__.py b/src/specify_cli/workflows/steps/do_while/__init__.py index 024ced55b5..84921ef556 100644 --- a/src/specify_cli/workflows/steps/do_while/__init__.py +++ b/src/specify_cli/workflows/steps/do_while/__init__.py @@ -5,6 +5,11 @@ from typing import Any from specify_cli.workflows.base import StepBase, StepContext, StepResult, StepStatus +from specify_cli.workflows.expressions import ( + condition_has_malformed_expression_block, + condition_is_never_evaluated, + format_condition_correction, +) class DoWhileStep(StepBase): @@ -88,6 +93,34 @@ def validate(self, config: dict[str, Any]) -> list[str]: f"Do-while step {config.get('id', '?')!r}: 'condition' must be a " f"string or boolean, got {type(config['condition']).__name__}." ) + elif condition_is_never_evaluated(config["condition"]): + # A string condition with no ``{{ }}`` block is never evaluated: + # evaluate_expression() returns it unchanged and bool() then makes + # any non-empty text true. `condition: inputs.count > 100` reads as + # a real comparison but always takes every iteration. This is the same + # silent-truthiness mistake the list/dict branch above rejects, and + # GitHub Actions accepts a bare expression in `if:`, so it is easy + # to write by habit. + errors.append( + f"Do-while step {config.get('id', '?')!r}: 'condition' " + f"{config['condition']!r} is not a single complete '{{{{ }}}}' block, so " + "it is never evaluated as an expression and is always true. Wrap the expression: " + + format_condition_correction(config["condition"]) + "." + ) + elif condition_has_malformed_expression_block(config["condition"]): + # Different fault, different advice. Here the block is *not* skipped: + # _interpolate_expressions cannot close it with its quote-aware scan, so it + # falls back to the first raw close and evaluates whatever that truncated. + # `{{ inputs.missing | default('oops }}` reaches the filter parser and raises + # ValueError at run time, so reporting it as "always true" would be wrong + # twice over: it is evaluated, and it does not end up true. + errors.append( + f"Do-while step {config.get('id', '?')!r}: 'condition' " + f"{config['condition']!r} opens a '{{{{' the interpolator cannot " + "close, so it falls back to the first raw '}}' and evaluates a " + "truncated expression instead of the one written. Balance the " + "delimiters and quotes." + ) max_iter = config.get("max_iterations") if max_iter is not None: # bool is a subclass of int, so isinstance(True, int) is True and diff --git a/src/specify_cli/workflows/steps/if_then/__init__.py b/src/specify_cli/workflows/steps/if_then/__init__.py index 7189ff8150..cb74db7b3d 100644 --- a/src/specify_cli/workflows/steps/if_then/__init__.py +++ b/src/specify_cli/workflows/steps/if_then/__init__.py @@ -5,7 +5,12 @@ from typing import Any from specify_cli.workflows.base import StepBase, StepContext, StepResult, StepStatus -from specify_cli.workflows.expressions import evaluate_condition +from specify_cli.workflows.expressions import ( + condition_has_malformed_expression_block, + condition_is_never_evaluated, + format_condition_correction, + evaluate_condition, +) class IfThenStep(StepBase): @@ -79,6 +84,34 @@ def validate(self, config: dict[str, Any]) -> list[str]: f"If step {config.get('id', '?')!r}: 'condition' must be a " f"string or boolean, got {type(config['condition']).__name__}." ) + elif condition_is_never_evaluated(config["condition"]): + # A string condition with no ``{{ }}`` block is never evaluated: + # evaluate_expression() returns it unchanged and bool() then makes + # any non-empty text true. `condition: inputs.count > 100` reads as + # a real comparison but always takes ``then``. This is the same + # silent-truthiness mistake the list/dict branch above rejects, and + # GitHub Actions accepts a bare expression in `if:`, so it is easy + # to write by habit. + errors.append( + f"If step {config.get('id', '?')!r}: 'condition' " + f"{config['condition']!r} is not a single complete '{{{{ }}}}' block, so " + "it is never evaluated as an expression and is always true. Wrap the expression: " + + format_condition_correction(config["condition"]) + "." + ) + elif condition_has_malformed_expression_block(config["condition"]): + # Different fault, different advice. Here the block is *not* skipped: + # _interpolate_expressions cannot close it with its quote-aware scan, so it + # falls back to the first raw close and evaluates whatever that truncated. + # `{{ inputs.missing | default('oops }}` reaches the filter parser and raises + # ValueError at run time, so reporting it as "always true" would be wrong + # twice over: it is evaluated, and it does not end up true. + errors.append( + f"If step {config.get('id', '?')!r}: 'condition' " + f"{config['condition']!r} opens a '{{{{' the interpolator cannot " + "close, so it falls back to the first raw '}}' and evaluates a " + "truncated expression instead of the one written. Balance the " + "delimiters and quotes." + ) if "then" not in config: errors.append( f"If step {config.get('id', '?')!r} is missing 'then' field." diff --git a/src/specify_cli/workflows/steps/while_loop/__init__.py b/src/specify_cli/workflows/steps/while_loop/__init__.py index e80b93d7f2..feda1b334d 100644 --- a/src/specify_cli/workflows/steps/while_loop/__init__.py +++ b/src/specify_cli/workflows/steps/while_loop/__init__.py @@ -5,7 +5,12 @@ from typing import Any from specify_cli.workflows.base import StepBase, StepContext, StepResult, StepStatus -from specify_cli.workflows.expressions import evaluate_condition +from specify_cli.workflows.expressions import ( + condition_has_malformed_expression_block, + condition_is_never_evaluated, + format_condition_correction, + evaluate_condition, +) class WhileStep(StepBase): @@ -97,6 +102,34 @@ def validate(self, config: dict[str, Any]) -> list[str]: f"While step {config.get('id', '?')!r}: 'condition' must be a " f"string or boolean, got {type(config['condition']).__name__}." ) + elif condition_is_never_evaluated(config["condition"]): + # A string condition with no ``{{ }}`` block is never evaluated: + # evaluate_expression() returns it unchanged and bool() then makes + # any non-empty text true. `condition: inputs.count > 100` reads as + # a real comparison but always takes every iteration. This is the same + # silent-truthiness mistake the list/dict branch above rejects, and + # GitHub Actions accepts a bare expression in `if:`, so it is easy + # to write by habit. + errors.append( + f"While step {config.get('id', '?')!r}: 'condition' " + f"{config['condition']!r} is not a single complete '{{{{ }}}}' block, so " + "it is never evaluated as an expression and is always true. Wrap the expression: " + + format_condition_correction(config["condition"]) + "." + ) + elif condition_has_malformed_expression_block(config["condition"]): + # Different fault, different advice. Here the block is *not* skipped: + # _interpolate_expressions cannot close it with its quote-aware scan, so it + # falls back to the first raw close and evaluates whatever that truncated. + # `{{ inputs.missing | default('oops }}` reaches the filter parser and raises + # ValueError at run time, so reporting it as "always true" would be wrong + # twice over: it is evaluated, and it does not end up true. + errors.append( + f"While step {config.get('id', '?')!r}: 'condition' " + f"{config['condition']!r} opens a '{{{{' the interpolator cannot " + "close, so it falls back to the first raw '}}' and evaluates a " + "truncated expression instead of the one written. Balance the " + "delimiters and quotes." + ) max_iter = config.get("max_iterations") if max_iter is not None: # bool is a subclass of int, so isinstance(True, int) is True and diff --git a/tests/unit/test_condition_expression_block.py b/tests/unit/test_condition_expression_block.py new file mode 100644 index 0000000000..7d9d235902 --- /dev/null +++ b/tests/unit/test_condition_expression_block.py @@ -0,0 +1,292 @@ +"""A string condition with no ``{{ }}`` block is never evaluated (always true).""" + +import pytest +import yaml + +from specify_cli.workflows.base import StepContext +from specify_cli.workflows.expressions import ( + condition_has_malformed_expression_block, + condition_is_never_evaluated, + evaluate_condition, + format_condition_correction, +) +from specify_cli.workflows.steps.do_while import DoWhileStep +from specify_cli.workflows.steps.if_then import IfThenStep +from specify_cli.workflows.steps.while_loop import WhileStep + +STEP_CLASSES = [IfThenStep, WhileStep, DoWhileStep] + + +@pytest.mark.parametrize( + "condition", + ["inputs.count > 100", "inputs.name == 'zzz'", "inputs.count < 3"], +) +def test_brace_less_condition_is_always_true_at_runtime(condition): + """The behaviour the validator now warns about, pinned so it cannot drift.""" + ctx = StepContext(inputs={"count": 5, "name": "abc"}) + # Same expression with braces resolves to its real (false) value... + assert evaluate_condition("{{ " + condition + " }}", ctx) is False + # ...without them it is only non-empty text, so bool() makes it true. + assert evaluate_condition(condition, ctx) is True + + +@pytest.mark.parametrize("step_cls", STEP_CLASSES) +def test_validator_rejects_condition_without_expression_block(step_cls): + config = {"id": "s1", "condition": "inputs.count > 100", "then": [], "steps": []} + errors = [e for e in step_cls().validate(config) if "never evaluated" in e] + assert len(errors) == 1 + assert "inputs.count > 100" in errors[0] + # The message hands back the corrected form. + assert '"{{ inputs.count > 100 }}"' in errors[0] + + +@pytest.mark.parametrize("step_cls", STEP_CLASSES) +@pytest.mark.parametrize( + "condition", + ["{{ inputs.count > 100 }}", "true", "false", "TRUE", True, False, ""], +) +def test_validator_accepts_evaluated_and_literal_conditions(step_cls, condition): + """No false positives: braces, boolean literals and bools stay valid.""" + config = {"id": "s1", "condition": condition, "then": [], "steps": []} + assert not [e for e in step_cls().validate(config) if "never evaluated" in e] + + +@pytest.mark.parametrize( + ("value", "expected"), + [ + ("inputs.count > 100", True), + ("{{ inputs.count > 100 }}", False), + ("prefix {{ inputs.a }} suffix", False), + ("true", False), + ("False", False), + ("", False), + # `bool(" ")` is true and evaluate_condition strips only around the + # true/false keywords, so whitespace is a silent always-true, not a + # definite False. Only "" coerces to False. + (" ", True), + ("\t\n ", True), + (True, False), + (["a"], False), + (3, False), + ], +) +def test_condition_is_never_evaluated(value, expected): + assert condition_is_never_evaluated(value) is expected + + +# --- An unterminated ``{{`` is the same defect, not a different one ----------- +# +# ``_interpolate_expressions`` substitutes nothing when no ``}}`` follows the +# opening ``{{`` (its ``raw_close == -1`` branch appends the tail verbatim), so +# ``{{ inputs.count > 100`` is returned unchanged and coerced to true exactly +# like a brace-less string. + +BACKSLASH = chr(92) + +NEVER_EVALUATED = [ + "inputs.count > 100", # no delimiter at all + "{{ inputs.count > 100", # opened, never closed + "}} inputs.count > 100 {{", # reversed: the only '{{' is last + # A complete block does not vouch for the rest: interpolation leaves the + # second fragment verbatim, and bool() makes the whole string true. + "{{ true }} and {{ inputs.ready", +] + +# A different fault, and the interpolator treats it differently: the quote-aware +# scan finds no close, but a raw '}}' exists further along, so +# _interpolate_expressions falls back to it and *evaluates* the truncated body. +# These are not "never evaluated" -- one leaves residual text that bool() makes +# true, the other reaches the filter parser and raises. +MALFORMED_BLOCKS = [ + "{{ inputs.x == '}}'", + "{{ inputs.missing | default('oops }}", + # Same, but the faulty block is the second one. + "{{ inputs.name }} {{ inputs.missing | default('oops }}", +] + + +@pytest.mark.parametrize("condition", NEVER_EVALUATED) +def test_incomplete_block_is_silently_true_and_is_flagged(condition): + ctx = StepContext(inputs={"count": 5, "name": "abc"}) + assert evaluate_condition(condition, ctx) is True + assert condition_is_never_evaluated(condition) is True + assert condition_has_malformed_expression_block(condition) is False + + +@pytest.mark.parametrize("condition", MALFORMED_BLOCKS) +def test_raw_close_fallback_is_malformed_not_never_evaluated(condition): + """The block *is* evaluated, so it must not be reported as always true.""" + assert condition_has_malformed_expression_block(condition) is True + assert condition_is_never_evaluated(condition) is False + + +def test_a_malformed_block_can_raise_rather_than_be_true(): + """The concrete case the "always true" wording got wrong. + + `default('oops` swallows the real close, the raw-close fallback hands the + filter parser a truncated argument, and the run dies instead of taking a branch. + """ + ctx = StepContext(inputs={"count": 5}) + with pytest.raises(ValueError): + evaluate_condition("{{ inputs.missing | default('oops }}", ctx) + + +@pytest.mark.parametrize("condition", NEVER_EVALUATED + MALFORMED_BLOCKS) +def test_the_two_faults_are_mutually_exclusive(condition): + assert condition_is_never_evaluated(condition) != condition_has_malformed_expression_block(condition) + + +@pytest.mark.parametrize( + "condition", + [ + "{{ inputs.count > 100 }}", + "{{ inputs.a }} and {{ inputs.b }}", + "{{ inputs.text | default('}}') }}", # literal '}}' inside an argument + "{{ inputs.x == '}}' }}", # quoted '}}' then the real close + ], +) +def test_complete_block_is_not_flagged(condition): + assert condition_is_never_evaluated(condition) is False + + +# --- The suggested correction has to survive a YAML round trip --------------- + +TRICKY_CONDITIONS = [ + "inputs.count > 100", + 'inputs.name == "zzz"', # double quote + "inputs.name == 'zzz'", # single quote + 'inputs.a == "x" and inputs.b == \'y\'', # both + "inputs.path == 'C:" + BACKSLASH + "tmp'", # backslash + 'inputs.path == "C:' + BACKSLASH + 'tmp"', # backslash + quote + '{{ inputs.name == "zzz"', # incomplete + quote + "}} inputs.count > 100 {{", + # A YAML literal block hands the loader a real newline; a folded scalar + # would lose it, so the correction has to escape rather than embed it. + "inputs.x == 1\nand inputs.name == 'abc'", + 'he said "hi"\nthen left', # newline + quote + "inputs.a == 'x\ty'", # tab + "inputs.a == 'x\ry'", # carriage return + "inputs.ten == 'mười'", # non-ASCII operand +] + + +@pytest.mark.parametrize("condition", TRICKY_CONDITIONS) +def test_correction_is_valid_yaml_and_round_trips(condition): + """A correction the author cannot paste into their workflow is no correction.""" + loaded = yaml.safe_load("condition: " + format_condition_correction(condition)) + stripped = condition.strip().lstrip("{}").rstrip("{}").strip() + assert loaded["condition"] == "{{ " + stripped + " }}" + + +@pytest.mark.parametrize("condition", TRICKY_CONDITIONS) +def test_correction_does_not_trip_the_validator_again(condition): + loaded = yaml.safe_load("condition: " + format_condition_correction(condition)) + assert condition_is_never_evaluated(loaded["condition"]) is False + + +@pytest.mark.parametrize("condition", ["{{ inputs.count > 100", "}} a > 1 {{"]) +def test_correction_replaces_a_stray_delimiter_instead_of_nesting_one(condition): + corrected = format_condition_correction(condition) + assert "{{ {{" not in corrected and "}} }}" not in corrected + assert corrected.count("{{") == 1 and corrected.count("}}") == 1 + + +@pytest.mark.parametrize("step_cls", STEP_CLASSES) +@pytest.mark.parametrize("condition", ['inputs.name == "zzz"', "{{ inputs.count > 100"]) +def test_validator_correction_is_yaml_safe(step_cls, condition): + config = {"id": "s1", "condition": condition, "then": [], "steps": []} + errors = [e for e in step_cls().validate(config) if "never evaluated" in e] + assert len(errors) == 1 + suggested = errors[0].split("Wrap the expression: ", 1)[1].rstrip(".") + loaded = yaml.safe_load("condition: " + suggested) + assert condition_is_never_evaluated(loaded["condition"]) is False + + +def test_correction_keeps_non_ascii_readable(): + """ensure_ascii=False: an operand should not turn into numeric escapes.""" + corrected = format_condition_correction("inputs.ten == 'mười'") + assert "mười" in corrected + assert chr(92) + "u" not in corrected + + +def test_whitespace_condition_is_flagged_but_the_empty_string_is_not(): + """Whitespace is the silent always-true this validator exists to catch. + + ``test_condition_whitespace_only_string_stays_truthy`` pins the runtime + behaviour deliberately, so the mistake can only be caught at validation time. + """ + assert evaluate_condition(" ", StepContext()) is True + assert condition_is_never_evaluated(" ") is True + + assert evaluate_condition("", StepContext()) is False + assert condition_is_never_evaluated("") is False + + +@pytest.mark.parametrize( + "condition", + [ + "prefix {{ inputs.ready", + "inputs.ready }} suffix", + "{{ inputs.a }} and {{ inputs.b", + ], +) +def test_correction_removes_an_interior_delimiter_too(condition): + """Trimming only the edges left the correction carrying an inner block. + + ``prefix {{ inputs.ready`` corrected to ``"{{ prefix {{ inputs.ready }}"``, + whose complete outer block then walked back past this very validator. + """ + corrected = format_condition_correction(condition) + inner = yaml.safe_load("condition: " + corrected)["condition"] + assert inner.count("{{") == 1 and inner.count("}}") == 1 + assert inner.startswith("{{ ") and inner.endswith(" }}") + + +def test_correction_keeps_a_delimiter_that_is_quoted_data(): + """``'}}'`` is an operand, not a block, so the stripper must not eat it.""" + corrected = format_condition_correction("{{ inputs.x == '}}'") + inner = yaml.safe_load("condition: " + corrected)["condition"] + assert inner == "{{ inputs.x == '}}' }}" + assert condition_is_never_evaluated(inner) is False + + +def test_correction_preserves_spacing_inside_a_quoted_operand(): + """Whitespace is collapsed only where a delimiter was removed.""" + corrected = format_condition_correction('{{ inputs.name == "a b"') + inner = yaml.safe_load("condition: " + corrected)["condition"] + assert inner == '{{ inputs.name == "a b" }}' + + +@pytest.mark.parametrize("step_cls", STEP_CLASSES) +@pytest.mark.parametrize("condition", MALFORMED_BLOCKS) +def test_validator_reports_malformed_rather_than_always_true(step_cls, condition): + """The two faults need opposite advice, so they must not share a message. + + "never evaluated and is always true" is wrong here on both halves: the + interpolator does evaluate the truncated body, and the result is not + reliably true -- it can raise. + """ + config = {"id": "s1", "condition": condition, "then": [], "steps": []} + errors = [e for e in step_cls().validate(config) if "'condition'" in e] + + assert len(errors) == 1 + assert "never evaluated" not in errors[0] + assert "cannot close" in errors[0] + assert "truncated expression" in errors[0] + + +@pytest.mark.parametrize("step_cls", STEP_CLASSES) +@pytest.mark.parametrize("condition", MALFORMED_BLOCKS) +def test_malformed_message_offers_no_paste_ready_correction(step_cls, condition): + """Deliberately no suggestion for this class. + + The fault is unbalanced delimiters or quotes, so the quote-aware stripper + cannot tell operand from delimiter -- for `{{ inputs.missing | default('oops }}` + it produces `"{{ inputs.missing | default('oops }} }}"`, which is not a fix. + Naming the fault beats handing back something that looks authoritative and + is not. + """ + config = {"id": "s1", "condition": condition, "then": [], "steps": []} + errors = [e for e in step_cls().validate(config) if "'condition'" in e] + assert "Wrap the expression" not in errors[0] + assert errors[0].rstrip().endswith("Balance the delimiters and quotes.") From 5c171f711b9a3b6e4b4c7855614ea4e1d0880707 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Thu, 20 Aug 2026 08:34:21 -0500 Subject: [PATCH 011/102] Update SpecKit Companion extension to v0.20.2 (#4225) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Update companion extension submitted by @alfredoperez: - extensions/catalog.community.json (version 0.11.0 → 0.20.2, download_url, description, provides.commands 13 → 18, tags, category visibility → process, updated_at) - docs/community/extensions.md community extensions table (description, category) Closes #4221 Assisted-by: GitHub Copilot (model: claude-sonnet-4.6, autonomous) Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com> Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- docs/community/extensions.md | 2 +- extensions/catalog.community.json | 209 +++++++++++++++++++++++------- 2 files changed, 165 insertions(+), 46 deletions(-) diff --git a/docs/community/extensions.md b/docs/community/extensions.md index 40f1bdcdaa..dddcc79892 100644 --- a/docs/community/extensions.md +++ b/docs/community/extensions.md @@ -153,7 +153,7 @@ The following community-contributed extensions are available in [`catalog.commun | Spec2Cloud | Spec-driven workflow tuned for shipping to Azure | `process` | Read+Write | [spec2cloud](https://github.com/Azure-Samples/Spec2Cloud) | | SpecAssay Check | Gate 2 refuses silent gaps and emits a trace-manifest (trace-manifest.json). | `visibility` | Read+Write | [specassay](https://github.com/rdryfoos/specassay) | | SpecJudge — right-size the model before you implement | Recommends the model that fits your tasks, citing the spec fragment behind every level. | `process` | Read-only | [SpecJudge](https://github.com/JoaquinRuiz/SpecJudge) | -| SpecKit Companion | Live spec-driven progress — lifecycle capture, status, resume, and a turbo pipeline profile | `visibility` | Read+Write | [speckit-companion](https://github.com/alfredoperez/speckit-companion) | +| SpecKit Companion | Live spec-driven progress — lifecycle capture, status, resume, living specs, and composable commands with hooks and recipes | `process` | Read+Write | [speckit-companion](https://github.com/alfredoperez/speckit-companion) | | SpecKit Grill Me | Exhaustively resolve specification ambiguities and decisions before planning | `process` | Read+Write | [speckit-grill-me](https://github.com/yoshi1220/speckit-grill-me) | | SpecTest | Auto-generate test scaffolds from spec criteria, map coverage, and find untested requirements | `code` | Read+Write | [spec-kit-spectest](https://github.com/Quratulain-bilal/spec-kit-spectest) | | Squad Bridge | Bootstrap and synchronize a Squad agent team from your Speckit spec and tasks. | `process` | Read+Write | [spec-kit-squad](https://github.com/jwill824/spec-kit-squad) | diff --git a/extensions/catalog.community.json b/extensions/catalog.community.json index f14877c1be..43c7e0227e 100644 --- a/extensions/catalog.community.json +++ b/extensions/catalog.community.json @@ -1,6 +1,6 @@ { "schema_version": "1.0", - "updated_at": "2026-08-19T00:00:00Z", + "updated_at": "2026-08-20T00:00:00Z", "catalog_url": "https://raw.githubusercontent.com/github/spec-kit/main/extensions/catalog.community.json", "extensions": { "adrkit": { @@ -19,7 +19,13 @@ "effect": "read-write", "requires": { "speckit_version": ">=0.13.0,<0.16.0", - "tools": [{ "name": "adr", "version": ">=0.3.0", "required": true }] + "tools": [ + { + "name": "adr", + "version": ">=0.3.0", + "required": true + } + ] }, "provides": { "commands": 3, @@ -338,8 +344,15 @@ "requires": { "speckit_version": ">=0.1.0", "tools": [ - { "name": "python", "version": ">=3.11", "required": true }, - { "name": "uv", "required": true } + { + "name": "python", + "version": ">=3.11", + "required": true + }, + { + "name": "uv", + "required": true + } ] }, "provides": { @@ -514,8 +527,15 @@ "requires": { "speckit_version": ">=0.1.0", "tools": [ - { "name": "python", "version": ">=3.11", "required": true }, - { "name": "uv", "required": true } + { + "name": "python", + "version": ">=3.11", + "required": true + }, + { + "name": "uv", + "required": true + } ] }, "provides": { @@ -687,8 +707,14 @@ "requires": { "speckit_version": ">=0.10.0", "tools": [ - { "name": "bash", "required": false }, - { "name": "git", "required": false } + { + "name": "bash", + "required": false + }, + { + "name": "git", + "required": false + } ] }, "provides": { @@ -967,7 +993,12 @@ "effect": "read-write", "requires": { "speckit_version": ">=0.11.9", - "tools": [{ "name": "git", "required": false }] + "tools": [ + { + "name": "git", + "required": false + } + ] }, "provides": { "commands": 5, @@ -1122,40 +1153,42 @@ "companion": { "name": "SpecKit Companion", "id": "companion", - "description": "Live spec-driven progress for SpecKit Companion — lifecycle capture, status, resume, and composable commands you can customize with hooks and recipes.", + "description": "Live spec-driven progress for SpecKit Companion — lifecycle capture, status, resume, living specs, and composable commands you can customize with hooks and recipes.", "author": "alfredoperez", - "version": "0.11.0", - "download_url": "https://github.com/alfredoperez/speckit-companion/releases/download/speckit-ext-v0.11.0/companion-0.11.0.zip", + "version": "0.20.2", + "download_url": "https://github.com/alfredoperez/speckit-companion/releases/download/speckit-ext-v0.20.2/companion-0.20.2.zip", "repository": "https://github.com/alfredoperez/speckit-companion", "homepage": "https://github.com/alfredoperez/speckit-companion/tree/main/speckit-extension", "documentation": "https://github.com/alfredoperez/speckit-companion/blob/main/speckit-extension/README.md", "changelog": "https://github.com/alfredoperez/speckit-companion/blob/main/speckit-extension/CHANGELOG.md", "license": "MIT", - "category": "visibility", + "category": "process", "effect": "read-write", "requires": { "speckit_version": ">=0.9.5", "tools": [ - { "name": "python3", "required": false } + { + "name": "python3", + "required": false + } ] }, "provides": { - "commands": 13, + "commands": 18, "hooks": 4 }, "tags": [ "vscode", "progress", - "status", - "resume", - "configurable", - "extensible" + "living-specs", + "drift", + "hooks" ], "verified": false, "downloads": 0, "stars": 0, "created_at": "2026-06-11T00:00:00Z", - "updated_at": "2026-06-24T00:00:00Z" + "updated_at": "2026-08-20T00:00:00Z" }, "conduct": { "name": "Conduct Extension", @@ -1671,11 +1704,26 @@ "requires": { "speckit_version": ">=0.1.0", "tools": [ - { "name": "git", "required": true }, - { "name": "bash", "required": false }, - { "name": "curl", "required": false }, - { "name": "jq", "required": false }, - { "name": "pwsh", "required": false } + { + "name": "git", + "required": true + }, + { + "name": "bash", + "required": false + }, + { + "name": "curl", + "required": false + }, + { + "name": "jq", + "required": false + }, + { + "name": "pwsh", + "required": false + } ] }, "provides": { @@ -1712,7 +1760,11 @@ "requires": { "speckit_version": ">=0.1.0", "tools": [ - { "name": "python3", "version": ">=3.8", "required": true } + { + "name": "python3", + "version": ">=3.8", + "required": true + } ] }, "provides": { @@ -2018,7 +2070,12 @@ "effect": "read-write", "requires": { "speckit_version": ">=0.16.2", - "tools": [{ "name": "bash", "required": true }] + "tools": [ + { + "name": "bash", + "required": true + } + ] }, "provides": { "commands": 1, @@ -2304,12 +2361,31 @@ "requires": { "speckit_version": ">=0.1.0", "tools": [ - { "name": "bash", "version": ">=4.4", "required": true }, - { "name": "git", "required": true }, - { "name": "curl", "required": true }, - { "name": "jq", "required": true }, - { "name": "gitleaks", "required": false }, - { "name": "trufflehog", "required": false } + { + "name": "bash", + "version": ">=4.4", + "required": true + }, + { + "name": "git", + "required": true + }, + { + "name": "curl", + "required": true + }, + { + "name": "jq", + "required": true + }, + { + "name": "gitleaks", + "required": false + }, + { + "name": "trufflehog", + "required": false + } ] }, "provides": { @@ -2447,7 +2523,12 @@ "effect": "read-write", "requires": { "speckit_version": ">=0.13.0,<1.0.0", - "tools": [{ "name": "linear-mcp", "required": true }] + "tools": [ + { + "name": "linear-mcp", + "required": true + } + ] }, "provides": { "commands": 5, @@ -2868,7 +2949,10 @@ "requires": { "speckit_version": ">=0.2.0", "tools": [ - { "name": "memsearch", "required": false } + { + "name": "memsearch", + "required": false + } ] }, "provides": { @@ -4386,8 +4470,15 @@ "requires": { "speckit_version": ">=0.14.0", "tools": [ - { "name": "bash", "required": true }, - { "name": "python3", "version": ">=3.8", "required": true } + { + "name": "bash", + "required": true + }, + { + "name": "python3", + "version": ">=3.8", + "required": true + } ] }, "provides": { @@ -4423,7 +4514,13 @@ "effect": "read-only", "requires": { "speckit_version": ">=0.13.0", - "tools": [{ "name": "specjudge", "version": ">=0.5.0", "required": true }] + "tools": [ + { + "name": "specjudge", + "version": ">=0.5.0", + "required": true + } + ] }, "provides": { "commands": 1, @@ -4830,8 +4927,14 @@ "requires": { "speckit_version": ">=0.2.0", "tools": [ - { "name": "gh", "required": true }, - { "name": "python3", "required": true } + { + "name": "gh", + "required": true + }, + { + "name": "python3", + "required": true + } ] }, "provides": { @@ -5200,11 +5303,27 @@ "requires": { "speckit_version": ">=0.10.0", "tools": [ - { "name": "rtk", "required": false }, - { "name": "headroom", "required": false }, - { "name": "token-router", "required": false }, - { "name": "ollama", "required": false }, - { "name": "python", "version": ">=3.10", "required": false } + { + "name": "rtk", + "required": false + }, + { + "name": "headroom", + "required": false + }, + { + "name": "token-router", + "required": false + }, + { + "name": "ollama", + "required": false + }, + { + "name": "python", + "version": ">=3.10", + "required": false + } ] }, "provides": { From a7eb6064a1f94537296de99b456ad62000d93766 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Thu, 20 Aug 2026 08:39:44 -0500 Subject: [PATCH 012/102] [extension] Update Architecture Guard extension to v2.3.6 (#4224) * Update Architecture Guard extension to v2.3.6 Update architecture-guard extension submitted by @DyanGalih to: - extensions/catalog.community.json (version, download_url, repository, description, etc.) - docs/community/extensions.md community extensions table Closes #4219 Assisted-by: GitHub Copilot (model: claude-sonnet-4.6, autonomous) Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * Revert unrelated community catalog formatting Keep the Architecture Guard v2.3.6 update while restoring all unrelated catalog entries to their existing formatting. Assisted-by: GitHub Copilot (model: GPT-5.6 Sol, 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 <223556219+Copilot@users.noreply.github.com> Co-authored-by: Manfred Riem <15701806+mnriem@users.noreply.github.com> --- docs/community/extensions.md | 2 +- extensions/catalog.community.json | 38 +++++++++++++++++++------------ 2 files changed, 24 insertions(+), 16 deletions(-) diff --git a/docs/community/extensions.md b/docs/community/extensions.md index dddcc79892..081c17cff6 100644 --- a/docs/community/extensions.md +++ b/docs/community/extensions.md @@ -35,7 +35,7 @@ The following community-contributed extensions are available in [`catalog.commun | API Evolve | Managed API contract evolution — breaking-change detection, semver enforcement, deprecation orchestration, and lifecycle gates across REST, GraphQL, and gRPC | `process` | Read+Write | [spec-kit-api-evolve](https://github.com/Quratulain-bilal/spec-kit-api-evolve) | | Architect Impact Previewer | Predicts architectural impact, complexity, and risks of proposed changes before implementation. | `visibility` | Read-only | [spec-kit-architect-preview](https://github.com/UmmeHabiba1312/spec-kit-architect-preview) | | Architecture Governance | Keep specs, code & ADRs in sync: citation slots + a read-only, fail-closed validator | `docs` | Read+Write | [spec-kit-arch-governance](https://github.com/ashbrener/spec-kit-arch-governance) | -| Architecture Guard | Framework-agnostic architecture review extension for validating implementation against governance and architecture constitutions, detecting architectural drift, and generating non-blocking refactor tasks | `process` | Read+Write | [spec-kit-architecture-guard](https://github.com/DyanGalih/spec-kit-architecture-guard) | +| Architecture Guard | Framework-agnostic architecture governance for Spec Kit workflows, detecting drift, enforcing architectural rules, and generating actionable refactor tasks | `process` | Read+Write | [architecture-guard](https://github.com/DyanGalih/architecture-guard) | | Architecture Workflow | Generate or reverse project-level 4+1 architecture views with per-view and full-workflow commands | `docs` | Read+Write | [spec-kit-arch](https://github.com/bigsmartben/spec-kit-arch) | | Archive Extension | Archive merged features into main project memory, 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) | diff --git a/extensions/catalog.community.json b/extensions/catalog.community.json index 43c7e0227e..5745054039 100644 --- a/extensions/catalog.community.json +++ b/extensions/catalog.community.json @@ -409,39 +409,47 @@ "architecture-guard": { "name": "Architecture Guard", "id": "architecture-guard", - "description": "Framework-agnostic architecture review extension for validating implementation against governance and architecture constitutions, detecting architectural drift, and generating non-blocking refactor tasks.", + "description": "Framework-agnostic architecture governance for Spec Kit workflows, detecting drift, enforcing architectural rules, and generating actionable refactor tasks.", "author": "DyanGalih", - "version": "1.13.1", - "download_url": "https://github.com/DyanGalih/spec-kit-architecture-guard/archive/refs/tags/v1.13.1.zip", - "repository": "https://github.com/DyanGalih/spec-kit-architecture-guard", - "homepage": "https://github.com/DyanGalih/spec-kit-architecture-guard", - "documentation": "https://github.com/DyanGalih/spec-kit-architecture-guard/blob/main/docs/architecture-overview.md", - "changelog": "https://github.com/DyanGalih/spec-kit-architecture-guard/releases", + "version": "2.3.6", + "download_url": "https://github.com/DyanGalih/architecture-guard/archive/refs/tags/v2.3.6.zip", + "repository": "https://github.com/DyanGalih/architecture-guard", + "homepage": "https://github.com/DyanGalih/architecture-guard", + "documentation": "https://github.com/DyanGalih/architecture-guard/blob/main/SPECKIT-INTEGRATION.md", + "changelog": "https://github.com/DyanGalih/architecture-guard/blob/main/docs/release-notes.md", "license": "MIT", "category": "process", "effect": "read-write", "requires": { - "speckit_version": ">=0.1.0" + "speckit_version": ">=0.1.0", + "tools": [ + { + "name": "node", + "version": ">=18", + "required": false + }, + { + "name": "npm", + "required": false + } + ] }, "provides": { - "commands": 14, + "commands": 18, "hooks": 3 }, "tags": [ "architecture", - "spec-kit", + "governance", "review", "refactor", - "workflow", - "governance", - "guardrails", - "hygiene" + "workflow" ], "verified": false, "downloads": 0, "stars": 0, "created_at": "2026-05-05T07:26:00Z", - "updated_at": "2026-07-24T00:00:00Z" + "updated_at": "2026-08-20T00:00:00Z" }, "archive": { "name": "Archive Extension", From 2d217aef8186822258745e0948e2b082a009adf9 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Thu, 20 Aug 2026 08:55:44 -0500 Subject: [PATCH 013/102] [extension] Add Spec Inventory extension to community catalog (#4228) * Add Spec Inventory extension to community catalog Add speckit-inventory extension submitted by @Yash-Chindam to: - extensions/catalog.community.json (alphabetical order) - docs/community/extensions.md community extensions table Closes #4226 Assisted-by: GitHub Copilot (model: claude-sonnet-4.6, autonomous) Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * Potential fix for pull request finding Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --------- Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com> Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Co-authored-by: Manfred Riem <15701806+mnriem@users.noreply.github.com> Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --- docs/community/extensions.md | 1 + extensions/catalog.community.json | 35 +++++++++++++++++++++++++++++++ 2 files changed, 36 insertions(+) diff --git a/docs/community/extensions.md b/docs/community/extensions.md index 081c17cff6..29c2363125 100644 --- a/docs/community/extensions.md +++ b/docs/community/extensions.md @@ -135,6 +135,7 @@ The following community-contributed extensions are available in [`catalog.commun | Spec Changelog | Auto-generate changelogs and release notes from spec git history and requirement diffs | `docs` | Read-only | [spec-kit-changelog](https://github.com/Quratulain-bilal/spec-kit-changelog) | | Spec Critique Extension | Dual-lens critical review of spec and plan from product strategy and engineering risk perspectives | `docs` | Read-only | [spec-kit-critique](https://github.com/arunt14/spec-kit-critique) | | Spec Diagram | Auto-generate Mermaid diagrams of SDD workflow state, feature progress, and task dependencies | `visibility` | Read-only | [spec-kit-diagram-](https://github.com/Quratulain-bilal/spec-kit-diagram-) | +| Spec Inventory | Read-only inventory of live requirement and task IDs, with focused per-task context packs instead of whole-file dumps | `visibility` | Read-only | [spec-kit-inventory-alignment](https://github.com/Yash-Chindam/spec-kit-inventory-alignment) | | Spec Kit Discovery Extension | Run technical discovery commands for feasibility, technology selection, scenario-specific technical decisions, legacy codebase assessment, implementation understanding, and proof-of-concept validation | `process` | Read+Write | [spec-kit-discovery](https://github.com/bigsmartben/spec-kit-discovery) | | Spec Kit Figma | Agent-agnostic SpecKit extension that grounds spec, plan & task generation in Figma design context — REST + optional MCP, single/mono/multi-repo, 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) | diff --git a/extensions/catalog.community.json b/extensions/catalog.community.json index 5745054039..b798dd9da8 100644 --- a/extensions/catalog.community.json +++ b/extensions/catalog.community.json @@ -4546,6 +4546,41 @@ "created_at": "2026-08-12T00:00:00Z", "updated_at": "2026-08-12T00:00:00Z" }, + "speckit-inventory": { + "name": "Spec Inventory", + "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", + "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", + "changelog": "https://github.com/Yash-Chindam/spec-kit-inventory-alignment/blob/main/speckit-inventory/CHANGELOG.md", + "license": "MIT", + "category": "visibility", + "effect": "read-only", + "requires": { + "speckit_version": ">=0.9.0" + }, + "provides": { + "commands": 2, + "hooks": 2 + }, + "tags": [ + "inventory", + "requirements", + "context", + "traceability", + "alignment" + ], + "verified": false, + "downloads": 0, + "stars": 0, + "created_at": "2026-08-20T00:00:00Z", + "updated_at": "2026-08-20T00:00:00Z" + }, "speckit-superpowers-bridge": { "name": "Superpowers Implementation Bridge", "id": "speckit-superpowers-bridge", From abfc66b670c81b9758f1f47f18f7fea0f48686cf Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Thu, 20 Aug 2026 09:10:18 -0500 Subject: [PATCH 014/102] [preset] Add Inventory Alignment preset to community catalog (#4229) * Add Inventory Alignment preset to community catalog Add inventory-alignment preset submitted by @Yash-Chindam to: - presets/catalog.community.json (alphabetical order) - docs/community/presets.md community presets table Closes #4227 Assisted-by: GitHub Copilot (model: claude-sonnet-4.6, autonomous) Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * Potential fix for pull request finding Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> * fix(presets): complete inventory alignment metadata Restore the required speckit-inventory dependency and pin the submitted release archive SHA-256. Assisted-by: GitHub Copilot (model: GPT-5.6 Sol, 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 <223556219+Copilot@users.noreply.github.com> Co-authored-by: Manfred Riem <15701806+mnriem@users.noreply.github.com> Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --- docs/community/presets.md | 1 + presets/catalog.community.json | 34 +++++++++++++++++++++++++++++++++- 2 files changed, 34 insertions(+), 1 deletion(-) diff --git a/docs/community/presets.md b/docs/community/presets.md index 02d376a850..d19eb35447 100644 --- a/docs/community/presets.md +++ b/docs/community/presets.md @@ -23,6 +23,7 @@ The following community-contributed presets customize how Spec Kit behaves — o | Intake Authoring Governance | Governs traceable intake CRUD, language-aware requirements collections, bounded public HTTPS sources, and explicitly approved single or series authoring. | 13 templates, 5 commands, 7 scripts | — | [spec-kit-preset-intake-authoring-governance](https://github.com/hindermath/spec-kit-preset-intake-authoring-governance) | | Intake Review Governance | Reviews single, series, campaign, and language-aware requirements collections before Spec Kit execution. | 9 templates, 3 commands, 5 scripts | — | [spec-kit-preset-intake-review-governance](https://github.com/hindermath/spec-kit-preset-intake-review-governance) | | Intake Sequencing Governance | Manages language-aware intake-series order, typed dependencies, lifecycle, and authority-neutral next-candidate selection. | 11 templates, 6 commands, 8 scripts | — | [spec-kit-preset-intake-sequencing-governance](https://github.com/hindermath/spec-kit-preset-intake-sequencing-governance) | +| Inventory Alignment | Classifies each requirement against a read-only inventory of live IDs before writing, so reworded requirements are updated instead of duplicated. | 1 template, 2 commands | speckit-inventory extension | [spec-kit-inventory-alignment](https://github.com/Yash-Chindam/spec-kit-inventory-alignment) | | iSAQB Architecture Governance | Adds iSAQB/CPSA-F and arc42 architecture governance, architecture views, quality scenarios, ADRs, risks, technical-debt evidence, and provider-neutral model routing. | 14 templates, 3 commands | — | [spec-kit-preset-isaqb-architecture-governance](https://github.com/hindermath/spec-kit-preset-isaqb-architecture-governance) | | Jira Issue Tracking | Overrides `speckit.taskstoissues` to create Jira epics, stories, and tasks instead of GitHub Issues via Atlassian MCP tools | 1 command | — | [spec-kit-preset-jira](https://github.com/luno/spec-kit-preset-jira) | | Model Driven Engineering | Focuses on streamlined commands, app repository support, cross-spec support, and capability-aware project memory for model-driven engineering workflows | 6 templates, 11 commands | MDE extension | [spec-kit-preset-mde](https://github.com/AI-MDE/spec-kit-preset-mde) | diff --git a/presets/catalog.community.json b/presets/catalog.community.json index 66e8521677..567dd354e1 100644 --- a/presets/catalog.community.json +++ b/presets/catalog.community.json @@ -1,6 +1,6 @@ { "schema_version": "1.0", - "updated_at": "2026-08-19T00:00:00Z", + "updated_at": "2026-08-20T00:00:00Z", "catalog_url": "https://raw.githubusercontent.com/github/spec-kit/main/presets/catalog.community.json", "presets": { "a11y-governance": { @@ -458,6 +458,38 @@ "created_at": "2026-07-27T00:00:00Z", "updated_at": "2026-07-28T00:00:00Z" }, + "inventory-alignment": { + "name": "Inventory Alignment", + "id": "inventory-alignment", + "version": "0.1.0", + "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", + "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", + "extensions": [ + "speckit-inventory" + ] + }, + "provides": { + "templates": 1, + "commands": 2 + }, + "tags": [ + "inventory", + "alignment", + "requirements", + "traceability", + "workflow" + ], + "created_at": "2026-08-20T00:00:00Z", + "updated_at": "2026-08-20T00:00:00Z" + }, "isaqb-architecture-governance": { "name": "iSAQB Architecture Governance", "id": "isaqb-architecture-governance", From fa19e1c68b6daec5cab3309913cf5ecf6553075d Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Thu, 20 Aug 2026 11:03:23 -0500 Subject: [PATCH 015/102] [bug-fix] Fix qodercli-skills-migration: migrate QodercliIntegration to SkillsIntegration (#4205) * Fix qodercli-skills-migration: migrate QodercliIntegration to SkillsIntegration Apply the remediation from the bug assessment on issue #4199. Qoder IDE 1.24+ dropped .qoder/commands/ scanning in favour of the skills layout (.qoder/skills/{skill-name}/SKILL.md). Migrated QodercliIntegration from MarkdownIntegration to SkillsIntegration, updating config[commands_subdir] to 'skills' and registrar_config[dir] to '.qoder/skills' with extension '/SKILL.md'. Updated tests to use SkillsIntegrationTests base mixin. Refs #4199 Assisted-by: GitHub Copilot (model: claude-sonnet-4.6, autonomous) Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * fix(qodercli): resolve failing skills-flag test and slash invocation Builds on the qodercli->SkillsIntegration migration (PR #4205). Qoder IDE 1.24+ is always skills-based, so it should not expose a --skills toggle. Override the inherited SkillsIntegrationTests.test_options_include_skills_flag to skip (mirroring Grok/Zed/Droid) and add a test asserting no --skills option, plus a requires_cli/name/multi_install_safe check. Also add "qodercli" to ALWAYS_SLASH_AGENTS so hooks and next-steps render the hyphenated /speckit- invocation instead of the legacy dotted /speckit. form. Fixes the single failing test reported for #4199. Assisted-by: GitHub Copilot (model: claude-opus-4.8, autonomous) Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: 43394151-ce2a-432d-9cc5-88f587d1b570 * fix(qodercli): migrate legacy extension commands Retire old flat Qoder extension commands only after their replacement skills are successfully written. Cover old-layout upgrades and both slash invocation states, and update the integration reference path. Assisted-by: GitHub Copilot (model: GPT-5.6 Sol, 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 <223556219+Copilot@users.noreply.github.com> Co-authored-by: Manfred Riem <15701806+mnriem@users.noreply.github.com> Copilot-Session: 43394151-ce2a-432d-9cc5-88f587d1b570 --- docs/reference/integrations.md | 2 +- src/specify_cli/_invocation_style.py | 4 +- src/specify_cli/extensions/__init__.py | 77 +++++++++++++++++++ src/specify_cli/integrations/base.py | 6 ++ .../integrations/qodercli/__init__.py | 19 +++-- .../integrations/test_integration_qodercli.py | 37 ++++++++- .../test_integration_subcommand.py | 60 +++++++++++++++ tests/integrations/test_integration_zed.py | 2 + 8 files changed, 195 insertions(+), 12 deletions(-) diff --git a/docs/reference/integrations.md b/docs/reference/integrations.md index 57bb46b10c..57b079bcd1 100644 --- a/docs/reference/integrations.md +++ b/docs/reference/integrations.md @@ -292,7 +292,7 @@ The currently declared multi-install safe integrations are: | `lingma` | `.lingma/skills` | | `omp` | `.omp/commands` | | `pi` | `.pi/prompts` | -| `qodercli` | `.qoder/commands` | +| `qodercli` | `.qoder/skills` | | `qwen` | `.qwen/commands` | | `shai` | `.shai/commands` | | `tabnine` | `.tabnine/agent/commands` | diff --git a/src/specify_cli/_invocation_style.py b/src/specify_cli/_invocation_style.py index 5cc7098837..ec6ac0f323 100644 --- a/src/specify_cli/_invocation_style.py +++ b/src/specify_cli/_invocation_style.py @@ -12,7 +12,9 @@ DOLLAR_SKILLS_AGENTS: frozenset[str] = frozenset({"codex", "zcode", "command-code"}) # Agents that always render /speckit-, regardless of ai_skills. -ALWAYS_SLASH_AGENTS: frozenset[str] = frozenset({"devin", "droid", "grok", "trae", "zed"}) +ALWAYS_SLASH_AGENTS: frozenset[str] = frozenset( + {"devin", "droid", "grok", "qodercli", "trae", "zed"} +) # Agents that render /speckit- only when ai_skills is enabled. CONDITIONAL_SLASH_AGENTS: frozenset[str] = frozenset( diff --git a/src/specify_cli/extensions/__init__.py b/src/specify_cli/extensions/__init__.py index fb4a30519d..3968e4fcbe 100644 --- a/src/specify_cli/extensions/__init__.py +++ b/src/specify_cli/extensions/__init__.py @@ -3100,6 +3100,76 @@ def unregister_agent_artifacts( if updates: self.registry.update(ext_id, updates) + def _retire_legacy_flat_extension_commands( + self, + agent_name: str, + command_names: List[str], + ) -> List[Path]: + """Remove old flat commands whose replacement skills were written.""" + from ..agents import CommandRegistrar + from ..integrations import get_integration + + integration = get_integration(agent_name) + legacy_dir = getattr(integration, "legacy_flat_command_dir", None) + legacy_extension = getattr( + integration, "legacy_flat_command_extension", None + ) + if ( + not isinstance(legacy_dir, str) + or not legacy_dir + or not isinstance(legacy_extension, str) + or not legacy_extension + ): + return [] + + registrar = CommandRegistrar() + agent_config = registrar.AGENT_CONFIGS.get(agent_name) + if not agent_config or agent_config.get("extension") != "/SKILL.md": + return [] + + def safe_project_dir(relative: str) -> Optional[Path]: + rel = Path(relative) + if rel.is_absolute() or ".." in rel.parts: + return None + current = self.project_root + for part in rel.parts: + current /= part + if current.is_symlink(): + return None + try: + current.resolve().relative_to(self.project_root.resolve()) + except (OSError, ValueError): + return None + return current + + legacy_root = safe_project_dir(legacy_dir) + skills_root = safe_project_dir(str(agent_config.get("dir", ""))) + if legacy_root is None or skills_root is None or not legacy_root.is_dir(): + return [] + + removed: List[Path] = [] + for command_name in command_names: + if ( + not isinstance(command_name, str) + or not command_name + or not registrar._is_safe_command_name(command_name) + ): + continue + + skill_name = registrar._compute_output_name( + agent_name, command_name, agent_config + ) + replacement = skills_root / skill_name / "SKILL.md" + if replacement.is_symlink() or not replacement.is_file(): + continue + + legacy_file = legacy_root / f"{command_name}{legacy_extension}" + if legacy_file.is_symlink() or legacy_file.is_file(): + legacy_file.unlink() + removed.append(legacy_file) + + return removed + def register_enabled_extensions_for_agent(self, agent_name: str, *, force: bool = False) -> None: """Register installed, enabled extensions for ``agent_name``. @@ -3160,6 +3230,7 @@ def register_enabled_extensions_for_agent(self, agent_name: str, *, force: bool # registration of the remaining enabled extensions for this agent. try: updates: Dict[str, Any] = {} + registered: List[str] = [] # Set when a command -> skills toggle for this same agent # defers stale command-mode cleanup until the skills # replacement below confirms success (#2948). @@ -3380,6 +3451,12 @@ def register_enabled_extensions_for_agent(self, agent_name: str, *, force: bool if new_registered != registered_commands: updates["registered_commands"] = new_registered + if registered: + self._retire_legacy_flat_extension_commands( + agent_name, + registered, + ) + if updates: self.registry.update(ext_id, updates) except Exception as ext_err: diff --git a/src/specify_cli/integrations/base.py b/src/specify_cli/integrations/base.py index 03c7a90e74..27c43582b0 100644 --- a/src/specify_cli/integrations/base.py +++ b/src/specify_cli/integrations/base.py @@ -142,6 +142,12 @@ class IntegrationBase(ABC): integration that sets this flag. """ + legacy_flat_command_dir: str | None = None + """Previous flat command directory retired after skill replacements exist.""" + + legacy_flat_command_extension: str | None = None + """File extension used by commands in ``legacy_flat_command_dir``.""" + def post_process_command_content(self, content: str) -> str: """Transform command content after format rendering. diff --git a/src/specify_cli/integrations/qodercli/__init__.py b/src/specify_cli/integrations/qodercli/__init__.py index 13535203cf..0fec683fae 100644 --- a/src/specify_cli/integrations/qodercli/__init__.py +++ b/src/specify_cli/integrations/qodercli/__init__.py @@ -1,21 +1,28 @@ -"""Qoder CLI integration.""" +"""Qoder CLI integration. -from ..base import MarkdownIntegration +Qoder IDE 1.24+ dropped ``.qoder/commands/`` scanning in favour of the +skills layout: ``.qoder/skills/{skill-name}/SKILL.md`` with a ``name`` +field in frontmatter. Migrated to ``SkillsIntegration`` to match. +""" +from ..base import SkillsIntegration -class QodercliIntegration(MarkdownIntegration): + +class QodercliIntegration(SkillsIntegration): key = "qodercli" config = { "name": "Qoder CLI", "folder": ".qoder/", - "commands_subdir": "commands", + "commands_subdir": "skills", "install_url": "https://qoder.com/cli", "requires_cli": True, } registrar_config = { - "dir": ".qoder/commands", + "dir": ".qoder/skills", "format": "markdown", "args": "$ARGUMENTS", - "extension": ".md", + "extension": "/SKILL.md", } + legacy_flat_command_dir = ".qoder/commands" + legacy_flat_command_extension = ".md" multi_install_safe = True diff --git a/tests/integrations/test_integration_qodercli.py b/tests/integrations/test_integration_qodercli.py index 29a6d16d29..f30f62cae0 100644 --- a/tests/integrations/test_integration_qodercli.py +++ b/tests/integrations/test_integration_qodercli.py @@ -1,10 +1,39 @@ """Tests for QodercliIntegration.""" -from .test_integration_base_markdown import MarkdownIntegrationTests +import pytest +from specify_cli.integrations import get_integration -class TestQodercliIntegration(MarkdownIntegrationTests): +from .test_integration_base_skills import SkillsIntegrationTests + + +class TestQodercliIntegration(SkillsIntegrationTests): KEY = "qodercli" FOLDER = ".qoder/" - COMMANDS_SUBDIR = "commands" - REGISTRAR_DIR = ".qoder/commands" + COMMANDS_SUBDIR = "skills" + REGISTRAR_DIR = ".qoder/skills" + + def test_options_include_skills_flag(self): + """Not applicable — Qoder IDE 1.24+ is always skills-based.""" + pytest.skip( + "Qoder is always skills-based and does not expose a --skills option" + ) + + def test_options_do_not_include_skills_flag(self): + """Qoder is always skills-based; no --skills option is exposed.""" + i = get_integration(self.KEY) + assert i is not None + opts = i.options() + skills_opts = [o for o in opts if o.name == "--skills"] + assert len(skills_opts) == 0, ( + "Qoder is always skills-based and should not expose a --skills option" + ) + + def test_requires_cli_is_true(self): + """Qoder CLI is a CLI-based agent; requires_cli must remain True.""" + i = get_integration(self.KEY) + assert i is not None + assert i.config is not None + assert i.config["requires_cli"] is True + assert i.config["name"] == "Qoder CLI" + assert i.multi_install_safe is True diff --git a/tests/integrations/test_integration_subcommand.py b/tests/integrations/test_integration_subcommand.py index 994fecb148..eaeecc6740 100644 --- a/tests/integrations/test_integration_subcommand.py +++ b/tests/integrations/test_integration_subcommand.py @@ -3153,6 +3153,66 @@ def test_upgrade_migrates_kilocode_legacy_dir(self, tmp_path): f"after upgrade, found: {[f.name for f in core_remaining]}" ) + def test_upgrade_migrates_qodercli_extension_commands_to_skills(self, tmp_path): + """Qoder upgrade retires old extension commands after skills exist.""" + project = _init_project(tmp_path, "qodercli") + result = _run_in_project(project, ["extension", "add", "git"]) + assert result.exit_code == 0, f"extension add failed: {result.output}" + + skills = project / ".qoder" / "skills" + commands = project / ".qoder" / "commands" + commands.mkdir(parents=True) + + manifest_path = ( + project / ".specify" / "integrations" / "qodercli.manifest.json" + ) + manifest_data = json.loads(manifest_path.read_text(encoding="utf-8")) + legacy_manifest_files = {} + for path, info in manifest_data["files"].items(): + skill_path = project / path + command_name = skill_path.parent.name.replace("speckit-", "speckit.", 1) + legacy_path = commands / f"{command_name}.md" + legacy_path.write_bytes(skill_path.read_bytes()) + legacy_manifest_files[ + legacy_path.relative_to(project).as_posix() + ] = info + manifest_data["files"] = legacy_manifest_files + manifest_path.write_text(json.dumps(manifest_data), encoding="utf-8") + + registry_path = project / ".specify" / "extensions" / ".registry" + registry = json.loads(registry_path.read_text(encoding="utf-8")) + git_metadata = registry["extensions"]["git"] + registered_commands = git_metadata["registered_commands"]["qodercli"] + for command_name in registered_commands: + skill_name = command_name.replace("speckit.", "speckit-", 1).replace( + ".", "-" + ) + old_command = commands / f"{command_name}.md" + old_command.write_bytes( + (skills / skill_name / "SKILL.md").read_bytes() + ) + missing_replacement = commands / "speckit.git.missing.md" + missing_replacement.write_text("# preserve until replaced\n", encoding="utf-8") + registered_commands.append("speckit.git.missing") + git_metadata["registered_skills"] = [] + registry_path.write_text(json.dumps(registry), encoding="utf-8") + + shutil.rmtree(skills) + result = _run_in_project(project, [ + "integration", "upgrade", "qodercli", "--script", "sh", "--force", + ]) + assert result.exit_code == 0, f"upgrade failed: {result.output}" + + for command_name in registered_commands[:-1]: + skill_name = command_name.replace("speckit.", "speckit-", 1).replace( + ".", "-" + ) + assert (skills / skill_name / "SKILL.md").is_file() + assert not (commands / f"{command_name}.md").exists() + assert missing_replacement.is_file(), ( + "a legacy command must remain when no replacement skill was written" + ) + def test_upgrade_kilocode_legacy_dir_rejects_installed_preset_overrides( self, tmp_path ): diff --git a/tests/integrations/test_integration_zed.py b/tests/integrations/test_integration_zed.py index 23627d316d..1a55c9ae87 100644 --- a/tests/integrations/test_integration_zed.py +++ b/tests/integrations/test_integration_zed.py @@ -143,6 +143,8 @@ def _render_invocation(project_path, ai: str, ai_skills: bool) -> str: ("devin", False, "/speckit-plan"), ("grok", True, "/speckit-plan"), ("grok", False, "/speckit-plan"), + ("qodercli", True, "/speckit-plan"), + ("qodercli", False, "/speckit-plan"), ("trae", True, "/speckit-plan"), ("trae", False, "/speckit-plan"), ("zed", True, "/speckit-plan"), From 1e28d416a677381ca396b0f86d7867485db84414 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Thu, 20 Aug 2026 11:54:35 -0500 Subject: [PATCH 016/102] =?UTF-8?q?Update=20MAQA=20=E2=80=94=20Multi-Agent?= =?UTF-8?q?=20&=20Quality=20Assurance=20extension=20to=20v0.1.6=20(#4234)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Update maqa extension submitted by @GenieRobot: - extensions/catalog.community.json (version, download_url, requires/tools, updated_at) - docs/community/extensions.md community extensions table (no changes needed) Closes #4233 Assisted-by: GitHub Copilot (model: claude-sonnet-4.6, autonomous) Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com> Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- extensions/catalog.community.json | 12 ++++++++---- 1 file changed, 8 insertions(+), 4 deletions(-) diff --git a/extensions/catalog.community.json b/extensions/catalog.community.json index b798dd9da8..3dc882cd78 100644 --- a/extensions/catalog.community.json +++ b/extensions/catalog.community.json @@ -2633,8 +2633,8 @@ "id": "maqa", "description": "Coordinator → feature → QA agent workflow with parallel worktree-based implementation. Language-agnostic. Auto-detects installed board plugins (Trello, Linear, GitHub Projects, Jira, Azure DevOps). Optional CI gate.", "author": "GenieRobot", - "version": "0.1.3", - "download_url": "https://github.com/GenieRobot/spec-kit-maqa-ext/releases/download/maqa-v0.1.3/maqa.zip", + "version": "0.1.6", + "download_url": "https://github.com/GenieRobot/spec-kit-maqa-ext/releases/download/maqa-v0.1.6/maqa.zip", "repository": "https://github.com/GenieRobot/spec-kit-maqa-ext", "homepage": "https://github.com/GenieRobot/spec-kit-maqa-ext", "documentation": "https://github.com/GenieRobot/spec-kit-maqa-ext/blob/main/README.md", @@ -2643,7 +2643,11 @@ "category": "process", "effect": "read-write", "requires": { - "speckit_version": ">=0.3.0" + "speckit_version": ">=0.3.0", + "tools": [ + { "name": "git", "required": true }, + { "name": "python3", "required": true } + ] }, "provides": { "commands": 4, @@ -2661,7 +2665,7 @@ "downloads": 0, "stars": 0, "created_at": "2026-03-26T00:00:00Z", - "updated_at": "2026-03-27T00:00:00Z" + "updated_at": "2026-08-20T00:00:00Z" }, "maqa-azure-devops": { "name": "MAQA Azure DevOps Integration", From 2f96c91f346722f1232cc7edcfb4a103f534abb9 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Thu, 20 Aug 2026 12:03:39 -0500 Subject: [PATCH 017/102] Update Intake Sequencing Governance preset to v0.2.3 (#4235) Update intake-sequencing-governance preset submitted by @hindermath to: - presets/catalog.community.json (version, download_url, documentation, templates count, tags, updated_at) - docs/community/presets.md community presets table Closes #4214 Assisted-by: GitHub Copilot (model: claude-sonnet-4.6, autonomous) Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com> Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- docs/community/presets.md | 2 +- presets/catalog.community.json | 12 ++++++------ 2 files changed, 7 insertions(+), 7 deletions(-) diff --git a/docs/community/presets.md b/docs/community/presets.md index d19eb35447..08b9da21e5 100644 --- a/docs/community/presets.md +++ b/docs/community/presets.md @@ -22,7 +22,7 @@ The following community-contributed presets customize how Spec Kit behaves — o | Game Narrative Writing | Preset for game narrative design and interactive storytelling. It adapts the Spec-Driven Development workflow for game narratives: features become story mechanics, specs become narrative briefs, plans become story maps, and tasks become dialogue and scene-writing tasks. Supports branching narratives, player agency systems, state machines, and interactive dialogue trees. | 37 templates, 34 commands, 5 scripts | — | [speckit-preset-game-narrative-writing](https://github.com/adaumann/speckit-preset-game-narrative-writing) | | Intake Authoring Governance | Governs traceable intake CRUD, language-aware requirements collections, bounded public HTTPS sources, and explicitly approved single or series authoring. | 13 templates, 5 commands, 7 scripts | — | [spec-kit-preset-intake-authoring-governance](https://github.com/hindermath/spec-kit-preset-intake-authoring-governance) | | Intake Review Governance | Reviews single, series, campaign, and language-aware requirements collections before Spec Kit execution. | 9 templates, 3 commands, 5 scripts | — | [spec-kit-preset-intake-review-governance](https://github.com/hindermath/spec-kit-preset-intake-review-governance) | -| Intake Sequencing Governance | Manages language-aware intake-series order, typed dependencies, lifecycle, and authority-neutral next-candidate selection. | 11 templates, 6 commands, 8 scripts | — | [spec-kit-preset-intake-sequencing-governance](https://github.com/hindermath/spec-kit-preset-intake-sequencing-governance) | +| Intake Sequencing Governance | Manages language-aware intake-series order, typed dependencies, lifecycle, and authority-neutral next-candidate selection. | 12 templates, 6 commands, 8 scripts | — | [spec-kit-preset-intake-sequencing-governance](https://github.com/hindermath/spec-kit-preset-intake-sequencing-governance) | | Inventory Alignment | Classifies each requirement against a read-only inventory of live IDs before writing, so reworded requirements are updated instead of duplicated. | 1 template, 2 commands | speckit-inventory extension | [spec-kit-inventory-alignment](https://github.com/Yash-Chindam/spec-kit-inventory-alignment) | | iSAQB Architecture Governance | Adds iSAQB/CPSA-F and arc42 architecture governance, architecture views, quality scenarios, ADRs, risks, technical-debt evidence, and provider-neutral model routing. | 14 templates, 3 commands | — | [spec-kit-preset-isaqb-architecture-governance](https://github.com/hindermath/spec-kit-preset-isaqb-architecture-governance) | | Jira Issue Tracking | Overrides `speckit.taskstoissues` to create Jira epics, stories, and tasks instead of GitHub Issues via Atlassian MCP tools | 1 command | — | [spec-kit-preset-jira](https://github.com/luno/spec-kit-preset-jira) | diff --git a/presets/catalog.community.json b/presets/catalog.community.json index 567dd354e1..baa342c76e 100644 --- a/presets/catalog.community.json +++ b/presets/catalog.community.json @@ -432,19 +432,19 @@ "intake-sequencing-governance": { "name": "Intake Sequencing Governance", "id": "intake-sequencing-governance", - "version": "0.2.2", + "version": "0.2.3", "description": "Manages language-aware intake-series order, typed dependencies, lifecycle, and authority-neutral next-candidate selection.", "author": "Thorsten Hindermann", "repository": "https://github.com/hindermath/spec-kit-preset-intake-sequencing-governance", - "download_url": "https://github.com/hindermath/spec-kit-preset-intake-sequencing-governance/archive/refs/tags/v0.2.2.zip", + "download_url": "https://github.com/hindermath/spec-kit-preset-intake-sequencing-governance/archive/refs/tags/v0.2.3.zip", "homepage": "https://github.com/hindermath/spec-kit-preset-intake-sequencing-governance", - "documentation": "https://github.com/hindermath/spec-kit-preset-intake-sequencing-governance/blob/v0.2.2/README.md", + "documentation": "https://github.com/hindermath/spec-kit-preset-intake-sequencing-governance/blob/v0.2.3/README.md", "license": "MIT", "requires": { "speckit_version": ">=0.8.3" }, "provides": { - "templates": 11, + "templates": 12, "commands": 6, "scripts": 8 }, @@ -453,10 +453,10 @@ "sequencing", "governance", "dag", - "lifecycle" + "model-routing" ], "created_at": "2026-07-27T00:00:00Z", - "updated_at": "2026-07-28T00:00:00Z" + "updated_at": "2026-08-20T00:00:00Z" }, "inventory-alignment": { "name": "Inventory Alignment", From 77528dc48bce031bc28ea45173b744581810e857 Mon Sep 17 00:00:00 2001 From: Noor ul ain Date: Thu, 20 Aug 2026 22:10:24 +0500 Subject: [PATCH 018/102] fix(bundler): decode a downloaded (non-zip) bundle manifest as UTF-8 (#4190) _download_remote_manifest's non-zip branch fed the downloaded bytes straight to `yaml.safe_load(io.BytesIO(raw))`. PyYAML's Reader auto-detects a UTF-16 BOM on a byte stream, so a well-formed UTF-16 bundle.yml (a realistic PowerShell `Out-File`/`>` output) was silently *accepted* here, while `yamlio.load_yaml` decodes local sources strictly as UTF-8 and rejects the identical content with "Could not read ...". BEFORE: a UTF-16 manifest downloaded via `bundle info`/`install` parses successfully -- exit code 0, no warning. AFTER: rejected with "... could not be read: ..." -- exit code 1, matching local directory and .zip sources. This is the same divergence, in the sibling branch of the same function, that was just fixed for the .zip case in commit 56aec8a (PR #3958): "feeding PyYAML the byte stream let its Reader honour a UTF-16 BOM and accept a manifest yamlio.load_yaml rejects, so zip and directory sources diverged." That fix covered `_local_manifest_source`'s `.zip` branch (which this same function calls for zip artifacts); the direct raw-YAML-download branch a few lines below it had the identical bug. Also drops the now-unused `import io` from this function. Co-authored-by: Claude Sonnet 5 --- src/specify_cli/commands/bundle/__init__.py | 16 +++++++++-- tests/contract/test_bundle_cli.py | 32 +++++++++++++++++++++ 2 files changed, 46 insertions(+), 2 deletions(-) diff --git a/src/specify_cli/commands/bundle/__init__.py b/src/specify_cli/commands/bundle/__init__.py index 1edbeef2ca..165f674a36 100644 --- a/src/specify_cli/commands/bundle/__init__.py +++ b/src/specify_cli/commands/bundle/__init__.py @@ -934,7 +934,6 @@ def _download_remote_manifest( expected_sha256: str | None = None, ): """Fetch a remote bundle artifact over HTTPS and extract its manifest.""" - import io import tempfile from pathlib import PurePosixPath from urllib.parse import urlparse as _urlparse @@ -1038,7 +1037,20 @@ def _validate_redirect(old_url: str, new_url: str) -> None: ) return manifest - data = _yaml.safe_load(io.BytesIO(raw)) + # Decode as UTF-8 explicitly -- matching yamlio.load_yaml's contract -- + # instead of feeding PyYAML the raw byte stream. PyYAML's Reader + # auto-detects a UTF-16 BOM and would silently *accept* a manifest + # that the local directory/bundle.yml sources reject, letting this + # remote-download path diverge from them (see the sibling .zip fix + # for _local_manifest_source, which had the identical bug). + try: + text = raw.decode("utf-8") + except UnicodeError as exc: + raise BundlerError( + f"Downloaded content for bundle '{entry_id}' from " + f"{_source_desc} could not be read: {exc}" + ) from exc + data = _yaml.safe_load(text) return BundleManifest.from_dict(data) except BundlerError: raise diff --git a/tests/contract/test_bundle_cli.py b/tests/contract/test_bundle_cli.py index c458a810ba..9d6024a277 100644 --- a/tests/contract/test_bundle_cli.py +++ b/tests/contract/test_bundle_cli.py @@ -786,6 +786,38 @@ def fake_open_url(url, timeout=None, extra_headers=None, redirect_validator=None assert asset_calls[0][1] == {"Accept": "application/octet-stream"} +def test_bundle_info_rejects_utf16_remote_manifest_like_local_sources(project: Path): + """A downloaded (non-zip) bundle.yml must be decoded strictly as UTF-8. + + ``yamlio.load_yaml`` decodes local ``bundle.yml`` sources strictly as + UTF-8, so a well-formed UTF-16 manifest (a realistic PowerShell + ``Out-File`` output) is rejected. Feeding the downloaded bytes straight + to ``yaml.safe_load(io.BytesIO(raw))`` let PyYAML's Reader honour the + UTF-16 BOM and silently *accept* the same manifest instead, diverging + from local/zip sources (the zip branch of this same download path was + already fixed for the identical bug). + """ + api_asset_url = "https://api.github.com/repos/org/repo/releases/assets/99" + manifest_yaml_utf16 = yaml.safe_dump(valid_manifest_dict()).encode("utf-16") + + def fake_open_url(url, timeout=None, extra_headers=None, redirect_validator=None): + return FakeBundleResponse(manifest_yaml_utf16, url=api_asset_url) + + catalog = project / "catalog.json" + write_catalog_file( + catalog, + {"demo-bundle": catalog_entry_dict("demo-bundle", download_url=api_asset_url)}, + ) + _make_catalog_config(catalog, project) + + with patch("specify_cli.authentication.http.open_url", side_effect=fake_open_url): + result = runner.invoke(app, ["bundle", "info", "demo-bundle", "--json"]) + + assert result.exit_code == 1 + output_flat = " ".join(result.output.split()) + assert "could not be read" in output_flat.lower() + + def test_bundle_info_passes_through_api_asset_url(project: Path): """bundle info passes a direct GitHub API asset URL through with octet-stream.""" api_asset_url = "https://api.github.com/repos/org/repo/releases/assets/77" From 58a7edaf5a87fa77b39fca2cbd4bc1039197f2b3 Mon Sep 17 00:00:00 2001 From: Noor ul ain Date: Thu, 20 Aug 2026 22:18:20 +0500 Subject: [PATCH 019/102] fix(presets): reject duplicate provides.templates name+type entries (#4191) PresetResolver._manifest_declared_template returns the FIRST 'provides.templates' entry matching a given (name, type) pair: for tmpl in manifest.templates: if tmpl.get("name") == template_name and tmpl.get("type") == template_type: ... return tmpl, ... So a preset.yml declaring two templates with the same (name, type) -- e.g. two "command"/"specify" entries pointing at different files -- had its second entry silently unreachable, while PresetManifest.templates still counted and exposed both. PresetManifest._validate never checked for this. Reject the duplicate at manifest-validation time instead, matching the sibling fix already applied to ExtensionManifest's provides.templates/ provides.scripts (commit 11e3176, PR #4016): "The resolver returns the first entry matching a declared name, so a later duplicate ... was silently unreachable while still counted". Presets use a (name, type) composite key rather than extensions' bare name, since the same name can legitimately recur across different template types (e.g. a "specify" template and a "specify" command); the fix only rejects a duplicate within the exact same (name, type) pair. Co-authored-by: Claude Sonnet 5 --- src/specify_cli/presets/__init__.py | 15 +++++++++++++ tests/test_presets.py | 35 +++++++++++++++++++++++++++++ 2 files changed, 50 insertions(+) diff --git a/src/specify_cli/presets/__init__.py b/src/specify_cli/presets/__init__.py index 3d37f6fb74..54dc5d2845 100644 --- a/src/specify_cli/presets/__init__.py +++ b/src/specify_cli/presets/__init__.py @@ -409,6 +409,7 @@ def _validate(self): raise PresetValidationError( "Preset must provide at least one template" ) + seen_name_types: set[tuple[str, str]] = set() for tmpl in templates: if not isinstance(tmpl, dict): raise PresetValidationError( @@ -438,6 +439,20 @@ def _validate(self): f"must be one of {sorted(VALID_PRESET_TEMPLATE_TYPES)}" ) + # PresetResolver._manifest_declared_template returns the first + # 'provides.templates' entry matching a given (name, type) pair, so + # a later duplicate would be silently unreachable while still being + # counted by PresetManifest.templates. Reject at validation time + # instead, mirroring the sibling fix for ExtensionManifest's + # provides.templates/scripts (#4016). + name_type = (tmpl["name"], tmpl["type"]) + if name_type in seen_name_types: + raise PresetValidationError( + f"Duplicate template name '{tmpl['name']}' of type " + f"'{tmpl['type']}' in 'provides.templates'" + ) + seen_name_types.add(name_type) + # Validate file path safety: must be relative, no parent traversal file_path = tmpl["file"] normalized = os.path.normpath(file_path) diff --git a/tests/test_presets.py b/tests/test_presets.py index 9775e0afa9..660a26d1b1 100644 --- a/tests/test_presets.py +++ b/tests/test_presets.py @@ -500,6 +500,41 @@ def test_multiple_templates(self, temp_dir, valid_pack_data): manifest = PresetManifest(manifest_path) assert len(manifest.templates) == 4 + def test_duplicate_template_name_and_type_raises_validation_error( + self, temp_dir, valid_pack_data + ): + """A later entry with the same (name, type) pair must be rejected. + + ``PresetResolver._manifest_declared_template`` returns the FIRST + 'provides.templates' entry matching a given (name, type) pair, so a + later duplicate would be silently unreachable while still being + counted by ``PresetManifest.templates`` -- mirroring the sibling bug + fixed for ``ExtensionManifest``'s provides.templates/scripts (#4016). + """ + valid_pack_data["provides"]["templates"] = [ + {"type": "command", "name": "specify", "file": "commands/specify-v1.md"}, + {"type": "command", "name": "specify", "file": "commands/specify-v2.md"}, + ] + manifest_path = temp_dir / "preset.yml" + with open(manifest_path, 'w') as f: + yaml.dump(valid_pack_data, f) + with pytest.raises(PresetValidationError, match="Duplicate template name"): + PresetManifest(manifest_path) + + def test_same_name_different_type_templates_allowed( + self, temp_dir, valid_pack_data + ): + """The same name may recur across different template types.""" + valid_pack_data["provides"]["templates"] = [ + {"type": "template", "name": "specify", "file": "templates/specify.md"}, + {"type": "command", "name": "specify", "file": "commands/specify.md"}, + ] + manifest_path = temp_dir / "preset.yml" + with open(manifest_path, 'w') as f: + yaml.dump(valid_pack_data, f) + manifest = PresetManifest(manifest_path) + assert len(manifest.templates) == 2 + # ===== PresetRegistry Tests ===== From 17e773595e1a8ee598eb814ee70455ac883e4e99 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Thu, 20 Aug 2026 12:20:57 -0500 Subject: [PATCH 020/102] [extension] Update Security Review extension to v2.0.0 (#4223) * Update Security Review extension to v2.0.0 Update security-review extension submitted by @DyanGalih: - extensions/catalog.community.json (version, download_url, repository, author, tags, tools, updated_at) - docs/community/extensions.md community extensions table Closes #4217 Assisted-by: GitHub Copilot (model: claude-sonnet-4.6, autonomous) Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * Preserve security review tool versions Carry the submitted minimum versions for the required git tool and optional Node.js CLI dependency into the community catalog entry. Assisted-by: GitHub Copilot (model: GPT-5.6 Sol, autonomous) Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: 312140f1-9c82-4e1e-a0ca-9a687ff71e27 --------- Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com> Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Co-authored-by: Manfred Riem <15701806+mnriem@users.noreply.github.com> Copilot-Session: 312140f1-9c82-4e1e-a0ca-9a687ff71e27 --- docs/community/extensions.md | 2 +- extensions/catalog.community.json | 28 ++++++++++++++++------------ 2 files changed, 17 insertions(+), 13 deletions(-) diff --git a/docs/community/extensions.md b/docs/community/extensions.md index 29c2363125..f9c26e825b 100644 --- a/docs/community/extensions.md +++ b/docs/community/extensions.md @@ -129,7 +129,7 @@ The following community-contributed extensions are available in [`catalog.commun | Review Extension | Post-implementation comprehensive code review with specialized agents for code quality, comments, tests, error handling, type design, and simplification | `code` | Read-only | [spec-kit-review](https://github.com/ismaelJimenez/spec-kit-review) | | Ripple | Detect side effects that tests can't catch after implementation — surface hidden ripple effects across 9 analysis categories | `code` | Read+Write | [spec-kit-ripple](https://github.com/chordpli/spec-kit-ripple) | | SDD Utilities | Resume interrupted workflows, validate project health, and verify spec-to-task traceability | `process` | Read+Write | [speckit-utils](https://github.com/mvanhorn/speckit-utils) | -| Security Review | Full-project secure-by-design security audits plus staged, branch/PR, plan, task, follow-up, and apply reviews | `code` | Read+Write | [spec-kit-security-review](https://github.com/DyanGalih/spec-kit-security-review) | +| Security Review | Full-project secure-by-design security audits plus staged, branch/PR, plan, task, follow-up, and apply reviews | `code` | Read+Write | [security-review](https://github.com/DyanGalih/security-review) | | SFSpeckit | Enterprise Salesforce SDLC with 18 commands for the full SDD lifecycle. | `process` | Read+Write | [spec-kit-sf](https://github.com/ysumanth06/spec-kit-sf) | | Ship Release Extension | Automates release pipeline: pre-flight checks, branch sync, changelog generation, CI verification, and PR creation | `process` | Read+Write | [spec-kit-ship](https://github.com/arunt14/spec-kit-ship) | | Spec Changelog | Auto-generate changelogs and release notes from spec git history and requirement diffs | `docs` | Read-only | [spec-kit-changelog](https://github.com/Quratulain-bilal/spec-kit-changelog) | diff --git a/extensions/catalog.community.json b/extensions/catalog.community.json index 3dc882cd78..31ccc5e5cf 100644 --- a/extensions/catalog.community.json +++ b/extensions/catalog.community.json @@ -4256,35 +4256,39 @@ "name": "Security Review", "id": "security-review", "description": "Full-project secure-by-design security audits plus staged, branch/PR, plan, task, follow-up, and apply reviews", - "author": "Spec-Kit Security Team", - "version": "1.5.3", - "download_url": "https://github.com/DyanGalih/spec-kit-security-review/archive/refs/tags/v1.5.3.zip", - "repository": "https://github.com/DyanGalih/spec-kit-security-review", - "homepage": "https://github.com/DyanGalih/spec-kit-security-review", - "documentation": "https://github.com/DyanGalih/spec-kit-security-review/blob/main/README.md", - "changelog": "https://github.com/DyanGalih/spec-kit-security-review/blob/main/CHANGELOG.md", + "author": "DyanGalih", + "version": "2.0.0", + "download_url": "https://github.com/DyanGalih/security-review/archive/refs/tags/v2.0.0.zip", + "repository": "https://github.com/DyanGalih/security-review", + "homepage": "https://github.com/DyanGalih/security-review", + "documentation": "https://github.com/DyanGalih/security-review/blob/main/docs/usage.md", + "changelog": "https://github.com/DyanGalih/security-review/blob/main/CHANGELOG.md", "license": "MIT", "category": "code", "effect": "read-write", "requires": { - "speckit_version": ">=0.1.0" + "speckit_version": ">=0.1.0", + "tools": [ + { "name": "git", "version": ">=2.0.0", "required": true }, + { "name": "node", "version": ">=22.0.0", "required": false } + ] }, "provides": { - "commands": 9, + "commands": 10, "hooks": 3 }, "tags": [ "security", - "devsecops", "audit", "owasp", - "compliance" + "compliance", + "governance" ], "verified": false, "downloads": 0, "stars": 0, "created_at": "2026-04-03T03:24:03Z", - "updated_at": "2026-06-08T00:00:00Z" + "updated_at": "2026-08-20T00:00:00Z" }, "sf": { "name": "SFSpeckit — Salesforce Spec-Driven Development", From 5cf60225e989ee9c7d9ac789352838676a00181b Mon Sep 17 00:00:00 2001 From: Manfred Riem <15701806+mnriem@users.noreply.github.com> Date: Fri, 21 Aug 2026 06:42:04 -0500 Subject: [PATCH 021/102] chore: release 1.0.0, begin 1.0.1.dev0 development (#4246) * chore: bump version to 1.0.0 * chore: begin 1.0.1.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 0ef915b936..3ec23d5f33 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,31 @@ +## [1.0.0] - 2026-08-21 + +### Changed + +- [extension] Update Security Review extension to v2.0.0 (#4223) +- fix(presets): reject duplicate provides.templates name+type entries (#4191) +- fix(bundler): decode a downloaded (non-zip) bundle manifest as UTF-8 (#4190) +- Update Intake Sequencing Governance preset to v0.2.3 (#4235) +- Update MAQA — Multi-Agent & Quality Assurance extension to v0.1.6 (#4234) +- [bug-fix] Fix qodercli-skills-migration: migrate QodercliIntegration to SkillsIntegration (#4205) +- [preset] Add Inventory Alignment preset to community catalog (#4229) +- [extension] Add Spec Inventory extension to community catalog (#4228) +- [extension] Update Architecture Guard extension to v2.3.6 (#4224) +- Update SpecKit Companion extension to v0.20.2 (#4225) +- fix(workflows): reject a condition that has no {{ }} block (#4182) +- fix: raise feature assessment credit budget (#4222) +- [extension] Add AgentDocx extension to community catalog (#4184) +- fix(integrations): report a falsy non-mapping integration descriptor as a shape error (#4187) +- Update Autonomous Run Governance preset to v0.4.1 (#4203) +- fix(workflows): validate dispatch defaults (#4181) +- Update Atlas extension display name in community catalog (#4202) +- Add Closed Vocabulary Check preset to community catalog (#4201) +- fix(utils): narrow bare except Exception in merge_json_files (#4189) +- chore: release 0.16.5, begin 0.16.6.dev0 development (#4206) + ## [0.16.5] - 2026-08-19 ### Changed diff --git a/pyproject.toml b/pyproject.toml index 5563328245..c6bb6a93de 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "specify-cli" -version = "0.16.6.dev0" +version = "1.0.1.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 28545894a0e8315b57a67f418acd6a9816855c46 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Fri, 21 Aug 2026 07:25:03 -0500 Subject: [PATCH 022/102] chore(deps): bump the codeql-action group with 2 updates (#4241) 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.6 to 4.37.7 - [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/5595ccaf912efad79be6eef63a5619ff05969be3...ff2f1c621b7f889edc0d3c761ac2e6a3f8cdb0dd) Updates `github/codeql-action/analyze` from 4.37.6 to 4.37.7 - [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/5595ccaf912efad79be6eef63a5619ff05969be3...ff2f1c621b7f889edc0d3c761ac2e6a3f8cdb0dd) --- updated-dependencies: - dependency-name: github/codeql-action/init dependency-version: 4.37.7 dependency-type: direct:production update-type: version-update:semver-patch dependency-group: codeql-action - dependency-name: github/codeql-action/analyze dependency-version: 4.37.7 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 abd808926c..8940117042 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@5595ccaf912efad79be6eef63a5619ff05969be3 # v4 + uses: github/codeql-action/init@ff2f1c621b7f889edc0d3c761ac2e6a3f8cdb0dd # v4 with: languages: ${{ matrix.language }} - name: Perform CodeQL Analysis - uses: github/codeql-action/analyze@5595ccaf912efad79be6eef63a5619ff05969be3 # v4 + uses: github/codeql-action/analyze@ff2f1c621b7f889edc0d3c761ac2e6a3f8cdb0dd # v4 with: category: "/language:${{ matrix.language }}" From 5df8c4c6ef51283e6d091331544aca57d0f534d3 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Fri, 21 Aug 2026 07:29:31 -0500 Subject: [PATCH 023/102] chore(deps): bump actions/setup-node from 6.4.0 to 7.0.0 (#4242) Bumps [actions/setup-node](https://github.com/actions/setup-node) from 6.4.0 to 7.0.0. - [Release notes](https://github.com/actions/setup-node/releases) - [Commits](https://github.com/actions/setup-node/compare/v6.4.0...820762786026740c76f36085b0efc47a31fe5020) --- updated-dependencies: - dependency-name: actions/setup-node dependency-version: 7.0.0 dependency-type: direct:production update-type: version-update:semver-major ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- .github/workflows/feature-assess.lock.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/feature-assess.lock.yml b/.github/workflows/feature-assess.lock.yml index 5b3adc7c7d..605bb5579c 100644 --- a/.github/workflows/feature-assess.lock.yml +++ b/.github/workflows/feature-assess.lock.yml @@ -35,7 +35,7 @@ # - actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 # - actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 # - actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0 -# - actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0 +# - actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 # - actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # 5fda3b95a4ea91299a34e894583c3862153e4b97 # - actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 # - astral-sh/setup-uv@c771a70e6277c0a99b617c7a806ffedaca235ff9 # c771a70e6277c0a99b617c7a806ffedaca235ff9 @@ -1400,7 +1400,7 @@ jobs: mkdir -p /tmp/gh-aw/threat-detection touch /tmp/gh-aw/threat-detection/detection.log - name: Setup Node.js - uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0 + uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 with: node-version: '24' package-manager-cache: false From 47ca8e148d03f3b24e5012027410a660efcbd3b5 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Fri, 21 Aug 2026 08:06:08 -0500 Subject: [PATCH 024/102] chore(deps): bump actions/checkout from 6.0.3 to 7.0.1 (#4243) Bumps [actions/checkout](https://github.com/actions/checkout) from 6.0.3 to 7.0.1. - [Release notes](https://github.com/actions/checkout/releases) - [Changelog](https://github.com/actions/checkout/blob/main/CHANGELOG.md) - [Commits](https://github.com/actions/checkout/compare/v6.0.3...3d3c42e5aac5ba805825da76410c181273ba90b1) --- updated-dependencies: - dependency-name: actions/checkout dependency-version: 7.0.1 dependency-type: direct:production update-type: version-update:semver-major ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- .github/workflows/feature-assess.lock.yml | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/.github/workflows/feature-assess.lock.yml b/.github/workflows/feature-assess.lock.yml index 605bb5579c..198b50f107 100644 --- a/.github/workflows/feature-assess.lock.yml +++ b/.github/workflows/feature-assess.lock.yml @@ -32,7 +32,7 @@ # - GITHUB_TOKEN # # Custom actions used: -# - actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 +# - actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 # - actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 # - actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0 # - actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 @@ -163,7 +163,7 @@ jobs: env: COPILOT_GITHUB_TOKEN: ${{ secrets.COPILOT_GITHUB_TOKEN }} - name: Checkout .github and .agents folders - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: persist-credentials: false sparse-checkout: | @@ -432,7 +432,7 @@ jobs: echo "GH_AW_SAFE_OUTPUTS_TOOLS_PATH=${RUNNER_TEMP}/gh-aw/safeoutputs/tools.json" } >> "$GITHUB_OUTPUT" - name: Checkout repository - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: persist-credentials: false fetch-depth: 0 @@ -1333,7 +1333,7 @@ jobs: echo "GH_AW_AGENT_OUTPUT=/tmp/gh-aw/agent_output.json" >> "$GITHUB_OUTPUT" - name: Checkout repository for patch context if: needs.agent.outputs.has_patch == 'true' - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: persist-credentials: false # --- Threat Detection --- From 95efce42c1161ac1de0ea0d62217c0790d8f0eaa Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Fri, 21 Aug 2026 08:23:33 -0500 Subject: [PATCH 025/102] Add Azure Cosmos DB extension to community catalog (#4247) Add cosmosdb extension submitted by @TheovanKraay to: - extensions/catalog.community.json (alphabetical order) - docs/community/extensions.md community extensions table Closes #4238 Assisted-by: GitHub Copilot (model: claude-sonnet-4.6, autonomous) Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com> Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- 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 f9c26e825b..4353c80f3e 100644 --- a/docs/community/extensions.md +++ b/docs/community/extensions.md @@ -40,6 +40,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) | +| 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) | | Blueprint | Stay code-literate in AI-driven development: review a complete code blueprint for every task from spec artifacts before /speckit.implement runs | `docs` | Read+Write | [spec-kit-blueprint](https://github.com/chordpli/spec-kit-blueprint) | | 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) | diff --git a/extensions/catalog.community.json b/extensions/catalog.community.json index 31ccc5e5cf..237150b1ac 100644 --- a/extensions/catalog.community.json +++ b/extensions/catalog.community.json @@ -1,6 +1,6 @@ { "schema_version": "1.0", - "updated_at": "2026-08-20T00:00:00Z", + "updated_at": "2026-08-21T00:00:00Z", "catalog_url": "https://raw.githubusercontent.com/github/spec-kit/main/extensions/catalog.community.json", "extensions": { "adrkit": { @@ -1327,6 +1327,40 @@ "created_at": "2026-07-13T00:00:00Z", "updated_at": "2026-07-13T00:00:00Z" }, + "cosmosdb": { + "name": "Azure Cosmos DB", + "id": "cosmosdb", + "description": "Best-practice Azure Cosmos DB code generation and review for any AI coding agent", + "author": "Theo van Kraay (maintained on behalf of the Azure Cosmos DB team; hosted in the AzureCosmosDB org)", + "version": "0.1.0", + "download_url": "https://github.com/AzureCosmosDB/spec-kit-cosmosdb/archive/refs/tags/v0.1.0.zip", + "repository": "https://github.com/AzureCosmosDB/spec-kit-cosmosdb", + "homepage": "https://github.com/AzureCosmosDB/spec-kit-cosmosdb", + "documentation": "https://github.com/AzureCosmosDB/spec-kit-cosmosdb/blob/main/README.md", + "changelog": "https://github.com/AzureCosmosDB/spec-kit-cosmosdb/blob/main/CHANGELOG.md", + "license": "MIT", + "category": "code", + "effect": "read-write", + "requires": { + "speckit_version": ">=0.1.0" + }, + "provides": { + "commands": 53, + "hooks": 2 + }, + "tags": [ + "azure", + "cosmosdb", + "database", + "nosql", + "recommend-coding" + ], + "verified": false, + "downloads": 0, + "stars": 0, + "created_at": "2026-08-21T00:00:00Z", + "updated_at": "2026-08-21T00:00:00Z" + }, "cost": { "name": "Cost Tracker", "id": "cost", From 8c31da95beed39dadd13f6ba386372e4675e543d Mon Sep 17 00:00:00 2001 From: Manfred Riem <15701806+mnriem@users.noreply.github.com> Date: Fri, 21 Aug 2026 10:38:57 -0500 Subject: [PATCH 026/102] docs: update landing page stats for 1.0.0 (#4251) Assisted-by: GitHub Copilot (model: GPT-5.6 Sol, autonomous) Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: edc3d861-f065-4747-8ed4-30e3e9f0ea99 --- docs/index.md | 20 ++++++++++---------- 1 file changed, 10 insertions(+), 10 deletions(-) diff --git a/docs/index.md b/docs/index.md index 61cd50dd47..93857ba0e8 100644 --- a/docs/index.md +++ b/docs/index.md @@ -31,7 +31,7 @@ Define what to build before building it. Rich templates, quality checklists, and ### Use any coding agent -35 integrations — Copilot, Gemini, Codex, Kilo Code, Zed, Claude, Forge, Kiro, and more. Switch freely between agents with a single command. No lock-in. +38 integrations — Copilot, Gemini, Codex, Kilo Code, Zed, Claude, Forge, Kiro, and more. Switch freely between agents with a single command. No lock-in. Run `specify init` with your agent of choice and Spec Kit sets up the right command files and directory structures automatically. If your agent isn't listed, the `generic` integration is an escape hatch for any tool. @@ -43,7 +43,7 @@ Run `specify init` with your agent of choice and Spec Kit sets up the right comm ### Make it your own -138 community extensions (70+ authors), 25 presets, and growing. Tune the core process with presets, extend it with extensions, orchestrate it with workflows, and package it all up as bundles you can share — or replace the process entirely. The process itself lives in these building blocks, so you're never locked to SDD, or even to software. +157 community extensions (90+ authors), 33 presets, and growing. Tune the core process with presets, extend it with extensions, orchestrate it with workflows, and package it all up as bundles you can share — or replace the process entirely. The process itself lives in these building blocks, so you're never locked to SDD, or even to software. Including entirely different processes: @@ -82,31 +82,31 @@ Community extensions like CI Guard and Architecture Guard add compliance gates a ## Built by the community -**240+ contributors** power the Spec Kit ecosystem — from core integrations to entirely new processes. Anyone can create and publish an extension, preset, or workflow. +**270+ contributors** power the Spec Kit ecosystem — from core integrations to entirely new processes. Anyone can create and publish an extension, preset, or workflow.
- 121K+ + 130K+ GitHub stars
- 240+ + 270+ Contributors
- 35 + 38 Integrations
- 138 + 157 Extensions
- 25 + 33 Presets
- 6 + 7 Friends projects
@@ -155,4 +155,4 @@ Ready to start? Follow the [Quick Start Guide](quickstart.md). -

Last updated: July 16, 2026

+

Last updated: August 21, 2026

From d3f921270176cb51ba30b2629c9c42033098f105 Mon Sep 17 00:00:00 2001 From: Quratulain-bilal Date: Fri, 21 Aug 2026 20:52:10 +0500 Subject: [PATCH 027/102] fix: use chunked read for integration and preset manifest hash (#3843) * fix: use chunked read for integration and preset manifest hash Replace unbounded fh.read() with chunked iteration to prevent excessive memory allocation on large or corrupted manifest files. Applies to both integrations/catalog.py and presets/__init__.py get_hash() methods. * test: verify full hash value in get_hash() tests to cover chunked path The existing tests only checked the sha256: prefix, which would pass even if the chunked hash was broken. Now verify the complete hash matches hashlib.sha256(content).hexdigest() to exercise the multi-chunk path introduced by the chunked read change. --- src/specify_cli/integrations/catalog.py | 5 ++++- src/specify_cli/presets/__init__.py | 5 ++++- tests/integrations/test_integration_catalog.py | 4 ++++ tests/test_presets.py | 5 ++++- 4 files changed, 16 insertions(+), 3 deletions(-) diff --git a/src/specify_cli/integrations/catalog.py b/src/specify_cli/integrations/catalog.py index e93dab5185..b8d76cb9c6 100644 --- a/src/specify_cli/integrations/catalog.py +++ b/src/specify_cli/integrations/catalog.py @@ -871,5 +871,8 @@ def tools(self) -> List[Dict[str, Any]]: def get_hash(self) -> str: """SHA-256 hash of the descriptor file.""" + h = hashlib.sha256() with open(self.path, "rb") as fh: - return f"sha256:{hashlib.sha256(fh.read()).hexdigest()}" + for chunk in iter(lambda: fh.read(8192), b""): + h.update(chunk) + return f"sha256:{h.hexdigest()}" diff --git a/src/specify_cli/presets/__init__.py b/src/specify_cli/presets/__init__.py index 54dc5d2845..a5cea4f958 100644 --- a/src/specify_cli/presets/__init__.py +++ b/src/specify_cli/presets/__init__.py @@ -541,8 +541,11 @@ def tags(self) -> List[str]: def get_hash(self) -> str: """Calculate SHA256 hash of manifest file.""" + h = hashlib.sha256() with open(self.path, 'rb') as f: - return f"sha256:{hashlib.sha256(f.read()).hexdigest()}" + for chunk in iter(lambda: f.read(8192), b""): + h.update(chunk) + return f"sha256:{h.hexdigest()}" class PresetRegistry: diff --git a/tests/integrations/test_integration_catalog.py b/tests/integrations/test_integration_catalog.py index 87ab98a4d0..c414c3d8ea 100644 --- a/tests/integrations/test_integration_catalog.py +++ b/tests/integrations/test_integration_catalog.py @@ -745,6 +745,10 @@ def test_get_hash(self, tmp_path): desc = IntegrationDescriptor(p) h = desc.get_hash() assert h.startswith("sha256:") + import hashlib + content = p.read_bytes() + expected = f"sha256:{hashlib.sha256(content).hexdigest()}" + assert h == expected def test_tools_accessor(self, tmp_path): data = {**VALID_DESCRIPTOR, "requires": { diff --git a/tests/test_presets.py b/tests/test_presets.py index 660a26d1b1..47201d8f6e 100644 --- a/tests/test_presets.py +++ b/tests/test_presets.py @@ -484,7 +484,10 @@ def test_get_hash(self, pack_dir): manifest = PresetManifest(pack_dir / "preset.yml") hash_val = manifest.get_hash() assert hash_val.startswith("sha256:") - assert len(hash_val) > 10 + import hashlib + content = (pack_dir / "preset.yml").read_bytes() + expected = f"sha256:{hashlib.sha256(content).hexdigest()}" + assert hash_val == expected def test_multiple_templates(self, temp_dir, valid_pack_data): """Test pack with multiple templates of different types.""" From 2dddaa54f4dff4e6b6d07da3357efb51fdda6364 Mon Sep 17 00:00:00 2001 From: Nguyen Thanh Dat Date: Fri, 21 Aug 2026 23:21:15 +0700 Subject: [PATCH 028/102] fix(workflows): stop offering a condition correction that inverts it (#4230) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix(workflows): stop offering a correction that would not repair the condition `format_condition_correction` wraps whatever it is handed — correct for a formatter, wrong to advertise as paste-ready for two inputs it cannot repair. Both reach the never-evaluated branch, and both were being suggested: condition: " " -> "{{ }}" {{ inputs.name == 'abc -> "{{ inputs.name == 'abc }}" Measured what pasting each one does, rather than assuming: " " is True -> "{{ }}" is False "{{ inputs.name == 'abc" is True -> "{{ inputs.name == 'abc }}" is False The blank core interpolates to the empty string. The open quote survives wrapping, so the raw-close fallback evaluates a truncated comparison whose result is the string "False", which `evaluate_condition` then reads as the `false` keyword. In both cases the advertised correction silently inverts the condition — a different defect, not a fix. Add `format_condition_remediation`, which the three step validators now call in place of hand-building the sentence. It offers the correction only when wrapping would actually repair the input, and otherwise names the fault, matching the call already made for `condition_has_malformed_expression_block`. `_has_unbalanced_quote` uses the same left-to-right scan as `_find_block_close` and `_strip_stray_delimiters`, so "inside a string" means the same thing everywhere in this module. I had the second case wrong at first and said the wrapped form "stays always true" — the new test caught it, and the message and docstring now say inverted. Verified on Python 3.11: - tests/unit/test_condition_expression_block.py 133 passed (was 116) - tests/unit + tests/test_workflows.py 1216 passed (was 1199), 22 failed before and after — the pre-existing symlink tests needing Windows elevation. Mutation-checked: removing either gate fails exactly the 9 new parametrised cases and nothing else. * fix(workflows): withhold the correction whenever wrapping cannot repair the core Copilot found two more holes in the previous commit, and both were real. 1. The quote-balance gate was not sufficient. `inputs.name ==` has a non-empty, quote-balanced core, so a correction was still advertised: inputs.name == -> "{{ inputs.name == }}" True -> False The missing operand resolves to None, the comparison evaluates False, and the author again trades an always-true condition for an always-false one. 2. The message named the wrong mechanism. It said the wrapped form goes through the raw-close fallback. Measured: `_is_single_expression("{{ inputs.name == 'abc }}")` is True, so it takes the typed fast path instead. Stop enumerating broken shapes. `_wrapping_would_not_repair` now reports the first reason wrapping cannot yield the intended expression — empty core, unclosed quote, unbalanced bracket, or an operator missing an operand — and the advice names it instead of offering a suggestion. `_has_incomplete_operand` reads `_COMPARISON_OPERATORS`, extracted from `_evaluate_simple_expression`, so the check cannot drift from what the evaluator actually splits on. The messages now describe the text itself rather than the interpolator path it will take: asserting an internal route is what made the previous two versions wrong. Tests state the property rather than listing shapes: `test_every_offered_correction_is_a_complete_expression` asserts that anything advertised as paste-ready survives both validators, so a new malformed shape is caught by the invariant rather than by another fixture row. Verified on Python 3.11: - tests/unit/test_condition_expression_block.py 182 passed (was 133) - tests/unit + tests/test_workflows.py 1282 passed (was 1233), 22 failed before and after — pre-existing symlink tests needing Windows elevation. Mutation-checked, each gate against its own cases: dropping the operand gate fails 12, the bracket gate 3, and removing an operator from `_COMPARISON_OPERATORS` fails 1. That last one passed vacuously at first because the test parametrised over the constant it was checking — the same can't-fail shape this module rejects — so it is hard-coded now. * fix(workflows): check every operator position and match bracket types Copilot found two more, and both were right. 1. `_has_incomplete_operand` inspected only the first occurrence of each operator, and its end-of-string check covered only trailing boolean keywords: inputs.a == inputs.b == -> correction still offered, True -> False and inputs.ready -> correction still offered, True -> False That is the same defect this PR's parent commit fixed one level up — stopping at the first match — reintroduced in the gate meant to prevent it. It now splits on every top-level occurrence and requires every operand to be non-empty. A stripped core also loses the space that delimits a word operator, so `inputs.a not in` matched nothing. `_WORD_OPERATORS` is derived from `_COMPARISON_OPERATORS` and matched against both ends without it. 2. `_has_unbalanced_bracket` counted depth, so mismatched types cancelled: inputs.f(] -> correction still offered, True -> False It tracks opener types on a stack and rejects a non-matching closer. The docstring Copilot flagged at line 950 is unchanged on purpose: it does not attribute the inversion to the raw-close fallback, it records that two earlier versions did and were wrong because `_is_single_expression` accepts the wrapped form. That thread is marked outdated and refers to the text before `6944920`. Verified on Python 3.11: - tests/unit/test_condition_expression_block.py 207 passed (was 182) - tests/unit + tests/test_workflows.py 1307 passed (was 1282), 22 failed before and after — pre-existing symlink tests needing Windows elevation. Mutation-checked: depth-only brackets fails 9, first-occurrence-only fails 3, dropping the end-of-core word scan fails 16. * fix(workflows): reject an unregistered filter and prose before suggesting a wrap Copilot's remaining point was the strongest one on this PR: `reason is None` only excluded four structural shapes, and structural shapes cannot establish that wrapping produces a working expression. Two inputs proved it: inputs.items | length -> offered; wrapped form raises ValueError("unknown filter 'length'") he said "hi"\nthen left -> offered; wrapped form resolves to None, True -> False The first replaces an always-true condition with a crash, the second inverts it. Two checks close the gap, both reading the evaluator rather than guessing: - `_unregistered_filter` walks the top-level `|` segments and reports the first name missing from `_REGISTERED_FILTERS`, the same tuple `_apply_filter` raises on. - `_reads_as_prose` reports a core that is several bare terms with no operator and no filter joining them. Quoted spans and bracketed groups are skipped, so `inputs.f('a b')` and `inputs.name == 'two words'` are unaffected, and a `not ` prefix is allowed. `he said "hi"\nthen left` was in `OFFERED_CORRECTION_INPUTS` only because that fixture was built as `TRICKY_CONDITIONS + [...]`. TRICKY_CONDITIONS exists to exercise the formatter's quoting and deliberately contains prose, so reusing it asserted the wrong thing. The list is explicit now, and the tricky-quoting entries that really are expressions are carried over by hand — adding prose to that fixture can no longer widen what this invariant claims. `inputs.tags | length > 0` was also mine, and `length` is not a registered filter; it is `join(',')` now. Verified on Python 3.11: - tests/unit/test_condition_expression_block.py 212 passed (was 207) - tests/unit + tests/test_workflows.py 1312 passed (was 1307), 22 failed before and after — pre-existing symlink tests needing Windows elevation. Mutation-checked: dropping either new gate fails 3 cases and nothing else. * fix(workflows): ask the evaluator whether the core parses, instead of guessing Copilot found two more shapes the structural gates did not know about: inputs.tags | join -> offered; `join` is registered, but with no argument `_apply_filter` raises ValueError inputs.count+1 -> offered; the evaluator has no arithmetic, reads it as a key named "count+1", and the wrapped form resolves to None, turning a truthy condition false That is the fifth shape in four rounds, which is the argument against enumerating shapes at all. Replace the two structural checks with two that read the evaluator: - `_evaluator_rejects` runs the core through `_evaluate_simple_expression` against a probe namespace and returns its own error. Any filter under an unknown name or in an unsupported form is now reported by the code that will actually run, so `_unregistered_filter` — which restated the filter table — is gone. - `_is_not_a_bare_path` covers what a probe cannot: a single-term core is resolved as a path lookup, so every dotted segment must be an identifier. `count+1` is not, and neither is prose, so `_reads_as_prose` is gone too. The probe namespace resolves roots but not leaves, deliberately. A namespace that answers every lookup also answers `inputs.count+1`, hiding the shape the probe exists to expose. Net effect is two helpers fewer and no restatement of the evaluator's tables. Verified on Python 3.11: - tests/unit/test_condition_expression_block.py 230 passed (was 212) - tests/unit + tests/test_workflows.py 1330 passed (was 1312), 22 failed before and after — pre-existing symlink tests needing Windows elevation. Mutation-checked: dropping either check fails 6 cases and nothing else. * fix(workflows): stop the probe rejecting valid expressions, and match the path grammar Copilot found a false positive in the probe, which is worse than the false negatives the earlier rounds fixed: it withheld a correction from a condition that was already correct. steps.emit.output.stdout | from_json -> refused inputs.tags | join(inputs.separator) -> refused Both are valid; the first is exercised in tests/test_workflows.py. The probe hands `from_json` a dict and it raises, so treating every probe error as a rejection blamed the author for the placeholder's type. `_evaluator_rejects` now reports only the two failures `_apply_filter` raises about the expression itself -- an unknown filter name, and a registered filter used in an unsupported form. Everything else a probe run raises is about probe values. `_TERM_SUFFIX` also accepted any bracket contents and repeated indexes, while `_resolve_dot_path` matches `^([\w-]+)\[(\d+)\]$` -- one numeric index. So `inputs.tags[foo]` and `inputs.matrix[0][1]` passed as paths, resolved to None, and were offered a correction that turns a truthy condition false. `_PATH_SEGMENT` is that grammar now. It also replaces `str.isidentifier`, which was wrong in the other direction: the resolver allows a hyphen and a leading digit in a key name. Lint: this branch had added 3 ruff errors (2x SIM102, PIE810/UP037 on new code) and left a top-level class without its blank lines. `ruff check` on this file is back to the 5 pre-existing errors on `main`, all in code this PR does not touch. On the E305 comment specifically: `ruff rule E305` reports "Selection `E305` has no effect because preview is not enabled", and `ruff check --select E305` on this file passes, so the repository's CI does not report it. The blank lines were still wrong and are fixed. Verified on Python 3.11: - tests/unit/test_condition_expression_block.py 236 passed (was 230) - tests/unit + tests/test_workflows.py 1336 passed (was 1330), 22 failed before and after -- pre-existing symlink tests needing Windows elevation. Mutation-checked: treating every probe error as a rejection fails 2, loosening the path grammar fails 2. * fix(workflows): validate operands recursively, and keep probe-value errors out Copilot found three more, and the first explains why this took so many rounds: every gate so far only inspected the shape it was written for. inputs.a === inputs.b -> offered; splits cleanly on `==`, and the evaluator reads `= inputs.b` as a path, resolving to None bogus == 'x' -> offered; unknown root, same result inputs.payload | from_json() -> offered; raises at run time `_unresolvable_term` replaces `_is_not_a_bare_path` and walks operands the way `_evaluate_simple_expression` does -- filters, `or`/`and`/`not`, comparisons -- down to the leaves. A leaf must be a literal or a dotted path rooted in `_NAMESPACE_ROOTS`, the roots `_build_namespace` actually supplies. Both shapes above fall out of that without either being named. `_evaluator_rejects` now keeps only the errors `_apply_filter` raises about the filter *expression*. Those quote the segment back as `got '| ...'`; its value errors name the type they received, which under a probe is the placeholder. The previous prefix list missed `from_json()` (a wiring error) and, when widened by filter name, wrongly rejected `steps.emit.output.stdout | from_json` (a value error) -- the regression the round before had just fixed. One case fell out that no review raised: `_find_top_level` matches " and " with literal spaces, so a newline before the keyword is not an operator. `inputs.x == 1\nand inputs.name == 'abc'` evaluates False wrapped, where the same expression with a space evaluates True. It was in the offered fixture; it is a refusal case now. Verified on Python 3.11: - tests/unit/test_condition_expression_block.py 253 passed (was 236) - tests/unit + tests/test_workflows.py 1353 passed (was 1336), 22 failed before and after -- pre-existing symlink tests needing Windows elevation. - ruff check on this file is back to the 5 errors already on main. Mutation-checked: dropping the recursion fails 15, dropping the namespace-root check fails 5, treating every probe error as a rejection fails 2. * fix(workflows): mirror the evaluator's literal and root tests exactly Three more from Copilot, all cases where my check approximated the evaluator instead of matching it: 1e3 -> offered; no "." so the evaluator calls int(), which fails, and it falls through to a path lookup. float() alone accepted it. 'a' 'b' -> offered; the evaluator requires the opening quote's match to be the final character, which first/last-character equality is not. inputs[0] -> offered; `_build_namespace` hands back mappings, so an indexed root resolves to None however the index is written. All three are truthy before wrapping and False after, which is the inversion this change exists to prevent. `_looks_numeric` and `_is_literal` now use the evaluator's own tests rather than a looser stand-in, and the root segment is matched without stripping an index off it first. Not fixed, and worth being explicit about: `inputs.tags | join(5)` is still offered. `join` always raises for a non-string separator, but that is a *type* rule, and `_evaluator_rejects` deliberately ignores value errors because under a probe they usually describe the placeholder rather than the author's text. The two cannot be told apart from the message alone -- `join: expected a string separator, got int` and `join: ..., got NoneType` differ only in a type name the probe may have supplied. Catching it means encoding each filter's argument types in the validator, which is the reimplementation this PR has been backing away from. Verified on Python 3.11: - tests/unit/test_condition_expression_block.py 267 passed (was 253) - tests/unit + tests/test_workflows.py 1367 passed (was 1353), 22 failed before and after -- pre-existing symlink tests needing Windows elevation. - ruff check on this file is back to the 5 errors already on main. Mutation-checked: restoring the bare float() fails 2, restoring the first/last-character quote test fails 3. * fix(workflows): mirror list literals and filter arguments in the operand check Two shapes the leaf check did not mirror, each wrong in the opposite direction. A list literal is a term the evaluator understands -- it recurses into the elements rather than resolving the brackets as a name. Resolving them as a path reported `"['x', 'y']" is not a name the evaluator can resolve` and withheld the correction from `inputs.tag in ['x', 'y']`, a condition wrapping repairs completely. A filter argument is an ordinary operand to `_apply_filter`, which evaluates it with `_evaluate_simple_expression` like any other. Skipping it offered `inputs.tags | join(bogus)` as paste-ready: `bogus` is no namespace root, arrives as None, and the wrapped form raises `join: expected a string separator, got NoneType`. Parsed with the same pattern `_apply_filter` uses, so a form this does not recognize is left to the evaluator probe rather than guessed at. Every case is asserted against what the evaluator does with the wrapped form, not against a restatement of the check. * fix(workflows): let an indexed `item` root keep the correction `item` is the only namespace root that is not always a mapping. `StepContext.item` is `Any` and a fan-out assigns the item value itself, so when that value is a list `_resolve_dot_path` indexes it and `item[0] == 'x'` resolves. Rejecting every indexed root withheld the correction from a condition that evaluates. The other roots come back from `_build_namespace` as mappings, so the index branch finds no list and returns None however the index is written. The strip is therefore for `item` alone, and the paired test pins that it does not widen into "any indexed root". This narrows the root check added earlier in this branch, which was written as though every root were a mapping. --- src/specify_cli/workflows/expressions.py | 334 ++++++++++++- .../workflows/steps/do_while/__init__.py | 6 +- .../workflows/steps/if_then/__init__.py | 6 +- .../workflows/steps/while_loop/__init__.py | 6 +- tests/unit/test_condition_expression_block.py | 466 ++++++++++++++++++ 5 files changed, 808 insertions(+), 10 deletions(-) diff --git a/src/specify_cli/workflows/expressions.py b/src/specify_cli/workflows/expressions.py index 35106758bf..78b57f8c8b 100644 --- a/src/specify_cli/workflows/expressions.py +++ b/src/specify_cli/workflows/expressions.py @@ -474,6 +474,12 @@ def _apply_filter(value: Any, filter_expr: str, namespace: dict[str, Any]) -> An ) +# Order matters -- multi-char operators first, so "!=" is not split as "!" + "=". +# Shared with the remediation check so a validator cannot drift from what the +# evaluator will actually split on. +_COMPARISON_OPERATORS = ("!=", "==", ">=", "<=", ">", "<", " not in ", " in ") + + def _evaluate_simple_expression(expr: str, namespace: dict[str, Any]) -> Any: """Evaluate a simple expression against the namespace. @@ -533,7 +539,7 @@ def _evaluate_simple_expression(expr: str, namespace: dict[str, Any]) -> Any: # Comparison operators (order matters — check multi-char ops first). Split at # the first top-level occurrence so an operator inside a quoted operand is # ignored. - for op in ("!=", "==", ">=", "<=", ">", "<", " not in ", " in "): + for op in _COMPARISON_OPERATORS: op_idx = _find_top_level(expr, op) if op_idx != -1: left = _evaluate_simple_expression(expr[:op_idx].strip(), namespace) @@ -879,3 +885,329 @@ def format_condition_correction(condition: Any) -> str: # double-spaced "{{ }}" that string concatenation would otherwise produce. body = "{{ " + core + " }}" if core else "{{ }}" return json.dumps(body, ensure_ascii=False) + + +def _has_unbalanced_quote(text: str) -> bool: + """True when a quote opened in *text* is never closed. + + Same left-to-right, first-quote-wins scan the rest of this module uses, so the + answer agrees with what ``_find_block_close`` and ``_strip_stray_delimiters`` + consider "inside a string". + """ + quote: str | None = None + for ch in text: + if quote is not None: + if ch == quote: + quote = None + elif ch in ("'", '"'): + quote = ch + return quote is not None + + +_BRACKET_PAIRS = {")": "(", "]": "[", "}": "{"} + +# The operators the evaluator delimits with spaces; derived so the check cannot +# drift from _COMPARISON_OPERATORS. +_WORD_OPERATORS = tuple( + op for op in (" or ", " and ") + _COMPARISON_OPERATORS if op.startswith(" ") +) + + +def _has_unbalanced_bracket(text: str) -> bool: + """True when brackets outside a quoted operand do not nest and match. + + A depth counter is not enough: it calls ``inputs.f(]`` balanced, because the + ``]`` cancels the ``(``. The evaluator then resolves that body to ``None`` and + the comparison is false, which is the inversion this module is trying to keep + out of the suggested correction. Track the opener types instead. + """ + stack: list[str] = [] + quote: str | None = None + for ch in text: + if quote is not None: + if ch == quote: + quote = None + elif ch in ("'", '"'): + quote = ch + elif ch in "([{": + stack.append(ch) + elif ch in _BRACKET_PAIRS and (not stack or stack.pop() != _BRACKET_PAIRS[ch]): + return True + return bool(stack) + + +def _has_incomplete_operand(text: str) -> bool: + """True when an operator in *text* is missing an operand on either side. + + Splits on **every** top-level occurrence rather than the first. Checking only + the first is the same defect this module exists to reject one level up: it let + ``inputs.a == inputs.b ==`` through, because the leading ``==`` has operands on + both sides and the scan stopped there. + + Reads ``_COMPARISON_OPERATORS`` from the evaluator rather than restating it, so + the check cannot drift from what ``_evaluate_simple_expression`` splits on. + """ + stripped = text.strip() + if not stripped: + return True + + # `not x` is a valid prefix form; `and x` and `or x` are not, and none of the + # three is valid alone or trailing. The keyword scans below use bare words + # because a leading operator has no space in front of it to match on. + if stripped in ("and", "or", "not") or stripped.endswith(" not"): + return True + # Word operators lose their delimiting space at the ends of a stripped core, so + # a trailing "not in" or a leading "and" needs matching without it. Derived from + # the evaluator's own table rather than restated. + for op in _WORD_OPERATORS: + if stripped.endswith(op.rstrip()) or stripped.startswith(op.lstrip()): + return True + + for op in (" or ", " and ") + _COMPARISON_OPERATORS: + if _find_top_level(stripped, op) == -1: + continue + if any(not segment.strip() for segment in _split_top_level(stripped, op)): + return True + + return _find_top_level(stripped, "|") != -1 and any( + not segment.strip() for segment in _split_top_level(stripped, "|") + ) + + +# The roots _build_namespace supplies. A reference to anything else resolves to +# None, so a correction built on one turns a truthy condition false. +_NAMESPACE_ROOTS = ("inputs", "steps", "item", "fan_in", "context") + +# Exactly what _resolve_dot_path accepts: a name, optionally one numeric index. +_PATH_SEGMENT = re.compile(r"^[\w-]+(\[\d+\])?$") + + +class _ProbeNamespace(dict): + """Namespace for the parse probe: every root exists, every leaf is absent. + + Enough for ``_evaluate_simple_expression`` to walk the grammar without needing + real inputs. Deliberately *not* resolving leaves to a sentinel value: a probe + that answers every lookup also answers ``inputs.count+1``, which is the + malformed shape the probe is meant to expose. + """ + + def __missing__(self, key: str) -> "_ProbeNamespace": # noqa: UP037 # pragma: no cover + return _ProbeNamespace() + + +def _evaluator_rejects(text: str) -> str | None: + """The evaluator's own complaint about how *text* is wired, or ``None``. + + Structural checks cannot establish that a core is parseable -- four rounds of + review found a new shape each time -- so this asks the evaluator. It reports + only the two failures ``_apply_filter`` raises about the expression itself: an + unknown filter name, and a registered filter used in an unsupported form. + + Anything else a probe run raises is about the probe's placeholder values, not + the author's text. ``steps.emit.output.stdout | from_json`` is valid against a + string output and is exercised in ``tests/test_workflows.py``; the probe hands + ``from_json`` a dict and it raises, so treating every error as a rejection + withheld a correction from a perfectly good condition. + """ + try: + _evaluate_simple_expression( + text, {root: _ProbeNamespace() for root in _NAMESPACE_ROOTS} + ) + except ValueError as exc: + message = str(exc) + # Every error _apply_filter raises about the filter *expression* quotes the + # segment back as `got '| ...'`. Its value errors instead name the type they + # received, which under a probe is the placeholder, not anything the author + # wrote -- treating those as rejections withheld corrections from valid + # conditions such as `steps.emit.output.stdout | from_json`. + if "got '| " in message: + return message.split(":", 1)[0] + except Exception: # noqa: BLE001 - probe values, not the author's text + return None + return None + + + +def _looks_numeric(text: str) -> bool: + """Mirror the evaluator's numeric literal test exactly. + + `_evaluate_simple_expression` only calls `float()` when a `.` is present and + `int()` otherwise, so `1e3` is not a number to it -- it falls through to a path + lookup and resolves to None. A bare `float()` here accepted `1e3` and the + correction turned a truthy condition false. + """ + try: + if "." in text: + float(text) + else: + int(text) + except (ValueError, TypeError): + return False + return True + + +def _is_literal(text: str) -> bool: + """Mirror the evaluator's literal tests exactly. + + The string case is the opening quote's *matching close being the final + character*, not first/last-character equality: `'a' 'b'` passes the latter but + is two literals to the evaluator, which falls through to a path lookup. + """ + if text[:1] in ("'", '"') and text.find(text[0], 1) == len(text) - 1: + return True + return text.lower() in ("true", "false", "none", "null") or _looks_numeric(text) + + +def _unresolvable_term(text: str) -> str | None: + """The first operand in *text* the evaluator cannot resolve, or ``None``. + + Walks operands the way ``_evaluate_simple_expression`` does -- filters, then + ``or``/``and``/``not``, then comparisons -- and checks each leaf. A leaf must be + a literal or a dotted path rooted in ``_NAMESPACE_ROOTS``. + + Enumerating broken shapes is what made this take several rounds: each new gate + only knew the shapes named so far. ``inputs.a === inputs.b`` split cleanly on + ``==`` and looked complete, while the evaluator read ``= inputs.b`` as a path + and resolved it to ``None``; ``bogus == 'x'`` passed for the same reason one + level up. Recursing to the leaves covers both without naming either. + """ + stripped = text.strip() + if not stripped: + return "an operand is empty" + + if _find_top_level(stripped, "|") != -1: + segments = _split_top_level(stripped, "|") + reason = _unresolvable_term(segments[0]) + if reason is not None: + return reason + # A filter argument is an ordinary operand to `_apply_filter`, which + # evaluates it with `_evaluate_simple_expression` like any other. Skipping + # it let `inputs.tags | join(bogus)` be offered as paste-ready: `bogus` is + # no namespace root, resolves to None, and the wrapped form then raises + # `join: expected a string separator, got NoneType`. Parse with the same + # pattern `_apply_filter` uses, so a form this does not recognize is left + # to the evaluator probe rather than guessed at here. + for segment in segments[1:]: + match = re.fullmatch(r"(\w+)\((.+)\)", segment.strip()) + if match is None: + continue + reason = _unresolvable_term(match.group(2)) + if reason is not None: + return reason + return None + + for op in (" or ", " and "): + idx = _find_top_level(stripped, op) + if idx != -1: + return _unresolvable_term(stripped[:idx]) or _unresolvable_term( + stripped[idx + len(op):] + ) + + if stripped.startswith("not "): + return _unresolvable_term(stripped[4:]) + + for op in _COMPARISON_OPERATORS: + idx = _find_top_level(stripped, op) + if idx != -1: + return _unresolvable_term(stripped[:idx]) or _unresolvable_term( + stripped[idx + len(op):] + ) + + if _is_literal(stripped): + return None + + # A list literal is a term the evaluator understands, and it recurses into the + # elements rather than resolving the brackets as a name. Not mirroring that + # denied the correction to `inputs.tag in ['x', 'y']` -- a condition wrapping + # repairs completely -- while reporting the list as an unresolvable name. The + # empty-segment skip matches `_evaluate_simple_expression`, which drops them so + # `[1, 2,]` is `[1, 2]` rather than `[1, 2, None]`. + if stripped.startswith("[") and stripped.endswith("]"): + inner = stripped[1:-1].strip() + if not inner: + return None + for element in _split_top_level_commas(inner): + if not element.strip(): + continue + reason = _unresolvable_term(element) + if reason is not None: + return reason + return None + + segments = _split_top_level(stripped, ".") + if not _PATH_SEGMENT.match(segments[0].strip()): + return f"{stripped!r} is not a name the evaluator can resolve" + # `item` is the only root that is not always a mapping: `StepContext.item` is + # `Any` and a fan-out assigns the item value itself, so when that value is a + # list `_resolve_dot_path` indexes it and `item[0] == 'x'` resolves. Every + # other root comes back from `_build_namespace` as a mapping, and the index + # branch returns None for those however it is written -- so the index is + # stripped for `item` alone rather than for roots in general. + root = segments[0].strip() + indexed_root = re.fullmatch(r"([\w-]+)\[\d+\]", root) + if indexed_root is not None and indexed_root.group(1) == "item": + root = indexed_root.group(1) + if root not in _NAMESPACE_ROOTS: + return ( + f"{segments[0].strip()!r} is not one of the namespace roots " + f"({', '.join(_NAMESPACE_ROOTS)})" + ) + for segment in segments[1:]: + if not _PATH_SEGMENT.match(segment.strip()): + return f"{segment.strip()!r} is not a valid path segment" + return None + + +def _wrapping_would_not_repair(core: str) -> str | None: + """Why wrapping *core* in ``{{ }}`` would not yield the expression intended. + + ``None`` when it would. Each branch names something observable about the text + itself, deliberately not the interpolator path it will take: two earlier + versions of this message asserted an internal route -- the raw-close fallback -- + and were wrong, because ``_is_single_expression`` accepts the wrapped form and + sends it down the typed fast path instead. + """ + if not core: + return "there is no expression here to wrap" + if _has_unbalanced_quote(core): + return "the quote opened in it is never closed" + if _has_unbalanced_bracket(core): + return "its brackets do not balance" + if _has_incomplete_operand(core): + return "an operator in it is missing an operand" + unresolvable = _unresolvable_term(core) + if unresolvable is not None: + return unresolvable + rejected = _evaluator_rejects(core) + if rejected is not None: + return f"the evaluator rejects it ({rejected})" + return None + + +def format_condition_remediation(condition: Any) -> str: + """The advice sentence for a condition that is never evaluated. + + ``format_condition_correction`` wraps whatever it is handed, which is right for a + formatter but wrong to advertise as paste-ready when wrapping cannot repair the + input. Measured, each of these was being offered as the fix and each **inverts** + the condition instead: + + " " -> "{{ }}" True -> False + {{ inputs.name == 'abc -> "{{ inputs.name == 'abc }}" True -> False + inputs.name == -> "{{ inputs.name == }}" True -> False + + The author is told the condition is always true, pastes the suggestion, and now + has an always-false one. Naming the fault beats handing back something that looks + authoritative and is not -- the same call already made for + ``condition_has_malformed_expression_block``, which offers no suggestion at all. + """ + core = _strip_stray_delimiters(str(condition)).strip() + reason = _wrapping_would_not_repair(core) + if reason is None: + return "Wrap the expression: " + format_condition_correction(condition) + "." + return ( + f"No correction is offered because {reason}: wrapping it as written would " + "produce a different expression from the one intended, and its result can " + "silently invert the condition rather than repair it. Complete the " + "expression, or use the literal true or false." + ) diff --git a/src/specify_cli/workflows/steps/do_while/__init__.py b/src/specify_cli/workflows/steps/do_while/__init__.py index 84921ef556..783fe44232 100644 --- a/src/specify_cli/workflows/steps/do_while/__init__.py +++ b/src/specify_cli/workflows/steps/do_while/__init__.py @@ -8,7 +8,7 @@ from specify_cli.workflows.expressions import ( condition_has_malformed_expression_block, condition_is_never_evaluated, - format_condition_correction, + format_condition_remediation, ) @@ -104,8 +104,8 @@ def validate(self, config: dict[str, Any]) -> list[str]: errors.append( f"Do-while step {config.get('id', '?')!r}: 'condition' " f"{config['condition']!r} is not a single complete '{{{{ }}}}' block, so " - "it is never evaluated as an expression and is always true. Wrap the expression: " - + format_condition_correction(config["condition"]) + "." + "it is never evaluated as an expression and is always true. " + + format_condition_remediation(config["condition"]) ) elif condition_has_malformed_expression_block(config["condition"]): # Different fault, different advice. Here the block is *not* skipped: diff --git a/src/specify_cli/workflows/steps/if_then/__init__.py b/src/specify_cli/workflows/steps/if_then/__init__.py index cb74db7b3d..4ad2d5c9df 100644 --- a/src/specify_cli/workflows/steps/if_then/__init__.py +++ b/src/specify_cli/workflows/steps/if_then/__init__.py @@ -8,7 +8,7 @@ from specify_cli.workflows.expressions import ( condition_has_malformed_expression_block, condition_is_never_evaluated, - format_condition_correction, + format_condition_remediation, evaluate_condition, ) @@ -95,8 +95,8 @@ def validate(self, config: dict[str, Any]) -> list[str]: errors.append( f"If step {config.get('id', '?')!r}: 'condition' " f"{config['condition']!r} is not a single complete '{{{{ }}}}' block, so " - "it is never evaluated as an expression and is always true. Wrap the expression: " - + format_condition_correction(config["condition"]) + "." + "it is never evaluated as an expression and is always true. " + + format_condition_remediation(config["condition"]) ) elif condition_has_malformed_expression_block(config["condition"]): # Different fault, different advice. Here the block is *not* skipped: diff --git a/src/specify_cli/workflows/steps/while_loop/__init__.py b/src/specify_cli/workflows/steps/while_loop/__init__.py index feda1b334d..85cd97cbb5 100644 --- a/src/specify_cli/workflows/steps/while_loop/__init__.py +++ b/src/specify_cli/workflows/steps/while_loop/__init__.py @@ -8,7 +8,7 @@ from specify_cli.workflows.expressions import ( condition_has_malformed_expression_block, condition_is_never_evaluated, - format_condition_correction, + format_condition_remediation, evaluate_condition, ) @@ -113,8 +113,8 @@ def validate(self, config: dict[str, Any]) -> list[str]: errors.append( f"While step {config.get('id', '?')!r}: 'condition' " f"{config['condition']!r} is not a single complete '{{{{ }}}}' block, so " - "it is never evaluated as an expression and is always true. Wrap the expression: " - + format_condition_correction(config["condition"]) + "." + "it is never evaluated as an expression and is always true. " + + format_condition_remediation(config["condition"]) ) elif condition_has_malformed_expression_block(config["condition"]): # Different fault, different advice. Here the block is *not* skipped: diff --git a/tests/unit/test_condition_expression_block.py b/tests/unit/test_condition_expression_block.py index 7d9d235902..e2503f2fd8 100644 --- a/tests/unit/test_condition_expression_block.py +++ b/tests/unit/test_condition_expression_block.py @@ -9,6 +9,16 @@ condition_is_never_evaluated, evaluate_condition, format_condition_correction, + _has_unbalanced_quote, + _has_unbalanced_bracket, + _has_incomplete_operand, + _unresolvable_term, + _evaluator_rejects, + _is_literal, + _strip_stray_delimiters, + _COMPARISON_OPERATORS, + _WORD_OPERATORS, + format_condition_remediation, ) from specify_cli.workflows.steps.do_while import DoWhileStep from specify_cli.workflows.steps.if_then import IfThenStep @@ -290,3 +300,459 @@ def test_malformed_message_offers_no_paste_ready_correction(step_cls, condition) errors = [e for e in step_cls().validate(config) if "'condition'" in e] assert "Wrap the expression" not in errors[0] assert errors[0].rstrip().endswith("Balance the delimiters and quotes.") + + +# A correction is only offered when wrapping would actually repair the condition. +# These two inputs reach the same "never evaluated" branch, but wrapping them +# produces something the author must not paste, so the advice names the fault +# instead. Both were previously advertised as paste-ready (Copilot review). +UNFIXABLE_BY_WRAPPING = [ + (" ", "no expression here to wrap"), + ("{{ inputs.name == 'abc", "quote opened in it is never closed"), + ("'unterminated", "quote opened in it is never closed"), + ("inputs.name ==", "missing an operand"), + ("inputs.count >", "missing an operand"), + ("inputs.ready and", "missing an operand"), + ("inputs.x | ", "missing an operand"), + ("inputs.f(", "brackets do not balance"), +] + + +@pytest.mark.parametrize("step_cls", STEP_CLASSES) +@pytest.mark.parametrize("condition,expected", UNFIXABLE_BY_WRAPPING) +def test_no_paste_ready_correction_when_wrapping_would_not_repair( + step_cls, condition, expected +): + config = {"id": "s1", "condition": condition, "then": [], "steps": []} + errors = [e for e in step_cls().validate(config) if "'condition'" in e] + + assert len(errors) == 1 + assert "Wrap the expression" not in errors[0] + assert expected in errors[0] + + +def test_wrapping_whitespace_would_invert_the_condition(): + """Why the blank case gets advice instead of a suggestion. + + `{{ }}` interpolates to the empty string, so pasting it turns an always-true + condition into an always-false one -- a different defect, not a repair. + """ + ctx = StepContext(inputs={}) + assert evaluate_condition(" ", ctx) is True + assert evaluate_condition("{{ }}", ctx) is False + + +def test_wrapping_an_open_quote_inverts_the_condition(): + """Why the unbalanced-quote case gets advice instead of a suggestion. + + The raw-close fallback evaluates a truncated comparison and yields the string + "False", which evaluate_condition then reads as the `false` keyword. Pasting + the "correction" flips the condition rather than repairing it. + """ + ctx = StepContext(inputs={"name": "Bob"}) + assert evaluate_condition("{{ inputs.name == 'abc", ctx) is True + assert evaluate_condition("{{ inputs.name == 'abc }}", ctx) is False + + +@pytest.mark.parametrize( + "text,unbalanced", + [ + ("inputs.name == 'abc'", False), + ('inputs.name == "abc"', False), + ("inputs.name == 'abc", True), + ('inputs.name == "abc', True), + ("inputs.text == '\"'", False), + ("inputs.count > 100", False), + ], +) +def test_unbalanced_quote_scan(text, unbalanced): + assert _has_unbalanced_quote(text) is unbalanced + + +# The property behind the case list above, stated once so a new malformed shape +# is caught by the invariant rather than by adding another fixture row. +# Genuine expressions only. TRICKY_CONDITIONS is a quoting/escaping fixture for +# the formatter and deliberately includes prose, so it must not be reused here. +OFFERED_CORRECTION_INPUTS = [ + "inputs.count > 100", + 'inputs.name == "zzz"', + "inputs.name == 'zzz'", + "{{ inputs.count > 100", + "{{ true }} and {{ inputs.ready", + "inputs.a and inputs.b", + "inputs.name", + "not inputs.ready", + "inputs.tags | join(',')", + # The tricky-quoting cases from TRICKY_CONDITIONS that really are expressions. + # Listed rather than filtered out of that fixture, so adding prose there cannot + # silently widen what this invariant claims. + 'inputs.a == "x" and inputs.b == \'y\'', + "inputs.path == 'C:" + BACKSLASH + "tmp'", + 'inputs.path == "C:' + BACKSLASH + 'tmp"', + "inputs.a == 'x\ty'", + "inputs.a == 'x\ry'", + "inputs.ten == 'mười'", + '{{ inputs.name == "zzz"', + "}} inputs.count > 100 {{", +] + + +@pytest.mark.parametrize("condition", OFFERED_CORRECTION_INPUTS) +def test_every_offered_correction_is_a_complete_expression(condition): + """Whatever is advertised as paste-ready must pass our own validators. + + Both earlier rounds of this fix were partial because they enumerated broken + shapes -- blank, then unbalanced quote. This asserts the property instead: if + the remediation offers a correction at all, the wrapped form it hands back is + a single complete block that neither validator objects to. + """ + advice = format_condition_remediation(condition) + assert advice.startswith("Wrap the expression: ") + + suggested = yaml.safe_load( + "condition: " + advice.split("Wrap the expression: ", 1)[1].rstrip(".") + )["condition"] + assert condition_is_never_evaluated(suggested) is False + assert condition_has_malformed_expression_block(suggested) is False + + +@pytest.mark.parametrize("condition,_reason", UNFIXABLE_BY_WRAPPING) +def test_withheld_corrections_would_indeed_have_been_broken(condition, _reason): + """The other half: what is withheld really would not have survived wrapping. + + Guards against the gate growing over-eager and refusing to help with input it + could have corrected. + """ + core = _strip_stray_delimiters(condition).strip() + wrapped = "{{ " + core + " }}" + assert ( + not core + or _has_unbalanced_quote(core) + or _has_unbalanced_bracket(core) + or _has_incomplete_operand(core) + or condition_is_never_evaluated(wrapped) + or condition_has_malformed_expression_block(wrapped) + ) + + +@pytest.mark.parametrize( + "text,unbalanced", + [ + ("inputs.f(1)", False), + ("inputs.f(", True), + ("inputs.f)", True), + ("inputs.tags[0]", False), + ("inputs.text == '('", False), + ], +) +def test_unbalanced_bracket_scan(text, unbalanced): + assert _has_unbalanced_bracket(text) is unbalanced + + +def test_incomplete_operand_reads_the_evaluator_operator_list(): + """The check must not restate the operator table it is predicting.""" + for op in _COMPARISON_OPERATORS: + assert _has_incomplete_operand("inputs.a" + op) is True + assert _has_incomplete_operand("inputs.a" + op + "inputs.b") is False + + +def test_incomplete_operand_covers_every_operator_the_evaluator_splits_on(): + """Hard-coded on purpose. + + Parametrising over `_COMPARISON_OPERATORS` shrinks with the constant, so + dropping an operator from it would make that test pass vacuously -- the same + can't-fail-when-it-matters shape this module exists to reject. Listing the + operators here means removing one from the evaluator fails a test. + """ + for op in ("!=", "==", ">=", "<=", ">", "<", " not in ", " in ", " and ", " or "): + assert _has_incomplete_operand("inputs.a" + op) is True, op + assert _has_incomplete_operand("inputs.a" + op + "inputs.b") is False, op + + +# Copilot round 3: the first two gates each inspected only one position. These pin +# every-position scanning, both ends, and bracket-type matching. +MULTI_POSITION_UNFIXABLE = [ + ("inputs.a == inputs.b ==", "missing an operand"), # trailing, not the first op + ("and inputs.ready", "missing an operand"), # leading boolean operator + ("inputs.a not in", "missing an operand"), # trailing word operator + ("in inputs.tags", "missing an operand"), # leading word operator + ("inputs.f(]", "brackets do not balance"), # matched count, wrong types + ("inputs.f(]", "brackets do not balance"), + ("inputs.items | length", "the evaluator rejects it"), + ("inputs.tags | join", "used in an unsupported form"), + ('he said "hi" then left', "is not a name the evaluator can resolve"), + ("inputs.count+1", "is not a valid path segment"), + ("inputs.a === inputs.b", "is not a name the evaluator can resolve"), + ("bogus == 'x'", "is not one of the namespace roots"), + ("inputs.payload | from_json()", "the evaluator rejects it"), + # `_find_top_level` matches " and " with literal spaces, so a newline before + # the keyword is not an operator: the wrapped form evaluates False where the + # same expression with a space evaluates True. + ("inputs.x == 1\nand inputs.name == 'abc'", "is not a name the evaluator can resolve"), +] + + +@pytest.mark.parametrize("step_cls", STEP_CLASSES) +@pytest.mark.parametrize("condition,expected", MULTI_POSITION_UNFIXABLE) +def test_gates_inspect_every_position_not_just_the_first(step_cls, condition, expected): + config = {"id": "s1", "condition": condition, "then": [], "steps": []} + errors = [e for e in step_cls().validate(config) if "'condition'" in e] + + assert len(errors) == 1 + assert "Wrap the expression" not in errors[0] + assert expected in errors[0] + + +@pytest.mark.parametrize( + "text,unbalanced", + [ + ("inputs.f(]", True), # counts match, types do not + ("inputs.f[)", True), + ("inputs.f(}", True), + ("inputs.f([])", False), + ("inputs.f(])", True), + ("inputs.text == '(]'", False), # mismatched pair inside a quoted operand + ], +) +def test_bracket_scan_matches_types_not_just_depth(text, unbalanced): + assert _has_unbalanced_bracket(text) is unbalanced + + +def test_word_operators_are_derived_from_the_evaluator_table(): + """Guards the derivation, not the literal tuple. + + If a space-delimited operator is added to _COMPARISON_OPERATORS, the end-of-core + checks must pick it up without another edit here. + """ + assert _WORD_OPERATORS == (" or ", " and ", " not in ", " in ") + for op in _WORD_OPERATORS: + assert _has_incomplete_operand("inputs.a" + op.rstrip()) is True, op + assert _has_incomplete_operand(op.lstrip() + "inputs.a") is True, op + + +def test_the_probe_reports_what_the_evaluator_reports(): + """The parse probe must not restate the filter table. + + Four review rounds each found a shape the structural gates did not know about. + Asking the evaluator removes that class: any filter used under an unknown name + or in an unsupported form is reported by the code that will run. + """ + assert _evaluator_rejects("inputs.items | length") is not None + assert _evaluator_rejects("inputs.tags | join") is not None + assert _evaluator_rejects("inputs.tags | join(',')") is None + assert _evaluator_rejects("inputs.count > 100") is None + + +@pytest.mark.parametrize( + "text,not_a_path", + [ + ("inputs.name", False), + ("inputs.a.b.c", False), + ("inputs.tags[0]", False), + ("not inputs.ready", False), + ("true", False), + ("42", False), + ("'a literal'", False), + ("inputs.count > 100", False), # has an operator, not a bare term + ("inputs.count+1", True), # the evaluator has no arithmetic + ('he said "hi" then left', True), + # _resolve_dot_path keys on [w-]+, so a key literally named "2bad" resolves. + ("inputs.2bad", False), + ("inputs.tags[foo]", True), + ("inputs.matrix[0][1]", True), + # Round 7: an operand one level down, which the single-term gate never saw. + ("inputs.a === inputs.b", True), + ("bogus", True), + ("bogus == 'x'", True), + ("item.name == 'x'", False), + ("fan_in.results | join(',')", False), + ("context.run_id != ''", False), + ], +) +def test_operands_must_be_literals_or_known_paths(text, not_a_path): + """Recursing to the leaves replaced the single-term check. + + The old gate only looked at a core with no operator, so `inputs.a === inputs.b` + and `bogus == 'x'` walked past it. This asserts the reachable leaf instead. + """ + assert (_unresolvable_term(text) is not None) is not_a_path + + +@pytest.mark.parametrize( + "condition", + [ + # Valid against a string output and exercised in tests/test_workflows.py. + # The probe hands from_json a dict, so treating every probe error as a + # rejection withheld a correction from a good condition. + "steps.emit.output.stdout | from_json", + # The filter argument is resolved from the namespace too. + "inputs.tags | join(inputs.separator)", + ], +) +def test_probe_value_errors_are_not_treated_as_rejections(condition): + assert _evaluator_rejects(condition) is None + assert format_condition_remediation(condition).startswith("Wrap the expression: ") + + +@pytest.mark.parametrize( + "condition", + ["inputs.items | length", "inputs.tags | join"], +) +def test_filter_wiring_errors_are_still_rejections(condition): + """The other half: a filter named wrong or used wrong is the author's text.""" + assert _evaluator_rejects(condition) is not None + assert "Wrap the expression" not in format_condition_remediation(condition) + + +@pytest.mark.parametrize( + "condition,literal", + [ + ("42", True), + ("3.14", True), + ("-7", True), + # `1e3` has no "." so the evaluator calls int() on it, which fails; it then + # falls through to a path lookup. float() alone accepted it here. + ("1e3", False), + ("'one'", True), + ('"one"', True), + # Two literals, not one: the evaluator requires the opening quote's match to + # be the final character, which first/last-character equality does not. + ("'a' 'b'", False), + ("'a' == 'b'", False), + ("true", True), + ("inputs.name", False), + ], +) +def test_literal_test_mirrors_the_evaluator(condition, literal): + assert _is_literal(condition) is literal + + +@pytest.mark.parametrize( + "condition", + [ + # `_build_namespace` hands back mappings, so an indexed root always resolves + # to None however the index is written. + "inputs[0]", + "steps[1]", + "1e3", + "'a' 'b'", + ], +) +def test_shapes_the_evaluator_resolves_to_none_get_no_correction(condition): + advice = format_condition_remediation(condition) + assert "Wrap the expression" not in advice + + +# The two shapes below were each offered or withheld for the wrong reason. Both are +# checked against what the evaluator actually does with the wrapped form, not against +# a restatement of the check, so a check that drifts from the evaluator fails here. +CORRECTION_OFFERED = "Wrap the expression" + + +def _wrapped_evaluates(condition: str) -> bool: + ctx = StepContext( + inputs={ + "tag": "x", + "tags": ["a", "b"], + "count": 3, + "fallback": ", ", + "blob": '{"k": 1}', + } + ) + try: + evaluate_condition("{{ " + condition + " }}", ctx) + except Exception: + return False + return True + + +@pytest.mark.parametrize( + "condition", + [ + "inputs.tag in ['x', 'y']", + "inputs.tag not in ['x']", + "inputs.tag in [inputs.other, 'z']", + # `_evaluate_simple_expression` drops empty segments, so a trailing comma is + # `[1, 2]` rather than `[1, 2, None]`, and an empty list is a list. + "inputs.count in [1, 2,]", + "inputs.count in []", + ], +) +def test_list_literal_operands_keep_the_correction(condition): + """A list literal is a term, not a name. + + Resolving the brackets as a path reported `"['x', 'y']" is not a name the + evaluator can resolve` and withheld the correction from a condition that + wrapping repairs completely. + """ + assert CORRECTION_OFFERED in format_condition_remediation(condition) + assert _wrapped_evaluates(condition) + + +@pytest.mark.parametrize( + "condition", + ["inputs.tags | join(bogus)", "inputs.tags | map(bogus)"], +) +def test_filter_arguments_that_make_the_wrapped_form_raise_lose_the_correction(condition): + """A filter argument is an operand like any other. + + `_apply_filter` evaluates it with `_evaluate_simple_expression`, so a name that + is no namespace root arrives as None and the filter raises on it. Skipping the + argument offered these as paste-ready. + """ + assert CORRECTION_OFFERED not in format_condition_remediation(condition) + assert not _wrapped_evaluates(condition) + + +def test_a_filter_argument_that_cannot_resolve_loses_it_even_without_raising(): + """`default` tolerates the None, so this one is policy rather than a crash. + + Withholding it is the same call already made for an unresolvable name anywhere + else -- `bogus == 'x'` evaluates fine and is withheld too -- so the argument + check does not need the wrapped form to raise before it declines. + """ + condition = "inputs.count | default(bogus)" + assert CORRECTION_OFFERED not in format_condition_remediation(condition) + assert _wrapped_evaluates(condition) + assert CORRECTION_OFFERED not in format_condition_remediation("bogus == 'x'") + + +@pytest.mark.parametrize( + "condition", + [ + "inputs.tags | join(', ')", + "inputs.tags | join(inputs.fallback)", + "inputs.tags | map('name')", + "inputs.count | default(0)", + "inputs.blob | from_json", + ], +) +def test_resolvable_filter_arguments_keep_the_correction(condition): + """The other direction: the argument check must not become a blanket refusal.""" + assert CORRECTION_OFFERED in format_condition_remediation(condition) + assert _wrapped_evaluates(condition) + + +@pytest.mark.parametrize("condition", ["item[0] == 'x'", "item[1] == 'y'"]) +def test_an_indexed_item_root_keeps_the_correction(condition): + """`item` is the only root that is not always a mapping. + + `StepContext.item` is `Any` and a fan-out assigns the item value itself, so an + item that is a list makes `item[0]` resolve. Rejecting every indexed root + withheld the correction from a condition that evaluates. + """ + ctx = StepContext(inputs={"a": 1}, item=["x", "y"]) + assert CORRECTION_OFFERED in format_condition_remediation(condition) + assert evaluate_condition("{{ " + condition + " }}", ctx) is True + + +@pytest.mark.parametrize("condition", ["inputs[0]", "steps[1]", "fan_in[0]", "context[0]"]) +def test_indexing_an_always_mapping_root_still_loses_the_correction(condition): + """The other side of that split, so it does not widen into "any indexed root". + + `_build_namespace` hands these back as mappings, so `_resolve_dot_path` takes + the index branch, finds no list, and returns None however the index is written. + """ + ctx = StepContext(inputs={"a": 1}, item=["x", "y"]) + assert CORRECTION_OFFERED not in format_condition_remediation(condition) + assert evaluate_condition("{{ " + condition + " }}", ctx) is False From f5ab7796dda59fe6e3b526e54aa00e80b614a067 Mon Sep 17 00:00:00 2001 From: Marsel Safin <179933638+marcelsafin@users.noreply.github.com> Date: Fri, 21 Aug 2026 09:23:42 -0700 Subject: [PATCH 029/102] fix(presets): reject non-mapping catalog mutations (#4094) Assisted-by: GitHub Copilot (model: gpt-5.6-sol, autonomous) Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- src/specify_cli/presets/_commands.py | 14 ++++++++++-- tests/test_presets.py | 32 ++++++++++++++++++++++++++++ 2 files changed, 44 insertions(+), 2 deletions(-) diff --git a/src/specify_cli/presets/_commands.py b/src/specify_cli/presets/_commands.py index 48d5c9f14f..90c0ff1630 100644 --- a/src/specify_cli/presets/_commands.py +++ b/src/specify_cli/presets/_commands.py @@ -767,11 +767,16 @@ def preset_catalog_add( # Load existing config if config_path.exists(): try: - config = yaml.safe_load(config_path.read_text(encoding="utf-8")) or {} + config = yaml.safe_load(config_path.read_text(encoding="utf-8")) except Exception as e: config_label = _display_project_path(project_root, config_path) console.print(f"[red]Error:[/red] Failed to read {_escape_markup(str(config_label))}: {_escape_markup(str(e))}") raise typer.Exit(1) + if config is None: + config = {} + elif not isinstance(config, dict): + console.print("[red]Error:[/red] Invalid catalog config: expected a mapping.") + raise typer.Exit(1) else: config = {} @@ -827,10 +832,15 @@ def preset_catalog_remove( raise typer.Exit(1) try: - config = yaml.safe_load(config_path.read_text(encoding="utf-8")) or {} + config = yaml.safe_load(config_path.read_text(encoding="utf-8")) except Exception as e: console.print(f"[red]Error:[/red] Failed to read preset catalog config: {e}") raise typer.Exit(1) + if config is None: + config = {} + elif not isinstance(config, dict): + console.print("[red]Error:[/red] Invalid catalog config: expected a mapping.") + raise typer.Exit(1) catalogs = config.get("catalogs", []) if not isinstance(catalogs, list): diff --git a/tests/test_presets.py b/tests/test_presets.py index 47201d8f6e..f30ab4909e 100644 --- a/tests/test_presets.py +++ b/tests/test_presets.py @@ -3312,6 +3312,38 @@ def test_catalog_remove_escapes_markup_in_not_found_error(self, project_dir): assert result.exit_code == 1 assert "[/red]absent" in result.output + @pytest.mark.parametrize( + "args", + [ + [ + "preset", + "catalog", + "add", + "https://example.com/catalog.json", + "--name", + "example", + ], + ["preset", "catalog", "remove", "example"], + ], + ) + def test_catalog_mutation_rejects_non_mapping_config_root( + self, project_dir, args + ): + from typer.testing import CliRunner + from unittest.mock import patch + from specify_cli import app + + config_path = project_dir / ".specify" / "preset-catalogs.yml" + original = "[]\n" + config_path.write_text(original, encoding="utf-8") + + with patch.object(Path, "cwd", return_value=project_dir): + result = CliRunner().invoke(app, args) + + assert result.exit_code == 1 + assert "expected a mapping" in result.output + assert config_path.read_text(encoding="utf-8") == original + def test_env_var_overrides_catalogs(self, project_dir, monkeypatch): """Test that SPECKIT_PRESET_CATALOG_URL env var overrides defaults.""" monkeypatch.setenv( From 36ff0158b18252c486d8c91347d60779642ad1a8 Mon Sep 17 00:00:00 2001 From: Marsel Safin <179933638+marcelsafin@users.noreply.github.com> Date: Fri, 21 Aug 2026 09:24:54 -0700 Subject: [PATCH 030/102] fix(bundler): reject non-string manifest list members (#4091) * fix(bundler): reject non-string manifest list members Assisted-by: GitHub Copilot (model: gpt-5.6-sol, autonomous) Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * docs(bundler): clarify string list validation Assisted-by: GitHub Copilot (model: gpt-5.6-sol, autonomous) Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --------- Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- src/specify_cli/bundler/models/manifest.py | 10 ++++++---- tests/contract/test_manifest_schema.py | 19 +++++++++++++++++++ 2 files changed, 25 insertions(+), 4 deletions(-) diff --git a/src/specify_cli/bundler/models/manifest.py b/src/specify_cli/bundler/models/manifest.py index 032863a2e8..39684b2327 100644 --- a/src/specify_cli/bundler/models/manifest.py +++ b/src/specify_cli/bundler/models/manifest.py @@ -237,17 +237,19 @@ def _text(raw: Any) -> str: def _parse_str_list(raw: Any, field_name: str) -> tuple[str, ...]: - """Coerce a manifest list-of-strings field into a tuple of strings. + """Parse a manifest list-of-strings field into a tuple of strings. Rejects a bare string/bytes (which would otherwise be iterated - character-by-character) and any non-list/tuple, matching the manifest - contract (``string[]``). + character-by-character), any non-list/tuple, and any non-string member, + matching the manifest contract (``string[]``). """ if raw is None: return () if isinstance(raw, (str, bytes)) or not isinstance(raw, (list, tuple)): raise BundlerError(f"'{field_name}' must be a list of strings when present.") - return tuple(str(item) for item in raw) + if any(not isinstance(item, str) for item in raw): + raise BundlerError(f"'{field_name}' must be a list of strings when present.") + return tuple(raw) def _parse_refs(kind: str, raw: Any) -> list[ComponentRef]: diff --git a/tests/contract/test_manifest_schema.py b/tests/contract/test_manifest_schema.py index 2f38620423..4784bdf462 100644 --- a/tests/contract/test_manifest_schema.py +++ b/tests/contract/test_manifest_schema.py @@ -165,6 +165,25 @@ def test_string_mcp_rejected_not_split_per_character(): BundleManifest.from_dict(data) +@pytest.mark.parametrize( + ("field", "value"), + [ + ("tags", [1]), + ("requires.tools", [False]), + ("requires.mcp", [{}]), + ], +) +def test_string_list_fields_reject_non_string_members(field, value): + data = valid_manifest_dict() + if field == "tags": + data["tags"] = value + else: + data["requires"][field.split(".", 1)[1]] = value + + with pytest.raises(BundlerError, match="must be a list of strings"): + BundleManifest.from_dict(data) + + def test_string_integration_rejected_not_silently_dropped(): # A present-but-non-mapping 'integration' (a bare string) was silently # dropped, leaving the bundle wrongly integration-agnostic. Reject it like From 3cc1472098b88d0e7408fd425ce0055eff7f4de1 Mon Sep 17 00:00:00 2001 From: Ali jawwad <33836051+jawwad-ali@users.noreply.github.com> Date: Fri, 21 Aug 2026 21:29:00 +0500 Subject: [PATCH 031/102] fix(workflows): strip the resolved value before matching switch cases (#4143) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `SwitchStep.execute` matched with `str(value)` and no strip. The values a switch dispatches on are overwhelmingly captured command output, and `ShellStep` stores `proc.stdout` verbatim, so `run: echo approve` resolves to "approve\n" — which matches no `approve:` case: stdout stored : 'approve\n' matched_case : '__default__' <-- silently wrong next steps : ['fallback'] The switch falls through to `default:` (or dispatches nothing at all) while still reporting COMPLETED. A workflow author cannot fix it themselves: the registered filters are default/join/map/contains/from_json — there is no `trim`. spec-kit already treats exactly this as a bug wherever else it matches a resolved string against declared literals — `evaluate_condition` strips for this same shell-newline reason, and `InitStep._resolve_bool` does `resolved.strip().lower()`. Switch case keys are such literals, and this was the only site not stripping. `expression_value` still reports the raw value, so nothing downstream loses information, and a genuine mismatch ("approve-later") still falls through. Co-authored-by: Claude Opus 5 (1M context) --- .../workflows/steps/switch/__init__.py | 17 +++++-- tests/test_workflows.py | 49 +++++++++++++++++++ 2 files changed, 63 insertions(+), 3 deletions(-) diff --git a/src/specify_cli/workflows/steps/switch/__init__.py b/src/specify_cli/workflows/steps/switch/__init__.py index 690df0f19a..93145e870d 100644 --- a/src/specify_cli/workflows/steps/switch/__init__.py +++ b/src/specify_cli/workflows/steps/switch/__init__.py @@ -12,7 +12,8 @@ class SwitchStep(StepBase): """Multi-branch dispatch on an expression. Evaluates ``expression:`` once, matches against ``cases:`` keys - (exact match, string-coerced). Falls through to ``default:`` if + (exact match; the resolved value is string-coerced and stripped of + surrounding whitespace first). Falls through to ``default:`` if no case matches. """ @@ -22,8 +23,18 @@ def execute(self, config: dict[str, Any], context: StepContext) -> StepResult: expression = config.get("expression", "") value = evaluate_expression(expression, context) - # String-coerce for matching - str_value = str(value) if value is not None else "" + # String-coerce for matching, stripping surrounding whitespace first. + # The value a switch dispatches on is most often captured command + # output, and a ``shell`` step stores ``proc.stdout`` verbatim, so + # ``run: echo approve`` resolves to ``"approve\n"`` and matches no + # ``approve:`` case -- the switch silently falls through to ``default:`` + # while still reporting COMPLETED. A workflow cannot strip it itself: + # the registered filters are default/join/map/contains/from_json, there + # is no ``trim``. ``evaluate_condition`` and ``InitStep._resolve_bool`` + # already strip before matching a resolved string against declared + # literals, and case keys are exactly such literals. ``expression_value`` + # below still reports the raw value, so nothing downstream loses it. + str_value = str(value).strip() if value is not None else "" cases = config.get("cases", {}) if not isinstance(cases, dict): diff --git a/tests/test_workflows.py b/tests/test_workflows.py index 60a9b9ce8b..241a991074 100644 --- a/tests/test_workflows.py +++ b/tests/test_workflows.py @@ -3128,6 +3128,55 @@ def test_validate_accepts_missing_else(self): class TestSwitchStep: """Test the switch step type.""" + def test_execute_matches_case_ignoring_surrounding_whitespace(self): + """A shell step's stdout keeps its trailing newline; the case must match. + + `ShellStep` stores `proc.stdout` verbatim, so `run: echo approve` + resolves to "approve" plus a newline. Unstripped, that matched no + `approve:` case and the switch silently fell through to `default:` + while still reporting COMPLETED. There is no `trim` filter, so a + workflow author cannot strip it themselves. + """ + from specify_cli.workflows.steps.switch import SwitchStep + from specify_cli.workflows.base import StepContext, StepStatus + + config = { + "id": "route", + "expression": "{{ steps.check.output.stdout }}", + "cases": { + "approve": [{"id": "approved", "type": "command", "command": "echo"}], + "reject": [{"id": "rejected", "type": "command", "command": "echo"}], + }, + "default": [{"id": "fallback", "type": "command", "command": "echo"}], + } + for raw in ("approve\n", "approve\r\n", " approve ", "approve"): + ctx = StepContext(steps={"check": {"output": {"stdout": raw}}}) + result = SwitchStep().execute(config, ctx) + assert result.status == StepStatus.COMPLETED + assert result.output["matched_case"] == "approve", repr(raw) + assert [s["id"] for s in result.next_steps] == ["approved"], repr(raw) + # The raw value is still reported unchanged. + assert result.output["expression_value"] == raw + + def test_execute_still_falls_through_for_a_genuine_mismatch(self): + """Stripping must not make unrelated values match.""" + from specify_cli.workflows.steps.switch import SwitchStep + from specify_cli.workflows.base import StepContext + + config = { + "id": "route", + "expression": "{{ steps.check.output.stdout }}", + "cases": { + "approve": [{"id": "approved", "type": "command", "command": "echo"}] + }, + "default": [{"id": "fallback", "type": "command", "command": "echo"}], + } + ctx = StepContext(steps={"check": {"output": {"stdout": "approve-later\n"}}}) + result = SwitchStep().execute(config, ctx) + + assert result.output["matched_case"] == "__default__" + assert [s["id"] for s in result.next_steps] == ["fallback"] + def test_execute_matches_case(self): from specify_cli.workflows.steps.switch import SwitchStep from specify_cli.workflows.base import StepContext From ca5cd0c0dc9ad815e11299a10c269820d917c653 Mon Sep 17 00:00:00 2001 From: Ali jawwad <33836051+jawwad-ali@users.noreply.github.com> Date: Fri, 21 Aug 2026 21:32:29 +0500 Subject: [PATCH 032/102] fix(workflows): require a 'cases' block on switch steps (#4144) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `SwitchStep.validate` requires `expression` and type-checks `cases`, but never checks that `cases` is PRESENT. It is the only control-flow step whose branch payload is optional: if -> requires 'then' fan-out -> requires 'items' and 'step' fan-in -> requires a non-empty 'wait_for' gate -> requires 'message' switch -> cases optional So a switch whose branch table is absent or mistyped — `case:` for `cases:` is the obvious slip — passes validation with zero errors: if missing then : ["If step 'x' is missing 'then' field."] fanout missing all: ["Fan-out step 'y' is missing 'items' field.", ...] switch typo case: : [] switch no cases : [] and then at run time reports COMPLETED with `matched_case: "__default__"` — a default it does not even declare — having dispatched nothing, so the whole run "succeeds". That is the "silent empty result + COMPLETED" wiring bug the fan-in guard exists to prevent. An explicitly declared but empty `cases: {}` is still a declaration and stays valid, pinned by a test. Co-authored-by: Claude Opus 5 (1M context) --- .../workflows/steps/switch/__init__.py | 13 ++++++++ tests/test_workflows.py | 32 +++++++++++++++++++ 2 files changed, 45 insertions(+) diff --git a/src/specify_cli/workflows/steps/switch/__init__.py b/src/specify_cli/workflows/steps/switch/__init__.py index 93145e870d..8a2e4b343e 100644 --- a/src/specify_cli/workflows/steps/switch/__init__.py +++ b/src/specify_cli/workflows/steps/switch/__init__.py @@ -107,6 +107,19 @@ def validate(self, config: dict[str, Any]) -> list[str]: f"Switch step {config.get('id', '?')!r} is missing " f"'expression' field." ) + # Every other control-flow step requires its branch payload: ``if`` + # requires ``then``, ``fan-out`` requires ``items`` and ``step``, + # ``fan-in`` a non-empty ``wait_for``, ``gate`` a ``message``. Without + # the same check, a switch whose ``cases:`` block is missing or mistyped + # (``case:`` is the obvious slip) validates clean and then reports + # COMPLETED with ``matched_case: "__default__"`` -- a default it may not + # even declare -- having dispatched nothing. That is the "silent empty + # result + COMPLETED" wiring bug the fan-in guard exists to prevent. + if "cases" not in config: + errors.append( + f"Switch step {config.get('id', '?')!r} is missing " + f"'cases' field." + ) cases = config.get("cases", {}) if not isinstance(cases, dict): errors.append( diff --git a/tests/test_workflows.py b/tests/test_workflows.py index 241a991074..d599f3c6a4 100644 --- a/tests/test_workflows.py +++ b/tests/test_workflows.py @@ -3358,6 +3358,38 @@ def test_validate_missing_expression(self): errors = step.validate({"id": "test", "cases": {}}) assert any("missing 'expression'" in e for e in errors) + def test_validate_missing_cases(self): + """`cases` is the switch's branch payload and must be required. + + Every other control-flow step requires its own: `if` requires `then`, + `fan-out` requires `items` and `step`, `fan-in` a non-empty `wait_for`, + `gate` a `message`. Without it, a `case:` typo validated clean and then + reported COMPLETED with `matched_case: "__default__"` having dispatched + nothing. + """ + from specify_cli.workflows.steps.switch import SwitchStep + + step = SwitchStep() + + # Absent entirely. + errors = step.validate({"id": "route", "expression": "{{ inputs.x }}"}) + assert any("missing 'cases'" in e for e in errors), errors + + # The realistic slip: `case:` instead of `cases:`. + errors = step.validate( + {"id": "route", "expression": "{{ inputs.x }}", "case": {"a": []}} + ) + assert any("missing 'cases'" in e for e in errors), errors + + def test_validate_accepts_an_empty_cases_mapping(self): + """An explicitly declared but empty `cases:` is still a declaration.""" + from specify_cli.workflows.steps.switch import SwitchStep + + errors = SwitchStep().validate( + {"id": "route", "expression": "{{ inputs.x }}", "cases": {}} + ) + assert not any("missing 'cases'" in e for e in errors), errors + def test_validate_invalid_cases_and_default(self): from specify_cli.workflows.steps.switch import SwitchStep From 27cc286d520be2d3d07b675477ebe2240d4f92cc Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Fri, 21 Aug 2026 12:43:24 -0500 Subject: [PATCH 033/102] Update SpecAssay Check extension to v0.4.12 (#4254) Update specassay-check extension submitted by @rdryfoos to: - extensions/catalog.community.json (version, download_url, description, provides.commands) - docs/community/extensions.md community extensions table Closes #4252 Assisted-by: GitHub Copilot (model: claude-sonnet-4.6, autonomous) Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com> Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- extensions/catalog.community.json | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/extensions/catalog.community.json b/extensions/catalog.community.json index 237150b1ac..a4fa835c1a 100644 --- a/extensions/catalog.community.json +++ b/extensions/catalog.community.json @@ -4506,10 +4506,10 @@ "specassay-check": { "name": "SpecAssay Check", "id": "specassay-check", - "description": "Gate 2 refuses silent gaps and emits a trace-manifest (trace-manifest.json).", + "description": "Gate 2 refuses silent gaps and emits a trace-manifest (`trace-manifest.json`).", "author": "Rik Dryfoos", - "version": "0.3.3", - "download_url": "https://github.com/rdryfoos/specassay/releases/download/v0.3.3/specassay-check-0.3.3.zip", + "version": "0.4.12", + "download_url": "https://github.com/rdryfoos/specassay/releases/download/v0.4.12/specassay-check-0.4.12.zip", "repository": "https://github.com/rdryfoos/specassay", "homepage": "https://www.specassay.com", "documentation": "https://github.com/rdryfoos/specassay/blob/main/extensions/specassay-check/README.md", @@ -4532,7 +4532,7 @@ ] }, "provides": { - "commands": 1, + "commands": 2, "hooks": 1 }, "tags": [ @@ -4546,7 +4546,7 @@ "downloads": 0, "stars": 0, "created_at": "2026-08-13T00:00:00Z", - "updated_at": "2026-08-13T00:00:00Z" + "updated_at": "2026-08-21T00:00:00Z" }, "specjudge": { "name": "SpecJudge — right-size the model before you implement", From b41058b5e8cbec8dab27d642f53b6ea8fc63b606 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Fri, 21 Aug 2026 12:44:25 -0500 Subject: [PATCH 034/102] chore(deps): bump astral-sh/setup-uv from 9.0.0 to 10.0.1 (#4244) * chore(deps): bump astral-sh/setup-uv from 9.0.0 to 10.0.1 Bumps [astral-sh/setup-uv](https://github.com/astral-sh/setup-uv) from 9.0.0 to 10.0.1. - [Release notes](https://github.com/astral-sh/setup-uv/releases) - [Commits](https://github.com/astral-sh/setup-uv/compare/c771a70e6277c0a99b617c7a806ffedaca235ff9...20cfd1bf945f4377ade1205e4dbc17946fc9a30d) --- updated-dependencies: - dependency-name: astral-sh/setup-uv dependency-version: 10.0.1 dependency-type: direct:production update-type: version-update:semver-major ... Signed-off-by: dependabot[bot] * fix(workflows): align setup-uv generated sources Update the agentic workflow sources, action cache, generated metadata, and regression expectation for setup-uv v10.0.1. Assisted-by: GitHub Copilot (model: GPT-5.6 Sol, autonomous) Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: 394a7929-b17c-470f-a3e6-b5863f9c9d40 --------- Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> Co-authored-by: Manfred Riem <15701806+mnriem@users.noreply.github.com> Copilot-Session: 394a7929-b17c-470f-a3e6-b5863f9c9d40 --- .github/aw/actions-lock.json | 6 +++--- .github/workflows/bug-test.lock.yml | 8 ++++---- .github/workflows/bug-test.md | 2 +- .github/workflows/feature-assess.lock.yml | 8 ++++---- .github/workflows/feature-assess.md | 2 +- .github/workflows/publish-pypi.yml | 4 ++-- .github/workflows/security.yml | 4 ++-- .github/workflows/test.yml | 4 ++-- tests/test_github_workflows.py | 2 +- 9 files changed, 20 insertions(+), 20 deletions(-) diff --git a/.github/aw/actions-lock.json b/.github/aw/actions-lock.json index 36daac9877..253a22b53f 100644 --- a/.github/aw/actions-lock.json +++ b/.github/aw/actions-lock.json @@ -25,10 +25,10 @@ "version": "v7.0.0", "sha": "5fda3b95a4ea91299a34e894583c3862153e4b97" }, - "astral-sh/setup-uv@v9.0.0": { + "astral-sh/setup-uv@v10.0.1": { "repo": "astral-sh/setup-uv", - "version": "v9.0.0", - "sha": "c771a70e6277c0a99b617c7a806ffedaca235ff9" + "version": "v10.0.1", + "sha": "20cfd1bf945f4377ade1205e4dbc17946fc9a30d" }, "actions/upload-artifact@v7.0.1": { "repo": "actions/upload-artifact", diff --git a/.github/workflows/bug-test.lock.yml b/.github/workflows/bug-test.lock.yml index 810be3ae77..f4fe11ea64 100644 --- a/.github/workflows/bug-test.lock.yml +++ b/.github/workflows/bug-test.lock.yml @@ -1,5 +1,5 @@ -# gh-aw-metadata: {"schema_version":"v4","frontmatter_hash":"aa190ac1bd31b2e5e68cafd25951bda4d92a275ce1c55f58856f924e415fdb17","body_hash":"5aa25f2a19d30f31a71fb4fa9c709563d3d2c5060b2984f4ba913b7097158763","compiler_version":"v0.79.8","strict":true,"agent_id":"copilot","engine_versions":{"copilot":"1.0.60"}} -# gh-aw-manifest: {"version":1,"secrets":["COPILOT_GITHUB_TOKEN","GH_AW_GITHUB_MCP_SERVER_TOKEN","GH_AW_GITHUB_TOKEN","GITHUB_TOKEN"],"actions":[{"repo":"actions/checkout","sha":"3d3c42e5aac5ba805825da76410c181273ba90b1","version":"v7.0.1"},{"repo":"actions/download-artifact","sha":"3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c","version":"v8.0.1"},{"repo":"actions/github-script","sha":"3a2844b7e9c422d3c10d287c895573f7108da1b3","version":"v9.0.0"},{"repo":"actions/setup-node","sha":"820762786026740c76f36085b0efc47a31fe5020","version":"v7.0.0"},{"repo":"actions/setup-python","sha":"5fda3b95a4ea91299a34e894583c3862153e4b97","version":"v7.0.0"},{"repo":"actions/upload-artifact","sha":"043fb46d1a93c77aae656e7c1c64a875d1fc6a0a","version":"v7.0.1"},{"repo":"astral-sh/setup-uv","sha":"c771a70e6277c0a99b617c7a806ffedaca235ff9","version":"v9.0.0"},{"repo":"github/gh-aw-actions/setup","sha":"c0338fef4749d08c21f8f975fb0e37efa17dda47","version":"v0.79.8"}],"containers":[{"image":"ghcr.io/github/gh-aw-firewall/agent:0.27.2","digest":"sha256:f88e5b17b6b7a600117bc121114d6ce2155c88c983c0c939c5df884f730fa1d6","pinned_image":"ghcr.io/github/gh-aw-firewall/agent:0.27.2@sha256:f88e5b17b6b7a600117bc121114d6ce2155c88c983c0c939c5df884f730fa1d6"},{"image":"ghcr.io/github/gh-aw-firewall/api-proxy:0.27.2","digest":"sha256:ee39841d980878ebbb87592903b06d31a1af500c71525c9616f7e8e2a27041a4","pinned_image":"ghcr.io/github/gh-aw-firewall/api-proxy:0.27.2@sha256:ee39841d980878ebbb87592903b06d31a1af500c71525c9616f7e8e2a27041a4"},{"image":"ghcr.io/github/gh-aw-firewall/squid:0.27.2","digest":"sha256:2e3a717e5f19a654cd9a2263beb52012b56bcb68562ec5ae2e42f9d156b49591","pinned_image":"ghcr.io/github/gh-aw-firewall/squid:0.27.2@sha256:2e3a717e5f19a654cd9a2263beb52012b56bcb68562ec5ae2e42f9d156b49591"},{"image":"ghcr.io/github/gh-aw-mcpg:v0.3.25","digest":"sha256:c10331ad17668ef89f38f5e356678788a40b0cd5fef96e8f92e1d9c1de47cbaa","pinned_image":"ghcr.io/github/gh-aw-mcpg:v0.3.25@sha256:c10331ad17668ef89f38f5e356678788a40b0cd5fef96e8f92e1d9c1de47cbaa"},{"image":"ghcr.io/github/github-mcp-server:v1.1.2","digest":"sha256:30197479d8036c7811892bc07e06f9a05c9ef3cdd79bc59f256d50647f95788c","pinned_image":"ghcr.io/github/github-mcp-server:v1.1.2@sha256:30197479d8036c7811892bc07e06f9a05c9ef3cdd79bc59f256d50647f95788c"}]} +# gh-aw-metadata: {"schema_version":"v4","frontmatter_hash":"ec50d44af032f2f0c04073858a24d73cb1fa9036515b3bc7ee4dcfe02138f34a","body_hash":"5aa25f2a19d30f31a71fb4fa9c709563d3d2c5060b2984f4ba913b7097158763","compiler_version":"v0.79.8","strict":true,"agent_id":"copilot","engine_versions":{"copilot":"1.0.60"}} +# gh-aw-manifest: {"version":1,"secrets":["COPILOT_GITHUB_TOKEN","GH_AW_GITHUB_MCP_SERVER_TOKEN","GH_AW_GITHUB_TOKEN","GITHUB_TOKEN"],"actions":[{"repo":"actions/checkout","sha":"3d3c42e5aac5ba805825da76410c181273ba90b1","version":"v7.0.1"},{"repo":"actions/download-artifact","sha":"3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c","version":"v8.0.1"},{"repo":"actions/github-script","sha":"3a2844b7e9c422d3c10d287c895573f7108da1b3","version":"v9.0.0"},{"repo":"actions/setup-node","sha":"820762786026740c76f36085b0efc47a31fe5020","version":"v7.0.0"},{"repo":"actions/setup-python","sha":"5fda3b95a4ea91299a34e894583c3862153e4b97","version":"v7.0.0"},{"repo":"actions/upload-artifact","sha":"043fb46d1a93c77aae656e7c1c64a875d1fc6a0a","version":"v7.0.1"},{"repo":"astral-sh/setup-uv","sha":"20cfd1bf945f4377ade1205e4dbc17946fc9a30d","version":"v10.0.1"},{"repo":"github/gh-aw-actions/setup","sha":"c0338fef4749d08c21f8f975fb0e37efa17dda47","version":"v0.79.8"}],"containers":[{"image":"ghcr.io/github/gh-aw-firewall/agent:0.27.2","digest":"sha256:f88e5b17b6b7a600117bc121114d6ce2155c88c983c0c939c5df884f730fa1d6","pinned_image":"ghcr.io/github/gh-aw-firewall/agent:0.27.2@sha256:f88e5b17b6b7a600117bc121114d6ce2155c88c983c0c939c5df884f730fa1d6"},{"image":"ghcr.io/github/gh-aw-firewall/api-proxy:0.27.2","digest":"sha256:ee39841d980878ebbb87592903b06d31a1af500c71525c9616f7e8e2a27041a4","pinned_image":"ghcr.io/github/gh-aw-firewall/api-proxy:0.27.2@sha256:ee39841d980878ebbb87592903b06d31a1af500c71525c9616f7e8e2a27041a4"},{"image":"ghcr.io/github/gh-aw-firewall/squid:0.27.2","digest":"sha256:2e3a717e5f19a654cd9a2263beb52012b56bcb68562ec5ae2e42f9d156b49591","pinned_image":"ghcr.io/github/gh-aw-firewall/squid:0.27.2@sha256:2e3a717e5f19a654cd9a2263beb52012b56bcb68562ec5ae2e42f9d156b49591"},{"image":"ghcr.io/github/gh-aw-mcpg:v0.3.25","digest":"sha256:c10331ad17668ef89f38f5e356678788a40b0cd5fef96e8f92e1d9c1de47cbaa","pinned_image":"ghcr.io/github/gh-aw-mcpg:v0.3.25@sha256:c10331ad17668ef89f38f5e356678788a40b0cd5fef96e8f92e1d9c1de47cbaa"},{"image":"ghcr.io/github/github-mcp-server:v1.1.2","digest":"sha256:30197479d8036c7811892bc07e06f9a05c9ef3cdd79bc59f256d50647f95788c","pinned_image":"ghcr.io/github/github-mcp-server:v1.1.2@sha256:30197479d8036c7811892bc07e06f9a05c9ef3cdd79bc59f256d50647f95788c"}]} # This file was automatically generated by gh-aw (v0.79.8). DO NOT EDIT. To debug this workflow, load the skill at https://github.com/github/gh-aw/blob/main/debug.md # # ___ _ _ @@ -38,7 +38,7 @@ # - actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 # - actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0 # - actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 -# - astral-sh/setup-uv@c771a70e6277c0a99b617c7a806ffedaca235ff9 # v9.0.0 +# - astral-sh/setup-uv@20cfd1bf945f4377ade1205e4dbc17946fc9a30d # v10.0.1 # - github/gh-aw-actions/setup@c0338fef4749d08c21f8f975fb0e37efa17dda47 # v0.79.8 # # Container images used: @@ -438,7 +438,7 @@ jobs: persist-credentials: false fetch-depth: 0 - name: Setup uv - uses: astral-sh/setup-uv@c771a70e6277c0a99b617c7a806ffedaca235ff9 # v9.0.0 + uses: astral-sh/setup-uv@20cfd1bf945f4377ade1205e4dbc17946fc9a30d # v10.0.1 - name: Create gh-aw temp directory run: bash "${RUNNER_TEMP}/gh-aw/actions/create_gh_aw_tmp_dir.sh" - name: Configure gh CLI for GitHub Enterprise diff --git a/.github/workflows/bug-test.md b/.github/workflows/bug-test.md index 87656d7eec..6febb032d3 100644 --- a/.github/workflows/bug-test.md +++ b/.github/workflows/bug-test.md @@ -68,7 +68,7 @@ network: steps: - name: Setup uv - uses: astral-sh/setup-uv@c771a70e6277c0a99b617c7a806ffedaca235ff9 # v9.0.0 + uses: astral-sh/setup-uv@20cfd1bf945f4377ade1205e4dbc17946fc9a30d # v10.0.1 - name: Set up Python uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0 with: diff --git a/.github/workflows/feature-assess.lock.yml b/.github/workflows/feature-assess.lock.yml index 198b50f107..d8c5cab2d9 100644 --- a/.github/workflows/feature-assess.lock.yml +++ b/.github/workflows/feature-assess.lock.yml @@ -1,5 +1,5 @@ -# gh-aw-metadata: {"schema_version":"v4","frontmatter_hash":"d0588e989403a51f8849be4ac0ceb184d3a30f1c2e6860f8dc65fd5728592946","body_hash":"6d78e8c183819f6f12a07f0c9cb28a83cc2471ac20c6df6999e503a0d731da4b","compiler_version":"v0.79.8","strict":true,"agent_id":"copilot","engine_versions":{"copilot":"1.0.60"}} -# gh-aw-manifest: {"version":1,"secrets":["COPILOT_GITHUB_TOKEN","GH_AW_GITHUB_MCP_SERVER_TOKEN","GH_AW_GITHUB_TOKEN","GITHUB_TOKEN"],"actions":[{"repo":"actions/checkout","sha":"df4cb1c069e1874edd31b4311f1884172cec0e10","version":"v6.0.3"},{"repo":"actions/download-artifact","sha":"3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c","version":"v8.0.1"},{"repo":"actions/github-script","sha":"3a2844b7e9c422d3c10d287c895573f7108da1b3","version":"v9.0.0"},{"repo":"actions/setup-node","sha":"48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e","version":"v6.4.0"},{"repo":"actions/setup-python","sha":"5fda3b95a4ea91299a34e894583c3862153e4b97","version":"5fda3b95a4ea91299a34e894583c3862153e4b97"},{"repo":"actions/upload-artifact","sha":"043fb46d1a93c77aae656e7c1c64a875d1fc6a0a","version":"v7.0.1"},{"repo":"astral-sh/setup-uv","sha":"c771a70e6277c0a99b617c7a806ffedaca235ff9","version":"c771a70e6277c0a99b617c7a806ffedaca235ff9"},{"repo":"github/gh-aw-actions/setup","sha":"c0338fef4749d08c21f8f975fb0e37efa17dda47","version":"v0.79.8"}],"containers":[{"image":"ghcr.io/github/gh-aw-firewall/agent:0.27.2","digest":"sha256:f88e5b17b6b7a600117bc121114d6ce2155c88c983c0c939c5df884f730fa1d6","pinned_image":"ghcr.io/github/gh-aw-firewall/agent:0.27.2@sha256:f88e5b17b6b7a600117bc121114d6ce2155c88c983c0c939c5df884f730fa1d6"},{"image":"ghcr.io/github/gh-aw-firewall/api-proxy:0.27.2","digest":"sha256:ee39841d980878ebbb87592903b06d31a1af500c71525c9616f7e8e2a27041a4","pinned_image":"ghcr.io/github/gh-aw-firewall/api-proxy:0.27.2@sha256:ee39841d980878ebbb87592903b06d31a1af500c71525c9616f7e8e2a27041a4"},{"image":"ghcr.io/github/gh-aw-firewall/squid:0.27.2","digest":"sha256:2e3a717e5f19a654cd9a2263beb52012b56bcb68562ec5ae2e42f9d156b49591","pinned_image":"ghcr.io/github/gh-aw-firewall/squid:0.27.2@sha256:2e3a717e5f19a654cd9a2263beb52012b56bcb68562ec5ae2e42f9d156b49591"},{"image":"ghcr.io/github/gh-aw-mcpg:v0.3.25","digest":"sha256:c10331ad17668ef89f38f5e356678788a40b0cd5fef96e8f92e1d9c1de47cbaa","pinned_image":"ghcr.io/github/gh-aw-mcpg:v0.3.25@sha256:c10331ad17668ef89f38f5e356678788a40b0cd5fef96e8f92e1d9c1de47cbaa"},{"image":"ghcr.io/github/github-mcp-server:v1.1.2","digest":"sha256:30197479d8036c7811892bc07e06f9a05c9ef3cdd79bc59f256d50647f95788c","pinned_image":"ghcr.io/github/github-mcp-server:v1.1.2@sha256:30197479d8036c7811892bc07e06f9a05c9ef3cdd79bc59f256d50647f95788c"}]} +# gh-aw-metadata: {"schema_version":"v4","frontmatter_hash":"669e5f4d2956792cf5b7a2dfbbda10e7ef26f25fc8283f5b3db1cc838f05d940","body_hash":"6d78e8c183819f6f12a07f0c9cb28a83cc2471ac20c6df6999e503a0d731da4b","compiler_version":"v0.79.8","strict":true,"agent_id":"copilot","engine_versions":{"copilot":"1.0.60"}} +# gh-aw-manifest: {"version":1,"secrets":["COPILOT_GITHUB_TOKEN","GH_AW_GITHUB_MCP_SERVER_TOKEN","GH_AW_GITHUB_TOKEN","GITHUB_TOKEN"],"actions":[{"repo":"actions/checkout","sha":"df4cb1c069e1874edd31b4311f1884172cec0e10","version":"v6.0.3"},{"repo":"actions/download-artifact","sha":"3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c","version":"v8.0.1"},{"repo":"actions/github-script","sha":"3a2844b7e9c422d3c10d287c895573f7108da1b3","version":"v9.0.0"},{"repo":"actions/setup-node","sha":"48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e","version":"v6.4.0"},{"repo":"actions/setup-python","sha":"5fda3b95a4ea91299a34e894583c3862153e4b97","version":"5fda3b95a4ea91299a34e894583c3862153e4b97"},{"repo":"actions/upload-artifact","sha":"043fb46d1a93c77aae656e7c1c64a875d1fc6a0a","version":"v7.0.1"},{"repo":"astral-sh/setup-uv","sha":"20cfd1bf945f4377ade1205e4dbc17946fc9a30d","version":"v10.0.1"},{"repo":"github/gh-aw-actions/setup","sha":"c0338fef4749d08c21f8f975fb0e37efa17dda47","version":"v0.79.8"}],"containers":[{"image":"ghcr.io/github/gh-aw-firewall/agent:0.27.2","digest":"sha256:f88e5b17b6b7a600117bc121114d6ce2155c88c983c0c939c5df884f730fa1d6","pinned_image":"ghcr.io/github/gh-aw-firewall/agent:0.27.2@sha256:f88e5b17b6b7a600117bc121114d6ce2155c88c983c0c939c5df884f730fa1d6"},{"image":"ghcr.io/github/gh-aw-firewall/api-proxy:0.27.2","digest":"sha256:ee39841d980878ebbb87592903b06d31a1af500c71525c9616f7e8e2a27041a4","pinned_image":"ghcr.io/github/gh-aw-firewall/api-proxy:0.27.2@sha256:ee39841d980878ebbb87592903b06d31a1af500c71525c9616f7e8e2a27041a4"},{"image":"ghcr.io/github/gh-aw-firewall/squid:0.27.2","digest":"sha256:2e3a717e5f19a654cd9a2263beb52012b56bcb68562ec5ae2e42f9d156b49591","pinned_image":"ghcr.io/github/gh-aw-firewall/squid:0.27.2@sha256:2e3a717e5f19a654cd9a2263beb52012b56bcb68562ec5ae2e42f9d156b49591"},{"image":"ghcr.io/github/gh-aw-mcpg:v0.3.25","digest":"sha256:c10331ad17668ef89f38f5e356678788a40b0cd5fef96e8f92e1d9c1de47cbaa","pinned_image":"ghcr.io/github/gh-aw-mcpg:v0.3.25@sha256:c10331ad17668ef89f38f5e356678788a40b0cd5fef96e8f92e1d9c1de47cbaa"},{"image":"ghcr.io/github/github-mcp-server:v1.1.2","digest":"sha256:30197479d8036c7811892bc07e06f9a05c9ef3cdd79bc59f256d50647f95788c","pinned_image":"ghcr.io/github/github-mcp-server:v1.1.2@sha256:30197479d8036c7811892bc07e06f9a05c9ef3cdd79bc59f256d50647f95788c"}]} # This file was automatically generated by gh-aw (v0.79.8). DO NOT EDIT. To debug this workflow, load the skill at https://github.com/github/gh-aw/blob/main/debug.md # # ___ _ _ @@ -38,7 +38,7 @@ # - actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 # - actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # 5fda3b95a4ea91299a34e894583c3862153e4b97 # - actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 -# - astral-sh/setup-uv@c771a70e6277c0a99b617c7a806ffedaca235ff9 # c771a70e6277c0a99b617c7a806ffedaca235ff9 +# - astral-sh/setup-uv@20cfd1bf945f4377ade1205e4dbc17946fc9a30d # v10.0.1 # - github/gh-aw-actions/setup@c0338fef4749d08c21f8f975fb0e37efa17dda47 # v0.79.8 # # Container images used: @@ -437,7 +437,7 @@ jobs: persist-credentials: false fetch-depth: 0 - name: Setup uv - uses: astral-sh/setup-uv@c771a70e6277c0a99b617c7a806ffedaca235ff9 # c771a70e6277c0a99b617c7a806ffedaca235ff9 + uses: astral-sh/setup-uv@20cfd1bf945f4377ade1205e4dbc17946fc9a30d # v10.0.1 - name: Create gh-aw temp directory run: bash "${RUNNER_TEMP}/gh-aw/actions/create_gh_aw_tmp_dir.sh" - name: Configure gh CLI for GitHub Enterprise diff --git a/.github/workflows/feature-assess.md b/.github/workflows/feature-assess.md index 4381d44136..4f2dbff5f8 100644 --- a/.github/workflows/feature-assess.md +++ b/.github/workflows/feature-assess.md @@ -39,7 +39,7 @@ checkout: steps: - name: Setup uv continue-on-error: true - uses: astral-sh/setup-uv@c771a70e6277c0a99b617c7a806ffedaca235ff9 # v9.0.0 + uses: astral-sh/setup-uv@20cfd1bf945f4377ade1205e4dbc17946fc9a30d # v10.0.1 - name: Set up Python continue-on-error: true uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0 diff --git a/.github/workflows/publish-pypi.yml b/.github/workflows/publish-pypi.yml index ce6185ea6c..f0fb8283be 100644 --- a/.github/workflows/publish-pypi.yml +++ b/.github/workflows/publish-pypi.yml @@ -32,7 +32,7 @@ jobs: ref: refs/tags/${{ inputs.tag }} - name: Install uv - uses: astral-sh/setup-uv@c771a70e6277c0a99b617c7a806ffedaca235ff9 # v9.0.0 + uses: astral-sh/setup-uv@20cfd1bf945f4377ade1205e4dbc17946fc9a30d # v10.0.1 - name: Set up Python uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0 @@ -74,7 +74,7 @@ jobs: path: dist/ - name: Install uv - uses: astral-sh/setup-uv@c771a70e6277c0a99b617c7a806ffedaca235ff9 # v9.0.0 + uses: astral-sh/setup-uv@20cfd1bf945f4377ade1205e4dbc17946fc9a30d # v10.0.1 - name: Publish to PyPI run: uv publish diff --git a/.github/workflows/security.yml b/.github/workflows/security.yml index ed9f6606ed..8c5a5eb72a 100644 --- a/.github/workflows/security.yml +++ b/.github/workflows/security.yml @@ -24,7 +24,7 @@ jobs: fetch-depth: 0 - name: Install uv - uses: astral-sh/setup-uv@c771a70e6277c0a99b617c7a806ffedaca235ff9 # v9.0.0 + uses: astral-sh/setup-uv@20cfd1bf945f4377ade1205e4dbc17946fc9a30d # v10.0.1 - name: Set up Python uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0 @@ -55,7 +55,7 @@ jobs: uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - name: Install uv - uses: astral-sh/setup-uv@c771a70e6277c0a99b617c7a806ffedaca235ff9 # v9.0.0 + uses: astral-sh/setup-uv@20cfd1bf945f4377ade1205e4dbc17946fc9a30d # v10.0.1 - name: Set up Python ${{ matrix.python-version }} uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0 diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 1d4399cb23..dceb97c6e5 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -16,7 +16,7 @@ jobs: uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - name: Install uv - uses: astral-sh/setup-uv@c771a70e6277c0a99b617c7a806ffedaca235ff9 # v9.0.0 + uses: astral-sh/setup-uv@20cfd1bf945f4377ade1205e4dbc17946fc9a30d # v10.0.1 - name: Set up Python uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0 @@ -37,7 +37,7 @@ jobs: uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - name: Install uv - uses: astral-sh/setup-uv@c771a70e6277c0a99b617c7a806ffedaca235ff9 # v9.0.0 + uses: astral-sh/setup-uv@20cfd1bf945f4377ade1205e4dbc17946fc9a30d # v10.0.1 - name: Set up Python ${{ matrix.python-version }} uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0 diff --git a/tests/test_github_workflows.py b/tests/test_github_workflows.py index aeb8ad7e21..952736ef2f 100644 --- a/tests/test_github_workflows.py +++ b/tests/test_github_workflows.py @@ -144,7 +144,7 @@ def test_bug_test_workflow_provisions_python_dependencies(): compiled_text = compiled.read_text(encoding="utf-8") setup_uv = ( - "astral-sh/setup-uv@c771a70e6277c0a99b617c7a806ffedaca235ff9 # v9.0.0" + "astral-sh/setup-uv@20cfd1bf945f4377ade1205e4dbc17946fc9a30d # v10.0.1" ) setup_python = ( "actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0" From 3f773571077a1632a2593d22209f171f557bcec9 Mon Sep 17 00:00:00 2001 From: Manfred Riem <15701806+mnriem@users.noreply.github.com> Date: Fri, 21 Aug 2026 12:56:40 -0500 Subject: [PATCH 035/102] docs: add workflow quickstarts (#4258) * docs: add workflow quickstarts Add concise setup and command recipes for SDD, structured bug fixing, and standalone idea assessment. Assisted-by: GitHub Copilot (model: GPT-5.6 Sol, autonomous) Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: af05cfd0-746d-4292-9380-0a785a991cba * docs: clarify quickstart release tags Tell readers to replace the placeholder in every standalone quickstart with the latest tagged release. Assisted-by: GitHub Copilot (model: GPT-5.6 Sol, autonomous) Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: af05cfd0-746d-4292-9380-0a785a991cba --------- Copilot-Session: af05cfd0-746d-4292-9380-0a785a991cba --- README.md | 71 +++++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 71 insertions(+) diff --git a/README.md b/README.md index de92639cec..c4418bf039 100644 --- a/README.md +++ b/README.md @@ -25,6 +25,8 @@ ## Table of Contents - [🤔 What is Spec-Driven Development?](#-what-is-spec-driven-development) +- [🐞 Bug Fixing with Spec Kit](#-bug-fixing-with-spec-kit) +- [💡 Assessing Ideas with Spec Kit](#-assessing-ideas-with-spec-kit) - [⚡ Get Started](#-get-started) - [📽️ Video Overview](#️-video-overview) - [🌍 Community](#-community) @@ -45,6 +47,75 @@ Spec-Driven Development **flips the script** on traditional software development. For decades, code has been king — specifications were just scaffolding we built and discarded once the "real work" of coding began. Spec-Driven Development changes this: **specifications become executable**, directly generating working implementations rather than just guiding them. +### SDD Quickstart + +Replace `vX.Y.Z` with the [latest release tag](https://github.com/github/spec-kit/releases), keeping the leading `v`. + +```bash +uv tool install specify-cli --from git+https://github.com/github/spec-kit.git@vX.Y.Z +specify init my-project --integration copilot +cd my-project +``` + +Launch your coding agent in the project directory, then: + +0. **Establish** your project principles once (`/speckit-constitution`). This is a one-time step per project. +1. **Specify** what you want to build (`/speckit-specify`). +2. **Plan** how you will build it (`/speckit-plan`). +3. **Break down** the plan into actionable tasks (`/speckit-tasks`). +4. **Implement** the tasks (`/speckit-implement`). +5. **Converge** the implementation against the spec, plan, and tasks (`/speckit-converge`). + +> [!NOTE] +> Repeat steps 4 and 5 until `/speckit-converge` reports **Converged**. + +## 🐞 Bug Fixing with Spec Kit + +Bug fixes are risky when an agent jumps straight from a report to a patch without validating the diagnosis or confirming that the fix resolves the original symptom. The bundled, opt-in bug extension provides a repeatable **assess → fix → test** workflow that keeps each fix scoped, evidence-based, and documented from root cause through verification. + +### Bug Fix Quickstart + +Replace `vX.Y.Z` with the [latest release tag](https://github.com/github/spec-kit/releases), keeping the leading `v`. + +```bash +uv tool install specify-cli --from git+https://github.com/github/spec-kit.git@vX.Y.Z +specify init my-project --integration copilot +cd my-project +specify extension add bug +``` + +Launch your coding agent in the project directory, then: + +1. **Assess** the bug (`/speckit-bug-assess "" slug=login-crash`). +2. **Fix** the assessed cause (`/speckit-bug-fix slug=login-crash`). +3. **Test** the fix (`/speckit-bug-test slug=login-crash`). + +## 💡 Assessing Ideas with Spec Kit + +Good ideas deserve evidence before commitment, whether or not they become software. The bundled, opt-in assess extension turns a raw idea into a documented **go / needs-clarification / kill** decision through an independent **intake → research → define → shape → decide** workflow. + +### Idea Assessment Quickstart + +Replace `vX.Y.Z` with the [latest release tag](https://github.com/github/spec-kit/releases), keeping the leading `v`. + +```bash +uv tool install specify-cli --from git+https://github.com/github/spec-kit.git@vX.Y.Z +specify init my-project --integration copilot +cd my-project +specify extension add assess +``` + +Launch your coding agent in the project directory, then: + +1. **Intake** the idea (`/speckit-assess-intake "" slug=offline-mode`). +2. **Research** supporting and opposing evidence (`/speckit-assess-research slug=offline-mode`). +3. **Define** the problem, goals, and success metrics (`/speckit-assess-define slug=offline-mode`). +4. **Shape** possible solutions and their trade-offs (`/speckit-assess-shape slug=offline-mode`). +5. **Decide** whether to proceed, clarify, or stop (`/speckit-assess-decide slug=offline-mode`). + +> [!NOTE] +> Idea assessment is standalone. If you choose to build an idea with a **go** decision, you can hand it off to `/speckit-specify`. + ## ⚡ Get Started ### 1. Install Specify CLI From 8b29f37114f70e63e3dadee8ffff78676c961a47 Mon Sep 17 00:00:00 2001 From: Manfred Riem <15701806+mnriem@users.noreply.github.com> Date: Fri, 21 Aug 2026 13:11:08 -0500 Subject: [PATCH 036/102] docs: mark Spec Kit's first anniversary (#4260) Assisted-by: GitHub Copilot (model: GPT-5.6 Sol, autonomous) Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: 0a6b3d69-9459-4a26-a2f6-4d946e368c81 --- README.md | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/README.md b/README.md index c4418bf039..b9b3243520 100644 --- a/README.md +++ b/README.md @@ -20,6 +20,15 @@ 简体中文

+> [!NOTE] +> **One year of Spec Kit — and 1.0.0** +> +> One year after the first commit, Spec Kit has reached [1.0.0](https://github.com/github/spec-kit/releases/tag/v1.0.0) — not because the work is finished or its shape is frozen, but because the project has grown into something coherent, useful, and shaped by far more people than those who started it. +> +> The lead maintainer's personal anniversary post, [*Spec Kit Turns One — and Ships 1.0.0*](https://www.manorrock.com/blog/2026/08/21/spec_kit_turns_one.html), defines what 1.0.0 actually means for the project: **it is now just a number**. As agents make adapting to change dramatically cheaper, the value moves from stability to adaptability. +> +> To everyone who has used Spec Kit, challenged its assumptions, reported a problem, contributed code or documentation, created an extension or preset, shared an idea, or helped someone else get started: **thank you**. This milestone belongs to the community that carried the project through its first year and continues to shape where it goes next. + --- ## Table of Contents From 9a2c2650a581a733015399c2e126e42fd3f125cc Mon Sep 17 00:00:00 2001 From: Manfred Riem <15701806+mnriem@users.noreply.github.com> Date: Fri, 21 Aug 2026 14:16:04 -0500 Subject: [PATCH 037/102] docs: add project history page (#4262) * docs: add project history page Document Spec Kit's stewardship periods, major technical milestones, community catalogs, and evolution from core SDD processes to a composable toolkit. Assisted-by: GitHub Copilot (model: GPT-5.6 Sol, autonomous) Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: 46d71f0b-59fc-4bdb-a57e-620210197597 * docs: clarify stewardship wording Use the possessive form to make clear that the focus belongs to the maintainer team. Assisted-by: GitHub Copilot (model: GPT-5.6 Sol, autonomous) Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: 46d71f0b-59fc-4bdb-a57e-620210197597 --------- Copilot-Session: 46d71f0b-59fc-4bdb-a57e-620210197597 --- docs/history.md | 173 ++++++++++++++++++++++++++++++++++++++++++++++++ docs/index.md | 4 ++ docs/toc.yml | 6 ++ 3 files changed, 183 insertions(+) create mode 100644 docs/history.md diff --git a/docs/history.md b/docs/history.md new file mode 100644 index 0000000000..b3aee62909 --- /dev/null +++ b/docs/history.md @@ -0,0 +1,173 @@ +# History + +Spec Kit began as a toolkit for making specifications the starting point of +AI-assisted development. From its +[first full check-in](https://github.com/github/spec-kit/commit/28fdfaa86973d4402eecd89ba6c87d31e1edae03), +it described three ways to apply Spec-Driven Development: + +- **0-to-1 Development ("Greenfield")** generates a new system from + requirements. +- **Creative Exploration** compares parallel implementations, technology + choices, and experience designs. +- **Iterative Enhancement ("Brownfield")** adds features to and modernizes + existing systems. + +All three moved from durable planning artifacts into implementation: + +**Specify → Plan → Tasks → Implement** + +Those development paths and that core sequence remain, but the project has +grown into an extensible harness for coding agents, software delivery +processes, and other structured work. + +## Project stewardship + +Spec Kit's history includes two distinct stewardship periods. Recording them +here preserves the contemporary account of the project's leadership without +reducing the work to any one person. + +### Founding stewardship: August 2025–January 2026 + +[Den Delimarsky](https://github.com/localden) and +[John Lam](https://github.com/jflam) conceived Spec Kit and gave the project its +first shape. Den authored the +[initial commit](https://github.com/github/spec-kit/commit/fa2736371e077f55c4fe145fea186bab2561386d) on +August 21, 2025 and led the repository through its first months. + +That founding period established the shape users still recognize: the Specify +CLI, coding-agent-specific scaffolding, project constitutions, and the +specification → plan → tasks → implementation process. It also framed SDD as +useful for greenfield development, parallel exploration, and brownfield +enhancement rather than tying the method to a single agent or development +scenario. + +### Community stewardship: January 2026–present + +[Manfred Riem](https://github.com/mnriem) took over as lead maintainer on +January 22, 2026. The transition became publicly visible when the repository's +global [`CODEOWNERS` entry](https://github.com/github/spec-kit/commit/3040d33c31d8a26d50f91aec5d62d1cecac3298c) +changed to `@mnriem` on February 23. + +During this stewardship, the maintainer team's focus moved from building a +composable model to using it to ship complete first-party processes. That shift +was not sequential for the community: the modular extension system began as a +community contribution, and contributors adopted and extended each primitive +as it arrived. + +These dates and roles are also documented in the lead maintainer's +[six-month retrospective](https://www.manorrock.com/blog/2026/07/22/six_months_leading_spec_kit.html) +and +[first-anniversary account](https://www.manorrock.com/blog/2026/08/21/spec_kit_turns_one.html), +and are consistent with the repository's commit and ownership history. + +## Milestones + +### August 2025: The foundation + +The repository history begins on August 21, 2025. The first releases established +the Specify CLI, reusable templates, and the core Spec-Driven Development +paths. Support for multiple coding agents through centrally configured, +agent-specific scaffolding was part of the project from the start, keeping the +process independent of any one model or tool. + +### February–April 2026: Building the primitives + +The modular extension system arrived in February as a community contribution +from Michal Bachorik, allowing capabilities to be added without expanding the +core process. March brought pluggable presets, which made templates and +commands replaceable or composable while preserving the same CLI experience. + +The founding-era agent scaffolding was rewritten as a registry-backed +integration architecture. Core assets were also embedded in the Python package, +enabling reliable offline and air-gapped initialization. + +The workflow engine introduced catalog-distributed automation and built-in +workflow step types in April. Workflows could coordinate reusable steps rather +than requiring users to invoke every command manually. An integration catalog +followed, making coding-agent support discoverable and independently +distributable. + +The composable model came to be described through five primitives: + +- **Integrations** connect Spec Kit to coding agents. +- **Extensions** add capabilities, commands, templates, scripts, and hooks. +- **Presets** customize or replace behavior. +- **Workflows** automate multi-step processes. +- **Workflow steps** provide reusable units of workflow behavior. + +The emphasis during these first months was on creating reusable machinery: +making the process configurable, distributable, and automatable before adding +more first-party processes. Community contributors did not wait for the full +model to be complete; they quickly used the new extension and preset surfaces +to publish their own capabilities and process variations. + +### June–July 2026: Composing and applying the primitives + +For the core team, June marked the turn from mainly building primitives to using +them. A workflow step catalog made custom step types community-installable, +extending a primitive that had shipped with the workflow engine in April. +Bundles then made it possible to package extensions, presets, workflows, and +steps as a coherent setup for a role or team, optionally targeting a specific +integration. + +Catalogs became the bridge between the primitives and the community. Community +authors built extensions, presets, integrations, workflows, step types, and +bundles; the maintainer team checked submission metadata and listed accepted +entries in community catalogs so users could discover and install them. A +catalog listing made a component visible, but did not mean its code had been +audited or endorsed. + +At the same time, core maintainers began using the model to add two first-party +processes alongside feature delivery: + +- On June 5, version 0.9.5 introduced the bundled, opt-in + [`bug` extension](https://github.com/github/spec-kit/commit/60302fefec541a68fcac6f0428a95ba35f2acadf). + Its assess → fix → test process keeps bug diagnosis, remediation, and + verification separate and documented. +- On July 17, version 0.13.0 introduced the bundled, opt-in + [`assess` extension](https://github.com/github/spec-kit/commit/208d38695fc88d8eaec7855c96e5098a852927cf). + Its intake → research → define → shape → decide process evaluates an idea + before it enters SDD. + +Distribution broadened too: the release pipeline added PyPI publishing, and +Python joined Bash and PowerShell as a supported project script type. These +changes made installation and cross-platform use simpler while preserving +support for offline and enterprise environments. + +### August 2026: First anniversary + +Spec Kit turned one and released version 1.0.0 on August 21, 2026. By then, its +five primitives — integrations, extensions, presets, workflows, and workflow +steps — already formed a coherent model. Bundles composed extensions, presets, +workflows, and steps around a selected integration. A README refresh made the +existing SDD, bug-fixing, and idea-assessment processes easier to discover +through separate quickstarts. + +Version 1.0.0 did not create or freeze that model; it gave the project's +evolving state a round number. The documentation then reported 38 coding-agent +integrations, 157 community extensions, 33 presets, and 270+ contributors. Spec +Kit continues to favor adaptability: processes, integrations, and conventions +can evolve while agents help projects apply those changes. + +## Enduring themes + +Several themes connect the project's stewardship periods and technical +evolution: + +- **Intent comes before implementation.** Specifications capture what should be + built before technical decisions dominate the work. +- **Artifacts should be durable.** Specs, plans, and tasks remain useful beyond + a single prompt or agent session. +- **The process should be agent-independent.** Teams can change coding agents + without abandoning their development method. +- **The method should adapt to the work.** The original development paths grew + into a formally composable model that teams can modify, automate, or replace. +- **The community shapes the kit.** Community contributions have influenced + both the project's infrastructure and the ecosystem built on it. + +## Release history + +This page records the project's broad evolution, not every feature or breaking +change. For release-level detail, see the +[changelog](https://github.com/github/spec-kit/blob/main/CHANGELOG.md) and +[GitHub Releases](https://github.com/github/spec-kit/releases). diff --git a/docs/index.md b/docs/index.md index 93857ba0e8..d1007aed85 100644 --- a/docs/index.md +++ b/docs/index.md @@ -140,6 +140,10 @@ Community extensions like CI Guard and Architecture Guard add compliance gates a What is SDD? The philosophy behind Spec-Driven Development + + History + How Spec Kit grew from its SDD foundation into an extensible process harness + --- diff --git a/docs/toc.yml b/docs/toc.yml index a2e07b270c..d6996640dc 100644 --- a/docs/toc.yml +++ b/docs/toc.yml @@ -2,6 +2,12 @@ - name: Home href: index.md +# About +- name: About + items: + - name: History + href: history.md + # Getting started section - name: Getting Started items: From 214e5104b64184daf8a0b72e295ff2da47e53ca0 Mon Sep 17 00:00:00 2001 From: Manfred Riem <15701806+mnriem@users.noreply.github.com> Date: Fri, 21 Aug 2026 14:21:29 -0500 Subject: [PATCH 038/102] docs: add existing project adoption guide (#4263) Add a safe brownfield onboarding path and connect it to the docs homepage, quick start, navigation, and spec maintenance guidance. Assisted-by: GitHub Copilot (model: GPT-5.6 Sol, autonomous) Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: 663b4e07-d79f-4bd1-aa86-8aeea21a2643 --- docs/guides/evolving-specs.md | 4 ++ docs/guides/existing-projects.md | 106 +++++++++++++++++++++++++++++++ docs/index.md | 4 ++ docs/quickstart.md | 3 + docs/toc.yml | 2 + 5 files changed, 119 insertions(+) create mode 100644 docs/guides/existing-projects.md diff --git a/docs/guides/evolving-specs.md b/docs/guides/evolving-specs.md index e2941f08b3..17a91298ea 100644 --- a/docs/guides/evolving-specs.md +++ b/docs/guides/evolving-specs.md @@ -1,5 +1,9 @@ # Evolving Specs in Existing Projects +If the repository has not been initialized with Spec Kit yet, start with +[Adopting Spec Kit in an Existing Project](existing-projects.md). This page +covers how to maintain artifacts after adoption. + Existing projects need two separate maintenance loops: - **Spec Kit project-file updates** refresh managed commands, scripts, diff --git a/docs/guides/existing-projects.md b/docs/guides/existing-projects.md new file mode 100644 index 0000000000..479715546e --- /dev/null +++ b/docs/guides/existing-projects.md @@ -0,0 +1,106 @@ +# Adopting Spec Kit in an Existing Project + +You do not need to recreate an existing system from specifications before using +Spec Kit. Initialize the repository in place, capture the rules that matter, +and use the workflow for the next bounded change. + +## 1. Start from a Reviewable Baseline + +Before initialization, commit or stash existing work and create a branch for the +adoption. This makes every generated file visible in a normal code review. + +Choose the [integration key](../reference/integrations.md) for the coding agent +you use. Then run the command from the repository root: + +```bash +specify init --here --force --integration +``` + +`--here` targets the current directory. `--force` allows initialization in a +non-empty directory and may replace files at conflicting managed paths, so use +it only after creating a reviewable baseline. It does not delete the rest of +your application. + +Review the resulting diff before continuing. Initialization adds the shared +`.specify/` project files and the command or skill files required by your +selected integration. It does not rewrite your application or infer +specifications for existing behavior. + +> [!NOTE] +> Git initialization and feature branches are optional and are managed by the +> **git** extension. Add it with `specify extension add git` if you want that +> workflow. + +## 2. Capture Project Guardrails + +Run `/speckit.constitution` with principles that are already true for the +repository or that the team has explicitly agreed to adopt: + +```text +/speckit.constitution Preserve public API compatibility. Follow the existing +service boundaries. Every database migration must include a rollback plan. +Run the repository's established unit and integration test suites. +``` + +Use the repository's README, architecture decisions, contribution guide, and +CI configuration as evidence. Do not invent standards merely to fill the +constitution template. The constitution governs later planning and analysis, +so unrealistic rules create noise instead of useful constraints. + +## 3. Choose a Bounded First Change + +Start with a feature, bug fix, or modernization slice that can be reviewed +independently. Do not make "document the entire existing system" your first +feature unless that inventory is itself the intended deliverable. + +Describe both the requested outcome and the compatibility boundaries that must +remain intact: + +```text +/speckit.specify Add CSV export to the existing orders page. Preserve current +filters and authorization behavior. Export only the rows visible to the signed-in +user, and do not change the existing JSON API response. +``` + +The codebase remains implementation context. The new `spec.md` defines the +change you intend to make, not a retroactive specification of every existing +behavior. + +## 4. Plan Against the Repository + +Continue through the normal workflow: + +1. Run `/speckit.clarify` to resolve uncertain behavior and compatibility + requirements. +2. Run `/speckit.plan` and verify that the proposed design reuses the existing + architecture, dependencies, and test conventions. +3. Run `/speckit.tasks`, then `/speckit.analyze` to check consistency before + implementation. +4. Run `/speckit.implement` and review code and artifact changes together. +5. Run `/speckit.converge` to find remaining gaps. If it adds tasks, repeat + implementation and convergence until the feature is complete. + +For command details and optional quality gates, see the +[Quick Start Guide](../quickstart.md) and +[Agentic SDD reference](../reference/agentic-sdd.md). + +## 5. Decide How Specs Will Age + +After the first change, agree on how the team will maintain completed feature +artifacts: + +- Keep each feature directory as an immutable historical record. +- Maintain `spec.md` as a living contract and regenerate downstream artifacts. +- Allow discoveries to flow back from code, tasks, or plans, then reconcile the + full artifact set. + +The [Spec Persistence Models](../concepts/spec-persistence.md) page compares +these choices. The [Evolving Specs guide](evolving-specs.md) provides the +maintenance loop for each model. + +## Existing-Project Examples + +The [community walkthroughs](../community/walkthroughs.md) include brownfield +examples across .NET, Java, and Go/React codebases. Community extensions for +architecture discovery and brownfield bootstrapping are listed in the +[extension catalog](../community/extensions.md). diff --git a/docs/index.md b/docs/index.md index d1007aed85..5e8b25f861 100644 --- a/docs/index.md +++ b/docs/index.md @@ -124,6 +124,10 @@ Community extensions like CI Guard and Architecture Guard add compliance gates a Getting Started Install, configure, and run your first SDD workflow + + Existing Projects + Adopt Spec Kit safely in an established codebase + Reference Core commands, integrations, extensions, presets, and workflows diff --git a/docs/quickstart.md b/docs/quickstart.md index 2813118b5f..fb2ecc2f74 100644 --- a/docs/quickstart.md +++ b/docs/quickstart.md @@ -47,6 +47,9 @@ specify init taskify # or: specify init . to use the current directory > [!NOTE] > Prefer `pipx`, one-time `uvx` runs, a pinned release, or an offline/air-gapped setup? See the [Installation Guide](installation.md) for all supported methods. +> Adding Spec Kit to a repository that already contains code? Follow +> [Adopting Spec Kit in an Existing Project](guides/existing-projects.md) before +> starting the workflow below. ### Step 1: `/speckit.constitution` — set the ground rules diff --git a/docs/toc.yml b/docs/toc.yml index d6996640dc..7548ba95f6 100644 --- a/docs/toc.yml +++ b/docs/toc.yml @@ -15,6 +15,8 @@ href: installation.md - name: Quick Start href: quickstart.md + - name: Existing Projects + href: guides/existing-projects.md - name: Upgrade href: upgrade.md - name: Install uv From 99b5c7c533851660c2eb9224bdfd58170348b37a Mon Sep 17 00:00:00 2001 From: Manfred Riem <15701806+mnriem@users.noreply.github.com> Date: Fri, 21 Aug 2026 14:48:59 -0500 Subject: [PATCH 039/102] docs: use Spec Kit branding on documentation site (#4264) Use the README logo for the DocFX navbar, favicon, and landing hero, and add Upgrade to the balanced Explore the docs grid. Assisted-by: GitHub Copilot (model: GPT-5.6 Sol, autonomous) Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: 78ca683f-2995-44eb-a2fa-f7600c18bbd9 --- docs/docfx.json | 2 ++ docs/images/spec-kit-logo.webp | Bin 0 -> 46884 bytes docs/index.md | 6 ++++++ docs/template/public/main.css | 14 ++++++++++++++ 4 files changed, 22 insertions(+) create mode 100644 docs/images/spec-kit-logo.webp diff --git a/docs/docfx.json b/docs/docfx.json index e22b394ba0..77a7653dd7 100644 --- a/docs/docfx.json +++ b/docs/docfx.json @@ -64,6 +64,8 @@ "globalMetadata": { "_appTitle": "Spec Kit Documentation", "_appName": "Spec Kit", + "_appLogoPath": "images/spec-kit-logo.webp", + "_appFaviconPath": "images/spec-kit-logo.webp", "_appFooter": "Spec Kit - A specification-driven development toolkit", "_enableSearch": true, "_disableContribution": false, diff --git a/docs/images/spec-kit-logo.webp b/docs/images/spec-kit-logo.webp new file mode 100644 index 0000000000000000000000000000000000000000..209e3deeff1776f4b66ca7edf850fb24025afd13 GIT binary patch literal 46884 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
%cquH zWGOqhn<1co#TUvJJBDXxV~HipKV~<@pl7i_m|daEkB5i04_kCfsxmPikA9 z)tIJ`{O7eVaZ(s6dzZW9LG_cTmJdk(W;OmZa zIvPU;cE8B?;WYoSV@1TMgK31RulD_=*z$-8Y5E1dY36~Y4-i5xYY zmyjrg4{_NPG5`Potdbvw%N!di<_Bk=;?T-h&N%o&wPlwRug&t@%YGzN|4SBExo31@ zgXaek4>Tq|<#peU+e+{OeO;*YPfz#W^1FSxU>F3>G}UdsE&8w1^WBA3{9|8bsm5Io zUf&-^PnMP_wJua5@Pt7(vQtSGR12iqH62So+DwGWPi&>?It{!B-3|`GFeJj?eDZwV z5e*7`^cDxc{*$ep-?7Z}?J|2bq2F7vsS|NEr$a6>Rv5bm;kySlKIvALkJqcWi7YBP zf`A4aEYx8@0MGx$2RA)s@)(`Ux8`vydQ>RIhA*M^HcyE=46?wO=XdU$xe3ni2X@*? z5}r^hY!xqmPx7dc8gEuRy5G-!dHW+wNRyz9mji26e~cCwygXJ5VmT&V1c9a|uKQOJ zIg1JWAtD5MWmH~sUz=j%M^NXgDnU+E>C-wGC`$xXbeYO$G2fnFiN+1!vv*$Y&|zl9DOJ{pa5CEPuSU=;u5-rE6H0c zZPAF>gt5AeZ&EE|PGmtii>X(6RP1`%vZvyimRxDpT3eESO%C0wpjSn8bw&G>C>IEy z$_tk|Mot=Gvi?Af$c*b^UG(k08vc@5EghM+%1r|)t~SuF&w~sT8Hy?Xt_fR&@`>qWyYY%khCvlS)zo<=A`^wjDB1Dq$1F08^0HwVe90 za9dk}b*iK9089ZtR%N1;woUQ>-+U(vTuaZpHh46SJ5H9pb!m)%6HT;{X?5^3xyLc4 zeM9gxE8t!B+c#Gc`TNxr`{t-IiR{Gw@D8K!dOoN@v<|Pveo#UPFfuw4T7h}l6LuKF z^!GMlQ`c(ZMQ}zeCb8CDXA$|{SR9iWup}>4|DBJI4ZbyAX)X_PTwP=Gy#U!iWXi;W zQJzuDEY>Je$Wx*IUYt4yDZfZsq`~p72*Pcs^%daGYeIAULu)+bbu{!UF)Ql;r&~fq zO&V1$p#J}hS+2;53n zv4=fa7lc0tfUK^4e2SAK_WzL{?oM#jhW0?miL>ZvQ*r?Sw+cL|Fs4QEzOI4K1Q^tr zU!ax8wzmxkH(2%4qXLp21n$s065hBW^k8-jjo4mj93Dm!_a+>WrwU2Y8$TVYtBG4w z7xlT5sz{#fzf1&iNJ@>AJU&NM*?lR%RDO*~?UHm5yeIB^9J+_@83YoF0@WMReCO;V z_9sq4 zTXKM%fpwbIv^x<9bnGqjVe6Gx4j0k8<8y8_0`#l5dj1UB zXOu?M;eUfbGnC}I1iKO03B*Q??&aQf(Fk1rRD~X)(4WF#JpV;jl_Z;pz1@hs<5eB|4s~X+szVJr9dyvlca93JKaz*aduh>l>4FFDEW>Txn7n_Bi40?FiAN+3!L|` zJ$~fq#roth9-eZ7S%`cG$w34#mYf~TB>b@#AK=CRjNy>ZH))H`O_YV_Y0O>Fsb@iZ zz~J1bh53oDY{E=Fx<3s#HhZz2>d1QnK5vS@Uh2$V<9p|FP7}5RL`>k9a)mEErFq8+ z*W*(Ft5z+rZI;JC`&A%O+O3W>qll7X!q-DjlzRe|;L=lM2EZKtNm!txF9w^E1q%?5(2@bTf(C-vuhX2h_}1^2rA4Bk0$ z&T3J1;hx?o^d(D+Di?a}Qq8*H!~mdi*7dytSoYCQ9N-1SaIt^K>Q(?lO|{7;*xa;5 z?XzZM<{z`9^}JZO!o<$X^QV3Z)t4>kbd=|}NddjT_Q*2CFDR7sMa=41oCzUJ96hdbbl+EJF2DoI zm;mJ+NX8HYl|TIXJfYl{egFUfancWdSUh&a=Audq-=0jyH?Xu9YI!8{3Mh$a7$fC? zvH^K#ogHEMEW;Q?xc@a1^ zv>mPNvu4%UlYT5WtR(?&wlnZvKrhXVgqQzdR7uC4VP|W?Y2i}J^5zL{ko9h^YxLh4 z23d>+!1yDO-%C2`e|(}&{8b}c_~fO`P3(}S20N*%8aGbNXML|5W#Ml?k0#>q=% zv1p)4d$H#DT>XMGbe7qm(i_3J3Wpq|lC~0LUjyW)W9`63e^$gY+%ve!j++GX{n|nH zQIuT966jIIZ48wIwlB+q38V?=8F*lB^%zGo|9m~zHb=J?E1HeL0X@&+q(tzO2OecJu~ZItf3su75yWTU$@u!uQVwxi>IwKZb_F z^(LWwj>BQh#Yb7}1gGIJHvC@!OMwB=m4WC~U|2R-wuE3!QvrY@ojnyf!?{~}ueZYI zwzsDOFA@Ue8#YoHUP<4(oO0aWxoVEjrkMO(OE&naS8mj+qipqnU+o#IEbAo}hSP@L zLnKW)V}y~S>jz4GrIPdmqQ+!PimjQk*g8yOD3!2V`*ztmrqLC_fJb3}41#mkg%4`V z>?6k3QB+0D*`h?AL^n^hs{z7TVi|_1gvBZO;S1Fs!_sc+-nHnF?Otaxb9o%r@J%fM z0JK4zT@dn52}Whb0*e9omzgN*C=cGmr(+5KhWgWR;Q}%f1!wV44EnQ8sh8F(w|@%L zXU|I=Rs(5S^$`Pqi9GQo`6P6Tqv&Ik5iFqN%*1e$YM=VJoX7;NL4O21NMatvy}DsS zHTY~XL;(FU-6<~5yi>u+d@(t2QEFMC0Z;0(r0zK(SGu%)>oXFXMx@gBWX`N~>+2BbZL8i*cwkO_r=Q4gEbYH4 zn&17a9Tet9AR!WkCcY zOn&R$Ufg{*(@gLEf5mU=YGsXyNTvY>-=nyS7UB`S=KMSLe2;XoN5;IDKsc^&`nh6W z$d_nb$#IUl_>Zr% z45Ee*Ov=b!31O0$Q+(CZ8pDYPKvOefK{YX}_{B8oWvYGRTL?G`5nstKwiCT zaD9YyIhnXQlP)9;?U6~f9oK!cTm+yp@;7cQ1522(2EYlc zgVoEBwc>P4^YK{XK9akKlOy}yWShX|4h>gHSB^~))FaNJbx+)*_f+J2{G35lUL>R% z8Q6LSGgmlBXiQx%iqJcPkv;E2L8Gb~i1@bC7R<&KUa{JdYwSW?RGHmVvE_S>ofpNo zH;(`=Ea8#PPEt)mp7H>)kSYUD!l`#PvKwjU!2-f{Zoaf75Mot@tfabPDVujSf{Trq zfHWPWZnkq)5nS5PW9I@Y>s18~%MDO;GB8rL??-9f-}}!$*{$i6()8d+slCZXP6}uU zN&o;mI4zDT8XJ;-zNqC&`#X?rg*t{V{4 z-Y&miL(d-ZH?|LTVge+#+{1>s7h86ymaHNp#kGNcU4TUrp?)`vuwmt>4&O-$UC-nk zHa^@LqJ2^#Vm0k~+|8qBhrn_Qu5?-CiwG^HkCmJwM#@w(eHn~5yBjTnORiuEaWg)1 zQ&LSe6uP~ZfgM4xSqt!2#bF^sBNw}y0uQ7>#UHxNB;x(V2mW0APKlcYFjE6jgTYz~ z;mHi*F)r`XoNteyK0ATJ>>e4P`Js802}|vyBJRzeHh!)mb0^H)Am9)9I{|s$y_szU z1k(x*pyupSQEuLRTuMx4FDStzr)^q|8#^t(H4n60}!;JF`vbN6}snf`FsK_|t>l#JULz)>^Q zc%v;#2VVmmOejSPCGX%+2YQLt${V8a+)05P#2zXwq6B}~&#v_z!f|FY-#`O@qwpsx z^&M@5*Zm#~na)`bO1;8d28)Ws-C7D3`d+(goKKA;GL{o$U-fs|S%1pNpdfT62DEKf ztFN%5Pw^R5ujzMl5TLN6C9ImbaQ>-bV&u_(93etQ{rK@v%X7zaPPlr?#$UN;x9-Am z{Nx}&Qw!q18l_PvQsk~)7F`D`9Y}kVk{gUBJ>otredhZIRQxD)u$UsT0vNKILY7Rdt3I)DOvx_QAx#3nnRZYNfh``B|wp4XWdG7#9 z4R5+I_zD<@07+FCw&|=MR8FSHm`w?ulqAtX9_@WWB`^HC4*<)IUpwk~>H+XG4rCA} zo0;#g?R>ZaUK(o(%*y}(K%T`Gc+(S(L`xUp6(!XPrrh*7J9;H2#poe*JS8^G2mZ|u zl9(frFl6W_nd|hZ^Ol|wRljYEWzk}i3{1&}Ei(f%!Xzb%Ev0=cgs$(YFO)|dFQ)Y? z3xO0?j&SM>Z3gmw@)4azPyz$GI+7lRKo5M2*LAK}+G9HsP1G_Xo`4fdyV3ADj!DQAT%yM2k)&8rV z6HP)aVY*NT=`%hBjiWX)vVq0UW!i1dM*0mYc1tfMD#M9>63NRRp`d3v@f2pMfcxs2 z?K$K=Q{s>{1x-3fB3TQ%Le`-pl6*C0s~*LPv;3gtOxG;JYtQ2a2j@KR#9~SBC88T{ zV920|n{o8OSeM{^AvvnkxCOaGK#pZ3myY9Q;uC~4_J@j2u)6mBHiu(+>>cRu=nifJ zH0lXovuS9kBW8711%8oClBZ~!*A1=bm+i_m6#yQr6cdNvF@fAlmf)-~1~5vI80zB6 zffxKofnj<6Aj3&ni?n<6Pk} zhj&TGCgXG+dw;(2yV;MPVA+1Ih7w$cKi4}fB6HV|r%E$Kt%T`11G;&5j5l@k?-l?6 ztYBh2?>-c#cv003d8XAlqio2z_olIN_(nIT3Xe({o`I3qs;(t1Xs z^twCaj+p0sj10>+yN^@FEy86oMlOp5Iqef3TFkZGb`+?$;Y+cKb(Q#Ii*J+zY8Lr6 zX)pCScHbiR_?WTAbC7pDI=?tFV=u$ z4j**8B`{-Ci~8`IQ*zBc+A@5igK5y)7dO0ja?jpEQ=N(5t}1sS0rtQ`yBTlTUc*xa zB#Zr;ZfhNVz-BgHI&!Qp0Gy1{~biYOgJY)U z?lQv57`nHueh*XbTaij*Q!1_(wS|5<@|PBOoh)7M`{97UaVDpt-mMYidY_hFr~*-%{<7y**SI_0D zmw{inmOG{(k>nrRfy@=grNd%o3?AJrA_3_tB7AemBK$3P)m62nvdHwNx9nqG#P5Ez zL_u5VlZk(H$EYS@opD2*hLq&i+(Scbfoy)bTn>FN~_ z0_ifu55U0q=<0oXr?_)#_h82B|@0yj{+q`qqbl{;pV9(|@Ge3p1!n3e1% zAp^#qrPeRxwS#X((X2}?Frg_@;={)F_UrQFulEo0<^S&XjQQ)CY4yy*Rf!kWFHRdi z!0tin)PDmSe~@Nf5mx`067ok&%*~luiHYDiE=h>dl?W*|B2L+X_G%B}8DSGvHW|9eufOz4p_H*z*|qzH(~1dKS- z#$ScZaj;S4*?9Oj_8rFxw+PFj@z5^4-w10U9?SsPI?ASNZ8NVkXwnJpErSW*tbAIIvxuxqN_9MN$*Nojz*uJvm?#krLpU#mM~ zU2o+O^X5GIC=6A-`%SQCRws>#_8gDC?-!O~-^lU0P`EPFUFgw}SqA2B zUEEso-W$U7jZU&ah2J;1K5g8>QX9meUIqzm80BTDURosGn@89#D*)8+xL@Bld^5X}f_xeoM!GbeO}a!9m!jDO5Wes&{o zr+xZN#gBmB6`_riGM!rhq3G2eMU1Ll*E2-}LQTGW__Ig0p(s=Y_NXHt*$2un9VI2( z#w|l@1lsq7uaS4{eW2PJ3+~Z`uh2}ZgUk#+2JNN1wmI(U-ssO}$@)tTUMmD2DrQmP z4#Qr_^xLu<>QqmV@Sg`Bt}=L)nIMe>G-`+be+>;#z@Z}<|o zYPIrriI+(n-bL;V{hX-?S@`FKAn0M7v{r>(AtiVX3@-XLCQR|E7QX&w-T0{`}vTrfK-4W98s%AvR%0jdPgPvpcUBxnlI=)$8-3HwTa+-2fV z7&Qv-e5g0j->QnCR|F$#L$#cN3x)BKu~)=i1B_2N`Ig~NyvIFd0?6yLb{|ODx)t?E zs_#rn%TUd?&VHsdGbMt**6!|+{3XW=ha_f*4}+%{anS0M-8nRjuqdaq1eQ@-sW613 zom_;C{ep`M1j7QaRY`90Up!(L+Zeg+-F@Qv2}s;lmFkt( zIqeAEdy-6|r?P zENJ_m3lQfGHa)y`qI!Irg{R>`b}?mejBru7d<#YPUCLYV*0DOe_9r6p#7Tk3c9x)k z+)u^PI5w>nkDsjZ+4xqoGpWmi!HI^!H4;B&727q(lC01uPca3IZ!5NHyIEX*x`C_G zJr>!5xp={5wO}1^veCo^qJR-ZpdAEQk$>DIT3B zJCly-6@)_kAzId!TxqR;AKT4%55c)=_S{`4GIz2gl$`p)n--3>IFq%!sMgO4hUIju zp`_*<_{M&ao)Y6_003~(dWYZZ*~iv2$eLDj(OthItKdIwmC(u!_d&~}Sq8QF8f2CN zkUnwM?@~pARgeq@gr&>XsbHkx)w8msS_H#Hr%YaoO+Q&BBq zHLBRp3eKBnP1@k>$oi5KCFhG{L7kSJ)aS&P#Cm-HCwja&^{^*wSwdz1AQG zSD;I@@P_GNM^g0ffRfxwND z3>5goxyLs!b51?$SH?KN;be!|7BMANtXJ)b^2f<;s$JFgxB;G+c0HVA;0?!<_12QK zOxPOyCm?&7W9@UF5nWe7@~6}6*Kq~yX2-vA|9+3@VA=%wo!X9P6B*rcFB4Qu@i0=h zkbnPr%@5T{L<=V;C!@+QVEpoIP-@MsouVS3U|mfsvBdaA9FHNgqQjz(M{w7Ag45hH z+9_BEA8GwvjsscuHPHm^#!){Yp!Z!2qXCb|o1wDoxOM27r+EnNs^4<(aZ~u!BI(Y^ z;>?~Gh*BsegFn_l>lbbqJ9iZT$g(|+NLo|8Fx5`bPtp;v83$@VpvZ+w)ckQN=opHF zWbv(WzuYg3lUBtiTOF}VMbKQ4mNLXCE(b4QB@hcbf#A^TV-N@T?C7y~UK~&4Y7$7d z`xhPpyXRce?R!rCME zEkwxkCRt!}=?7yNoku#q5v|Yq$(Y?RA4gi)qeIEZp1fSBsy3I^v-g(+LvsNTPA#i% zXTQAa#<=lYeBcmysiDH%QkSl-_YMBL1D;UYL@{sEoH@uRDuU7%h7$9@7AA zpKov|S?`2e{q5PRNqm+q?RI{Pq0}mP65m(TiqHHt-Da}|mA6eK{rc2tV+-9>;178D zQTPdK#`B;AyU>K!ZkdyGPKwF#6+bl!`G1v90QkqnR2yL{Bca-nLyvCp-jYh<~iyvbV z?Gwesv9IQ0l1S<+B~6zhY=1}|&cVF;n0L!NZX;tXDr$;<{46)bTXx;akC^daX{xCPIu=u^dqas!X~c0*k5+;s80l)(zeD(=-r)UX za5ApN72=>_j7(Yxzf-~kwJ1j?GM5YpbzTn_VL*?lF{T)$iulQg=>jd(2>ti9SA}*0ob}RCe_Xjz|nU~ zlH7BbqGcl-{9o@A3>J5XsWLUVg8Ged_fX%qf!T92-k7Knx8q9AR^I;9r~LD55Ek7* zy*Q~ZXZYEGacD9 zFoT#5PB7iUnd5xjl3bA&-2Q^&xfkiWrIR*#>a$xqx>ZNNeSfWCaGc{WTu? zh-ITd#pke44O#l$Q-IJF26!{})pKD3xB(N9Avm)q1JiX3FbDn(QW$k%L5FTTXm4I} zD8+$*7t#PIisT-SeZ8Hc{Kmb!p0m24j_Fdupmm4n%--pNKe0KG9p^o2>B_cYoT@mbG7{@JBy+_6bO!@>L4Z;I{yyx>v&15bai{TWW=#! zdh4_oYwzi_@#-^ru%?k)m#aaOjf~KFfV>T6xD87agf#Ij$bP591bMSI#2wAp_l#3s z6Y0eBD8RouM?uok^(L7Q8_nd^=mrQ`W7aaIN{BHV#dW{wzrasDswLu-ESd z&3b(Mx&tweECi7`!d?Ra3BKH4*)izn3&nsw7O^(vm4~7qYrryy&Wknv5_v>5E+jZ` zQQWlweL4W_Tmy5dq{Mi6+Hs)D-YeUo&V_J#gMJI?F>jl-&OA!Bp6>X;9(FRq@q(I# zq*{&os=!6lJ< z=7jMz2`Pp*SbbeGQ} zPKY*HgD;BRnRr6}0r2P#61gut)2h$&u)Em#4in9Pc=twGzDiE(0O4c%M%U)Gx6t<$ zN)I3f8zE#E!CMJ317d@t(2X~Y*2-D|Etx0;I5tZ+M#&nI&ZT*E9U00 zru)?Bqj46>uG&QXJ-H6Prb5tF#`46O+dAIVZd8ICRzo|2oXOQ)t-J8SvX05ur+ez% z>_|^LDrBj?Ts)M<^ny~?xg85Cu3Z0wm8wQ+7;zeRP^IvK9}q$$UL}|j zBR^i{(RWUX*NKtaCxwH7RJ24MVY1Bq(>}NKF-;m`ULQ9IQM)x4A5U$KE@)8q@}0{P zs+CTNNIeX4+V8VR{Q#cx7G?(83!c$30PzPtSZ=~8V}(96${lj(W1t?^uaMFiK#bCG zhYSv+)SXElq6pd;?&KSVNvQL1t^Lx2jL5; z-eFQQu=ON7EuX-u;7hL?c*42E*%91Zzl2_#k4~`Ew(Z-edNQwb4XsvgJ5LJqqPwo4 z5sQ)Yff3ka*fwv$`LfTrpP!Pt_qeW78Ey*{gnsS@>mpqb_KDY*vsd!JmEJe@L<8l* zF1ulU5=!c~^jR&vO!M~9Os$6}4rXo>7*MZ!&JRswHs6*#96I*jVioZx!O zYRcl?p^TP45dwXW6(a=zJl*x>ibfL%fW_i!nGS8ft{@N*4RAgV?%MF?br67ttrEfl z^ePp6cBH*u=nJ;X)Y$FBE$og3q23B`i%bz{#Tx~i-J>aO`0@PGz0NYR=dUS_r|Trp zTDgaKn^QPLUVtOC9hG0qIl9Fc(R>{&1A2Ufa|F%Pu^Pxpi-a4*(shOaM*MU2wQB((Ja&*AeyV zaI2f|!QQYJZ50cpxVjYf#t1AWd!0`h*dfZTFTGwosGO>&9;#foh*;J&<_i4r?2 zkYF%IkbSKNF)q&ExIOOz5TsGNC{tUvAI4V0q3if&gbj^>EO+m%Idk^ER2JvYsLv1hl!s7tk<`P^r9g4dZ;lmn=J;g3Oqd%Ir5&%HW zbipN^wrRc~(C`mI9*CF=!W+Coyir(HeA9W?$cNQ;@KU>+#?frZo&eiZ^{ zCr>myJvj_>V+Oeg4&O86oGJ;7vfnElnvP`1QtA&2Iut61PgZl^TjRz(dZ%1W7BWtE zuBlYYiI=R!^KUIT3DwHT*r&&+7Cz9pn7&fKdjkbh+@vWW(Ut%|I1(3xW-79PvO99K zZ{8g??qMMzNJ)wEreIrcA#Pb=z+nM*hEhfqZ4P_^007N#+`j<&7%vtlk{BoeR%sfJ z04M`>w{~F$U^>aQ-dSf*dibE29o$t)Rs1E7))(Dtij0sB7C|tm9&Zz>7l4oP4u)4B zu#x`b!a#%{&J>&;f$`R6 zb)bMJZx951{7lFx$P`TjxeO}a?|(dU{b>17QKVc-Og@%AIOC{uEdle&3yAZ=7YMJj zvLL%&+W_u7?;|-Ye!=i)ChUO2)EJHfJ|rrDYa1OwSh?E48hr@tt1LMmJ~qiK8+s@p z?|A7&ft{k9H=WF18YTTeH5YcGQX-~WHxH@n=y)wQf3}I98KTeNJpv#*FGCQQ zrUnO|m>J9zO8Q0AAU*V1Eq7H`OBA?5&~jqfUeR?b%dj$Yyj1)pb}a6K*>_MbQ6oo# zL~dQ*`OL=}O>{0Dp@nCWvks6rL7d=*fIbfZxCRyT7r2~@855d?HY{oas<;`0I8z>m zo1+}tCfVkC;v37Vx76Y|eIia^IERYK(0M?u@~O=HK3|kJzy;C|u^!9>N}wM<&7%(J zgOkDM2QD9pkpWWt1J`5U-Pix-`^4eC^uisp0S&Mx3oC|~?z@oK&mm9`vv_^LsilcG z6|P~F>q@vzXn0ePMJbp%vYI};ZegS}Y9M1dley=23{&Xzx`Uld1*J%Wrq(NZ5S@;e zs{rc+kaj1ar_L_nTxIVH`2b}msZR7$sJQahI+iug5tJ`wk5^5RE!fJLhI}R{l{)al zED>89ELCvOfphjahs2%-1a>>Z%{f9ze~cMmcH+^Q$wXO3|EU9m=YfOvd+BIuJTEOR zh@V?f_%(@8(t|9b$=tY{GB`s#x%)_M5m-kV2S4WXFM6ix%?lTkYSfvxjN$<=+XWb6 zHTR&g>A$%0Rqp-!8Qeo$>nbr(D*fuy+Nv+i{h+HdX`6JPLw5|TG?fYQtYN9;^-H~E zVtVO6K4ejP!aTeQY;p@#-EW!4Is7N-iI?uZFzjCOQa4=qMdrIj*1|VSnHh?5-@3?o zsT+V7`aGVz+7zgk{#R`d+{N^us=c4FV|1S(gx}sFBVtfI05G;`e3g(crJvy3v;&fK zeg#_ZH}|<>JgvXj5B^c?4D0t9jjVKkK4;^&p_Of>O167}{)t@~D>JU8fj&@w z{xi(YPdqL#IuKf4e=Qn~m02!QlATR@7ph<1TZGL}w+ZI99E>mTb?oMh^X?OuZvHVJ zznsS%$ti0vn0Dz4Bvdjf_MB1TT4_=5>Uk}JS~}hU8=U{63U&r`m7TQRM@eEWtI$Kw zf-j4}gL7RS7Bmq3bii( z{E(jh5;6&K#4bZ`?%c+`C>>6@3<02vE>*6d$@1xG(RAjcPNYB?W1RFwqPahnezHO< z_l?a@=jBx#cMbw^T6*u<+&VIo71V z8H}WoFO}penwW|HeIjeSO$o{!n5>VqCGGe_uJ+;>j0y+{L5ar6FcItgHT7sSBHj*aY--U_R;TvRlvV!Uq!oo4epregy$U&MTButewo<&Nr$_~ko3@HR z0(Fp~hFI`(DboEw;j&^(ZTGt=xOoreX&XQhb)C0AeN+*^5ox?o6DzLaMIc@>i*1GS z-e{wozb6#G!BPbaYKI2%8}Z1y6QFCJ2NBD$(mVACM1*LUTc1;h3VY#@R$eJ2pOF2@ z1sGmjiAWRE2ZbIV#WFpts?=0(YdXGyg2`7JMJCo*HlU4~ex#J08|&AjHW?EDfGwdr zIi4c`P_qnd)}@Hoew(EP(+(!CYaQED z@+d`dFSl~STuP-1UH?=tzqtOH#V`G<9yX5j=D_uLUMu@OX-R)3a`2Xh0ZGpX zvYn8+v4h6KDmGMJh0%6aH^8RtqQyaovl8lM<oMyFwvKdsH?{MG-dvJC*c8mzf?zKl!$Dc?GYNm7*1H;NwK>w?{4(=77c?e@i@wT&~($I zpA(CXgO#;D?-kO)U!D>umYY0Wt6$n|vwk08rnzac6Iyl4K-fYz?+gU=FwPyJ`y}j4 z^DNvw>s1ZhV93URTnaF za?rF7-E2!Qs2+^?v5EXz&7S!dZke>1J9~U5{>50Y?kLekPE>#Y8G#p^xIAs6x}I- zSE5x=0C>JfSaMNhN28U8FPz%hHJ-BLaqnLB!#GLQvNDns3MIEzFe5__$qbZ=Tv?#+99bHLD|i-W^WbCTL&Q| zA%1oQof&`nCm@L=HFgOAcZ}BcID1K+1w6a9|H0Tp@UuQ8VQ z+!CrS_+r;2yw^4%s`s?O5H_#K;?t&o@1Fb#0+>24UW_t{jVyi^RVER!0al0l^AlFq zEV09!``Kkc(&`=G%SdVV0fwg^R}b`mKm-0#ChFR4l4=?Nig#V`>aB}ii3>s9-tiI& z>14E1E<tXZWQ*OKK#(xQc9Q@7UJ0^1}>r#o45T!3nI5@J-fgXLuAR{wFb zG`AGjBL8;_k0(?1PR6t15X&zDQ`;(ZdRjbyRGCG|bKm|h4D+BmC}DvjW2RCsl7T%r z(6eWrD6+_Q(lR_bfN4oLj@!q%Dc$k5yG2W{KL4hh^IjVyL3&1GGCPTRzvR{x6xy|1 zEhRF(D}OI+D{WsG(c&p*T5Wf>)P#5f3qeXoU{|9$)>URS6d?kp$-I?>HBAl?te@G) z$ml%@o8z6xw#N-DaVDOGGSSiJebVhuBU!P+>hQM04u{oglv!dKyus(pplV=9enGp^ zU+SfHm6l9GYHxx?Et>eo(-xBU@8Ku(TY*;+l5AmDMJGjlE=#d znm5134hT=7aK!S52xanuVbzYlRRe3rKig-J(e$Nlf27IsiYHdmNvu6@T7)6 zIz$ljl?ba~`(bVl5+Ii0jmPDOxhOl&y_2>^^7*^05&y`bn3g5EZ`*txoH>*WA!u9@$y*C`%JdZSO z>IFYwI3F&=BtCQ;8AFM$h+s;dF@XwYubhLt2qd`tEiEO0-ULt+%Mxw;(_E@^FryDS z3E1nb8)}iQvi3dQRp+jtY|!>`eOmtXh;;WP1yJA4F?j1H-={vL9OL2t``2Xv@%LA{ W>|eJ1-vcrC`=QJ5zpwwV`+oorq9+9a literal 0 HcmV?d00001 diff --git a/docs/index.md b/docs/index.md index 5e8b25f861..082c22ac7d 100644 --- a/docs/index.md +++ b/docs/index.md @@ -1,5 +1,7 @@
+ + # GitHub Spec Kit **Spec-Driven Development or your own process — step by step or as an automated workflow.** @@ -128,6 +130,10 @@ Community extensions like CI Guard and Architecture Guard add compliance gates a Existing Projects Adopt Spec Kit safely in an established codebase + + Upgrade + Keep an existing Spec Kit project current across releases + Reference Core commands, integrations, extensions, presets, and workflows diff --git a/docs/template/public/main.css b/docs/template/public/main.css index 52ce456064..68f91d9dfa 100644 --- a/docs/template/public/main.css +++ b/docs/template/public/main.css @@ -25,6 +25,13 @@ --gh-coral-subtle: #2d0f0d; } +/* Keep the raster Spec Kit logo aligned with DocFX's default header dimensions. */ +.navbar-brand #logo { + width: 1.5rem; + height: 1.5rem; + margin-right: 0.375rem; +} + /* Override Bootstrap primary with GitHub blue */ body[data-layout="landing"] { --bs-primary: var(--gh-blue); @@ -44,6 +51,13 @@ body[data-layout="landing"][data-bs-theme="dark"] { padding: 3rem 0 1.5rem; } +.landing-hero-logo { + display: block; + width: 7.5rem; + height: 7.5rem; + margin: 0 auto 1rem; +} + .landing-hero h1 { font-size: 2.6rem; font-weight: 800; From 720b31ffce1a43f426d246c2b5a833346e8b1da1 Mon Sep 17 00:00:00 2001 From: Manfred Riem <15701806+mnriem@users.noreply.github.com> Date: Fri, 21 Aug 2026 14:53:56 -0500 Subject: [PATCH 040/102] docs: flatten project history navigation (#4265) Assisted-by: GitHub Copilot (model: GPT-5.6 Sol, autonomous) Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: 0f0ad8f8-ea22-44ca-86c7-a485c888ec91 --- docs/toc.yml | 8 +++----- 1 file changed, 3 insertions(+), 5 deletions(-) diff --git a/docs/toc.yml b/docs/toc.yml index 7548ba95f6..d2f1b2bd21 100644 --- a/docs/toc.yml +++ b/docs/toc.yml @@ -2,11 +2,9 @@ - name: Home href: index.md -# About -- name: About - items: - - name: History - href: history.md +# Project history +- name: Project History + href: history.md # Getting started section - name: Getting Started From 27f50f7e6b618ea14d74dd4037f9e7c60218b16c Mon Sep 17 00:00:00 2001 From: Manfred Riem <15701806+mnriem@users.noreply.github.com> Date: Fri, 21 Aug 2026 14:57:32 -0500 Subject: [PATCH 041/102] chore: release 1.0.1, begin 1.0.2.dev0 development (#4266) * chore: bump version to 1.0.1 * chore: begin 1.0.2.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 3ec23d5f33..ca3dde8da5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,31 @@ +## [1.0.1] - 2026-08-21 + +### Changed + +- docs: flatten project history navigation (#4265) +- docs: use Spec Kit branding on documentation site (#4264) +- docs: add existing project adoption guide (#4263) +- docs: add project history page (#4262) +- docs: mark Spec Kit's first anniversary (#4260) +- docs: add workflow quickstarts (#4258) +- chore(deps): bump astral-sh/setup-uv from 9.0.0 to 10.0.1 (#4244) +- Update SpecAssay Check extension to v0.4.12 (#4254) +- fix(workflows): require a 'cases' block on switch steps (#4144) +- fix(workflows): strip the resolved value before matching switch cases (#4143) +- fix(bundler): reject non-string manifest list members (#4091) +- fix(presets): reject non-mapping catalog mutations (#4094) +- fix(workflows): stop offering a condition correction that inverts it (#4230) +- fix: use chunked read for integration and preset manifest hash (#3843) +- docs: update landing page stats for 1.0.0 (#4251) +- Add Azure Cosmos DB extension to community catalog (#4247) +- chore(deps): bump actions/checkout from 6.0.3 to 7.0.1 (#4243) +- chore(deps): bump actions/setup-node from 6.4.0 to 7.0.0 (#4242) +- chore(deps): bump the codeql-action group with 2 updates (#4241) +- chore: release 1.0.0, begin 1.0.1.dev0 development (#4246) + ## [1.0.0] - 2026-08-21 ### Changed diff --git a/pyproject.toml b/pyproject.toml index c6bb6a93de..39bd6d32ca 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "specify-cli" -version = "1.0.1.dev0" +version = "1.0.2.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 d9d27048b4e6478cab7b0cfa3d0c4e31291653a2 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Mon, 24 Aug 2026 09:53:27 -0500 Subject: [PATCH 042/102] Update Reconcile extension to v1.2.1 (#4297) Update reconcile extension submitted by @stn1slv: - extensions/catalog.community.json (version, download_url, requires.speckit_version, provides.hooks, updated_at) - docs/community/extensions.md community extensions table (no changes needed) Closes #4279 Assisted-by: GitHub Copilot (model: claude-sonnet-4.6, autonomous) Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com> Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- extensions/catalog.community.json | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/extensions/catalog.community.json b/extensions/catalog.community.json index a4fa835c1a..a5ed7879a9 100644 --- a/extensions/catalog.community.json +++ b/extensions/catalog.community.json @@ -1,6 +1,6 @@ { "schema_version": "1.0", - "updated_at": "2026-08-21T00:00:00Z", + "updated_at": "2026-08-24T00:00:00Z", "catalog_url": "https://raw.githubusercontent.com/github/spec-kit/main/extensions/catalog.community.json", "extensions": { "adrkit": { @@ -3826,8 +3826,8 @@ "id": "reconcile", "description": "Reconcile implementation drift by surgically updating the feature's own spec, plan, and tasks.", "author": "Stanislav Deviatov", - "version": "1.1.0", - "download_url": "https://github.com/stn1slv/spec-kit-reconcile/archive/refs/tags/v1.1.0.zip", + "version": "1.2.1", + "download_url": "https://github.com/stn1slv/spec-kit-reconcile/archive/refs/tags/v1.2.1.zip", "repository": "https://github.com/stn1slv/spec-kit-reconcile", "homepage": "https://github.com/stn1slv/spec-kit-reconcile", "documentation": "https://github.com/stn1slv/spec-kit-reconcile/blob/main/README.md", @@ -3836,11 +3836,11 @@ "category": "docs", "effect": "read-write", "requires": { - "speckit_version": ">=0.1.0" + "speckit_version": ">=0.16.2" }, "provides": { "commands": 1, - "hooks": 0 + "hooks": 2 }, "tags": [ "reconcile", @@ -3852,7 +3852,7 @@ "downloads": 0, "stars": 0, "created_at": "2026-03-14T00:00:00Z", - "updated_at": "2026-08-10T00:00:00Z" + "updated_at": "2026-08-24T00:00:00Z" }, "red-team": { "name": "Red Team", From f83836c7f9145963e98cfdd34cd1269a61842ddb Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Mon, 24 Aug 2026 10:55:19 -0500 Subject: [PATCH 043/102] Update Archive Extension to v1.3.0 (#4298) Update archive extension submitted by @stn1slv: - extensions/catalog.community.json (version, download_url, requires.speckit_version, updated_at) - docs/community/extensions.md community extensions table Closes #4278 Assisted-by: GitHub Copilot (model: claude-sonnet-4.6, autonomous) Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com> Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- 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 a5ed7879a9..cd162734e5 100644 --- a/extensions/catalog.community.json +++ b/extensions/catalog.community.json @@ -456,8 +456,8 @@ "id": "archive", "description": "Archive merged features into main project memory, resolving gaps and conflicts.", "author": "Stanislav Deviatov", - "version": "1.2.2", - "download_url": "https://github.com/stn1slv/spec-kit-archive/archive/refs/tags/v1.2.2.zip", + "version": "1.3.0", + "download_url": "https://github.com/stn1slv/spec-kit-archive/archive/refs/tags/v1.3.0.zip", "repository": "https://github.com/stn1slv/spec-kit-archive", "homepage": "https://github.com/stn1slv/spec-kit-archive", "documentation": "https://github.com/stn1slv/spec-kit-archive/blob/main/README.md", @@ -466,7 +466,7 @@ "category": "docs", "effect": "read-write", "requires": { - "speckit_version": ">=0.1.0" + "speckit_version": ">=0.14.0" }, "provides": { "commands": 1, @@ -482,7 +482,7 @@ "downloads": 0, "stars": 0, "created_at": "2026-03-14T00:00:00Z", - "updated_at": "2026-08-11T00:00:00Z" + "updated_at": "2026-08-24T00:00:00Z" }, "ascii-diagram": { "name": "ASCII Diagram Renderer", From d0eb9c471b968417d91c5c4fd8c5618ad8cda35e Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Mon, 24 Aug 2026 14:04:38 -0500 Subject: [PATCH 044/102] Update SpecAssay preset to v0.4.12 (#4256) Update specassay preset submitted by @rdryfoos: - presets/catalog.community.json (version, download_url, updated_at) Closes #4253 Assisted-by: GitHub Copilot (model: claude-sonnet-4.6, autonomous) Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com> Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- presets/catalog.community.json | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/presets/catalog.community.json b/presets/catalog.community.json index baa342c76e..6e4f5f07b0 100644 --- a/presets/catalog.community.json +++ b/presets/catalog.community.json @@ -1,6 +1,6 @@ { "schema_version": "1.0", - "updated_at": "2026-08-20T00:00:00Z", + "updated_at": "2026-08-21T00:00:00Z", "catalog_url": "https://raw.githubusercontent.com/github/spec-kit/main/presets/catalog.community.json", "presets": { "a11y-governance": { @@ -809,11 +809,11 @@ "specassay": { "name": "SpecAssay", "id": "specassay", - "version": "0.3.4", + "version": "0.4.12", "description": "Appends durable-ID, Carries, and SpecAssay vocabulary onto Spec Kit spec, tasks, and constitution templates.", "author": "Rik Dryfoos", "repository": "https://github.com/rdryfoos/specassay", - "download_url": "https://github.com/rdryfoos/specassay/releases/download/v0.3.4/specassay-preset-0.3.4.zip", + "download_url": "https://github.com/rdryfoos/specassay/releases/download/v0.4.12/specassay-preset-0.4.12.zip", "homepage": "https://github.com/rdryfoos/specassay", "documentation": "https://github.com/rdryfoos/specassay/blob/main/presets/specassay/README.md", "license": "MIT", @@ -831,7 +831,7 @@ "sdd" ], "created_at": "2026-08-14T00:00:00Z", - "updated_at": "2026-08-14T00:00:00Z" + "updated_at": "2026-08-21T00:00:00Z" }, "test-first-governance": { "name": "Test-First Governance", From b1d9d5626c02df8d378c8ab401ceb8961009f6dc Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Mon, 24 Aug 2026 14:29:50 -0500 Subject: [PATCH 045/102] Update SpecAssay bundle to v0.4.12 (#4257) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Update the specassay community bundle catalog entry from v0.3.4 to v0.4.12. - Updated version: 0.3.4 → 0.4.12 - Updated download_url to v0.4.12 release asset - Updated updated_at timestamp to 2026-08-21 Validation results: - Bundle ID 'specassay' matches naming convention - Version 0.4.12 is a valid semver X.Y.Z and higher than existing 0.3.4 - Repository https://github.com/rdryfoos/specassay confirmed: bundle.yml, README.md, LICENSE present - bundle.yml fields match submission (id, name, version, role, author, license, speckit_version, provides) - Release v0.4.12 confirmed with specassay-0.4.12.zip asset attached - Download URL matches HTTPS GitHub release asset pattern - Catalog entry validated: all required fields present, verified=false, 5 tags - Required component catalogs (extensions + presets) documented in README and tested - All checklists checked; testing details and example usage are complete Closes #4255 cc @rdryfoos Assisted-by: GitHub Copilot (model: claude-sonnet-4.6, autonomous) Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com> Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- bundles/catalog.community.json | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/bundles/catalog.community.json b/bundles/catalog.community.json index ed6b97dcd5..070a1132cd 100644 --- a/bundles/catalog.community.json +++ b/bundles/catalog.community.json @@ -1,6 +1,6 @@ { "schema_version": "1.0", - "updated_at": "2026-08-14T00:00:00Z", + "updated_at": "2026-08-21T00:00:00Z", "catalog_url": "https://raw.githubusercontent.com/github/spec-kit/main/bundles/catalog.community.json", "bundles": { "sicario-spec": { @@ -34,12 +34,12 @@ "specassay": { "name": "SpecAssay", "id": "specassay", - "version": "0.3.4", + "version": "0.4.12", "role": "developer", "description": "Durable-ID promotion for stock Spec Kit: templates, Gate 2 refusal, and trace-manifest emission.", "author": "Rik Dryfoos", "license": "MIT", - "download_url": "https://github.com/rdryfoos/specassay/releases/download/v0.3.4/specassay-0.3.4.zip", + "download_url": "https://github.com/rdryfoos/specassay/releases/download/v0.4.12/specassay-0.4.12.zip", "repository": "https://github.com/rdryfoos/specassay", "requires": { "speckit_version": ">=0.14.0" From f5d0422e1f0ad0cbff6d26328110ae1bae16d70e Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Mon, 24 Aug 2026 16:15:16 -0500 Subject: [PATCH 046/102] Update Parallel Autonomous Run Governance preset to v0.2.6 (#4304) Update parallel-autonomous-run-governance preset submitted by @hindermath: - presets/catalog.community.json (version, download_url, documentation, provides.templates, tags, description, updated_at) - docs/community/presets.md community presets table Closes #4237 Assisted-by: GitHub Copilot (model: claude-sonnet-4.6, autonomous) Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com> Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- docs/community/presets.md | 2 +- presets/catalog.community.json | 18 +++++++++--------- 2 files changed, 10 insertions(+), 10 deletions(-) diff --git a/docs/community/presets.md b/docs/community/presets.md index 08b9da21e5..9963157f3f 100644 --- a/docs/community/presets.md +++ b/docs/community/presets.md @@ -29,7 +29,7 @@ The following community-contributed presets customize how Spec Kit behaves — o | Model Driven Engineering | Focuses on streamlined commands, app repository support, cross-spec support, and capability-aware project memory for model-driven engineering workflows | 6 templates, 11 commands | MDE extension | [spec-kit-preset-mde](https://github.com/AI-MDE/spec-kit-preset-mde) | | Model Routing Governance | Maps provider-neutral Spec Kit roles to validated harness-local runner profiles without storing model availability, credentials, or machine-specific selections in Git. | 4 templates, 2 commands, 2 scripts | — | [spec-kit-preset-model-routing-governance](https://github.com/hindermath/spec-kit-preset-model-routing-governance) | | Multi-Repo Branching | Coordinates feature branch creation across multiple git repositories (independent repos and submodules) during plan and tasks phases | 2 commands | — | [spec-kit-preset-multi-repo-branching](https://github.com/sakitA/spec-kit-preset-multi-repo-branching) | -| Parallel Autonomous Run Governance | Coordinates permission-bounded autonomous campaigns while preserving the project's learner and accessibility contract across workers and consolidation. | 9 templates, 5 commands, 2 scripts | autonomous-run-governance >=0.2.2; optional: intake-review-governance >=0.1.0 | [spec-kit-preset-parallel-autonomous-run-governance](https://github.com/hindermath/spec-kit-preset-parallel-autonomous-run-governance) | +| Parallel Autonomous Run Governance | Coordinates isolated autonomous campaigns and optionally gates worker scheduling on a current campaign intake review. | 10 templates, 5 commands, 2 scripts | autonomous-run-governance >=0.2.2; optional: intake-review-governance >=0.1.0 | [spec-kit-preset-parallel-autonomous-run-governance](https://github.com/hindermath/spec-kit-preset-parallel-autonomous-run-governance) | | Pirate Speak (Full) | Transforms all Spec Kit output into pirate speak — specs become "Voyage Manifests", plans become "Battle Plans", tasks become "Crew Assignments" | 6 templates, 9 commands | — | [spec-kit-presets](https://github.com/mnriem/spec-kit-presets) | | Screenwriting | Spec-Driven Development for screenwriting/scriptwriting/tutorials: feature films, television (pilot, episode, limited series), and stage plays. Adapts the Spec Kit workflow to screenplay craft — slug lines, action lines, act breaks, beat sheets, and industry-standard pitch documents. Supports three-act, Save the Cat, TV pilot, network episode, cable/streaming episode, and stage-play structural frameworks. Export to Fountain, FTX, PDF | 26 templates, 32 commands, 1 script | — | [speckit-preset-screenwriting](https://github.com/adaumann/speckit-preset-screenwriting) | | Security Governance | Adds memory-safe-language and secure-coding governance, exact-head security evidence, ASVS, supply-chain transparency, EU regulatory screening, and provider-neutral model routing. | 15 templates, 3 commands | — | [spec-kit-preset-security-governance](https://github.com/hindermath/spec-kit-preset-security-governance) | diff --git a/presets/catalog.community.json b/presets/catalog.community.json index 6e4f5f07b0..36d62b6ab3 100644 --- a/presets/catalog.community.json +++ b/presets/catalog.community.json @@ -1,6 +1,6 @@ { "schema_version": "1.0", - "updated_at": "2026-08-21T00:00:00Z", + "updated_at": "2026-08-24T00:00:00Z", "catalog_url": "https://raw.githubusercontent.com/github/spec-kit/main/presets/catalog.community.json", "presets": { "a11y-governance": { @@ -635,19 +635,19 @@ "parallel-autonomous-run-governance": { "name": "Parallel Autonomous Run Governance", "id": "parallel-autonomous-run-governance", - "version": "0.2.4", - "description": "Coordinates permission-bounded autonomous campaigns while preserving the project's learner and accessibility contract across workers and consolidation.", + "version": "0.2.6", + "description": "Coordinates isolated autonomous campaigns and optionally gates worker scheduling on a current campaign intake review.", "author": "Thorsten Hindermann", "repository": "https://github.com/hindermath/spec-kit-preset-parallel-autonomous-run-governance", - "download_url": "https://github.com/hindermath/spec-kit-preset-parallel-autonomous-run-governance/archive/refs/tags/v0.2.4.zip", + "download_url": "https://github.com/hindermath/spec-kit-preset-parallel-autonomous-run-governance/archive/refs/tags/v0.2.6.zip", "homepage": "https://github.com/hindermath/spec-kit-preset-parallel-autonomous-run-governance", - "documentation": "https://github.com/hindermath/spec-kit-preset-parallel-autonomous-run-governance/blob/v0.2.4/README.md", + "documentation": "https://github.com/hindermath/spec-kit-preset-parallel-autonomous-run-governance/blob/v0.2.6/README.md", "license": "MIT", "requires": { "speckit_version": ">=0.8.3" }, "provides": { - "templates": 9, + "templates": 10, "commands": 5, "scripts": 2 }, @@ -655,11 +655,11 @@ "parallel", "autonomous", "governance", - "accessibility", - "orchestration" + "orchestration", + "model-routing" ], "created_at": "2026-07-22T00:00:00Z", - "updated_at": "2026-07-28T00:00:00Z" + "updated_at": "2026-08-24T00:00:00Z" }, "pirate": { "name": "Pirate Speak (Full)", From cf049cc472d404464c19c9fafe3034d55d161047 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Tue, 25 Aug 2026 09:05:59 -0500 Subject: [PATCH 047/102] [extension] Update BDD extension to v1.0.3 (#4299) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Update BDD extension to v1.0.3 Update bdd extension submitted by @RSginer: - extensions/catalog.community.json (version, download_url, homepage, documentation, name, updated_at) - docs/community/extensions.md community extensions table Closes #4277 Assisted-by: GitHub Copilot (model: claude-sonnet-4.6, autonomous) Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * docs(extensions.md): update bdd extension table row order (#4308) Update the extensions table catalog to folow sorting rules --------- Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com> Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Co-authored-by: Rubén Soler --- docs/community/extensions.md | 2 +- extensions/catalog.community.json | 14 +++++++------- 2 files changed, 8 insertions(+), 8 deletions(-) diff --git a/docs/community/extensions.md b/docs/community/extensions.md index 4353c80f3e..ad8ef61e80 100644 --- a/docs/community/extensions.md +++ b/docs/community/extensions.md @@ -42,6 +42,7 @@ The following community-contributed extensions are available in [`catalog.commun | 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) | | 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) | | Blueprint | Stay code-literate in AI-driven development: review a complete code blueprint for every task from spec artifacts before /speckit.implement runs | `docs` | Read+Write | [spec-kit-blueprint](https://github.com/chordpli/spec-kit-blueprint) | | 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) | @@ -151,7 +152,6 @@ The following community-contributed extensions are available in [`catalog.commun | Spec Sync | Detect and resolve drift between specs and implementation. AI-assisted resolution with human approval | `docs` | Read+Write | [spec-kit-sync](https://github.com/bgervin/spec-kit-sync) | | Spec Trace | Build a requirement → test traceability matrix from spec.md and the test suite — surface untested requirements and orphan tests | `code` | Read+Write | [spec-kit-trace](https://github.com/Quratulain-bilal/spec-kit-trace) | | Spec Validate | Comprehension validation, review gating, and approval state for spec-kit artifacts — staged quizzes, peer review SLA, and a hard gate before /speckit.implement | `process` | Read+Write | [spec-kit-spec-validate](https://github.com/aeltayeb/spec-kit-spec-validate) | -| Spec-Kit BDD | ATDD/BDD extension: 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) | | Spec2Cloud | Spec-driven workflow tuned for shipping to Azure | `process` | Read+Write | [spec2cloud](https://github.com/Azure-Samples/Spec2Cloud) | | SpecAssay Check | Gate 2 refuses silent gaps and emits a trace-manifest (trace-manifest.json). | `visibility` | Read+Write | [specassay](https://github.com/rdryfoos/specassay) | | SpecJudge — right-size the model before you implement | Recommends the model that fits your tasks, citing the spec fragment behind every level. | `process` | Read-only | [SpecJudge](https://github.com/JoaquinRuiz/SpecJudge) | diff --git a/extensions/catalog.community.json b/extensions/catalog.community.json index cd162734e5..40dcf89ac6 100644 --- a/extensions/catalog.community.json +++ b/extensions/catalog.community.json @@ -605,15 +605,15 @@ "updated_at": "2026-03-03T00:00:00Z" }, "bdd": { - "name": "Spec-Kit BDD", + "name": "BDD", "id": "bdd", - "description": "ATDD/BDD extension: convert specs to Gherkin scenarios, scaffold step definitions, and verify acceptance test coverage.", + "description": "Convert specs to Gherkin scenarios, scaffold step definitions, and verify acceptance test coverage.", "author": "RSginer", - "version": "1.0.2", - "download_url": "https://github.com/RSginer/spec-kit-bdd/archive/refs/tags/v1.0.2.zip", + "version": "1.0.3", + "download_url": "https://github.com/RSginer/spec-kit-bdd/archive/refs/tags/v1.0.3.zip", "repository": "https://github.com/RSginer/spec-kit-bdd", - "homepage": "https://github.com/RSginer/spec-kit-bdd", - "documentation": "https://github.com/RSginer/spec-kit-bdd/blob/main/docs/usage.md", + "homepage": "https://rsginer.github.io/spec-kit-bdd/", + "documentation": "https://rsginer.github.io/spec-kit-bdd/", "changelog": "https://github.com/RSginer/spec-kit-bdd/releases", "license": "MIT", "category": "process", @@ -662,7 +662,7 @@ "downloads": 0, "stars": 0, "created_at": "2026-07-15T00:00:00Z", - "updated_at": "2026-07-15T00:00:00Z" + "updated_at": "2026-08-24T00:00:00Z" }, "blueprint": { "name": "Blueprint", From c6b0d3f53a7c63017e3d57b7b820ce2de8822076 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Tue, 25 Aug 2026 09:52:52 -0500 Subject: [PATCH 048/102] Update SpecKit Grill Me extension to v1.0.1 (#4317) Update grill extension submitted by @yoshi1220: - extensions/catalog.community.json (version, download_url, description, commands count) - docs/community/extensions.md community extensions table Closes #4315 Assisted-by: GitHub Copilot (model: claude-sonnet-4.6, autonomous) Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com> Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- docs/community/extensions.md | 2 +- extensions/catalog.community.json | 12 ++++++------ 2 files changed, 7 insertions(+), 7 deletions(-) diff --git a/docs/community/extensions.md b/docs/community/extensions.md index ad8ef61e80..7231cb8510 100644 --- a/docs/community/extensions.md +++ b/docs/community/extensions.md @@ -156,7 +156,7 @@ The following community-contributed extensions are available in [`catalog.commun | SpecAssay Check | Gate 2 refuses silent gaps and emits a trace-manifest (trace-manifest.json). | `visibility` | Read+Write | [specassay](https://github.com/rdryfoos/specassay) | | SpecJudge — right-size the model before you implement | Recommends the model that fits your tasks, citing the spec fragment behind every level. | `process` | Read-only | [SpecJudge](https://github.com/JoaquinRuiz/SpecJudge) | | SpecKit Companion | Live spec-driven progress — lifecycle capture, status, resume, living specs, and composable commands with hooks and recipes | `process` | Read+Write | [speckit-companion](https://github.com/alfredoperez/speckit-companion) | -| SpecKit Grill Me | Exhaustively resolve specification ambiguities and decisions before planning | `process` | Read+Write | [speckit-grill-me](https://github.com/yoshi1220/speckit-grill-me) | +| SpecKit Grill Me | Exhaustively clarify specifications and optionally sync canonical domain knowledge | `process` | Read+Write | [speckit-grill-me](https://github.com/yoshi1220/speckit-grill-me) | | SpecTest | Auto-generate test scaffolds from spec criteria, map coverage, and find untested requirements | `code` | Read+Write | [spec-kit-spectest](https://github.com/Quratulain-bilal/spec-kit-spectest) | | Squad Bridge | Bootstrap and synchronize a Squad agent team from your Speckit spec and tasks. | `process` | Read+Write | [spec-kit-squad](https://github.com/jwill824/spec-kit-squad) | | Staff Review Extension | Staff-engineer-level code review that validates implementation against spec, checks security, performance, and test coverage | `code` | Read-only | [spec-kit-staff-review](https://github.com/arunt14/spec-kit-staff-review) | diff --git a/extensions/catalog.community.json b/extensions/catalog.community.json index 40dcf89ac6..6a626dc1bf 100644 --- a/extensions/catalog.community.json +++ b/extensions/catalog.community.json @@ -1,6 +1,6 @@ { "schema_version": "1.0", - "updated_at": "2026-08-24T00:00:00Z", + "updated_at": "2026-08-25T00:00:00Z", "catalog_url": "https://raw.githubusercontent.com/github/spec-kit/main/extensions/catalog.community.json", "extensions": { "adrkit": { @@ -2099,10 +2099,10 @@ "grill": { "name": "SpecKit Grill Me", "id": "grill", - "description": "Exhaustively resolve specification ambiguities and decisions before planning.", + "description": "Exhaustively clarify specifications and optionally sync canonical domain knowledge.", "author": "yoshi1220", - "version": "1.0.0", - "download_url": "https://github.com/yoshi1220/speckit-grill-me/releases/download/v1.0.0/speckit-grill-me-extension-v1.0.0.zip", + "version": "1.0.1", + "download_url": "https://github.com/yoshi1220/speckit-grill-me/releases/download/v1.0.1/speckit-grill-me-extension-v1.0.1.zip", "repository": "https://github.com/yoshi1220/speckit-grill-me", "homepage": "https://github.com/yoshi1220/speckit-grill-me/tree/main/spec-kit-extension", "documentation": "https://github.com/yoshi1220/speckit-grill-me/blob/main/spec-kit-extension/README.md", @@ -2120,7 +2120,7 @@ ] }, "provides": { - "commands": 1, + "commands": 2, "hooks": 0 }, "tags": [ @@ -2134,7 +2134,7 @@ "downloads": 0, "stars": 0, "created_at": "2026-08-11T00:00:00Z", - "updated_at": "2026-08-11T00:00:00Z" + "updated_at": "2026-08-25T00:00:00Z" }, "harness": { "name": "Research Harness", From c58a8487461052b4fa65e626df167521d297b184 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Tue, 25 Aug 2026 10:35:22 -0500 Subject: [PATCH 049/102] Add Taco Review extension to community catalog (#4322) Add taco extension submitted by @Arcadia822 to: - extensions/catalog.community.json (alphabetical order) - docs/community/extensions.md community extensions table Closes #4309 Assisted-by: GitHub Copilot (model: claude-sonnet-4.6, autonomous) Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com> Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- docs/community/extensions.md | 1 + extensions/catalog.community.json | 42 ++++++++++++++++++++++++++++++- 2 files changed, 42 insertions(+), 1 deletion(-) diff --git a/docs/community/extensions.md b/docs/community/extensions.md index 7231cb8510..5f09149309 100644 --- a/docs/community/extensions.md +++ b/docs/community/extensions.md @@ -164,6 +164,7 @@ The following community-contributed extensions are available in [`catalog.commun | Superpowers Bridge | Bridges selected Superpowers disciplines into Spec Kit as evidence-first trust gates for agent workflows. | `process` | Read+Write | [superpowers-bridge](https://github.com/RbBtSn0w/spec-kit-extensions/tree/main/superpowers-bridge) | | Superpowers Implementation Bridge | Thin orchestrator between Spec Kit (design) and Superpowers (implementation). Cross-agent. | `process` | Read+Write | [speckit-superpowers-bridge](https://github.com/lihan3238/speckit-superpowers-bridge) | | Superspec | Bridges spec-kit with obra/superpowers (brainstorming, TDD, subagent, code-review) into a unified, resumable workflow with graceful degradation and session progress tracking | `process` | Read+Write | [superspec](https://github.com/WangX0111/superspec) | +| Taco Review | Packages Spec Kit features for human review and syncs edits and comments back. | `integration` | Read+Write | [taco](https://github.com/Arcadia822/taco) | | Tasks to GitHub Project | Publish and synchronize Spec Kit tasks as cards on a GitHub Project (v2) kanban board, with priority and status sync between spec.md/tasks.md and the board. | `integration` | Read+Write | [spec-kit-tasks-to-project](https://github.com/mancioshell/spec-kit-tasks-to-project) | | TDD Extension | Drives spec-kit implementation with tests: a language-agnostic red-green-refactor loop with a per-feature test list, recorded red and green evidence, and mutation-checked test strength. | `process` | Read+Write | [spec-kit-tdd](https://github.com/d0whc3r/spec-kit-tdd) | | Team Assign | Assign tasks.md items to human engineers, split into subtasks, and generate a per-engineer workboard | `process` | Read+Write | [spec-kit-team-assign](https://github.com/tarunkumarbhati/spec-kit-team-assign) | diff --git a/extensions/catalog.community.json b/extensions/catalog.community.json index 6a626dc1bf..2afc2592cf 100644 --- a/extensions/catalog.community.json +++ b/extensions/catalog.community.json @@ -1,6 +1,6 @@ { "schema_version": "1.0", - "updated_at": "2026-08-25T00:00:00Z", + "updated_at": "2026-08-25T15:08:04Z", "catalog_url": "https://raw.githubusercontent.com/github/spec-kit/main/extensions/catalog.community.json", "extensions": { "adrkit": { @@ -4995,6 +4995,46 @@ "created_at": "2026-03-02T00:00:00Z", "updated_at": "2026-03-02T00:00:00Z" }, + "taco": { + "name": "Taco Review", + "id": "taco", + "description": "Packages Spec Kit features for human review and syncs edits and comments back.", + "author": "Arcadia822", + "version": "0.3.1", + "download_url": "https://github.com/Arcadia822/taco/archive/refs/tags/v0.3.1.zip", + "repository": "https://github.com/Arcadia822/taco", + "homepage": "https://github.com/Arcadia822/taco", + "documentation": "https://github.com/Arcadia822/taco/blob/main/extensions/taco/README.md", + "changelog": "https://github.com/Arcadia822/taco/blob/v0.3.1/CHANGELOG.md", + "license": "MIT", + "category": "integration", + "effect": "read-write", + "requires": { + "speckit_version": ">=0.16.0,<2.0.0", + "tools": [ + { + "name": "node", + "version": ">=22", + "required": true + } + ] + }, + "provides": { + "commands": 2, + "hooks": 8 + }, + "tags": [ + "documentation", + "review", + "spec-kit", + "human-in-the-loop" + ], + "verified": false, + "downloads": 0, + "stars": 0, + "created_at": "2026-08-25T00:00:00Z", + "updated_at": "2026-08-25T00:00:00Z" + }, "tasks-to-project": { "name": "Tasks to GitHub Project", "id": "tasks-to-project", From 6fe81f3e95ebea9299cc0cbfabb499d9a5e282a3 Mon Sep 17 00:00:00 2001 From: Noor ul ain Date: Wed, 26 Aug 2026 22:00:00 +0500 Subject: [PATCH 050/102] fix(events): stop `event run` crashing on every piped stdin payload (#4326) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix(events): stop `event run` crashing on every piped stdin payload `event_run` (src/specify_cli/commands/event.py) capped its stdin read at 1 MiB to prevent a DoS (#3857), but the truncation check reads a `.eof` attribute that does not exist on any Python file-like object, including `sys.stdin` (`hasattr(sys.stdin, "eof")` is False). Every piped-stdin invocation raised `AttributeError: '...' object has no attribute 'eof'` instead of running — piped stdin is the command's documented primary use case (a native hook feeds it a JSON payload this way), and `isatty()` is False whenever stdin isn't an interactive terminal, so this fired on essentially every real invocation, not just oversized ones. Even the intended oversized-payload branch was broken a second way: `typer.Exit(code=1, message=...)` — `typer.Exit.__init__` only accepts `code`, not `message` — so that path raised `TypeError` instead of the documented clean error. Fix: detect truncation the standard way (read one more byte once the cap is hit; a non-empty result means more data was waiting beyond it), and report the oversized-payload error via `typer.echo(..., err=True)` before `raise typer.Exit(code=1)`. Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_01FW9fAYsCBCAgdKWovtSyqt * fix(events): enforce stdin cap in bytes and fix TTY-fallback test gap Address Copilot review feedback on PR #4326: - sys.stdin is a text stream, so reading MAX_STDIN_BYTES counted Unicode characters, not encoded bytes. A multibyte payload (e.g. ~300k emoji, ~1.14 MiB in UTF-8) could slip past the 1 MiB DoS guard. Read from sys.stdin.buffer instead so the cap counts real bytes, then decode. - The TTY-fallback test invoked via CliRunner, which always supplies a non-TTY stream even without input=, so it never exercised the `"{}"` fallback. Split it into an empty-pipe test (CliRunner) and a real TTY test that calls event_run directly with a mocked isatty()=True stdin. - Added a regression test proving the byte-vs-character cap distinction. Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_01PJHJ2dHP2RVCNncHqN8Qm9 * Potential fix for pull request finding Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> * test(events): cover invalid-UTF-8 stdin negative path Reviewer noted the new UnicodeDecodeError guard in event_run had no test proving it exits cleanly instead of leaking a raw UnicodeDecodeError. Add a case piping invalid UTF-8 (b"\xff\xfe") and assert exit code 1, the "must be valid UTF-8" message, and that the handler is never invoked. Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_01M9aV6DhKNL7k3HreTczcb1 --------- Co-authored-by: Claude Sonnet 5 Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --- src/specify_cli/commands/event.py | 23 +++-- tests/test_event_command.py | 145 ++++++++++++++++++++++++++++++ 2 files changed, 162 insertions(+), 6 deletions(-) create mode 100644 tests/test_event_command.py diff --git a/src/specify_cli/commands/event.py b/src/specify_cli/commands/event.py index d1576c2c70..47bcf86e70 100644 --- a/src/specify_cli/commands/event.py +++ b/src/specify_cli/commands/event.py @@ -27,14 +27,25 @@ def event_run( # Read payload from stdin if available (capped at 1 MiB to prevent DoS). MAX_STDIN_BYTES = 1 * 1024 * 1024 if not sys.stdin.isatty(): - raw = sys.stdin.read(MAX_STDIN_BYTES) - if not sys.stdin.eof: - raise typer.Exit( - code=1, - message="stdin payload exceeds 1 MiB limit; " + # Read from the underlying binary buffer so the cap counts encoded + # bytes, not decoded characters — `sys.stdin.read()` on a text stream + # counts Unicode characters, which lets multibyte payloads (e.g. a + # few hundred thousand emoji) exceed 1 MiB on the wire while still + # passing the length check. Reading one byte past the cap tells us + # whether more data was waiting beyond it. + raw = sys.stdin.buffer.read(MAX_STDIN_BYTES + 1) + if len(raw) > MAX_STDIN_BYTES: + typer.echo( + "stdin payload exceeds 1 MiB limit; " "truncate or pipe a smaller payload", + err=True, ) - payload = raw + raise typer.Exit(code=1) + try: + payload = raw.decode("utf-8") + except UnicodeDecodeError: + typer.echo("stdin payload must be valid UTF-8", err=True) + raise typer.Exit(code=1) from None else: payload = "{}" diff --git a/tests/test_event_command.py b/tests/test_event_command.py new file mode 100644 index 0000000000..0a431fb5c6 --- /dev/null +++ b/tests/test_event_command.py @@ -0,0 +1,145 @@ +"""`specify event run` must read piped stdin without crashing. + +`event_run` (src/specify_cli/commands/event.py) capped its stdin read at 1 +MiB to prevent a DoS (#3857), but the truncation check read a `.eof` +attribute that does not exist on any Python file-like object (including +`sys.stdin`) — every piped-stdin invocation raised `AttributeError` instead +of running, regardless of payload size. Piped stdin is the command's +documented primary use case (it is how a native hook feeds it a JSON +payload), so this broke the feature entirely rather than only rejecting +oversized payloads. Even the intended oversized-payload branch was broken a +second way: `typer.Exit(code=1, message=...)` — `typer.Exit` accepts no +`message` keyword argument, so that path raised `TypeError` instead of a +clean CLI error. +""" + +from __future__ import annotations + +from unittest.mock import patch + +import pytest +import typer +from typer.testing import CliRunner + +from specify_cli import app +from specify_cli.commands.event import event_run + + +def test_event_run_reads_piped_stdin_payload(): + """A normal, under-the-cap piped payload must reach the handler intact.""" + with patch( + "specify_cli.events.resolve_and_run_event_command", return_value=0 + ) as mock_run: + result = CliRunner().invoke( + app, + ["event", "run", "some-command", "session_start"], + input='{"key": "value"}', + ) + + assert result.exit_code == 0, result.output + assert mock_run.called + payload_arg = mock_run.call_args[0][2] + assert payload_arg == '{"key": "value"}' + + +def test_event_run_empty_pipe_reads_empty_payload(): + """An empty (but non-TTY) piped stream must not crash; it forwards `""`. + + CliRunner always provides a non-TTY stdin, even when no `input=` is + given, so this exercises the piped-input branch with zero bytes — not + the TTY fallback. See `test_event_run_tty_uses_empty_object` below for + the actual TTY case. + """ + with patch( + "specify_cli.events.resolve_and_run_event_command", return_value=0 + ) as mock_run: + result = CliRunner().invoke( + app, + ["event", "run", "some-command", "session_start"], + ) + + assert result.exit_code == 0, result.output + assert mock_run.called + payload_arg = mock_run.call_args[0][2] + assert payload_arg == "" + + +def test_event_run_tty_uses_empty_object(monkeypatch): + """A real TTY (no piped input at all) must fall back to `"{}"`.""" + + class FakeTtyStdin: + def isatty(self): + return True + + monkeypatch.setattr("specify_cli.commands.event.sys.stdin", FakeTtyStdin()) + + with patch( + "specify_cli.events.resolve_and_run_event_command", return_value=0 + ) as mock_run: + with pytest.raises(typer.Exit): + event_run(command_name="some-command", event_name="session_start", timeout=120) + + assert mock_run.called + payload_arg = mock_run.call_args[0][2] + assert payload_arg == "{}" + + +def test_event_run_oversized_stdin_reports_clean_error(): + """A payload exceeding the 1 MiB cap must exit 1 with the limit message, + not crash with AttributeError (missing `.eof`) or TypeError (`typer.Exit` + does not accept `message=`).""" + oversized = "x" * (1 * 1024 * 1024 + 10) + with patch( + "specify_cli.events.resolve_and_run_event_command", return_value=0 + ) as mock_run: + result = CliRunner().invoke( + app, + ["event", "run", "some-command", "session_start"], + input=oversized, + ) + + assert result.exit_code == 1, result.output + assert "1 MiB limit" in result.output + assert not mock_run.called + + +def test_event_run_invalid_utf8_reports_clean_error(): + """A piped payload that isn't valid UTF-8 must exit 1 with the encoding + error message, not propagate a raw `UnicodeDecodeError`, and the handler + must never be invoked with undecodable data.""" + with patch( + "specify_cli.events.resolve_and_run_event_command", return_value=0 + ) as mock_run: + result = CliRunner().invoke( + app, + ["event", "run", "some-command", "session_start"], + input=b"\xff\xfe", + ) + + assert result.exit_code == 1, result.output + assert "must be valid UTF-8" in result.output + assert not mock_run.called + + +def test_event_run_multibyte_payload_enforces_byte_limit(): + """The 1 MiB cap must be enforced in encoded bytes, not decoded characters. + + 300,000 emoji is ~1.14 MiB of UTF-8 (4 bytes each) but only 300,000 + *characters* — comfortably under the 1,048,576 character cap a text-mode + `sys.stdin.read(MAX_STDIN_BYTES)` would have applied. Reading from the + binary buffer instead must still reject it. + """ + oversized = "\U0001F600" * 300_000 # 😀, 4 bytes each in UTF-8 + assert len(oversized) < 1 * 1024 * 1024 # under the old, wrong character cap + with patch( + "specify_cli.events.resolve_and_run_event_command", return_value=0 + ) as mock_run: + result = CliRunner().invoke( + app, + ["event", "run", "some-command", "session_start"], + input=oversized, + ) + + assert result.exit_code == 1, result.output + assert "1 MiB limit" in result.output + assert not mock_run.called From 241d9163640603beb8e2ef1d1223756c7ccdfdb3 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Wed, 26 Aug 2026 12:42:06 -0500 Subject: [PATCH 051/102] Add Verified Codebase Context preset to community catalog (#4344) Add codebase-memory-context preset submitted by @philo-x to: - presets/catalog.community.json (alphabetical order) - docs/community/presets.md community presets table Closes #4327 Assisted-by: GitHub Copilot (model: claude-sonnet-4.6, autonomous) Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com> Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- docs/community/presets.md | 1 + presets/catalog.community.json | 29 ++++++++++++++++++++++++++++- 2 files changed, 29 insertions(+), 1 deletion(-) diff --git a/docs/community/presets.md b/docs/community/presets.md index 9963157f3f..69a1523343 100644 --- a/docs/community/presets.md +++ b/docs/community/presets.md @@ -38,6 +38,7 @@ The following community-contributed presets customize how Spec Kit behaves — o | SpecAssay | Appends durable-ID, Carries, and SpecAssay vocabulary onto Spec Kit spec, tasks, and constitution templates. | 3 templates | — | [specassay](https://github.com/rdryfoos/specassay) | | Table of Contents Navigation | Adds a navigable Table of Contents to generated spec.md, plan.md, and tasks.md documents | 3 templates, 3 commands | — | [spec-kit-preset-toc-navigation](https://github.com/Quratulain-bilal/spec-kit-preset-toc-navigation) | | Test-First Governance | Governs TDD with coverage-complete BDD/ATDD Gherkin scenarios, explicit suite ownership, professional test reports, traceability, and risk-based quality gates. | 10 templates, 8 commands | — | [spec-kit-preset-test-first-governance](https://github.com/ka-zo/spec-kit-preset-test-first-governance) | +| Verified Codebase Context | Generates evidence-qualified repository context with codebase-memory-mcp and applies it across planning, tasks, analysis, and implementation. | 1 template, 5 commands | — | [spec-kit-preset-codebase-memory-context](https://github.com/philo-x/spec-kit-preset-codebase-memory-context) | | VS Code Ask Questions | Enhances the clarify command to use `vscode/askQuestions` for batched interactive questioning. | 1 command | — | [spec-kit-presets](https://github.com/fdcastel/spec-kit-presets) | | Workflow Preset | Behavior-first specification, design artifacts, and agent-native handoff orchestration — adds requirement-phase behavior drafts, formal BDD/UIF/behavior contracts, optional design artifacts, and scoped implementation handoffs with Core Agent, Vertical Planner Agent, and Worker Agent modes | 22 templates, 8 commands | — | [spec-kit-workflow-preset](https://github.com/bigsmartben/spec-kit-workflow-preset) | diff --git a/presets/catalog.community.json b/presets/catalog.community.json index 36d62b6ab3..3f608378a0 100644 --- a/presets/catalog.community.json +++ b/presets/catalog.community.json @@ -1,6 +1,6 @@ { "schema_version": "1.0", - "updated_at": "2026-08-24T00:00:00Z", + "updated_at": "2026-08-26T00:00:00Z", "catalog_url": "https://raw.githubusercontent.com/github/spec-kit/main/presets/catalog.community.json", "presets": { "a11y-governance": { @@ -224,6 +224,33 @@ "created_at": "2026-08-19T00:00:00Z", "updated_at": "2026-08-19T00:00:00Z" }, + "codebase-memory-context": { + "name": "Verified Codebase Context", + "id": "codebase-memory-context", + "version": "1.0.1", + "description": "Generates evidence-qualified repository context with codebase-memory-mcp and applies it across planning, tasks, analysis, and implementation.", + "author": "Xu Yin (philo-x)", + "repository": "https://github.com/philo-x/spec-kit-preset-codebase-memory-context", + "download_url": "https://github.com/philo-x/spec-kit-preset-codebase-memory-context/archive/refs/tags/v1.0.1.zip", + "homepage": "https://github.com/philo-x/spec-kit-preset-codebase-memory-context", + "documentation": "https://github.com/philo-x/spec-kit-preset-codebase-memory-context/blob/v1.0.1/README.md", + "license": "MIT", + "requires": { + "speckit_version": ">=1.0.1" + }, + "provides": { + "templates": 1, + "commands": 5 + }, + "tags": [ + "code-intelligence", + "codebase-memory", + "architecture", + "workflow" + ], + "created_at": "2026-08-26T00:00:00Z", + "updated_at": "2026-08-26T00:00:00Z" + }, "command-density": { "name": "Command Density", "id": "command-density", From e595201f097e4c5beb13ad87e0a1e9840576164e Mon Sep 17 00:00:00 2001 From: Noor ul ain Date: Fri, 28 Aug 2026 02:16:09 +0500 Subject: [PATCH 052/102] fix(presets): validate catalog URL port, not just hostname (#4341) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `PresetCatalog._validate_catalog_url` is a standalone reimplementation (PresetCatalog does not inherit `CatalogStackBase`) of the same guard that exists in `specify_cli.catalogs` and `bundler/services/adapters.py`, but it had drifted: it accessed `parsed.hostname` inside the malformed-URL try/except but never `parsed.port`. `urlparse(url).hostname` does not perform port validation — only accessing `.port` does, lazily. So a catalog URL like `https://example.com:99999/catalog.json` (or a non-numeric port) sailed straight through this validator with no error at all, and would only fail later, at actual fetch time, with a raw untranslated exception instead of the clean `PresetValidationError` this function's docstring promises. The sibling `preset add --from ` download-URL guard already probes `.port` for exactly this reason (see `test_preset_add_from_url_out_of_range_port_exits_cleanly`) — this catalog-*source*-URL validator is a different function that just never got the same fix applied to it. Fix: add the same `_ = parsed.port` probe inside the try/except, matching `specify_cli.catalogs._validate_catalog_url` and `bundler/services/adapters.py::_validate_remote_url`. ## Test plan - Added `test_validate_catalog_url_out_of_range_port_rejected` to `tests/test_presets.py::TestPresetCatalog`, next to the existing malformed-IPv6-URL test for the same function. - Verified the test fails without the fix (test-the-test): asserted `PresetValidationError` but got `Failed: DID NOT RAISE` — the malformed port silently passed validation. - Ran `TestPresetCatalog`/`TestPresetCatalogMultiCatalog`/ `TestPresetCatalogEntry`/`TestPresetCatalogRichMarkup` (106 tests) and the full `tests/test_presets.py` suite (605 passed, 8 pre-existing Windows symlink-elevation failures needing admin rights, unrelated to this change, 2 skipped). Claude-Session: https://claude.ai/code/session_01PJHJ2dHP2RVCNncHqN8Qm9 Co-authored-by: Claude Sonnet 5 --- src/specify_cli/presets/__init__.py | 7 +++++++ tests/test_presets.py | 19 +++++++++++++++++++ 2 files changed, 26 insertions(+) diff --git a/src/specify_cli/presets/__init__.py b/src/specify_cli/presets/__init__.py index a5cea4f958..b8c8f390db 100644 --- a/src/specify_cli/presets/__init__.py +++ b/src/specify_cli/presets/__init__.py @@ -4165,6 +4165,13 @@ def _validate_catalog_url(self, url: str) -> None: try: parsed = urlparse(url) hostname = parsed.hostname + # Accessing ``port`` performs urllib's syntax/range validation; + # ``hostname`` alone does not, so a non-numeric or out-of-range + # port would otherwise pass validation here and only fail later, + # at fetch time, as a raw error this function does not translate + # into PresetValidationError. Mirrors specify_cli.catalogs and + # bundler/services/adapters.py's copy of this same guard. + _ = parsed.port except ValueError: raise PresetValidationError(f"Catalog URL is malformed: {url}") from None is_localhost = hostname in ("localhost", "127.0.0.1", "::1") diff --git a/tests/test_presets.py b/tests/test_presets.py index f30ab4909e..8f9a2e358f 100644 --- a/tests/test_presets.py +++ b/tests/test_presets.py @@ -2086,6 +2086,25 @@ def test_validate_catalog_url_malformed_rejected(self, project_dir): with pytest.raises(PresetValidationError, match="malformed"): catalog._validate_catalog_url("https://[::1") + def test_validate_catalog_url_out_of_range_port_rejected(self, project_dir): + """An out-of-range port raises ValueError lazily on ``.port`` access. + + ``urlparse(...).hostname`` alone does not validate the port, so + without a ``_ = parsed.port`` probe inside the try/except, a URL like + ``https://example.com:99999/catalog.json`` sails through this + validator and only fails later, at fetch time, with a raw + untranslated error instead of a clean ``PresetValidationError``. The + sibling ``preset add --from `` download-URL guard already + catches this shape (see + ``test_preset_add_from_url_out_of_range_port_exits_cleanly``); this + catalog-source-URL validator had drifted from it and from the + original guard in ``specify_cli.catalogs``/ + ``bundler/services/adapters.py``. + """ + catalog = PresetCatalog(project_dir) + with pytest.raises(PresetValidationError, match="malformed"): + catalog._validate_catalog_url("https://example.com:99999/catalog.json") + def test_env_var_catalog_url(self, project_dir, monkeypatch): """Test catalog URL from environment variable.""" monkeypatch.setenv("SPECKIT_PRESET_CATALOG_URL", "https://custom.example.com/catalog.json") From 5aa8bea7823dcd056f111f847bf2d576bad3f0a5 Mon Sep 17 00:00:00 2001 From: WOLIKIMCHENG <35391914+WOLIKIMCHENG@users.noreply.github.com> Date: Fri, 28 Aug 2026 06:03:29 +0800 Subject: [PATCH 053/102] docs(workflows): document Python init script support (#4331) Co-authored-by: root --- workflows/README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/workflows/README.md b/workflows/README.md index bd0b767938..d5569541a2 100644 --- a/workflows/README.md +++ b/workflows/README.md @@ -145,7 +145,7 @@ and resolves the integration from the step config or the workflow default: here: true # or: project: my-project integration: copilot # Optional: defaults to workflow integration integration_options: "--skills" # Optional: extra options for the integration - script: sh # Optional: sh or ps + script: sh # Optional: sh, ps, or py force: true # Optional: required when target directory already exists preset: healthcare-compliance # Optional preset ID ``` From 241eaca090655b9fb4349696b1d619e78bd16db9 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Fri, 28 Aug 2026 09:20:28 -0500 Subject: [PATCH 054/102] Add Pre-Spec Cards extension to community catalog (#4365) Add prespec extension submitted by @bendlikeabamboo to: - extensions/catalog.community.json (alphabetical order) - docs/community/extensions.md community extensions table Closes #4360 Assisted-by: GitHub Copilot (model: claude-sonnet-4.6, autonomous) Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com> Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- 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 5f09149309..ec5ea6f3a2 100644 --- a/docs/community/extensions.md +++ b/docs/community/extensions.md @@ -111,6 +111,7 @@ The following community-contributed extensions are available in [`catalog.commun | PatchWarden Evidence Pack | Map Spec Kit tasks into a guarded PatchWarden Goal and export bounded, traceable evidence for an accepted lineage. | `process` | Read+Write | [spec-kit-patchwarden](https://github.com/jiezeng2004-design/spec-kit-patchwarden) | | Plan Review Gate | Require spec.md and plan.md to be merged via MR/PR before allowing task generation | `process` | Read-only | [spec-kit-plan-review-gate](https://github.com/luno/spec-kit-plan-review-gate) | | PR Bridge | Auto-generate pull request descriptions, checklists, and summaries from spec artifacts | `process` | Read-only | [spec-kit-pr-bridge-](https://github.com/Quratulain-bilal/spec-kit-pr-bridge-) | +| Pre-Spec Cards | Card-based pre-spec thinking: paste an idea, get your card plus the paths you'd miss, then play each through — story, snags, trade-offs, difficulty vs payoff — before /speckit.specify | `process` | Read+Write | [pre-spec](https://github.com/bendlikeabamboo/pre-spec) | | 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) | diff --git a/extensions/catalog.community.json b/extensions/catalog.community.json index 2afc2592cf..34f315edc2 100644 --- a/extensions/catalog.community.json +++ b/extensions/catalog.community.json @@ -1,6 +1,6 @@ { "schema_version": "1.0", - "updated_at": "2026-08-25T15:08:04Z", + "updated_at": "2026-08-28T00:00:00Z", "catalog_url": "https://raw.githubusercontent.com/github/spec-kit/main/extensions/catalog.community.json", "extensions": { "adrkit": { @@ -3590,6 +3590,40 @@ "created_at": "2026-03-18T00:00:00Z", "updated_at": "2026-03-18T00:00:00Z" }, + "prespec": { + "name": "Pre-Spec Cards", + "id": "prespec", + "description": "Card-based pre-spec thinking: paste an idea, get your card plus the paths you'd miss, then play each through — story, snags, trade-offs, difficulty vs payoff — before /speckit.specify.", + "author": "bendlikeabamboo", + "version": "0.3.0", + "download_url": "https://github.com/bendlikeabamboo/pre-spec/archive/refs/tags/v0.3.0.zip", + "repository": "https://github.com/bendlikeabamboo/pre-spec", + "homepage": "https://github.com/bendlikeabamboo/pre-spec", + "documentation": "https://github.com/bendlikeabamboo/pre-spec#readme", + "changelog": "https://github.com/bendlikeabamboo/pre-spec/releases", + "license": "MIT", + "category": "process", + "effect": "read-write", + "requires": { + "speckit_version": ">=0.9.0" + }, + "provides": { + "commands": 2, + "hooks": 0 + }, + "tags": [ + "discovery", + "ideation", + "decision-cards", + "product", + "workflow" + ], + "verified": false, + "downloads": 0, + "stars": 0, + "created_at": "2026-08-28T00:00:00Z", + "updated_at": "2026-08-28T00:00:00Z" + }, "preview": { "name": "Spec Kit Preview", "id": "preview", From 2dc229bccb21acec3061db204a427fc0a354bb01 Mon Sep 17 00:00:00 2001 From: Noor ul ain Date: Fri, 28 Aug 2026 21:22:53 +0500 Subject: [PATCH 055/102] fix(events): stop falling back to a fake "pwsh" argv when no launcher exists (#4340) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `_resolve_event_command_argv`'s `ps` branch did: launcher = shutil.which("pwsh") or shutil.which("powershell") or "pwsh" When neither `pwsh` nor `powershell` is on PATH, this silently returns `["pwsh", "-File",