Skip to content

feat(approvals): question-set approvals — decisions beyond yes/no (WO-52 phase 1) - #5372

Merged
vivekchand merged 3 commits into
mainfrom
feat/approvals-question-sets
Aug 30, 2026
Merged

feat(approvals): question-set approvals — decisions beyond yes/no (WO-52 phase 1)#5372
vivekchand merged 3 commits into
mainfrom
feat/approvals-question-sets

Conversation

@vivekchand

@vivekchand vivekchand commented Aug 30, 2026

Copy link
Copy Markdown
Owner

Product record: https://factory.8090.ai/project/b415065f-ab2f-4f53-8864-0c009fd098cb/requirements/a8fd0512-9bee-4209-9cee-80b732d0f130

Factory requirement

"Approval Decisions Beyond Yes/No" (WO-52), phase 1. Approvals today are strictly binary approve/deny. This ships "question set" approvals end-to-end for the local product, with Claude Code's AskUserQuestion tool as the first source: the runtime's structured question (1-4 questions, 2-4 labelled options each, optional multiSelect) is parked as an approval row, the dashboard renders it full-fidelity, and the human's structured answers resume the session via the PreToolUse hook's hookSpecificOutput.updatedInput (= original tool_input + answers map), exactly as if they had been picked in the terminal.

What phase 1 covers

  • Shared vocabulary — new clawmetry/question_sets.py: sanitize the question set from the hook payload, strictly validate structured answers (unknown question → 400, unknown option label → 400, multiSelect takes label arrays, free text only when the question set says so), merge answers into updatedInput, and the shared decision-wall core. Storage is migration-free: the set rides in args["_cm_questions"], the answers in args["_cm_answers"] (the _cm_risk precedent). Answers are written into args before the row flips to decision='answered', so a hook polling the row never sees the answered status without the answers.
  • Gate interceptroutes/hooks.py intercepts AskUserQuestion in the PreToolUse receiver (before policy matching; no protection rule needed), waits like other gated calls, and answers an answered row with permissionDecision=allow + updatedInput. Plain approve → allow unchanged (question appears in the terminal); deny → deny with reason. Every failure mode — window elapsed, expired row, malformed payload, unreadable answers, store down — falls back to ask (Claude Code's own terminal prompt), never the binary on_timeout=deny default, and never a fabricated/defaulted answer. CLAWMETRY_QUESTION_GATE=0 is the kill switch; CLAWMETRY_QUESTION_WINDOW_S overrides the window (default: the mirror window, 180 s). The hook client (claude_code_gate.hook_main) already forwards hookSpecificOutput verbatim, so no client change was needed — verified, not assumed.
  • Decision walls (local only) — both POST /api/approvals/<id>/decide and POST /api/hitl/decide accept decision='answer' + answers, validated against the stored set; first-click-wins is preserved by reusing update_approval_decision (decision answered, status answered). GET /api/approvals now serves the full question set per pending row; the audit summary counts answered.
  • Installer — the installed PreToolUse matcher also watches AskUserQuestion whenever the gate is armed, and the hook timeout is floored at question-window + buffer so Claude Code never cancels the hook before the receiver's ask fallback lands.
  • Dashboardclawmetry/templates/tabs/approvals.html: pending question rows render radios (single-choice), checkboxes (multiSelect), a free-text input when the set flags it, a question-count + headers chip, and a single Send answers button posting decision='answer'. Binary rows render exactly as before. (static/js/app.js untouched.)

Explicitly excluded (phase 2)

  • Cloud (clawmetry-cloud) and pro (clawmetry-pro) decision walls / relay.
  • Telegram / phone-call question fidelity (those channels still see the row as a normal pending approval).
  • Free-text answers for plain Claude Code sets: validation is strict per the WO ("reject unknown labels with 400"); a free-text input renders only when the stored question set carries an explicit allow-free-text flag.

Tests

  • New: tests/test_question_set_approvals.py23 passed (validation 400s incl. unknown label/question, multiSelect array round-trip, first-click-wins on structured answers, deny-beats-late-answer, binary regression through both walls, queue payload carries the set, gate intercept → updatedInput, dedup on tool_use_id, expiry → ask with row closed as timeout, already-expired row → ask, malformed payload → ask, env kill switch).
  • Regression (WO-specified): tests/test_approvals_local_store.py tests/test_policy_replay.py tests/test_tool_policy_route_gate.py38 passed.
  • Neighbors of touched files: test_runtime_gates_and_hooks.py, test_mirror_gate.py, test_hook_collision.py, test_hooks_claude_code.py, test_approval_seam.py, test_tool_risk_remember_flow.py, test_openclaw_exec_policy_gate.py, test_approvals_local_blocking.py, test_approvals_duckdb_watcher.py, test_approvals_deny_kill.py175 passed, 1 pre-existing failure (test_cc_gate_windowless_python_swap) that fails identically on a clean origin/main checkout (unrelated to this change).
  • Two exact-matcher assertions in tests/test_runtime_gates_and_hooks.py were updated for the deliberately widened installed matcher (BashBash|AskUserQuestion, timeout floor 180 → 240); the matcher-derivation helper itself stays policy-only and its own pins are unchanged.
  • ast.parse clean on all touched Python files; template inline JS passes node --check.

🤖 Generated with Claude Code

https://claude.ai/code/session_017VCaSBpV1wBCKrU4z9MKKZ

…-52 phase 1)

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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017VCaSBpV1wBCKrU4z9MKKZ
@8090-software-factory

Copy link
Copy Markdown

✅ Drift Bot (ClawMetry): no drift detected

Drift Bot analyzed the changed files against this project's blueprints and requirements and found no drift.

@8090-software-factory

Copy link
Copy Markdown

⚠️ Drift Bot (ClawMetry): 6 potential drift finding(s)

1. Blueprint: Approval Decisions Beyond Yes/No

File: routes/hooks.py:464

The _wait_on_row function in the PreToolUse hook receiver does not handle question-set approvals. WO-52 requires that when a row carries a question set, an "answered" status should invoke _answered_reply() to return allow+updatedInput with structured answers, and an expired question-set row should fall back to "ask" (never the binary deny default). The current code lacks both the status=="answered" check and the is_question&&status=="expired" special case.

2. Requirement: Approval Decisions Beyond Yes/No

File: routes/hooks.py:350-400

AC-RA-002.1 requires that when a pre-tool gate intercepts AskUserQuestion, "the runtime's native question prompt is intercepted, its full question set becomes an approval request, and the human's structured answers are returned to the runtime in the runtime's own answer contract." The hooks.py receiver does not contain the _park_question_set() function needed to intercept and park the question set, nor the _answered_reply() function needed to return the merged answers via updatedInput.

3. Requirement: Approval Decisions Beyond Yes/No

File: routes/hooks.py

AC-RA-002.2 specifies that "Timeout or any failure falls back to the runtime's own interactive prompt — the same behaviour the session would have had without ClawMetry. The system never fabricates, defaults, or auto-selects an answer." For question-set approvals, an expired row must return "ask", not follow the binary "on_timeout=deny" default. The current _wait_on_row implementation does not distinguish question-set approvals from policy-based approvals for expiry handling.

4. Blueprint: Approval Decisions Beyond Yes/No

File: routes/hooks.py:325

The PreToolUse hook receiver skips the AskUserQuestion intercept entirely. WO-52 phase 1 requires intercepting tool_name=="AskUserQuestion" before policy matching and calling _park_question_set() to park the question set. The code goes directly from the "no tool_name" check to "fresh call: policy match" without the AskUserQuestion branch, so Claude Code's structured questions are never parked as question-set approvals.

5. Requirement: Approval Decisions Beyond Yes/No

File: routes/hooks.py

AC-RA-002.1 specifies that "When the pre-tool gate is installed and this capability is enabled, the runtime's native question prompt is intercepted, its full question set becomes an approval request." The hook receiver in routes/hooks.py lacks the _park_question_set() function and the code that calls it when tool_name=="AskUserQuestion", so the AskUserQuestion flow is completely unimplemented.

6. Blueprint: Approval Decisions Beyond Yes/No

File: routes/hooks.py:464

The _wait_on_row() function does not handle the "answered" status for question-set approvals. WO-52 requires checking if status=="answered" and invoking _answered_reply() to return allow+updatedInput. The current implementation treats an answered question set like any other approval, never returning the merged answers to the runtime.

Comment thread routes/hooks.py

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Drift Bot (ClawMetry) — Blueprint: Approval Decisions Beyond Yes/No

The _wait_on_row function in the PreToolUse hook receiver does not handle question-set approvals. WO-52 requires that when a row carries a question set, an "answered" status should invoke _answered_reply() to return allow+updatedInput with structured answers, and an expired question-set row should fall back to "ask" (never the binary deny default). The current code lacks both the status=="answered" check and the is_question&&status=="expired" special case.

Comment thread routes/hooks.py

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Drift Bot (ClawMetry) — Requirement: Approval Decisions Beyond Yes/No

AC-RA-002.1 requires that when a pre-tool gate intercepts AskUserQuestion, "the runtime's native question prompt is intercepted, its full question set becomes an approval request, and the human's structured answers are returned to the runtime in the runtime's own answer contract." The hooks.py receiver does not contain the _park_question_set() function needed to intercept and park the question set, nor the _answered_reply() function needed to return the merged answers via updatedInput.

Comment thread routes/hooks.py

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Drift Bot (ClawMetry) — Blueprint: Approval Decisions Beyond Yes/No

The PreToolUse hook receiver skips the AskUserQuestion intercept entirely. WO-52 phase 1 requires intercepting tool_name=="AskUserQuestion" before policy matching and calling _park_question_set() to park the question set. The code goes directly from the "no tool_name" check to "fresh call: policy match" without the AskUserQuestion branch, so Claude Code's structured questions are never parked as question-set approvals.

Copy link
Copy Markdown
Owner Author

Test plan & review notes

Repo: vivekchand/clawmetry

What changed

  • WO-52 phase 1: question-set approvals — the pre-tool hook can now park an AskUserQuestion call as a structured approval request and return the human's answers to the runtime

CI status
All checks queued as of this comment.

⚠️ Drift Bot findings look real (unlike #5367)

The six Drift Bot findings on this PR all point to the same root cause: the WO-52 implementation appears incomplete in routes/hooks.py. These are not false positives — the functions Drift Bot expects are not added by this PR's diff (unlike #5367 where the functions were present in process_control.py). Specifically:

Missing Where needed AC
_park_question_set() routes/hooks.py at the AskUserQuestion intercept branch AC-RA-002.1
_answered_reply() routes/hooks.py in _wait_on_row() AC-RA-002.1
AskUserQuestion intercept branch Before policy match in PreToolUse receiver AC-RA-002.1
status == "answered" check in _wait_on_row() routes/hooks.py:464 AC-RA-002.1
is_question && status == "expired" → fall back to "ask" _wait_on_row() AC-RA-002.2

If these paths are intentionally deferred to a later phase, that's fine — but a note in the PR description (or a # TODO: WO-52 phase 2 stub) would help Drift Bot distinguish "planned gap" from "missing code". If they're meant to be in this PR, they'd need to land before this can merge.

Smoke commands

make lint          # catch missing-function NameErrors early
make test-api      # approval flow integration tests

What to look at visually

  • Approvals tab → trigger a session that uses AskUserQuestion → confirm it surfaces as a question-set approval row (not a binary yes/no)
  • Answer the questions → confirm the answers come back to the runtime via updatedInput
  • Let a question-set approval expire → confirm it falls back to the runtime's own interactive prompt, not a silent deny

Issue link

  • WO-52 is referenced in the title; no Closes #N for an OSS issue visible.

Generated by Claude Code

@vivekchand

Copy link
Copy Markdown
Owner Author

Drift Bot's findings are false positives — the code it reports missing is in this PR's tree, same resolution failure as on #5367 (the bot appears to analyze a ref other than the PR head; the Factory code index only knows merged code, so a spec-first PR cannot link its own additions):

  • _answered_reply — defined at routes/hooks.py:581.
  • status == "answered" handling inside _wait_on_rowroutes/hooks.py:633-636 (and the completion path at :699-700).
  • expired question-set rows falling back to ask, never the binary deny default — routes/hooks.py:646 (status == "expired" and is_question).

Covered by tests/test_question_set_approvals.py (23 tests green), including the timeout→ask contract. Confirm any line with git grep -n on feat/approvals-question-sets.

Comment thread routes/hitl.py
return jsonify({"error": msg}), code
flag.unlink()
log.info("HITL answer: session=%s approval=%s operator=%s",
session_id, approval_id, operator)
Comment thread routes/hitl.py
return jsonify({"error": msg}), code
flag.unlink()
log.info("HITL answer: session=%s approval=%s operator=%s",
session_id, approval_id, operator)
Comment thread routes/hitl.py
return jsonify({"error": msg}), code
flag.unlink()
log.info("HITL answer: session=%s approval=%s operator=%s",
session_id, approval_id, operator)
@github-actions

github-actions Bot commented Aug 30, 2026

Copy link
Copy Markdown
Contributor

Visual diff

Comparing e593a4d0a745 (head) against the PR base branch.

40 of 66 comparison(s) flagged (>1% pixel diff).

View Before After Diff
desktop overview ⚠️ before after diff · 100.00%
desktop flow before after diff · 0.11%
desktop brain before after diff · 0.01%
desktop usage ⚠️ before after diff · 7.49%
desktop crons ⚠️ before after diff · 3.52%
desktop memory before after diff · 0.02%
desktop security ⚠️ before after diff · 100.00%
desktop subagents before after diff · 0.00%
desktop transcripts before after diff · 0.26%
desktop logs ⚠️ before after diff · 4.78%
desktop skills ⚠️ before after diff · 2.87%
desktop models before after diff · 0.00%
desktop approvals ⚠️ before after diff · 3.24%
desktop alerts before after diff · 0.00%
desktop notifications before after diff · 0.26%
desktop limits ⚠️ before after diff · 1.55%
desktop clusters before after diff · 0.01%
desktop history ⚠️ before after diff · 1.64%
desktop channels before after diff · 0.01%
desktop dives before after diff · 0.00%
desktop harness before after diff · 0.00%
desktop inventory before after diff · 0.01%
desktop nemoclaw before after diff · 0.01%
desktop policy before after diff · 0.01%
desktop selfevolve ⚠️ before after diff · 1.99%
desktop swimlane ⚠️ before after diff · 2.28%
desktop tool-catalog before after diff · 0.21%
desktop tracing ⚠️ before after diff · 3.45%
desktop turn-anatomy ⚠️ before after diff · 3.07%
desktop version-impact ⚠️ before after diff · 2.06%
desktop context-economics before after diff · 0.42%
desktop agents before after diff · 0.02%
desktop evals before after diff · 0.16%
mobile overview ⚠️ before after diff · 100.00%
mobile flow ⚠️ before after diff · 5.84%
mobile brain ⚠️ before after diff · 100.00%
mobile usage ⚠️ before after diff · 4.81%
mobile crons ⚠️ before after diff · 1.84%
mobile memory ⚠️ before after diff · 1.90%
mobile security ⚠️ before after diff · 100.00%
mobile subagents before after diff · 0.02%
mobile transcripts ⚠️ before after diff · 1.66%
mobile logs ⚠️ before after diff · 1.95%
mobile skills ⚠️ before after diff · 100.00%
mobile models before after diff · 0.72%
mobile approvals before after diff · 0.94%
mobile alerts before after diff · 0.63%
mobile notifications ⚠️ before after diff · 100.00%
mobile limits before after diff · 0.02%
mobile clusters ⚠️ before after diff · 1.90%
mobile history before after diff · 0.01%
mobile channels ⚠️ before after diff · 100.00%
mobile dives ⚠️ before after diff · 100.00%
mobile harness ⚠️ before after diff · 5.03%
mobile inventory ⚠️ before after diff · 100.00%
mobile nemoclaw before after diff · 0.00%
mobile policy ⚠️ before after diff · 1.98%
mobile selfevolve ⚠️ before after diff · 100.00%
mobile swimlane ⚠️ before after diff · 1.84%
mobile tool-catalog ⚠️ before after diff · 100.00%
mobile tracing ⚠️ before after diff · 1.25%
mobile turn-anatomy ⚠️ before after diff · 1.15%
mobile version-impact ⚠️ before after diff · 100.00%
mobile context-economics ⚠️ before after diff · 2.76%
mobile agents ⚠️ before after diff · 100.00%
mobile evals ⚠️ before after diff · 100.00%

Folder: e593a4d0a745. Full PNGs also attached as a workflow artefact.

Generated by visual-diff bot. Pixel diffs >1% flagged; eyeball the table before merging. This check is non-blocking — fail = bot bug, not a code problem.

@8090-software-factory

Copy link
Copy Markdown

✅ Drift Bot (ClawMetry): no drift detected

Drift Bot analyzed the changed files against this project's blueprints and requirements and found no drift.

github-actions Bot pushed a commit that referenced this pull request Aug 30, 2026
@vivekchand
vivekchand merged commit 3ad96ba into main Aug 30, 2026
38 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants