When review findings reach the developer (an AI-DLC stage review at its approval gate, or a code review), shows each open finding in plain words with a…

When review findings reach you, you decide what happens to them before anything else does. The agent shows each open finding in plain words, with a suggested action, and asks one question: Fix, Accept (only when nothing blocks) or Open in form. A hook keeps that order.
Two sources, nothing else in this version:
aidlc engine review-brief review --stage …). Open findings are the rows with status New or Unresolved; Critical and Major block. The brief must be printed unpiped and in full: a piped or redirected call is refused, and a cut-off output (table without the > R-NN Finding: lines) is not recorded as findings./code-review reports its findings through the ReportFindings tool. Every finding is open; those marked CONFIRMED block.skills/review-findings/SKILL.md the procedure: the summary, the question, what each answer means, the board form
skills/release/SKILL.md /review-findings:release
scripts/findings.py parses a brief, a review-NN.md or a ReportFindings input; renders the summary and the board rows
scripts/aidlc_ids.py the shared id map (copy of shared/aidlc-ids/aidlc_ids.py): the board's ids tooltips
hooks/findings_guard.py the guard (PreToolUse, PostToolUse, UserPromptSubmit)
hooks/register.tsx the mod: builds the question from the saved findings and draws them above the dialog
The findings in plain words, in chat and again above the question (the mod draws them in the dialog's frame: Markdown on desktop, up to 48 rows; plain lines on the terminal, up to 12; what does not fit is counted on one line that points at the form). No finding ids, rule codes or file:line in the sentences:
## Review findings · Domain Design
2 open, 1 blocking. Choose below what happens to them.
### 1 · The `Cart` rule `checkout | expire` is ambiguous.
- **Blocking** · domain model › Cart aggregate, invariants list
- Two events can fire on the same cart, and the model does not say which one wins. …
- **Suggested:** State the winning event. Add one line on what happens to reserved stock.
### 2 · The Events table lists `OrderPlaced` but the flow text calls it `CheckoutDone`.
- **Not blocking** · domain model › Events
- **Suggested:** Use one name for the order event in the table and the flow text.
2 review findings are open (1 blocking). What should I do with them? Fix / Open in form
The suggested action is the reviewer's required action. Where the reviewer named none (a code review's findings, an AI-DLC finding without a Required action: line), the mod asks a model for one sentence (suggestModel, Haiku by default; off keeps a fixed line), once per round, and keeps it only when it carries no id. Another finding's id in the text becomes "finding 2"; a path with a line becomes the file's name.
question-forms plugin): one card per finding, in the boxes Fix, Accept and Reject (reason required), each card starting where its severity says. You sort them and press Done; the agent shows one line per card and acts on it. Rejected cards go on the rejected report as --reject-finding.While findings are waiting for the first answer:
aidlc engine orchestrate report --result approved|rejected;git commit|push.And always:
--reason misses an open finding id;status: "done";findings.py answers ("## Review Findings: User Answer", one ### <id>: Fix|Accept|Reject per finding, with #### Action items: and #### User Note: where they apply) and matches the recorded answers;status: "done" on a findings form (only your press on Done does that).A refusal ends with how to release it. Type /review-findings:release to clear the guard for the session. If python3 is missing, the hook refuses guarded calls only while a state file exists, and says so.
State is in ~/.claude/review-findings/<session>.json (REVIEW_FINDINGS_DIR moves it) next to the findings file the agent renders from (<session>.findings). The hook changes it; the agent does not.
findings.py turns every source into one findings document:
{"source": "aidlc", "stage": "domain-design", "stage_name": "Domain Design", "artifact": "<path>",
"findings": [{"id": "R-02", "severity": "Major", "where": "…", "finding": "…", "action": "…",
"status": "New", "blocking": true, "title": "…"}]}
findings.py parse --brief FILE | --review FILE | --report FILE [--stage SLUG] [--unit U]
findings.py summary --findings FILE
findings.py question --findings FILE
findings.py answers --findings FILE [--choice fix|accept | --form FILE] (the summary of the answers)
findings.py dialog --findings FILE | --session ID (what the mod draws and asks, as JSON)
findings.py suggest --findings FILE --set JSON (records the model's suggested actions)
findings.py board --findings FILE [--id ID] [--title T] [--project-dir D] --out DIR
board writes forms.json, cards/<id>.json and writes.json for one ArtifactData batch: the form forms/<id> (view: "board", three boxes) and a forms/<id>/questions/<finding id> card per finding, in the contract of the question-forms template. Each card's ctx is the plain words (What's wrong, Suggested, Where); the card also carries the optional fields plain (what the finding means, after its title), action (the suggested action), detail (ctx without those two) and refs for a page that draws them apart:
"refs": [{"label": "domain model › Cart aggregate", "tip": "domain-model.md > Cart aggregate",
"target": "doc:domain-model.md > Cart aggregate"},
{"label": "finding 2", "tip": "R-04, card 2 in this form", "target": "#R-04"}]
A #<qid> target is another card of the same form, a live link; a doc:, path: or finding: target is something the form does not hold (a document section, a file, a finding closed before this round), and tip is all that is known of it.
In an AI-DLC project board also writes the form's ids, {id: {kind, title, text, doc, line}} for every id the cards mention, from the project's shared id map (scripts/aidlc_ids.py, a copy of shared/aidlc-ids/aidlc_ids.py; scripts/sync-shared.py keeps it the same). It syncs the map first and picks each id's definition nearest the reviewed document. The page draws every such id as a link with a tooltip; an id no document defines is shown as not defined. Outside an AI-DLC project the form has no ids, and the output's ids_note says why. The form id is <intent>-<stage>[-<unit>]-findings-<YYYYMMDDTHHMM>, or code-review-<YYYYMMDDTHHMM>.
claude plugin marketplace add maksymskuibida/mskuibida-ai-tools
claude plugin install review-findings@mskuibida-tooling
question-forms for the board. Without it the Fix and Accept answers work as described; Open in form needs the artifact and the template from that plugin.approval-gates, which treats the same Approval question as the engine's gate. This plugin stands before it: the findings are answered first, the gate comes after.python3 -m unittest discover -s plugins/review-findings/tests
claude plugin validate plugins/review-findings
claude plugin test plugins/review-findings
The Python suites use the standard library only; no network and no aidlc binary needed. The mod's tests (tests/dialog.test.tsx) stand in for the engine, findings.py and the model.
hooks/register.tsx 157 lines1import type { EngineInterface, Register } from 'claude-code'
2
3// The triage question of review findings (header Findings: Fix / Accept / Open in form). The agent asks it with
4// `metadata.source` "review-findings" (`findings.py question` prints it so); this module puts in the question built
5// from the findings the guard saved for this session, has a model write a suggested action for each finding the
6// reviewer named none for, and draws every open finding in plain words above the dialog. findings_guard.py still
7// checks the question and records the answer; with no saved findings the call passes untouched.
8
9const SOURCE = 'review-findings'
10const HEADER = 'Findings'
11const FINDING_ID = /\b[RF]-\d+\b/
12
13type Question = { question: string; header: string; options: { label: string; description: string }[]; multiSelect: boolean }
14type Item = { id: string; n: number; title: string; what: string; where: string; scenario: string }
15type Dialog = {
16 findings: string
17 question: { questions: Question[] }
18 markdown: string
19 lines: string[]
20 needs_suggestion: string[]
21 items: Item[]
22}
23
24// What the last triage call asked, by its question text: the render hook draws it above that dialog.
25const shown = new Map<string, Dialog>()
26// The findings each suggestion was asked for once (file and ids), so asking again does not wait on the model again.
27const asked = new Set<string>()
28
29async function run($: EngineInterface, argv: string[], cwd: string): Promise<{ code: number | null; out: string; err: string }> {
30 const proc = $.process.spawn({ argv, cwd })
31 let out = ''
32 let err = ''
33 for await (const { stream, text } of proc) {
34 if (stream === 'stdout') out += text
35 else err += text
36 }
37 const done = await proc.result
38
39 return { code: done.code, out, err }
40}
41
42function jsonOf(text: string): unknown {
43 try {
44 return JSON.parse(/\{[\s\S]*\}/.exec(text)?.[0] ?? '')
45 } catch {
46 return undefined
47 }
48}
49
50function isTriage(q: { header?: string; options?: { label?: string }[] }): boolean {
51 const labels = (q.options ?? []).map(o => String(o.label ?? '').trim().toLowerCase())
52
53 return String(q.header ?? '').trim().toLowerCase() === 'findings' && labels.includes('fix') && labels.includes('open in form')
54}
55
56/** The dialog findings.py builds from the saved findings; undefined when there are none or they cannot be read. */
57async function dialogOf($: EngineInterface, args: string[], cwd: string): Promise<Dialog | undefined> {
58 const script = `${$.plugin.root}/scripts/findings.py`
59 const got = await run($, ['python3', script, 'dialog', ...args], cwd).catch(() => undefined)
60 if (!got || got.code !== 0) return undefined
61 const dialog = jsonOf(got.out) as Dialog | undefined
62
63 return dialog?.question?.questions?.length && typeof dialog.markdown === 'string' ? dialog : undefined
64}
65
66/** Asks the model for a suggested action per finding that has none; keeps only plain, short, id-free answers. */
67async function suggestions($: EngineInterface, model: string, dialog: Dialog): Promise<Record<string, string>> {
68 const wanted = dialog.items.filter(i => dialog.needs_suggestion.includes(i.id))
69 const reply = await $.model.complete({
70 model,
71 maxTokens: 800,
72 system:
73 'You turn review findings into one suggested action each, for a developer who decides whether to fix them. ' +
74 'Stay with what the finding says; never add a requirement it does not imply.',
75 prompt: [
76 'The findings, as JSON (id, title, what is wrong, where, what goes wrong when it is left):',
77 JSON.stringify(wanted.map(i => ({ id: i.id, title: i.title, what: i.what, where: i.where, scenario: i.scenario }))),
78 '',
79 'For each, write one plain sentence, at most 25 words, that starts with a verb and says what to change. No ids,',
80 'no file paths, no line numbers. Answer with JSON only: {"<id>": "<suggested action>"}',
81 ].join('\n'),
82 })
83 if (!reply.isAnswered) return {}
84 const json = (jsonOf(reply.text) ?? {}) as Record<string, unknown>
85 const out: Record<string, string> = {}
86 for (const { id } of wanted) {
87 const text = typeof json[id] === 'string' ? json[id].replace(/\s+/g, ' ').trim() : ''
88 if (text && text.length <= 300 && !FINDING_ID.test(text)) out[id] = text
89 }
90
91 return out
92}
93
94async function open($: EngineInterface, model: string): Promise<Dialog | undefined> {
95 const cwd = await $.session.cwd()
96 const dialog = await dialogOf($, ['--session', await $.session.id()], cwd)
97 if (!dialog || !dialog.needs_suggestion.length || !model) return dialog
98 const once = `${dialog.findings}#${dialog.needs_suggestion.join(',')}`
99 if (asked.has(once)) return dialog
100 asked.add(once)
101 const found = await suggestions($, model, dialog).catch(() => ({}))
102 if (!Object.keys(found).length) return dialog
103 const script = `${$.plugin.root}/scripts/findings.py`
104 const set = await run($, ['python3', script, 'suggest', '--findings', dialog.findings, '--set', JSON.stringify(found)], cwd).catch(
105 () => undefined,
106 )
107 if (!set || set.code !== 0) return dialog
108
109 return (await dialogOf($, ['--findings', dialog.findings], cwd)) ?? dialog
110}
111
112export const register: Register = (on, options) => {
113 const configured = String(options.suggestModel ?? '').trim()
114 const model = configured === 'off' ? '' : configured || 'claude-haiku-5-5'
115
116 on('tool.call', { tool: 'AskUserQuestion' }, async ($, e, next) => {
117 if (e.metadata?.source !== SOURCE && !e.questions.some(isTriage)) return next(e)
118 const dialog = await open($, model).catch(() => undefined)
119 if (!dialog) return next(e)
120 const questions = dialog.question.questions
121 shown.clear()
122 shown.set(questions[0]?.question ?? '', dialog)
123
124 return next({ ...e, questions })
125 })
126
127 on('ui.render', { component: 'AskUserQuestion' }, async ($, e, next) => {
128 const engine = await next(e)
129 const first = e.props.questions[0] as { question?: string; header?: string } | undefined
130 const dialog = shown.get(first?.question ?? '')
131 if (!dialog || first?.header !== HEADER) return engine
132
133 const els = $.ui.resolve(e)
134 const { Box, Text } = els
135 if (e.surface === 'terminal' || !('Markdown' in els)) {
136 return (
137 <Box flexDirection="column">
138 {dialog.lines.map((line, i) => (
139 <Text bold={i === 0} dimColor={i > 0 && line.startsWith(' ')}>
140 {line}
141 </Text>
142 ))}
143 {engine}
144 </Box>
145 )
146 }
147 const { Markdown } = els
148
149 return (
150 <Box flexDirection="column">
151 <Markdown text={dialog.markdown} />
152 {engine}
153 </Box>
154 )
155 })
156}
157