Before a team AI-DLC Unit is claimed, one dialog asks which design stages run and the approval rhythm (unit-end or per-stage), with a model's recommendation…

Before a team AI-DLC Unit is claimed, you choose which design stages it runs and how it is approved. You choose in one dialog, with a recommendation for each stage shown above it.
The agent opens the dialog, and the plugin's mod fills it in:
unit-of-work.md, its stories and each stage's own condition: from the project's stage graph. It splits the four stages (Functional Design, NFR Requirements, NFR Design, Infrastructure Design) into Recommended stages and Skip these, with a reason under each.aidlc unit claim --help): today unit-end (one plan page before the code and one page at the end, through the approval-gates plugin) and per-stage (an approval after every stage). No default is picked for you.What you type under Other is read by a second model (Haiku by default), so typos and short forms work ("funtional desgn, nfr reqs", "infra", "per stag"). An answer that names something that isn't a stage or rhythm, or names a stage twice, is refused. The refusal and a suggestion of what you probably meant are shown above the same two questions, asked again.
The Unit is then claimed with --rhythm <your choice> --skip-stages <the stages you left out>. The claim itself skips those stages for that Unit only, and the Unit starts at its first stage that runs. This needs AI-DLC engine 2.11.2-preview.20261026.101 or later. The plugin reads the claim's usage line from the project's own harness (.claude/tools/aidlc-unit.ts) to tell. On an older engine the Unit is claimed with --rhythm alone, and each left-out stage is skipped when the engine's directive reaches it:
aidlc engine orchestrate report --stage <slug> --unit <unit> --result skipped --reason "skipped at claim by the developer"
If you pick NFR Design without NFR Requirements, NFR Requirements is added back, and the agent tells you in one line. Nothing is saved across Units or claims: every claim asks. Your answer is used by the claim it was given for; once that claim goes through, the answer is marked used, and claiming the same Unit again later (after a release, or from another session) shows the dialog again.
hooks/register.tsx the mod: the dialog's questions, the recommendation above it, reading typed answers, recording
hooks/claim_guard.py the guard (PreToolUse, PostToolUse, UserPromptSubmit)
hooks/hooks.json registers both
scripts/claim_choice.py the stage conditions, the Unit's brief, the recorded answer, what the engine takes at a claim, the claim's flags
skills/unit-claim/SKILL.md the procedure the agent follows before a claim
A mod can ask only one question at a time on its own ($.ui.ask); AskUserQuestion is the model's. So the agent calls AskUserQuestion with metadata.source set to unit-claim:<unit> and a placeholder question. The mod catches that call, puts its two questions in, and draws the recommendation (or a refusal) above the dialog. Once you have answered, it records the answer with claim_choice.py record and adds one line for the agent: either recorded and claim with --rhythm, or refused and ask again. Nothing reads the session transcript.
aidlc unit claim <unit>, aidlc --claim <unit>, or aidlc-unit.ts claim <unit>. It is refused until that Unit has a recorded answer, and also when its --rhythm or --skip-stages differs from the answer (an engine without --skip-stages takes --rhythm alone). The refusal tells the agent how to open the dialog.claim_choice.py record from Bash, and writing an answer file by hand. An answer is recorded only by the mod, after you answer.aidlc engine state unit start of a stage the choice leaves out for that Unit. The refusal names the skip report to run instead.The hook does nothing outside an AI-DLC project (no aidlc/spaces). It never fails an after-call or prompt hook. The answer of each Unit is kept in ~/.claude/plugins/data/unit-claim/answers/<project>/<intent>/<unit>.json, the project being the main checkout, so a git worktree and its main checkout share one answer. UNIT_CLAIM_DIR changes that folder; the tests use it.
| Setting | Default | What it does |
|---|---|---|
Recommendation model (recommendModel) | claude-sonnet-5-5 | Writes the run or skip recommendation and a reason per stage |
Answer resolver model (resolveModel) | claude-haiku-5-5 | Reads stage names or a rhythm you typed under Other |
claude plugin marketplace add maksymskuibida/mskuibida-ai-tools
claude plugin install unit-claim@mskuibida-tooling
The dialog needs a Claude Code build that loads plugin mods ("modules" in hooks/hooks.json). Without the mod, the hook still refuses the claim, but nothing fills in the dialog.
unit-end, it builds the plan page and the unit-end page.python3 -m unittest discover -s plugins/unit-claim/tests
claude plugin test plugins/unit-claim
The first covers the hook and the script, with the Python 3 standard library only. The second runs the mod's tests (tests/dialog.test.ts) against the engine, with the dialog, the processes and the two models stood in for.
hooks/register.tsx 307 lines1import type { EngineInterface, Register } from 'claude-code'
2
3// The claim dialog of a team Unit. The engine keeps AskUserQuestion for the model (a mod asks one question at a time,
4// with $.ui.ask), so the agent opens it with a placeholder and `metadata.source` "unit-claim:<unit>"; this module puts
5// in its two questions (Stages, Rhythm), draws its stage recommendation above them, resolves what the developer typed
6// under Other, and records the answer with claim_choice.py. A refusal is drawn above the same questions, asked again.
7// claim_guard.py refuses the claim until an answer is recorded.
8
9const SOURCE = /^unit-claim:([A-Za-z0-9][A-Za-z0-9._-]*)$/
10const STAGES_LINE = /^Which design stages run for `([^`]+)`\?$/
11
12const STAGES = [
13 { slug: 'functional-design', name: 'Functional Design' },
14 { slug: 'nfr-requirements', name: 'NFR Requirements' },
15 { slug: 'nfr-design', name: 'NFR Design' },
16 { slug: 'infrastructure-design', name: 'Infrastructure Design' },
17] as const
18const NAMES: readonly string[] = STAGES.map(s => s.name)
19const SUGGESTED = 'Suggested stages'
20const ALL = 'All four stages'
21// What each rhythm the plugin knows means; one the engine adds later is offered with its name alone.
22const RHYTHM_NOTES: Record<string, string> = {
23 'unit-end': 'Design stages run without a stop; you look at the plan page, then the unit-end page',
24 'per-stage': 'An approval after every stage',
25}
26
27type Advice = { stage: string; why: string }
28type Recommendation = { run: Advice[]; skip: Advice[] } | { error: string }
29type Resolved = { ok: string[] } | { refused: string; suggestion?: string }
30type Refusal = { refused: string; suggestion?: string }
31type Claim = { recommendation: Recommendation; rhythms: string[]; refusals: Refusal[] }
32
33// Per Unit, while its dialog is open or asked again: the recommendation and the engine's rhythms are read once.
34const claims = new Map<string, Claim>()
35
36const stagesQuestion = (unit: string) => `Which design stages run for \`${unit}\`?`
37const rhythmQuestion = (unit: string) => `Which approval rhythm for \`${unit}\`?`
38
39async function run($: EngineInterface, argv: string[], cwd: string): Promise<{ code: number | null; out: string; err: string }> {
40 const proc = $.process.spawn({ argv, cwd })
41 let out = ''
42 let err = ''
43 for await (const { stream, text } of proc) {
44 if (stream === 'stdout') out += text
45 else err += text
46 }
47 const done = await proc.result
48
49 return { code: done.code, out, err }
50}
51
52function jsonOf(text: string): unknown {
53 try {
54 return JSON.parse(/\{[\s\S]*\}/.exec(text)?.[0] ?? '')
55 } catch {
56 return undefined
57 }
58}
59
60/** The rhythms the pinned engine accepts, read from its claim usage line by `claim_choice.py engine` (not by running
61 * `aidlc`, which fails while the project's pin is not registered); the plugin's two when it names none. */
62async function rhythmsOf($: EngineInterface, cwd: string): Promise<string[]> {
63 const script = `${$.plugin.root}/scripts/claim_choice.py`
64 const eng = await run($, ['python3', script, 'engine'], cwd).catch(() => ({ code: 1, out: '', err: '' }))
65 const found = ((jsonOf(eng.out) as { rhythms?: string[] } | undefined)?.rhythms ?? []).filter(Boolean)
66
67 // unit-end first: the rhythm the plugin exists for.
68 return (found.length >= 2 ? found : Object.keys(RHYTHM_NOTES)).sort((a, b) => Number(b === 'unit-end') - Number(a === 'unit-end'))
69}
70
71async function recommend($: EngineInterface, model: string, unit: string, cwd: string): Promise<Recommendation> {
72 const script = `${$.plugin.root}/scripts/claim_choice.py`
73 const brief = await run($, ['python3', script, 'brief', '--unit', unit], cwd).catch(why => ({ code: 1, out: '', err: String(why) }))
74 if (brief.code !== 0) return { error: `the Unit's brief could not be read (${brief.err.trim() || 'no output'})` }
75 const reply = await $.model.complete({
76 model,
77 maxTokens: 1200,
78 system:
79 'You decide which AI-DLC design stages a Unit of work needs. Judge each stage only against its own condition and ' +
80 'what the Unit delivers. A stage that needs another (NFR Design needs NFR Requirements) is skipped when that one is.',
81 prompt: [
82 'The Unit, its stories and the four design stages with their conditions, as JSON:',
83 brief.out,
84 '',
85 'Put every one of the four stages in exactly one list: run or skip. Give each a reason of one plain sentence, at',
86 'most 20 words, saying what in this Unit makes the condition true or false. Answer with JSON only:',
87 '{"run": [{"stage": "Functional Design", "why": "..."}], "skip": [{"stage": "NFR Requirements", "why": "..."}]}',
88 ].join('\n'),
89 })
90 if (!reply.isAnswered) return { error: `the recommendation could not be made (${reply.reason})` }
91 const json = jsonOf(reply.text) as { run?: Advice[]; skip?: Advice[] } | undefined
92 const listed = [...(json?.run ?? []), ...(json?.skip ?? [])].map(a => a.stage)
93 if (!json || listed.length !== 4 || NAMES.some(name => !listed.includes(name))) {
94 return { error: 'the recommendation did not name each design stage once' }
95 }
96
97 return { run: json.run ?? [], skip: json.skip ?? [] }
98}
99
100function resolverPrompt(stages: string | undefined, rhythm: string | undefined, rhythms: string[]): string {
101 return [
102 'Resolve what a developer typed into known options. They may misspell, abbreviate or use other words.',
103 `Valid stages: ${NAMES.join(', ')}.`,
104 `Valid rhythms: ${rhythms.join(', ')}.`,
105 '',
106 'Stages: split the input into the parts it names. Map a part to a stage only when it clearly means that stage.',
107 'Refuse the whole input when a part maps to no stage ("Refused: invalid options: <those parts>") or when a stage is',
108 'named more than once ("Refused: duplicated: <stage>:<count>" for each). "none" or "no stages" is an empty list.',
109 'Rhythm: map the input to one rhythm only when it clearly means it; otherwise refuse and say why.',
110 'With every refusal give a suggestion: what they probably meant, in valid option names.',
111 '',
112 'Answer with JSON only, keys only for the inputs given:',
113 '{"stages": {"ok": ["Functional Design"]} or {"refused": "Refused: ...", "suggestion": "..."},',
114 ' "rhythm": {"ok": ["unit-end"]} or {"refused": "Refused: ...", "suggestion": "..."}}',
115 '',
116 ...(stages !== undefined ? [`Stages input: ${JSON.stringify(stages)}`] : []),
117 ...(rhythm !== undefined ? [`Rhythm input: ${JSON.stringify(rhythm)}`] : []),
118 ].join('\n')
119}
120
121async function resolve(
122 $: EngineInterface,
123 model: string,
124 stages: string | undefined,
125 rhythm: string | undefined,
126 rhythms: string[],
127): Promise<{ stages?: Resolved; rhythm?: Resolved }> {
128 const failed: Resolved = { refused: 'Refused: the typed answer could not be resolved; please answer again.' }
129 const reply = await $.model.complete({ model, prompt: resolverPrompt(stages, rhythm, rhythms), maxTokens: 400 })
130 const json = reply.isAnswered ? (jsonOf(reply.text) as { stages?: Resolved; rhythm?: Resolved } | undefined) : undefined
131 const out: { stages?: Resolved; rhythm?: Resolved } = {}
132 if (stages !== undefined) {
133 const s = json?.stages ?? failed
134 // The resolver is held to the four stages.
135 out.stages = 'ok' in s && s.ok.some(name => !NAMES.includes(name)) ? failed : s
136 }
137 if (rhythm !== undefined) {
138 const r = json?.rhythm ?? failed
139 // And to the engine's own rhythms.
140 out.rhythm =
141 'ok' in r && !rhythms.includes(r.ok[0] ?? '')
142 ? { refused: `Refused: "${rhythm}" is not a rhythm of the pinned engine (${rhythms.join(', ')}).` }
143 : r
144 }
145
146 return out
147}
148
149function questions(unit: string, claim: Claim) {
150 const rec = claim.recommendation
151 const suggested = 'run' in rec ? rec.run.map(a => a.stage) : []
152
153 return [
154 {
155 question: stagesQuestion(unit),
156 header: 'Stages',
157 multiSelect: false,
158 options: [
159 { label: SUGGESTED, description: 'run' in rec ? suggested.join(', ') || 'No design stage' : `No suggestion: ${rec.error}` },
160 { label: ALL, description: NAMES.join(', ') },
161 ],
162 },
163 {
164 question: rhythmQuestion(unit),
165 header: 'Rhythm',
166 multiSelect: false,
167 options: claim.rhythms.slice(0, 4).map(r => ({ label: r, description: RHYTHM_NOTES[r] ?? `The engine's ${r} rhythm` })),
168 },
169 ]
170}
171
172function reasoning(rec: Recommendation): string {
173 if ('error' in rec) return `# Suggested design stages\n\nNo suggestion: ${rec.error}. Pick the stages yourself.`
174 const block = (list: Advice[]) => (list.length ? list.map(a => `### ${a.stage}\n${a.why}`).join('\n') : 'None.')
175
176 return ['# Suggested design stages', '## Recommended stages', block(rec.run), '## Skip these', block(rec.skip)].join('\n')
177}
178
179async function settle(
180 $: EngineInterface,
181 model: string,
182 unit: string,
183 claim: Claim,
184 answers: Record<string, string>,
185 cwd: string,
186): Promise<string> {
187 const rec = claim.recommendation
188 const pick = answers[stagesQuestion(unit)] ?? ''
189 const pace = answers[rhythmQuestion(unit)] ?? ''
190 const typedStages = pick === SUGGESTED || pick === ALL ? undefined : pick
191 const typedRhythm = claim.rhythms.includes(pace) ? undefined : pace
192 const got =
193 typedStages === undefined && typedRhythm === undefined ? {} : await resolve($, model, typedStages, typedRhythm, claim.rhythms)
194
195 claim.refusals = [got.stages, got.rhythm].filter((r): r is Refusal => !!r && 'refused' in r)
196 if (pick === SUGGESTED && 'error' in rec) {
197 claim.refusals.unshift({ refused: 'Refused: there is no suggestion to take.', suggestion: `Pick "${ALL}" or type the stages.` })
198 }
199 if (claim.refusals.length) {
200 return (
201 `${claim.refusals.map(r => r.refused).join(' ')} Call AskUserQuestion again exactly as before (metadata ` +
202 `{"source": "unit-claim:${unit}"}, the same placeholder); the mod shows the refusal above the same questions. ` +
203 'Say nothing else.'
204 )
205 }
206
207 const names =
208 pick === SUGGESTED ? ('run' in rec ? rec.run.map(a => a.stage) : [])
209 : pick === ALL ? [...NAMES]
210 : got.stages && 'ok' in got.stages ? got.stages.ok
211 : []
212 const rhythm = got.rhythm && 'ok' in got.rhythm ? (got.rhythm.ok[0] ?? pace) : pace
213 const slugs = STAGES.filter(s => names.includes(s.name)).map(s => s.slug)
214 const script = `${$.plugin.root}/scripts/claim_choice.py`
215 const recorded = await run(
216 $,
217 ['python3', script, 'record', '--unit', unit, '--stages', slugs.join(',') || 'none', '--rhythm', rhythm],
218 cwd,
219 ).catch(why => ({ code: 1, out: '', err: String(why) }))
220 if (recorded.code !== 0) {
221 return `The answer could not be recorded (${recorded.err.trim() || 'no output'}). Tell the developer in one line; do not claim.`
222 }
223 claims.delete(unit)
224 const { choice, notes, claimFlags } = (jsonOf(recorded.out) ?? {}) as {
225 choice?: { stages: string[] }
226 notes?: string[]
227 claimFlags?: string
228 }
229 const chosen = STAGES.filter(s => choice?.stages.includes(s.slug)).map(s => s.name)
230
231 return (
232 `Recorded for ${unit}: ${chosen.join(', ') || 'no design stage'}; rhythm ${rhythm}. ` +
233 (notes?.length ? `Tell the developer in one line: ${notes.join(' ')} ` : '') +
234 `Claim with \`${claimFlags || `--rhythm ${rhythm}`}\` added to the engine's claim command.`
235 )
236}
237
238export const register: Register = (on, options) => {
239 const recommendModel = String(options.recommendModel ?? '') || 'claude-sonnet-5-5'
240 const resolveModel = String(options.resolveModel ?? '') || 'claude-haiku-5-5'
241
242 on('tool.call', { tool: 'AskUserQuestion' }, async ($, e, next) => {
243 const unit = SOURCE.exec(e.metadata?.source ?? '')?.[1]
244 if (!unit) return next(e)
245 let cwd: string
246 let claim = claims.get(unit)
247 try {
248 cwd = await $.session.cwd()
249 if (!claim) {
250 const [recommendation, rhythms] = await Promise.all([recommend($, recommendModel, unit, cwd), rhythmsOf($, cwd)])
251 claim = { recommendation, rhythms, refusals: [] }
252 claims.set(unit, claim)
253 }
254 } catch (why) {
255 return { deny: `unit-claim could not open the claim dialog for ${unit} (${String(why)}). Tell the developer.` }
256 }
257 const ran = await next({ ...e, questions: questions(unit, claim) })
258 if (ran.deny !== undefined) return ran
259 const answers = (ran.result as { answers?: Record<string, string> } | undefined)?.answers ?? {}
260 const line = await settle($, resolveModel, unit, claim, answers, cwd).catch(
261 why => `The answer could not be settled (${String(why)}). Ask the claim dialog again.`,
262 )
263
264 return { ...ran, context: [...(ran.context ?? []), `unit-claim: ${line}`] }
265 })
266
267 on('ui.render', { component: 'AskUserQuestion' }, async ($, e, next) => {
268 const engine = await next(e)
269 const first = e.props.questions[0] as { question?: string } | undefined
270 const claim = claims.get(STAGES_LINE.exec(first?.question ?? '')?.[1] ?? '')
271 if (!claim) return engine
272
273 const els = $.ui.resolve(e)
274 const { Box, Text } = els
275 const rec = claim.recommendation
276 if (e.surface === 'terminal' || !('Markdown' in els)) {
277 const lines = claim.refusals.length
278 ? claim.refusals.flatMap(r => [r.refused, ...(r.suggestion ? [`Suggestion: ${r.suggestion}`] : [])])
279 : 'error' in rec
280 ? [`No suggestion: ${rec.error}.`]
281 : [`Run: ${rec.run.map(a => a.stage).join(', ') || 'none'}`, `Skip: ${rec.skip.map(a => a.stage).join(', ') || 'none'}`]
282
283 return (
284 <Box flexDirection="column">
285 {lines.map(line => (
286 <Text color={claim.refusals.length ? 'red' : undefined} dimColor={!claim.refusals.length}>
287 {line}
288 </Text>
289 ))}
290 {engine}
291 </Box>
292 )
293 }
294 const { Markdown } = els
295 const text = claim.refusals.length
296 ? claim.refusals.map(r => `**${r.refused}**${r.suggestion ? `\n\nSuggestion: ${r.suggestion}` : ''}`).join('\n\n')
297 : reasoning(rec)
298
299 return (
300 <Box flexDirection="column">
301 <Markdown text={text} />
302 {engine}
303 </Box>
304 )
305 })
306}
307