diff --git a/bundles/assess/README.md b/bundles/assess/README.md new file mode 100644 index 0000000000..e139af5754 --- /dev/null +++ b/bundles/assess/README.md @@ -0,0 +1,40 @@ +# Idea Assessment Bundle + +A first-party GitHub Spec Kit bundle that installs an idea-triage pipeline before committing to Spec-Driven Development. + +## What it provides + +- **Assess extension** (`extensions/assess`) — the `speckit.assess.intake`, `speckit.assess.research`, `speckit.assess.define`, `speckit.assess.shape`, and `speckit.assess.decide` commands. +- **Assess workflow** (`workflows/assess`) — a guided, resumable pipeline: + 1. `intake` the raw idea. + 2. `research` the evidence. + 3. `define` the problem. + 4. `shape` the concept. + 5. `decide` the verdict. + 6. `review-verdict` gate — approve to complete the assessment; reject to abort. A `go` verdict is then handed off manually to `/speckit.specify`. + +## Install + +```bash +specify bundle install assess +# or +specify bundle add assess +``` + +## Run the workflow + +```bash +specify workflow run assess \ + --input idea="Let users work offline and sync when they reconnect" \ + --input slug="offline-mode" +``` + +Inputs omitted from the command line are prompted interactively. The slug is used as the working directory under `.specify/assessments//` for all artifacts. + +## Remove + +```bash +specify bundle remove assess +``` + +Removing the bundle uninstalls the workflow and the extension it contributed, unless they are still depended on by another installed bundle (FR-022). Components you installed independently are not attributed to this bundle and survive removal. diff --git a/bundles/assess/bundle.yml b/bundles/assess/bundle.yml new file mode 100644 index 0000000000..d788a5e7de --- /dev/null +++ b/bundles/assess/bundle.yml @@ -0,0 +1,25 @@ +schema_version: "1.0" + +bundle: + id: "assess" + name: "Idea Assessment Pipeline" + version: "1.0.0" + role: "developer" + description: "Idea triage before Spec-Driven Development: intake, research, define, shape, decide with a verdict review gate; surviving ideas hand off manually to the specify command." + author: "GitHub" + license: "MIT" + +requires: + speckit_version: ">=0.9.0" + tools: [] + mcp: [] + +provides: + extensions: + - id: "assess" + version: "1.0.0" + workflows: + - id: "assess" + version: "1.0.0" + +tags: ["assessment", "discovery", "triage", "product"] diff --git a/bundles/bugfix/README.md b/bundles/bugfix/README.md new file mode 100644 index 0000000000..6116cff36a --- /dev/null +++ b/bundles/bugfix/README.md @@ -0,0 +1,38 @@ +# Bug Fix Bundle + +A first-party GitHub Spec Kit bundle that installs an orchestrated bug-fixing pipeline. + +## What it provides + +- **Bug extension** (`extensions/bug`) — the `speckit.bug.assess`, `speckit.bug.fix`, and `speckit.bug.test` commands. +- **Bugfix workflow** (`workflows/bugfix`) — a guided, resumable pipeline: + 1. `assess` the bug report. + 2. `review-assessment` gate — approve to proceed, reject to abort. + 3. `fix` the bug. + 4. `test` the fix. + +## Install + +```bash +specify bundle install bugfix +# or +specify bundle add bugfix +``` + +## Run the workflow + +```bash +specify workflow run bugfix \ + --input report="https://github.com/example/repo/issues/1234" \ + --input slug="callback-token" +``` + +Inputs omitted from the command line are prompted interactively. The slug is used as the working directory under `.specify/bugs//` for all artifacts. + +## Remove + +```bash +specify bundle remove bugfix +``` + +Removing the bundle uninstalls the workflow and the extension it contributed, unless they are still depended on by another installed bundle (FR-022). Components you installed independently are not attributed to this bundle and survive removal. diff --git a/bundles/bugfix/bundle.yml b/bundles/bugfix/bundle.yml new file mode 100644 index 0000000000..8c81f76679 --- /dev/null +++ b/bundles/bugfix/bundle.yml @@ -0,0 +1,25 @@ +schema_version: "1.0" + +bundle: + id: "bugfix" + name: "Guided Bug Fix" + version: "1.0.0" + role: "developer" + description: "Orchestrated bug triage: assess a bug report, review the assessment behind a human gate, apply the fix, and verify it with tests." + author: "GitHub" + license: "MIT" + +requires: + speckit_version: ">=0.9.0" + tools: [] + mcp: [] + +provides: + extensions: + - id: "bug" + version: "1.0.0" + workflows: + - id: "bugfix" + version: "1.0.0" + +tags: ["bug", "triage", "workflow", "qa"] diff --git a/bundles/catalog.json b/bundles/catalog.json new file mode 100644 index 0000000000..789bdd149b --- /dev/null +++ b/bundles/catalog.json @@ -0,0 +1,51 @@ +{ + "schema_version": "1.0", + "updated_at": "2026-09-10T00:00:00Z", + "catalog_url": "https://raw.githubusercontent.com/github/spec-kit/main/bundles/catalog.json", + "bundles": { + "bugfix": { + "id": "bugfix", + "name": "Guided Bug Fix", + "version": "1.0.0", + "role": "developer", + "description": "Orchestrated bug triage: assess a bug report, review behind a human gate, apply the fix, and verify with tests.", + "author": "GitHub", + "license": "MIT", + "download_url": "https://raw.githubusercontent.com/github/spec-kit/main/bundles/bugfix/bundle.yml", + "repository": "https://github.com/github/spec-kit", + "requires": { + "speckit_version": ">=0.9.0" + }, + "provides": { + "extensions": 1, + "presets": 0, + "steps": 0, + "workflows": 1 + }, + "tags": ["bug", "triage", "workflow", "qa"], + "verified": true + }, + "assess": { + "id": "assess", + "name": "Idea Assessment Pipeline", + "version": "1.0.0", + "role": "developer", + "description": "Idea triage before Spec-Driven Development: intake, research, define, shape, decide with a verdict review gate; surviving ideas hand off manually to specify.", + "author": "GitHub", + "license": "MIT", + "download_url": "https://raw.githubusercontent.com/github/spec-kit/main/bundles/assess/bundle.yml", + "repository": "https://github.com/github/spec-kit", + "requires": { + "speckit_version": ">=0.9.0" + }, + "provides": { + "extensions": 1, + "presets": 0, + "steps": 0, + "workflows": 1 + }, + "tags": ["assessment", "discovery", "triage", "product"], + "verified": true + } + } +} diff --git a/docs/reference/bundles.md b/docs/reference/bundles.md index 2bd33c960b..998ef8646b 100644 --- a/docs/reference/bundles.md +++ b/docs/reference/bundles.md @@ -4,6 +4,24 @@ Bundles compose existing Spec Kit components — extensions, presets, workflows, A bundle is described by a `bundle.yml` manifest and is discovered through the same catalog stack as other components. Installing a bundle resolves its declared components against pinned versions, checks for the single cross-bundle conflict point (the active integration), and applies each component idempotently with full provenance tracking so it can be cleanly removed or refreshed later. +## First-party Bundles + +Spec Kit ships a first-party bundle catalog in `bundles/catalog.json`. These bundles are curated, marked `verified: true`, and resolve through the built-in `builtin://default` catalog source. + +| Bundle | Role | Components | Use case | +| --------- | ----------- | ----------------------------------------------------- | --------------------------------------- | +| `bugfix` | `developer` | `bug` extension + `bugfix` workflow | Guided assess → gate → fix → test | +| `assess` | `developer` | `assess` extension + `assess` workflow | Idea triage before Spec-Driven Development | + +Install a first-party bundle the same way you install any bundle (`add` is an alias for `install`): + +```bash +specify bundle install bugfix +specify bundle add assess +``` + +The first-party catalog is fetched from the repository online and falls back to the packaged wheel snapshot offline so discovery works without network access. A local bundle manifest can install bundled extensions and workflows with `--offline`. Catalog-discovered bundle manifests still resolve from their `download_url`, so `specify bundle add ` requires network today; fully offline catalog installation is tracked as follow-up work. + ## Search Available Bundles ```bash @@ -128,7 +146,10 @@ If your bundle references components from non-default catalogs, document those c ## Manage Catalog Sources -Bundles are discovered through a priority-ordered stack of catalog sources (project, user, and built-in scopes). +Bundles are discovered through a priority-ordered stack of catalog sources (project, user, and built-in scopes). The built-in sources are: + +- `builtin://default` — first-party bundles shipped in `bundles/catalog.json` (`bugfix`, `assess`, ...), install-allowed. +- `builtin://community` — community submissions in `bundles/catalog.community.json`, discovery-only. ### List the Catalog Stack diff --git a/pyproject.toml b/pyproject.toml index 06e8f5df56..b1573aece9 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -45,12 +45,15 @@ packages = ["src/specify_cli"] "extensions/agent-context" = "specify_cli/core_pack/extensions/agent-context" "extensions/assess" = "specify_cli/core_pack/extensions/assess" "extensions/bug" = "specify_cli/core_pack/extensions/bug" -# Bundled workflows (auto-installed during `specify init`) +# Bundled workflows (auto-installed during `specify init` or via first-party bundles) "workflows/speckit" = "specify_cli/core_pack/workflows/speckit" +"workflows/bugfix" = "specify_cli/core_pack/workflows/bugfix" +"workflows/assess" = "specify_cli/core_pack/workflows/assess" # Bundled presets (installable via `specify preset add ` or `specify init --preset `) "presets/lean" = "specify_cli/core_pack/presets/lean" "presets/constitution-sync" = "specify_cli/core_pack/presets/constitution-sync" -# Community bundle catalog snapshot (used for offline discovery) +# Bundle catalog snapshots (used for offline discovery) +"bundles/catalog.json" = "specify_cli/core_pack/bundles/catalog.json" "bundles/catalog.community.json" = "specify_cli/core_pack/bundles/catalog.community.json" [project.optional-dependencies] diff --git a/src/specify_cli/bundler/services/adapters.py b/src/specify_cli/bundler/services/adapters.py index ca39a2489b..b855f3a4e7 100644 --- a/src/specify_cli/bundler/services/adapters.py +++ b/src/specify_cli/bundler/services/adapters.py @@ -26,16 +26,22 @@ "https://raw.githubusercontent.com/github/spec-kit/main/" "bundles/catalog.community.json" ) +FIRSTPARTY_CATALOG_URL = ( + "https://raw.githubusercontent.com/github/spec-kit/main/" + "bundles/catalog.json" +) -# The default catalog is reserved for first-party bundles. The community -# catalog is loaded from the repository online and from the packaged snapshot -# offline so discovery remains useful without network access. -_BUILTIN_CATALOGS: dict[str, dict] = { - "builtin://default": { - "schema_version": "1.0", - "catalog_url": "builtin://default", - "bundles": {}, - }, +# Built-in catalogs are resolved directly by URL. ``builtin://default`` is the +# repository-shipped first-party bundle catalog; ``builtin://community`` is the +# community catalog. Both are fetched from the repository online and fall back +# to the packaged wheel snapshot offline so discovery works without network. +_BUILTIN_REPOSITORY_URLS: dict[str, str] = { + "builtin://default": FIRSTPARTY_CATALOG_URL, + "builtin://community": COMMUNITY_CATALOG_URL, +} +_BUILTIN_PACKAGED_SNAPSHOTS: dict[str, str] = { + "builtin://default": "catalog.json", + "builtin://community": "catalog.community.json", } HTTP_TIMEOUT_SECONDS = 10 @@ -99,15 +105,16 @@ def _validate_remote_url(source_id: str, url: str) -> None: ) -def _load_packaged_community_catalog() -> dict: +def _load_packaged_catalog(filename: str) -> dict: + """Load a packaged bundle catalog snapshot from the wheel or repo root.""" core_pack = _locate_core_pack() path = ( - core_pack / "bundles" / "catalog.community.json" + core_pack / "bundles" / filename if core_pack is not None - else _repo_root() / "bundles" / "catalog.community.json" + else _repo_root() / "bundles" / filename ) if not path.is_file(): - raise BundlerError(f"Bundled community catalog not found: {path}") + raise BundlerError(f"Bundled catalog not found: {path}") return loads_json(path.read_text(encoding="utf-8"), origin=str(path)) @@ -132,14 +139,12 @@ def fetch(source: CatalogSource) -> dict: scheme = parsed.scheme.lower() if scheme == "builtin": - if url == "builtin://community": - if allow_network: - return _http_get_json(source.id, COMMUNITY_CATALOG_URL) - return _load_packaged_community_catalog() - payload = _BUILTIN_CATALOGS.get(url) - if payload is None: + repository_url = _BUILTIN_REPOSITORY_URLS.get(url) + if repository_url is None: raise BundlerError(f"Unknown built-in catalog '{url}'.") - return payload + if allow_network: + return _http_get_json(source.id, repository_url) + return _load_packaged_catalog(_BUILTIN_PACKAGED_SNAPSHOTS[url]) if scheme == "file": path = _file_url_to_path(parsed) diff --git a/src/specify_cli/bundler/services/primitives.py b/src/specify_cli/bundler/services/primitives.py index 68a9c47632..29fce13d9b 100644 --- a/src/specify_cli/bundler/services/primitives.py +++ b/src/specify_cli/bundler/services/primitives.py @@ -327,7 +327,38 @@ def is_installed(self, component: ComponentRef) -> bool: return False def install(self, component: ComponentRef) -> None: - if not self._allow_network and not self._is_bundled(component.id): + from ..._assets import _locate_bundled_workflow + + bundled = _locate_bundled_workflow(component.id) + if bundled is not None: + workflow_file = bundled / "workflow.yml" + try: + from ...workflows.engine import WorkflowDefinition + + definition = WorkflowDefinition.from_yaml(workflow_file) + except (OSError, ValueError) as exc: + raise BundlerError( + f"Failed to load bundled workflow '{component.id}': {exc}" + ) from exc + if definition.id != component.id: + raise BundlerError( + f"Bundled workflow at {workflow_file} declares ID " + f"'{definition.id}', expected '{component.id}'." + ) + _assert_pinned_version( + "Workflow", component.id, component.version, definition.version + ) + from ... import workflow_add + + with _chdir(self._root): + _delegate_command( + "install", + f"workflow '{component.id}'", + lambda: workflow_add(str(workflow_file), dev=True, from_url=None), + ) + return + + if not self._allow_network: raise BundlerError( f"Workflow '{component.id}' installs from a catalog and network " f"access is disabled; re-run without --offline or install it first " @@ -361,13 +392,6 @@ def _assert_pinned_version(self, component: ComponentRef) -> None: "Workflow", component.id, component.version, info.get("version") ) - @staticmethod - def _is_bundled(workflow_id: str) -> bool: - # A workflow that ships with Spec Kit installs fully offline. - from ..._assets import _locate_bundled_workflow - - return _locate_bundled_workflow(workflow_id) is not None - def remove(self, component: ComponentRef) -> None: from ... import workflow_remove diff --git a/src/specify_cli/commands/bundle/__init__.py b/src/specify_cli/commands/bundle/__init__.py index 165f674a36..b506eac2c1 100644 --- a/src/specify_cli/commands/bundle/__init__.py +++ b/src/specify_cli/commands/bundle/__init__.py @@ -439,6 +439,20 @@ def bundle_install( ) +@bundle_app.command("add") +def bundle_add( + bundle_id: str = typer.Argument( + ..., + help="Bundle id (from the catalog stack) or a local path to a .zip " + "artifact, bundle directory, or bundle.yml", + ), + integration: str = typer.Option(None, "--integration", help="Override integration"), + offline: bool = typer.Option(False, "--offline", help="Do not access the network"), +) -> None: + """Install a bundle's full component set (alias for install).""" + return bundle_install(bundle_id=bundle_id, integration=integration, offline=offline) + + @bundle_app.command("update") def bundle_update( bundle_id: str = typer.Argument(None, help="Bundle id, or omit with --all"), diff --git a/tests/contract/test_bundle_cli.py b/tests/contract/test_bundle_cli.py index 6db4dab769..221c1a9908 100644 --- a/tests/contract/test_bundle_cli.py +++ b/tests/contract/test_bundle_cli.py @@ -16,6 +16,7 @@ from typer.testing import CliRunner from specify_cli import app +from specify_cli.bundler.services.adapters import FIRSTPARTY_CATALOG_URL from specify_cli.bundler.services.packager import build_bundle from tests.conftest import strip_ansi from tests.bundler_helpers import ( @@ -25,6 +26,7 @@ ) runner = CliRunner() +REPO_ROOT = Path(__file__).parents[2] MARKUP_BUNDLE_ID = "[red]markup-id[/red]" MARKUP_SOURCE_ID = "[underline]markup-source[/underline]" @@ -73,8 +75,8 @@ def project(tmp_path: Path, monkeypatch) -> Path: def test_bundle_help_lists_all_commands(): result = runner.invoke(app, ["bundle", "--help"]) assert result.exit_code == 0 - for cmd in ("search", "info", "list", "install", "update", "remove", - "validate", "build", "init", "catalog"): + for cmd in ("search", "info", "list", "install", "add", "update", "remove", + "validate", "build", "init", "catalog"): assert cmd in result.output @@ -397,6 +399,133 @@ def _mock_manifest_download(monkeypatch, source_path: Path) -> None: ) +def _bundled_workflow_manifest(workflow_id: str, version: str = "1.0.0") -> dict: + return valid_manifest_dict( + provides={"workflows": [{"id": workflow_id, "version": version}]} + ) + + +@pytest.mark.parametrize( + ("command", "bundle_id", "extension_id"), + [("install", "bugfix", "bug"), ("add", "assess", "assess")], +) +def test_local_firstparty_bundle_installs_bundled_components_offline( + project: Path, command: str, bundle_id: str, extension_id: str +): + bundle_dir = REPO_ROOT / "bundles" / bundle_id + + result = runner.invoke( + app, ["bundle", command, str(bundle_dir), "--offline"] + ) + + assert result.exit_code == 0, result.output + assert ( + project / ".specify" / "extensions" / extension_id / "extension.yml" + ).is_file() + assert (project / ".specify" / "workflows" / bundle_id / "workflow.yml").is_file() + registry = json.loads( + (project / ".specify" / "workflows" / "workflow-registry.json").read_text( + encoding="utf-8" + ) + ) + assert registry["workflows"][bundle_id]["version"] == "1.0.0" + + +@pytest.mark.parametrize( + ("bundle_id", "extension_id"), + [("bugfix", "bug"), ("assess", "assess")], +) +def test_bundle_add_by_id_initializes_empty_project_from_firstparty_catalog( + tmp_path: Path, monkeypatch, bundle_id: str, extension_id: str +): + """``bundle add `` from an empty directory resolves ``builtin://default``. + + The command fetches the first-party catalog and bundle manifest over the + network (both mocked here), initializes a new Spec Kit project, and installs + the bundled extension and workflow without further network access. + """ + project = tmp_path / "fresh" + project.mkdir() + monkeypatch.chdir(project) + + catalog_bytes = (REPO_ROOT / "bundles" / "catalog.json").read_bytes() + manifest_bytes = (REPO_ROOT / "bundles" / bundle_id / "bundle.yml").read_bytes() + expected_manifest_url = ( + "https://raw.githubusercontent.com/github/spec-kit/main/" + f"bundles/{bundle_id}/bundle.yml" + ) + captured_urls: list[str] = [] + + def fake_open_url( + url: str, + timeout: int | None = None, + extra_headers: dict[str, str] | None = None, + redirect_validator=None, + ): + captured_urls.append(url) + if url == FIRSTPARTY_CATALOG_URL: + return FakeBundleResponse(catalog_bytes, url=url) + if url == expected_manifest_url: + return FakeBundleResponse(manifest_bytes, url=url) + raise AssertionError( + f"Unexpected network request in by-ID bundle test: {url}" + ) + + with patch("specify_cli.authentication.http.open_url", side_effect=fake_open_url): + result = runner.invoke( + app, ["bundle", "add", bundle_id, "--integration", "copilot"] + ) + + assert result.exit_code == 0, result.output + assert "No Spec Kit project here" in result.output + assert (project / ".specify").is_dir() + assert ( + project / ".specify" / "extensions" / extension_id / "extension.yml" + ).is_file() + assert ( + project / ".specify" / "workflows" / bundle_id / "workflow.yml" + ).is_file() + registry = json.loads( + (project / ".specify" / "workflows" / "workflow-registry.json").read_text( + encoding="utf-8" + ) + ) + assert registry["workflows"][bundle_id]["version"] == "1.0.0" + assert FIRSTPARTY_CATALOG_URL in captured_urls + assert expected_manifest_url in captured_urls + + +def test_local_bundle_rejects_mismatched_bundled_workflow_pin_offline(project: Path): + bundle_dir = project / "mismatched-workflow-pin" + (bundle_dir / "bundle.yml").parent.mkdir() + (bundle_dir / "bundle.yml").write_text( + yaml.safe_dump(_bundled_workflow_manifest("bugfix", "9.9.9")), encoding="utf-8" + ) + + result = runner.invoke( + app, ["bundle", "install", str(bundle_dir), "--offline"] + ) + + assert result.exit_code == 1 + assert "pinned to version 9.9.9" in result.output + assert not (project / ".specify" / "workflows" / "bugfix").exists() + + +def test_local_bundle_refuses_unbundled_workflow_offline(project: Path): + bundle_dir = project / "unbundled-workflow" + (bundle_dir / "bundle.yml").parent.mkdir() + (bundle_dir / "bundle.yml").write_text( + yaml.safe_dump(_bundled_workflow_manifest("not-bundled")), encoding="utf-8" + ) + + result = runner.invoke( + app, ["bundle", "install", str(bundle_dir), "--offline"] + ) + + assert result.exit_code == 1 + assert "network access is disabled" in " ".join(result.output.lower().split()) + + def test_info_expands_full_component_set(project: Path, monkeypatch): bundle_dir = project / "src-bundle" bundle_dir.mkdir() @@ -649,7 +778,11 @@ def test_search_json_offline(project: Path): config = { "schema_version": "1.0", "catalogs": [ - {"id": "c", "url": str(catalog), "priority": 1, + # Priority 0 wins over the built-in first-party catalog so the demo + # entry is resolved from this project catalog, while the offline + # packaged first-party catalog (bugfix / assess) still appears in + # search results alongside it. + {"id": "c", "url": str(catalog), "priority": 0, "install_policy": "install-allowed"} ], } @@ -659,10 +792,11 @@ def test_search_json_offline(project: Path): result = runner.invoke(app, ["bundle", "search", "--offline", "--json"]) assert result.exit_code == 0 payload = json.loads(result.output) - assert payload[0]["id"] == "demo" + by_id = {entry["id"]: entry for entry in payload} + assert "demo" in by_id # Trust indicator is exposed on the discovery surface (FR-010 / FR-027). - assert payload[0]["verified"] is True - assert payload[0]["trust"] == "verified" + assert by_id["demo"]["verified"] is True + assert by_id["demo"]["trust"] == "verified" def test_search_text_shows_trust(project: Path): @@ -727,14 +861,19 @@ def geturl(self) -> str: def _make_catalog_config(catalog_path: Path, project: Path) -> None: - """Write a bundle-catalogs.yml pointing at *catalog_path* in *project*.""" + """Write a bundle-catalogs.yml pointing at *catalog_path* in *project*. + + Uses priority 0 so the test catalog wins over the built-in first-party + ``builtin://default`` catalog and the command under test does not need to + fetch the repository catalog from the network. + """ config = { "schema_version": "1.0", "catalogs": [ { "id": "test", "url": str(catalog_path), - "priority": 1, + "priority": 0, "install_policy": "install-allowed", } ], diff --git a/tests/contract/test_firstparty_bundle_catalog_consistency.py b/tests/contract/test_firstparty_bundle_catalog_consistency.py new file mode 100644 index 0000000000..5a7737ab55 --- /dev/null +++ b/tests/contract/test_firstparty_bundle_catalog_consistency.py @@ -0,0 +1,155 @@ +"""Consistency tests for the first-party bundle catalog and manifests. + +``_validate_catalog_manifest`` enforces that a bundle manifest's ``bundle.id`` +and ``bundle.version`` match the catalog entry that pointed to it. This test +locks that relationship in at the source files so it cannot drift without +failing CI. +""" + +from __future__ import annotations + +import json +from pathlib import Path + +import yaml + +REPO_ROOT = Path(__file__).parents[2] + + +def _read_json(path: Path) -> dict: + return json.loads(path.read_text(encoding="utf-8")) + + +def _read_yaml(path: Path) -> dict: + return yaml.safe_load(path.read_text(encoding="utf-8")) + + +def _extension_version(extension_id: str) -> str: + manifest = _read_yaml(REPO_ROOT / "extensions" / extension_id / "extension.yml") + return str(manifest["extension"]["version"]) + + +def _workflow_version(workflow_id: str) -> str: + manifest = _read_yaml(REPO_ROOT / "workflows" / workflow_id / "workflow.yml") + return str(manifest["workflow"]["version"]) + + +def _manifest_component_versions(bundle_id: str) -> dict[tuple[str, str], str]: + """Return {(kind, id): version, ...} for pinned components in a bundle manifest.""" + manifest = _read_yaml(REPO_ROOT / "bundles" / bundle_id / "bundle.yml") + provides = manifest.get("provides", {}) + versions: dict[tuple[str, str], str] = {} + for kind in ("extensions", "presets", "steps", "workflows"): + for ref in provides.get(kind, []): + if "version" in ref: + versions[(kind.rstrip("s"), ref["id"])] = str(ref["version"]) + return versions + + +def test_firstparty_catalog_matches_manifests(): + catalog = _read_json(REPO_ROOT / "bundles" / "catalog.json") + + for bundle_id, entry in catalog["bundles"].items(): + manifest = _read_yaml(REPO_ROOT / "bundles" / bundle_id / "bundle.yml") + meta = manifest["bundle"] + + assert entry["id"] == bundle_id, ( + f"catalog key '{bundle_id}' does not match entry id '{entry['id']}'" + ) + assert meta["id"] == bundle_id, ( + f"manifest id '{meta['id']}' does not match catalog key '{bundle_id}'" + ) + assert entry["version"] == meta["version"], ( + f"catalog version for '{bundle_id}' ({entry['version']}) does not match " + f"manifest version ({meta['version']})" + ) + + +def _workflow_catalog_entry(workflow_id: str) -> dict: + catalog = _read_json(REPO_ROOT / "workflows" / "catalog.json") + entry = catalog["workflows"].get(workflow_id) + assert entry is not None, ( + f"workflow '{workflow_id}' is missing from workflows/catalog.json" + ) + return entry + + +def test_firstparty_workflow_catalog_entries_match_shipped_yamls(): + """workflows/catalog.json entries must match the shipped workflow YAMLs. + + ``specify workflow add`` resolves a catalog entry by fetching its ``url`` + and then comparing the downloaded manifest's ``version`` against the + catalog's — a stale entry version or URL makes the install fail (or worse, + serve an old workflow). Lock id, version, and URL in at the source files so + they cannot drift without failing CI. + """ + for workflow_id in ("speckit", "bugfix", "assess"): + entry = _workflow_catalog_entry(workflow_id) + manifest = _read_yaml(REPO_ROOT / "workflows" / workflow_id / "workflow.yml") + meta = manifest["workflow"] + + assert entry["id"] == workflow_id, ( + f"catalog entry id '{entry['id']}' does not match catalog key " + f"'{workflow_id}'" + ) + assert meta["id"] == workflow_id, ( + f"workflows/{workflow_id}/workflow.yml declares id '{meta['id']}', " + f"expected '{workflow_id}'" + ) + assert entry["version"] == str(meta["version"]), ( + f"catalog version for workflow '{workflow_id}' ({entry['version']}) " + f"does not match the shipped workflow.yml version " + f"({meta['version']})" + ) + assert entry["url"] == ( + "https://raw.githubusercontent.com/github/spec-kit/main/" + f"workflows/{workflow_id}/workflow.yml" + ), ( + f"catalog URL for workflow '{workflow_id}' does not point at the " + "shipped workflow.yml on the repository default branch" + ) + + +def test_firstparty_manifest_pins_match_shipped_versions(): + for bundle_id in ("bugfix", "assess"): + manifest = _read_yaml(REPO_ROOT / "bundles" / bundle_id / "bundle.yml") + provides = manifest.get("provides", {}) + + for ext_ref in provides.get("extensions", []): + expected = _extension_version(ext_ref["id"]) + assert str(ext_ref.get("version")) == expected, ( + f"{bundle_id} manifest pins extension {ext_ref['id']} at " + f"{ext_ref.get('version')}, but extensions/{ext_ref['id']}/extension.yml " + f"ships {expected}" + ) + + for wf_ref in provides.get("workflows", []): + expected = _workflow_version(wf_ref["id"]) + assert str(wf_ref.get("version")) == expected, ( + f"{bundle_id} manifest pins workflow {wf_ref['id']} at " + f"{wf_ref.get('version')}, but workflows/{wf_ref['id']}/workflow.yml " + f"ships {expected}" + ) + + +def test_firstparty_catalog_provides_counts_match_manifests(): + catalog = _read_json(REPO_ROOT / "bundles" / "catalog.json") + + for bundle_id, entry in catalog["bundles"].items(): + manifest = _read_yaml(REPO_ROOT / "bundles" / bundle_id / "bundle.yml") + provides = manifest.get("provides", {}) + + for kind in ("extensions", "presets", "steps", "workflows"): + expected_count = len(provides.get(kind, [])) + assert entry["provides"][kind] == expected_count, ( + f"catalog entry '{bundle_id}' claims {entry['provides'][kind]} {kind}, " + f"but the manifest lists {expected_count}" + ) + + +def test_firstparty_catalog_entries_are_verified(): + catalog = _read_json(REPO_ROOT / "bundles" / "catalog.json") + for bundle_id, entry in catalog["bundles"].items(): + assert entry.get("verified") is True, ( + f"first-party catalog entry '{bundle_id}' must be marked verified: true" + ) diff --git a/tests/contract/test_wheel_bundled_workflows.py b/tests/contract/test_wheel_bundled_workflows.py new file mode 100644 index 0000000000..1346d9c5d2 --- /dev/null +++ b/tests/contract/test_wheel_bundled_workflows.py @@ -0,0 +1,52 @@ +"""Contract tests: every bundled workflow must ship inside the wheel's core_pack. + +``specify workflow add `` (and the bundler's workflow primitive) resolve a +bundled workflow via ``specify_cli._assets._locate_bundled_workflow``, which +checks the wheel's ``specify_cli/core_pack/workflows//`` directory first. +Any workflow marked ``bundled: true`` in ``workflows/catalog.json`` must +therefore be force-included at build time; otherwise the released wheel +advertises a bundled workflow it does not actually ship. +""" + +from __future__ import annotations + +import json +import tomllib +from pathlib import Path + +REPO_ROOT = Path(__file__).parents[2] + + +def _force_include() -> dict[str, str]: + with (REPO_ROOT / "pyproject.toml").open("rb") as pyproject_file: + pyproject = tomllib.load(pyproject_file) + return pyproject["tool"]["hatch"]["build"]["targets"]["wheel"]["force-include"] + + +def _bundled_workflow_ids() -> list[str]: + catalog = json.loads((REPO_ROOT / "workflows" / "catalog.json").read_text()) + return sorted( + workflow_id + for workflow_id, entry in catalog["workflows"].items() + if entry.get("bundled") + ) + + +def test_every_bundled_workflow_is_force_included(): + force_include = _force_include() + bundled = _bundled_workflow_ids() + + assert bundled, "expected at least one bundled workflow in workflows/catalog.json" + for workflow_id in bundled: + assert force_include.get(f"workflows/{workflow_id}") == ( + f"specify_cli/core_pack/workflows/{workflow_id}" + ), f"bundled workflow '{workflow_id}' is missing from the wheel force-include list" + + +def test_stock_bundled_workflows_are_force_included(): + # Explicit regression guard for the first-party workflows shipped in #4495. + force_include = _force_include() + for workflow_id in ("speckit", "bugfix", "assess"): + assert force_include[f"workflows/{workflow_id}"] == ( + f"specify_cli/core_pack/workflows/{workflow_id}" + ) diff --git a/tests/integration/test_bundler_offline.py b/tests/integration/test_bundler_offline.py index 8cbc7af9cc..2297dbe504 100644 --- a/tests/integration/test_bundler_offline.py +++ b/tests/integration/test_bundler_offline.py @@ -24,11 +24,21 @@ def _src(source_id, url, priority=1, policy="install-allowed"): ) -def test_builtin_catalog_resolves_offline(): +def test_builtin_default_catalog_resolves_first_party_bundles_offline(): fetcher = make_catalog_fetcher(allow_network=False) stack = CatalogStack([_src("default", "builtin://default")], fetcher) - # Built-in default ships empty; search works without network and returns []. - assert stack.search() == [] + # Built-in default now ships the first-party bundles bugfix and assess. + results = {r.entry.id: r for r in stack.search()} + assert set(results) == {"bugfix", "assess"} + assert all(r.source.id == "default" and r.install_allowed for r in results.values()) + + resolved = stack.resolve("bugfix") + assert resolved.entry.id == "bugfix" + assert resolved.install_allowed is True + + resolved = stack.resolve("assess") + assert resolved.entry.id == "assess" + assert resolved.install_allowed is True def test_builtin_community_catalog_resolves_from_packaged_snapshot_offline(): diff --git a/tests/integrations/test_events.py b/tests/integrations/test_events.py index f5159c1d41..4302602ab6 100644 --- a/tests/integrations/test_events.py +++ b/tests/integrations/test_events.py @@ -1328,10 +1328,11 @@ def failing_read_text(self_path, *args, **kwargs): argv = _resolve_event_command_argv(template, tmp_path, None) assert argv is None - def test_ps_variant_prefixed_with_powershell_launcher(self, tmp_path): + def test_ps_variant_prefixed_with_powershell_launcher(self, tmp_path, monkeypatch): """S6: the ps variant prefixes argv with pwsh/powershell -File so subprocess.run(shell=False) can execute the .ps1 script.""" from specify_cli.events import _resolve_event_command_argv + import shutil as _shutil cmd_dir = tmp_path / ".specify" / "templates" / "commands" cmd_dir.mkdir(parents=True) @@ -1347,6 +1348,13 @@ def test_ps_variant_prefixed_with_powershell_launcher(self, tmp_path): ps_dir.mkdir(parents=True) (ps_dir / "boot.ps1").write_text("exit 0\n", encoding="utf-8") + # The argv contract under test does not depend on a real PowerShell + # install; pin the launcher (mirroring the no-launcher sibling below) + # so the test runs on platforms without pwsh/powershell on PATH. + monkeypatch.setattr( + _shutil, "which", lambda name: "/usr/bin/pwsh" if name == "pwsh" else None + ) + argv = _resolve_event_command_argv(cmd_dir / "boot.md", tmp_path, None) assert argv is not None # Launcher (pwsh or powershell), -File, then the .specify-anchored diff --git a/tests/unit/test_bundler_adapters.py b/tests/unit/test_bundler_adapters.py index 854e60df3f..cc813893b4 100644 --- a/tests/unit/test_bundler_adapters.py +++ b/tests/unit/test_bundler_adapters.py @@ -116,6 +116,46 @@ def test_local_catalog_decode_errors_are_wrapped(tmp_path, use_file_url): fetcher(_source(url)) +def test_builtin_default_catalog_fetches_repository_catalog_online(monkeypatch): + captured: dict = {} + + def fake_http_get_json(source_id, url): + captured["source_id"] = source_id + captured["url"] = url + return {"schema_version": "1.0", "bundles": {}} + + monkeypatch.setattr(adapters, "_http_get_json", fake_http_get_json) + + fetcher = adapters.make_catalog_fetcher(allow_network=True) + result = fetcher(_source("builtin://default")) + + assert result["bundles"] == {} + assert captured == { + "source_id": "team", + "url": adapters.FIRSTPARTY_CATALOG_URL, + } + + +def test_builtin_default_catalog_uses_core_pack_snapshot_offline(monkeypatch, tmp_path): + catalog_path = tmp_path / "bundles" / "catalog.json" + catalog_path.parent.mkdir() + catalog_path.write_text( + '{"schema_version":"1.0","bundles":{"packaged":{' + '"id":"packaged","name":"Packaged","version":"1.0.0",' + '"role":"developer","description":"Packaged catalog entry.",' + '"author":"Spec Kit","license":"MIT","download_url":"",' + '"requires":{"speckit_version":">=0.1.0"},' + '"provides":{},"verified":false}}}', + encoding="utf-8", + ) + monkeypatch.setattr(adapters, "_locate_core_pack", lambda: tmp_path) + + fetcher = adapters.make_catalog_fetcher(allow_network=False) + result = fetcher(_source("builtin://default")) + + assert "packaged" in result["bundles"] + + def test_builtin_community_catalog_fetches_repository_catalog_online(monkeypatch): captured: dict = {} diff --git a/tests/unit/test_bundler_primitives.py b/tests/unit/test_bundler_primitives.py index a0fcb3c635..4fa515c1b8 100644 --- a/tests/unit/test_bundler_primitives.py +++ b/tests/unit/test_bundler_primitives.py @@ -75,8 +75,13 @@ def test_offline_workflow_allows_bundled(tmp_path: Path, monkeypatch): import specify_cli import specify_cli._assets as assets + bundled = tmp_path / "wf" + bundled.mkdir() + (bundled / "workflow.yml").write_text( + "workflow:\n id: bundled-wf\n version: 1.0.0\n", encoding="utf-8" + ) monkeypatch.setattr( - assets, "_locate_bundled_workflow", lambda wid: tmp_path / "wf" + assets, "_locate_bundled_workflow", lambda wid: bundled ) calls: list[tuple] = [] monkeypatch.setattr( @@ -88,7 +93,7 @@ def test_offline_workflow_allows_bundled(tmp_path: Path, monkeypatch): manager = primitive_manager("workflows", tmp_path, allow_network=False) manager.install(_component("workflows", "bundled-wf")) - assert calls == [("bundled-wf", False, None)] + assert calls == [(str(bundled / "workflow.yml"), True, None)] def test_assert_pinned_version_matches_passes(): diff --git a/tests/workflows/test_bundled_bugfix_assess_workflows.py b/tests/workflows/test_bundled_bugfix_assess_workflows.py new file mode 100644 index 0000000000..d38689b29c --- /dev/null +++ b/tests/workflows/test_bundled_bugfix_assess_workflows.py @@ -0,0 +1,95 @@ +"""Guards for the bundled bugfix and assess workflows.""" + +from __future__ import annotations + +from pathlib import Path + +import pytest + +from specify_cli._assets import _locate_bundled_workflow +from specify_cli.workflows.engine import WorkflowDefinition, validate_workflow + + +def _load_workflow(workflow_id: str) -> WorkflowDefinition: + directory = _locate_bundled_workflow(workflow_id) + assert directory is not None, f"bundled workflow '{workflow_id}' not found" + return WorkflowDefinition.from_yaml(directory / "workflow.yml") + + +@pytest.mark.parametrize("workflow_id", ["bugfix", "assess"]) +def test_bundled_workflow_validates_cleanly(workflow_id: str) -> None: + definition = _load_workflow(workflow_id) + assert validate_workflow(definition) == [] + + +def test_bugfix_workflow_has_expected_steps() -> None: + definition = _load_workflow("bugfix") + assert [step["id"] for step in definition.steps] == [ + "assess", + "review-assessment", + "fix", + "test", + ] + + expected_commands = { + "assess": ("speckit.bug.assess", "{{ inputs.report }} slug={{ inputs.slug }}"), + "fix": ("speckit.bug.fix", "slug={{ inputs.slug }}"), + "test": ("speckit.bug.test", "slug={{ inputs.slug }}"), + } + for step in definition.steps: + if step["id"] not in expected_commands: + continue + command, args = expected_commands[step["id"]] + assert step["command"] == command + assert step["integration"] == "{{ inputs.integration }}" + assert step["input"]["args"] == args + + gate = definition.steps[1] + assert gate.get("type") == "gate" + assert gate.get("options") == ["approve", "reject"] + assert gate.get("on_reject") == "abort" + + +def test_assess_workflow_has_expected_steps() -> None: + definition = _load_workflow("assess") + assert [step["id"] for step in definition.steps] == [ + "intake", + "research", + "define", + "shape", + "decide", + "review-verdict", + ] + + expected_commands = { + "intake": ("speckit.assess.intake", "{{ inputs.idea }} slug={{ inputs.slug }}"), + "research": ("speckit.assess.research", "slug={{ inputs.slug }}"), + "define": ("speckit.assess.define", "slug={{ inputs.slug }}"), + "shape": ("speckit.assess.shape", "slug={{ inputs.slug }}"), + "decide": ("speckit.assess.decide", "slug={{ inputs.slug }}"), + } + for step in definition.steps: + if step["id"] not in expected_commands: + continue + command, args = expected_commands[step["id"]] + assert step["command"] == command + assert step["integration"] == "{{ inputs.integration }}" + assert step["input"]["args"] == args + + final_gate = definition.steps[-1] + assert final_gate.get("type") == "gate" + assert final_gate.get("options") == ["approve", "reject"] + assert final_gate.get("on_reject") == "abort" + + +@pytest.mark.parametrize( + ("workflow_id", "required_inputs"), + [("bugfix", ("report", "slug")), ("assess", ("idea", "slug"))], +) +def test_bundled_workflow_has_required_inputs( + workflow_id: str, required_inputs: tuple[str, ...] +) -> None: + definition = _load_workflow(workflow_id) + for input_id in required_inputs: + assert definition.inputs[input_id].get("required") is True + assert definition.inputs.get("integration", {}).get("default") == "auto" diff --git a/workflows/README.md b/workflows/README.md index 2c1a9f2bb7..7382d6e625 100644 --- a/workflows/README.md +++ b/workflows/README.md @@ -539,6 +539,10 @@ workflows/ ├── README.md # This file ├── catalog.json # Official workflow catalog ├── catalog.community.json # Community workflow catalog -└── speckit/ # Built-in SDD cycle workflow +├── speckit/ # Built-in SDD cycle workflow +│ └── workflow.yml +├── bugfix/ # Built-in bug-fixing pipeline +│ └── workflow.yml +└── assess/ # Built-in idea-assessment pipeline └── workflow.yml ``` diff --git a/workflows/assess/workflow.yml b/workflows/assess/workflow.yml new file mode 100644 index 0000000000..1cab4d5cbf --- /dev/null +++ b/workflows/assess/workflow.yml @@ -0,0 +1,62 @@ +schema_version: "1.0" +workflow: + id: "assess" + name: "Idea Assessment Pipeline" + version: "1.0.0" + author: "GitHub" + description: "Runs intake → research → define → shape → decide with a verdict review gate before a manual /speckit.specify handoff" + +requires: + # Matches the composed assess extension's requirement (extensions/assess/extension.yml). + speckit_version: ">=0.9.0" + +inputs: + idea: + type: string + required: true + prompt: "Raw idea (pasted text, URL, ticket, or codebase pointer)" + slug: + type: string + required: true + prompt: "Assessment slug (short kebab-case name, e.g. offline-mode)" + integration: + type: string + default: "auto" + prompt: "Integration to use (e.g. claude, copilot, gemini; 'auto' uses the project's initialized integration)" + +steps: + - id: intake + command: speckit.assess.intake + integration: "{{ inputs.integration }}" + input: + args: "{{ inputs.idea }} slug={{ inputs.slug }}" + + - id: research + command: speckit.assess.research + integration: "{{ inputs.integration }}" + input: + args: "slug={{ inputs.slug }}" + + - id: define + command: speckit.assess.define + integration: "{{ inputs.integration }}" + input: + args: "slug={{ inputs.slug }}" + + - id: shape + command: speckit.assess.shape + integration: "{{ inputs.integration }}" + input: + args: "slug={{ inputs.slug }}" + + - id: decide + command: speckit.assess.decide + integration: "{{ inputs.integration }}" + input: + args: "slug={{ inputs.slug }}" + + - id: review-verdict + type: gate + message: "Review the assessment verdict. A go verdict is handed off manually with the specify command; this workflow stops here." + options: [approve, reject] + on_reject: abort diff --git a/workflows/bugfix/workflow.yml b/workflows/bugfix/workflow.yml new file mode 100644 index 0000000000..a914eeafa9 --- /dev/null +++ b/workflows/bugfix/workflow.yml @@ -0,0 +1,50 @@ +schema_version: "1.0" +workflow: + id: "bugfix" + name: "Guided Bug Fix" + version: "1.0.0" + author: "GitHub" + description: "Runs bug assess → human review gate → bug fix → bug test on a bug report" + +requires: + # Matches the composed bug extension's requirement (extensions/bug/extension.yml). + speckit_version: ">=0.9.0" + +inputs: + report: + type: string + required: true + prompt: "Bug report (pasted text or URL)" + slug: + type: string + required: true + prompt: "Bug slug (short kebab-case name, e.g. login-timeout)" + integration: + type: string + default: "auto" + prompt: "Integration to use (e.g. claude, copilot, gemini; 'auto' uses the project's initialized integration)" + +steps: + - id: assess + command: speckit.bug.assess + integration: "{{ inputs.integration }}" + input: + args: "{{ inputs.report }} slug={{ inputs.slug }}" + + - id: review-assessment + type: gate + message: "Review the assessment before any code change." + options: [approve, reject] + on_reject: abort + + - id: fix + command: speckit.bug.fix + integration: "{{ inputs.integration }}" + input: + args: "slug={{ inputs.slug }}" + + - id: test + command: speckit.bug.test + integration: "{{ inputs.integration }}" + input: + args: "slug={{ inputs.slug }}" diff --git a/workflows/catalog.json b/workflows/catalog.json index c26a3230a1..d55754a819 100644 --- a/workflows/catalog.json +++ b/workflows/catalog.json @@ -1,6 +1,6 @@ { "schema_version": "1.0", - "updated_at": "2026-09-03T00:00:00Z", + "updated_at": "2026-09-10T00:00:00Z", "catalog_url": "https://raw.githubusercontent.com/github/spec-kit/main/workflows/catalog.json", "workflows": { "speckit": { @@ -10,7 +10,28 @@ "author": "GitHub", "version": "1.0.1", "url": "https://raw.githubusercontent.com/github/spec-kit/main/workflows/speckit/workflow.yml", - "tags": ["sdd", "full-cycle"] + "tags": ["sdd", "full-cycle"], + "bundled": true + }, + "bugfix": { + "id": "bugfix", + "name": "Guided Bug Fix", + "description": "Runs bug assess → human review gate → bug fix → bug test on a bug report", + "author": "GitHub", + "version": "1.0.0", + "url": "https://raw.githubusercontent.com/github/spec-kit/main/workflows/bugfix/workflow.yml", + "tags": ["bug", "triage", "workflow", "qa"], + "bundled": true + }, + "assess": { + "id": "assess", + "name": "Idea Assessment Pipeline", + "description": "Runs intake → research → define → shape → decide with a verdict review gate before a manual specify handoff", + "author": "GitHub", + "version": "1.0.0", + "url": "https://raw.githubusercontent.com/github/spec-kit/main/workflows/assess/workflow.yml", + "tags": ["assessment", "discovery", "triage", "product"], + "bundled": true } } }