lib/flow/ is the spine that makes nine studios behave like one platform, and the thing the product is named for. It owns no facts of its own: every module here is a pure function over registries and ledgers that already existed.
| Module | Answers |
|---|---|
state.ts | Where is this session, and is it going anywhere? |
pipeline.ts | What longer piece of work is this part of? |
next.ts | What is the next move worth making? |
motion.ts | How does this platform move? |
simulate.ts | If this diagram were real, where would the work pile up? |
graph-adapter.ts | How do I turn what somebody drew into something runnable? |
Everything is deterministic, clock-free (the caller passes now) and free of React, the DOM and the network, so the same answers are available to a server route, a client component and a test.
1. Flow state — state.ts
The Continuum layer already recorded the two facts that matter: the Pulse (lib/platform/pulse.ts) remembers where you have been working, and the Lineage ledger (lib/platform/lineage.ts) remembers each piece of work you handed from one studio to another. Nothing read them together, so the platform could show a list of recent places and still have no idea that you had switched studios five times in eight minutes and finished nothing.
flowState(touches, handoffs, now) // → { phase, momentum, depthMs, switches, carries, streakDays, … }
A session is the run of work with no gap longer than 20 minutes. Within it the module measures three things and nothing else:
- depth — minutes since the last studio switch, the best proxy for concentration observable without watching keystrokes;
- switching — studio changes inside a recent window, the thing that most reliably destroys depth;
- carry — handoffs recorded in the session, the one signal that work moved forward rather than merely moved.
Five phases: idle, warming, flowing, deep, scattered. Only scattered is not congratulatory, and it exists to be acted on — the console offers to carry the current document forward — not to scold.
How a phase READS is PHASE_LOOK in the same module: one word and one colour, for every surface that shows the reading. It lives beside the classifier because the Flow Dock and the Flow Console each used to keep a table of their own and the two disagreed on both halves — "Scattered" was amber in one and pink in the other, and the same state read "Flowing"/"Deep" here and "In flow"/"Deep work" there. One ledger described two ways is precisely the incoherence this layer exists to remove.
Nothing here is persisted or sent anywhere. It is computed on the client from ledgers that were already on the client, and it dies with the tab. A "focus score" that leaves the device is surveillance whatever the marketing calls it.
2. Pipelines — pipeline.ts
Some sequences of moves are the whole point: a value stream map is worth drawing because a control chart comes after it; a fault tree is half an analysis until a bowtie hangs the barriers on it; a sketch is a diagram is a reviewed diagram is a figure in a paper.
A pipeline is data, not code:
{ id, title, blurb, family, steps: [{ capabilityId, label, why }] }
Every capabilityId is a real id from the Kernel registry (lib/kernel/capabilities.ts) — a studio, an engine or a platform action. Nothing here invents a capability, which is what stops the file becoming a wish list. validatePipeline resolves every step and, crucially, intersects the material kinds pairwise: a step must be able to accept what the previous one produces. That catches the mistake every hand-written "recommended workflow" eventually makes — suggesting a photo be opened in the BPMN modeller.
tests/flow-pipeline.test.ts runs the validator over every built-in, so a pipeline that stops being coherent fails the build rather than shipping.
pipelineProgress(pipeline, touches, handoffs, sinceTs) ticks the steps the ledgers show you have taken. The evidence is deliberately loose — the Pulse records the studio you worked in, not "you ran the review agent" — so it over-counts a little and under-counts never. An optimistic progress bar that occasionally says you are further along beats a pessimistic one that forgets work you did, and nothing is gated on the number.
sinceTs is not optional in practice, and the Flow Console learned that the hard way. Called without one it counts the WHOLE Pulse horizon and all sixty lineage rows, and because the rule is optimistic (done = 0..reached), one visit to /bpmn days ago marked every earlier step of "Diagnose a slow system" complete — so for any regular user most pipelines read as in progress, permanently. The console passes the current session's start, or failing that the last day: "what am I in the middle of" has to mean now.
3. Next moves — next.ts
Three surfaces already answered "what next?" and all three answered from a hardcoded list, so the same document could be offered three different obvious next steps depending which corner of the screen you asked — and none of them knew about an engine added last week.
nextMoves(ctx) is the shared answer, derived rather than listed. Two bands, and heuristics may never cross between them:
- a pipeline suggestion is a transition a human wrote down about this exact pair of steps;
- a base score by capability kind is a guess.
Letting a "+0.06 you'd be changing studio" nudge lift a guess above a written-down transition is exactly the scorer arithmetic that produces confidently wrong advice, so the modifiers apply to the heuristic band only.
4. Motion — motion.ts
Six durations, four easings, and the rules for choosing between them. Duration scales with the distance a thing travels and the size of what changes, never with how important it is. Only transform and opacity are animated — everything else is a layout or a paint on every frame.
The same numbers are published as CSS custom properties in app/globals.css (--flow-duration-*, --flow-ease-*), and tests/flow-motion.test.ts pins the two together so a value cannot be changed in one place only.
prefers-reduced-motion: reduce means somebody told their operating system that movement makes them unwell. Honouring that by making animations shorter is not honouring it: every duration collapses to zero, so the state change still happens, instantly, and no transition-end handler waits for an event that never fires.
5. Simulation — simulate.ts + graph-adapter.ts
Every diagramming tool draws a process. This one runs it.
const graph = weaveToFlowGraph(nodes, edges) // or sourceToFlowGraph("mermaid", code)
const result = simulateFlow(graph, { arrivals: 24 })
const frame = positionsAt(result, t) // 60fps playback, no re-simulation
simulateFlow is a discrete-event stepper returning trajectories, not frames — so the animation can be scrubbed, slowed, reversed and resized without re-running anything — plus the statistics that make the drawing tell you something you did not already know: utilisation per node, mean and maximum queue length, waiting time, throughput, work-in-progress over time, and the end-to-end lead time of every token that made it out.
There is no random number generator anywhere in the file. A simulation seeded from Math.random() gives a different answer every time you press play, so "did my change help?" becomes unanswerable — the tool would be adding noise to the very comparison it exists to support. Branching is resolved by deficit round robin: over any run of N tokens the split matches the declared weights to within one token, exactly, every time.
Cycles are legal (rework loops are the most interesting thing a process diagram contains), so termination cannot rely on acyclicity. The run is bounded three ways — a time horizon, an event cap and a per-token hop limit — and every bound is reported rather than hidden, because a truncated simulation presented as a complete one is worse than no simulation.
graph-adapter.ts is the other half: simulation must never require preparation. An un-annotated box is a task that takes one unit, a node with two outgoing edges is a decision, a node nothing points at is where work enters. Press play on a diagram drawn for a meeting and something sensible happens; annotate it and the answer gets sharper. Timings are read from structured shape data first and from the label second — because nobody fills in a properties panel to try a feature once, but people have always written Review (2d) in the label. describeAssumptions() says out loud how many timings were guessed, since a default the user cannot see is a lie by omission.
Surfaces
/flow— the Flow Console: the session reading, the ranked next moves, and every pipeline with real progress ticked off the ledgers. A next move whose capability names a specialist (FlowMove.agentId) is a BUTTON that buffers the ask on the same channel ⌘K uses (lib/kernel/dispatch) and then navigates; one without is a plain link. Every card used to be a link, so "Review and fix" opened a studio with nothing queued — the dead-button silence the readiness layer was built to remove. The console's clock is re-stamped whenever the Pulse or Lineage ledgers move, so a console left open on a second monitor follows the work happening in the next tab instead of freezing at the moment it was opened.- Weave —
components/weave/FlowSimulation.tsxmounts the transport and the token layer inside the canvas.
