Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
35 changes: 35 additions & 0 deletions docs/reference/workflows.md
Original file line number Diff line number Diff line change
Expand Up @@ -286,6 +286,40 @@ edits:

Lower priority values have higher precedence. Change this overlay to `priority: 5` if it must win a conflict with the `add-lint` overlay above. It replaces the `review-plan` gate with a non-interactive command.

### Plugin slots (upstream extension points)

Workflow authors can declare a named, no-op extension point with `type: plugin`:

```yaml
- id: post-implement
type: plugin
name: "Post-implementation checks"
```

The step `id` is the unique overlay anchor; `name` is a required non-blank,
human-readable label only. An unfilled slot completes as a `skipped` step with
`output: {slot: <name>}`, so subsequent steps continue normally.

Fill a slot with a schema-valid overlay `replace` edit anchored on the step
`id`, not its `name`:

```yaml
id: fill-post-implement
extends: my-workflow
edits:
- replace: post-implement
step:
id: post-implement
type: shell
run: "echo Run project-specific checks"
```

Reuse the slot's `id` when later expressions or `fan-in.wait_for` refer to it.
The replacement must also preserve every output key those later steps consume:
an unfilled plugin slot supplies only `steps.<id>.output.slot`. Plugin slots are
not supported inside `fan-out.step` templates because runtime-multiplied
templates cannot be overlay anchors.

### Interaction with Bundles and Updates

`specify workflow add <local-directory>` installs the complete local workflow
Expand Down Expand Up @@ -494,6 +528,7 @@ specify workflow run speckit -i spec="Build a kanban board with drag-and-drop ta
| `prompt` | Send an arbitrary prompt to the AI coding agent |
| `shell` | Execute a shell command and capture output |
| `init` | Bootstrap a project (like `specify init`) |
| `plugin` | Named extension point; skipped when unfilled |
| `gate` | Pause for human approval before continuing |
| `if` | Conditional branching (then/else) |
| `switch` | Multi-branch dispatch on an expression |
Expand Down
2 changes: 2 additions & 0 deletions src/specify_cli/workflows/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,7 @@ def _register_builtin_steps() -> None:
from .steps.gate import GateStep
from .steps.if_then import IfThenStep
from .steps.init import InitStep
from .steps.plugin import PluginStep
from .steps.prompt import PromptStep
from .steps.shell import ShellStep
from .steps.switch import SwitchStep
Expand All @@ -63,6 +64,7 @@ def _register_builtin_steps() -> None:
_register_step(GateStep())
_register_step(IfThenStep())
_register_step(InitStep())
_register_step(PluginStep())
_register_step(PromptStep())
_register_step(ShellStep())
_register_step(SwitchStep())
Expand Down
9 changes: 8 additions & 1 deletion src/specify_cli/workflows/engine.py
Original file line number Diff line number Diff line change
Expand Up @@ -139,7 +139,7 @@ def _get_valid_step_types() -> set[str]:
if STEP_REGISTRY:
return set(STEP_REGISTRY.keys())
return {
"command", "shell", "prompt", "gate", "if", "init",
"command", "shell", "prompt", "gate", "if", "init", "plugin",
"switch", "while", "do-while", "fan-out", "fan-in",
}

Expand Down Expand Up @@ -432,6 +432,13 @@ def _validate_steps(
step_errors = step_impl.validate(step_config)
errors.extend(step_errors)

if step_type == "plugin" and inside_fan_out:
errors.append(
f"Plugin step {step_id!r} is not supported inside fan-out "
"templates because overlays cannot address runtime-multiplied "
"templates."
)

# Validate optional `continue_on_error` field. The engine honours
# this on any step that returns StepStatus.FAILED so the pipeline can route
# around the failure via a downstream `if` or `switch` (or a
Expand Down
58 changes: 58 additions & 0 deletions src/specify_cli/workflows/steps/plugin/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
"""Plugin step — a named, no-op workflow extension point.

An upstream workflow declares a slot at the position where a downstream
project may extend it. The step ``id`` is the overlay anchor; ``name`` is only
the human-readable slot label. A project overlay fills the slot with the
standard ``replace`` operation on the slot step's ``id``. Unfilled slots are
skipped when the workflow runs.

Example YAML::

# Upstream workflow
- id: post-implement
type: plugin
name: post-implement

# .specify/workflows/overlays/my-workflow/fill-post-implement.yml
id: fill-post-implement
extends: my-workflow
edits:
- replace: post-implement
step:
id: post-implement
type: shell
run: echo "Run project-specific checks"
"""

from __future__ import annotations

from typing import Any

from specify_cli.workflows.base import StepBase, StepContext, StepResult, StepStatus


class PluginStep(StepBase):
"""Provide a named workflow extension point that skips when unfilled."""

type_key = "plugin"

def execute(self, config: dict[str, Any], context: StepContext) -> StepResult:
return StepResult(
status=StepStatus.SKIPPED,
output={"slot": config.get("name")},
)

def validate(self, config: dict[str, Any]) -> list[str]:
errors = super().validate(config)
name = config.get("name")
if name is None:
errors.append(
f"Plugin step {config.get('id', '?')!r} requires a 'name' field "
"(the slot label)."
)
elif not isinstance(name, str) or not name.strip():
errors.append(
f"Plugin step {config.get('id', '?')!r}: 'name' must be a "
"non-blank string."
)
return errors
4 changes: 2 additions & 2 deletions tests/test_workflows.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@
- Step registry & auto-discovery
- Base classes (StepBase, StepContext, StepResult)
- Expression engine
- All 10 built-in step types
- All 12 built-in step types
- Workflow definition loading & validation
- Workflow engine execution & state persistence
- Workflow catalog & registry
Expand Down Expand Up @@ -108,7 +108,7 @@ def test_all_step_types_registered(self):

expected = {
"command", "shell", "prompt", "gate", "if", "switch",
"while", "do-while", "fan-out", "fan-in", "init",
"while", "do-while", "fan-out", "fan-in", "init", "plugin",
}
assert expected.issubset(set(STEP_REGISTRY.keys()))

Expand Down
4 changes: 2 additions & 2 deletions tests/unit/test_bundler_references.py
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,7 @@ def test_bundled_extension_resolves(tmp_path: Path):
def test_builtin_step_type_resolves(tmp_path: Path):
"""A built-in step type must resolve, like a bundled extension.

Spec Kit ships 11 step types as built-ins registered in ``STEP_REGISTRY``
Spec Kit ships 12 step types as built-ins registered in ``STEP_REGISTRY``
rather than as on-disk asset directories, so there is no
``_locate_bundled_step``. The ``steps`` branch of ``_resolved_locally`` only
asked ``StepRegistry(root).is_installed()``, which tracks *community* step
Expand All @@ -40,7 +40,7 @@ def test_builtin_step_type_resolves(tmp_path: Path):
warnings: list[str] = []
check = make_reference_checker(root, allow_network=True, warnings=warnings)

for step_id in ("shell", "gate", "command", "if"):
for step_id in ("shell", "gate", "command", "if", "plugin"):
assert step_id in BUILTIN_STEP_TYPES, step_id
assert check(_ref("steps", step_id)) is None, step_id
assert warnings == []
Expand Down
Loading