diff --git a/scripts/test_action_class.py b/scripts/test_action_class.py new file mode 100644 index 0000000..4892df9 --- /dev/null +++ b/scripts/test_action_class.py @@ -0,0 +1,47 @@ +#!/usr/bin/env python3 +"""Action-class normalize + ui_fields known-goods.""" + +from __future__ import annotations + +import sys +from pathlib import Path + +ROOT = Path(__file__).resolve().parents[1] +sys.path.insert(0, str(ROOT / "src")) + +from ask_question_mcp.action_class import ( # noqa: E402 + normalize_action_class, + resolve_action_class, + ui_fields, +) + + +def main() -> int: + assert normalize_action_class("SECRETS") == "secrets" + assert normalize_action_class("whatsapp") == "comms" + assert normalize_action_class("fs") == "file" + assert normalize_action_class("nope") is None + + assert resolve_action_class(dangerous=True) == "destructive" + assert resolve_action_class(action_class="comms", dangerous=True) == "comms" + + f = ui_fields(action_class="secrets") + assert f["dangerous"] is True + assert f["eyebrow"] == "Secrets" + assert f["css_band"] == "is-secrets" + assert "Secrets" in f["banner_prefix"] + + quiet = ui_fields() + assert quiet["dangerous"] is False + assert quiet["eyebrow"] == "Decide" + + file_b = ui_fields(action_class="file") + assert file_b["dangerous"] is False # FILE does not arm by itself + assert file_b["css_band"] == "is-file" + + print("PASS test_action_class") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/src/ask_question_mcp/action_class.py b/src/ask_question_mcp/action_class.py new file mode 100644 index 0000000..fcf74aa --- /dev/null +++ b/src/ask_question_mcp/action_class.py @@ -0,0 +1,128 @@ +"""Action-class bands for MCQ chrome + agent Risk tagging. + +Taxonomy (Alex 2026-08-09): FILE ยท SECRETS ยท COMMS ยท DESTRUCTIVE ยท POLICY +(+ quiet/default when unset). +""" + +from __future__ import annotations + +from typing import Any + +# Canonical ids (lowercase). Aliases normalize into these. +ACTION_CLASSES = ("file", "secrets", "comms", "destructive", "policy") + +_ALIASES: dict[str, str] = { + "file": "file", + "fs": "file", + "filesystem": "file", + "read": "file", + "write": "file", + "secrets": "secrets", + "secret": "secrets", + "creds": "secrets", + "credential": "secrets", + "credentials": "secrets", + "comms": "comms", + "communicate": "comms", + "communication": "comms", + "send": "comms", + "whatsapp": "comms", + "email": "comms", + "destructive": "destructive", + "danger": "destructive", + "delete": "destructive", + "destroy": "destructive", + "policy": "policy", + "governance": "policy", +} + +# Eyebrow + banner labels (short, uppercase-friendly in UI). +_LABELS: dict[str, str] = { + "file": "File", + "secrets": "Secrets", + "comms": "Comms", + "destructive": "Destructive", + "policy": "Policy", +} + +_MARKS: dict[str, str] = { + "file": "๐Ÿ“", + "secrets": "๐Ÿ”", + "comms": "๐Ÿ“ก", + "destructive": "โ›”", + "policy": "โš–", +} + +# Bands that arm OK + show confirm chrome (same family as dangerous=true). +_ARMS: frozenset[str] = frozenset( + {"secrets", "comms", "destructive", "policy"} +) + + +def normalize_action_class(raw: Any) -> str | None: + """Return canonical action_class or None if unset/unknown.""" + if raw is None: + return None + key = str(raw).strip().lower().replace("-", "_").replace(" ", "_") + if not key: + return None + return _ALIASES.get(key) + + +def action_class_label(action_class: str | None) -> str | None: + if not action_class: + return None + return _LABELS.get(action_class) + + +def action_class_mark(action_class: str | None) -> str: + if not action_class: + return "โ›”" + return _MARKS.get(action_class, "โ›”") + + +def action_class_arms(action_class: str | None) -> bool: + """True when this band should arm OK / confirm chrome.""" + return bool(action_class and action_class in _ARMS) + + +def resolve_action_class( + *, + action_class: Any = None, + dangerous: bool = False, +) -> str | None: + """Normalize explicit class; if only ``dangerous``, map to destructive.""" + cls = normalize_action_class(action_class) + if cls: + return cls + if dangerous: + return "destructive" + return None + + +def ui_fields( + *, + action_class: Any = None, + dangerous: bool = False, +) -> dict[str, Any]: + """Fields to merge into Nebula/Gtk UI payload.""" + cls = resolve_action_class(action_class=action_class, dangerous=dangerous) + armed = bool(dangerous) or action_class_arms(cls) + label = action_class_label(cls) + mark = action_class_mark(cls) + if cls and label: + eyebrow = label + banner = f"{mark} {label} โ€” " + elif armed: + eyebrow = "Confirm" + banner = f"{mark} Confirm โ€” " + else: + eyebrow = "Decide" + banner = "" + return { + "action_class": cls, + "dangerous": armed, + "eyebrow": eyebrow, + "banner_prefix": banner, + "css_band": f"is-{cls}" if cls else ("is-danger" if armed else ""), + } diff --git a/src/ask_question_mcp/assets/dialog/dialog.css b/src/ask_question_mcp/assets/dialog/dialog.css index ea19800..1d34441 100644 --- a/src/ask_question_mcp/assets/dialog/dialog.css +++ b/src/ask_question_mcp/assets/dialog/dialog.css @@ -264,12 +264,35 @@ body { box-shadow: inset 0 1px 0 rgb(255 255 255 / 0.08); } -.eyebrow.is-danger { +.eyebrow.is-danger, +.eyebrow.is-destructive { color: var(--danger-text); background: var(--danger-bg); border-color: var(--danger-border); } +/* Action-class bands (Alex 2026-08-09) โ€” FILE ยท SECRETS ยท COMMS ยท DESTRUCTIVE ยท POLICY */ +.eyebrow.is-file { + color: #bfdbfe; + background: rgb(59 130 246 / 0.16); + border-color: rgb(59 130 246 / 0.35); +} +.eyebrow.is-secrets { + color: #fde68a; + background: rgb(217 119 6 / 0.18); + border-color: rgb(217 119 6 / 0.4); +} +.eyebrow.is-comms { + color: #99f6e4; + background: rgb(13 148 136 / 0.18); + border-color: rgb(13 148 136 / 0.4); +} +.eyebrow.is-policy { + color: #fed7aa; + background: rgb(194 65 12 / 0.18); + border-color: rgb(194 65 12 / 0.4); +} + .agent { overflow: hidden; text-overflow: ellipsis; @@ -390,6 +413,51 @@ body { color: var(--danger-text); } +.banner.is-file .banner-inner { + background: rgb(59 130 246 / 0.14); +} +.banner.is-file .banner-accent { + background: #3b82f6; +} +.banner.is-file .banner-copy { + color: #bfdbfe; +} + +.banner.is-secrets .banner-inner { + background: rgb(217 119 6 / 0.16); +} +.banner.is-secrets .banner-accent { + background: #d97706; +} +.banner.is-secrets .banner-copy { + color: #fde68a; +} + +.banner.is-comms .banner-inner { + background: rgb(13 148 136 / 0.16); +} +.banner.is-comms .banner-accent { + background: #0d9488; +} +.banner.is-comms .banner-copy { + color: #99f6e4; +} + +.banner.is-policy .banner-inner { + background: rgb(194 65 12 / 0.16); +} +.banner.is-policy .banner-accent { + background: #c2410c; +} +.banner.is-policy .banner-copy { + color: #fed7aa; +} + +.banner.is-destructive .banner-inner, +.banner.is-danger .banner-inner { + background: var(--danger-bg); +} + .options { flex: 1 1 auto; min-height: 48px; @@ -965,15 +1033,49 @@ body { background: #fff; } -.btn-primary.is-danger { +.btn-primary.is-danger, +.btn-primary.is-destructive { background: var(--ember); color: #1a0610; } -.btn-primary.is-danger:hover:not(:disabled) { +.btn-primary.is-danger:hover:not(:disabled), +.btn-primary.is-destructive:hover:not(:disabled) { background: #ff7a94; } +.btn-primary.is-file { + background: #3b82f6; + color: #0b1220; +} +.btn-primary.is-file:hover:not(:disabled) { + background: #60a5fa; +} + +.btn-primary.is-secrets { + background: #d97706; + color: #1a0f00; +} +.btn-primary.is-secrets:hover:not(:disabled) { + background: #f59e0b; +} + +.btn-primary.is-comms { + background: #0d9488; + color: #041412; +} +.btn-primary.is-comms:hover:not(:disabled) { + background: #14b8a6; +} + +.btn-primary.is-policy { + background: #c2410c; + color: #1a0a00; +} +.btn-primary.is-policy:hover:not(:disabled) { + background: #ea580c; +} + .btn-icon { width: 28px; height: 28px; diff --git a/src/ask_question_mcp/assets/dialog/dialog.js b/src/ask_question_mcp/assets/dialog/dialog.js index a3a9a31..b776cb1 100644 --- a/src/ask_question_mcp/assets/dialog/dialog.js +++ b/src/ask_question_mcp/assets/dialog/dialog.js @@ -678,19 +678,37 @@ state.focusIdx = Math.max(0, ids.indexOf(focusId)); const dangerous = !!(payload.dangerous || (payload.danger_ids || []).length); - $("#eyebrow").textContent = dangerous ? "Confirm" : "Decide"; - $("#eyebrow").classList.toggle("is-danger", dangerous); + const band = + String(payload.action_class || "").toLowerCase() || + (dangerous ? "destructive" : ""); + const bandClass = band ? `is-${band}` : ""; + const eyebrowEl = $("#eyebrow"); + const BANDS = ["file", "secrets", "comms", "destructive", "policy", "danger"]; + eyebrowEl.textContent = + payload.eyebrow || (dangerous ? "Confirm" : "Decide"); + for (const b of BANDS) eyebrowEl.classList.remove(`is-${b}`); + if (bandClass) eyebrowEl.classList.add(bandClass); + else if (dangerous) eyebrowEl.classList.add("is-danger"); $("#title-agent").textContent = payload.agent_hint || payload.title || ""; $("#question").textContent = payload.question || ""; const banner = $("#banner"); banner.classList.toggle("is-on", dangerous); + for (const b of BANDS) banner.classList.remove(`is-${b}`); + if (bandClass) banner.classList.add(bandClass); + else if (dangerous) banner.classList.add("is-danger"); + const prefix = + payload.banner_prefix || + (dangerous ? "โ›” Confirm โ€” " : ""); $("#banner-copy").textContent = dangerous - ? `โ›” Confirm โ€” ${payload.question || ""}` + ? `${prefix}${payload.question || ""}` : ""; const ok = $("#ok-btn"); - ok.classList.toggle("is-danger", dangerous); + for (const b of BANDS) ok.classList.remove(`is-${b}`); + ok.classList.toggle("is-danger", dangerous && (!band || band === "destructive")); + if (bandClass && band !== "destructive") ok.classList.add(bandClass); + else if (dangerous) ok.classList.add("is-danger"); const showOther = payload.allow_other !== false; $("#freeform").hidden = !showOther; diff --git a/src/ask_question_mcp/linux_webview_ask.py b/src/ask_question_mcp/linux_webview_ask.py index f56fc81..99c04df 100644 --- a/src/ask_question_mcp/linux_webview_ask.py +++ b/src/ask_question_mcp/linux_webview_ask.py @@ -891,9 +891,25 @@ def main() -> int: str(x) for x in (payload.get("recommended_ids") or []) ] ui_payload["danger_ids"] = [str(x) for x in (payload.get("danger_ids") or [])] - ui_payload["dangerous"] = bool( - payload.get("dangerous") or ui_payload["danger_ids"] - ) + try: + from ask_question_mcp.action_class import ui_fields as _action_ui_fields + + _band = _action_ui_fields( + action_class=payload.get("action_class"), + dangerous=bool(payload.get("dangerous") or ui_payload["danger_ids"]), + ) + ui_payload["action_class"] = _band.get("action_class") + ui_payload["eyebrow"] = _band.get("eyebrow") + ui_payload["banner_prefix"] = _band.get("banner_prefix") + ui_payload["css_band"] = _band.get("css_band") + ui_payload["dangerous"] = bool(_band["dangerous"]) + except Exception: + ui_payload["dangerous"] = bool( + payload.get("dangerous") or ui_payload["danger_ids"] + ) + for key in ("action_class", "eyebrow", "banner_prefix", "css_band"): + if payload.get(key) is not None: + ui_payload[key] = payload.get(key) ui_payload["allow_multiple"] = bool(payload.get("allow_multiple")) ui_payload["allow_other"] = bool(payload.get("allow_other", True)) timeout_sec = int(payload.get("timeout_sec") or 0) diff --git a/src/ask_question_mcp/server.py b/src/ask_question_mcp/server.py index 9d14873..d90399d 100644 --- a/src/ask_question_mcp/server.py +++ b/src/ask_question_mcp/server.py @@ -18,7 +18,8 @@ "REQUIRED for decision forks: call ask_multiple_choice โ€” never markdown " "A/B/C or host AskQuestion when this server is available. Desktop Gtk/tk " "MCQ; text-only without TTS/STT. Pass agent=LANE.id; recommended in label + " - "recommended_id; dangerous=true for irreversible; Something else always " + "recommended_id; action_class=file|secrets|comms|destructive|policy; " + "dangerous=true for irreversible; Something else always " "offered. check_setup only on first enable, dialog failure, or before " "enabling voice โ€” never before routine MCQs. UI before audio. " "Detail: docs/AGENTS.md." @@ -93,6 +94,7 @@ def ask_multiple_choice( allow_multiple: bool = False, allow_other: bool = True, dangerous: bool = False, + action_class: str | None = None, speak: bool = True, title: str = "Decide", agent: str | None = None, @@ -101,7 +103,7 @@ def ask_multiple_choice( image: str | None = None, images: list[str] | None = None, ) -> str: - """Desktop MCQ for every decision fork โ€” never markdown A/B/C when available. agent=LANE.id; recommended in label + recommended_id; Something else always; optional image/images (local path or file://) for Gtk+Nebula preview; human Ctrl+V paste returns pasted_images base64 in JSON; default timeout_sec=0 (waits); dangerous arms OK ~1s. On cancel/errors โ†’ check_setup once.""" + """Desktop MCQ for decision forks โ€” not markdown A/B/C. agent=LANE.id; recommended_id; action_class=file|secrets|comms|destructive|policy; dangerous arms OK; Something else always; optional image/images; timeout_sec=0 waits. Cancel โ†’ check_setup once.""" try: result = ask_zenity( question, @@ -111,6 +113,7 @@ def ask_multiple_choice( allow_multiple=allow_multiple, allow_other=True, dangerous=dangerous, + action_class=action_class, speak=speak, title=title, agent=agent, diff --git a/src/ask_question_mcp/zenity_ask.py b/src/ask_question_mcp/zenity_ask.py index fec59f6..0f63c13 100644 --- a/src/ask_question_mcp/zenity_ask.py +++ b/src/ask_question_mcp/zenity_ask.py @@ -730,6 +730,7 @@ def _ask_list( recommended: set[str] | None = None, danger_ids: set[str], dangerous: bool, + action_class: str | None = None, allow_multiple: bool, allow_other: bool, title: str, @@ -749,6 +750,12 @@ def _ask_list( """ image_paths = [str(p) for p in (images or []) if str(p).strip()] rec_ids = sorted(recommended or set()) + from ask_question_mcp.action_class import ui_fields as _action_ui_fields + + band = _action_ui_fields( + action_class=action_class, + dangerous=bool(dangerous or danger_ids), + ) if _is_windows(): win_py = _resolve_win_python() list_script = _resolve_win_list_script(win_py) @@ -760,7 +767,11 @@ def _ask_list( "preselect": sorted(preselect), "recommended_ids": rec_ids, "danger_ids": sorted(danger_ids), - "dangerous": bool(dangerous or danger_ids), + "dangerous": bool(band["dangerous"]), + "action_class": band.get("action_class"), + "eyebrow": band.get("eyebrow"), + "banner_prefix": band.get("banner_prefix"), + "css_band": band.get("css_band"), "allow_multiple": allow_multiple, "allow_other": bool(allow_other), "timeout_sec": timeout_sec, @@ -876,7 +887,11 @@ def _ask_list( "preselect": sorted(preselect), "recommended_ids": rec_ids, "danger_ids": sorted(danger_ids), - "dangerous": bool(dangerous or danger_ids), + "dangerous": bool(band["dangerous"]), + "action_class": band.get("action_class"), + "eyebrow": band.get("eyebrow"), + "banner_prefix": band.get("banner_prefix"), + "css_band": band.get("css_band"), "allow_multiple": allow_multiple, "allow_other": bool(allow_other), "timeout_sec": timeout_sec, @@ -966,6 +981,7 @@ def ask_zenity( allow_multiple: bool = False, allow_other: bool = True, dangerous: bool = False, + action_class: str | None = None, speak: bool = True, title: str = "Decide", agent: str | None = None, @@ -982,6 +998,8 @@ def ask_zenity( pair with ``"auto_listen": true`` to start mic on open (e.g. Re-record). ``entry_seed`` prefills the edit box (voice-turn transcript confirm). Pass ``dangerous=True`` to flag the whole decision. + ``action_class``: ``file`` | ``secrets`` | ``comms`` | ``destructive`` | + ``policy`` โ€” colours Confirm chrome (Alex 2026-08-09). ``speak`` defaults True (local TTS). Pass ``speak=False``, uncheck dialog **Audio** (prefs ``audio_enabled``), or set ``ASK_QUESTION_AUDIO=0`` / ``ASK_QUESTION_SPEAK=0`` to mute. @@ -1091,7 +1109,13 @@ def ask_zenity( # UI first: never duck / speak / listen if the dialog cannot appear. display = _ensure_ui_ready() - whole_danger = bool(dangerous) or bool(danger_ids) + from ask_question_mcp.action_class import ui_fields as _action_ui_fields + + _band = _action_ui_fields( + action_class=action_class, + dangerous=bool(dangerous) or bool(danger_ids), + ) + whole_danger = bool(_band["dangerous"]) win_title = window_title(agent=who, title=title, dangerous=whole_danger) speak_line = _speak_script(question=question.strip(), dangerous=whole_danger) @@ -1159,6 +1183,7 @@ def _release_session_duck() -> None: recommended=recommended, danger_ids=danger_ids, dangerous=whole_danger, + action_class=action_class, allow_multiple=allow_multiple, allow_other=allow_other, title=win_title,