Files
lda-wf/docs/historical/superpowers/plans/2026-07-20-generic-draft-add-step.md
T

894 lines
31 KiB
Markdown

# Generic Draft Step Authoring Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** Add typed draft-step insertion across the Python application API, JSON-RPC client/server, and a discoverable `wf draft add` CLI subgroup while closing interrupt and subgraph draft-model gaps.
**Architecture:** `wf_artifacts.drafts` remains the canonical persisted authoring model; the generic API accepts a validated `DraftStep` plus a separate map-key `step_id` and lowers one atomic semantic edit into JSON Patch. Python RPC carries the same typed shape, while type-specific Typer commands construct draft models and delegate through the protocol-neutral `WorkflowApiSurface`. Capability composition remains a specialized helper because it also projects schemas and bindings.
**Tech Stack:** Python 3.14, Pydantic v2, Typer, fastapi-jsonrpc, pytest/pytest-asyncio, Ruff, basedpyright.
## Global Constraints
- Follow `docs/superpowers/specs/2026-07-20-generic-draft-add-step-design.md`.
- Use `DraftStep`, not the core `Step` union, at draft/API/RPC boundaries.
- Keep `step_id` separate because draft identifiers are keys in `WorkflowDraft.steps`.
- Preserve one revision increment for step insertion plus requested incoming/outgoing routes.
- Preserve `add_step_from_capability` behavior, especially schema projection and complete multi-outcome routing.
- Remove `wf draft add-step`; do not add a compatibility alias without a real caller.
- Do not add TypeScript/Effect RPC parity or code generation in this slice.
- Structured conditions, clauses, cases, and schemas enter the CLI through JSON files.
- Add docstrings/comments around alias serialization, route validation, and other non-obvious seams.
- Use focused tests during tasks; run the full Python quality gate only in Task 7.
## File Map
- `src/wf_artifacts/drafts/models.py`: persisted draft variants, including typed interrupt contracts and subgraph payloads.
- `src/wf_artifacts/drafts/adapter.py`: lower draft variants into core nodes without artifact loading.
- `src/wf_api/draft_authoring.py`: generic semantic insertion, route validation, and `RouteSource`.
- `src/wf_api/service.py`, `src/wf_api/surface.py`: concrete and protocol-neutral public API methods.
- `src/wf_transport_rpc_http/models.py`: typed RPC parameter models.
- `src/wf_transport_rpc_http/methods/drafts.py`: JSON-RPC method registration.
- `src/wf_transport_rpc_http/client/drafts.py`: remote implementation of the same surface.
- `src/wf_cli/commands/draft_options.py`: shared parsing helpers used by existing and new draft commands.
- `src/wf_cli/commands/draft_add.py`: `wf draft add` Typer subgroup and variant construction.
- `src/wf_cli/commands/drafts.py`: subgroup registration and removal of the flat command.
- `tests/artifacts/test_draft_models.py`, `tests/artifacts/test_draft_adapter.py`: model/adapter parity.
- `tests/wf_api/test_drafts_service.py`: semantic application behavior and atomicity.
- `tests/wf_transport_rpc_http/test_app.py`, `tests/wf_transport_rpc_http/test_client.py`: transport round trips.
- `tests/wf_cli/test_app.py`, `tests/wf_cli/test_remote_target.py`: command UX and local/remote delegation.
- `docs/wf_cli.md`, `docs/wf_api_architecture.md`, `docs/current_roadmap.md`, `skills/wf-cli/SKILL.md`, `skills/wf-workflow/references/*.md`, `ISSUES.md`: live documentation and issue closure.
---
### Task 1: Complete Draft Interrupt And Subgraph Parity
**Files:**
- Modify: `src/wf_artifacts/drafts/models.py`
- Modify: `src/wf_artifacts/drafts/adapter.py`
- Modify: `src/wf_artifacts/drafts/__init__.py` if it exports individual variants
- Test: `tests/artifacts/test_draft_models.py`
- Test: `tests/artifacts/test_draft_adapter.py`
**Interfaces:**
- Produces: `DraftSubgraphPayload`, `DraftSubgraphStep`, expanded `DraftInterruptPayload`, and updated `DraftStep`.
- Produces: adapter lowering to `InterruptNode` and `SubgraphNode` with contracts intact.
- [x] **Step 1: Write failing model tests for typed interrupt contracts**
Add a draft containing:
```python
"review": {
"interrupt": {
"kind": "issue_review",
"request_schema": {
"type": "object",
"properties": {"issues": {"type": "array"}},
"required": ["issues"],
},
"resume_schema": {
"type": "object",
"properties": {"selected": {"type": "array"}},
"required": ["selected"],
},
"outcomes": ["submitted", "cancelled"],
}
}
```
Assert the parsed fields and `model_dump(mode="json", by_alias=True)` preserve both schemas.
- [x] **Step 2: Write failing model tests for subgraph boundaries**
Cover both workflow reference forms:
```python
{"subgraph": {"workflow": {"name": "child"}, "outcomes": ["ok"]}}
{"subgraph": {
"workflow": {"artifact_id": "child_report", "version": 2},
"input_schema": {"type": "object", "properties": {"topic": {"type": "string"}}},
"output_schema": {"type": "object", "properties": {"report": {"type": "string"}}},
"input": [{"target": "topic", "path": "state.topic"}],
"output": [{"source": "report", "target": "state.report"}],
"outcomes": ["ok", "error"],
}}
```
Assert `DraftSubgraphStep` is selected and aliases round-trip.
- [x] **Step 3: Run model tests and confirm red**
Run: `uv run pytest tests/artifacts/test_draft_models.py -q`
Expected: failures for forbidden interrupt schema fields and unknown `subgraph` kind.
- [x] **Step 4: Implement the draft model fields and union member**
Import `SchemaRef` and `WorkflowRef`, add `subgraph` to `STEP_KIND_KEYS`, add the payload/step classes from the approved design, and append `DraftSubgraphStep` to `DraftStep`. Add to `DraftInterruptPayload`:
```python
request_schema: SchemaRef | None = None
resume_schema: SchemaRef | None = None
```
Add a field validator that accepts `None` and rejects a supplied schema unless
`schema.type == "object"`. This keeps untyped interrupts untyped while
validating explicit contracts at draft parse time.
- [x] **Step 5: Write failing adapter tests**
Assert `build_workflow_from_draft` produces:
```python
assert review.request_schema == request_schema
assert review.resume_schema == resume_schema
assert child.workflow.artifact_id == "child_report"
assert child.workflow.version == 2
assert child.input_schema == input_schema
assert child.output_schema == output_schema
assert child.outcomes == ["ok", "error"]
```
- [x] **Step 6: Implement adapter lowering**
Build interrupt keyword arguments so `request_schema`/`resume_schema` are
omitted when `None`; passing object defaults would incorrectly set
`has_explicit_contract`. For subgraphs, append a direct core node because
adapting a draft must not resolve an artifact:
```python
node = SubgraphNode(
id=step_id,
type="subgraph",
**step.subgraph.model_dump(),
)
builder.nodes.append(node)
return node
```
Use an explicit `isinstance(step, DraftSubgraphStep)` branch before the final `TypeError`.
- [x] **Step 7: Verify and commit**
Run:
```bash
uv run pytest tests/artifacts/test_draft_models.py tests/artifacts/test_draft_adapter.py -q
uv run basedpyright src/wf_artifacts tests/artifacts --level error
```
Expected: both pass.
Commit:
```bash
git add src/wf_artifacts/drafts tests/artifacts/test_draft_models.py tests/artifacts/test_draft_adapter.py
git commit -m "feat: complete draft step model parity"
```
---
### Task 2: Add Atomic Generic Draft-Step Insertion
**Files:**
- Modify: `src/wf_api/draft_authoring.py`
- Modify: `src/wf_api/service.py`
- Modify: `src/wf_api/surface.py`
- Modify: `src/wf_api/__init__.py` if `RouteSource` is publicly exported
- Test: `tests/wf_api/test_drafts_service.py`
**Interfaces:**
- Consumes: `DraftStep` including `DraftSubgraphStep` from Task 1.
- Produces: `RouteSource` and `WorkflowApiSurface.add_step(*, workspace_id, revision, step_id, step, incoming, routes)`.
- [x] **Step 1: Rename the internal route value object**
Replace `DraftOutcomeRef` with:
```python
@dataclass(frozen=True)
class RouteSource:
"""One source step/outcome pair used for atomic route edits."""
step_id: str
outcome: str = DEFAULT_OK_OUTCOME
```
Update `handle_draft`, `WorkflowApi.handle_draft`, imports, and existing tests. Do not retain an alias because all callers are repository-owned.
- [x] **Step 2: Write failing parameterized insertion tests**
Parameterize the nine payloads (`use`, `foreach`, `interrupt`, `join`, `end`, `when`, `choose`, `match`, `subgraph`). For each, call:
```python
step_adapter = TypeAdapter(DraftStep)
result = await api.add_step(
workspace_id="draft_ws",
revision=1,
step_id="new_step",
step=step_adapter.validate_python(step_payload),
)
```
Use a Pydantic `TypeAdapter(DraftStep)` in the test and assert revision `2` plus the canonical dumped payload under `draft.steps.new_step`.
- [x] **Step 3: Write failing atomic routing/error tests**
Cover:
- `incoming=RouteSource("existing", "ok")` plus outgoing routes in one revision;
- unknown incoming source;
- duplicate id;
- unknown route outcome;
- routes supplied for `end`, `when`, `choose`, and `match`;
- incomplete but valid route subsets accepted;
- each failure leaves revision and draft bytes unchanged.
- [x] **Step 4: Implement declared-outcome validation**
Add a private helper with exhaustive `isinstance` branches:
```python
def _draft_step_route_outcomes(self, step: DraftStep) -> set[str] | None:
if isinstance(step, DraftUseStep):
return set(self._outcomes_for_capability(step.use) or ("ok",))
if isinstance(step, DraftForeachStep):
outcomes = {"loop", "done"}
if step.foreach.item_error.action in {"skip", "collect"}:
outcomes.add("completed_with_errors")
return outcomes
if isinstance(step, DraftInterruptStep):
return set(step.interrupt.outcomes)
if isinstance(step, DraftJoinStep):
return {"done"}
if isinstance(step, DraftSubgraphStep):
return set(step.subgraph.outcomes)
if isinstance(step, (DraftEndStep, DraftWhenStep, DraftChooseStep, DraftMatchStep)):
return None
raise TypeError(f"unsupported draft step {type(step)!r}")
```
`None` means top-level routes are forbidden, not unknown.
- [x] **Step 5: Implement `WorkflowDraftAuthoringApi.add_step`**
Build a patch only after all checks pass:
```python
patch = [{
"op": "add",
"path": f"/steps/{escape_json_pointer(step_id)}",
"value": step.model_dump(mode="json", by_alias=True),
}]
if routes is not None:
patch.append({
"op": "add",
"path": f"/routes/{escape_json_pointer(step_id)}",
"value": routes,
})
if incoming is not None:
source_routes = draft_routes.get(incoming.step_id)
if source_routes is None:
# JSON Patch cannot add a nested outcome until its parent route map exists.
patch.append({
"op": "add",
"path": f"/routes/{escape_json_pointer(incoming.step_id)}",
"value": {incoming.outcome: step_id},
})
else:
patch.append({
"op": "add",
"path": (
f"/routes/{escape_json_pointer(incoming.step_id)}/"
f"{escape_json_pointer(incoming.outcome)}"
),
"value": step_id,
})
return await self.drafts.patch_draft_workspace(
workspace_id=workspace_id,
revision=revision,
patch=patch,
)
```
Check `steps`, `routes`, duplicate id, incoming source existence, forbidden routes, and unknown route keys before this call.
- [x] **Step 6: Expose the method through service and surface**
Use the exact signature from the design in both `WorkflowApi` and `WorkflowApiSurface`. The service method delegates to `self.draft_authoring.add_step` without converting the typed step back to a raw dict.
- [x] **Step 7: Verify and commit**
Run:
```bash
uv run pytest tests/wf_api/test_drafts_service.py -q
uv run basedpyright src/wf_api tests/wf_api/test_drafts_service.py --level error
```
Commit:
```bash
git add src/wf_api tests/wf_api/test_drafts_service.py
git commit -m "feat: add atomic generic draft step insertion"
```
---
### Task 3: Expose Generic Insertion Through Python JSON-RPC
**Files:**
- Modify: `src/wf_transport_rpc_http/models.py`
- Modify: `src/wf_transport_rpc_http/methods/drafts.py`
- Modify: `src/wf_transport_rpc_http/client/drafts.py`
- Test: `tests/wf_transport_rpc_http/test_app.py`
- Test: `tests/wf_transport_rpc_http/test_client.py`
**Interfaces:**
- Consumes: `WorkflowApiSurface.add_step`, `DraftStep`, and `RouteSource` from Task 2.
- Produces: method `workflow.draft_workspaces.add_step` and remote client parity.
- [x] **Step 1: Write failing RPC parameter tests**
Add:
```python
class RouteSourceParams(RpcParamsModel):
step_id: str = Field(min_length=1)
outcome: str = Field(default="ok", min_length=1)
class AddDraftStepParams(RpcParamsModel):
workspace_id: str = Field(min_length=1)
revision: int = Field(ge=1)
step_id: str = Field(min_length=1)
step: DraftStep
incoming: RouteSourceParams | None = None
routes: dict[str, str] | None = None
```
Before implementation, tests should attempt to import the models and validate a foreach alias (`as`), a when alias (`if`), typed interrupt schemas, and a subgraph artifact reference. Add malformed tests for unknown/multiple kind keys and blank route-source fields.
- [x] **Step 2: Implement parameter models and canonical serialization tests**
Import `DraftStep` from `wf_artifacts.drafts`. Assert:
```python
dumped = params.model_dump(mode="json", by_alias=True)
assert dumped["step"]["foreach"]["as"] == "item"
assert "as_" not in dumped["step"]["foreach"]
```
- [x] **Step 3: Write a failing server round-trip test**
Call `workflow.draft_workspaces.add_step` against a temporary store with a typed interrupt step, incoming source, and routes. Assert one revision increment and preserved request/resume schemas. Add a malformed RPC request and assert the workspace is unchanged.
- [x] **Step 4: Register the method**
Add to `methods/drafts.py`:
```python
@entrypoint.method(
name="workflow.draft_workspaces.add_step",
errors=[WorkflowRpcError],
)
async def workflow_draft_workspaces_add_step(
params: AddDraftStepParams = RpcParams(),
) -> dict[str, Any]:
try:
incoming = (
None
if params.incoming is None
else RouteSource(
step_id=params.incoming.step_id,
outcome=params.incoming.outcome,
)
)
return await server.api.add_step(
workspace_id=params.workspace_id,
revision=params.revision,
step_id=params.step_id,
step=params.step,
incoming=incoming,
routes=params.routes,
)
except (ValueError, KeyError, LookupError, FileNotFoundError) as exc:
raise_workflow_rpc_error(exc)
```
- [x] **Step 5: Write failing client request-shape tests**
Use the existing recording transport fixture. Assert exact method name and payload:
```python
assert request["method"] == "workflow.draft_workspaces.add_step"
assert request["params"]["step"]["when"]["if"]["op"] == "exists"
assert request["params"]["incoming"] == {"step_id": "lookup", "outcome": "ok"}
```
Parameterize all nine variants so alias/schema/reference fields cannot be dropped.
- [x] **Step 6: Implement the client method**
The client accepts typed values and dumps aliases explicitly:
```python
async def add_step(
self: RpcCaller,
*,
workspace_id: str,
revision: int,
step_id: str,
step: DraftStep,
incoming: RouteSource | None = None,
routes: dict[str, str] | None = None,
) -> dict[str, Any]:
return await self._call(
"workflow.draft_workspaces.add_step",
{
"workspace_id": workspace_id,
"revision": revision,
"step_id": step_id,
"step": step.model_dump(mode="json", by_alias=True),
"incoming": (
None
if incoming is None
else {"step_id": incoming.step_id, "outcome": incoming.outcome}
),
"routes": routes,
},
)
```
- [x] **Step 7: Verify and commit**
Run:
```bash
uv run pytest tests/wf_transport_rpc_http/test_app.py tests/wf_transport_rpc_http/test_client.py -q
uv run basedpyright src/wf_transport_rpc_http tests/wf_transport_rpc_http --level error
```
Commit:
```bash
git add src/wf_transport_rpc_http tests/wf_transport_rpc_http
git commit -m "feat: expose generic draft steps over rpc"
```
---
### Task 4: Establish The `wf draft add` Command Boundary
**Files:**
- Create: `src/wf_cli/commands/draft_options.py`
- Create: `src/wf_cli/commands/draft_add.py`
- Modify: `src/wf_cli/commands/drafts.py`
- Test: `tests/wf_cli/test_app.py`
- Test: `tests/wf_cli/test_remote_target.py`
**Interfaces:**
- Consumes: `WorkflowApiSurface.add_step` and existing `add_step_from_capability`.
- Produces: `draft_add.app` registered as `wf draft add` and migrated `capability` command.
- [x] **Step 1: Write failing command-tree tests**
Assert:
```python
result = runner.invoke(app, ["draft", "add", "--help"])
assert result.exit_code == 0
for name in ("capability", "interrupt", "foreach", "join", "end", "when", "choose", "match", "subgraph"):
assert name in result.output
removed = runner.invoke(app, ["draft", "add-step", "--help"])
assert removed.exit_code != 0
```
- [x] **Step 2: Extract only shared parser helpers**
Move `_parse_assignment_flags`, `_parse_map_flags`, `_parse_output_map_flags`, `_parse_step_input_map_flags`, and `_parse_route_flags` from `drafts.py` into `draft_options.py`. Add:
```python
def parse_json_file(path: Path, *, option_name: str) -> Any:
"""Read one structured CLI value and report file/JSON failures as option errors."""
try:
return json.loads(path.read_text(encoding="utf-8"))
except OSError as exc:
raise typer.BadParameter(f"{option_name}: cannot read {path}: {exc}") from exc
except json.JSONDecodeError as exc:
raise typer.BadParameter(f"{option_name}: invalid JSON in {path}: {exc.msg}") from exc
def route_source(from_step: str | None, from_outcome: str | None) -> RouteSource | None:
if from_step is None:
if from_outcome is not None:
raise typer.BadParameter("--from-outcome requires --from-step")
return None
return RouteSource(step_id=from_step, outcome=from_outcome or "ok")
```
Keep imports updated so existing draft commands retain identical parsing.
- [x] **Step 3: Create and register the subgroup**
In `draft_add.py`:
```python
app = typer.Typer(
name="add",
help="Add one typed step to a draft workspace.",
no_args_is_help=True,
)
```
In `drafts.py`, import `draft_add` and register `app.add_typer(draft_add.app, name="add")` after constructing the draft app.
- [x] **Step 4: Move the capability command without changing behavior**
Register the existing body as `@app.command("capability")`. Keep all current
capability options and call `context.handlers.add_step_from_capability` with
`workspace_id`, `revision`, `step_id`, `capability_name`, incoming route
fields, parsed routes, input map, and output bindings exactly as the removed
command does. Its docstring must state that it also projects schemas/bindings
and recommend `wf draft validate`.
- [x] **Step 5: Verify local and remote capability behavior**
Update old CLI tests from:
```text
wf draft add-step WORKSPACE --revision REVISION --step STEP --capability QUALIFIED_NAME
```
to:
```text
wf draft add capability WORKSPACE --revision REVISION --step STEP --capability QUALIFIED_NAME
```
Keep assertions on request payload, projected schemas, route errors, and revision unchanged. Add a remote test proving it still calls `workflow.draft_workspaces.add_step_from_capability`, not generic insertion.
- [x] **Step 6: Verify and commit**
Run:
```bash
uv run pytest tests/wf_cli/test_app.py tests/wf_cli/test_remote_target.py -q
uv run basedpyright src/wf_cli tests/wf_cli --level error
```
Commit:
```bash
git add src/wf_cli/commands tests/wf_cli/test_app.py tests/wf_cli/test_remote_target.py
git commit -m "feat: group draft add commands"
```
---
### Task 5: Add Interrupt, Foreach, Join, And End Commands
**Files:**
- Modify: `src/wf_cli/commands/draft_add.py`
- Test: `tests/wf_cli/test_app.py`
- Test: `tests/wf_cli/test_remote_target.py`
**Interfaces:**
- Consumes: generic `add_step`, parsing helpers, and concrete draft models.
- Produces: four type-specific commands with local/remote parity.
- [x] **Step 1: Add a private command dispatcher and failing delegation tests**
Use one helper so every command has identical transport behavior:
```python
def _submit_step(
ctx: typer.Context,
*,
workspace_id: str,
revision: int,
step_id: str,
step: DraftStep,
from_step: str | None,
from_outcome: str | None,
routes: dict[str, str] | None,
) -> None:
context = load_cli_context(ctx)
emit_json(run_cli_operation(
context,
context.handlers.add_step(
workspace_id=workspace_id,
revision=revision,
step_id=step_id,
step=step,
incoming=route_source(from_step, from_outcome),
routes=routes,
),
))
```
Tests must invoke both local fake handlers and `--url` RPC targets and assert the concrete model received by `add_step`.
- [x] **Step 2: Implement `interrupt` with schema and binding validation**
Construct:
```python
DraftInterruptStep(interrupt=DraftInterruptPayload(
kind=kind,
request_schema=(
SchemaRef.model_validate(parse_json_file(request_schema_file, option_name="--request-schema-file"))
if request_schema_file else None
),
resume_schema=(
SchemaRef.model_validate(parse_json_file(resume_schema_file, option_name="--resume-schema-file"))
if resume_schema_file else None
),
request=[
InputPathBinding(path=source, target=target)
for source, target in parse_map_flags(request).items()
],
resume=[
OutputBinding(source=source, target=target)
for source, target in parse_output_map_flags(resume).items()
],
outcomes=outcomes or ["submitted"],
))
```
Use the repository's existing binding payload/model helpers rather than duplicating path conversion. Tests cover two outcomes, both schemas, aliases, duplicate flags, malformed files, and no API call after parse failure.
- [x] **Step 3: Implement `foreach` and validate policy relationships**
Construct `DraftForeachPayload` from `--over`, `--as`, `--mode`, and:
```python
item_error = ForeachItemErrorPolicy(action=item_error, collect_to=collect_to)
concurrent = (
ForeachConcurrentPolicy(
max_active=max_active,
max_outstanding=max_outstanding,
)
if max_active is not None or max_outstanding is not None
else None
)
```
Reject concurrent limits in serial mode with `typer.BadParameter`; rely on Pydantic to require `collect_to` for collect behavior. Route tests cover `loop`, `done`, and `completed_with_errors`.
- [x] **Step 4: Implement `join` and `end`**
`join` constructs `DraftJoinStep(join={})` and accepts routes. `end` constructs `DraftEndStep(end=DraftEndPayload(outcome=outcome))`, exposes no `--route`, and passes `routes=None`.
- [x] **Step 5: Pin per-command help and error surfaces**
For each command assert `--help` lists its own fields and does not list unrelated fields. Specifically:
- interrupt has schema/request/resume/outcome flags, not foreach policy flags;
- foreach has concurrency flags, not schema flags;
- join has only common routing flags;
- end has `--outcome` but no `--route`.
- [x] **Step 6: Verify and commit**
Run:
```bash
uv run pytest tests/wf_cli/test_app.py tests/wf_cli/test_remote_target.py -q
uv run ruff check src/wf_cli/commands/draft_add.py tests/wf_cli
uv run basedpyright src/wf_cli/commands/draft_add.py tests/wf_cli --level error
```
Commit:
```bash
git add src/wf_cli/commands/draft_add.py tests/wf_cli
git commit -m "feat: add draft control step commands"
```
---
### Task 6: Add Decision And Subgraph Commands
**Files:**
- Modify: `src/wf_cli/commands/draft_add.py`
- Test: `tests/wf_cli/test_app.py`
- Test: `tests/wf_cli/test_remote_target.py`
**Interfaces:**
- Consumes: `_submit_step`, JSON-file parsing, and draft models from prior tasks.
- Produces: `when`, `choose`, `match`, and `subgraph` commands.
- [x] **Step 1: Write failing `when` tests and implement the command**
Given `condition.json`:
```json
{"op":"exists","path":"state.report"}
```
Invoke `wf draft add when WORKSPACE --revision 1 --step decide --condition-file condition.json --then publish --otherwise revise`. Parse with `Condition.model_validate(parse_json_file(condition_file, option_name="--condition-file"))`, then construct:
```python
DraftWhenStep(when=DraftWhenPayload(
if_=condition,
then=then,
otherwise=otherwise,
))
```
The command must not expose `--route` because targets are embedded.
- [x] **Step 2: Write failing `choose` tests and implement the command**
`--clauses-file` contains a JSON array. Validate with
`TypeAdapter(list[DraftChooseClause]).validate_python(value)`, then construct
`DraftChooseStep(choose=DraftChoosePayload(clauses=clauses, default=default))`.
Tests cover ordered clauses, canonical `if` alias output, an empty array, a
non-array document, and no generic routes.
- [x] **Step 3: Write failing `match` tests and implement the command**
`--cases-file` contains a JSON array. Validate with
`TypeAdapter(list[DraftMatchCase])`, then construct:
```python
DraftMatchStep(match=DraftMatchPayload(
value=value,
cases=cases,
default=default,
))
```
Tests preserve scalar `equals` values (`str`, `int`, `bool`, `None`) and ordered targets.
- [x] **Step 4: Write failing subgraph reference tests**
Cover:
- `--workflow-name child`;
- `--artifact-id child_report --artifact-version 2`;
- neither reference form;
- both forms;
- artifact id without version and version without artifact id.
All invalid combinations must fail before `add_step` is called.
- [x] **Step 5: Implement subgraph construction**
Build the reference explicitly:
```python
if workflow_name is not None:
if artifact_id is not None or artifact_version is not None:
raise typer.BadParameter(
"--workflow-name cannot be combined with --artifact-id/--artifact-version"
)
workflow = WorkflowRef(name=workflow_name)
else:
if artifact_id is None or artifact_version is None:
raise typer.BadParameter(
"use --workflow-name or both --artifact-id and --artifact-version"
)
workflow = WorkflowRef(artifact_id=artifact_id, version=artifact_version)
```
Then construct `DraftSubgraphPayload` with optional schema files, canonical input/output bindings, outcomes defaulting to `['ok']`, and description. Pass repeatable routes through `_submit_step`.
- [x] **Step 6: Verify all nine commands and remote parity**
Add a parameterized remote test that invokes every generic command and asserts method `workflow.draft_workspaces.add_step`, canonical step payload aliases, incoming route source, and routes. Keep capability in a separate assertion because it intentionally calls the composed method.
Run:
```bash
uv run pytest tests/wf_cli/test_app.py tests/wf_cli/test_remote_target.py -q
uv run ruff check src/wf_cli/commands tests/wf_cli
uv run basedpyright src/wf_cli/commands tests/wf_cli --level error
```
- [x] **Step 7: Commit**
```bash
git add src/wf_cli/commands/draft_add.py tests/wf_cli
git commit -m "feat: add draft decision and subgraph commands"
```
---
### Task 7: Migrate Live Documentation And Close The Slice
**Files:**
- Modify: `docs/wf_cli.md`
- Modify: `docs/wf_api_architecture.md`
- Modify: `docs/current_roadmap.md`
- Modify: `skills/wf-cli/SKILL.md`
- Modify: `skills/wf-workflow/references/draft-workspaces.md`
- Modify: `skills/wf-workflow/references/workflow-lifecycle.md`
- Modify: `ISSUES.md`
- Move after all checks pass: `docs/superpowers/plans/2026-07-20-generic-draft-add-step.md` to `docs/historical/superpowers/plans/2026-07-20-generic-draft-add-step.md`
**Interfaces:**
- Consumes: all implemented commands and method names.
- Produces: accurate live docs and a clean, archived implementation record.
- [x] **Step 1: Search live references before editing**
Run:
```powershell
rg -n -F 'wf draft add-step' docs skills README.md ISSUES.md --glob '!docs/historical/**'
rg -n -F 'add_step_from_capability' docs skills --glob '!docs/historical/**'
```
Classify each reference: migrate command examples; retain API references when they describe the composed capability helper; do not rewrite thesis/history solely for naming.
- [x] **Step 2: Update user-facing CLI and skill documentation**
Document the command tree and at least these complete examples:
```bash
wf draft add capability report_ws --revision 1 --step render \
--capability local.report.render --route ok=__end__
wf draft add interrupt report_ws --revision 2 --step review \
--kind issue_review --request-schema-file request.schema.json \
--resume-schema-file resume.schema.json \
--outcome submitted --outcome cancelled \
--from-step draft_issues --from-outcome ok \
--route submitted=create_issues --route cancelled=revision_requested
wf draft add when report_ws --revision 3 --step decide \
--condition-file has-report.json --then publish --otherwise revise
```
Explain that `when`/`choose`/`match` embed targets and do not accept `--route`, while invalid intermediate drafts remain saveable and should be checked with `wf draft validate`.
- [x] **Step 3: Update API architecture and roadmap**
Document `workflow.draft_workspaces.add_step`, the `DraftStep` boundary, separate map-key `step_id`, atomic route wiring, and the continued role of `add_step_from_capability`. Mark the roadmap slice complete only after verification.
- [x] **Step 4: Resolve tracked issues honestly**
Change the three items in `ISSUES.md` to checked entries only if tests prove:
```markdown
- [x] Dedicated draft CLI subcommands cover every draft step kind.
- [x] Draft interrupts preserve request and resume schemas.
- [x] Draft subgraphs preserve workflow references and boundary contracts.
```
Add any newly discovered out-of-scope defects as unchecked, reproducible statements.
- [x] **Step 5: Run focused regression suites**
```bash
uv run pytest tests/artifacts/test_draft_models.py tests/artifacts/test_draft_adapter.py tests/wf_api/test_drafts_service.py tests/wf_transport_rpc_http/test_app.py tests/wf_transport_rpc_http/test_client.py tests/wf_cli/test_app.py tests/wf_cli/test_remote_target.py -q
```
Expected: all pass.
- [x] **Step 6: Run the repository quality gate**
```bash
uv run ruff check
uv run ruff format --check
uv run basedpyright --level error
git diff --check
```
If formatting fails, run `uv run ruff format`, inspect the diff, and rerun all four checks. Do not claim the full `uv run pytest -q` suite unless it is actually run; the focused matrix above is the required test gate for this slice.
- [x] **Step 7: Review and archive**
Run the `requesting-code-review` skill against the design/spec and this plan. Fix Critical/Important findings, rerun affected checks, tick completed plan checkboxes, then move the plan to the matching historical path and update live links.
- [x] **Step 8: Commit documentation and issue closure**
```bash
git add docs skills ISSUES.md
git commit -m "docs: complete generic draft step authoring"
```