"""Decide whether an implementation-planning clarification re-run can be
incremental, and if so which stages to re-verify vs carry forward.

Deterministic half of the incremental-reverification feature: the lead
prompt supplies the impacted-stage set (its discretionary C2 judgement) and
the base SHAs; everything here is pure so the same inputs always yield the
same decision. See docs/superpowers/specs/2026-07-09-incremental-clarification-reverification-design.md.
"""
from __future__ import annotations

import argparse
import json
import re
import sys
from dataclasses import asdict, dataclass
from pathlib import Path

from okstra_ctl.final_report_paths import final_report_data_path
from okstra_ctl.paths import infer_project_root
from okstra_ctl.stage_citations import cited_stage_numbers
from okstra_ctl.stage_targets import downstream_stage_closure
from okstra_ctl.json_boundary import load_owned_object, write_owned_object_atomic

CUTOFF_RATIO = 0.5

# Prefix that marks a full decision the lead *chose* rather than fell back to.
# Both cases return `mode: full`, and without this the two are one string —
# which is what made a re-run's "no impacted stages resolved" unreadable as
# either a structural judgement or a mapping the lead never made.
DECLARED_FULL_PREFIX = "declared structural change:"
DECLARED_CARRY_ALL_PREFIX = "declared additive change:"

# CLI-to-lead signal only: an answered id traced to no stage and the user has
# not named `--impacted` stages. Never recorded as incrementalDecision (schema
# enum is incremental|full). The lead asks for stage numbers and calls again.
UNRESOLVED_MODE = "unresolved"

# `P-Step-<stage>.<step>` and `P-Prep-S<stage>-<kind>` carry their stage in the
# id itself. Every other prefix is numbered by position; its stage comes from
# `stageScope` / `stageRefs`, then from the prose the planner wrote.
_STRUCTURAL_STAGE_IN_ID_RE = re.compile(r"^P-(?:Step-(\d+)\.\d+|Prep-S(\d+)-)")


@dataclass
class IncrementalDecision:
    mode: str  # "incremental" | "full" | "unresolved"
    reverify_stages: list[int]
    carry_stages: list[int]
    reason: str


@dataclass(frozen=True)
class UserReverifyScope:
    """What the user answered at the wizard's re-verification-scope step.

    The lead still runs `okstra incremental-scope`; this only says which of the
    CLI's inputs the user pinned. `auto` pins nothing.
    """
    mode: str  # "auto" | "full" | "carry-all" | "stages"
    stages: list[int]


class ReverifyScopeError(ValueError):
    """The `--reverify-scope` value is not one of the three accepted forms."""


# 위저드가 "이월만 하고 재검증은 없음" 을 고를 때 쓰는 핀. 직전 stage 가 전부
# `done` 이고 이번 재실행이 stage 를 **추가만** 하는 형태에서는 지정할 stage 번호가
# 없다 — 그래서 `stages` 핀으로 표현할 수 없고, `full` 을 고르면 이월해도 되는
# stage 를 전부 다시 교차검증하는 비용을 사용자가 치른다.
CARRY_ALL_SCOPE = "carry-all"


def parse_user_reverify_scope(raw: str) -> UserReverifyScope:
    """`--reverify-scope` → the user's pinned scope.

    Accepts exactly `""` / `auto`, `full`, `carry-all`, or a comma-separated
    stage list.
    Anything else raises rather than degrading to `auto`: a typo silently read
    as "let the lead decide" would drop a full-re-verification request the user
    made on purpose, and the run would look like it honoured it.
    """
    value = (raw or "").strip()
    if not value or value == "auto":
        return UserReverifyScope("auto", [])
    if value in ("full", CARRY_ALL_SCOPE):
        return UserReverifyScope(value, [])
    tokens = [token.strip() for token in value.split(",") if token.strip()]
    if not tokens or not all(token.isdigit() for token in tokens):
        raise ReverifyScopeError(
            f"--reverify-scope must be 'auto', 'full', {CARRY_ALL_SCOPE!r}, or a "
            f"stage-number list (e.g. '2,3'); got {raw!r}"
        )
    return UserReverifyScope("stages", sorted({int(token) for token in tokens}))


def _parse_depends_on(cell: str) -> list[int]:
    text = (cell or "").strip()
    if not text or text == "(none)":
        return []
    return [int(tok.strip()) for tok in text.split(",") if tok.strip()]


def parse_stage_graph(data: dict) -> list[tuple[int, list[int]]]:
    stage_map = data.get("implementationPlanning", {}).get("stageMap", [])
    return [(int(row["stage"]), _parse_depends_on(row.get("dependsOn", ""))) for row in stage_map]


def design_prep_impacted_stages(data: dict, item_ids: set[str]) -> set[int]:
    items = (
        data.get("implementationPlanning", {})
        .get("designPreparation", {})
        .get("items", [])
    )
    if not isinstance(items, list):
        raise ValueError("designPreparation.items must be an array")
    matched = {
        str(item.get("id")): item
        for item in items
        if isinstance(item, dict) and str(item.get("id")) in item_ids
    }
    missing = item_ids - set(matched)
    if missing:
        raise ValueError(f"unknown design-prep item(s): {', '.join(sorted(missing))}")

    impacted: set[int] = set()
    for item_id, item in matched.items():
        stage_refs = item.get("stageRefs")
        if not isinstance(stage_refs, list) or not stage_refs:
            raise ValueError(f"design-prep item {item_id} has invalid stageRefs")
        try:
            parsed_refs = {int(stage) for stage in stage_refs}
        except (TypeError, ValueError) as exc:
            raise ValueError(
                f"design-prep item {item_id} has invalid stageRefs"
            ) from exc
        if any(stage < 1 for stage in parsed_refs):
            raise ValueError(f"design-prep item {item_id} has invalid stageRefs")
        impacted.update(parsed_refs)
    return impacted


def _int_stages(value: object) -> set[int]:
    if not isinstance(value, list):
        return set()
    stages: set[int] = set()
    for entry in value:
        if isinstance(entry, int) and not isinstance(entry, bool) and entry >= 1:
            stages.add(entry)
    return stages


def _plan_item_stages(item: dict) -> set[int]:
    """항목이 걸린 스테이지. id 좌표, 그다음 기록된 범위, 그다음 산문.

    `P-Val-*` / `P-Req-*` 는 위치 번호라 id 에서 스테이지가 안 나온다. 행이
    `stageScope` / `stageRefs` 를 실어도 산문 `subject` 만 읽으면 C-039 같은
    답이 unlinked 가 되어 재실행이 full 로 떨어진다.
    """
    structural = _STRUCTURAL_STAGE_IN_ID_RE.match(str(item.get("id") or ""))
    if structural:
        return {int(structural.group(1) or structural.group(2))}
    stages = _int_stages(item.get("stageScope")) or _int_stages(item.get("stageRefs"))
    payload = item.get("payload")
    if not stages and isinstance(payload, dict):
        stages = _int_stages(payload.get("stageRefs"))
    if stages:
        return stages
    return cited_stage_numbers(str(item.get("subject") or ""))


_BLOCK_MARKER_FIELDS = ("status", "approvalDisposition")


def coverage_row_blocked_on(row: object, clarification_id: str) -> bool:
    """Whether a requirement-coverage row records a block on this clarification.

    The marker lives in either of two fields, and the schema says so: `status`
    allows `blocked C-NNN`, and so does `approvalDisposition`. A row recorded
    as a `documented-deviation` carries its block in the latter, so reading
    `status` alone missed it — which forced the whole re-run to full for an
    answer whose blast radius the report did record.

    `validate-run.py` shares this predicate to require that every approval
    blocker has at least one such link. One definition, so the gate cannot
    demand a link shape this resolver would refuse to follow.
    """
    if not isinstance(row, dict):
        return False
    blocked = f"blocked {clarification_id}"
    return any(
        str(row.get(field) or "").strip() == blocked
        for field in _BLOCK_MARKER_FIELDS
    )


def _stages_blocked_on(coverage: object, clarification_id: str) -> set[int]:
    """Stages of every coverage row blocked on ``clarification_id``."""
    stages: set[int] = set()
    for row in coverage if isinstance(coverage, list) else []:
        if coverage_row_blocked_on(row, clarification_id):
            stages |= cited_stage_numbers(str(row.get("coveredBy") or ""))
            stages |= _int_stages(row.get("stageRefs"))
    return stages


def partition_clarification_stages(
    data: dict, clarification_ids: set[str]
) -> tuple[set[int], list[str]]:
    """연결된 stage 번호와, 아무 stage 에도 안 닿는 id.

    preview 와 판정 경로가 같은 분할을 쓴다. unlinked 는 full 강등이 아니라
    `--impacted` 를 받거나 `--full-reason` 을 받는 분기이다.
    """
    impacted: set[int] = set()
    unlinked: list[str] = []
    for clarification_id in sorted(clarification_ids):
        stages = stages_for_clarification(data, clarification_id)
        if stages:
            impacted |= stages
        else:
            unlinked.append(clarification_id)
    return impacted, unlinked


def clarification_impacted_stages(
    data: dict, clarification_ids: set[str]
) -> set[int]:
    """Stage Map stages the answered clarifications touch, read off the prior run.

    Two links the prior report already recorded: the `P-*` plan item that blocked
    on the clarification, and the requirement-coverage row blocked on the same
    id. Resolving them here removes the discretionary mapping step that made the
    lead fall back to full even for an answer that changed nothing.

    Raises when any id resolves to no stage — a partially-resolved set would
    narrow the re-run past an answer whose blast radius nobody established.
    The CLI decision path does not use this raise as a full fallback; it asks
    for `--impacted` instead.
    """
    impacted, unlinked = partition_clarification_stages(data, clarification_ids)
    if unlinked:
        raise ValueError(
            f"answered clarification {unlinked[0]} traces to no stage "
            "(no plan item or coverage row cites one)"
        )
    return impacted


def stages_for_clarification(data: dict, clarification_id: str) -> set[int]:
    """Stages the prior run linked to one clarification; empty when it linked none.

    Separate from the raising walk above because the preview needs the same
    lookup without the exception: it reports *which* ids are unlinked, and a
    raise on the first one would hide the rest.
    """
    planning = data.get("implementationPlanning")
    if not isinstance(planning, dict):
        raise ValueError("implementationPlanning is missing")
    verification = planning.get("planBodyVerification")
    plan_items = (
        verification.get("planItems") if isinstance(verification, dict) else []
    )
    stages = _stages_blocked_on(planning.get("requirementCoverage"), clarification_id)
    for item in plan_items if isinstance(plan_items, list) else []:
        if isinstance(item, dict) and (
            item.get("clarificationId") == clarification_id
            or clarification_id in (item.get("clarificationRefs") or [])
        ):
            stages |= _plan_item_stages(item)
    return stages


def preview_link_availability(data: dict, clarification_ids: set[str]) -> dict:
    """Would the answered ids let the next re-run narrow — decided without a SHA.

    The full decision also needs safety condition C1 (base ref unchanged), and
    the current base SHA only exists once `render-bundle` has written a
    manifest and registered the run. By then the two-hour cost of a full
    re-verification is already committed. Link availability is the half that
    decides the common case and needs nothing but the prior report, so it can
    be shown while the run is still reshapeable.

    `wouldForceFull: false` is therefore not a promise of `incremental` — it
    says only that this half found nothing forcing full. An unlinked id is
    not such a thing: it cannot auto-narrow, and it does not force full.
    """
    _, unlinked = partition_clarification_stages(data, clarification_ids)
    if unlinked:
        return {
            "wouldForceFull": False,
            "unlinkedIds": unlinked,
            "reason": (
                f"{', '.join(unlinked)} trace(s) to no stage in the prior report — "
                "name those stages via --impacted, or declare a structural change "
                "with --full-reason; an unlinked id does not force the whole re-run "
                "to full"
            ),
        }
    if not clarification_ids:
        return {
            "wouldForceFull": True,
            "unlinkedIds": [],
            "reason": "no answered clarifications given; nothing to narrow with",
        }
    return {
        "wouldForceFull": False,
        "unlinkedIds": [],
        "reason": (
            "every answered id traces to a stage; the base-ref comparison at run "
            "time still decides the final mode"
        ),
    }


def preview_link_availability_for_report(
    report: Path, clarification_ids: set[str]
) -> dict:
    """`preview_link_availability` for a report on disk, data read included.

    Both callers hold a report path, not a loaded dict. Leaving the read at
    each call site is how they would drift on what a missing or unreadable
    data sibling means — and that verdict is the one the user acts on.
    """
    data_path = final_report_data_path(report)
    if not data_path.is_file():
        return {
            "wouldForceFull": True,
            "unlinkedIds": [],
            "reason": f"prior report has no data sibling at {data_path.name}",
        }
    try:
        data = load_owned_object(data_path, artifact="incremental scope report")
    except (OSError, ValueError) as exc:
        return {
            "wouldForceFull": True,
            "unlinkedIds": [],
            "reason": f"prior report data is unreadable: {exc}",
        }
    return preview_link_availability(data, clarification_ids)


def decide_scope(
    *,
    stages: list[tuple[int, list[int]]],
    impacted_stages: set[int],
    prev_base_sha: str,
    cur_base_sha: str,
    cutoff_ratio: float = CUTOFF_RATIO,
) -> IncrementalDecision:
    if not prev_base_sha or prev_base_sha != cur_base_sha:
        return IncrementalDecision(
            "full", [], [],
            "the branch this plan was written against has moved on "
            f"({prev_base_sha or 'unrecorded'} -> {cur_base_sha or 'unrecorded'}), "
            "so none of the prior stages can be reused as they stand",
        )
    if not impacted_stages:
        return IncrementalDecision(
            "full", [], [],
            "no stage of the prior plan could be tied to the answers, so there is "
            "nothing to narrow the rework down to",
        )
    all_stages = {num for num, _ in stages}
    closure = downstream_stage_closure(stages, set(impacted_stages))
    if len(closure) * 2 > len(all_stages):
        return IncrementalDecision(
            "full", [], [],
            f"the answers reach {len(closure)} of the plan's {len(all_stages)} stages — "
            f"past the {int(cutoff_ratio * 100)}% mark where replanning outright costs "
            "less than tracking what carried over",
        )
    reverify = sorted(closure)
    carry = sorted(all_stages - closure)
    return IncrementalDecision(
        "incremental", reverify, carry,
        f"the answers reach {len(reverify)} of the plan's {len(all_stages)} stages; "
        "the rest is reused as written",
    )


def decision_record(
    decision: IncrementalDecision,
    *,
    prev_data: str,
) -> dict:
    """디스크에 남길 판정. narrative 의 `incrementalDecision` 과 같은 camelCase.

    작성자가 리포트에 적을 필드명과 다르게 두면 옮겨 적는 사람이 매핑을 한 번
    더 해야 하고, 그 매핑이 이 판정을 전달하는 유일한 경로였다.
    """
    return {
        "schemaVersion": "1.0",
        "mode": decision.mode,
        "reverifyStages": list(decision.reverify_stages),
        "carryStages": list(decision.carry_stages),
        "reason": decision.reason,
        "prevDataPath": prev_data,
    }


def _record_path(run_manifest: Path) -> Path:
    """매니페스트가 이 run 의 판정 레코드로 지목한 경로.

    경로를 CLI 인자로 받지 않는 이유가 이 함수다 — 판정을 쓰는 쪽과 읽는 쪽이
    각자 경로를 조립하면 그 조립이 어긋날 자리가 생기고, 어긋나면 읽는 쪽은
    "판정이 없다"(= full)로 읽는다. 매니페스트 한 곳이 답한다.
    """
    manifest = load_owned_object(run_manifest, artifact="run manifest")
    value = manifest.get("incrementalDecisionPath")
    if not isinstance(value, str) or not value:
        raise ValueError(
            "run manifest has no incrementalDecisionPath; re-render the run "
            "bundle with a runtime that ships it"
        )
    candidate = Path(value)
    if candidate.is_absolute():
        return candidate
    # 매니페스트의 경로 필드는 프로젝트 상대다. 부모를 고정 횟수로 세지 않는다
    # — 매니페스트는 `.okstra/tasks/<group>/<id>/runs/<type>/manifests/` 아래에
    # 있고 그 깊이를 손으로 세면 계층이 하나 바뀔 때 조용히 어긋난다.
    return infer_project_root(run_manifest.resolve()) / candidate


def _persist(decision: IncrementalDecision, args) -> None:
    write_owned_object_atomic(
        _record_path(Path(args.run_manifest)),
        decision_record(decision, prev_data=args.prev_data),
        artifact="incremental scope decision",
    )


def _preview_result(args) -> dict:
    """`preview_link_availability` over CLI args, degrading to full on bad input.

    Same posture as the decision path below: a caller reads the answer off
    stdout, so a traceback would leave it with nothing. `full` is the safe
    answer when the input cannot be read.
    """
    try:
        data = load_owned_object(
            Path(args.prev_data), artifact="incremental scope report"
        )
        answered = {
            token.strip()
            for token in args.answered_clarifications.split(",")
            if token.strip()
        }
        return preview_link_availability(data, answered)
    except (OSError, ValueError, KeyError, TypeError) as exc:
        return {
            "wouldForceFull": True,
            "unlinkedIds": [],
            "reason": f"invalid incremental-scope input: {exc}",
        }


def _carry_all_decision(args, declared: str) -> IncrementalDecision:
    """이전 stage 전부를 그대로 이월하고 아무것도 재검증하지 않는 판정.

    base ref 는 여전히 본다 — 브랜치가 움직였으면 이전 stage 본문이 "그대로"
    가 아니고, 그때 이월은 사실이 아닌 주장이 된다.
    """
    if not args.prev_base_sha or args.prev_base_sha != args.cur_base_sha:
        return IncrementalDecision(
            "full", [], [],
            "the branch this plan was written against has moved on "
            f"({args.prev_base_sha or 'unrecorded'} -> "
            f"{args.cur_base_sha or 'unrecorded'}), so the prior stages cannot "
            "be carried as written even though this run only adds to them",
        )
    try:
        data = load_owned_object(
            Path(args.prev_data), artifact="incremental scope report"
        )
        stages = parse_stage_graph(data)
    except (OSError, ValueError, KeyError, TypeError) as exc:
        return IncrementalDecision(
            "full", [], [], f"invalid incremental-scope input: {exc}",
        )
    carry = sorted({stage for stage, _ in stages})
    if not carry:
        return IncrementalDecision(
            UNRESOLVED_MODE, [], [],
            "--carry-all-reason was given but the prior report has no Stage Map "
            "row to carry; check --prev-data",
        )
    return IncrementalDecision(
        "incremental", [], carry,
        f"{DECLARED_CARRY_ALL_PREFIX} {declared}",
    )


def _decision_for_run(args) -> IncrementalDecision:
    """SHA·폐포·컷오프 판정. unlinked 는 full 로 강등하지 않는다."""
    data = load_owned_object(
        Path(args.prev_data), artifact="incremental scope report"
    )
    stages = parse_stage_graph(data)
    impacted = {
        int(token.strip()) for token in args.impacted.split(",") if token.strip()
    }
    prep_ids = {
        token.strip() for token in args.prep_items.split(",") if token.strip()
    }
    impacted.update(design_prep_impacted_stages(data, prep_ids))
    answered = {
        token.strip()
        for token in args.answered_clarifications.split(",")
        if token.strip()
    }
    linked, unlinked = partition_clarification_stages(data, answered)
    if unlinked and not impacted:
        return IncrementalDecision(
            UNRESOLVED_MODE,
            [],
            [],
            f"{', '.join(unlinked)} trace(s) to no stage in the prior report — "
            "pass --impacted with the stage numbers those answers affect, "
            "--full-reason for a structural change, or --carry-all-reason when "
            "this run only adds stages and every prior stage carries forward "
            "as written",
        )
    impacted.update(linked)
    unknown_stages = impacted - {stage for stage, _ in stages}
    if unknown_stages:
        unknown = ", ".join(str(stage) for stage in sorted(unknown_stages))
        raise ValueError(f"impacted stage(s) absent from Stage Map: {unknown}")
    return decide_scope(
        stages=stages,
        impacted_stages=impacted,
        prev_base_sha=args.prev_base_sha,
        cur_base_sha=args.cur_base_sha,
    )


_CLI_EPILOG = r"""Usage:
  okstra incremental-scope --prev-data <path> --cur-base-sha <sha> \
    --prev-base-sha <sha> [--impacted <csv>] [--option <name>]
  okstra incremental-scope --preview --prev-data <path> \
    --answered-clarifications <csv>

Prints a JSON decision {mode, reverify_stages, carry_stages, reason} to stdout.

`--preview` prints {wouldForceFull, unlinkedIds, reason} instead and needs no
base SHA. It answers only the link half — whether the answered ids trace to a
stage in the prior report — so it can run before `render-bundle` fixes a
worktree base commit, while the decision to start is still reversible.
`wouldForceFull: false` is not a promise of `incremental`: the base-ref check
still runs at decision time.
"""
_CLI_DESCRIPTION = "Decide the re-verify vs carry-forward scope for a clarification re-run."


def main(argv: list[str]) -> int:
    ap = argparse.ArgumentParser(
        description=_CLI_DESCRIPTION,
        epilog=_CLI_EPILOG,
        formatter_class=argparse.RawDescriptionHelpFormatter,
        prog="okstra incremental-scope")
    ap.add_argument("--prev-data", required=True, help="prior run final-report data.json")
    ap.add_argument(
        "--run-manifest",
        default="",
        help="this run's manifest. Required unless --preview: the decision is "
             "written to the record it names (incrementalDecisionPath) so the "
             "report writer's authoring contract and `incremental-carry` read "
             "the same values instead of re-typed CSVs",
    )
    # Not required: `--preview` answers the link half before a worktree base
    # commit exists, which is the whole reason that mode is there.
    ap.add_argument("--cur-base-sha", default="")
    ap.add_argument("--prev-base-sha", default="")
    ap.add_argument(
        "--preview",
        action="store_true",
        help="report whether the answered ids could narrow the re-run, using the "
             "prior report alone. Side-effect free and needs no base SHA, so it "
             "can run before the decision to start is irreversible",
    )
    ap.add_argument("--impacted", default="", help="comma-separated impacted stage numbers")
    ap.add_argument("--prep-items", default="", help="comma-separated changed PREP item IDs")
    ap.add_argument(
        "--answered-clarifications",
        default="",
        help="comma-separated C-NNN ids answered since the prior run; their "
             "stages are resolved from that run's plan-item and coverage links",
    )
    ap.add_argument(
        "--full-reason",
        default="",
        help="declare that an answer changes the selected option, Stage Map, or "
             "recommended approach. Forces full and records the judgement, so it "
             "is distinguishable from a re-run that simply resolved no stage",
    )
    ap.add_argument(
        "--carry-all-reason",
        default="",
        help="declare that this run only ADDS to the prior plan — every prior "
             "stage carries forward as written and nothing is re-verified. The "
             "mirror of --full-reason: without it a run that appends a stage has "
             "no answer to give, because pinning any prior stage as impacted "
             "makes `incremental-carry` demand it from a snapshot that no longer "
             "holds it",
    )
    args = ap.parse_args(argv)

    if args.preview:
        print(json.dumps(_preview_result(args), ensure_ascii=False))
        return 0

    if not args.run_manifest.strip():
        ap.error("--run-manifest is required unless --preview")

    declared = args.full_reason.strip()
    if declared:
        # The lead's structural judgement is exactly what the back-trace cannot
        # make, so a successful trace must not override it.
        decision = IncrementalDecision(
            "full", [], [], f"{DECLARED_FULL_PREFIX} {declared}",
        )
        _persist(decision, args)
        print(json.dumps(asdict(decision), ensure_ascii=False))
        return 0

    additive = args.carry_all_reason.strip()
    if additive:
        # 추가만 하는 run 은 되짚기로 표현할 수 없다. 이전 stage 중 어느 것도
        # 영향받지 않았다는 것은 `--impacted` 로 못 쓰고(빈 값은 "좁힐 수
        # 없음" 으로 읽힌다), 아무 stage 나 찍으면 `incremental-carry` 가 그
        # stage 를 현재 스냅샷에서 찾다가 거절한다 — done 이라 이번 서술문에
        # 없기 때문이다. 그래서 판단을 선언으로 받는다.
        decision = _carry_all_decision(args, additive)
        if decision.mode != UNRESOLVED_MODE:
            _persist(decision, args)
        print(json.dumps(asdict(decision), ensure_ascii=False))
        return 0 if decision.mode != UNRESOLVED_MODE else 1

    try:
        decision = _decision_for_run(args)
    # Every bad input degrades to a full re-verification rather than raising:
    # `full` is always the safe answer, and the caller reads the decision off
    # stdout, so a traceback would leave it with no decision at all. OSError
    # covers an unreadable --prev-data, KeyError/TypeError a stageMap row that
    # is missing `stage` or is not a mapping. Unlinked ids are not this case
    # — `_decision_for_run` returns `unresolved` instead of raising.
    except (OSError, ValueError, KeyError, TypeError) as exc:
        decision = IncrementalDecision(
            "full", [], [], f"invalid incremental-scope input: {exc}",
        )
    # `unresolved` 는 판정이 아니라 리드에게 되묻는 신호이고 리포트 스키마의
    # enum 에도 없다. 레코드로 남기면 소비자가 그것을 판정으로 읽는다.
    if decision.mode != UNRESOLVED_MODE:
        _persist(decision, args)
    print(json.dumps(asdict(decision), ensure_ascii=False))
    return 0


if __name__ == "__main__":
    raise SystemExit(main(sys.argv[1:]))
