"""Transport-neutral plan-decision normalizer (the ONE safe mapping). THE LOAD-BEARING B3 SAFETY MAPPING, in a single transport-neutral home so BOTH the graph (:func:`agent_team.graph._parse_decision`) and the transports (the Slack listener / adapter) call ONE function rather than each re-deriving the allowlist. Keeping it here avoids a graph→transport import: the graph is the universal chokepoint and depends only on this leaf module, and the Slack adapter keeps a thin :func:`~agent_team.transport.slack_adapter.map_plan_decision` wrapper that delegates here. A plan-gate decision can be WRITTEN by several paths — the Slack listener (which pre-maps), but ALSO the operator CLI (``answer_on_behalf`` → ``answer_question``, RAW) and ``Coordinator.submit_answer`` (RAW). On resume, whatever was stored reaches the graph's decision parser. Previously the graph mapped any UNRECOGNIZED verb to ``abandon`` → terminal FAILED, so an operator (or any non-listener writer) answering change-request prose silently FAILED the task. Centralizing the safe mapping here and calling it AT THE GRAPH makes every writer safe. ``allow_abandon`` is the LOGIC-05 distinction. A destructive verb (``cancel``/``stop``/``kill``/``reject``/``abandon``) is honoured as an abandon ONLY when the caller vouches that the input is a *confirmed* destructive intent — i.e. the Slack Block Kit "Abandon" button, which is guarded by a confirm dialog. A bare destructive verb typed as FREE TEXT in a thread (``allow_abandon =False``) must NOT terminally fail the task with no confirmation; it normalizes to ``request_changes`` carrying the full reply as notes. """ from __future__ import annotations from typing import Any __all__ = [ "ABANDON_VERBS", "APPROVE_VERBS", "VALID_DECISIONS", "normalize_decision", ] # Approve-allowlist: a reply that is exactly one of these (case/whitespace # insensitive) is an approval; anything else is never an accidental approve. APPROVE_VERBS: frozenset[str] = frozenset( {"approve", "approved", "yes", "ok", "lgtm", "ship"} ) # Abandon-allowlist (DESTRUCTIVE). A bare verb here abandons the task → # terminal FAILED, BUT ONLY when ``allow_abandon=True`` — i.e. the input is a # CONFIRMED destructive intent (the confirm-dialog-guarded Block Kit Abandon # button, or the graph chokepoint honouring an explicit prior decision). The # SAME verbs typed as FREE TEXT (``allow_abandon=False``) are NOT honoured as a # destructive abandon (LOGIC-05): they fall through to ``request_changes`` so a # casually-typed "cancel" never terminally fails a task with no confirmation. ABANDON_VERBS: frozenset[str] = frozenset( {"abandon", "reject", "cancel", "stop", "kill"} ) # The three valid structured decisions a normalized result can carry. VALID_DECISIONS: frozenset[str] = frozenset({"approve", "request_changes", "abandon"}) def normalize_decision(raw: Any, *, allow_abandon: bool) -> dict[str, str]: """Map a raw plan-gate reply to a structured ``{"decision","notes"}`` dict. The single transport-neutral normalizer. Behavior: * **Idempotent** — if ``raw`` is already a dict carrying a valid ``"decision"`` in :data:`VALID_DECISIONS`, return it normalized (decision lowercased/stripped, notes coerced to ``str``) WITHOUT re-mapping. This lets the graph chokepoint accept an already-decided value (e.g. the listener's pre-mapped ``request_changes`` with notes) unchanged, while a RAW string from another writer is mapped below. An ``abandon`` dict is honoured here regardless of ``allow_abandon`` because it is an explicit, already-formed decision, not raw free text. * **String** — normalize (``str`` → strip → lowercase): * in :data:`APPROVE_VERBS` → ``approve`` (notes ``""``); * if ``allow_abandon`` AND in :data:`ABANDON_VERBS` → ``abandon`` (notes ``""``); * **everything else** — prose, empty/whitespace, AND abandon-verbs when ``allow_abandon=False`` — → ``request_changes`` carrying the FULL ORIGINAL reply (original casing preserved) as ``notes``. This is the safe default: arbitrary input is a change request, never an accidental approve or (unconfirmed) abandon. The reply is opaque DATA throughout — never executed or interpreted beyond this verb match. """ # Idempotency: an already-formed decision dict passes through normalized. if isinstance(raw, dict): decision = str(raw.get("decision", "") or "").strip().lower() if decision in VALID_DECISIONS: return { "decision": decision, "notes": str(raw.get("notes", "") or ""), } # A dict WITHOUT a recognized decision falls through to the string path # using its stringified form (defensive; should not occur in practice). original = "" if raw is None else str(raw) verb = original.strip().lower() if verb in APPROVE_VERBS: return {"decision": "approve", "notes": ""} if allow_abandon and verb in ABANDON_VERBS: return {"decision": "abandon", "notes": ""} # Everything else → request_changes, carrying the full ORIGINAL reply (not # the lowercased form) so the notes preserve the human's exact wording. A # bare destructive verb typed as free text lands here when allow_abandon is # False (LOGIC-05): it requests changes, never a silent terminal abandon. return {"decision": "request_changes", "notes": original.strip()}