Experimental mod: a questions inbox. The model parks a structured question instead of blocking the session, the person answers later from a pane, and the…

Experimental. A Claude Code mod — function hooks, not a skill module — that gives questions to the person a place to wait.
Specified in features/business/session-mods/ (section scrumia-questions); where it lives is ADR-0026.
A session often reaches a decision that belongs to the person while the work could go on without it. Asking in chat blocks the session or buries the question. This mod parks it in an inbox, in one format, and the person answers when they choose.
| Piece | Role |
|---|---|
ask_later tool | The model parks a question: context, ticket, question, impact, isBlocking, options (1 to 6), recommended (optional, copied from the options). question, ticket, the options and recommended are single lines; context and impact may span several. A blocking question tells the model not to go ahead on what depends on it; the others tell it to carry on. |
| Band above the prompt | N questions waiting, the blocking ones counted in red, with an Open button. Absent when nothing waits. |
/questions and its pane | A filter (All / Blocking / Non-blocking, with counts), the waiting questions blocking first with a coloured badge, one question as a bordered card (context, impact in Markdown) with a Remove button, a picker over the suggested answers (the recommended one starred and preselected) that always ends with Other, an Additional notes field, a ready mark per question, and one Send N answers button on top. |
prompt.submit hook | The answers not yet delivered ride the next prompt the person typed (prompt box, Remote Control bridge, or a plugin submitting as the person) as context for the model, once, and are marked delivered only after the prompt went through. A task notification, a sub-agent's return or a scheduled trigger leaves them waiting. |
$.store | One entry per project root: questions, drafts and the next number. Delivered questions are dropped thirty days after their delivery, at a later delivery. Unanswered or undelivered questions are never purged. |
isBlocking is missing, there are no options or more than six, the recommendation is not one of the options, or the question, ticket, an option or the recommendation holds a line break or a control character. Nothing is stored, so the pane never holds a question it cannot draw.Other answer without a note: it is not ready, since it would say nothing.Additional notes travel with the answer whichever choice is picked.hooks/hooks.json naming modules). Older versions ignore the plugin without saying so.ticket is free text, no tracker is read.| What | Role |
|---|---|
hooks/register.tsx | The tool, the band, the command, the pane and the prompt hook, all in one module. |
hooks/register.test.ts | Tests: parking and numbering, parallel calls, malformed calls, the context delivery once and labelled, a prompt refused below, one project not seeing another's questions, more than six options refused, a line break refused in each single-line field, answers kept for a prompt the person typed, the thirty-day purge counted from the delivery, a question stored in an older shape, the band, the send limited to the filtered view, and on two surfaces the pane answer, the filter, Other, the default recommendation, the grouped send and Remove. |
What a later version must keep reading or announce in the changelog: the tool name mcp__scrumia-questions__ask_later and its inputSchema; the /questions command; the shape stored under project:<root> in $.store (nextId, questions, drafts; a question may carry deliveredAt).
/plugin install scrumia-questions --marketplace tibs245/scrumia
For development, from a checkout of the repository:
claude --plugin-dir plugins/scrumia-questions
claude plugin validate plugins/scrumia-questions
claude plugin test plugins/scrumia-questionshooks/register.tsx 429 lines1import type { EngineInterface, PromptOrigin, Register } from 'claude-code'
2
3type Answer = { text: string; notes?: string; isOther: boolean }
4
5type Draft = { choice?: string; notes?: string }
6
7type Question = {
8 id: number
9 context: string
10 ticket?: string
11 question: string
12 impact: string
13 isBlocking?: boolean
14 options: string[]
15 recommended?: string
16 askedAt: number
17 answer?: Answer
18 isDelivered: boolean
19 deliveredAt?: number
20}
21
22type Project = { nextId: number; questions: Question[]; drafts: Record<string, Draft> }
23
24type Filter = 'all' | 'blocking' | 'non-blocking'
25
26const PANE = 'questions'
27const ASK_TOOL = 'mcp__scrumia-questions__ask_later'
28const OTHER = 'Other'
29const MAX_OPTIONS = 6
30const TYPED_BY_PERSON: PromptOrigin['kind'][] = ['composer', 'bridge']
31const CONTROL_CHARACTER = /[\u0000-\u001f\u007f-\u009f\u2028\u2029]/
32const KEEP_DELIVERED_MS = 30 * 24 * 60 * 60 * 1000
33
34let queue: Promise<unknown> = Promise.resolve()
35const view: { filter: Filter; selectedId?: number } = { filter: 'all' }
36
37const isPending = (one: Question) => one.answer === undefined
38const isUndelivered = (one: Question) => one.answer !== undefined && !one.isDelivered
39const isBlockingQuestion = (one: Question) => one.isBlocking === true
40const matchesFilter = (filter: Filter) => (one: Question) =>
41 filter === 'all' || (filter === 'blocking') === isBlockingQuestion(one)
42const blockingFirst = (first: Question, second: Question) =>
43 Number(isBlockingQuestion(second)) - Number(isBlockingQuestion(first)) || first.id - second.id
44
45const isText = (value: unknown): value is string => typeof value === 'string' && value.trim() !== ''
46const isSingleLine = (value: string) => !CONTROL_CHARACTER.test(value)
47
48const isTypedByPerson = (origin: PromptOrigin | undefined) =>
49 origin !== undefined && (TYPED_BY_PERSON.includes(origin.kind) || (origin.kind === 'plugin' && origin.asUser === true))
50
51function problemWith(input: Record<string, unknown>): string | undefined {
52 const { context, ticket, question, impact, isBlocking, options, recommended } = input
53 if (!isText(context) || !isText(question) || !isText(impact)) {
54 return 'context, question and impact must be non-empty text'
55 }
56 if (ticket !== undefined && typeof ticket !== 'string') {
57 return 'ticket must be text'
58 }
59 if (!isSingleLine(question)) {
60 return 'question must be one line: no line break or control character'
61 }
62 if (ticket !== undefined && !isSingleLine(ticket)) {
63 return 'ticket must be one line: no line break or control character'
64 }
65 if (typeof isBlocking !== 'boolean') {
66 return 'isBlocking must be true or false'
67 }
68 if (!Array.isArray(options) || options.length === 0 || options.length > MAX_OPTIONS || !options.every(isText)) {
69 return `options must be 1 to ${MAX_OPTIONS} non-empty texts`
70 }
71 if (!options.every(isSingleLine)) {
72 return 'every entry of options must be one line: no line break or control character'
73 }
74 if (typeof recommended === 'string' && !isSingleLine(recommended)) {
75 return 'recommended must be one line: no line break or control character'
76 }
77 if (recommended !== undefined && !options.includes(recommended)) {
78 return 'recommended must be copied from options'
79 }
80
81 return undefined
82}
83
84const readyAnswer = (one: Question, draft: Draft | undefined): Answer | undefined => {
85 const choice = draft?.choice ?? one.recommended
86 const notes = draft?.notes?.trim() ?? ''
87 if (choice === undefined || (choice === OTHER && notes === '')) {
88 return undefined
89 }
90
91 return { text: choice, notes: notes === '' ? undefined : notes, isOther: choice === OTHER }
92}
93
94const missing = (one: Question, draft: Draft | undefined) =>
95 (draft?.choice ?? one.recommended) === undefined ? 'pick one' : 'needs a note'
96
97const emptyProject = (): Project => ({ nextId: 1, questions: [], drafts: {} })
98
99const keyOf = (root: string) => `project:${root}`
100
101async function load($: EngineInterface): Promise<Project> {
102 const stored = (await $.store.get(keyOf(await $.session.root()))) as Project | undefined
103
104 return stored ?? emptyProject()
105}
106
107function change($: EngineInterface, edit: (project: Project) => Project, isRedrawn = true): Promise<Project> {
108 const run = queue.then(async () => {
109 const key = keyOf(await $.session.root())
110 const next = edit(((await $.store.get(key)) as Project | undefined) ?? emptyProject())
111 await $.store.set(key, next)
112 if (isRedrawn) {
113 $.ui.invalidate('ui.render')
114 }
115
116 return next
117 })
118 queue = run.catch(() => undefined)
119
120 return run
121}
122
123function amend($: EngineInterface, id: number, draft: Partial<Draft>, isRedrawn: boolean) {
124 return change(
125 $,
126 project => ({ ...project, drafts: { ...project.drafts, [id]: { ...project.drafts[id], ...draft } } }),
127 isRedrawn,
128 )
129}
130
131function sendAll($: EngineInterface, filter: Filter) {
132 return change($, project => {
133 const answers = new Map<number, Answer>()
134 for (const one of project.questions.filter(isPending).filter(matchesFilter(filter))) {
135 const given = readyAnswer(one, project.drafts[one.id])
136 if (given !== undefined) {
137 answers.set(one.id, given)
138 }
139 }
140 const drafts = { ...project.drafts }
141 for (const id of answers.keys()) {
142 delete drafts[id]
143 }
144
145 return {
146 ...project,
147 drafts,
148 questions: project.questions.map(one => (answers.has(one.id) ? { ...one, answer: answers.get(one.id) } : one)),
149 }
150 })
151}
152
153function remove($: EngineInterface, id: number) {
154 return change($, project => {
155 const drafts = { ...project.drafts }
156 delete drafts[id]
157
158 return { ...project, drafts, questions: project.questions.filter(one => one.id !== id) }
159 })
160}
161
162const deliveryNote = (ready: Question[]) =>
163 [
164 '[scrumia-questions] The person answered questions you parked. The question texts are yours; the answers and notes are theirs.',
165 ...ready.map(
166 one =>
167 `- #${one.id}${one.ticket ? ` (${one.ticket})` : ''} ${one.question}\n Answer: ${one.answer!.text}${one.answer!.notes ? `\n Additional notes: ${one.answer!.notes}` : ''}`,
168 ),
169 ].join('\n')
170
171export const register: Register = on => {
172 on('session.start', async ($, e, next) => {
173 await $.command.register({
174 name: 'questions',
175 description: 'Open the inbox of questions parked by the model',
176 })
177 await $.tool.register({
178 name: 'ask_later',
179 isDeferred: false,
180 description:
181 'Park a question for the person instead of blocking the session on it. Use it when a decision belongs to them. Give the context, the linked ticket, the impact of the choice, whether it blocks work that cannot proceed without the answer, 1-6 suggested answers and which one you recommend (copied from the options). Question, ticket, options and recommended are single lines; only context and impact may span several. Their answer comes back in a later prompt. The call is refused when a field is missing or malformed: read the reason and call again.',
182 inputSchema: {
183 type: 'object',
184 properties: {
185 context: { type: 'string', description: 'Situation the question arises from' },
186 ticket: { type: 'string', description: 'Linked ticket, e.g. #3528' },
187 question: { type: 'string' },
188 impact: { type: 'string', description: 'What changes depending on the answer' },
189 isBlocking: {
190 type: 'boolean',
191 description: 'true when part of the work cannot proceed without the answer, false when it can wait',
192 },
193 options: { type: 'array', items: { type: 'string' }, description: 'Suggested answers' },
194 recommended: { type: 'string', description: 'The recommended option, copied from options' },
195 },
196 required: ['context', 'question', 'impact', 'isBlocking', 'options'],
197 },
198 })
199
200 return next(e)
201 })
202
203 on('tool.call', { tool: ASK_TOOL }, async ($, e) => {
204 const input = e as unknown as Record<string, unknown>
205 const problem = problemWith(input)
206 if (problem !== undefined) {
207 return { deny: `scrumia-questions: ${problem}` }
208 }
209 const asked = input as unknown as Omit<Question, 'id' | 'askedAt' | 'isDelivered'>
210 const askedAt = await $.clock.now()
211 const project = await change($, current => ({
212 ...current,
213 nextId: current.nextId + 1,
214 questions: [
215 ...current.questions,
216 {
217 id: current.nextId,
218 context: asked.context,
219 ticket: asked.ticket,
220 question: asked.question,
221 impact: asked.impact,
222 isBlocking: asked.isBlocking,
223 options: asked.options,
224 recommended: asked.recommended,
225 askedAt,
226 isDelivered: false,
227 },
228 ],
229 }))
230 const id = project.nextId - 1
231 $.ui.toast(`Question parked (${project.questions.filter(isPending).length} pending)`)
232
233 return {
234 result: asked.isBlocking
235 ? `Question #${id} parked and it blocks. Do not go ahead on the part that depends on it; carry on with what does not. The answer arrives in a later prompt.`
236 : `Question #${id} parked. Carry on without waiting; the answer arrives in a later prompt.`,
237 }
238 })
239
240 on('prompt.submit', async ($, e, next) => {
241 if (!isTypedByPerson(e.origin)) {
242 return next(e)
243 }
244 const ready = (await load($)).questions.filter(isUndelivered)
245 if (ready.length === 0) {
246 return next(e)
247 }
248 const ran = await next({ ...e, context: [...(e.context ?? []), deliveryNote(ready)] })
249 if (ran.drop === undefined) {
250 const ids = new Set(ready.map(one => one.id))
251 const now = await $.clock.now()
252 await change($, project => ({
253 ...project,
254 questions: project.questions
255 .map(one => (ids.has(one.id) ? { ...one, isDelivered: true, deliveredAt: now } : one))
256 .filter(one => !(one.isDelivered && now - (one.deliveredAt ?? one.askedAt) > KEEP_DELIVERED_MS)),
257 }))
258 }
259
260 return ran
261 })
262
263 on('command.run', { command: 'questions' }, async $ => {
264 await $.ui.open({ id: PANE, title: 'Questions' })
265
266 return { text: 'Questions pane opened.' }
267 })
268
269 on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
270 const pending = (await load($)).questions.filter(isPending)
271 if (e.props.hasSurvey || pending.length === 0) {
272 return next(e)
273 }
274 const blocking = pending.filter(isBlockingQuestion).length
275 const { Box, Button, Text } = $.ui.resolve(e)
276
277 return (
278 <Box gap={1}>
279 <Text bold>
280 {pending.length} question{pending.length > 1 ? 's' : ''} waiting
281 </Text>
282 {blocking > 0 && (
283 <Text color="error" bold>
284 {blocking} blocking
285 </Text>
286 )}
287 <Button key="open" label="Open" onPress={() => $.ui.open({ id: PANE, title: 'Questions' })} />
288 </Box>
289 )
290 })
291
292 on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
293 const { Box, Button, Input, Markdown, Select, Text } = $.ui.resolve(e)
294 const project = await load($)
295 const everything = project.questions.filter(isPending)
296 const filter = view.filter
297 const shown = everything.filter(matchesFilter(filter)).sort(blockingFirst)
298 const current = shown.find(one => one.id === view.selectedId) ?? shown[0]
299 const blockingCount = everything.filter(isBlockingQuestion).length
300 const draftOf = (one: Question) => project.drafts[one.id]
301 const draft = current === undefined ? undefined : draftOf(current)
302 const readyCount = shown.filter(one => readyAnswer(one, draftOf(one)) !== undefined).length
303 const show = () => $.ui.invalidate('ui.render')
304 const filters: { value: Filter; label: string; count: number }[] = [
305 { value: 'all', label: 'All', count: everything.length },
306 { value: 'blocking', label: 'Blocking', count: blockingCount },
307 { value: 'non-blocking', label: 'Non-blocking', count: everything.length - blockingCount },
308 ]
309
310 return (
311 <Box flexDirection="column" gap={1}>
312 <Box gap={2}>
313 <Text bold>Questions</Text>
314 {filters.map(one => (
315 <Button
316 key={`filter-${one.value}`}
317 onPress={() => {
318 view.filter = one.value
319 show()
320 }}
321 >
322 <Text bold={filter === one.value} inverse={filter === one.value} dimColor={filter !== one.value}>
323 {' '}
324 {one.label} {one.count}{' '}
325 </Text>
326 </Button>
327 ))}
328 </Box>
329 {current !== undefined && (
330 <Box gap={2}>
331 <Button key="send-all" onPress={() => sendAll($, filter)}>
332 <Text bold inverse={readyCount > 0} dimColor={readyCount === 0}>
333 {' '}
334 Send {readyCount} answer{readyCount === 1 ? '' : 's'}{' '}
335 </Text>
336 </Button>
337 <Text dimColor>
338 {shown.length - readyCount > 0 ? `${shown.length - readyCount} not ready` : 'all ready'}
339 </Text>
340 </Box>
341 )}
342 {current === undefined && <Text dimColor>No question here. Nothing waits on you.</Text>}
343 {current !== undefined && (
344 <Box flexDirection="column">
345 {shown.map(one => (
346 <Button
347 key={`q${one.id}`}
348 onPress={() => {
349 view.selectedId = one.id
350 show()
351 }}
352 >
353 <Text color={isBlockingQuestion(one) ? 'error' : 'suggestion'} bold>
354 {one.id === current.id ? '▶ ' : ' '}
355 {isBlockingQuestion(one) ? '● BLOCKING ' : '○ can wait '}
356 </Text>
357 <Text color={readyAnswer(one, draftOf(one)) ? 'success' : 'warning'}>
358 {readyAnswer(one, draftOf(one)) ? '✓ ready ' : `… ${missing(one, draftOf(one))} `}
359 </Text>
360 <Text bold={one.id === current.id}>
361 #{one.id} {one.ticket ? `${one.ticket} · ` : ''}
362 {one.question.slice(0, 60)}
363 </Text>
364 </Button>
365 ))}
366 </Box>
367 )}
368 {current !== undefined && (
369 <Box
370 flexDirection="column"
371 gap={1}
372 padding={1}
373 borderStyle="round"
374 borderColor={isBlockingQuestion(current) ? 'error' : 'suggestion'}
375 >
376 <Box gap={2}>
377 <Text color={isBlockingQuestion(current) ? 'error' : 'suggestion'} bold>
378 {isBlockingQuestion(current) ? '● BLOCKING' : '○ CAN WAIT'}
379 </Text>
380 {current.ticket && <Text dimColor>{current.ticket}</Text>}
381 <Text dimColor>#{current.id}</Text>
382 <Button key="remove" label="Remove" onPress={() => remove($, current.id)} />
383 </Box>
384 <Text bold>{current.question}</Text>
385 <Box flexDirection="column">
386 <Text color="subtle" bold>
387 CONTEXT
388 </Text>
389 <Markdown text={current.context} />
390 </Box>
391 <Box flexDirection="column">
392 <Text color="subtle" bold>
393 IMPACT
394 </Text>
395 <Markdown text={current.impact} />
396 </Box>
397 <Select
398 key={`pick${current.id}`}
399 label="Answer"
400 options={[
401 ...current.options.map(option => ({
402 value: option,
403 label: option === current.recommended ? `★ ${option} (recommended)` : option,
404 })),
405 { value: OTHER, label: OTHER },
406 ]}
407 value={draft?.choice ?? current.recommended}
408 onSelect={value => amend($, current.id, { choice: value }, true)}
409 />
410 <Input
411 key={`notes${current.id}`}
412 label="Additional notes"
413 placeholder={
414 (draft?.choice ?? current.recommended) === OTHER
415 ? 'required with Other: say what you decide'
416 : 'always sent with your answer (optional)'
417 }
418 value={draft?.notes}
419 submitLabel="keep notes"
420 onInput={text => amend($, current.id, { notes: text }, false)}
421 onSubmit={text => amend($, current.id, { notes: text }, true)}
422 />
423 </Box>
424 )}
425 </Box>
426 )
427 })
428}
429