Skip to content

Commit 3247ceb

Browse files
author
Markus
committed
feat(workflows): add plugin slots
Assisted-by: GitHub Copilot (model: gpt-5.6-terra, autonomous)
1 parent 241d916 commit 3247ceb

10 files changed

Lines changed: 362 additions & 9 deletions

File tree

docs/reference/workflows.md

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -286,6 +286,40 @@ edits:
286286

287287
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.
288288

289+
### Plugin slots (upstream extension points)
290+
291+
Workflow authors can declare a named, no-op extension point with `type: plugin`:
292+
293+
```yaml
294+
- id: post-implement
295+
type: plugin
296+
name: "Post-implementation checks"
297+
```
298+
299+
The step `id` is the unique overlay anchor; `name` is a required non-blank,
300+
human-readable label only. An unfilled slot completes as a `skipped` step with
301+
`output: {slot: <name>}`, so subsequent steps continue normally.
302+
303+
Fill a slot with a schema-valid overlay `replace` edit anchored on the step
304+
`id`, not its `name`:
305+
306+
```yaml
307+
id: fill-post-implement
308+
extends: my-workflow
309+
edits:
310+
- replace: post-implement
311+
step:
312+
id: post-implement
313+
type: shell
314+
run: "echo Run project-specific checks"
315+
```
316+
317+
Reuse the slot's `id` when later expressions or `fan-in.wait_for` refer to it.
318+
The replacement must also preserve every output key those later steps consume:
319+
an unfilled plugin slot supplies only `steps.<id>.output.slot`. Plugin slots are
320+
not supported inside `fan-out.step` templates because runtime-multiplied
321+
templates cannot be overlay anchors.
322+
289323
### Interaction with Bundles and Updates
290324

291325
`specify workflow add <local-directory>` installs the complete local workflow
@@ -494,6 +528,7 @@ specify workflow run speckit -i spec="Build a kanban board with drag-and-drop ta
494528
| `prompt` | Send an arbitrary prompt to the AI coding agent |
495529
| `shell` | Execute a shell command and capture output |
496530
| `init` | Bootstrap a project (like `specify init`) |
531+
| `plugin` | Named extension point; skipped when unfilled |
497532
| `gate` | Pause for human approval before continuing |
498533
| `if` | Conditional branching (then/else) |
499534
| `switch` | Multi-branch dispatch on an expression |

src/specify_cli/workflows/__init__.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -51,6 +51,7 @@ def _register_builtin_steps() -> None:
5151
from .steps.gate import GateStep
5252
from .steps.if_then import IfThenStep
5353
from .steps.init import InitStep
54+
from .steps.plugin import PluginStep
5455
from .steps.prompt import PromptStep
5556
from .steps.shell import ShellStep
5657
from .steps.switch import SwitchStep
@@ -63,6 +64,7 @@ def _register_builtin_steps() -> None:
6364
_register_step(GateStep())
6465
_register_step(IfThenStep())
6566
_register_step(InitStep())
67+
_register_step(PluginStep())
6668
_register_step(PromptStep())
6769
_register_step(ShellStep())
6870
_register_step(SwitchStep())

src/specify_cli/workflows/engine.py

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -139,7 +139,7 @@ def _get_valid_step_types() -> set[str]:
139139
if STEP_REGISTRY:
140140
return set(STEP_REGISTRY.keys())
141141
return {
142-
"command", "shell", "prompt", "gate", "if", "init",
142+
"command", "shell", "prompt", "gate", "if", "init", "plugin",
143143
"switch", "while", "do-while", "fan-out", "fan-in",
144144
}
145145

@@ -432,6 +432,13 @@ def _validate_steps(
432432
step_errors = step_impl.validate(step_config)
433433
errors.extend(step_errors)
434434

435+
if step_type == "plugin" and inside_fan_out:
436+
errors.append(
437+
f"Plugin step {step_id!r} is not supported inside fan-out "
438+
"templates because overlays cannot address runtime-multiplied "
439+
"templates."
440+
)
441+
435442
# Validate optional `continue_on_error` field. The engine honours
436443
# this on any step that returns StepStatus.FAILED so the pipeline can route
437444
# around the failure via a downstream `if` or `switch` (or a
Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,58 @@
1+
"""Plugin step — a named, no-op workflow extension point.
2+
3+
An upstream workflow declares a slot at the position where a downstream
4+
project may extend it. The step ``id`` is the overlay anchor; ``name`` is only
5+
the human-readable slot label. A project overlay fills the slot with the
6+
standard ``replace`` operation on the slot step's ``id``. Unfilled slots are
7+
skipped when the workflow runs.
8+
9+
Example YAML::
10+
11+
# Upstream workflow
12+
- id: post-implement
13+
type: plugin
14+
name: post-implement
15+
16+
# .specify/workflows/overlays/my-workflow/fill-post-implement.yml
17+
id: fill-post-implement
18+
extends: my-workflow
19+
edits:
20+
- replace: post-implement
21+
step:
22+
id: post-implement
23+
type: shell
24+
run: echo "Run project-specific checks"
25+
"""
26+
27+
from __future__ import annotations
28+
29+
from typing import Any
30+
31+
from specify_cli.workflows.base import StepBase, StepContext, StepResult, StepStatus
32+
33+
34+
class PluginStep(StepBase):
35+
"""Provide a named workflow extension point that skips when unfilled."""
36+
37+
type_key = "plugin"
38+
39+
def execute(self, config: dict[str, Any], context: StepContext) -> StepResult:
40+
return StepResult(
41+
status=StepStatus.SKIPPED,
42+
output={"slot": config.get("name")},
43+
)
44+
45+
def validate(self, config: dict[str, Any]) -> list[str]:
46+
errors = super().validate(config)
47+
name = config.get("name")
48+
if name is None:
49+
errors.append(
50+
f"Plugin step {config.get('id', '?')!r} requires a 'name' field "
51+
"(the slot label)."
52+
)
53+
elif not isinstance(name, str) or not name.strip():
54+
errors.append(
55+
f"Plugin step {config.get('id', '?')!r}: 'name' must be a "
56+
"non-blank string."
57+
)
58+
return errors

tests/test_workflows.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@
44
- Step registry & auto-discovery
55
- Base classes (StepBase, StepContext, StepResult)
66
- Expression engine
7-
- All 10 built-in step types
7+
- All 12 built-in step types
88
- Workflow definition loading & validation
99
- Workflow engine execution & state persistence
1010
- Workflow catalog & registry
@@ -108,7 +108,7 @@ def test_all_step_types_registered(self):
108108

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

tests/unit/test_bundler_references.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -27,7 +27,7 @@ def test_bundled_extension_resolves(tmp_path: Path):
2727
def test_builtin_step_type_resolves(tmp_path: Path):
2828
"""A built-in step type must resolve, like a bundled extension.
2929
30-
Spec Kit ships 11 step types as built-ins registered in ``STEP_REGISTRY``
30+
Spec Kit ships 12 step types as built-ins registered in ``STEP_REGISTRY``
3131
rather than as on-disk asset directories, so there is no
3232
``_locate_bundled_step``. The ``steps`` branch of ``_resolved_locally`` only
3333
asked ``StepRegistry(root).is_installed()``, which tracks *community* step
@@ -40,7 +40,7 @@ def test_builtin_step_type_resolves(tmp_path: Path):
4040
warnings: list[str] = []
4141
check = make_reference_checker(root, allow_network=True, warnings=warnings)
4242

43-
for step_id in ("shell", "gate", "command", "if"):
43+
for step_id in ("shell", "gate", "command", "if", "plugin"):
4444
assert step_id in BUILTIN_STEP_TYPES, step_id
4545
assert check(_ref("steps", step_id)) is None, step_id
4646
assert warnings == []

0 commit comments

Comments
 (0)