← Files SavvyARCHIVED FILE

skills/savvy/scripts/workflow/edit.py

29.9 KB · Oct 2, 2026 · 00:28 UTC

↓ Download file

#!/usr/bin/env python3
"""Editor orchestrator — the deterministic save-and-verify loop around an in-memory recipe edit.

The LLM is the intelligence hub; this tool does the mechanical work and hands structured data
back at each judgment boundary. Two phases, with the confirm gate between them (mirrors
`workflow create`):

  PROPOSE (default): fetch the live recipe, capture a rollback snapshot, diff it against the
  proposed recipe the caller built (via `recipe_edit` / `node_builders`), and validate. It does
  NOT save. Returns the `recipe_model_diff` + validation so the LLM/user can review and confirm.

  COMMIT (--confirm): save the proposed recipe (`PUT /api/recipes`), re-fetch, report the persisted
  diff, write the post-save recipe JSON to the standard session folder, then run the SHARED
  Inspector (`workflow.inspection.inspect` — the same verify process the Creator uses) over the
  caller-chosen `--checkpoint` nodes, and return the verification report. Layout quality is judged
  from `validate workflow` (which runs the deterministic layout checks) on the persisted JSON — no
  rendered-canvas inspection.

The proposal comes from exactly one of two inputs: `--proposed-recipe` (a live-shaped edited
recipe) or `--replace-from-build` (creation-shaped Builder output applied to THIS flow in place —
the rebuild-in-place path that replaces a second import after a failed verification; the rebuilt
content is merged onto the live identity envelope and the same content blockers as create
preflight run before save).

Control points the LLM owns: form the edit, resolve any propagation gaps, confirm the edit scope
between phases, and decide the next step (done / iterate / roll back to the snapshot). Routine
preview targets and evidence paths are deterministic defaults, with CLI flags only for overrides.

INTERNAL ONLY: uses the authenticated Savant API.
"""
from __future__ import annotations

import argparse
import copy
import json
import tempfile
from pathlib import Path

from savant_api import cli as api
from savant_api.fileio import workspace_tmp
from savant_api.recipe_input import assert_flow_id, load_id_set, load_recipe
from savant_api.recipes import recipe_model_diff, update_workflow_recipe
from workflow import evidence as workflow_evidence
from workflow import inspection as savant_inspect
from workflow import polish as workflow_polish
from validators import workflow as vw


def _load(path: str | Path) -> dict:
    return json.loads(Path(path).read_text(encoding="utf-8"))


def _resolve_flow(flow_url: str):
    su = api.parse_savant_url(flow_url)
    if su.kind != "flow" or not su.flow_id:
        raise SystemExit("workflow edit requires a Savant flow URL (.../flow/{flowId}).")
    ctx = api.discover_session(su.namespace, origin=su.origin)
    return su, ctx


def merge_rebuilt_recipe(live: dict, rebuilt: dict) -> dict:
    """Merge a creation-shaped rebuilt workflow JSON onto the live recipe's identity envelope.

    This is the deterministic rebuild-in-place path: after a failed verification the Builder
    regenerates the FULL workflow JSON, and instead of importing a second flow (which mints a new
    flowId and leaves a duplicate), the rebuilt model content replaces the existing flow's content
    via recipe save on the SAME flowId. The live envelope (id, folderId, namespace, version fields,
    audit fields, name) is kept; the model content (nodes, sourceProfiles, description) comes from
    the rebuild. The live name is kept deliberately — a rebuild must not rename a flow the user
    already knows; renames are their own edit.
    """
    if not isinstance(rebuilt, dict) or not isinstance(rebuilt.get("nodes"), list) or not rebuilt["nodes"]:
        raise SystemExit("--replace-from-build JSON must be a workflow object with a non-empty `nodes` array.")
    if rebuilt.get("id"):
        raise SystemExit(
            "--replace-from-build expects creation-shaped builder output (no `id`). "
            "A live-shaped edited recipe goes through --proposed-recipe instead."
        )
    merged = copy.deepcopy(live)
    merged["nodes"] = copy.deepcopy(rebuilt["nodes"])
    for key in ("sourceProfiles", "description"):
        if rebuilt.get(key) is not None:
            merged[key] = copy.deepcopy(rebuilt[key])
    return merged


def _replace_content_blockers(nodes: list[dict], workspace_ids: set[str] | None) -> list[str]:
    """The same pre-write content checks `workflow create` preflight runs, applied before a
    full-content replace: a rebuilt model can carry exactly the same silent-drop hazards as a
    fresh import (placeholder/foreign dataset ids, AI nodes without a provider)."""
    blockers: list[str] = []
    missing_ai = vw.ai_missing_provider_nodes(nodes)
    if missing_ai:
        blockers.append(f"missing AI provider on: {', '.join(missing_ai)}")
    placeholders = vw.source_placeholder_nodes(nodes)
    if placeholders:
        blockers.append(f"placeholder/missing source dataset id on: {', '.join(placeholders)}")
    if workspace_ids:
        unresolved = vw.unresolved_source_dataset_ids(nodes, workspace_ids)
        if unresolved:
            blockers.append(
                f"source dataset id(s) do not resolve in this flow's workspace: {', '.join(unresolved)} "
                "(datasets are workspace-scoped; saving anyway silently drops these source nodes)"
            )
    return blockers


_CREATE_CONTINUATION_STATUSES = {
    "verified",
    "created-with-issues",
    "imported",
    "import-unverified",
}


def same_session_create_context(flow_id: str) -> dict | None:
    """Return same-session create evidence for this flow, if the create helper produced it.

    A create task often imports a flow, finds issues during verification, then routes fixes
    through the editor. The editor should not depend on the model to carry that context in a
    flag; it can read the current AI session's create reports and infer that the edit is still
    part of the original create delivery.
    """
    if not flow_id:
        return None
    try:
        root = workspace_tmp()
    except RuntimeError:
        return None
    if not root.exists():
        return None
    matches: list[dict] = []
    for path in root.rglob("*.json"):
        try:
            data = json.loads(path.read_text(encoding="utf-8"))
        except Exception:
            continue
        if not isinstance(data, dict) or data.get("phase") != "create":
            continue
        if data.get("status") not in _CREATE_CONTINUATION_STATUSES:
            continue
        report_flow_id = str(data.get("flowId") or "")
        flow_url = str(data.get("flowUrl") or "")
        if report_flow_id != flow_id and f"/flow/{flow_id}" not in flow_url:
            continue
        matches.append({
            "reportPath": str(path),
            "status": data.get("status"),
            "workflowName": data.get("workflowName"),
            "flowId": report_flow_id or flow_id,
            "flowUrl": flow_url,
            "folder": data.get("folder"),
        })
    if not matches:
        return None
    matches.sort(key=lambda item: item["reportPath"])
    return matches[-1]


# Config keys whose change never alters what a step DOES in business terms; a swap here must
# not nag for a description rewrite (the description describes meaning, not plumbing).
_OPERATIONAL_CONFIG_KEYS = {"providerId"}


def _semantic_config_changed(before: dict | None, after: dict | None) -> bool:
    b = {k: v for k, v in (before or {}).items() if k not in _OPERATIONAL_CONFIG_KEYS}
    a = {k: v for k, v in (after or {}).items() if k not in _OPERATIONAL_CONFIG_KEYS}
    return b != a


def documentation_staleness(current: dict, proposed: dict, *, edit_class: str = "logic") -> list[dict]:
    """Deterministic check that an edit keeps the documentation in step with the change.

    Descriptions, group headers, and the workflow description are the user-facing record of
    what the workflow does. Detection is always deterministic; what changes with `edit_class`
    is the severity, because fix-vs-logic intent is a judgment the editor must DECLARE:

    - ``logic`` (default): the change alters business meaning — stale documentation is a
      "warn" the editor must resolve in the same edit.
    - ``fix``: a declared behavior-preserving bug fix (same business meaning, outputs, grain) —
      items become "note"s: no rewrite demanded, but the declaration is recorded in the report
      so a mis-declared logic change is auditable.

    Purely operational config changes (see _OPERATIONAL_CONFIG_KEYS) never flag at all.
    Returns [{"severity": "warn"|"note", "message": str}]. Node/group/text fields save with the
    recipe; the workflow description saves via the metadata path.
    """
    if edit_class not in {"logic", "fix"}:
        raise ValueError(f"edit_class must be 'logic' or 'fix', got {edit_class!r}")
    severity = "warn" if edit_class == "logic" else "note"
    cur = {n["id"]: n for n in current.get("nodes") or [] if isinstance(n, dict)}
    prop = {n["id"]: n for n in proposed.get("nodes") or [] if isinstance(n, dict)}
    warnings: list[dict] = []

    def name_of(node: dict) -> str:
        return str(node.get("name") or node.get("id"))

    def emit(message: str) -> None:
        if edit_class == "fix":
            message += " (declared as a behavior-preserving fix — no rewrite required if the wording still holds.)"
        warnings.append({"severity": severity, "message": message})

    body_types_excluded = {"group", "text", "outlet"}
    for nid in sorted(cur.keys() & prop.keys()):
        before, after = cur[nid], prop[nid]
        if (after.get("type") or before.get("type")) in body_types_excluded:
            continue
        if _semantic_config_changed(before.get("config"), after.get("config")) and \
                (before.get("description") or "") == (after.get("description") or ""):
            emit(
                f"Node '{name_of(after)}': configuration changed but its description did not — "
                "refresh the node-specific configuration readout in the description, or confirm it still "
                "accurately explains the current settings in business language."
            )

    def members_of(nodes: dict, gid: str) -> set[str]:
        return {nid for nid, n in nodes.items()
                if (n.get("canvasConfig") or {}).get("parentId") == gid
                and n.get("type") not in {"text"}}

    def header_text(nodes: dict, gid: str) -> str:
        for n in nodes.values():
            if n.get("type") == "text" and (n.get("canvasConfig") or {}).get("parentId") == gid:
                return str((n.get("config") or {}).get("text") or "")
        return ""

    for gid in sorted(nid for nid, n in prop.items() if n.get("type") == "group"):
        if gid not in cur:
            continue
        if members_of(cur, gid) != members_of(prop, gid) and \
                header_text(cur, gid) == header_text(prop, gid) and \
                (cur[gid].get("description") or "") == (prop[gid].get("description") or ""):
            emit(
                f"Group '{name_of(prop[gid])}': its steps changed but the header/description did not — "
                "update them or confirm they still describe the stage."
            )

    added = sorted(prop.keys() - cur.keys())
    removed = sorted(cur.keys() - prop.keys())
    structural = [nid for nid in added + removed
                  if (prop.get(nid) or cur.get(nid) or {}).get("type") not in body_types_excluded]
    if structural:
        emit(
            f"Workflow structure changed ({len(structural)} node(s) added/removed) — if the business "
            "process changed, refresh the workflow description too (saved via the metadata path, "
            "not the recipe save)."
        )
    return warnings


def relocation_documentation_gate(current: dict, proposed: dict) -> list[str]:
    """BLOCKING check (canvas-layout-rules.md, user rule 2026-06-10): moving steps between
    groups changes what both groups MEAN, so the SAME change must update the affected
    groups' names/headers and the workflow description. A relocation delivered without its
    documentation is an incomplete edit — this gate refuses it (override deliberately with
    --accept-stale-docs)."""
    cur = {n["id"]: n for n in current.get("nodes") or [] if isinstance(n, dict)}
    prop = {n["id"]: n for n in proposed.get("nodes") or [] if isinstance(n, dict)}
    moves: list[tuple[str, str, str]] = []
    for nid in cur.keys() & prop.keys():
        n0, n1 = cur[nid], prop[nid]
        if (n1.get("type") or n0.get("type")) in {"group", "text", "outlet"}:
            continue
        p0 = (n0.get("canvasConfig") or {}).get("parentId")
        p1 = (n1.get("canvasConfig") or {}).get("parentId")
        if p0 != p1 and p0 in cur and p1 in cur:
            moves.append((nid, p0, p1))
    if not moves:
        return []

    def header_text(nodes: dict, gid: str) -> str:
        for n in nodes.values():
            if n.get("type") == "text" and (n.get("canvasConfig") or {}).get("parentId") == gid:
                cfg = n.get("config") or {}
                return str(cfg.get("inputText") or cfg.get("text") or "")
        return ""

    affected = sorted({g for _nid, a, b in moves for g in (a, b) if g})
    stale = [g for g in affected
             if header_text(cur, g) == header_text(prop, g)
             and (cur.get(g, {}).get("name") or "") == (prop.get(g, {}).get("name") or "")]
    wf_unchanged = (current.get("description") or "") == (proposed.get("description") or "")
    if not stale and not wf_unchanged:
        return []
    missing: list[str] = []
    if stale:
        missing.append("group header/description updates for "
                       + ", ".join(f"'{prop[g].get('name') or g}'" for g in stale))
    if wf_unchanged:
        missing.append("a workflow description update")
    names = ", ".join(f"'{prop[g].get('name') or g}'" for g in affected)
    return [
        f"{len(moves)} step(s) moved between groups ({names}) but this change is missing "
        + " and ".join(missing)
        + ". Relocation must ship WITH its documentation in the same change "
        "(canvas-layout-rules.md). Update them, or re-run with --accept-stale-docs to "
        "override deliberately."
    ]


def propose(flow_url: str, proposed_path: str | None = None, *, current_recipe: Path,
            snapshot_path: Path | None = None,
            sources_json: Path | None = None,
            replace_from_build: str | None = None, merged_path: Path | None = None,
            polish: bool = False, polish_autolayout: bool = True, polish_colors: bool = True,
            polished_path: Path | None = None, edit_class: str = "logic",
            accept_stale_docs: bool = False, require_documentation: bool = False):
    """Phase 1: diff the supplied current recipe vs the proposed recipe, validate. No save.

    `current_recipe` is the live recipe the caller fetched with the MCP `fetch` tool on
    savant://workflow/{flowId}. It is the pre-edit baseline for the diff, and the file itself IS
    the rollback snapshot — the caller already holds it, so this route no longer reads the API to
    re-derive it. Pass the same file to `--confirm` and then to `workflow verify --before-json`.

    Exactly one of `proposed_path` (live-shaped edited recipe) or `replace_from_build`
    (creation-shaped builder output, merged onto the live envelope) provides the proposal."""
    if (proposed_path is None) == (replace_from_build is None):
        raise SystemExit("Provide exactly one of --proposed-recipe or --replace-from-build.")
    su, ctx = _resolve_flow(flow_url)
    create_context = same_session_create_context(su.flow_id)
    effective_require_documentation = require_documentation or bool(create_context)
    current = load_recipe(current_recipe, flag="--current-recipe")
    assert_flow_id(current, su.flow_id, flag="--current-recipe")
    if snapshot_path is not None:
        api.save_json(current, snapshot_path)            # copy of the caller's pre-edit fetch
    replace_blockers: list[str] = []
    if replace_from_build is not None:
        rebuilt = _load(replace_from_build)
        proposed = merge_rebuilt_recipe(current, rebuilt)
        replace_blockers = _replace_content_blockers(
            [n for n in proposed.get("nodes", []) if isinstance(n, dict)],
            load_id_set(sources_json, flag="--sources-json"))
        merged_path = merged_path or default_replace_merged_path(su.flow_id)
        api.save_json(proposed, merged_path)
        proposed_path = str(merged_path)
    else:
        proposed = _load(proposed_path)
    polish_report = {"applied": [], "autolayout": False, "colors": False, "groupCount": 0}
    validation_path = Path(proposed_path)
    if polish:
        proposed, polish_report = workflow_polish.polish_recipe(
            proposed,
            autolayout=polish_autolayout,
            colors=polish_colors,
        )
        if polished_path is not None:
            api.save_json(proposed, polished_path)
            validation_path = polished_path
        else:
            tmp = tempfile.NamedTemporaryFile("w", suffix=".json", delete=False)
            try:
                json.dump(proposed, tmp)
                tmp.close()
                validation_path = Path(tmp.name)
            finally:
                if not tmp.closed:
                    tmp.close()
    diff = recipe_model_diff(current, proposed)
    errors, warnings = vw.validate(validation_path, block_documentation_gaps=effective_require_documentation)
    # Rebuild-in-place arrives with freshly generated documentation; staleness only applies
    # to inline edits of the existing recipe.
    doc_warnings = [] if replace_from_build is not None \
        else documentation_staleness(current, proposed, edit_class=edit_class)
    documentation_errors = [
        f"Documentation scope requires this to be resolved: {item['message']}"
        for item in doc_warnings
        if effective_require_documentation and item.get("severity") == "warn"
    ]
    # Relocation gate: BLOCKS (not warns) when steps moved between groups without the
    # documentation updates the move requires — unless deliberately overridden.
    relocation_errors = [] if (replace_from_build is not None or accept_stale_docs) \
        else relocation_documentation_gate(current, proposed)
    errors = list(errors) + documentation_errors + relocation_errors
    blocked_reasons = [f"{len(errors)} validation error(s)"] if errors else []
    blocked_reasons.extend(replace_blockers)
    report = {
        "phase": "propose",
        "flowId": su.flow_id,
        "flowUrl": api.ensure_rns(flow_url, ctx.namespace),
        "snapshotPath": str(snapshot_path) if snapshot_path else None,
        "currentRecipePath": str(current_recipe),
        # Carried so --confirm can diff against it without re-reading the file. Stripped from the
        # emitted report below; it is the caller's own input, not a finding.
        "currentRecipe": current,
        "replaceFromBuild": replace_from_build,
        "mergedRecipePath": str(merged_path) if replace_from_build is not None else None,
        "polishedRecipePath": str(polished_path) if polished_path else None,
        "polish": polish_report,
        "diff": diff,
        "validation": {"errors": errors, "warnings": warnings},
        "documentation": doc_warnings,
        "documentationRequired": effective_require_documentation,
        "createContinuation": create_context,
        "blocked": bool(blocked_reasons),
        "blockedReasons": blocked_reasons,
    }
    return report, ctx, su, proposed


def commit(ctx, su, proposed: dict, *, before: dict, flow_url: str, current_recipe: Path,
           checkpoints: list[str],
           expect_columns: list[str] | None, expected_outputs: list[dict] | None = None,
           sample_tier: str, timeout: int,
           post_save_recipe_path: Path | None = None,
           terminal_preview: bool = True,
           block_documentation_gaps: bool = False) -> dict:
    """Phase 2: save the proposed recipe. Verification is a separate step.

    Proving the save persisted needs the recipe read back afterwards, which this toolchain no
    longer does. So commit saves and then reports exactly what to run: an MCP fetch, then
    `workflow verify --operation edit --before-json <the pre-edit recipe>`, which runs the same
    before/after model diff that used to happen here. **An edit is not persisted-verified until
    that returns ok.**"""
    update_report = update_workflow_recipe(ctx, su.flow_id, proposed, before=before)
    after_json = workflow_evidence.default_post_write_recipe_path(su.flow_id, "edit")
    verify_cmd = (
        f"python3 savant.py workflow verify --operation edit --workflow-json {after_json} "
        f"--expect-flow-id {su.flow_id} --before-json {current_recipe}"
    )
    inspect_cmd = (
        f"python3 savant.py workflow inspect {flow_url} --recipe-json {after_json}"
    )
    return {
        "phase": "commit",
        "flowId": su.flow_id,
        "flowUrl": api.ensure_rns(flow_url, ctx.namespace),
        "update": update_report,
        "verified": False,
        "status": "saved-unverified",
        "createContinuation": same_session_create_context(su.flow_id),
        "documentationRequired": block_documentation_gaps,
        "nextSteps": {
            "1_fetch": f"MCP `fetch` on savant://workflow/{su.flow_id}, written to {after_json}",
            "2_verify": verify_cmd,
            "3_inspect": inspect_cmd,
        },
    }


def default_output_path(flow_id: str, phase: str) -> Path:
    return workspace_tmp("workflow-edits", f"{flow_id}.edit-{phase}.json")


def default_snapshot_path(flow_id: str) -> Path:
    return workspace_tmp("workflow-edits", f"{flow_id}.rollback.json")


def default_replace_merged_path(flow_id: str) -> Path:
    return workspace_tmp("workflow-edits", f"{flow_id}.proposed-replace.json")


def default_polished_recipe_path(flow_id: str) -> Path:
    return workspace_tmp("workflow-edits", f"{flow_id}.proposed-polished.json")


def main(argv=None) -> int:
    p = argparse.ArgumentParser(description=__doc__)
    p.add_argument("flow_url", help="Savant flow URL: .../flow/{flowId}?rns={ns}")
    group = p.add_mutually_exclusive_group(required=True)
    group.add_argument("--proposed-recipe",
                       help="The edited recipe JSON the caller built (via recipe_edit / node_builders).")
    group.add_argument("--replace-from-build",
                       help="Creation-shaped builder workflow JSON to apply to THIS flow in place "
                            "(rebuild-in-place). The rebuilt nodes/sourceProfiles replace the flow's "
                            "content via recipe save on the same flowId — never a second import. The "
                            "merged proposal is written for review, and the same content blockers as "
                            "create preflight (placeholder/foreign dataset ids, missing AI provider) "
                            "block before save.")
    p.add_argument("--snapshot", type=Path, help="Where to write the pre-edit rollback snapshot.")
    p.add_argument("--polish", action="store_true",
                   help="Apply shared JSON polish before diff/validation/save after the user confirms visual polish is in scope.")
    p.add_argument("--no-polish-autolayout", action="store_true",
                   help="During shared JSON polish, keep existing layout positions/sizes.")
    p.add_argument("--polish-colors", action="store_true",
                   help="During shared JSON polish on an EDIT, fill missing group colors. Off by "
                        "default: editing should not change a flow's colors without the user asking, "
                        "since they may not want color edits. (Creation auto-colors by default.)")
    p.add_argument("--polished-recipe-path", type=Path,
                   help="Override where the polished proposed recipe JSON is written.")
    p.add_argument("--edit-class", choices=["logic", "fix"], default="logic",
                   help="Documentation intent of this edit. 'logic' (default): the change alters business "
                        "meaning, so stale node/group/workflow documentation is a warning to resolve in the "
                        "same edit. 'fix': a declared behavior-preserving bug fix — staleness items become "
                        "informational notes, and the declaration is recorded in the report. Declare 'fix' "
                        "ONLY when business meaning, outputs, and grain are unchanged.")
    p.add_argument("--current-recipe", type=Path, required=True,
                   help="The flow's live recipe, fetched with the MCP `fetch` tool on "
                        "savant://workflow/{flowId}. It is the pre-edit diff baseline AND the "
                        "rollback snapshot; pass the same file to `workflow verify --before-json`.")
    p.add_argument("--sources-json", type=Path,
                   help="MCP `search` result for types: [\"source\"], used by --replace-from-build "
                        "to catch source dataset ids that do not resolve in this workspace. Omit to "
                        "skip that check.")
    p.add_argument("--confirm", action="store_true",
                   help="Proceed past propose to save. Set ONLY after the user confirmed the diff in chat.")
    p.add_argument("--checkpoint", action="append", default=[],
                   help="Node display name to Analyze in the verify phase (repeatable). Default to the edit's "
                        "affected nodes; choose deliberately — Analyzing AI nodes costs money.")
    p.add_argument("--expect-columns", help="Comma-separated expected terminal columns (runs the output-contract check).")
    p.add_argument("--expected-outputs-json",
                   help="JSON list/object of output contracts for richer output checks; same format as workflow inspect.")
    p.add_argument("--no-terminal-preview", action="store_true",
                   help="On --confirm, skip the default terminal output preview and inspect only named checkpoints/contracts.")
    p.add_argument("--sample-tier", default="1k")
    p.add_argument("--timeout-seconds", type=int, default=120)
    p.add_argument("--accept-stale-docs", action="store_true",
                   help="Deliberately override the relocation documentation gate (steps moved "
                        "between groups without group/workflow description updates).")
    p.add_argument("--require-documentation", action="store_true",
                   help="Promote node-description coverage gaps and stale documentation warnings to errors. "
                        "Use after the user confirms Documentation scope, or when a config/logic edit must ship "
                        "with complete step documentation in the same change.")
    p.add_argument("--output-path", type=Path, help="Where to write the JSON report.")
    args = p.parse_args(argv)

    parsed = api.parse_savant_url(args.flow_url)
    snapshot_path = args.snapshot or default_snapshot_path(parsed.flow_id or "workflow")
    polished_path = args.polished_recipe_path or default_polished_recipe_path(parsed.flow_id or "workflow") if args.polish else None
    report, ctx, su, proposed = propose(
        args.flow_url,
        args.proposed_recipe,
        current_recipe=args.current_recipe,
        sources_json=args.sources_json,
        replace_from_build=args.replace_from_build,
        snapshot_path=snapshot_path,
        polish=args.polish,
        polish_autolayout=not args.no_polish_autolayout,
        polish_colors=args.polish_colors,
        polished_path=polished_path,
        edit_class=args.edit_class,
        accept_stale_docs=args.accept_stale_docs,
        require_documentation=args.require_documentation,
    )

    def emit(rep):
        phase = "commit" if rep.get("phase") == "commit" else "propose"
        api.save_json({k: v for k, v in rep.items() if k != "currentRecipe"},
                      args.output_path or default_output_path(su.flow_id, phase))

    if report["blocked"]:
        emit(report)
        print(f"EDIT [propose] BLOCKED — {'; '.join(report['blockedReasons'])}; resolve, then re-run.")
        for e in report["validation"]["errors"]:
            print(f"    ERROR: {e}")
        return 2
    if not args.confirm:
        emit(report)
        d = report["diff"]["nodes"]
        print(f"EDIT [propose] flow={su.flow_id}: +{len(d['added'])} added, -{len(d['removed'])} removed, "
              f"~{len(d['changed'])} changed; snapshot={report['snapshotPath']}")
        if report.get("mergedRecipePath"):
            print(f"  -> merged rebuild-in-place proposal: {report['mergedRecipePath']}")
        if report.get("polishedRecipePath"):
            print(f"  -> polished proposed recipe: {report['polishedRecipePath']}")
        for w in report.get("documentation") or []:
            print(f"  [{w['severity']}] documentation: {w['message']}")
        print("  -> review the diff, get the user's confirmation in chat, then re-run with --confirm.")
        return 0

    expect = [c.strip() for c in args.expect_columns.split(",")] if args.expect_columns else None
    expected_outputs = savant_inspect.load_expected_outputs(args.expected_outputs_json)

    out = commit(ctx, su, proposed, before=report["currentRecipe"], flow_url=args.flow_url,
                 current_recipe=args.current_recipe, checkpoints=args.checkpoint,
                 expect_columns=expect, expected_outputs=expected_outputs,
                 sample_tier=args.sample_tier, timeout=args.timeout_seconds,
                 terminal_preview=not args.no_terminal_preview,
                 block_documentation_gaps=report.get("documentationRequired", False))
    out["documentation"] = report.get("documentation") or []
    emit(out)
    print(f"EDIT: saved, NOT YET VERIFIED — {out['flowUrl']}")
    for w in out["documentation"]:
        print(f"  [{w['severity']}] documentation: {w['message']}")
    print("")
    print("The save returned, but nothing has confirmed it persisted yet.")
    print("Run these before reporting the edit as applied:")
    print(f"  1. {out['nextSteps']['1_fetch']}")
    print(f"  2. {out['nextSteps']['2_verify']}")
    print(f"  3. {out['nextSteps']['3_inspect']}")
    return 0


if __name__ == "__main__":
    raise SystemExit(main())

SHA-256: 5aada1a22b4a69a1b177cbe5d3028733b58af2324258913cc3735d817dee4e1e