Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
21 commits
Select commit Hold shift + click to select a range
b043398
feat: add workflow run definition
Oct 10, 2026
57b4136
fix: reject recursive workflow JSON aliases
Oct 10, 2026
a08f50e
fix: reject duplicate canonical workflow JSON keys
Oct 12, 2026
32dff35
fix: bound workflow JSON alias expansion
Oct 12, 2026
cb32b4c
fix: lower workflow JSON node budget
Oct 12, 2026
eacd38f
refactor: inline workflow JSON node budget
Oct 12, 2026
d1336d3
refactor: name the workflow JSON node budget
Oct 12, 2026
28fdd4f
fix: scope workflow JSON budget, single-pass scope summaries
Oct 13, 2026
db9d616
fix: restore workflows/expressions.py
Oct 13, 2026
53dc96d
fix: bound workflow JSON output by bytes and do not blame aliases
Oct 13, 2026
2f95dc4
test: accept Python 3.14 JSON decode error for deeply nested state
Oct 20, 2026
73b739a
fix: scope gate join to fan-out templates, drop decoder-specific asse…
Oct 20, 2026
12b6512
docs: document that gate.scope_path is omitted for root workflow gates
Oct 20, 2026
7a81012
fix: reference binary values and sort sets in workflow definition JSON
Oct 21, 2026
cb9ae84
fix: report decoder errors on run state as run-scoped errors
Oct 23, 2026
594a44c
fix: budget each workflow definition separately in definition output
Oct 23, 2026
07a0b95
Merge remote-tracking branch 'upstream/main' into feat/4792-workflow-…
Oct 24, 2026
bb6a71d
fix(workflows): inventory the definition command for MCP parity
Oct 24, 2026
54cbada
fix(workflows): require bound workflow IDs during execution validation
Oct 26, 2026
7a79445
docs(workflows): clarify schema_version and envelope stability in def…
Oct 26, 2026
3b21866
fix(workflows): enforce per-definition serialized JSON byte budget
Oct 27, 2026
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
52 changes: 51 additions & 1 deletion docs/reference/workflows.md
Original file line number Diff line number Diff line change
Expand Up @@ -109,6 +109,56 @@ specify workflow status [<run_id>]

Shows the status of a specific run, or lists all runs if no ID is given. Run states: `created`, `running`, `completed`, `paused`, `failed`, `aborted`.

## Workflow Definition

```bash
specify workflow definition <run_id> --json
```

| Option | Description |
| -------- | ---------------------------------------------------------------- |
| `--json` | Required. Emit the run's workflow definition as a JSON object |

Returns the workflow definition a run was **started with**, so an integration can render a run (for example the gates it declares and their messages) without drifting from what the engine actually executes. It reads only the run's own persisted data and never the installed workflow: editing or removing the installed workflow does not change the output. Runtime progress and the active gate come from [`specify workflow status --json`](#workflow-status).

Omitting `--json` exits with code `2` and prints a usage message on stderr; no stdout is produced. A text renderer is deferred, so the JSON shape below is the only contract.

```json
{
"run_id": "662bf791",
"definition": {
"schema_version": "1.0",
"workflow": { "id": "spec-review", "name": "Spec review", "version": "1.0.0" },
"steps": [
{ "id": "draft", "type": "command", "command": "speckit.specify" },
{ "id": "approve", "type": "gate", "message": "Review the specification." }
]
},
"workflow_scopes": []
}
```

- **`definition`** is the workflow as persisted for the run, verbatim and with overlays already applied, so it reflects the steps as executed. Every key is passed through (including `requires`, `outputs` and unknown keys). Nothing is added, renamed or filled in: an omitted field, such as a gate's `options`, means the engine default applies at run time. Expressions (`{{ ... }}`) are not evaluated. The structure follows the [workflow YAML schema](#workflow-yaml-schema), so `schema_version` is the version of this interface and a schema change is a change to it; because `definition` is emitted as authored, an omitted `schema_version` defaults to `"1.0"` and the engine compares `str(schema_version)` (so unquoted `schema_version: 1.0` in YAML appears as the JSON number `1.0`). Keys may be added to the top-level response envelope (`run_id`, `definition`, `workflow_scopes`) in future versions, but existing envelope keys are never removed or renamed.
- **Plain JSON.** YAML-native values that JSON cannot represent, such as an unquoted date, appear as strings. Binary values (`!!binary`) are not emitted: each appears as a `"<binary: N bytes>"` placeholder, and the value itself stays in the run's snapshot. A set (`!!set`) appears as a list sorted by its JSON form, so the output does not depend on hash randomization. Non-string mapping keys and non-finite numbers are stringified too. Two mapping keys that collapse to the same JSON property name (for example `1` and `"1"`) make the command fail rather than emit lossy JSON. JSON cannot share a reference, so a reused YAML alias (anchor) is duplicated; a recursive alias, or a non-recursive alias graph that expands past a fixed node or output-size budget, fails instead of consuming unbounded CPU, memory and stdout. The budget applies to each definition separately (the root and every entry in `workflow_scopes`), so a run with many bound workflow calls is not rejected for its total size.
- **`workflow_scopes`** (always present) holds the frozen definition of every [workflow call](#workflow-composition) that has been reached, in execution order. Each entry is `{ "scope_path", "workflow_id", "definition" }`, with `scope_path` and `workflow_id` identical to the values in `status --json`. A call step inside `definition` stays as authored; the callee is never expanded in place. A call that has not been reached yet, or whose target could not be resolved, has no entry, and its definition is never read from the installed workflow. The list grows as calls are reached, and a call inside a loop or fan-out has one entry per iteration or item.
- **Not included: dynamically generated steps.** The output contains the authored root definition and the frozen definitions of bound workflow calls. Steps that a custom step type generates at run time through `StepResult.next_steps` are persisted in the run's execution state but are not exported, so a gate created that way has no definition in this output. The built-in `if`, `switch`, `while`, `do-while` and `fan-out` steps take their steps from the authored definition and are covered.
- **`scope_path`** is a list of strings from the root: the authored `id` of every enclosing step (`step-<index>` for a step without an `id`). Containers such as `if`, `switch`, loops and fan-out are elements; each loop iteration and fan-out item adds its index as a string, including the first (`["loop", "0", "call"]`). `workflow_scopes` is always present here, while `status --json` includes it only when the run has workflow calls.

### Matching a gate to its definition

`status --json` reports an active gate as `gate.step_id` and `gate.scope_path`. To find the gate's definition:

1. Read `gate.scope_path` as `[]` when it is missing: `status --json` omits it for a gate directly in the root workflow. Then take the `workflow_scopes` entry with the longest `scope_path` that is a prefix of `gate.scope_path`. If there is none, use the root `definition`. Do not require an exact match: `gate.scope_path` lists every enclosing step, including `if`, `switch`, loops and fan-out containers, while an entry's `scope_path` ends at the call step. A gate in an `if` named `wrap` inside a workflow called by `call`, itself in an `if` named `route`, reports `["route", "call", "wrap"]`, and its entry has `["route", "call"]`.
2. In that definition, find the step whose `id` equals the authored step ID. In later loop iterations and in fan-out items `step_id` is qualified as `<container>:<id>:<n>` (for example `fan:g:0`); the authored ID is the second-to-last `:`-separated segment, because `:` is not allowed in authored step IDs. Step IDs are unique within a definition except across fan-out templates, which have their own ID namespace: a root step and a fan-out template step may share an ID. If `gate.scope_path` runs through a fan-out step, look only inside that step's `step` template; otherwise ignore fan-out templates. For example, a root shell `g` and a gate `g` in the template of fan-out `fan` both exist, and `fan:g:0` with `scope_path` `["fan", "0"]` is the template's gate, not the root shell.

A gate generated at run time by a custom step has no definition to match; see the note on dynamically generated steps above.

`status --json` does not report per-step status for steps inside called workflows.

### Errors

Failures write a single `{"error": "<message>"}` object to stderr, leave stdout empty and exit with code `1`: not a Spec Kit project, an invalid `SPECIFY_INIT_DIR`, unsafe (including symlinked) workflow storage, an invalid or unknown run ID, an unreadable or too deeply nested run state, a run with no persisted definition (for example one created before definitions were persisted), a corrupt definition or execution state, a definition whose mapping keys collapse to the same JSON property name, and a definition with recursive or too-large-to-expand YAML aliases. Usage errors with `--json` also use this JSON error contract and exit with code `2`.

## List Installed Workflows

```bash
Expand Down Expand Up @@ -496,7 +546,7 @@ Catalogs are resolved in this order (first match wins):
3. **User config** — `~/.specify/workflow-catalogs.yml`
4. **Built-in defaults** — official catalog + community catalog

## Workflow Definition
## Workflow YAML Schema

Workflows are defined in YAML files. Here is the built-in **Full SDD Cycle** workflow that ships with Spec Kit:

Expand Down
12 changes: 6 additions & 6 deletions src/specify_cli/_installed_list_json.py
Original file line number Diff line number Diff line change
@@ -1,8 +1,8 @@
"""Private JSON output helpers for installed preset and extension lists.
"""Private strict-JSON output helpers for installed-resource commands.

This module intentionally serves only the two installed-list commands. Their
human-facing renderers retain the legacy manager records, while this adapter
defines the public machine-readable wire contract.
Installed preset and extension lists use the item helpers below. The shared
error renderer and Typer command class also keep `info` and workflow-definition
commands on the strict JSON contract.
"""
from __future__ import annotations

Expand All @@ -21,7 +21,7 @@


class InstalledListJSONCommand(TyperCommand):
"""Keep JSON list parse failures on the JSON error contract."""
"""Keep `--json` parse failures on the JSON error contract."""

def make_context(self, info_name, args, parent=None, **extra):
json_output = "--json" in args
Expand Down Expand Up @@ -78,7 +78,7 @@ def emit_json(value: Any) -> None:


def emit_json_error(error: Exception, exit_code: int = 1) -> NoReturn:
"""Write the list-command error contract and terminate unsuccessfully."""
"""Write the strict JSON error contract and terminate unsuccessfully."""
message = str(error).strip() or error.__class__.__name__
typer.echo(json.dumps({"error": message}, ensure_ascii=False), err=True)
raise typer.Exit(code=exit_code)
Loading