SLOPSHOPPER

cf

[Experimental] Contract-driven development pipeline with human-in-the-loop decision gating — agents defined by Context + Goal + Tools, not roles; a live…

newbandguardtimer
★ 2v0.8.9MITupdated 2026-10-09musingfox/cc-plugins/context-flow
A shopper browsing a rack in a slop shop
README

Context Flow

Contract-driven development pipeline with human-in-the-loop decision gating.

Philosophy

Agent = Context + Goal + Tools

Agents are NOT defined by roles. Each agent is defined by what information it receives, what output it must produce, and what tools it can use. Everything is connected by contracts — behavioral specifications with concrete test cases.

Usage

/cf "Add CSV export for transaction history"
/cf "Implement the contracts in docs/handoff-csv-export.md"

/cf takes one argument: the goal. There are no mode flags.

A goal that points at a handoff document carrying explicit, human-approved contracts runs in baton mode: research shrinks to a gap-scan of what the handoff does not cover, plan consumes its contracts verbatim, and the human gate collapses to breaking changes only. A document that merely states a direction is an ordinary goal.

Effort per Seat

Each agent pins its own model: and effort: in frontmatter; the orchestrator sets neither at dispatch. Effort rises with how much of the seat's work is still undecided, and the model follows it: cf:implement executes pinned contracts on sonnet at medium, while cf:research, cf:plan and cf:review run on opus at xhigh. cf:implement sits at medium rather than low because Sonnet at low sometimes reports a change done without running its check. The opus pin keeps the xhigh seats off Sonnet when the session runs on it, and keeps the judge at or above the builder.

The default implementer is the Claude cf:implement agent. OMP, a worker outside Claude Code, is the opt-in overflow builder (CF_IMPLEMENTER=omp); its model and thinking level come from PI_DISPATCH_CMD, not from frontmatter.

Pipeline

[research] → validate → [plan] → validate → HUMAN GATE (High decisions only)
    → [implement — cf:implement shards, OMP opt-in] → gates
    → [review — Standards + Spec] → verdict → spec upkeep → rebase

Phases

PhasePurpose
ResearchExplore codebase, produce capability inventory with constraints and evidence
PlanDesign behavioral contracts with test cases; tier decisions High/Medium/Low
Implementcf:implement agents fulfil the contracts in parallel shards, each in its own worktree; OMP workers do the same when opted in
ReviewTwo read-only reviews in parallel: Standards (the repo's documented conventions and a fixed set of code smells) and Spec (every contract, its test cases, and probed edges inside its declared domain). Only Spec routes the verdict

Key Features

  • Parallel sharded implementation: Contracts are grouped by the files they touch; each group runs as a cf:implement shard (or an opt-in OMP shard) in its own worktree, passes deterministic gates (report, survivors, the orchestrator's own test run, and a revert check that each contract's tests fail with its implementation reverted), and is integrated before review.
  • Decision tiering: Plan classifies decisions as High/Medium/Low impact. The human gate surfaces High decisions only; Medium and Low stay with the plan agent.
  • Behavioral contracts: Contracts define input/output/errors, not file paths. Implementation plan is separate guidance.
  • Opinionated orchestrator: At every human interaction, the orchestrator provides its own analysis and recommendation — not just a list to approve.
  • Loop-back with budget: Any phase can loop back. Every re-dispatch draws on one counter, max 4 retries per flow; reaching it forces a human check-in, not a hard stop.
  • Graceful degradation: Structured escalation with re-entry points. Agents provide decision support when stuck.
  • Spec upkeep: After a PASS, the orchestrator writes a proposed spec entry only when the flow left something the next flow must know — a deliberate non-goal, an interface to reuse, or a spec that proved stale. Most flows write none; the human promotes what they keep.
  • Pluggable agents: The flow defines contracts, not agents. Specialized agents can substitute defaults if they satisfy the same contract.

Progress Band

While a /cf flow runs, one line above the prompt shows ● cf <name> · <phase> · <elapsed>: the flow's slug, the phase it is in (setup, research, plan, implement, review), and whole minutes since the flow started. During implement it adds the shards passed over the total, the round in progress and the retries left; while a question to you is open it adds waiting for you and turns the dot to the warning colour. The band reads cf's own files and Bash commands, never changes them, and redraws every 10 seconds.

When a flow ends, a record of how its time split across the six totals (setup, research, plan, implement, review, waiting) is kept in the plugin store under the key flows, newest 100 only. Its outcome is cleanup (the flow ran its cleanup script), abandoned (a different flow replaced it) or session-end (the Claude session ended first). A flow that one command both opens and closes, such as a fresh session's first command sourcing env.sh and then running cleanup, keeps no record, because it has no timing to report.

The band is a Claude Mod and needs Claude Code 2.1.293+.

Installation

/plugin install context-flow

Worktree Permissions

Parallel cf:implement builders write their shard worktrees under /tmp: cf sessions live in /tmp/cf-*. To spare a permission prompt per shard, add both /tmp and /private/tmp (macOS resolves /tmp there) to permissions.additionalDirectories in ~/.claude/settings.json:

{ "permissions": { "additionalDirectories": ["/tmp", "/private/tmp"] } }

A top-level additionalDirectories key has no effect; it must sit under permissions.

Dependencies

  • The pi-dispatch plugin — required, and declared in plugin.json: it supplies the shard worktrees and the gate scripts that every Phase 3 builder runs through. Without it cf-pi-setup.sh exits 4 before the flow starts.
  • jq, perl and python3 — required on PATH; the Phase 3 scripts call them.

Optional Dependencies

  • pi (npm i -g @earendil-works/pi-coding-agent) with PI_DISPATCH_CMD set — optional, needed only for the OMP opt-in builder. Without it, setup records PI_AVAILABLE=0 and Phase 3 stays on cf:implement.
  • CF_IMPLEMENTER=omp — opt in to OMP as the Phase 3 builder; set it in the environment before /cf starts. cf-pi-setup.sh records the choice once as CF_IMPLEMENTER=<claude|omp> in the session's env.sh; unset, empty or any other value records claude.
  • ctx7 CLI (npm i -g ctx7 then ctx7 login) — enables research and implement phases to verify third-party library / API behavior with version-specific docs. Falls back to WebFetch if not installed. Without either, agents report Unresolved when the goal hinges on external behavior they can't infer from the local codebase.

Direct Sub-agent Invocation Caveat

Agents (@cf:research, @cf:plan, etc.) are designed to be dispatched by the /cf orchestrator, which hands each one exactly the inputs its contract names. If you invoke a sub-agent directly (e.g., @cf:research <goal>), it still runs on its pinned model and effort, but without the orchestrator's transition validation, human gate, and loop budget. Prefer /cf for full pipeline behavior.

Design Documentation

See docs/: parallel-sharded-design.md (Phase 3 sharding), pi-implementer-protocol.md (the implementer brief, report schema and gates), human-gate-protocol.md, and escalation-protocol.md.

Source 4 files
hooks/register.ts 221 lines
1import { atom, read, update } from 'claude-code'
2import type { On } from 'claude-code'
3import type { EndedFlow, FlowDetail, FlowEvent, FlowRecord, FlowTracker } from '../types/index.d.ts'
4import { cleanupTargetOf, implementFiguresOf, isImplementCommand, isSetupCommand, phaseOfAgent, sessionRootOf, setupRootOf, slugOf, sourcedRootOf } from './cf-signals.ts'
5import { appendFlowRecord, bandLineOf, reduceFlow } from './flow-model.ts'
6
7const POLL_MS = 10_000
8const MAX_RECORDS = 100
9
10const tracker = atom({ plugin: 'cf', key: 'tracker' } as const, { flow: null, lastEndedRoot: null } as FlowTracker)
11const detail = atom({ plugin: 'cf', key: 'detail' } as const, null as FlowDetail | null)
12
13let timer: { cancel(): void } | null = null
14
15// An event that needs the tracker to decide (cleanup of "whichever root is open") is a function of it.
16type Step = FlowEvent | ((tracker: FlowTracker) => FlowEvent | null)
17
18// What Claude's Bash result says: the tool's stdout, or the text the model reads when stdout is not a string.
19function outputOf(result: any): string {
20  const stdout = result?.result?.stdout
21  if (typeof stdout === 'string') return stdout
22  return typeof result?.text === 'string' ? result.text : ''
23}
24
25const readText = ($: any, path: string): Promise<string | null> => Promise.resolve($.fs.read(path)).then((text: unknown) => (typeof text === 'string' ? text : null), () => null)
26
27// One update runs every step; the update may retry, so `ended` and `opened` are those of the attempt that committed.
28async function applyEvents($: any, steps: Step[]): Promise<{ ended: EndedFlow[]; opened: boolean }> {
29  let ended: EndedFlow[] = []
30  let opened = false
31  await update($, tracker, (before: FlowTracker) => {
32    ended = []
33    let current = before
34    for (const step of steps) {
35      const event = typeof step === 'function' ? step(current) : step
36      if (!event) continue
37      const reduced = reduceFlow(current, event)
38      current = reduced.tracker
39      // A flow opened and closed inside this one batch never outlived the command: it is no record.
40      if (reduced.ended && reduced.ended.root === before.flow?.root) ended.push(reduced.ended)
41    }
42    opened = current.flow !== null && current.flow.root !== before.flow?.root
43    return current
44  })
45  return { ended, opened }
46}
47
48async function recordFlow($: any, ended: EndedFlow) {
49  try {
50    const record: FlowRecord = { v: 1, root: ended.root, slug: slugOf(await readText($, `${ended.root}/env.sh`)), startedAt: ended.startedAt, endedAt: ended.endedAt, outcome: ended.outcome, phases: ended.phases }
51    await $.store.set('flows', appendFlowRecord(await $.store.get('flows'), record, MAX_RECORDS))
52  } catch {
53    // A refused store loses the record, never the band.
54  }
55}
56
57async function recordAll($: any, ended: EndedFlow[]) {
58  for (const flow of ended) await recordFlow($, flow)
59}
60
61// Reads env.sh, and while the phase is implement cf's shard files; never invalidates: the detail write redraws.
62async function poll($: any) {
63  const { flow } = await read($, tracker)
64  if (!flow) return
65  const { root } = flow
66  const name = slugOf(await readText($, `${root}/env.sh`))
67  let figures: Pick<FlowDetail, 'shards' | 'round' | 'retriesLeft'> = { shards: null, round: null, retriesLeft: null }
68  if (flow.phase === 'implement') {
69    const shardsJson = await readText($, `${root}/shards.json`)
70    let ids: string[] = []
71    try {
72      ids = Object.keys(JSON.parse(shardsJson ?? 'null')?.groups ?? {})
73    } catch {
74      // implementFiguresOf reports the unreadable file as no shards.
75    }
76    // fromEntries defines own keys, so a group id like __proto__ is an entry, not a prototype write.
77    const outcomes = Object.fromEntries(await Promise.all(ids.map(async (id) => [id, await readText($, `${root}/shards/${id}/outcome.md`)] as const)))
78    figures = implementFiguresOf({ shardsJson, outcomes, dispatchStateJson: await readText($, `${root}/dispatch-state.json`), loopBudgetJson: await readText($, `${root}/loop-budget.json`) })
79  }
80  // A poll that outlived its flow must not overwrite the new flow's detail.
81  if ((await read($, tracker)).flow?.root !== root) return
82  await update($, detail, () => ({ root, name, ...figures }))
83}
84
85// The bookkeeping after a state change: closed flows go to the store, a newly opened root is polled once.
86function settle($: any, { ended, opened }: { ended: EndedFlow[]; opened: boolean }) {
87  if (ended.length) void recordAll($, ended).catch(() => {})
88  if (opened) void poll($).catch(() => {})
89}
90
91async function drawBand($: any, e: any, beneath: unknown) {
92  const { Box, Text } = await $.ui.resolve(e)
93  const line = bandLineOf(await read($, tracker), await read($, detail), await $.clock.now())
94  if (!line) return beneath
95  const warning = Text({ color: 'warning', children: ['waiting for you'] })
96  return Box({
97    flexDirection: 'column',
98    children: [
99      Text({
100        wrap: 'truncate-end',
101        children: [Text({ color: line.isWaiting ? 'warning' : 'suggestion', children: ['●'] }), line.head, ...(line.isWaiting ? [' · ', warning] : []), ` · ${line.elapsed}`],
102      }),
103      beneath,
104    ],
105  })
106}
107
108async function onTick($: any) {
109  if (!(await read($, tracker)).flow) return
110  void poll($).catch(() => {})
111  $.ui.invalidate('ui.render')
112}
113
114export function register(on: On) {
115  on('session.start', async ($, e, next) => {
116    // A reload re-fires session.start on this instance; a second timer would double the cadence.
117    timer?.cancel()
118    timer = $.clock.every(POLL_MS, () => {
119      void onTick($).catch(() => {})
120    })
121    return next(e)
122  })
123
124  on('tool.call', { tool: 'Bash' }, async ($, e, next) => {
125    if (e.agentId !== undefined) return next(e)
126    let command = ''
127    try {
128      command = String(e.command ?? '')
129      const root = sourcedRootOf(command)
130      const target = cleanupTargetOf(command)
131      const implement = isImplementCommand(command)
132      if (root || target || implement) {
133        const at = await $.clock.now()
134        const steps: Step[] = []
135        if (root) steps.push({ kind: 'root', root, at })
136        if (implement) steps.push({ kind: 'phase', phase: 'implement', at })
137        if (target) steps.push((t) => (t.flow && (target === 'any' || target === t.flow.root) ? { kind: 'end', outcome: 'cleanup', at } : null))
138        settle($, await applyEvents($, steps))
139      }
140    } catch {
141      // The command still runs when the band cannot follow it.
142    }
143    const result = await next(e)
144    void (async () => {
145      const output = outputOf(result)
146      const root = sessionRootOf(output) ?? (isSetupCommand(command) ? setupRootOf(output) : null)
147      if (root) settle($, await applyEvents($, [{ kind: 'root', root, at: await $.clock.now() }]))
148    })().catch(() => {})
149    return result
150  })
151
152  on('tool.call', { tool: 'Agent' }, async ($, e, next) => {
153    const phase = e.agentId === undefined ? phaseOfAgent(e.subagent_type) : null
154    if (phase) {
155      try {
156        await applyEvents($, [{ kind: 'phase', phase, at: await $.clock.now() }])
157      } catch {
158        // The agent still runs.
159      }
160    }
161    return next(e)
162  })
163
164  on('tool.call', { tool: 'AskUserQuestion' }, async ($, e, next) => {
165    if (e.agentId !== undefined) return next(e)
166    let id = ''
167    try {
168      const at = await $.clock.now()
169      id = e.tool_use_id ?? `ask@${at}`
170      await applyEvents($, [{ kind: 'wait-start', id, at }])
171    } catch {
172      // The dialog opens without its mark.
173    }
174    try {
175      return await next(e)
176    } finally {
177      try {
178        await applyEvents($, [{ kind: 'wait-end', id, at: await $.clock.now() }])
179      } catch {
180        // A mark left open is cleared by the turn's end.
181      }
182    }
183  })
184
185  on('turn.complete', async ($, e, next) => {
186    if (e.agentId === undefined) {
187      try {
188        if ((await read($, tracker)).flow?.waits.length) await applyEvents($, [{ kind: 'turn-end', at: await $.clock.now() }])
189      } catch {
190        // The turn still completes.
191      }
192    }
193    return next(e)
194  })
195
196  on('session.end', async ($, e, next) => {
197    try {
198      const { ended } = await applyEvents($, [{ kind: 'end', outcome: 'session-end', at: await $.clock.now() }])
199      await recordAll($, ended)
200    } catch {
201      // The session still ends.
202    }
203    return next(e)
204  })
205
206  on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
207    if (e.props?.hasSurvey) return next(e)
208    try {
209      if (!(await read($, tracker)).flow) return next(e)
210    } catch {
211      return next(e)
212    }
213    const beneath = await next(e)
214    try {
215      return await drawBand($, e, beneath)
216    } catch {
217      return beneath
218    }
219  })
220}
221
types/index.d.ts 50 lines
1export type FlowPhase = 'setup' | 'research' | 'plan' | 'implement' | 'review'
2
3export type FlowBucket = FlowPhase | 'waiting'
4
5// `since` is the last moment time was accrued; `waits` holds the open AskUserQuestion ids.
6export type FlowState = {
7  root: string
8  startedAt: number
9  phase: FlowPhase
10  since: number
11  waits: string[]
12  totals: Record<FlowBucket, number>
13}
14
15export type FlowTracker = { flow: FlowState | null; lastEndedRoot: string | null }
16
17export type FlowOutcome = 'cleanup' | 'abandoned' | 'session-end'
18
19export type FlowEvent =
20  | { kind: 'root'; root: string; at: number }
21  | { kind: 'phase'; phase: 'research' | 'plan' | 'implement' | 'review'; at: number }
22  | { kind: 'wait-start'; id: string; at: number }
23  | { kind: 'wait-end'; id: string; at: number }
24  | { kind: 'turn-end'; at: number }
25  | { kind: 'end'; outcome: 'cleanup' | 'session-end'; at: number }
26
27export type EndedFlow = {
28  root: string
29  startedAt: number
30  endedAt: number
31  outcome: FlowOutcome
32  phases: Record<FlowBucket, number>
33}
34
35export type FlowRecord = EndedFlow & { v: 1; slug: string | null }
36
37export type ImplementFiguresResult = {
38  shards: { passed: number; total: number } | null
39  round: number | null
40  retriesLeft: number | null
41}
42
43export type FlowDetail = ImplementFiguresResult & { root: string; name: string | null }
44
45declare module 'claude-code' {
46  interface PluginState {
47    cf: { tracker: FlowTracker; detail: FlowDetail | null }
48  }
49}
50
hooks/cf-signals.ts 91 lines
1import type { FlowPhase, ImplementFiguresResult } from '../types/index.d.ts'
2
3const ROOT = '/tmp/cf-\\d{4}-[A-Za-z0-9]{4}'
4
5// cf-pi-setup.sh prints the session path alone on stdout; cf.md echoes it again as SESSION=<root>.
6export function sessionRootOf(stdout: string): string | null {
7  return stdout.match(new RegExp(`^SESSION=(${ROOT})$`, 'm'))?.[1] ?? null
8}
9
10// A regex cannot see quoting or heredocs, so a separator inside a quoted string or a heredoc body still counts as a position.
11function executedPattern(name: string, runner: 'optional' | 'source', prefix: boolean): RegExp {
12  const position = '(?:^|(?<=[\\n;&|()])|(?<=\\{)(?=\\s)|(?<!\\w)(?:do|then|else)(?!\\w))'
13  const assignments = `(?:\\w+=(?:'[^']*'|"[^"]*"|(?!['"])[^\\s;&|()]*)[ \\t]+)*`
14  const runnerWord = runner === 'source' ? '(?:\\.|source)[ \\t]+(?!["\']?-)' : '(?:(?:bash|sh|\\.|source)[ \\t]+(?!["\']?-))?'
15  const path = prefix ? '(?:[^\\s"\';&|()]*\\/)?' : ''
16  return new RegExp(`${position}[ \\t]*${assignments}${runnerWord}["']?${path}${name}["']?(?=[\\s;&|)<>]|$)`, 'g')
17}
18
19// Every cf Bash after setup starts with `. "<root>/env.sh"`; a shard's env.sh sits one level deeper and does not match.
20export function sourcedRootOf(command: string): string | null {
21  const found = [...command.matchAll(executedPattern(`(${ROOT})/env\\.sh`, 'source', false))]
22  return found.at(-1)?.[1] ?? null
23}
24
25export function phaseOfAgent(subagentType: unknown): Exclude<FlowPhase, 'setup'> | null {
26  switch (subagentType) {
27    case 'cf:research': return 'research'
28    case 'cf:plan': return 'plan'
29    case 'cf:implement': return 'implement'
30    case 'cf:review': return 'review'
31    default: return null
32  }
33}
34
35export const isImplementCommand = (command: string): boolean => executedPattern('cf-pi-(?:worktree|shard|run)\\.sh', 'optional', true).test(command)
36
37export const isSetupCommand = (command: string): boolean => executedPattern('cf-pi-setup\\.sh', 'optional', true).test(command)
38
39// A setup whose output has no SESSION= line alone still prints the root; the first root not followed by more name wins.
40export const setupRootOf = (output: string): string | null => output.match(new RegExp(`(${ROOT})(?![A-Za-z0-9])`))?.[1] ?? null
41
42// 'any' when the command runs whatever env.sh's CLEANUP_SCRIPT names; else the root of a literal <root>/cleanup.sh.
43export function cleanupTargetOf(command: string): 'any' | string | null {
44  if (executedPattern('\\$(?:CLEANUP_SCRIPT|\\{CLEANUP_SCRIPT\\})', 'optional', false).test(command)) return 'any'
45  return executedPattern(`(${ROOT})/cleanup\\.sh`, 'optional', false).exec(command)?.[1] ?? null
46}
47
48// The last CF_SLUG line wins: cf-pi-worktree.sh appends a collision-bumped one.
49export function slugOf(envSh: string | null): string | null {
50  const lines = (envSh ?? '').split('\n').filter((line) => line.startsWith('CF_SLUG='))
51  const value = lines.at(-1)?.slice('CF_SLUG='.length).trim().replace(/^"(.*)"$/, '$1')
52  return value ? value : null
53}
54
55const parse = (text: string | null): any => {
56  if (text === null) return null
57  try {
58    const value = JSON.parse(text)
59    return value !== null && typeof value === 'object' ? value : null
60  } catch {
61    return null
62  }
63}
64
65const isPass = (outcome: string | null): boolean => {
66  const lines = (outcome ?? '').split('\n')
67  const at = lines.findIndex((line) => line.trim() === '## Status')
68  return at >= 0 && lines[at + 1]?.trim() === 'PASS'
69}
70
71export function implementFiguresOf(input: {
72  shardsJson: string | null
73  outcomes: Record<string, string | null>
74  dispatchStateJson: string | null
75  loopBudgetJson: string | null
76}): ImplementFiguresResult {
77  const shardsFile = parse(input.shardsJson)
78  const retries = parse(input.loopBudgetJson)?.retries_used
79  const retriesLeft = typeof retries === 'number' ? Math.max(0, 4 - retries) : null
80  if (!shardsFile) return { shards: null, round: null, retriesLeft }
81  const ids = Object.keys(shardsFile.groups ?? {})
82  const outcomeOf = (id: string) => Object.hasOwn(input.outcomes, id) ? (input.outcomes[id] ?? null) : null
83  const currentRound = parse(input.dispatchStateJson)?.current_round
84  const running = ids.some((id) => outcomeOf(id) === null)
85  return {
86    shards: { passed: ids.filter((id) => isPass(outcomeOf(id))).length, total: typeof shardsFile.fan_out_count === 'number' ? shardsFile.fan_out_count : ids.length },
87    round: Math.max(1, (typeof currentRound === 'number' ? currentRound : 0) + (running ? 1 : 0)),
88    retriesLeft,
89  }
90}
91
hooks/flow-model.ts 69 lines
1import type { EndedFlow, FlowBucket, FlowDetail, FlowEvent, FlowRecord, FlowState, FlowTracker } from '../types/index.d.ts'
2
3const ZERO: Record<FlowBucket, number> = { setup: 0, research: 0, plan: 0, implement: 0, review: 0, waiting: 0 }
4
5const open = (root: string, at: number): FlowState => ({ root, startedAt: at, phase: 'setup', since: at, waits: [], totals: { ...ZERO } })
6
7// Adds the time since the last accrual to the bucket then current, never a negative amount.
8function accrue(flow: FlowState, at: number): FlowState {
9  const bucket: FlowBucket = flow.waits.length > 0 ? 'waiting' : flow.phase
10  return { ...flow, since: Math.max(flow.since, at), totals: { ...flow.totals, [bucket]: flow.totals[bucket] + Math.max(0, at - flow.since) } }
11}
12
13const endedOf = (flow: FlowState, outcome: EndedFlow['outcome']): EndedFlow => ({ root: flow.root, startedAt: flow.startedAt, endedAt: flow.since, outcome, phases: { ...flow.totals } })
14
15// An event that does not apply returns the same tracker object, so callers can tell a no-op by identity.
16export function reduceFlow(tracker: FlowTracker, event: FlowEvent): { tracker: FlowTracker; ended: EndedFlow | null } {
17  const same = { tracker, ended: null }
18  const { flow } = tracker
19  if (event.kind === 'root') {
20    if (event.root === flow?.root || event.root === tracker.lastEndedRoot) return same
21    if (!flow) return { tracker: { flow: open(event.root, event.at), lastEndedRoot: tracker.lastEndedRoot }, ended: null }
22    return { tracker: { flow: open(event.root, event.at), lastEndedRoot: flow.root }, ended: endedOf(accrue(flow, event.at), 'abandoned') }
23  }
24  if (!flow) return same
25  switch (event.kind) {
26    case 'phase':
27      return event.phase === flow.phase ? same : { tracker: { ...tracker, flow: { ...accrue(flow, event.at), phase: event.phase } }, ended: null }
28    case 'wait-start': {
29      if (flow.waits.includes(event.id)) return same
30      const next = accrue(flow, event.at)
31      return { tracker: { ...tracker, flow: { ...next, waits: [...next.waits, event.id] } }, ended: null }
32    }
33    case 'wait-end': {
34      if (!flow.waits.includes(event.id)) return same
35      const next = accrue(flow, event.at)
36      return { tracker: { ...tracker, flow: { ...next, waits: next.waits.filter((id) => id !== event.id) } }, ended: null }
37    }
38    case 'turn-end':
39      return flow.waits.length === 0 ? same : { tracker: { ...tracker, flow: { ...accrue(flow, event.at), waits: [] } }, ended: null }
40    case 'end':
41      return { tracker: { flow: null, lastEndedRoot: flow.root }, ended: endedOf(accrue(flow, event.at), event.outcome) }
42  }
43}
44
45export const elapsedOf = (ms: number): string => {
46  const minutes = Math.max(0, Math.floor(ms / 60_000))
47  return minutes < 60 ? `${minutes}m` : `${Math.floor(minutes / 60)}h ${minutes % 60}m`
48}
49
50export type BandLine = { head: string; isWaiting: boolean; elapsed: string }
51
52// `head` is everything between the dot and the waiting mark: the name, the phase and the implement figures.
53export function bandLineOf(tracker: FlowTracker, detail: FlowDetail | null, nowMs: number): BandLine | null {
54  const { flow } = tracker
55  if (!flow) return null
56  const mine = detail?.root === flow.root ? detail : null
57  const name = mine?.name ?? flow.root.slice(flow.root.lastIndexOf('/') + 1)
58  const figures: string[] = []
59  if (flow.phase === 'implement' && mine) {
60    if (mine.shards) figures.push(`${mine.shards.passed}/${mine.shards.total} shards`)
61    if (mine.round !== null) figures.push(`round ${mine.round}`)
62    if (mine.retriesLeft !== null) figures.push(mine.retriesLeft === 1 ? '1 retry left' : `${mine.retriesLeft} retries left`)
63  }
64  return { head: ` cf ${name} · ${[flow.phase, ...figures].join(' · ')}`, isWaiting: flow.waits.length > 0, elapsed: elapsedOf(nowMs - flow.startedAt) }
65}
66
67export const appendFlowRecord = (existing: unknown, record: FlowRecord, max: number): FlowRecord[] =>
68  (Array.isArray(existing) ? [...existing, record] : [record]).slice(-max)
69