Keeps the question dialog readable: a question the model writes longer than one line is sent back, with word to put the context in a markdown message first and…

The question dialog (AskUserQuestion) draws its question as plain text. Markdown stays raw and line breaks are ignored, so when the model packs a plan into the question you get a wall of text you can't read:
All 12 cards stayed in Fix in this PR; no board follow-up. Plan for #40: reopen Domain Design (2.6) on this branch (backward jump as its own call, unpark, orchestrate next --resume). Then a new questions file … Go ahead?
This mod sends any question longer than 250 characters back to the model before the dialog opens. Its error message tells the model how to ask instead:
description or preview;You never see the rejected dialog. You see the formatted message, then a short question.
Only the model's own questions are checked. Dialogs that other plugins open (model-fit's Switch or Stay, for example) pass through untouched. If the guard itself throws an error, the question goes through as-is, so a bug in the guard can never cost you a question.
Requires a Claude Code build with function-hooks (mod) support. CI pins 2.1.295.
/plugin install ask-guard@mskuibida-tooling
| Option | Default | What it does |
|---|---|---|
maxQuestionChars | 250 | The most characters one question may have. Set it in the plugin's config menu. |
response-format 2.1.0 adds the same rule to its writing guidance, so the model usually keeps questions short from the start. This mod catches the times it doesn't.
claude plugin validate plugins/ask-guard
claude plugin test plugins/ask-guardhooks/register.ts 42 lines1import type { Register } from 'claude-code'
2
3// The dialog draws a question as plain text: no markdown, no line structure. Past about one line it
4// is a wall, so the model is sent back to write the context as a message (which renders) first.
5const DEFAULT_MAX_QUESTION_CHARS = 250
6
7export function maxQuestionChars(value: unknown): number {
8 return typeof value === 'number' && Number.isFinite(value) && value > 0 ? Math.floor(value) : DEFAULT_MAX_QUESTION_CHARS
9}
10
11// Which questions run long, by their place in the call (1-based) and length.
12export function overLong(questions: readonly { question: string }[], max: number): { place: number; length: number }[] {
13 return questions
14 .map((q, i) => ({ place: i + 1, length: q.question.trim().length }))
15 .filter(q => q.length > max)
16}
17
18// What the model reads in place of the answer; it never reaches the person as the dialog would.
19export function denial(long: readonly { place: number; length: number }[], max: number): string {
20 const which = long.map(q => `question ${q.place} is ${q.length} characters`).join(', ')
21
22 return (
23 `ask-guard: ${which}; the limit is ${max}. The dialog draws a question as plain text, so a long one is unreadable. ` +
24 'Write the context (the plan, findings, lists) as a normal markdown message first, put per-option detail in ' +
25 "each option's description or preview, then call AskUserQuestion again with a one-line question, " +
26 'e.g. "Go ahead with the plan above?".'
27 )
28}
29
30export const register: Register = (on, options) => {
31 const max = maxQuestionChars(options.maxQuestionChars)
32
33 on('tool.call', { tool: 'AskUserQuestion' }, ($, e, next) => {
34 // Only the model's own asks: another plugin's dialog (model-fit's Switch or Stay) is its business.
35 if (next.origin.plugin !== 'engine') return next(e)
36
37 const long = overLong(e.questions, max)
38
39 return long.length === 0 ? next(e) : { deny: denial(long, max) }
40 }).catch(($, e, next) => next(e)) // A broken guard must never cost the person their question.
41}
42