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
3 changes: 3 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,9 @@ called out explicitly under **Changed** or **Removed**.
nested more than three deep, or one over the same array as a loop around
it, as Kizen's builder does. `kizen docs show automation` has a worked
example.
- **`automations steps add` of a `loop` adds its `next_or_finish` and its
item variable, as Kizen's builder does.** `steps remove` of a loop with an
empty body removes its closer with it and keeps the steps after it.

### Fixed

Expand Down
17 changes: 11 additions & 6 deletions scripts/cli-tree-baseline.txt
Original file line number Diff line number Diff line change
Expand Up @@ -1070,17 +1070,22 @@
│ add Insert one step. The spec is a single AutomationDef step (step_type + │
│ config block; field_refs welcome); placement comes from the flags, and │
│ by default the parent's existing children move under the new step, except │
│ a next_or_finish or merge_branches, which stays on its loop or group. │
│ a next_or_finish or merge_branches, which stays on its loop or group. A │
│ loop comes with its next_or_finish and a new `<array>_item` variable, and │
│ the parent's children follow the next_or_finish. │
│ remove Splice one step out of the graph (children adopt its parent, go_to │
│ references retarget). A condition, goal or loop with children needs │
│ --cascade, and a next_or_finish goes only with its loop. │
│ references retarget). A condition or goal with children, or a loop with a │
│ body, needs --cascade. A loop with an empty body goes with its │
│ next_or_finish, which goes only with its loop. │
╰──────────────────────────────────────────────────────────────────────────────────────────────────╯

Usage: automations steps add [OPTIONS] {api_name}

Insert one step. The spec is a single AutomationDef step (step_type + config block; field_refs
welcome); placement comes from the flags, and by default the parent's existing children move under
the new step, except a next_or_finish or merge_branches, which stays on its loop or group.
the new step, except a next_or_finish or merge_branches, which stays on its loop or group. A loop
comes with its next_or_finish and a new `<array>_item` variable, and the parent's children follow
the next_or_finish.

╭─ Arguments ──────────────────────────────────────────────────────────────────────────────────────╮
│ * api_name <str> Automation api_name. [required] │
Expand Down Expand Up @@ -1141,8 +1146,8 @@
Usage: automations steps remove [OPTIONS] {api_name} {key}

Splice one step out of the graph (children adopt its parent, go_to references retarget). A
condition, goal or loop with children needs --cascade, and a next_or_finish goes only with its
loop.
condition or goal with children, or a loop with a body, needs --cascade. A loop with an empty body
goes with its next_or_finish, which goes only with its loop.

╭─ Arguments ──────────────────────────────────────────────────────────────────────────────────────╮
│ * api_name <str> Automation api_name. [required] │
Expand Down
25 changes: 15 additions & 10 deletions src/kizen_builder/cli/steps.py
Original file line number Diff line number Diff line change
Expand Up @@ -243,22 +243,26 @@ def steps_add(
"""Insert one step. The spec is a single AutomationDef step (step_type +
config block; field_refs welcome); placement comes from the flags, and
by default the parent's existing children move under the new step, except
a next_or_finish or merge_branches, which stays on its loop or group."""
a next_or_finish or merge_branches, which stays on its loop or group. A
loop comes with its next_or_finish and a new `<array>_item` variable, and
the parent's children follow the next_or_finish."""
if bool(parent) == root:
err_console.print("[red]error:[/red] pass exactly one of --parent or --root")
raise typer.Exit(code=2)
spec, from_stdin = _read_spec(spec_file)

def mutate(payload: dict[str, Any], raw: dict[str, Any]) -> Any:
target_object = (raw.get("custom_object") or {}).get("name")
parent_key, adopt = parent or None, not leaf
if spec.get("step_type") == "loop":
loop, closer, item = auto_tools.build_wire_loop(
spec, payload, target_object
)
return step_tools.insert_loop(
payload, loop, closer, item, parent_key, branch, adopt
)
wire = auto_tools.build_wire_step(spec, payload, target_object)
return step_tools.insert_step(
payload,
wire,
parent_key=parent or None,
branch=branch,
adopt_children=not leaf,
)
return step_tools.insert_step(payload, wire, parent_key, branch, adopt)

_run_step_patch(api_name, mutate, dry_run, yes, json_out, from_stdin)

Expand All @@ -275,8 +279,9 @@ def steps_remove(
json_out: bool = typer.Option(False, "--json", help="Emit JSON."),
) -> None:
"""Splice one step out of the graph (children adopt its parent, go_to
references retarget). A condition, goal or loop with children needs
--cascade, and a next_or_finish goes only with its loop."""
references retarget). A condition or goal with children, or a loop with a
body, needs --cascade. A loop with an empty body goes with its
next_or_finish, which goes only with its loop."""

def mutate(payload: dict[str, Any], raw: dict[str, Any]) -> Any:
return step_tools.remove_step(payload, key, cascade=cascade)
Expand Down
26 changes: 19 additions & 7 deletions src/kizen_builder/docs/specs/automation-step.md
Original file line number Diff line number Diff line change
Expand Up @@ -99,16 +99,28 @@ Consequences worth knowing:
`next_or_finish` or `merge_branches` child is never adopted, so `--parent
<loop>` puts the new step at the head of the loop body, and `--parent
<next_or_finish>` puts it right after the loop. An `exit_loop` adds like any
step. A `loop` or `next_or_finish` can't be added on its own: add the loop,
its closer and its item variable with `kizen automations update
--spec-file`.
step.
- **`add` of a `loop` adds its `next_or_finish` and its item variable too**, as
Kizen's builder does. The spec is `{"step_type": "loop", "step_loop":
{"array_variable": "<array>"}}`, and the array must already be declared by
an `initialize_variable` step; the CLI never creates it. The item variable is
always created, named `<array>_item` (`<array>_item_2`, `_3`… when the name
is taken), with the array's type, in a new `initialize_variable` step after
the other variable steps. A spec that sets `step_loop.item_variable` is
refused. The loop's body starts empty, and the parent's existing children
follow the `next_or_finish`, so they run once, after the loop (`--leaf`
leaves them on the parent). A `next_or_finish` can't be added on its own.
Confirmed live 2026-10-27, with `roundtrip --execute` clean after each add
and remove.
- **`remove` splices:** children adopt the removed step's parent and inherit its
branch position, and `go_to` references retarget to that parent. Removing a
condition or goal that has children requires `--cascade`, which deletes the
whole subtree. So does a loop with steps under it, where the subtree is the
body and everything after the loop. A `next_or_finish` can't be removed, even
with `--cascade`: remove its loop instead. Removing the last body step leaves
an empty body, which is valid.
whole subtree. So does a loop with body steps, where the subtree is the body
and everything after the loop. A loop with an empty body is removed with its
`next_or_finish`, the steps after it move up, and its item variable step
stays. A `next_or_finish` can't be removed, even with `--cascade`: remove its
loop instead. Removing the last body step leaves an empty body, which is
valid.
- **The post-apply diff is positional**, so a mid-graph change shows up as
every later step shifting. Verify structure with `show` and treat the diff as
the audit trail, not as a minimal changeset.
Expand Down
4 changes: 3 additions & 1 deletion src/kizen_builder/docs/specs/automation.md
Original file line number Diff line number Diff line change
Expand Up @@ -138,7 +138,9 @@ sets a step's own severity. Omitted, the step inherits. Confirmed live

A loop runs its body once per item of an array variable. Loops are branch
group initiators, closed by `next_or_finish` instead of `merge_branches`. A
spec names all six parts; the CLI adds none of them:
spec names all six parts; `create` and `update` add none of them. `steps add`
of a loop adds its closer and item variable (see `kizen docs show
automation-step`):

1. **The array variable:** an `initialize_variable` step with `is_array: true`.
2. **Its item variable:** an `initialize_variable` step with `sources: []`,
Expand Down
94 changes: 83 additions & 11 deletions src/kizen_builder/tools/automations.py
Original file line number Diff line number Diff line change
Expand Up @@ -424,25 +424,97 @@ def build_wire_step(
Linkage fields are ignored here — placement belongs to insert_step. Any
``id`` on the spec is dropped too: this builds a *new* step, and a spec
authored by copying an existing step's ``steps get`` output must not
carry that step's id along and hijack its identity.
carry that step's id along and hijack its identity. A ``loop`` goes
through :func:`build_wire_loop` instead.
"""
from kizen_builder.tools.plans import PlanError

if spec.get("step_type") == "next_or_finish":
raise PlanError(
"a next_or_finish can't be added on its own; 'steps add' of a loop "
"adds the loop's next_or_finish with it."
)
return _build_wire(spec, payload, target_object)


def build_wire_loop(
spec: dict[str, Any], payload: dict[str, Any], target_object: str | None
) -> tuple[dict[str, Any], dict[str, Any], dict[str, Any] | None]:
"""Build a loop, its ``next_or_finish`` and its item variable step, as
Kizen's builder does. The item variable is always created, named
``<array>_item`` (``_2``, ``_3``… when taken), with the array's type. It is
``None`` when the payload declares no such array: validation then refuses
the loop. Placement belongs to :func:`steps.insert_loop`.
"""
from kizen_builder.tools.planners.automations import _unwrap_variable_name
from kizen_builder.tools.plans import PlanError

block = dict(spec.get("step_loop") or {})
array = _unwrap_variable_name(block.get("array_variable"))
if "item_variable" in block:
raise PlanError(
"step_loop.item_variable can't be set in 'steps add': the item "
f"variable is created automatically, named '{array}_item'. Remove it "
"from the spec."
)
declared: dict[str, dict[str, Any]] = {}
for s in payload["steps"]:
var = (s.get("action_initialize_variable") or {}).get("variable")
if s["type"] == "initialize_variable" and isinstance(var, dict):
declared.setdefault(var.get("name") or "", var)
item_name, n = f"{array}_item", 2
while item_name in declared:
item_name, n = f"{array}_item_{n}", n + 1

block["item_variable"] = item_name
loop = _build_wire(
{
**spec,
"step_loop": block,
"description": spec.get("description") or f"For each item in '{array}'",
},
payload,
target_object,
)
closer = _build_wire(
{
"step_type": "next_or_finish",
"description": "Next item or finish loop and continue",
},
payload,
target_object,
)
item = None
if array in declared:
variable = {
"name": item_name,
"data_type": declared[array].get("data_type"),
"is_array": False,
}
if declared[array].get("data_subtype"):
variable["data_subtype"] = declared[array]["data_subtype"]
item = _build_wire(
{
"step_type": "initialize_variable",
"description": "Item Reference Variable",
"action_initialize_variable": {"sources": [], "variable": variable},
},
payload,
target_object,
)
return loop, closer, item


def _build_wire(
spec: dict[str, Any], payload: dict[str, Any], target_object: str | None
) -> dict[str, Any]:
from kizen_builder.models.spec import AutomationStepDef
from kizen_builder.tools.planners.automations import (
LiveContext,
_build_step_payload,
)
from kizen_builder.tools.plans import PlanError
from kizen_builder.tools.steps import synthesize_key

counterpart = {"loop": "next_or_finish", "next_or_finish": "loop"}.get(
spec.get("step_type", "")
)
if counterpart:
raise PlanError(
f"a {spec['step_type']} is added together with its {counterpart} and "
"the loop's item variable, which one 'steps add' can't do. Add it "
"with 'kizen automations update --spec-file'."
)
spec = dict(spec)
for linkage in ("parent_key", "parent_branch", "id"):
spec.pop(linkage, None)
Expand Down
61 changes: 52 additions & 9 deletions src/kizen_builder/tools/steps.py
Original file line number Diff line number Diff line change
Expand Up @@ -88,7 +88,9 @@ def remove_step(
Without cascade: children adopt the removed step's parent (inheriting
its branch position), and go_to steps targeting it are retargeted to the
same parent. Refuses to splice a condition/goal that still has children
— their yes/no positions have no meaning under a different parent.
— their yes/no positions have no meaning under a different parent — or a
loop with a body. A loop with an empty body goes with its
next_or_finish, whose children take the loop's place.

Returns a report: removed keys, (child, new_parent) re-parentings, and
retargeted go_to keys.
Expand All @@ -111,15 +113,19 @@ def remove_step(
"orphan its yes/no branches. Remove or re-parent them first, "
"or pass cascade=True to delete the whole subtree."
)
if dead["type"] == "loop" and kids:
raise PlanError(
f"'{key}' is a loop with steps under it "
f"({', '.join(k['key'] for k in kids)}); removing it alone would "
"leave its next_or_finish without a loop. Remove its body first, "
"or pass cascade=True to delete the loop with its body and "
"everything after it."
)
doomed = {key}
if dead["type"] == "loop":
body = [k["key"] for k in kids if k["type"] != "next_or_finish"]
if body:
raise PlanError(
f"'{key}' is a loop with steps under it ({', '.join(body)}); "
"removing it alone would leave its next_or_finish without a "
"loop. Remove its body first, or pass cascade=True to delete "
"the loop with its body and everything after it."
)
closers = [k["key"] for k in kids]
doomed.update(closers)
kids = [c for k in closers for c in children_of(payload, k)]

new_parent = dead.get("parent_key")
gotos = _gotos_pointing_at(payload, doomed)
Expand Down Expand Up @@ -209,6 +215,43 @@ def insert_step(
return report


def insert_loop(
payload: dict[str, Any],
loop: dict[str, Any],
closer: dict[str, Any],
item: dict[str, Any] | None,
parent_key: str | None = None,
branch: str = "",
adopt_children: bool = True,
) -> dict[str, Any]:
"""Link a built loop, its ``next_or_finish`` and its item variable step.

The loop goes where :func:`insert_step` puts any step, with an empty body.
With ``adopt_children`` the parent's children follow the closer, so they
run once after the loop, as in Kizen's builder. The item variable step goes
after the last ``initialize_variable``, since variables come first.
"""
report = insert_step(payload, loop, parent_key, branch, adopt_children)
insert_step(payload, closer, loop["key"])
out: dict[str, Any] = {
"key": loop["key"],
"closer": closer["key"],
"item_variable": None,
"adopted": report["adopted"],
}
if item is not None:
last_var = max(
(s for s in payload["steps"] if s["type"] == "initialize_variable"),
key=lambda s: s.get("order") or 0,
)
insert_step(payload, item, last_var["key"])
out["item_variable"] = {
"name": item["action_initialize_variable"]["variable"]["name"],
"key": item["key"],
}
return out


def edit_step(
payload: dict[str, Any], key: str, patch: dict[str, Any]
) -> dict[str, Any]:
Expand Down
Loading
Loading