SLOPSHOPPER

run-lot-pane

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

newpaneguardcommandtoasttimer
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · run-lot-pane
│ ┃ Workflow ✕ › fix the failing auth test and add an audit log call │ ┃ │ ┃ ✻ Workflow ✕ ⏺ Read(src/auth.ts) │ ┃ ⎿ No workflow run yet in this session. ⎿ Read 6 lines │ ⏺ Update(src/auth.ts) │ ⎿ Added 2 lines, removed 1 line │ ⏺ Bash(bun test) │ ⎿ 3 pass, 1 fail │ │ ● Done. refresh now rejects expired claims and logs an audit event. │ │ ✻ Worked for 42s · done 4:20 PM │ │ › /run-lot-pane │ ⎿ run-lot-pane: Workflow pane 0.7.0 opened. │ │ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Pane · Workflow
✻ Workflow ✕ ⎿ No workflow run yet in this session.
README

Claude Code Toolkit

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.

Quick start

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.

How it works

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
  1. /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.
  2. /cctoolkit:plan-implementation — turns the spec into a technical plan, split into small batches.
  3. /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.

Watch a batch run

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.

run-lot-pane during a batch

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.

Opinionated by design

This kit encodes one way of working. It will:

  • write the test before the code, every time;
  • keep changes surgical — no drive-by refactors;
  • tie every business rule to the tests that cover it;
  • never commit — you decide when.

If your team works differently, half of it will feel like noise. Full list: skills/README.md.

Learn more

Install, update, dependenciesINSTALL.md
A feature from spec to reportexamples/
Every skill and agentskills/README.md
Autonomous runs, finishing a batch by handdocs/TOOLING.md
Layouts and configurationpresets/README.md
Other repos configuring a coding agentRESOURCES.md

Licence

MIT. Take what you want, closed-source projects included.

Source 2 files
hooks/register.tsx 1700 lines
1import { 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 lines
1export 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