Side pane following a run-lot run: phases as columns, agents (model, duration, cost split per agent, slowness, stalls, retries, interruptions), estimated end…

From a business need to tested, audited code — Claude Code does the work, you make the decisions.
cctoolkit is a Claude Code plugin for DDD / clean-architecture repos. You describe a feature; Claude writes the spec with you, plans it, then builds it batch by batch under strict TDD and audits the result before you commit.
.NET first; Python, Java/Kotlin and TypeScript repos work through their layout and test tags.
claude plugin marketplace add pierrebelin/claude-code-toolkit --scope project
claude plugin install cctoolkit@cctoolkit --scope project
Restart Claude Code, then run /cctoolkit:kit-init: it reads your repo, asks a few questions and sets everything up.
Requirements, options and what kit-init writes: INSTALL.md.
Three commands, one per step. Each one writes a file the next one reads.
flowchart LR
S["<b>1. Spec</b><br/>/business-spec"] -->|SPEC.md| P["<b>2. Plan</b><br/>/plan-implementation"]
P -->|"PLAN.md + one sheet per batch"| R["<b>3. Build</b><br/>/run-lot"]
R -->|"code, tests, report"| C(["you review and commit"])
C -.->|next batch| R
/cctoolkit:business-spec — Claude asks you one question at a time, then writes a short business spec: rules, use cases, no technical detail. A second agent tries to poke holes in it./cctoolkit:plan-implementation — turns the spec into a technical plan, split into small batches./cctoolkit:run-lot <sheet> — builds one batch on its own: tests first, then code, then an audit. Comes back DONE, or with what needs your eyes.Inside run-lot, each behaviour of the batch is one loop:
flowchart LR
I["run-lot / 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["audit<br/>ddd-tdd-auditor"]
The orchestrator writes neither tests nor code: it splits the work, checks the diffs, validates the cost and settles the design. One agent writes the failing test, another makes it pass without touching it, a third audits.
Commit, /clear, next batch. A batch that stops short is finished by hand with /cctoolkit:implement-tdd then /cctoolkit:verify-ddd-tdd (docs/TOOLING.md).
Want to see the files? examples/ walks one small feature from spec to report.
A batch takes tens of minutes and a dozen agents. The run-lot-pane mod shows it live inside Claude Code: each phase, which agent is working, the tests going red then green, the cost, and the final verdict.

claude plugin install run-lot-pane@cctoolkit --scope project
Two more mods — a live view of the spec as it's written, and a context meter telling you when to /clear: mods/README.md.
This kit encodes one way of working. It will:
If your team works differently, half of it will feel like noise. Full list: skills/README.md.
| Install, update, dependencies | INSTALL.md |
| A feature from spec to report | examples/ |
| Every skill and agent | skills/README.md |
| Autonomous runs, finishing a batch by hand | docs/TOOLING.md |
| Layouts and configuration | presets/README.md |
| Other repos configuring a coding agent | RESOURCES.md |
MIT. Take what you want, closed-source projects included.
hooks/register.tsx 1700 lines1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, FsEntry, ModelUsage, Register } from 'claude-code'
3
4import type {
5 AuditVerdict, Journal, Navigation, Outcome, PhaseDeclaration, PhaseSample, PhaseSamples, RunStatus, Screen, SessionFigures, Sheet,
6 Step, TddMarks, WorkflowAgent, WorkflowRun,
7} from '../types'
8
9const PANE = 'run-lot-pane'
10const TICK_MS = 2_000
11const KEPT_RUNS = 1
12const NO_PHASE = '—'
13const AUDIT_PHASE = 'Audit'
14const SLOW_FACTOR = 2
15const STALL_TOOLS = 40
16const STALL_CONTEXT = 150_000
17const STALL_IDLE_MS = 180_000
18const CACHE_READ_WEIGHT = 0.1
19const MAIN = '(main)'
20const ALERT_MS = 10_000
21const ALERT_REASON = 110
22const PARALLEL_PHASES = [['Global green', 'Closing'], ['Audit', 'Review']]
23const KEPT_JOURNALS = 8
24const SCANNED_SESSIONS = 200
25const DONE = '✅'
26const COUNT_WIDTH = 6
27const TIME_WIDTH = 7
28const COST_WIDTH = 7
29const MODEL_WIDTH = 14
30const WIDE_COLUMNS = 56
31const DETAILED_PENDING = 2
32const SHEET_PATTERN = /-PLAN-(F\d+)\.md$/
33const LOT_PATTERN = /(?:^|\/)([^/]+?)-PLAN-(F\d+)\.md$/
34const PLAN_LOT_PATTERN = /^(F\d+)\s+@?((?:\S*\/)?([^/\s]+)\/PLAN\.md)$/
35
36type Launched = { status?: string; runId?: string; scriptPath?: string; workflowName?: string; transcriptDir?: string }
37
38const runs = atom({ plugin: 'run-lot-pane', key: 'runs' } as const, [] as WorkflowRun[])
39const now = atom({ plugin: 'run-lot-pane', key: 'now' } as const, 0)
40const HOME: Navigation = { screen: 'run', phase: null, agentId: null, back: 'run', toggled: [], isAllExpanded: false }
41const nav = atom({ plugin: 'run-lot-pane', key: 'nav' } as const, HOME)
42const usage = atom({ plugin: 'run-lot-pane', key: 'usage' } as const, { context: null, percent: null, cost: null } as SessionFigures)
43const history = atom({ plugin: 'run-lot-pane', key: 'history' } as const, {} as PhaseSamples)
44
45export const duration = (ms: number): string => {
46 const seconds = Math.max(0, Math.round(ms / 1000))
47 const minutes = Math.floor(seconds / 60)
48 if (minutes < 60) return `${minutes}m${String(seconds % 60).padStart(2, '0')}`
49 return `${Math.floor(minutes / 60)}h${String(minutes % 60).padStart(2, '0')}`
50}
51
52const SPINNER = ['✢', '✳', '✶', '✻', '✽', '✻', '✶', '✳']
53
54export const spinnerFrame = (at: number): string => SPINNER[Math.floor(at / TICK_MS) % SPINNER.length] ?? '✻'
55
56export const statusTone = (status: RunStatus): 'claude' | 'success' | 'error' | 'warning' =>
57 status === 'running' ? 'claude' : status === 'completed' ? 'success' : status === 'killed' ? 'warning' : 'error'
58
59export const statusGlyph = (status: RunStatus): string =>
60 status === 'running' ? '⏺' : status === 'completed' ? '✓' : status === 'killed' ? '⏹' : '✗'
61
62export const progressBar = (reached: number, total: number, width: number) => {
63 const filled = total === 0 ? 0 : Math.round((Math.min(reached, total) / total) * width)
64 return [
65 { key: 'filled', text: '━'.repeat(filled), isFilled: true },
66 { key: 'rest', text: '─'.repeat(width - filled), isFilled: false },
67 ]
68}
69
70export const kilo = (tokens: number): string => {
71 if (tokens >= 1_000_000) return `${Number((tokens / 1_000_000).toFixed(1))}M`
72 if (tokens >= 1000) return `${Math.round(tokens / 1000)}k`
73 return `${tokens}`
74}
75
76export const dollars = (usd: number): string => `$${usd.toFixed(2)}`
77
78export const coarse = (ms: number): string => {
79 const minutes = Math.max(1, Math.round(ms / 60_000))
80 return minutes < 60 ? `${minutes}m` : `${Math.floor(minutes / 60)}h${String(minutes % 60).padStart(2, '0')}`
81}
82
83export const promptTokens = (used: ModelUsage): number =>
84 used.input_tokens + used.cache_read_input_tokens + used.cache_creation_input_tokens
85
86export const weightOf = (used: ModelUsage): number =>
87 used.input_tokens + used.cache_creation_input_tokens + used.output_tokens + CACHE_READ_WEIGHT * used.cache_read_input_tokens
88
89export const splitCost = (spent: number, weights: Record<string, number>): Record<string, number> => {
90 const total = Object.values(weights).reduce((sum, weight) => sum + weight, 0)
91 if (spent <= 0 || total <= 0) return {}
92 return Object.fromEntries(Object.entries(weights).map(([payer, weight]) => [payer, (spent * weight) / total]))
93}
94
95export type Totals = { context: number; tokens: number; cost: number }
96
97export const totalsOf = (agents: WorkflowAgent[]): Totals => {
98 const running = agents.filter(agent => agent.state === 'running')
99 return {
100 context: Math.max(0, ...(running.length > 0 ? running : agents).map(agent => agent.context)),
101 tokens: agents.reduce((sum, agent) => sum + agent.tokens, 0),
102 cost: agents.reduce((sum, agent) => sum + agent.cost, 0),
103 }
104}
105
106export const figuresLine = (totals: Totals): string =>
107 `ctx ${kilo(totals.context)} · ${kilo(totals.tokens)} tok · ${dollars(totals.cost)}`
108
109export const firstLine = (text: string, length = 160): string => {
110 const line = (text.split('\n').find(one => one.trim() !== '') ?? '').trim()
111 return line.length > length ? `${line.slice(0, length - 1)}…` : line
112}
113
114export const toolSummary = (counts: Record<string, number>): string =>
115 Object.entries(counts)
116 .sort((a, b) => b[1] - a[1] || a[0].localeCompare(b[0]))
117 .map(([tool, count]) => `${tool} ${count}`)
118 .join(' · ')
119
120export const relativePath = (path: string, root: string | null): string =>
121 root !== null && path.startsWith(`${root}/`) ? path.slice(root.length + 1) : path
122
123const EDITING_TOOLS = ['Edit', 'Write', 'NotebookEdit']
124const KEPT_FILES = 12
125
126export const editedPath = (tool: string, input: Record<string, unknown>): string | null => {
127 if (!EDITING_TOOLS.includes(tool)) return null
128 const path = input.file_path ?? input.notebook_path
129 return typeof path === 'string' && path !== '' ? path : null
130}
131
132export const withFile = (files: string[], path: string): string[] =>
133 [...files.filter(one => one !== path), path].slice(-KEPT_FILES)
134
135export type PhaseInsight = { totals: Totals; share: number | null; costliest: string | null; slowest: string | null }
136
137export const phaseInsight = (phase: PhaseRow, run: WorkflowRun, at: number): PhaseInsight => {
138 const totals = totalsOf(phase.agents)
139 const runCost = totalsOf(run.agents).cost
140 const top = (score: (agent: WorkflowAgent) => number): string | null =>
141 phase.agents.length < 2 ? null : ([...phase.agents].sort((a, b) => score(b) - score(a))[0]?.id ?? null)
142 return {
143 totals,
144 share: runCost > 0 ? Math.round((totals.cost / runCost) * 100) : null,
145 costliest: totals.cost > 0 ? top(agent => agent.cost) : null,
146 slowest: top(agent => (agent.endedAt ?? at) - agent.startedAt),
147 }
148}
149
150export const shortModel = (model: string): string => {
151 const family = /(opus|sonnet|haiku|fable)/i.exec(model)?.[1]
152 if (family === undefined) return model.replace(/^claude-/, '').replace(/-\d{8}$/, '')
153 return `${family.charAt(0).toUpperCase()}${family.slice(1).toLowerCase()}`
154}
155
156export const modelLabel = (agent: WorkflowAgent): string =>
157 agent.effort === null ? agent.model : `${agent.model} · ${agent.effort}`
158
159const phasesBlock = (script: string): string => {
160 const start = script.search(/\bphases\s*:\s*\[/)
161 if (start < 0) return ''
162 const open = script.indexOf('[', start)
163 let depth = 0
164 let quote = ''
165 for (let index = open; index < script.length; index++) {
166 const char = script.charAt(index)
167 if (quote !== '') {
168 if (char === '\\') index++
169 else if (char === quote) quote = ''
170 } else if (char === "'" || char === '"' || char === '`') quote = char
171 else if (char === '[') depth++
172 else if (char === ']' && --depth === 0) return script.slice(open, index)
173 }
174 return script.slice(open)
175}
176
177export const parsePhases = (script: string): PhaseDeclaration[] => {
178 const block = phasesBlock(script)
179 const titles = [...block.matchAll(/\btitle\s*:\s*(['"`])((?:(?!\1).)+)\1/g)]
180 return titles.flatMap((found, index) => {
181 const title = found[2]
182 if (title === undefined) return []
183 const segment = block.slice(found.index, titles[index + 1]?.index)
184 const detail = /\bdetail\s*:\s*(['"`])((?:(?!\1).)+)\1/.exec(segment)?.[2] ?? null
185 return [{ title, detail }]
186 })
187}
188
189export const detailsOf = (phases: PhaseDeclaration[]): Record<string, string> =>
190 Object.fromEntries(phases.flatMap(phase => (phase.detail === null ? [] : [[phase.title, phase.detail]])))
191
192export const journalPathOf = (transcriptDir: string, runId: string): string =>
193 transcriptDir.replace(new RegExp(`/subagents/workflows/${runId}/?$`), `/workflows/${runId}.json`)
194
195const STATUSES: RunStatus[] = ['completed', 'failed', 'killed']
196
197const jsonOf = (text: string): unknown => {
198 try {
199 return JSON.parse(text)
200 } catch {
201 return null
202 }
203}
204
205const textOf = (value: unknown): string | null => (typeof value === 'string' && value.trim() !== '' ? value.trim() : null)
206
207export const argumentOf = (input: Record<string, unknown>): string | null => {
208 const given = input.args
209 if (typeof given === 'object' && given !== null) return textOf((given as Record<string, unknown>).fiche)
210 return textOf(given)
211}
212
213const planLotOf = (argument: string | null): { lot: string; code: string; plan: string } | null => {
214 const found = argument === null ? null : PLAN_LOT_PATTERN.exec(argument)
215 return found?.[1] === undefined || found[2] === undefined || found[3] === undefined
216 ? null
217 : { lot: found[1], code: found[3], plan: found[2] }
218}
219
220export const lotOf = (argument: string | null): { lot: string; code: string } | null => {
221 const inPlan = planLotOf(argument)
222 if (inPlan !== null) return { lot: inPlan.lot, code: inPlan.code }
223 const found = argument === null ? null : LOT_PATTERN.exec(argument)
224 return found?.[1] === undefined || found[2] === undefined ? null : { lot: found[2], code: found[1] }
225}
226
227export const sheetPathOf = (argument: string | null): string | null =>
228 planLotOf(argument)?.plan ?? (argument !== null && SHEET_PATTERN.test(argument) ? argument : null)
229
230const VERDICTS: AuditVerdict['verdict'][] = ['VALIDE', 'ECARTS', 'ÉCARTS', 'VALID', 'GAPS']
231
232export const auditOf = (source: unknown): AuditVerdict | null => {
233 if (source === null || typeof source !== 'object') return null
234 const fields = source as Record<string, unknown>
235 const verdict = VERDICTS.find(one => one === fields.verdict)
236 if (verdict === undefined) return null
237 const gaps = Array.isArray(fields.gaps) ? fields.gaps : []
238 const count = (severities: string[]) => gaps.filter(gap => severities.includes(gap?.severity)).length
239 return { verdict, blocking: count(['Bloquant', 'Blocking']), major: count(['Majeur', 'Major']) }
240}
241
242export const auditLabel = (audit: AuditVerdict): string =>
243 [audit.verdict, audit.blocking > 0 ? `${audit.blocking} blocking` : '', audit.major > 0 ? `${audit.major} major` : '']
244 .filter(part => part !== '')
245 .join(' · ')
246
247export const auditTone = (audit: AuditVerdict): 'success' | 'warning' | 'error' =>
248 audit.verdict === 'VALIDE' || audit.verdict === 'VALID' ? 'success' : audit.blocking > 0 ? 'error' : 'warning'
249
250const OUTCOME_KEYS = ['statut', 'status', 'verdict', 'summary']
251
252const outcomeOf = (result: unknown): Outcome | null => {
253 if (typeof result === 'string') {
254 const label = firstLine(result, 120)
255 return label === '' ? null : { label, reason: null, report: null }
256 }
257 if (result === null || typeof result !== 'object') return null
258 const fields = result as Record<string, unknown>
259 const label = OUTCOME_KEYS.map(key => textOf(fields[key])).find(found => found !== null) ?? null
260 if (label === null) return null
261 const reason = textOf(fields.raison) ?? textOf(fields.reason)
262 return { label, reason: reason === '—' ? null : reason, report: textOf(fields.rapport) ?? textOf(fields.report) }
263}
264
265export const outcomeTone = (outcome: Outcome, status: RunStatus): 'success' | 'warning' | 'error' => {
266 if (['TERMINÉ', 'DONE'].includes(outcome.label)) return 'success'
267 if (['ECARTS', 'ÉCARTS', 'GAPS', 'RELECTURE-BLOQUANTE', 'REVIEW-BLOCKING'].includes(outcome.label)) return 'warning'
268 if (['BLOQUÉ', 'BLOCKED'].includes(outcome.label)) return 'error'
269 return status === 'completed' ? 'success' : status === 'killed' ? 'warning' : 'error'
270}
271
272const recordOf = (text: string): Record<string, unknown> | null => {
273 const parsed = jsonOf(text)
274 return parsed !== null && typeof parsed === 'object' ? (parsed as Record<string, unknown>) : null
275}
276
277export const parseJournal = (text: string): Journal | null => {
278 const raw = recordOf(text)
279 if (raw === null) return null
280 const status = STATUSES.find(one => one === raw.status) ?? (raw.status === 'running' ? 'running' : 'failed')
281 const declared = Array.isArray(raw.phases)
282 ? raw.phases.flatMap(one => (typeof one?.title === 'string' ? [{ title: one.title as string, detail: textOf(one.detail) }] : []))
283 : []
284 const agentPhases: Record<string, string> = {}
285 for (const entry of Array.isArray(raw.workflowProgress) ? raw.workflowProgress : []) {
286 if (entry?.type === 'workflow_agent' && typeof entry.agentId === 'string' && typeof entry.phaseTitle === 'string') {
287 agentPhases[entry.agentId] = entry.phaseTitle
288 }
289 }
290 const failure = typeof raw.error === 'string' ? { label: status, reason: raw.error, report: null } : null
291 return {
292 status,
293 durationMs: typeof raw.durationMs === 'number' ? raw.durationMs : null,
294 outcome: outcomeOf(raw.result) ?? failure,
295 argument: argumentOf({ args: raw.args }),
296 audit: auditOf(raw.result),
297 phases: declared.map(phase => phase.title),
298 details: detailsOf(declared),
299 agentPhases,
300 }
301}
302
303export type Sample = { phase: string; ms: number; tools: number | null; startedAt: number | null }
304
305export const samplesOf = (text: string): Sample[] => {
306 const raw = recordOf(text)
307 if (raw === null) return []
308 const isComplete = raw.status === 'completed'
309 return (Array.isArray(raw.workflowProgress) ? raw.workflowProgress : []).flatMap(entry =>
310 entry?.type === 'workflow_agent' && entry.state === 'done' && typeof entry.phaseTitle === 'string' && typeof entry.durationMs === 'number'
311 ? [
312 {
313 phase: entry.phaseTitle as string,
314 ms: entry.durationMs as number,
315 tools: typeof entry.toolCalls === 'number' ? entry.toolCalls : null,
316 startedAt: isComplete && typeof entry.startedAt === 'number' ? entry.startedAt : null,
317 },
318 ]
319 : [],
320 )
321}
322
323const spansOf = (samples: Sample[]): { phase: string; ms: number }[] => {
324 const bounds: Record<string, { from: number; to: number }> = {}
325 for (const sample of samples) {
326 if (sample.startedAt === null) continue
327 const found = bounds[sample.phase]
328 const end = sample.startedAt + sample.ms
329 bounds[sample.phase] = { from: Math.min(found?.from ?? sample.startedAt, sample.startedAt), to: Math.max(found?.to ?? end, end) }
330 }
331 return Object.entries(bounds).map(([phase, one]) => ({ phase, ms: one.to - one.from }))
332}
333
334const NO_SAMPLE: PhaseSample = { total: 0, count: 0, tools: 0, toolCount: 0, span: 0, spanCount: 0 }
335
336export const statsOf = (known: PhaseSamples, phase: string): PhaseSample => ({ ...NO_SAMPLE, ...known[phase] })
337
338export const withSamples = (known: PhaseSamples, samples: Sample[]): PhaseSamples => {
339 const merged: PhaseSamples = { ...known }
340 for (const sample of samples) {
341 const before = statsOf(merged, sample.phase)
342 merged[sample.phase] = {
343 ...before,
344 total: before.total + sample.ms,
345 count: before.count + 1,
346 tools: before.tools + (sample.tools ?? 0),
347 toolCount: before.toolCount + (sample.tools === null ? 0 : 1),
348 }
349 }
350 for (const one of spansOf(samples)) {
351 const before = statsOf(merged, one.phase)
352 merged[one.phase] = { ...before, span: before.span + one.ms, spanCount: before.spanCount + 1 }
353 }
354 return merged
355}
356
357const peersOf = (agents: WorkflowAgent[], agent: WorkflowAgent): WorkflowAgent[] =>
358 agents.filter(
359 other =>
360 other.id !== agent.id && (other.phase ?? NO_PHASE) === (agent.phase ?? NO_PHASE) && other.state === 'done' && other.endedAt !== null,
361 )
362
363export const typicalMs = (known: PhaseSamples, agents: WorkflowAgent[], agent: WorkflowAgent): number | null => {
364 const stats = statsOf(known, agent.phase ?? NO_PHASE)
365 const peers = peersOf(agents, agent)
366 const count = stats.count + peers.length
367 const own = peers.reduce((sum, other) => sum + ((other.endedAt ?? other.startedAt) - other.startedAt), 0)
368 return count === 0 ? null : (stats.total + own) / count
369}
370
371export const typicalTools = (known: PhaseSamples, agents: WorkflowAgent[], agent: WorkflowAgent): number | null => {
372 const stats = statsOf(known, agent.phase ?? NO_PHASE)
373 const peers = peersOf(agents, agent)
374 const count = stats.toolCount + peers.length
375 const own = peers.reduce((sum, other) => sum + other.tools, 0)
376 return count === 0 ? null : (stats.tools + own) / count
377}
378
379export const isSlow = (agent: WorkflowAgent, typical: number | null, at: number): boolean =>
380 typical !== null && (agent.endedAt ?? at) - agent.startedAt > SLOW_FACTOR * typical
381
382export const stallReasons = (agent: WorkflowAgent, typical: number | null, at: number): string[] => {
383 if (agent.state !== 'running') return []
384 const idle = at - agent.lastActiveAt
385 return [
386 agent.openCalls === 0 && idle > STALL_IDLE_MS ? `idle ${Math.floor(idle / 60_000)}m` : '',
387 agent.tools > (typical === null ? STALL_TOOLS : SLOW_FACTOR * typical) ? `${agent.tools} tools` : '',
388 agent.context > STALL_CONTEXT ? `ctx ${kilo(agent.context)}` : '',
389 ]
390 .filter(reason => reason !== '')
391 .map(reason => `⚠ ${reason}`)
392}
393
394export const attemptsOf = (agents: WorkflowAgent[]): Record<string, number> => {
395 const seen: Record<string, number> = {}
396 const attempts: Record<string, number> = {}
397 for (const agent of [...agents].sort((a, b) => a.startedAt - b.startedAt || a.index - b.index)) {
398 if (agent.state !== 'interrupted') seen[agent.label] = (seen[agent.label] ?? 0) + 1
399 attempts[agent.id] = seen[agent.label] ?? 1
400 }
401 return attempts
402}
403
404export const isPhaseDone = (tdd: TddMarks | null): boolean =>
405 tdd !== null && tdd.red === DONE && tdd.green === DONE && tdd.cost === DONE
406
407export const isStepDone = (step: Step): boolean =>
408 (step.isDone || isPhaseDone(step.tdd)) && step.corrections.every(one => isPhaseDone(one.tdd))
409
410export const isStepStarted = (step: Step): boolean =>
411 [step.tdd, ...step.corrections.map(one => one.tdd)].some(
412 tdd => tdd !== null && (tdd.red === DONE || tdd.green === DONE || tdd.cost === DONE),
413 )
414
415export const markOf = (value: string): string => (value === DONE ? '✓' : value === '' || value === '⬜' ? '·' : value)
416
417const LOT_STATE = /\s+—\s+(?:✅|⬜)\s*$/
418
419export const parseLotSection = (text: string, lot: string): Sheet | null => {
420 const lines = text.split(/\r?\n/)
421 const start = lines.findIndex(line => new RegExp(`^## Lot ${lot}\\b`).test(line))
422 const heading = lines[start]
423 if (heading === undefined) return null
424 const end = lines.findIndex((line, index) => index > start && line.startsWith('## '))
425 const name = heading.replace(LOT_STATE, '').split(' — ').slice(1).join(' — ')
426 const steps: Step[] = []
427 let section = ''
428 let hypotheses = 0
429 for (const line of lines.slice(start + 1, end < 0 ? undefined : end)) {
430 if (line.startsWith('### ')) section = line.slice(4).trim()
431 if (/^H[yi]poth[eè]ses/i.test(section) && /^\s*[-*]\s+\**H\d+\b/.test(line)) hypotheses += 1
432 const step = /^#### [EÉ]tape (\S+) — (.+)$/.exec(line)
433 if (step) {
434 const title = step[2] ?? ''
435 steps.push({ number: step[1] ?? '', title: title.replace(LOT_STATE, ''), isDone: /—\s+✅\s*$/.test(title), tdd: null, corrections: [] })
436 }
437 }
438 const isDone = /—\s+✅\s*$/.test(heading) || (steps.length > 0 && steps.every(isStepDone))
439 return { lot, name, isDone, steps, hypotheses }
440}
441
442export const parseSheet = (text: string, path: string): Sheet => {
443 const lines = text.split(/\r?\n/)
444 const title = lines.find(line => line.startsWith('# '))?.slice(2).trim() ?? ''
445 const lot = SHEET_PATTERN.exec(path)?.[1] ?? /PLAN-(F\d+)/.exec(title)?.[1] ?? '?'
446 const name = title
447 .replace(/\s+—\s+✅ DONE.*$/, '')
448 .split(' — ')
449 .slice(1)
450 .join(' — ')
451 const steps: Step[] = []
452 let section = ''
453 let hypotheses = 0
454
455 for (const line of lines) {
456 if (line.startsWith('## ')) {
457 section = line.slice(3).trim()
458 continue
459 }
460 if (/^(H[yi]poth[eè]ses|Assumptions)/i.test(section) && /^\s*(?:[-*]\s+)?\**H\d+\b/.test(line)) hypotheses += 1
461 if (!/^(D[eé]roulement TDD|TDD sequence)/i.test(section)) continue
462
463 const step = /^### (?:[EÉ]tape|Step) (\S+) — (.+)$/.exec(line)
464 if (step) {
465 const heading = step[2] ?? ''
466 steps.push({
467 number: step[1] ?? '',
468 title: heading.replace(/\s+—\s+✅ DONE.*$/, ''),
469 isDone: heading.includes('✅ DONE'),
470 tdd: null,
471 corrections: [],
472 })
473 continue
474 }
475 const current = steps[steps.length - 1]
476 if (current === undefined) continue
477
478 if (line.startsWith('✅ DONE')) {
479 current.isDone = true
480 continue
481 }
482
483 const correction = /^(?:#{3,4} )?Correction (C\d+) — (.+)$/.exec(line)
484 if (correction) {
485 current.corrections.push({ id: correction[1] ?? '', finding: correction[2] ?? '', tdd: null })
486 continue
487 }
488 const tdd = /^TDD ?: RED (\S+) · GREEN (\S+) · CO[US]T (\S+)/.exec(line)
489 if (tdd) {
490 const marks: TddMarks = { red: tdd[1] ?? '', green: tdd[2] ?? '', cost: tdd[3] ?? '' }
491 const owner = current.corrections[current.corrections.length - 1] ?? current
492 if (owner.tdd === null) owner.tdd = marks
493 }
494 }
495
496 const isDone = title.includes('✅ DONE') || (steps.length > 0 && steps.every(isStepDone))
497 return { lot, name, isDone, steps, hypotheses }
498}
499
500export type PhaseRow = {
501 title: string
502 detail: string | null
503 mark: 'done' | 'running' | 'pending'
504 agents: WorkflowAgent[]
505 span: number
506}
507
508const isLive = (agent: WorkflowAgent): boolean => agent.state !== 'interrupted'
509
510export const phaseRows = (run: WorkflowRun, at: number): PhaseRow[] => {
511 const titles = [...run.phases]
512 for (const agent of run.agents) {
513 const title = agent.phase ?? NO_PHASE
514 if (!titles.includes(title)) titles.push(title)
515 }
516 const reached = Math.max(-1, ...run.agents.filter(isLive).map(agent => titles.indexOf(agent.phase ?? NO_PHASE)))
517 return titles
518 .map((title, index) => {
519 const agents = run.agents.filter(agent => (agent.phase ?? NO_PHASE) === title)
520 const isRunning = agents.some(agent => agent.state === 'running')
521 const starts = agents.map(agent => agent.startedAt)
522 const ends = agents.map(agent => agent.endedAt ?? at)
523 const span = agents.length === 0 ? 0 : Math.max(...ends) - Math.min(...starts)
524 const mark: PhaseRow['mark'] = isRunning ? 'running' : index <= reached && agents.some(isLive) ? 'done' : 'pending'
525 return { title, detail: run.details[title] ?? null, mark, agents, span }
526 })
527 .filter(row => row.title !== NO_PHASE || row.agents.length > 0)
528}
529
530export type CurrentStep = {
531 row: PhaseRow
532 index: number
533 total: number
534 done: number
535 running: number
536}
537
538export const currentStep = (run: WorkflowRun, rows: PhaseRow[]): CurrentStep | null => {
539 if (run.status !== 'running') return null
540 const running = rows.map(row => row.mark).lastIndexOf('running')
541 const index = running >= 0 ? running : rows.findIndex(row => row.mark === 'pending')
542 const row = rows[index]
543 if (row === undefined) return null
544 const active = row.agents.filter(agent => agent.state === 'running').length
545 return { row, index, total: rows.length, done: row.agents.length - active, running: active }
546}
547
548export const phaseTypical = (known: PhaseSamples, title: string): number | null => {
549 const stats = statsOf(known, title)
550 if (stats.spanCount > 0) return stats.span / stats.spanCount
551 return stats.count > 0 ? stats.total / stats.count : null
552}
553
554export const remainingMs = (run: WorkflowRun, rows: PhaseRow[], known: PhaseSamples): number | null => {
555 if (run.status !== 'running') return null
556 const groups: PhaseRow[][] = []
557 for (const row of rows.filter(one => one.mark !== 'done')) {
558 const mates = PARALLEL_PHASES.find(group => group.includes(row.title)) ?? []
559 const placed = groups.find(group => group.some(other => mates.includes(other.title)))
560 if (placed === undefined) groups.push([row])
561 else placed.push(row)
562 }
563 const left = (row: PhaseRow): number | null => {
564 const typical = phaseTypical(known, row.title)
565 return typical === null ? null : row.mark === 'running' ? Math.max(0, typical - row.span) : typical
566 }
567 const parts = groups.flatMap(group => {
568 const lefts = group.map(left).filter((one): one is number => one !== null)
569 return lefts.length === 0 ? [] : [Math.max(...lefts)]
570 })
571 return parts.length === 0 ? null : parts.reduce((sum, part) => sum + part, 0)
572}
573
574export const fit = (text: string, width: number): string =>
575 width < 1 ? '' : text.length > width ? `${text.slice(0, Math.max(0, width - 1))}…` : text
576
577export const phaseLines = (rows: PhaseRow[], detailed: number): PhaseRow[][] => {
578 const tailStart = rows.reduce((start, row, index) => (row.mark === 'pending' ? start : index + 1), 0)
579 const folded = rows.slice(tailStart + detailed)
580 if (folded.length < 2) return rows.map(row => [row])
581 return [...rows.slice(0, tailStart + detailed).map(row => [row]), folded]
582}
583
584const emptyRun = (runId: string, at: number): WorkflowRun => ({
585 runId,
586 name: 'workflow',
587 argument: null,
588 phases: [],
589 details: {},
590 transcriptDir: null,
591 startedAt: at,
592 launchedAt: at,
593 durationMs: null,
594 status: 'running',
595 outcome: null,
596 audit: null,
597 sheet: null,
598 agents: [],
599})
600
601const completeAgent = (agent: WorkflowAgent): WorkflowAgent => ({
602 ...agent,
603 lastActiveAt: agent.lastActiveAt ?? agent.startedAt,
604 openCalls: agent.openCalls ?? 0,
605})
606
607export const completeRun = (run: WorkflowRun): WorkflowRun => {
608 const outcome = run.outcome as Outcome | string | null | undefined
609 return {
610 ...emptyRun(run.runId, run.startedAt),
611 ...run,
612 outcome: typeof outcome === 'string' ? { label: outcome, reason: null, report: null } : (outcome ?? null),
613 agents: (run.agents ?? []).map(completeAgent),
614 }
615}
616
617const withRun = (list: WorkflowRun[], runId: string, at: number, change: (run: WorkflowRun) => WorkflowRun) => {
618 const found = list.find(run => run.runId === runId)
619 const changed = change(found === undefined ? emptyRun(runId, at) : completeRun(found))
620 const rest = list.filter(run => run.runId !== runId)
621 return [...rest, changed].slice(-KEPT_RUNS)
622}
623
624const withAgent = (list: WorkflowRun[], agentId: string, change: (agent: WorkflowAgent) => WorkflowAgent) =>
625 list.map(run =>
626 run.agents.some(agent => agent.id === agentId)
627 ? { ...run, agents: run.agents.map(agent => (agent.id === agentId ? change(completeAgent(agent)) : agent)) }
628 : run,
629 )
630
631const withCosts = (list: WorkflowRun[], shares: Record<string, number>) =>
632 list.map(run =>
633 run.status === 'running' && run.agents.some(agent => shares[agent.id] !== undefined)
634 ? { ...run, agents: run.agents.map(agent => ({ ...agent, cost: agent.cost + (shares[agent.id] ?? 0) })) }
635 : run,
636 )
637
638const withRequest = (list: WorkflowRun[], agentId: string, used: ModelUsage, effort: string | null, at: number) =>
639 withAgent(list, agentId, agent => ({
640 ...agent,
641 context: promptTokens(used),
642 effort: effort ?? agent.effort,
643 contextStart: agent.contextStart === 0 ? promptTokens(used) : agent.contextStart,
644 contextPeak: Math.max(agent.contextPeak, promptTokens(used)),
645 tokens: agent.tokens + promptTokens(used) + used.output_tokens,
646 lastActiveAt: at,
647 }))
648
649const withInterrupted = (list: WorkflowRun[], runId: string, before: number) =>
650 list.map(run =>
651 run.runId === runId
652 ? { ...run, agents: run.agents.map(agent => (agent.state === 'running' && agent.startedAt < before ? interrupted(agent) : agent)) }
653 : run,
654 )
655
656const withAudit = (list: WorkflowRun[], agentId: string, audit: AuditVerdict) =>
657 list.map(run => (run.agents.some(agent => agent.id === agentId) ? { ...run, audit } : run))
658
659const interrupted = (agent: WorkflowAgent): WorkflowAgent => {
660 const kept = completeAgent(agent)
661 return { ...kept, state: 'interrupted', endedAt: kept.lastActiveAt, openCalls: 0 }
662}
663
664const isFollowed = (list: WorkflowRun[], agentId: string) =>
665 list.some(run => run.status === 'running' && run.agents.some(agent => agent.id === agentId))
666
667const DEMO = 'demo-'
668
669type DemoAgentOptions = { type?: string; lastTool?: string; tools?: number; answer?: string; activeAt?: number }
670
671const demoAgent = (
672 id: string, index: number, label: string, phase: string, from: number, to: number | null,
673 state: WorkflowAgent['state'], model = 'Sonnet', options: DemoAgentOptions = {},
674): WorkflowAgent => ({
675 id: `${DEMO}${id}`, index, label, phase, model, effort: 'high', type: options.type ?? 'tdd-test-author', startedAt: from, endedAt: to,
676 lastActiveAt: options.activeAt ?? to ?? from, openCalls: 0, state,
677 lastTool: options.lastTool ?? null, tools: options.tools ?? (state === 'running' ? 0 : 10 + index),
678 context: 18_000 + index * 3_000, contextStart: 11_000, contextPeak: 18_000 + index * 3_000,
679 tokens: 120_000 + index * 30_000, cost: 0.08 + index * 0.03,
680 prompt: `Workflow run-lot, batch F4. ${label}.`,
681 answer: state === 'running' || state === 'error' || state === 'interrupted' ? null : (options.answer ?? `${label}: done, tests green.`),
682 toolCounts: { Read: 4 + index, Bash: index, Edit: index > 3 ? 3 : 0, Grep: 2 },
683 files: index > 3 ? ['src/Stid.Configurator.Application/Configurator/DeploymentPackages/CreateDeploymentPackage/CreateDeploymentPackageCommandHandler.cs'] : [],
684 lastError: state === 'error' ? 'Bash: dotnet test exited with code 1 (2 failed)' : null,
685})
686
687const demoSheet = (isFinished: boolean): Sheet => {
688 const marks = (isReached: boolean): TddMarks => ({ red: isReached ? DONE : '⬜', green: isReached ? DONE : '⬜', cost: isReached ? DONE : '⬜' })
689 const step = (number: string, title: string, tdd: TddMarks): Step => ({ number, title, isDone: false, tdd, corrections: [] })
690 const partial = { red: DONE, green: '⬜', cost: '⬜' }
691 return {
692 lot: 'F4',
693 name: 'Package lifecycle',
694 isDone: isFinished,
695 hypotheses: 2,
696 steps: [
697 step('1', 'Create the package', marks(true)),
698 step('2', 'Refuse without firmware', marks(true)),
699 { ...step('3', 'Firmware lock', isFinished ? marks(true) : partial), corrections: [{ id: 'C1', finding: 'lock set after creation', tdd: marks(isFinished) }] },
700 step('4', 'Remove a deleted right', marks(isFinished)),
701 step('5', 'Package audit', marks(isFinished)),
702 ],
703 }
704}
705
706const inMs = (minutes: number, seconds = 0) => (minutes * 60 + seconds) * 1000
707
708const sampled = (agentMs: number, tools: number, spanMs: number): PhaseSample => ({
709 total: agentMs * 3, count: 3, tools: tools * 3, toolCount: 3, span: spanMs * 3, spanCount: 3,
710})
711
712export const demoHistory = (): PhaseSamples => ({
713 Design: sampled(inMs(4), 20, inMs(4)),
714 RED: sampled(inMs(1, 30), 14, inMs(2)),
715 GREEN: sampled(inMs(1, 30), 14, inMs(8)),
716 'Global green': sampled(inMs(1), 8, inMs(1, 10)),
717 Closing: sampled(inMs(1, 40), 12, inMs(1, 40)),
718 Audit: sampled(inMs(2), 16, inMs(5)),
719 Review: sampled(inMs(2), 18, inMs(2, 30)),
720 Report: sampled(inMs(0, 30), 4, inMs(0, 30)),
721})
722
723export const demoRuns = (at: number, isFinished = false): WorkflowRun[] => {
724 const t = inMs
725 const total = t(19, 35)
726 const begin = isFinished ? at - total : at - t(12, 40)
727 const phases = ['Design', 'RED', 'GREEN', 'Global green', 'Closing', 'Audit', 'Review', 'Report']
728 const details = {
729 Design: 'earlier audit gaps, behaviours, waves, RED and GREEN contracts, declaratives',
730 RED: 'tdd-test-author, one wave in parallel',
731 GREEN: 'tdd-implementer, serial, TDD tick after COST',
732 'Global green': 'whole or filtered suites per test-scope.md, beside closing',
733 Closing: 'closing.md §1-2 then pre-audit.sh, beside global green',
734 Audit: 'audit-capture.sh, ddd-tdd-auditor, two retries at most',
735 Review: 'adversarial-reviewer on the next sheet, beside the audit',
736 Report: 'FX-report.md, written even on stop',
737 }
738 const agent = (
739 id: string, index: number, label: string, phase: string, from: number, to: number | null,
740 state: WorkflowAgent['state'], model?: string, options: DemoAgentOptions = {},
741 ) =>
742 demoAgent(id, index, label, phase, begin + from, to === null ? null : begin + to, state, model, {
743 ...options,
744 ...(options.activeAt === undefined ? {} : { activeAt: begin + options.activeAt }),
745 })
746 const build = [
747 agent('d', 1, 'design', 'Design', 0, t(4, 30), 'done', 'Opus'),
748 agent('r1', 2, 'RED 1 create', 'RED', t(4, 30), t(6, 18), 'done'),
749 agent('r2', 3, 'RED 2 refusal', 'RED', t(4, 30), t(6, 10), 'done'),
750 agent('r3', 14, 'RED 3 declaratives', 'RED', t(4, 30), t(5, 20), 'interrupted', 'Sonnet'),
751 agent('g1', 4, 'GREEN 1 removal', 'GREEN', t(6, 20), t(7, 14), 'done', 'Sonnet', { type: 'tdd-implementer' }),
752 agent('g2', 5, 'GREEN 2 lock', 'GREEN', t(7, 20), t(8, 32), 'error', 'Sonnet', { type: 'tdd-implementer' }),
753 isFinished
754 ? agent('g2b', 6, 'GREEN 2 lock', 'GREEN', t(8, 40), t(12), 'done', 'Sonnet', { type: 'tdd-implementer' })
755 : agent('g2b', 6, 'GREEN 2 lock', 'GREEN', t(8, 40), null, 'running', 'Sonnet', {
756 type: 'tdd-implementer', lastTool: 'Edit', tools: 48, activeAt: t(12, 35),
757 }),
758 ]
759 const closing = [
760 agent('s', 7, 'suites', 'Global green', t(12, 5), t(13, 15), 'done', 'Haiku'),
761 agent('c', 8, 'closing + pre-audit', 'Closing', t(12, 5), t(13, 45), 'done', 'Sonnet'),
762 agent('a1', 9, 'audit', 'Audit', t(13, 50), t(15, 40), 'done', 'Opus', { type: 'ddd-tdd-auditor' }),
763 agent('rv', 10, 'next batch review', 'Review', t(13, 50), t(16, 20), 'done', 'Opus', { type: 'adversarial-reviewer' }),
764 agent('f1', 11, 'fix 1', 'Audit', t(15, 45), t(17, 45), 'done', 'Sonnet', { type: 'tdd-implementer' }),
765 agent('a2', 12, 'audit retry 1', 'Audit', t(17, 50), t(19), 'done', 'Opus', { type: 'ddd-tdd-auditor' }),
766 agent('rp', 13, 'report', 'Report', t(19, 5), total, 'done', 'Haiku'),
767 ]
768 return [
769 {
770 runId: `${DEMO}1`, name: 'run-lot', argument: 'todo/package-lifecycle/PKG-LIFECYCLE-PLAN-F4.md', phases, details,
771 transcriptDir: null, startedAt: begin, launchedAt: begin, durationMs: isFinished ? total : null, status: isFinished ? 'completed' : 'running',
772 outcome: isFinished
773 ? {
774 label: 'GAPS',
775 reason: 'two fix rounds done, one blocking gap remains on the organization lock',
776 report: 'todo/package-lifecycle/run/F4/F4-report.md',
777 }
778 : null,
779 audit: isFinished ? { verdict: 'GAPS', blocking: 1, major: 2 } : null,
780 sheet: demoSheet(isFinished),
781 agents: isFinished ? [...build, ...closing] : build,
782 },
783 ]
784}
785
786async function openPane($: EngineInterface): Promise<void> {
787 await $.ui.open({ id: PANE, title: 'Workflow' }).catch(() => undefined)
788}
789
790async function readText($: EngineInterface, path: string): Promise<string | null> {
791 try {
792 return await $.fs.read(path)
793 } catch {
794 return null
795 }
796}
797
798async function metaPhase($: EngineInterface, transcriptDir: string, agentId: string): Promise<string | null> {
799 const text = await readText($, `${transcriptDir}/agent-${agentId}.meta.json`)
800 if (text === null) return null
801 try {
802 const phase = (JSON.parse(text) as { workflowPhase?: unknown }).workflowPhase
803 return typeof phase === 'string' && phase !== '' ? phase : null
804 } catch {
805 return null
806 }
807}
808
809export const alertOf = (run: WorkflowRun): string => {
810 const lot = lotOf(run.argument)
811 const outcome = run.outcome
812 const reason = outcome !== null && outcomeTone(outcome, run.status) !== 'success' ? outcome.reason : null
813 return [
814 lot === null ? run.name : `${run.name} ${lot.lot}`,
815 outcome?.label ?? (run.status === 'killed' ? 'STOPPED' : run.status === 'failed' ? 'FAILED' : 'COMPLETED'),
816 coarse(run.durationMs ?? 0),
817 ...(reason === null ? [] : [fit(firstLine(reason), ALERT_REASON)]),
818 ].join(' · ')
819}
820
821const announced = new Set<string>()
822
823async function announce($: EngineInterface, runId: string): Promise<void> {
824 const run = (await read($, runs)).map(completeRun).find(one => one.runId === runId)
825 if (run === undefined || run.status === 'running' || run.runId.startsWith(DEMO)) return
826 const key = `${run.runId}@${run.launchedAt}`
827 if (announced.has(key)) return
828 announced.add(key)
829 $.ui.toast(alertOf(run), { timeoutMs: ALERT_MS })
830}
831
832async function poll($: EngineInterface): Promise<void> {
833 const list = (await read($, runs)).map(completeRun)
834 for (const run of list) {
835 if (run.status !== 'running' || run.transcriptDir === null) continue
836 const dir = run.transcriptDir
837 const journalPath = journalPathOf(dir, run.runId)
838 const journalText = await readText($, journalPath)
839 const journal = journalText === null ? null : parseJournal(journalText)
840 const stamp = journal === null || journal.status === 'running' ? null : await $.fs.stat(journalPath).catch(() => null)
841 const settled = journal !== null && journal.status !== 'running' && (stamp === null || stamp.mtimeMs >= run.launchedAt) ? journal : null
842 const phases: Record<string, string> = { ...(journal?.agentPhases ?? {}) }
843 for (const agent of run.agents) {
844 if (agent.phase === null && phases[agent.id] === undefined) {
845 const phase = await metaPhase($, dir, agent.id)
846 if (phase !== null) phases[agent.id] = phase
847 }
848 }
849 const at = await $.clock.now()
850 await update($, runs, stored =>
851 withRun(stored, run.runId, at, current => {
852 const agents = current.agents.map(agent => {
853 const phase = agent.phase ?? phases[agent.id] ?? null
854 if (settled === null || agent.state !== 'running') return { ...agent, phase }
855 return settled.status === 'completed'
856 ? { ...agent, phase, state: 'done' as const, endedAt: at }
857 : { ...interrupted(agent), phase }
858 })
859 const known = {
860 argument: current.argument ?? journal?.argument ?? null,
861 details: Object.keys(current.details).length > 0 ? current.details : (journal?.details ?? {}),
862 }
863 if (settled === null) return { ...current, ...known, agents }
864 return {
865 ...current,
866 ...known,
867 agents,
868 status: settled.status,
869 durationMs: settled.durationMs ?? at - current.startedAt,
870 outcome: settled.outcome,
871 audit: settled.audit ?? current.audit,
872 phases: current.phases.length > 0 ? current.phases : settled.phases,
873 }
874 }),
875 )
876 if (settled !== null) await announce($, run.runId)
877 }
878}
879
880async function listDirectory($: EngineInterface, path: string): Promise<FsEntry[]> {
881 try {
882 return (await $.fs.list(path)) ?? []
883 } catch {
884 return []
885 }
886}
887
888async function learnHistory($: EngineInterface, run: WorkflowRun): Promise<void> {
889 if (run.transcriptDir === null) return
890 const own = journalPathOf(run.transcriptDir, run.runId)
891 const sessionDir = own.slice(0, own.lastIndexOf('/workflows/'))
892 const projectDir = sessionDir.slice(0, sessionDir.lastIndexOf('/'))
893 if (own === run.transcriptDir || projectDir === '') return
894 const sessions = (await listDirectory($, projectDir)).filter(entry => entry.kind === 'dir').slice(0, SCANNED_SESSIONS)
895 const journals: { path: string; mtimeMs: number }[] = []
896 for (const session of sessions) {
897 const folder = `${projectDir}/${session.name}/workflows`
898 for (const entry of await listDirectory($, folder)) {
899 const path = `${folder}/${entry.name}`
900 if (entry.kind === 'file' && entry.name.endsWith('.json') && path !== own) journals.push({ path, mtimeMs: entry.mtimeMs })
901 }
902 }
903 const newest = journals.sort((a, b) => b.mtimeMs - a.mtimeMs).slice(0, KEPT_JOURNALS)
904 let known: PhaseSamples = {}
905 for (const journal of newest) {
906 const text = await readText($, journal.path)
907 if (text !== null) known = withSamples(known, samplesOf(text))
908 }
909 await update($, history, () => known)
910}
911
912let seenSheet: { path: string; mtimeMs: number } | null = null
913
914async function refreshSheet($: EngineInterface, run: WorkflowRun): Promise<void> {
915 const path = sheetPathOf(run.argument)
916 if (path === null || run.runId.startsWith(DEMO)) return
917 const stat = await $.fs.stat(path).catch(() => null)
918 if (stat === null) return
919 if (run.sheet !== null && seenSheet?.path === path && seenSheet.mtimeMs === stat.mtimeMs) return
920 const text = await readText($, path)
921 if (text === null) return
922 seenSheet = { path, mtimeMs: stat.mtimeMs }
923 const inPlan = planLotOf(run.argument)
924 const sheet = inPlan === null ? parseSheet(text, path) : parseLotSection(text, inPlan.lot)
925 if (sheet === null) return
926 await update($, runs, stored => stored.map(one => (one.runId === run.runId ? { ...one, sheet } : one)))
927}
928
929async function refreshUsage($: EngineInterface): Promise<void> {
930 const figures = await $.session.usage().catch(() => null)
931 if (figures === null) return
932 await update($, usage, () => ({
933 context: figures.context.tokens ?? null,
934 percent: figures.context.percent ?? null,
935 cost: figures.cost?.usd ?? null,
936 }))
937}
938
939const tick = async ($: EngineInterface): Promise<void> => {
940 const list = (await read($, runs)).map(completeRun)
941 const last = list[list.length - 1]
942 if (last !== undefined) await refreshSheet($, last)
943 if (!list.some(run => run.status === 'running')) return
944 const at = await $.clock.now()
945 await update($, now, () => at)
946 await refreshUsage($)
947 await poll($)
948}
949
950export const blankAgent = (id: string, index: number, label: string, model: string, type: string, prompt: string, at: number): WorkflowAgent => ({
951 id, index, label, phase: null, model, effort: null, type, startedAt: at, endedAt: null, lastActiveAt: at, openCalls: 0, state: 'running',
952 lastTool: null, tools: 0, context: 0, contextStart: 0, contextPeak: 0, tokens: 0, cost: 0, prompt: firstLine(prompt), answer: null,
953 toolCounts: {}, files: [], lastError: null,
954})
955
956export const errorOf = (tool: string, result: { deny?: string; isError?: boolean; text?: string }): string | null => {
957 if (typeof result.deny === 'string') return `${tool}: ${firstLine(result.deny)}`
958 if (result.isError === true) return `${tool}: ${firstLine(result.text ?? 'error')}`
959 return null
960}
961
962const closeCall = async ($: EngineInterface, agentId: string): Promise<void> => {
963 const at = await $.clock.now()
964 await update($, runs, list =>
965 withAgent(list, agentId, agent => ({ ...agent, openCalls: Math.max(0, agent.openCalls - 1), lastActiveAt: at })),
966 )
967}
968
969export const register: Register = on => {
970 let bookedCost: number | null = null
971 let requested: Record<string, number> = {}
972 let root: string | null = null
973
974 on('session.start', async ($, e, next) => {
975 root = e.cwd
976 await $.command.register({
977 name: 'run-lot-pane',
978 description: 'Open the Workflow progress pane (clear forgets the finished run, demo loads a sample run, demo end a finished one)',
979 })
980 $.clock.every(TICK_MS, () => void tick($))
981 if ((await read($, runs)).some(run => run.status === 'running')) void openPane($)
982 return next(e)
983 })
984
985 on('command.run', { command: 'run-lot-pane' }, async ($, e) => {
986 if (e.args.trim() === 'clear') {
987 await update($, runs, list => list.filter(run => run.status === 'running' && !run.runId.startsWith(DEMO)))
988 return { text: 'Finished workflow run cleared.' }
989 }
990 if (e.args.trim() === 'demo' || e.args.trim() === 'demo end') {
991 const at = await $.clock.now()
992 const isFinished = e.args.trim() === 'demo end'
993 await update($, runs, list => [...list.filter(run => !run.runId.startsWith(DEMO)), ...demoRuns(at, isFinished)].slice(-KEPT_RUNS))
994 await update($, history, () => demoHistory())
995 await update($, now, () => at)
996 await refreshUsage($)
997 await openPane($)
998 return { text: `0.7.0: demo workflow run loaded${isFinished ? ' (finished)' : ''} (/run-lot-pane clear removes them).` }
999 }
1000 await openPane($)
1001 return { text: 'Workflow pane 0.7.0 opened.' }
1002 })
1003
1004 on('tool.call', async ($, e, next) => {
1005 if (e.agentId !== undefined) {
1006 const agentId = e.agentId
1007 if (isFollowed(await read($, runs), agentId)) {
1008 const audit = (e.tool as string) === 'StructuredOutput' ? auditOf(e) : null
1009 if (audit !== null) await update($, runs, list => withAudit(list, agentId, audit))
1010 const calledAt = await $.clock.now()
1011 await update($, runs, list =>
1012 withAgent(list, agentId, agent => ({
1013 ...agent,
1014 lastTool: e.tool,
1015 tools: agent.tools + 1,
1016 openCalls: agent.openCalls + 1,
1017 lastActiveAt: calledAt,
1018 toolCounts: { ...agent.toolCounts, [e.tool]: (agent.toolCounts[e.tool] ?? 0) + 1 },
1019 })),
1020 )
1021 const answered = await next(e).catch(async (failure: unknown) => {
1022 await closeCall($, agentId)
1023 throw failure
1024 })
1025 await closeCall($, agentId)
1026 const error = errorOf(e.tool, answered)
1027 const path = editedPath(e.tool, e as unknown as Record<string, unknown>)
1028 if (error !== null || path !== null) {
1029 await update($, runs, list =>
1030 withAgent(list, agentId, agent => ({
1031 ...agent,
1032 lastError: error ?? agent.lastError,
1033 files: path !== null && error === null ? withFile(agent.files, relativePath(path, root)) : agent.files,
1034 })),
1035 )
1036 }
1037 return answered
1038 }
1039 return next(e)
1040 }
1041 if (e.tool !== 'Workflow') return next(e)
1042
1043 const argument = argumentOf(e as unknown as Record<string, unknown>)
1044 const before = await $.clock.now()
1045 const result = await next(e)
1046 const launched = result.result as Launched | null | undefined
1047 if (!launched || launched.status !== 'async_launched' || launched.runId === undefined) return result
1048 const runId = launched.runId
1049 const script = launched.scriptPath === undefined ? null : await readText($, launched.scriptPath)
1050 const declared = script === null ? [] : parsePhases(script)
1051 const at = await $.clock.now()
1052 await update($, runs, list =>
1053 withInterrupted(
1054 withRun(list, runId, at, run => ({
1055 ...run,
1056 name: launched.workflowName ?? run.name,
1057 argument: argument ?? run.argument,
1058 phases: declared.length > 0 ? declared.map(phase => phase.title) : run.phases,
1059 details: declared.length > 0 ? detailsOf(declared) : run.details,
1060 transcriptDir: launched.transcriptDir ?? run.transcriptDir,
1061 launchedAt: before,
1062 status: 'running',
1063 durationMs: null,
1064 outcome: null,
1065 audit: null,
1066 sheet: null,
1067 })),
1068 runId,
1069 before,
1070 ),
1071 )
1072 await update($, now, () => at)
1073 await refreshUsage($)
1074 await openPane($)
1075 const launchedRun = (await read($, runs)).find(run => run.runId === runId)
1076 if (launchedRun !== undefined) void learnHistory($, launchedRun)
1077 return result
1078 }).catch(($, e, next) => next(e))
1079
1080 on('turn.step', async function* ($, e, next) {
1081 const result = yield* next(e)
1082 try {
1083 const agentId = e.agentId
1084 const used = result.usage
1085 if (used !== null) requested[agentId ?? MAIN] = (requested[agentId ?? MAIN] ?? 0) + weightOf(used)
1086 const figures = await $.session.usage().catch(() => null)
1087 const cost = figures?.cost?.usd ?? null
1088 let shares: Record<string, number> = {}
1089 if (cost !== null) {
1090 if (bookedCost === null) requested = {}
1091 else if (cost > bookedCost) {
1092 shares = splitCost(cost - bookedCost, requested)
1093 requested = {}
1094 }
1095 bookedCost = cost
1096 }
1097 const list = await read($, runs)
1098 const mine = agentId !== undefined && used !== null && isFollowed(list, agentId) ? agentId : null
1099 if (mine === null && Object.keys(shares).length === 0) return result
1100 const at = await $.clock.now()
1101 const effort = e.effort === undefined ? null : String(e.effort)
1102 await update($, runs, stored => withCosts(mine === null || used === null ? stored : withRequest(stored, mine, used, effort, at), shares))
1103 } catch {
1104 return result
1105 }
1106 return result
1107 })
1108
1109 on('agent.spawn', async ($, e, next) => {
1110 const spawned = await next(e)
1111 if (e.workflow === undefined || e.workflow.runId === '' || 'deny' in spawned || spawned.agentId === undefined) {
1112 return spawned
1113 }
1114 const { runId, agentIndex } = e.workflow
1115 const agentId = spawned.agentId
1116 const at = await $.clock.now()
1117 const agent = blankAgent(agentId, agentIndex, e.description || e.subagentType, shortModel(spawned.model), e.subagentType, e.prompt, at)
1118 await update($, runs, list =>
1119 withRun(list, runId, at, run => ({
1120 ...run,
1121 status: 'running',
1122 agents: [...run.agents.filter(one => one.id !== agentId), agent],
1123 })),
1124 )
1125 const dir = (await read($, runs)).find(run => run.runId === runId)?.transcriptDir
1126 if (dir) {
1127 const phase = await metaPhase($, dir, agentId)
1128 if (phase !== null) await update($, runs, list => withAgent(list, agentId, one => ({ ...one, phase })))
1129 }
1130 return spawned
1131 }).catch(($, e, next) => next(e))
1132
1133 on('turn.complete', async ($, e, next) => {
1134 const agentId = e.agentId
1135 if (agentId !== undefined && isFollowed(await read($, runs), agentId)) {
1136 const at = await $.clock.now()
1137 const state = e.isAborted || e.reason === 'error' || e.reason === 'refusal' ? 'error' : 'done'
1138 const answer = (e as { answer?: unknown }).answer
1139 const audit = typeof answer === 'string' ? auditOf(jsonOf(answer)) : null
1140 if (audit !== null) await update($, runs, list => withAudit(list, agentId, audit))
1141 await update($, runs, list =>
1142 withAgent(list, agentId, agent => ({
1143 ...agent,
1144 state,
1145 endedAt: at,
1146 lastActiveAt: at,
1147 openCalls: 0,
1148 answer: typeof answer === 'string' && answer.trim() !== '' ? firstLine(answer, 400) : agent.answer,
1149 })),
1150 )
1151 }
1152 return next(e)
1153 })
1154
1155 on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
1156 const { Box, Button, Text } = $.ui.resolve(e)
1157 const list = (await read($, runs)).map(completeRun)
1158 const at = await read($, now)
1159 const session = await read($, usage)
1160 const where = await read($, nav)
1161 const known = await read($, history)
1162 const current = list[list.length - 1]
1163 const columns = e.props.bodyColumns
1164 const spin = spinnerFrame(at)
1165 const barWidth = Math.max(8, Math.min(40, columns - 28))
1166 const go = (change: Partial<Navigation>) => () => void update($, nav, state => ({ ...state, ...change }))
1167 const close = <Button key="close" label="✕" plain dimColor onPress={() => $.ui.close({ id: PANE })} />
1168
1169 const row = (key: string, left: JSX.Element, right?: JSX.Element | JSX.Element[]) => (
1170 <Box key={key} height={1}>
1171 <Box flexGrow={1} flexShrink={1}>
1172 {left}
1173 </Box>
1174 {right}
1175 </Box>
1176 )
1177
1178 const agentTone = (agent: WorkflowAgent) =>
1179 agent.state === 'running' ? 'claude' : agent.state === 'error' ? 'error' : agent.state === 'interrupted' ? 'inactive' : 'success'
1180 const agentGlyph = (agent: WorkflowAgent) =>
1181 agent.state === 'running' ? spin : agent.state === 'error' ? '✗' : agent.state === 'interrupted' ? '⊘' : '✓'
1182 const stallOf = (agent: WorkflowAgent) => stallReasons(agent, typicalTools(known, current?.agents ?? [], agent), at)
1183 const agentAge = (agent: WorkflowAgent) => duration((agent.endedAt ?? at) - agent.startedAt)
1184 const markTone = (mark: PhaseRow['mark']) => (mark === 'running' ? 'claude' : mark === 'done' ? 'success' : 'inactive')
1185 const markGlyph = (mark: PhaseRow['mark']) => (mark === 'running' ? spin : mark === 'done' ? '⏺' : '○')
1186
1187 const section = (key: string, title: string, lines: JSX.Element[]) => (
1188 <Box key={key} flexDirection="column" marginTop={1}>
1189 <Text bold>{title}</Text>
1190 {lines}
1191 </Box>
1192 )
1193 const line = (key: string, text: string, color?: string, wrap: 'truncate-end' | 'truncate-start' | 'wrap' = 'truncate-end') => (
1194 <Text key={key} color={color} wrap={wrap}>
1195 {' '}
1196 {text}
1197 </Text>
1198 )
1199
1200 const agentButton = (agent: WorkflowAgent, prefix: string, from: Screen, mark = '') => (types/index.d.ts 117 lines1export type AgentState = 'running' | 'done' | 'error' | 'interrupted'
2
3export type WorkflowAgent = {
4 id: string
5 index: number
6 label: string
7 phase: string | null
8 model: string
9 effort: string | null
10 type: string
11 startedAt: number
12 endedAt: number | null
13 lastActiveAt: number
14 openCalls: number
15 state: AgentState
16 lastTool: string | null
17 tools: number
18 context: number
19 contextStart: number
20 contextPeak: number
21 tokens: number
22 cost: number
23 prompt: string
24 answer: string | null
25 toolCounts: Record<string, number>
26 files: string[]
27 lastError: string | null
28}
29
30export type RunStatus = 'running' | 'completed' | 'failed' | 'killed'
31
32export type Outcome = {
33 label: string
34 reason: string | null
35 report: string | null
36}
37
38export type AuditVerdict = {
39 verdict: 'VALIDE' | 'ECARTS' | 'ÉCARTS' | 'VALID' | 'GAPS'
40 blocking: number
41 major: number
42}
43
44export type PhaseDeclaration = {
45 title: string
46 detail: string | null
47}
48
49export type TddMarks = { red: string; green: string; cost: string }
50
51export type Correction = { id: string; finding: string; tdd: TddMarks | null }
52
53export type Step = {
54 number: string
55 title: string
56 isDone: boolean
57 tdd: TddMarks | null
58 corrections: Correction[]
59}
60
61export type Sheet = { lot: string; name: string; isDone: boolean; steps: Step[]; hypotheses: number }
62
63export type WorkflowRun = {
64 runId: string
65 name: string
66 argument: string | null
67 phases: string[]
68 details: Record<string, string>
69 transcriptDir: string | null
70 startedAt: number
71 launchedAt: number
72 durationMs: number | null
73 status: RunStatus
74 outcome: Outcome | null
75 audit: AuditVerdict | null
76 sheet: Sheet | null
77 agents: WorkflowAgent[]
78}
79
80export type Journal = {
81 status: RunStatus
82 durationMs: number | null
83 outcome: Outcome | null
84 argument: string | null
85 audit: AuditVerdict | null
86 phases: string[]
87 details: Record<string, string>
88 agentPhases: Record<string, string>
89}
90
91export type PhaseSample = { total: number; count: number; tools: number; toolCount: number; span: number; spanCount: number }
92
93export type PhaseSamples = Record<string, PhaseSample>
94
95export type SessionFigures = {
96 context: number | null
97 percent: number | null
98 cost: number | null
99}
100
101export type Screen = 'run' | 'phase' | 'agent'
102
103export type Navigation = {
104 screen: Screen
105 phase: string | null
106 agentId: string | null
107 back: Screen
108 toggled: string[]
109 isAllExpanded: boolean
110}
111
112declare module 'claude-code' {
113 interface PluginState {
114 'run-lot-pane': { runs: WorkflowRun[]; now: number; usage: SessionFigures; nav: Navigation; history: PhaseSamples }
115 }
116}
117