Skip to content
Closed
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
2 changes: 2 additions & 0 deletions docs/reference/bundles.md
Original file line number Diff line number Diff line change
Expand Up @@ -81,6 +81,8 @@ The source may also be a bundle directory or `.zip` artifact. Refresh uses the s

A local bundle source supplies the manifest, not its component payloads. Components resolved through catalogs still require network access to refresh, even when already installed. Add `--offline` only when the components being installed or refreshed ship with Spec Kit; otherwise the command reports which component needs network access. Re-run without `--offline` to fetch that component through its catalog.

> **Step payloads resolve through the step catalog only.** A bundle's `provides.steps` entries still resolve exclusively through the active step catalogs. Bundle-local `steps/<id>/` payloads and relative `provides.steps[].source` overrides are **not** resolved in this release, so a step declared that way cannot be installed offline. To ship a step with a bundle today, publish it to a step catalog the bundle's users can reach.

## Update Bundles

```bash
Expand Down
240 changes: 238 additions & 2 deletions docs/reference/workflows.md
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
# Workflows

Workflows automate multi-step Spec-Driven Development processes — chaining commands, prompts, shell steps, and human checkpoints into repeatable sequences. They support conditional logic, loops, fan-out/fan-in, and can be paused and resumed from the exact point of interruption.
Workflows automate multi-step Spec-Driven Development processes — chaining commands, prompts, shell steps, and human checkpoints into repeatable sequences. They support conditional logic, loops, fan-out/fan-in, composition (running another installed workflow as a scoped subtree), and can be paused and resumed from the exact point of interruption.

## Run a Workflow

Expand Down Expand Up @@ -335,7 +335,6 @@ When an installed workflow is refreshed or reinstalled, project overlays in `.sp
- An overlay that targets a step id that does not exist in the base workflow will raise a validation error when the workflow is resolved.
- Overlays cannot target steps added by other overlays.
- Overlays cannot add new inputs or change the input schema of the base workflow.

## Update Workflows

```bash
Expand Down Expand Up @@ -540,9 +539,246 @@ specify workflow run speckit -i spec="Build a kanban board with drag-and-drop ta
| `do-while` | Execute at least once, then loop on condition |
| `fan-out` | Dispatch a step for each item in a list |
| `fan-in` | Aggregate results from a fan-out step |
| `workflow` | Run an installed workflow as a scoped subtree |

> **Security note:** a `shell` step runs a local command with **your** privileges. There is no capability sandbox — `requires` is an advisory pre-condition block (spec-kit version, integrations), not a runtime gate, so it does **not** restrict what a step can do. In particular there is no `requires.permissions` capability gate: it is rejected by validation precisely because it would imply a sandbox that does not exist. Review any catalog or downloaded workflow before running it, and use a `gate` step to require explicit approval before sensitive or destructive shell commands.

### Custom step packages

Custom step types are installed with `specify workflow step`. A step is a
directory package containing metadata and executable Python:

```text
my-step/
├── step.yml # required, at the package root
├── __init__.py # required, at the package root
└── helpers.py # optional nested modules and data files
```

`step.yml` declares the step's identity. `step.type_key` must exactly match the
`<step_id>` passed on the command line — the ID is never inferred from package
content:

```yaml
step:
type_key: my-step
name: My Step
version: 0.1.0
author: you
description: What this step does
```

`__init__.py` must define a `StepBase` subclass whose `type_key` matches:

```python
from specify_cli.workflows.base import StepBase, StepResult


class MyStep(StepBase):
type_key = "my-step"

def execute(self, config, context):
return StepResult(output={"ok": True})
```

#### Install from a local directory

```bash
specify workflow step add my-step --dev /path/to/my-step
```

`--dev` takes a **directory** (not an archive, not a bare `step.yml`) that is a
complete package. This needs no catalog, server, or network, which makes it the
supported local-authoring loop:

```bash
specify workflow step add my-step --dev ./my-step
specify workflow step list
specify workflow step info my-step
# edit ./my-step, then replace the installed copy:
specify workflow step add my-step --dev ./my-step --force
specify workflow step remove my-step
```

#### Install from an archive URL

```bash
specify workflow step add my-step --from https://example.com/my-step.zip
```

`--from` accepts a `.zip`, `.tar.gz`, or `.tgz` archive (a bare `step.yml`
URL is **not** a package). The archive may place `step.yml` and `__init__.py`
at its root or under exactly one top-level directory; unrelated top-level
siblings are rejected. Because a step package contains executable Python, a
direct URL install shows a default-deny trust confirmation before any network
request; declining cancels with no request and no error. HTTPS is required
(HTTP is permitted only for loopback hosts), redirects must remain secure, and
downloads are size-bounded.

#### Install from the catalog

```bash
specify workflow step add my-step
```

Catalog installs resolve individual file URLs from the active step catalogs and
then go through the same validation and commit path as `--dev` and `--from`.
Discovery-only catalogs cannot be installed from.

#### Replacement and force

```bash
specify workflow step add my-step --dev ./my-step --force
specify workflow step add my-step --from https://example.com/my-step.zip --force
```

`--force` first stages and validates the replacement before touching the
existing installation, and can replace both a registered install and a leftover
unregistered directory. Validation and staging failures leave the previous
package untouched. If a replacement commit fails after the previous package is
removed — removing the old directory, publishing the new one, or writing the
registry — the installation is left incomplete: rerun the command with the
original source and `--force` to reinstall. No automatic rollback is attempted.

#### Package validation

Every source is validated identically before anything is committed:

- `step.yml` and `__init__.py` must be regular, non-symlink files at the package
root.
- The package tree is copied recursively (relative imports, nested helper
modules, and data files are supported). A symlinked package root, any
descendant symlink, and any filesystem object that is not a regular file or
directory are rejected — including inside excluded directories.
- `.git`, `__pycache__`, and `.DS_Store` entries are excluded from the copy and
from the limits.
- The installed-package policy permits at most **512 retained files** and
**50 MiB** of retained content. Excluded entries do not consume this budget.
- Archive URLs also pass transport/extraction safety limits before package
validation: at most 512 archive entries, 50 MiB downloaded or extracted, and
10 MiB per archive member. Catalog files have a 50 MiB per-response bound.
- Installation validates and copies the package but does **not** import or
execute `__init__.py`. Installed custom step modules are loaded during startup
of `workflow add`, `workflow run`, and `workflow resume`, before any particular
custom step necessarily executes.

> **Security note:** Loading a custom step runs its Python with **your**
> privileges. Only install and retain step packages from sources you trust.

#### Listing, running, and removing

Installed custom steps appear in `specify workflow step list` and are loaded
automatically by `workflow add`, `workflow run`, and `workflow resume`. Remove
one with:

```bash
specify workflow step remove my-step
```

#### Registry provenance

Each installed step records only the *kind* of its source — `catalog`
(optionally with the catalog name), `local`, or `url`. Local paths and source
URLs are never persisted. `specify workflow step info <id>` shows the source.

#### Bundle-local limitation

A bundle's `provides.steps` still resolves only through the active step
catalogs. Bundle-local `steps/<id>/` payloads and relative
`provides.steps[].source` overrides are **not** resolved in this release, so
such steps are not installable offline. See the [Bundles reference](bundles.md).

### Workflow composition (`type: workflow`)

A `workflow` step runs an installed workflow as a **scoped subtree of the
current run** — there is one run, one run directory, and one process. The
included workflow behaves like a function call: values cross the boundary only
Comment on lines +691 to +695
through its declared `inputs` and `outputs`.

```yaml
steps:
- id: triage
type: prompt
prompt: "Select the workflow to run"

- id: run-selected
type: workflow
workflow: "{{ steps.triage.output.stdout }}"
input:
report: "{{ inputs.report }}"
slug: "{{ inputs.slug }}"
```

| Field | Required | Description |
| ---------- | -------- | ----------- |
| `workflow` | yes | Installed workflow ID, or an expression evaluated in the caller's scope. The resolved value must be a valid ID of a registered, installed, and enabled workflow. Literal IDs are validated at definition time. |
| `input` | no | Mapping of the target's declared input names to values evaluated in the caller's scope. An undeclared name is rejected. Defaults, required, type, and enum rules apply. |

`type: workflow` is an engine facility (like `fan-out`), not a custom-step API.
The engine owns the nested scope tree; custom steps still receive only a
`StepContext`.

#### Scope isolation

The included workflow receives a separate expression scope:

- `inputs` contains only the resolved, declared, and validated mapped inputs.
- `steps` contains only the included workflow's own step results.
- Caller inputs and caller step results are **not** visible unless explicitly
passed through the `input` mapping.
- Project root, integration defaults, and the run ID remain available as
execution infrastructure.

#### Declared outputs

An included workflow exposes values back to its caller only through a top-level
`outputs` block. Each entry requires a `value` expression evaluated in the
included workflow's local scope once it completes:

```yaml
outputs:
result:
value: "{{ steps.fix.output.stdout }}"
tested:
value: "{{ steps.test.output.exit_code == 0 }}"
```

The caller reads them from the workflow step's output:

```yaml
"{{ steps.run-selected.output.result }}"
```

Output names must be safe lowercase identifiers and cannot use the reserved
names `workflow`, `status`, `error`, `aborted`, `integration`, `model`,
`options`, or `input`. A whole expression preserves its resolved type;
interpolation mixed with text produces a string. Paused, failed, and aborted
scopes do not evaluate outputs.

#### Lifecycle and failure handling

The workflow step reports the aggregate outcome of its subtree: all required
steps complete → `completed`; an included step pauses → the run pauses; an
included failure (unhandled) → the run fails; an included gate abort → the run
aborts (`output.aborted: true`). `continue_on_error: true` on the workflow step
lets the caller continue past an otherwise unhandled included failure; it never
overrides an abort or bypasses a pause.

#### Resume and composition limits

The resolved target, its composed definition snapshot (including overlays), and
the validated inputs are persisted with the run. On resume the engine reuses the
snapshot and resumes at the included scope's local step index; it does not
re-resolve the target. Editing an installed workflow affects new invocations,
not a scope already bound within a persisted run. `workflow resume --input`
updates the **root** workflow's inputs; a composing workflow forwards them by
mapping them into the child's declared inputs.

Recursive composition is allowed, but cycles are rejected by path (`A -> B -> A`
fails while `A -> B -> D` and `A -> C -> D` is a legal diamond). Composition is
limited to 16 included levels; the root is depth 0 and entering depth 17 is
rejected.

### Per-Step Integration Configuration

Command steps may pass structured runtime configuration to integrations that
Expand Down
22 changes: 20 additions & 2 deletions src/specify_cli/workflows/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -75,11 +75,27 @@ def _register_builtin_steps() -> None:

# The step types Spec Kit ships, snapshotted before any community step can be
# loaded. ``load_custom_steps`` adds project-installed ids to the process-global
# ``STEP_REGISTRY`` and never removes them, so ``STEP_REGISTRY`` cannot answer
# ``STEP_REGISTRY`` and refreshes them for each project, so it cannot answer
# "is this bundled with Spec Kit?" in a long-lived process: a step loaded for one
# project would look built-in for the next. Callers that need the immutable set
# (e.g. the bundler's reference checker) must use this instead.
BUILTIN_STEP_TYPES: frozenset[str] = frozenset(STEP_REGISTRY)
_CUSTOM_STEP_MODULES: set[str] = set()


def _unload_custom_steps() -> None:
"""Clear custom registrations and synthetic imports from a prior project."""
import sys

for type_key in tuple(STEP_REGISTRY):
if type_key not in BUILTIN_STEP_TYPES:
del STEP_REGISTRY[type_key]
for module_name in _CUSTOM_STEP_MODULES:
sys.modules.pop(module_name, None)
prefix = module_name + "."
for loaded_name in [name for name in sys.modules if name.startswith(prefix)]:
sys.modules.pop(loaded_name, None)
_CUSTOM_STEP_MODULES.clear()


def load_custom_steps(project_root: Path) -> list[str]:
Expand All @@ -97,6 +113,7 @@ def load_custom_steps(project_root: Path) -> list[str]:
import re as _re
import sys as _sys

_unload_custom_steps()
steps_dir = Path(project_root) / ".specify" / "workflows" / "steps"

# Defense-in-depth: refuse to execute step code from a symlinked
Expand Down Expand Up @@ -192,6 +209,7 @@ def load_custom_steps(project_root: Path) -> list[str]:
_register_step(step_class())
loaded.append(type_key)
registered = True
_CUSTOM_STEP_MODULES.add(module_name)
finally:
# If the step wasn't successfully registered (failed import,
# no matching StepBase subclass, or registration error), remove
Expand All @@ -206,7 +224,7 @@ def load_custom_steps(project_root: Path) -> list[str]:
k for k in _sys.modules if k.startswith(submodule_prefix)
]:
_sys.modules.pop(_mod_key, None)
except Exception: # noqa: BLE001
except Exception: # noqa: BLE001, S112
# Silently skip broken step packages at load time
continue

Expand Down
Loading