From 3170b2c86b016c5357f613ff47cdb7d98c6d4532 Mon Sep 17 00:00:00 2001 From: vivekchand Date: Sun, 30 Aug 2026 02:29:51 +0200 Subject: [PATCH 1/2] =?UTF-8?q?feat(approvals):=20question-set=20approvals?= =?UTF-8?q?=20=E2=80=94=20decisions=20beyond=20yes/no=20(WO-52=20phase=201?= =?UTF-8?q?)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Approvals were strictly binary approve/deny. This adds "question set" approvals end-to-end for the LOCAL product, with Claude Code's AskUserQuestion tool as the first source: * clawmetry/question_sets.py (new) — shared vocabulary: sanitize the 1-4-question set from the PreToolUse payload, strictly validate structured answers against it (unknown question / unknown option label are rejected; multiSelect takes label arrays; free text only when the question set says so), merge winning answers into updatedInput, and the shared decision-wall core (answers land in args["_cm_answers"] BEFORE the row flips to decision='answered', so a waiting hook never sees the status without the answers). Storage is migration-free: the set rides in args["_cm_questions"] (the _cm_risk precedent). * routes/hooks.py — the PreToolUse receiver intercepts AskUserQuestion before policy matching, parks a question-set row (on_timeout="ask"), and answers an 'answered' row with permissionDecision=allow + hookSpecificOutput.updatedInput = tool_input + answers. Every failure mode — window elapsed, expired row, malformed payload, unreadable answers, store down — falls back to "ask" (the terminal prompt), NEVER the binary on_timeout=deny default and NEVER a fabricated answer. CLAWMETRY_QUESTION_GATE=0 kills the intercept; CLAWMETRY_QUESTION_WINDOW_S overrides the window (default: the mirror window, 180 s). The hook client already forwards hookSpecificOutput verbatim, so no client change was needed. * routes/policy.py + routes/hitl.py — both local decision walls accept decision='answer' carrying the answers map (validated → 400 on unknown labels/questions; first-click-wins preserved via update_approval_decision). GET /api/approvals now serves the full question set per row; the audit summary counts 'answered'. * clawmetry/claude_code_gate.py — the installed PreToolUse matcher also watches AskUserQuestion whenever the gate is armed, and the hook timeout is floored at the question window + buffer so Claude Code never cancels the hook before the receiver's "ask" fallback lands. * clawmetry/templates/tabs/approvals.html — pending question rows render full fidelity (radios / checkboxes / free-text when flagged, count + headers chip) with a single "Send answers" button posting decision='answer'; binary rows are untouched. * tests/test_question_set_approvals.py (23 tests) — validation 400s, multiSelect round-trip, first-click-wins on structured answers, binary regression through both walls, gate intercept → updatedInput, expiry → "ask". Two exact-matcher assertions in tests/test_runtime_gates_and_hooks.py updated for the widened matcher. Excluded from this phase: cloud/pro decision walls, Telegram/phone question fidelity (phase 2). Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_017VCaSBpV1wBCKrU4z9MKKZ --- clawmetry/claude_code_gate.py | 35 ++ clawmetry/question_sets.py | 231 ++++++++++ clawmetry/templates/tabs/approvals.html | 111 ++++- routes/hitl.py | 46 +- routes/hooks.py | 246 +++++++++-- routes/policy.py | 59 ++- tests/test_question_set_approvals.py | 540 ++++++++++++++++++++++++ tests/test_runtime_gates_and_hooks.py | 14 +- 8 files changed, 1232 insertions(+), 50 deletions(-) create mode 100644 clawmetry/question_sets.py create mode 100644 tests/test_question_set_approvals.py diff --git a/clawmetry/claude_code_gate.py b/clawmetry/claude_code_gate.py index 93cd5fd810..6fcb0df0c2 100644 --- a/clawmetry/claude_code_gate.py +++ b/clawmetry/claude_code_gate.py @@ -168,6 +168,27 @@ def _add(fragment: str) -> None: return "|".join(parts) if parts else "Bash" +def _question_gate_enabled() -> bool: + """Question-set approvals (WO-52): on by default, one env var off.""" + return os.environ.get("CLAWMETRY_QUESTION_GATE", "1").strip() != "0" + + +def _question_window_s() -> int: + """Mirror of routes.hooks._question_window_s (stdlib-only fast path + must not import Flask modules): env override → mirror window → 180 s.""" + raw = os.environ.get("CLAWMETRY_QUESTION_WINDOW_S", "").strip() + if raw: + try: + return max(10, int(raw)) + except ValueError: + pass + try: + from clawmetry import approval_events as _ae + return int(_ae.mirror_window_s("claude_code")) + except Exception: + return 180 + + def _timeout_from_policies(policies) -> int: """Hook timeout: the longest matching policy window + buffer, so the receiver's own on_timeout mapping always answers first.""" @@ -544,6 +565,20 @@ def _install(policies) -> None: command = _hook_command(base) matcher = _matcher_from_policies(policies) timeout = _timeout_from_policies(policies) + # Question-set approvals (WO-52 phase 1): whenever the gate is armed, + # also watch AskUserQuestion so the receiver can mirror the runtime's + # structured questions to the dashboard, which answers with the actual + # option labels (hookSpecificOutput.updatedInput). No answer inside the + # question window → the receiver replies "ask" and the terminal prompt + # takes over, exactly today's flow. CLAWMETRY_QUESTION_GATE=0 opts out. + if _question_gate_enabled(): + if "AskUserQuestion" not in matcher.split("|"): + matcher = f"{matcher}|AskUserQuestion" + # The hook timeout must outlive the question window, or Claude Code + # cancels the hook before our own "ask" fallback lands and the + # parked row is left pending with nobody waiting on it. + timeout = max(timeout, hook_ownership.clamp_hook_timeout( + _question_window_s() + _HOOK_TIMEOUT_BUFFER_S)) desired = { "matcher": matcher, "hooks": [{"type": "command", "command": command, diff --git a/clawmetry/question_sets.py b/clawmetry/question_sets.py new file mode 100644 index 0000000000..cfbf944e5b --- /dev/null +++ b/clawmetry/question_sets.py @@ -0,0 +1,231 @@ +"""Question-set approvals — decisions beyond yes/no (WO-52, phase 1). + +Approvals were strictly binary (approve/deny). Claude Code's +``AskUserQuestion`` tool carries a structured question set — 1-4 questions, +each with a short ``header``, 2-4 labelled ``options`` and an optional +``multiSelect`` flag — and its PreToolUse hook is allowed to answer with +``hookSpecificOutput.updatedInput`` = the original ``tool_input`` plus +``answers: {"": "