SLOPSHOPPER

debrief

Debriefs of completed agent work

newguardtoolmodelprocess
★ 31v0.1.5no licenseupdated 2026-10-23Crazytieguy/alignment-hive/plugins/debrief
A shopper browsing a rack in a slop shop
README

debrief

Reviewing every single thing an agent did is no longer viable if you want to scale up the work you produce. /debrief:debrief turns a finished session into the review you need, on one page: what was done and why, the decisions with the highest risk of not being what you meant, the side effects, and how the work was verified. For fuzzy tasks without a solid test or metric, it's how you get quality without reading everything. Comment on it and ask for another round; the next round answers your comments and marks what's new since you last looked.

A debrief page: the outcome and your original asks at the top, then a judgement call opened to its reasoning, alternatives and diff, marked seen, then the evidence behind a check, in light and dark themes

The page is a private claude.ai artifact. Requires hive@alignment-hive (hive CLI 0.2.0 or later) and jq.

Source 1 files
hooks/reader.ts 119 lines
1// The reader check as a tool: reads a rendered debrief round the way its user meets it and returns the questions
2// each passage leaves them with. `hive debrief view` gives what the reader sees; one call reads the top of the page
3// and the collapsed list, which writes that shared opening to the prompt cache, then one call per item reads the
4// opening from the cache plus the item's body, up to 16 at a time.
5import type { Register } from 'claude-code'
6
7const DEFAULT_MODEL = 'sonnet'
8const MAX_IN_FLIGHT = 16
9
10const BRIEF = `You are the person who delegated a task to an AI agent, reading the report it wrote for you. You run the project, but you did not watch the agent work and skipped most of its messages. What you knew before opening the report is under <known>.
11
12You read the page in a browser, top to bottom: first the top of the page, then the list of items, each collapsed to its heading and a one-line summary, then you open items and read their bodies. You can't use what comes later to understand what comes earlier.
13
14A question worth noting is one that stops you from understanding what a passage says, or from judging whether what it reports is right or what you would have decided: a name, label or term you can't place (quote it); a reference to something you haven't seen; a claim whose reason you would need to judge it; a consequence for you that isn't clear. Skip curiosity, what only someone auditing the evidence would ask, and what the next sentence or two answers. Lines like [Folded diff: ...] are collapsed evidence: you see only their label, and you need them only to audit, not to follow the prose. Most passages raise nothing; don't pad.
15
16Mark each question "blocks" (you can't follow or judge the passage without it) or "nags" (you can follow, but it bothers you). Reply with JSON only: {"questions": [{"item": <item number, 0 for the top of the page>, "piece": <0 for the item's heading and summary, else the body piece number>, "quote": "<the words that raised it>", "question": "<your question>", "severity": "blocks" | "nags"}]}, or {"questions": []} when nothing gets in your way.`
17
18interface Question { item: number; piece: number; quote: string; question: string; severity: string }
19interface View {
20  readmes: Array<{ path: string; text: string }>
21  messages: Array<string>
22  top: { title: string; heading: string; story: string; stats: Array<string>; foryou: Array<string> }
23  items: Array<{ id: string; section: string; heading: string; lede?: string; isJudgementCall: boolean; isLowerPriority: boolean; pieces: Array<string> }>
24}
25
26/** The reply's questions, or null when it is not the JSON asked for (cut off at the token cap, say). */
27function parseQuestions(text: string): Array<Question> | null {
28  const start = text.indexOf('{'), end = text.lastIndexOf('}')
29  if (start < 0 || end < start) return null
30  let questions: unknown
31  try { questions = JSON.parse(text.slice(start, end + 1)).questions } catch { return null }
32  if (!Array.isArray(questions)) return null
33  // A number written as a string is still that number; any other malformed entry makes the whole reply unreadable,
34  // so a raised question is never dropped as if none were asked.
35  const read: Array<Question> = []
36  for (const q of questions as Array<Record<string, unknown> | null>) {
37    const item = Number(q?.item), piece = Number(q?.piece ?? 0)
38    if (!q || typeof q.question !== 'string' || !Number.isInteger(item) || !Number.isInteger(piece)) return null
39    read.push({ item, piece, quote: String(q.quote ?? ''), question: q.question, severity: String(q.severity ?? 'nags') })
40  }
41  return read
42}
43
44export const register: Register = (on) => {
45  on('session.start', async ($, e, next) => {
46    await $.tool.register({
47      name: 'read',
48      description: 'Reads a rendered debrief round as its user would, knowing only the READMEs as of the session\'s start and their own messages, and returns the questions each passage leaves them with, by item.',
49      inputSchema: {
50        type: 'object',
51        properties: {
52          session: { type: 'string', description: 'The full parent session id.' },
53          round: { type: 'number' },
54          data: { type: 'string', description: 'The debrief plugin\'s data directory.' },
55          cwd: { type: 'string', description: 'The directory the work was done in.' },
56          model: { type: 'string', description: 'The readers\' model, an alias or full id; default sonnet.' },
57        },
58        required: ['session', 'round', 'data', 'cwd'],
59      },
60    })
61    return next(e)
62  })
63
64  on('tool.call', { tool: 'mcp__debrief__read' }, async ($, e) => {
65    const input = e as unknown as { session: string; round: number; data: string; cwd: string; model?: string }
66    const ran = await $.process.run(['hive', 'debrief', 'view', '--session', input.session, '--round', String(input.round), '--data', input.data], { cwd: input.cwd, timeoutMs: 120000 })
67    if (ran.exitCode !== 0) return { result: `hive debrief view failed: ${ran.stderr.trim() || ran.stdout.trim()}`, isError: true }
68    const view = JSON.parse(ran.stdout) as View
69
70    let inFlight = 0, calls = 0
71    const waiting: Array<() => void> = []
72    const failures: Array<string> = []
73    const ask = async (what: string, prompt: Array<{ text: string; cache?: true }>): Promise<Array<Question>> => {
74      if (inFlight >= MAX_IN_FLIGHT) await new Promise<void>((resolve) => waiting.push(resolve))
75      inFlight++, calls++
76      try {
77        // One retry: a reply cut off or not the JSON asked for usually comes back whole the second time.
78        let reason = ''
79        for (let attempt = 0; attempt < 2; attempt++) {
80          let r
81          // The engine refuses to send for a model it blocks, which no retry changes.
82          try { r = await $.model.complete({ model: input.model || DEFAULT_MODEL, prompt, maxTokens: 4000, timeoutMs: 180000 }) }
83          catch (err) { reason = `refused: ${String(err)}`; break }
84          const questions = r.isAnswered ? parseQuestions(r.text) : null
85          if (questions !== null) return questions
86          reason = r.isAnswered ? 'unreadable reply' : r.reason
87        }
88        failures.push(`${what}: ${reason}`)
89        return []
90      } finally { inFlight--; waiting.shift()?.() }
91    }
92
93    const known = [
94      view.readmes.length ? `The project's READMEs as of when the task began:\n\n${view.readmes.map((d) => `<readme path="${d.path}">\n${d.text.trim()}\n</readme>`).join('\n\n')}` : 'No README.',
95      `Your own messages in the session, in order:\n\n${view.messages.map((m) => `<message>\n${m.trim()}\n</message>`).join('\n\n')}`,
96    ].join('\n\n')
97    const { title, heading, story, stats, foryou } = view.top
98    const top = [`# ${title}`, heading, story.trim(), stats.length ? `Stats: ${stats.join(' · ')}` : '', foryou.length ? `Left for you:\n${foryou.map((f) => `- ${f}`).join('\n')}` : ''].filter(Boolean).join('\n\n')
99    const collapsed = view.items.map((item, i) => {
100      const tags = [item.isJudgementCall && 'JUDGEMENT CALL', item.isLowerPriority && 'lower priority, in a skippable tray'].filter(Boolean)
101      return `[Item ${i + 1}]\nSection "${item.section}": ${item.heading}${tags.length ? ` [${tags.join(', ')}]` : ''}${item.lede ? `\n${item.lede}` : ''}`
102    })
103    // Behind a gateway the cache is found again only while the prompt opens the same way, so the shared text leads it.
104    const shared = { text: `${BRIEF}\n\n<known>\n${known}\n</known>\n\n<page_top>\n${top}\n</page_top>\n\n<collapsed_list>\n${collapsed.join('\n\n')}\n</collapsed_list>\n\n`, cache: true as const }
105    const questions = await ask('top of the page', [shared, { text: 'You have read the top of the page and skimmed the collapsed list. Note the questions they leave you with (item 0 for the top; piece 0 for an item\'s heading and summary). Questions about bodies come later, when you open the items.' }])
106    const bodies = await Promise.all(view.items.map((item, i) => item.pieces.length === 0 ? [] : ask(`item ${i + 1}`, [shared,
107      { text: item.pieces.map((piece, j) => `<opened_item number="${i + 1}" piece="${j + 1}">\n${piece}\n</opened_item>\n\n`).join('') },
108      { text: `Now you open item ${i + 1} and read its body. Note the questions it leaves you with, each at the piece where it arose, from what you knew by then. Use item ${i + 1}.` }])))
109    questions.push(...bodies.flat())
110
111    const lines = questions
112      .sort((a, b) => a.item - b.item || (a.piece ?? 0) - (b.piece ?? 0))
113      .map((q) => `- ${q.item === 0 ? 'top of the page' : `item ${q.item} (${view.items[q.item - 1]?.id ?? '?'})${q.piece === 0 ? ', heading or lede' : ''}`}, ${q.severity}: "${q.quote}": ${q.question}`)
114    if (failures.length === calls) return { result: `Every reader call failed: ${failures.join('; ')}`, isError: true }
115    const note = failures.length ? `\n\nIncomplete: ${failures.length} of ${calls} reader calls gave no usable answer (${failures.join('; ')}); those passages were not read.` : ''
116    return { result: (lines.length ? lines.join('\n') : 'The readers raised no questions.') + note }
117  })
118}
119