SLOPSHOPPER

Unit claim

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…

newrowsguardmodelprocess
A shopper browsing a rack in a slop shop
README

unit-claim

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:

  • Above the dialog: suggested design stages. A model (Sonnet by default) reads the Unit's entry 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.
  • Stages: "Suggested stages", "All four stages", or type your own under Other.
  • Rhythm: the rhythms the project's pinned engine accepts (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

How the dialog is opened

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.

What the hook refuses

  • The claim: 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.
  • Recording an answer by the agent: 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.
  • A claim whose Unit can't be read, such as options before the Unit that the hook doesn't know.

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.

Settings

SettingDefaultWhat it does
Recommendation model (recommendModel)claude-sonnet-5-5Writes the run or skip recommendation and a reason per stage
Answer resolver model (resolveModel)claude-haiku-5-5Reads stage names or a rhythm you typed under Other

Install

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.

Pairs with

  • approval-gates: under unit-end, it builds the plan page and the unit-end page.
  • story-points: it checks that a Unit is estimated before it is claimed.

Tests

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.

Source 1 files
hooks/register.tsx 307 lines
1import 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