SLOPSHOPPER

scrumia-questions

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…

newpanebandguardcommandtoast
★ 1v0.1.2MITupdated 2026-10-10tibs245/scrumia/plugins/scrumia-questions
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · scrumia-questions
│ ┃ Questions ✕ › fix the failing auth test and add an audit log call │ ┃ Questions [ filter-all ] [ filter-blocking │ ┃ ⏺ Read(src/auth.ts) │ ┃ No question here. Nothing waits on you. ⎿ Read 6 lines │ ⏺ Update(src/auth.ts) │ ⎿ Added 2 lines, removed 1 line │ ⏺ Bash(bun test) │ ⎿ 3 pass, 1 fail │ │ ● Done. refresh now rejects expired claims and logs an audit event. │ │ ✻ Worked for 42s · done 4:20 PM │ │ › /questions │ ⎿ scrumia-questions: Questions pane opened. │ │ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Pane · Questions
Questions [ filter-all ] [ filter-blocking ] [ filter-non No question here. Nothing waits on you.
README

scrumia-questions

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.

What it answers

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.

How it works

PieceRole
ask_later toolThe 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 promptN questions waiting, the blocking ones counted in red, with an Open button. Absent when nothing waits.
/questions and its paneA 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 hookThe 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.
$.storeOne 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.

What it refuses

  • A malformed question: the call is refused with its reason when the context, question or impact is empty, 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.
  • A lost answer: the answers are marked delivered only once the prompt carrying them was accepted; a prompt refused further down leaves them to the next one.
  • A lost question: writes go through one queue, so parallel calls keep every question.
  • A silent answer: it is delivered once, never twice, and only on a prompt the person typed.
  • A forged answer: the single-line fields cannot carry a line break, so the model cannot write a line that passes for the person's answer in the delivered note.
  • An Other answer without a note: it is not ready, since it would say nothing.
  • One click per question: choices and notes are kept as drafts, and a single button sends every ready answer of the filtered view. The preselected recommendation counts as the answer when nothing else is picked.
  • Notes left behind: Additional notes travel with the answer whichever choice is picked.
  • The model's words in the person's voice: the question texts reach the model beside the prompt, labelled as the model's own, never inside the text the person typed.

What it expects to find

  • A Claude Code that loads plugins made of hooks modules (hooks/hooks.json naming modules). Older versions ignore the plugin without saying so.
  • Nothing from the other ScrumIA modules: ticket is free text, no tracker is read.

What it ships

WhatRole
hooks/register.tsxThe tool, the band, the command, the pane and the prompt hook, all in one module.
hooks/register.test.tsTests: 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.

Public contract

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).

Install

/plugin install scrumia-questions --marketplace tibs245/scrumia

For development, from a checkout of the repository:

claude --plugin-dir plugins/scrumia-questions

Known limits

  • Whether the pane draws depends on the surface the session runs in; it draws on the desktop app. The band and the command are the safer entry points elsewhere.
  • Chrome strings are English; the question text is whatever language the model writes in.
  • A question belongs to the machine and to the project root it was asked in: nothing is written in the repo, and two sessions open on the same project share its inbox, and an answer given in one is delivered at the next typed prompt of whichever session reads the project first. The decision taken still belongs in the linked ticket: the inbox is a buffer, and an answer returns with the ticket's number so the decision can be reported there, since state lives in the tracker.
  • The filter and the open question are per session and start again after a reload.
  • The queue serialises writes inside one process; two processes writing the same project at the same instant are not protected.

Tests

claude plugin validate plugins/scrumia-questions
claude plugin test plugins/scrumia-questions
Source 1 files
hooks/register.tsx 429 lines
1import 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