Skip to content

Commit cb44583

Browse files
author
Markus
committed
refactor(workflows): rename plugin slots to workflow slots
The feature reserves a no-op position replaced through a workflow overlay; it does not register or resolve plugins. Rename per maintainer feedback so 'plugin' stays available for a future genuine plugin mechanism and avoid confusion with Spec Kit extensions: - type: plugin -> type: slot - PluginStep -> SlotStep - 'plugin step/slot' -> 'workflow slot' in prose and error messages - steps/plugin/ -> steps/slot/ (git mv) - test_plugin_step.py -> test_slot_step.py (git mv) Assisted-by: opencode (model: glm-5.3-flash, autonomous)
1 parent 1c1c596 commit cb44583

10 files changed

Lines changed: 62 additions & 62 deletions

File tree

‎docs/reference/workflows.md‎

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -286,13 +286,13 @@ 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)
289+
### Workflow slots (upstream extension points)
290290

291-
Workflow authors can declare a named, no-op extension point with `type: plugin`:
291+
Workflow authors can declare a named, no-op workflow slot with `type: slot`:
292292

293293
```yaml
294294
- id: post-implement
295-
type: plugin
295+
type: slot
296296
name: "Post-implementation checks"
297297
```
298298

@@ -316,7 +316,7 @@ edits:
316316

317317
Reuse the slot's `id` when later expressions or `fan-in.wait_for` refer to it.
318318
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
319+
an unfilled workflow slot supplies only `steps.<id>.output.slot`. Slot steps are
320320
not supported inside `fan-out.step` templates because runtime-multiplied
321321
templates cannot be overlay anchors.
322322

@@ -528,7 +528,7 @@ specify workflow run speckit -i spec="Build a kanban board with drag-and-drop ta
528528
| `prompt` | Send an arbitrary prompt to the AI coding agent |
529529
| `shell` | Execute a shell command and capture output |
530530
| `init` | Bootstrap a project (like `specify init`) |
531-
| `plugin` | Named extension point; skipped when unfilled |
531+
| `slot` | Named workflow slot; skipped when unfilled |
532532
| `gate` | Pause for human approval before continuing |
533533
| `if` | Conditional branching (then/else) |
534534
| `switch` | Multi-branch dispatch on an expression |

‎src/specify_cli/workflows/__init__.py‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -51,9 +51,9 @@ 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
5554
from .steps.prompt import PromptStep
5655
from .steps.shell import ShellStep
56+
from .steps.slot import SlotStep
5757
from .steps.switch import SwitchStep
5858
from .steps.while_loop import WhileStep
5959

@@ -64,9 +64,9 @@ def _register_builtin_steps() -> None:
6464
_register_step(GateStep())
6565
_register_step(IfThenStep())
6666
_register_step(InitStep())
67-
_register_step(PluginStep())
6867
_register_step(PromptStep())
6968
_register_step(ShellStep())
69+
_register_step(SlotStep())
7070
_register_step(SwitchStep())
7171
_register_step(WhileStep())
7272

‎src/specify_cli/workflows/engine.py‎

Lines changed: 3 additions & 3 deletions
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", "plugin",
142+
"command", "shell", "prompt", "gate", "if", "init", "slot",
143143
"switch", "while", "do-while", "fan-out", "fan-in",
144144
}
145145

@@ -432,9 +432,9 @@ 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:
435+
if step_type == "slot" and inside_fan_out:
436436
errors.append(
437-
f"Plugin step {step_id!r} is not supported inside fan-out "
437+
f"Slot step {step_id!r} is not supported inside fan-out "
438438
"templates because overlays cannot address runtime-multiplied "
439439
"templates."
440440
)

src/specify_cli/workflows/steps/plugin/__init__.py renamed to src/specify_cli/workflows/steps/slot/__init__.py

Lines changed: 8 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
"""Plugin step — a named, no-op workflow extension point.
1+
"""Slot step — a named, no-op workflow slot.
22
33
An upstream workflow declares a slot at the position where a downstream
44
project may extend it. The step ``id`` is the overlay anchor; ``name`` is only
@@ -10,7 +10,7 @@
1010
1111
# Upstream workflow
1212
- id: post-implement
13-
type: plugin
13+
type: slot
1414
name: post-implement
1515
1616
# .specify/workflows/overlays/my-workflow/fill-post-implement.yml
@@ -31,17 +31,17 @@
3131
from specify_cli.workflows.base import StepBase, StepContext, StepResult, StepStatus
3232

3333

34-
class PluginStep(StepBase):
35-
"""Provide a named workflow extension point that skips when unfilled."""
34+
class SlotStep(StepBase):
35+
"""Provide a named workflow slot that skips when unfilled."""
3636

37-
type_key = "plugin"
37+
type_key = "slot"
3838

3939
def execute(self, config: dict[str, Any], context: StepContext) -> StepResult:
4040
if context.inside_fan_out:
4141
return StepResult(
4242
status=StepStatus.FAILED,
4343
error=(
44-
f"Plugin step {config.get('id', '?')!r} is not supported "
44+
f"Slot step {config.get('id', '?')!r} is not supported "
4545
"inside fan-out templates because overlays cannot address "
4646
"runtime-multiplied templates."
4747
),
@@ -56,12 +56,12 @@ def validate(self, config: dict[str, Any]) -> list[str]:
5656
name = config.get("name")
5757
if name is None:
5858
errors.append(
59-
f"Plugin step {config.get('id', '?')!r} requires a 'name' field "
59+
f"Slot step {config.get('id', '?')!r} requires a 'name' field "
6060
"(the slot label)."
6161
)
6262
elif not isinstance(name, str) or not name.strip():
6363
errors.append(
64-
f"Plugin step {config.get('id', '?')!r}: 'name' must be a "
64+
f"Slot step {config.get('id', '?')!r}: 'name' must be a "
6565
"non-blank string."
6666
)
6767
return errors

‎tests/test_workflows.py‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -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", "plugin",
111+
"while", "do-while", "fan-out", "fan-in", "init", "slot",
112112
}
113113
assert expected.issubset(set(STEP_REGISTRY.keys()))
114114

‎tests/unit/test_bundler_references.py‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -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", "plugin"):
43+
for step_id in ("shell", "gate", "command", "if", "slot"):
4444
assert step_id in BUILTIN_STEP_TYPES, step_id
4545
assert check(_ref("steps", step_id)) is None, step_id
4646
assert warnings == []
Lines changed: 34 additions & 34 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
"""Tests for the plugin workflow extension-point step."""
1+
"""Tests for the workflow slot step."""
22

33
from __future__ import annotations
44

@@ -12,19 +12,19 @@
1212
from specify_cli.workflows.base import RunStatus, StepContext, StepStatus
1313
from specify_cli.workflows.engine import WorkflowDefinition, WorkflowEngine, validate_workflow
1414
from specify_cli.workflows.overlays import WorkflowResolver
15-
from specify_cli.workflows.steps.plugin import PluginStep
15+
from specify_cli.workflows.steps.slot import SlotStep
1616

1717

1818
def _workflow_data(steps: list[dict[str, object]]) -> dict[str, object]:
1919
return {
2020
"schema_version": "1.0",
21-
"workflow": {"id": "plugin-workflow", "name": "Plugin Workflow", "version": "1.0.0"},
21+
"workflow": {"id": "slot-workflow", "name": "Slot Workflow", "version": "1.0.0"},
2222
"steps": steps,
2323
}
2424

2525

2626
def _write_workflow(project_root: Path, data: dict[str, object]) -> None:
27-
workflow_dir = project_root / ".specify" / "workflows" / "plugin-workflow"
27+
workflow_dir = project_root / ".specify" / "workflows" / "slot-workflow"
2828
workflow_dir.mkdir(parents=True, exist_ok=True)
2929
(workflow_dir / "workflow.yml").write_text(
3030
yaml.safe_dump(data), encoding="utf-8"
@@ -33,22 +33,22 @@ def _write_workflow(project_root: Path, data: dict[str, object]) -> None:
3333

3434
def _write_overlay(project_root: Path, data: dict[str, object]) -> None:
3535
overlay_dir = (
36-
project_root / ".specify" / "workflows" / "overlays" / "plugin-workflow"
36+
project_root / ".specify" / "workflows" / "overlays" / "slot-workflow"
3737
)
3838
overlay_dir.mkdir(parents=True, exist_ok=True)
3939
(overlay_dir / "fill-slot.yml").write_text(yaml.safe_dump(data), encoding="utf-8")
4040

4141

42-
def test_plugin_step_is_registered_as_builtin():
43-
step = get_step_type("plugin")
42+
def test_slot_step_is_registered_as_builtin():
43+
step = get_step_type("slot")
4444

45-
assert isinstance(step, PluginStep)
46-
assert step.type_key == "plugin"
47-
assert "plugin" in BUILTIN_STEP_TYPES
45+
assert isinstance(step, SlotStep)
46+
assert step.type_key == "slot"
47+
assert "slot" in BUILTIN_STEP_TYPES
4848

4949

50-
def test_plugin_step_validate_returns_errors_for_malformed_names():
51-
step = PluginStep()
50+
def test_slot_step_validate_returns_errors_for_malformed_names():
51+
step = SlotStep()
5252

5353
assert any("missing required 'id'" in error for error in step.validate({}))
5454
assert "requires a 'name' field" in step.validate({"id": "slot"})[0]
@@ -69,28 +69,28 @@ def test_plugin_step_validate_returns_errors_for_malformed_names():
6969
(123, "non-blank string"),
7070
],
7171
)
72-
def test_plugin_step_errors_are_reported_through_workflow_validation(
72+
def test_slot_step_errors_are_reported_through_workflow_validation(
7373
name: object, expected_error: str
7474
):
7575
definition = WorkflowDefinition(
76-
_workflow_data([{"id": "slot", "type": "plugin", "name": name}])
76+
_workflow_data([{"id": "slot", "type": "slot", "name": name}])
7777
)
7878

7979
errors = validate_workflow(definition)
8080

81-
assert any("Plugin step 'slot'" in error for error in errors)
81+
assert any("Slot step 'slot'" in error for error in errors)
8282
assert any(expected_error in error for error in errors)
8383

8484

85-
def test_addressable_nested_plugin_step_validates_cleanly():
85+
def test_addressable_nested_slot_step_validates_cleanly():
8686
definition = WorkflowDefinition(
8787
_workflow_data(
8888
[
8989
{
9090
"id": "conditional",
9191
"type": "if",
9292
"condition": "true",
93-
"then": [{"id": "slot", "type": "plugin", "name": "lint"}],
93+
"then": [{"id": "slot", "type": "slot", "name": "lint"}],
9494
}
9595
]
9696
)
@@ -99,8 +99,8 @@ def test_addressable_nested_plugin_step_validates_cleanly():
9999
assert validate_workflow(definition) == []
100100

101101

102-
def test_plugin_step_skips_without_mutating_the_shared_instance():
103-
step = PluginStep()
102+
def test_slot_step_skips_without_mutating_the_shared_instance():
103+
step = SlotStep()
104104
before = vars(step).copy()
105105

106106
result = step.execute({"id": "slot", "name": "lint"}, StepContext())
@@ -110,8 +110,8 @@ def test_plugin_step_skips_without_mutating_the_shared_instance():
110110
assert vars(step) == before
111111

112112

113-
def test_plugin_step_fails_when_executed_inside_fan_out():
114-
step = PluginStep()
113+
def test_slot_step_fails_when_executed_inside_fan_out():
114+
step = SlotStep()
115115

116116
result = step.execute(
117117
{"id": "slot", "name": "per-item"},
@@ -123,21 +123,21 @@ def test_plugin_step_fails_when_executed_inside_fan_out():
123123
assert result.output == {}
124124

125125

126-
def test_unfilled_plugin_slot_is_persisted_and_does_not_halt_workflow(project_dir):
126+
def test_unfilled_slot_is_persisted_and_does_not_halt_workflow(project_dir):
127127
_write_workflow(
128128
project_dir,
129129
_workflow_data(
130130
[
131-
{"id": "slot", "type": "plugin", "name": "post-implement"},
131+
{"id": "slot", "type": "slot", "name": "post-implement"},
132132
{"id": "marker", "type": "shell", "run": "echo marker"},
133133
]
134134
),
135135
)
136136
engine = WorkflowEngine(project_dir)
137137

138-
definition = engine.load_workflow("plugin-workflow")
138+
definition = engine.load_workflow("slot-workflow")
139139
assert engine.validate(definition) == []
140-
state = engine.execute(definition, run_id="plugin-run")
140+
state = engine.execute(definition, run_id="slot-run")
141141

142142
assert state.status is RunStatus.COMPLETED
143143
state_data = json.loads((state.runs_dir / "state.json").read_text(encoding="utf-8"))
@@ -158,13 +158,13 @@ def test_unfilled_plugin_slot_is_persisted_and_does_not_halt_workflow(project_di
158158
assert skipped_events[0]["status"] == "skipped"
159159

160160

161-
def test_overlay_replaces_plugin_slot_and_attributes_it_to_the_overlay(project_dir):
161+
def test_overlay_replaces_slot_and_attributes_it_to_the_overlay(project_dir):
162162
_write_workflow(
163163
project_dir,
164164
_workflow_data(
165165
[
166166
{"id": "before", "type": "shell", "run": "echo before"},
167-
{"id": "slot", "type": "plugin", "name": "post-implement"},
167+
{"id": "slot", "type": "slot", "name": "post-implement"},
168168
{"id": "after", "type": "shell", "run": "echo after"},
169169
]
170170
),
@@ -173,7 +173,7 @@ def test_overlay_replaces_plugin_slot_and_attributes_it_to_the_overlay(project_d
173173
project_dir,
174174
{
175175
"id": "fill-slot",
176-
"extends": "plugin-workflow",
176+
"extends": "slot-workflow",
177177
"edits": [
178178
{
179179
"replace": "slot",
@@ -184,7 +184,7 @@ def test_overlay_replaces_plugin_slot_and_attributes_it_to_the_overlay(project_d
184184
)
185185
engine = WorkflowEngine(project_dir)
186186

187-
definition = engine.load_workflow("plugin-workflow")
187+
definition = engine.load_workflow("slot-workflow")
188188
assert [step["id"] for step in definition.steps] == ["before", "slot", "after"]
189189
assert definition.steps[1]["type"] == "shell"
190190
assert engine.validate(definition) == []
@@ -193,7 +193,7 @@ def test_overlay_replaces_plugin_slot_and_attributes_it_to_the_overlay(project_d
193193
assert "filled" in state.step_results["slot"]["output"]["stdout"]
194194

195195
_definition, _layers, attribution = WorkflowResolver(project_dir).resolve_with_layers(
196-
"plugin-workflow"
196+
"slot-workflow"
197197
)
198198
sources = {step.step_id: step.source for step in attribution}
199199
assert sources == {
@@ -203,15 +203,15 @@ def test_overlay_replaces_plugin_slot_and_attributes_it_to_the_overlay(project_d
203203
}
204204

205205

206-
def test_plugin_steps_are_rejected_inside_fan_out_templates():
206+
def test_slot_steps_are_rejected_inside_fan_out_templates():
207207
definition = WorkflowDefinition(
208208
_workflow_data(
209209
[
210210
{
211211
"id": "fan",
212212
"type": "fan-out",
213213
"items": [],
214-
"step": {"id": "slot", "type": "plugin", "name": "per-item"},
214+
"step": {"id": "slot", "type": "slot", "name": "per-item"},
215215
}
216216
]
217217
)
@@ -220,12 +220,12 @@ def test_plugin_steps_are_rejected_inside_fan_out_templates():
220220
errors = validate_workflow(definition)
221221

222222
assert any(
223-
"Plugin step 'slot' is not supported inside fan-out templates" in error
223+
"Slot step 'slot' is not supported inside fan-out templates" in error
224224
for error in errors
225225
)
226226

227227

228-
def test_non_plugin_fan_out_templates_remain_valid():
228+
def test_non_slot_fan_out_templates_remain_valid():
229229
definition = WorkflowDefinition(
230230
_workflow_data(
231231
[

‎workflows/ARCHITECTURE.md‎

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,7 @@ flowchart TD
1919
G --> H{Step type?}
2020
H -- command --> I["CommandStep.execute()"]
2121
H -- shell --> J["ShellStep.execute()"]
22-
H -- plugin --> V["PluginStep.execute()"]
22+
H -- slot --> V["SlotStep.execute()"]
2323
H -- gate --> K["GateStep.execute()"]
2424
H -- "if" --> L["IfThenStep.execute()"]
2525
H -- switch --> M["SwitchStep.execute()"]
@@ -87,7 +87,7 @@ The engine ships with 12 built-in step types, each in its own subpackage under `
8787
| `prompt` | `PromptStep` | Send an arbitrary inline prompt to integration CLI | No |
8888
| `shell` | `ShellStep` | Run a shell command, capture output | No |
8989
| `init` | `InitStep` | Bootstrap a project (equivalent to `specify init`) | No |
90-
| `plugin` | `PluginStep` | Named extension point; skipped when unfilled | No |
90+
| `slot` | `SlotStep` | Named workflow slot; skipped when unfilled | No |
9191
| `gate` | `GateStep` | Interactive human review/approval | No (pauses in CI) |
9292
| `if` | `IfThenStep` | Conditional branching (then/else) | Yes |
9393
| `switch` | `SwitchStep` | Multi-branch dispatch on expression | Yes |
@@ -203,7 +203,7 @@ src/specify_cli/
203203
│ ├── command/ # Dispatch command to AI integration
204204
│ ├── shell/ # Run shell command
205205
│ ├── init/ # Bootstrap a project (specify init)
206-
│ ├── plugin/ # Named, skipped-when-unfilled extension point
206+
│ ├── slot/ # Named workflow slot; skipped when unfilled
207207
│ ├── gate/ # Human review checkpoint
208208
│ ├── if_then/ # Conditional branching
209209
│ ├── prompt/ # Arbitrary inline prompts

0 commit comments

Comments
 (0)