Pane d'avancement d'un lot /implement-tdd : étapes RED/GREEN/COUT, corrections Cn, vague, durées, pré-audit, verdict, BLOQUÉ, lus depuis la fiche de lot

A Claude Code plugin, cctoolkit: what I install in a DDD / clean-architecture repo so that Claude Code works the way I want from the first session — TDD skills, their agents, guard and traceability hooks, layout presets, layer rules, statusline. .NET first; the layout and the test tag adapt to Python, Java/Kotlin and TypeScript through kit.config.json.
This checkout is both the plugin and its marketplace (.claude-plugin/). Nothing to build: only .md, .sh, Python and JSON.
Two commands, then one skill does the rest:
claude plugin marketplace add pierrebelin/claude-code-toolkit
claude plugin install cctoolkit@cctoolkit --scope project
Restart Claude Code and run /cctoolkit:kit-init. A plugin cannot ship config, rules or settings — kit-init writes them into your repo: it reads your stack, asks one round of questions, and leaves the repo ready for /cctoolkit:business-spec. Details: Installation.
Four skills in a chain. Each step writes a file the next one reads — nothing travels through the conversation, and no step starts without the previous one's artefact.
flowchart LR
S["/business-spec<br/>one decision at a time"]
P["/plan-implementation<br/>splits into batches"]
I["/implement-tdd batch FX<br/>orchestrates the TDD"]
V["/verify-ddd-tdd<br/>audits, read-only"]
N(["next batch"])
S -->|"SPEC-feature.md<br/>RM-XX, CU-XX<br/>zero technical detail"| P
P -->|"PLAN.md<br/>+ one sheet per batch"| I
I -->|"green batch<br/>TDD evidence ticked"| V
V -->|"VALID"| N
V -->|"gaps"| I
N -.->|"next sheet"| I
/cctoolkit:business-spec — a short, testable business spec (RM-xx rules, CU-xx use cases), no technical design. An adversarial reviewer reads it fresh; every Blocking open question goes to you./cctoolkit:plan-implementation — turns the validated spec into a DDD plan split into batches, one sheet per batch./cctoolkit:implement-tdd batch F1 — implements one batch under strict TDD, behaviour by behaviour, Domain → Application → Infrastructure → WebAPI./cctoolkit:verify-ddd-tdd — read-only audit of the batch before the next one. VALID moves on; gaps go back to /implement-tdd in correction mode.Then /clear and the next batch.
Inside /implement-tdd, each behaviour is one loop:
flowchart LR
I["/implement-tdd"] --> R
R["RED"] -->|"compact contract:<br/>RM/CU, scenario, level"| A["tdd-test-author subagent<br/>writes the red test"]
A -->|"red test<br/>no production code"| R
R --> G["GREEN + REFACTOR"]
G -->|"compact contract:<br/>red test, signatures,<br/>invariants, expected cost"| B["tdd-implementer subagent<br/>writes the production code<br/>tests read-only"]
B -->|"green + cost stated<br/>or BLOCKED"| G
G --> C["COST<br/>validated by the orchestrator"]
C -.->|"next behaviour"| R
C ==>|"whole batch green"| V["/verify-ddd-tdd"]
The orchestrator writes neither tests nor code: it splits, reads the diffs, validates the cost and settles the design. How the delegation works: skills/README.md.
Side skills: /cctoolkit:quality-report (monthly snapshot), /cctoolkit:learn (turns recurring audit gaps into rules), /cctoolkit:bulk-read (a question over named files without loading them).
This kit encodes my way of working. Among other things it imposes strict TDD, zero comments in production, surgical changes, no git commit by the agent, and one rule sheet per handler folder checked against the tests (skills/README.md). On a project with other conventions, half of it is noise.
Requires Claude Code with plugin support, jq and python3.
bash claude plugin marketplace add pierrebelin/claude-code-toolkit claude plugin install cctoolkit@cctoolkit --scope project ` Project scope writes the plugin into the committed .claude/settings.json: every clone and worktree gets it. --scope local keeps it to your checkout; a worktree then needs cctoolkit install-git-hooks (docs/TOOLING.md`). A local checkout of this repo works as the marketplace too./cctoolkit:kit-init from the repo root, after /clear. This is the step that matters: the plugin brings skills, agents and hooks, but without kit-init the repo has no kit.config.json, no rules and no permissions, and the hooks assume the clean-architecture layout, whatever yours is. Don't set the repo up by hand.claude plugin install context-band@cctoolkit --scope project, same for tdd-batch, see mods/README.md) and the startup trim (templates/README.md). Add graphify-out/ to the repo's .gitignore./cctoolkit:kit-init does| Step | What happens |
|---|---|
| Detect | cctoolkit detect-stack and cctoolkit doctor: manifests, test framework, product name guess, use-case folders each preset recognises in your tree |
| Migrate | a manual copy of the kit found in .claude/ → saves its local edits, then removes it on confirmation; the repo's own skills, scripts, config and rules stay. Commit the deletions and the settings change together |
| Choose | one round of questions: the preset (ranked on your real folders), the documents' language, the code language, the product when ambiguous |
| Config | writes .claude/kit.config.json — only what differs from the preset — validates it, offers migrate-anchors on specs, plans and handler sheets older than the <!-- kit:… --> anchors |
| Rules | copies the universal rules and the preset's layer rules into .claude/rules/, {{PRODUCT}} substituted, dead paths: globs rewritten from your layout. No preset fits? It drafts one rule per layer from your most-changed files, each convention cited file:line, marked "review before trusting" |
| Settings | merges permissions and env into .claude/settings.json, installs the statusline, completes .claude/.gitignore |
| Report | applies the doctor's mechanical fixes, re-runs cctoolkit doctor, prints one summary table and the next step |
It never overwrites a file that differs: it shows the difference and asks keep / replace / merge. It never commits. Re-run it whenever you want to change the preset or the language, or pull a rule changed by a release. Configuration keys: presets/README.md.
jq and python3 are required. The rest degrades cleanly — every hook exits 0 when its tool is missing, except the guards that block by design (hooks/README.md).
| Tool | Used for | If missing |
|---|---|---|
perl | evals/run.sh timer, tools/bulk-read, tools/doctor | no latency budget in the evals |
claude CLI, logged in | cctoolkit bulk-read | falls back to a bounded read |
graphify | blast radius on aggregate edits, graph autosync | silently skipped |
rtk | output compression of Bash commands | drop the rtk prefix from kit.config.json commands |
caveman plugin | terse mode forced in some skills | no effect |
claude plugin marketplace update cctoolkit
claude plugin update cctoolkit@cctoolkit
Restart Claude Code, then cctoolkit doctor. Nothing in the repo changes: kit.config.json, .claude/rules/ and the settings stay as they are. A release changing a rule says so in its notes — re-run /cctoolkit:kit-init to pull it: it shows each difference and asks. Never edit the plugin cache (cctoolkit root): the next update replaces it. A change to the kit itself goes into a checkout of this repo, then reaches every repo through the update.
| Folder | What | Details |
|---|---|---|
skills/, agents/ | the 12 skills and the 4 subagents they delegate to | skills/README.md |
hooks/ | guards, bounds, traceability, context nudges, wired in hooks.json | hooks/README.md |
lib/ | modules the hooks call, config and test-tag readers | lib/README.md |
scripts/, bin/ | repo-wide scanners and one-shots, run as cctoolkit <name> | scripts/README.md |
tools/ | bulk-read, doctor | tools/README.md |
presets/, rules/ | layouts, kit.config.json, layer and universal rules | presets/README.md |
templates/ | settings, statusline, .gitignore merged into the repo | templates/README.md |
evals/ | recorded hook cases | evals/README.md |
mods/ | optional in-terminal panes | mods/README.md |
docs/ | context cost, tooling | CONTEXT-COST.md, TOOLING.md |
agents/ and rules/ carry no README of their own: the plugin loads every agents/*.md as an agent, and /cctoolkit:kit-init copies every rules/*.md into the repo.
Other repos configuring a coding agent: RESOURCES.md.
MIT. Take what you want, closed-source projects included.
hooks/register.tsx 454 lines1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register } from 'claude-code'
3
4import type { AgentKind, Awaited, Batch, Correction, Gate, Phase, Sheet, Step, Verdict } from '../types'
5
6const TICK_MS = 10_000
7const TODO = 'todo'
8const DONE = '✅'
9const BLOCKED_LITERALS = ['## BLOQUÉ', '## BLOCKED']
10const SHEET_PATTERN = /-PLAN-(F\d+)\.md$/
11const AGENT_KINDS: Record<string, 'RED' | 'GREEN'> = {
12 'tdd-test-author': 'RED',
13 'tdd-implementer': 'GREEN',
14}
15const TDD_SKILL = 'implement-tdd'
16const AUDIT_SKILL = 'verify-ddd-tdd'
17// `cctoolkit pre-audit` since the plugin, `scripts/pre-audit.sh` before it.
18const GATE_SCRIPT = 'pre-audit'
19// Skills, agents and commands of the cctoolkit plugin arrive namespaced.
20const bare = (name: unknown): string => (typeof name === 'string' ? name.replace(/^cctoolkit:/, '') : '')
21const TYPICAL_MS: Record<AgentKind, number> = { RED: 224_000, GREEN: 187_000, AUDIT: 309_000 }
22const SLOW_FACTOR = 2
23
24const EMPTY: Batch = {
25 path: null,
26 root: null,
27 sheet: null,
28 error: null,
29 wave: 0,
30 running: [],
31 blocked: [],
32 startedAt: null,
33 gate: null,
34 audit: { passes: 0, verdict: null },
35}
36
37const batch = atom({ plugin: 'tdd-batch', key: 'batch' } as const, EMPTY)
38const now = atom({ plugin: 'tdd-batch', key: 'now' } as const, 0)
39const isExpanded = atom({ plugin: 'tdd-batch', key: 'isExpanded' } as const, false)
40const awaited = atom({ plugin: 'tdd-batch', key: 'awaited' } as const, null as Awaited | null)
41
42const complete = (stored: Batch): Batch => ({ ...EMPTY, ...stored })
43
44const fileOf = (state: Batch): string | null =>
45 state.path === null || state.root === null || state.path.startsWith('/') ? state.path : `${state.root}/${state.path}`
46
47export const isPhaseDone = (tdd: Phase | null): boolean =>
48 tdd !== null && tdd.red === DONE && tdd.green === DONE && tdd.cost === DONE
49
50export const isStepDone = (step: Step): boolean =>
51 (step.isDone || isPhaseDone(step.tdd)) && step.corrections.every(one => isPhaseDone(one.tdd))
52
53export const parseSheet = (text: string, path: string): Sheet => {
54 const lines = text.split(/\r?\n/)
55 const title = lines.find(line => line.startsWith('# '))?.slice(2).trim() ?? ''
56 const lot = SHEET_PATTERN.exec(path)?.[1] ?? /PLAN-(F\d+)/.exec(title)?.[1] ?? '?'
57 const name = title
58 .replace(/\s+—\s+✅ DONE.*$/, '')
59 .split(' — ')
60 .slice(1)
61 .join(' — ')
62 const steps: Step[] = []
63 let section = ''
64 let hypotheses = 0
65
66 for (const line of lines) {
67 if (line.startsWith('## ')) {
68 section = line.slice(3).trim()
69 continue
70 }
71 if (/^(H[yi]poth[eè]ses|Assumptions)/i.test(section) && /^\s*(?:[-*]\s+)?\**H\d+\b/.test(line)) hypotheses += 1
72 if (!/^(D[eé]roulement TDD|TDD sequence)/i.test(section)) continue
73
74 const step = /^### (?:[EÉ]tape|Step) (\S+) — (.+)$/.exec(line)
75 if (step) {
76 const heading = step[2] ?? ''
77 steps.push({
78 number: step[1] ?? '',
79 title: heading.replace(/\s+—\s+✅ DONE.*$/, ''),
80 isDone: heading.includes('✅ DONE'),
81 tdd: null,
82 corrections: [],
83 })
84 continue
85 }
86 const current = steps[steps.length - 1]
87 if (current === undefined) continue
88
89 if (line.startsWith('✅ DONE')) {
90 current.isDone = true
91 continue
92 }
93
94 const correction = /^(?:#{3,4} )?Correction (C\d+) — (.+)$/.exec(line)
95 if (correction) {
96 current.corrections.push({ id: correction[1] ?? '', finding: correction[2] ?? '', tdd: null })
97 continue
98 }
99 const tdd = /^TDD ?: RED (\S+) · GREEN (\S+) · CO[US]T (\S+)/.exec(line)
100 if (tdd) {
101 const phase: Phase = { red: tdd[1] ?? '', green: tdd[2] ?? '', cost: tdd[3] ?? '' }
102 const owner: Step | Correction = current.corrections[current.corrections.length - 1] ?? current
103 if (owner.tdd === null) owner.tdd = phase
104 }
105 }
106
107 const isDone = title.includes('✅ DONE') || (steps.length > 0 && steps.every(isStepDone))
108 return { lot, name, isDone, steps, hypotheses }
109}
110
111export const parseGate = (text: string): Gate | null => {
112 const outcome = /PRE-AUDIT — (?:lot|batch) \S+ : (VERT|ROUGE|GREEN|RED)\b/.exec(text)?.[1]
113 if (outcome === undefined) return null
114 const failures = text.split(/\r?\n/).filter(line => /^\s*✗ /.test(line)).length
115 return { isGreen: outcome === 'VERT' || outcome === 'GREEN', failures }
116}
117
118export const parseVerdict = (text: string): Verdict | null => {
119 const outcome = /## Verdict — (VALIDE|VALID|ECARTS|DEVIATIONS)\b/.exec(text)?.[1]
120 if (outcome === undefined) return null
121 const rows = (severity: RegExp): number =>
122 text.split(/\r?\n/).filter(line => severity.test(line)).length
123 return {
124 isValid: outcome === 'VALIDE' || outcome === 'VALID',
125 blocking: rows(/^\|\s*(Bloquant|Blocking)\s*\|/),
126 major: rows(/^\|\s*(Majeur|Major)\s*\|/),
127 }
128}
129
130export const duration = (ms: number): string => {
131 const seconds = Math.max(0, Math.floor(ms / 1000))
132 if (seconds < 60) return `${seconds} s`
133 const minutes = Math.floor(seconds / 60)
134 if (minutes < 60) return `${minutes} min ${String(seconds % 60).padStart(2, '0')}`
135 return `${Math.floor(minutes / 60)} h ${String(minutes % 60).padStart(2, '0')}`
136}
137
138export const isSlow = (kind: AgentKind, ms: number): boolean => ms > TYPICAL_MS[kind] * SLOW_FACTOR
139
140export const verdictLabel = (verdict: Verdict): string => {
141 if (verdict.isValid) return 'VALID'
142 const counts = [
143 verdict.blocking > 0 ? `${verdict.blocking} blocking` : '',
144 verdict.major > 0 ? `${verdict.major} major` : '',
145 ].filter(one => one !== '')
146 return counts.length === 0 ? 'DEVIATIONS' : `DEVIATIONS — ${counts.join(', ')}`
147}
148
149export const sheetFromArgs = (args: string): { path: string | null; lot: string | null } => {
150 const lot = /\blot\s+(F\d+)\b/i.exec(args)?.[1]?.toUpperCase() ?? null
151 const sheet = /(\S+-PLAN-F\d+\.md)\b/.exec(args)?.[1]
152 if (sheet !== undefined) return { path: sheet, lot }
153 const plan = /(\S+-PLAN\.md)\b/.exec(args)?.[1]
154 if (plan !== undefined && lot !== null) return { path: plan.replace(/-PLAN\.md$/, `-PLAN-${lot}.md`), lot }
155 return { path: null, lot }
156}
157
158let seen: { path: string; mtimeMs: number } | null = null
159
160async function hasChanged($: EngineInterface, path: string): Promise<boolean> {
161 try {
162 const { mtimeMs } = await $.fs.stat(path)
163 if (seen !== null && seen.path === path && seen.mtimeMs === mtimeMs) return false
164 seen = { path, mtimeMs }
165 } catch {
166 seen = null
167 }
168 return true
169}
170
171async function refresh($: EngineInterface, isForced = true): Promise<void> {
172 const current = complete(await read($, batch))
173 const file = fileOf(current)
174 if (current.path === null || file === null) return
175 if (!(await $.fs.exists(file))) {
176 await update($, batch, () => EMPTY)
177 return
178 }
179 if (!isForced && !(await hasChanged($, file))) return
180 let sheet: Sheet | null = null
181 let error: string | null = null
182 try {
183 sheet = parseSheet(await $.fs.read(file), current.path)
184 } catch {
185 error = `unreadable sheet: ${current.path}`
186 }
187 if (JSON.stringify(sheet) === JSON.stringify(current.sheet) && error === current.error) return
188 await update($, batch, stored => ({ ...complete(stored), sheet, error }))
189}
190
191async function follow($: EngineInterface, path: string): Promise<void> {
192 const root = await $.session.cwd()
193 const relative = path.startsWith(`${root}/`) ? path.slice(root.length + 1) : path
194 const at = await $.clock.now()
195 await update($, awaited, () => null)
196 await update($, batch, stored => {
197 const current = complete(stored)
198 return current.path === relative && current.root === root ? current : { ...EMPTY, path: relative, root, startedAt: at }
199 })
200 await refresh($)
201}
202
203async function followArgs($: EngineInterface, args: string): Promise<void> {
204 const asked = sheetFromArgs(args)
205 if (asked.path !== null) return follow($, asked.path)
206 await update($, awaited, () => ({ lot: asked.lot }))
207}
208
209async function onSheetTouched($: EngineInterface, path: string): Promise<void> {
210 const found = SHEET_PATTERN.exec(path)
211 const pending = await read($, awaited)
212 if (found !== null && pending !== null && path.includes(`${TODO}/`) && (pending.lot === null || pending.lot === found[1])) {
213 return follow($, path)
214 }
215 if (path === fileOf(complete(await read($, batch)))) await refresh($)
216}
217
218async function trackAgent($: EngineInterface, id: string, kind: AgentKind, description: string): Promise<void> {
219 const at = await $.clock.now()
220 await update($, batch, stored => {
221 const current = complete(stored)
222 const opensWave = kind === 'RED' && !current.running.some(one => one.kind === 'RED')
223 return {
224 ...current,
225 wave: opensWave ? current.wave + 1 : current.wave,
226 running: [...current.running, { id, kind, description, startedAt: at }],
227 audit: kind === 'AUDIT' ? { ...current.audit, passes: current.audit.passes + 1 } : current.audit,
228 }
229 })
230}
231
232async function recordGate($: EngineInterface, gate: Gate): Promise<void> {
233 await update($, batch, stored => ({ ...complete(stored), gate }))
234}
235
236async function recordVerdict($: EngineInterface, verdict: Verdict): Promise<void> {
237 await update($, batch, stored => {
238 const current = complete(stored)
239 return { ...current, audit: { ...current.audit, verdict } }
240 })
241}
242
243const tick = async ($: EngineInterface): Promise<void> => {
244 const at = await $.clock.now()
245 await update($, now, () => at)
246 await refresh($, false)
247}
248
249async function settleAgent($: EngineInterface, id: string, isBlocked: boolean): Promise<void> {
250 await update($, batch, stored => {
251 const current = complete(stored)
252 const run = current.running.find(one => one.id === id)
253 return {
254 ...current,
255 running: current.running.filter(one => one.id !== id),
256 blocked:
257 isBlocked && run !== undefined
258 ? [...current.blocked, { kind: run.kind, description: run.description }]
259 : current.blocked,
260 }
261 })
262}
263
264const mark = (value: string): string => (value === '' ? '?' : value)
265
266export const phaseLabel = (tdd: Phase | null): string =>
267 tdd === null ? '—' : `RED ${mark(tdd.red)} · GREEN ${mark(tdd.green)} · COUT ${mark(tdd.cost)}`
268
269export const register: Register = on => {
270 on('session.start', async ($, e, next) => {
271 await $.command.register({
272 name: 'tdd-batch',
273 description: '/implement-tdd batch progress band, shown once /implement-tdd runs in this session (off hides it)',
274 })
275 await $.ui.close({ id: 'tdd-batch' }).catch(() => undefined)
276 $.clock.every(TICK_MS, () => void tick($))
277 await tick($)
278
279 return next(e)
280 })
281
282 on('command.run', { command: 'tdd-batch' }, async ($, e) => {
283 if (e.args.trim() === 'off') {
284 await update($, batch, () => EMPTY)
285 await update($, awaited, () => null)
286 return { text: 'Band hidden.' }
287 }
288 const path = complete(await read($, batch)).path
289 return { text: path === null ? 'No batch followed: run /implement-tdd first.' : `Band following ${path}.` }
290 })
291
292 on('command.run', async ($, e, next) => {
293 if (bare(e.command) !== TDD_SKILL) return next(e)
294 await followArgs($, e.args)
295 return next(e)
296 })
297
298 on('tool.call', async ($, e, next) => {
299 if (e.agentId !== undefined) return next(e)
300 const input = e as unknown as Record<string, unknown>
301
302 if (e.tool === 'Skill' && bare(input.skill) === TDD_SKILL) {
303 await followArgs($, typeof input.args === 'string' ? input.args : '')
304 return next(e)
305 }
306
307 if (e.tool === 'Skill' && bare(input.skill) === AUDIT_SKILL) {
308 const id = e.tool_use_id ?? `AUDIT-${await $.clock.now()}`
309 const reprise = typeof input.args === 'string' && /\breprise\b/i.test(input.args)
310 await trackAgent($, id, 'AUDIT', reprise ? 'audit (reprise)' : 'audit')
311 const result = await next(e)
312 const text = typeof result.deny === 'string' ? result.deny : (result.text ?? '')
313 await settleAgent($, id, false)
314 const verdict = parseVerdict(text)
315 if (verdict !== null) await recordVerdict($, verdict)
316 return result
317 }
318
319 if (e.tool === 'Agent') {
320 const kind = AGENT_KINDS[bare(input.subagent_type)]
321 if (kind === undefined) return next(e)
322 const id = e.tool_use_id ?? `${kind}-${await $.clock.now()}`
323 const description = typeof input.description === 'string' ? input.description : kind
324 await trackAgent($, id, kind, description)
325 const result = await next(e)
326 const text = typeof result.deny === 'string' ? result.deny : (result.text ?? '')
327 await settleAgent($, id, BLOCKED_LITERALS.some(literal => text.includes(literal)))
328 return result
329 }
330
331 const result = await next(e)
332 if (e.tool === 'Bash' && typeof input.command === 'string' && input.command.includes(GATE_SCRIPT)) {
333 const gate = parseGate(typeof result.deny === 'string' ? '' : (result.text ?? ''))
334 if (gate !== null) await recordGate($, gate)
335 }
336 if ((e.tool === 'Read' || e.tool === 'Write' || e.tool === 'Edit') && typeof input.file_path === 'string') {
337 await onSheetTouched($, input.file_path)
338 }
339 return result
340 })
341
342 on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
343 const state = complete(await read($, batch))
344 const sheet = state.sheet
345 if (e.props.hasSurvey || state.path === null) return next(e)
346
347 const { Box, Button, Text } = $.ui.resolve(e)
348
349 if (sheet === null) {
350 return (
351 <Box flexDirection="column" width={e.props.bodyColumns} marginTop={1}>
352 <Text color="red" wrap="truncate-end">{state.error ?? `reading sheet: ${state.path}`}</Text>
353 </Box>
354 )
355 }
356
357 const current = sheet.steps.find(step => !isStepDone(step))
358 const done = sheet.steps.filter(isStepDone).length
359 const at = await read($, now)
360 const expanded = await read($, isExpanded)
361 const { gate, audit } = state
362 const showsAudit = gate !== null || audit.passes > 0
363 const shown = expanded ? sheet.steps : sheet.steps.filter(step => step === current)
364
365 return (
366 <Box flexDirection="column" width={e.props.bodyColumns} marginTop={1}>
367 <Box height={1}>
368 <Box flexGrow={1} flexShrink={1}>
369 <Text wrap="truncate-end">
370 <Text bold>Batch {sheet.lot}</Text>
371 <Text color={sheet.isDone ? 'green' : undefined} dimColor={!sheet.isDone}>
372 {' '}
373 {sheet.isDone ? 'done' : `${done}/${sheet.steps.length} steps`}
374 </Text>
375 {state.startedAt !== null && at >= state.startedAt && (
376 <Text dimColor> · {duration(at - state.startedAt)}</Text>
377 )}
378 {state.wave > 0 && <Text dimColor> · wave {state.wave}</Text>}
379 {sheet.hypotheses > 0 && <Text dimColor> · {sheet.hypotheses} assumption(s)</Text>}
380 {sheet.name !== '' && <Text dimColor> — {sheet.name}</Text>}
381 </Text>
382 </Box>
383 <Button
384 key="steps-toggle"
385 label={expanded ? '[Collapse]' : `[All steps (${sheet.steps.length})]`}
386 plain
387 dimColor
388 onPress={() => update($, isExpanded, value => !value)}
389 />
390 </Box>
391 {state.running.map(run => {
392 const elapsed = Math.max(0, at - run.startedAt)
393 const slow = isSlow(run.kind, elapsed)
394 return (
395 <Text key={`run-${run.id}`} wrap="truncate-end" color={slow ? 'yellow' : undefined}>
396 <Text color={run.kind === 'RED' ? 'red' : run.kind === 'GREEN' ? 'green' : 'cyan'}>{run.kind}</Text>
397 <Text> {run.description} — {duration(elapsed)}</Text>
398 {slow && <Text> (usual {duration(TYPICAL_MS[run.kind])})</Text>}
399 </Text>
400 )
401 })}
402 {showsAudit && (
403 <Text wrap="truncate-end">
404 <Text dimColor>Audit: </Text>
405 {gate !== null && (
406 <Text color={gate.isGreen ? 'green' : 'red'}>
407 pre-audit {gate.isGreen ? 'GREEN' : `RED (${gate.failures} failed check(s))`}
408 </Text>
409 )}
410 {audit.passes > 0 && gate !== null && <Text dimColor> · </Text>}
411 {audit.passes > 0 && <Text dimColor>pass {audit.passes}</Text>}
412 {audit.verdict !== null && (
413 <Text color={audit.verdict.isValid ? 'green' : 'red'}> · {verdictLabel(audit.verdict)}</Text>
414 )}
415 </Text>
416 )}
417 {state.error !== null && <Text color="red">{state.error}</Text>}
418 {shown.map(step => {
419 const isDone = isStepDone(step)
420 const isCurrent = step === current
421 return (
422 <Box key={`step-${step.number}`} flexDirection="column">
423 <Text wrap="truncate-end" color={isDone ? 'green' : isCurrent ? 'yellow' : undefined} bold={isCurrent}>
424 {isCurrent ? '▸ ' : ' '}
425 {step.number}. {step.tdd === null ? (isDone ? DONE : '·') : phaseLabel(step.tdd)} {step.title}
426 </Text>
427 {step.corrections.map(one => (
428 <Text
429 key={`correction-${step.number}-${one.id}`}
430 wrap="truncate-end"
431 color={isPhaseDone(one.tdd) ? 'green' : 'yellow'}
432 >
433 {' '}
434 {one.id} {phaseLabel(one.tdd)} {one.finding}
435 </Text>
436 ))}
437 </Box>
438 )
439 })}
440 {state.blocked.map((one, index) => (
441 <Text key={`blocked-${index}`} color="red" wrap="truncate-end">
442 BLOCKED ({one.kind}) {one.description}
443 </Text>
444 ))}
445 {expanded && (
446 <Text dimColor wrap="truncate-end">
447 {state.path}
448 </Text>
449 )}
450 </Box>
451 )
452 })
453}
454types/index.d.ts 47 lines1export type Phase = { red: string; green: string; cost: string }
2
3export type Correction = { id: string; finding: string; tdd: Phase | null }
4
5export type Step = {
6 number: string
7 title: string
8 isDone: boolean
9 tdd: Phase | null
10 corrections: Correction[]
11}
12
13export type Sheet = { lot: string; name: string; isDone: boolean; steps: Step[]; hypotheses: number }
14
15export type AgentKind = 'RED' | 'GREEN' | 'AUDIT'
16
17export type Run = { id: string; kind: AgentKind; description: string; startedAt: number }
18
19export type Blocked = { kind: AgentKind; description: string }
20
21export type Gate = { isGreen: boolean; failures: number }
22
23export type Verdict = { isValid: boolean; blocking: number; major: number }
24
25export type Audit = { passes: number; verdict: Verdict | null }
26
27export type Awaited = { lot: string | null }
28
29export type Batch = {
30 path: string | null
31 root: string | null
32 sheet: Sheet | null
33 error: string | null
34 wave: number
35 running: Run[]
36 blocked: Blocked[]
37 startedAt: number | null
38 gate: Gate | null
39 audit: Audit
40}
41
42declare module 'claude-code' {
43 interface PluginState {
44 'tdd-batch': { batch: Batch; now: number; isExpanded: boolean; awaited: Awaited | null }
45 }
46}
47