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

Contract-driven development pipeline with human-in-the-loop decision gating.
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.
/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.
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.
[research] → validate → [plan] → validate → HUMAN GATE (High decisions only)
→ [implement — cf:implement shards, OMP opt-in] → gates
→ [review — Standards + Spec] → verdict → spec upkeep → rebase
| Phase | Purpose |
|---|---|
| Research | Explore codebase, produce capability inventory with constraints and evidence |
| Plan | Design behavioral contracts with test cases; tier decisions High/Medium/Low |
| Implement | cf:implement agents fulfil the contracts in parallel shards, each in its own worktree; OMP workers do the same when opted in |
| Review | Two 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 |
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.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.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+.
/plugin install context-flow
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.
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.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.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.
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.
hooks/register.ts 221 lines1import { 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}
221types/index.d.ts 50 lines1export 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}
50hooks/cf-signals.ts 91 lines1import 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}
91hooks/flow-model.ts 69 lines1import 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