FlowEngine

The deterministic state machine at the heart of xCoder. A typed FlowState, an acceptance check at every gate, and named bypass discipline. Built on LangGraph TS but usable as a plain TypeScript class — pick the shape that fits your host.

Package

Ships separately as @xcoder/flow-engine so non-xCoder hosts can adopt it without inheriting xCoder's CLI.

bash
# in the monorepo
pnpm --filter @xcoder/flow-engine build
pnpm --filter @xcoder/flow-engine test

FlowState — the persistent shape

Every tick reads and writes one FlowState object. The engine never mutates fields out-of-phase: e.g., prNumber stays null until the agent reaches PR phase and the acceptance check verifies the PR exists.

ts
interface FlowState {
  cwd: string                          // repo root, absolute
  integrationBranch: string            // 'main', 'develop', etc.
  phase: FlowPhase                     // current phase

  taskId: string | null                // active task identifier
  issueNumber: number | null           // GitHub issue, required to leave BRANCH
  branch: string | null                // working branch (must not be integration)
  branchPrefix: string | null
  specPath: string | null              // required if specsRequired
  prNumber: number | null              // required to leave PR

  metrics: TaskRunMetrics              // duration, turns, cost, tokens
  acceptanceLog: AcceptanceResult[]    // append-only within a run
  recentEvents: SupervisorEvent[]
  bypasses: BypassRecord[]             // named overrides for the active task

  lastError: string | null
  iterations: number
  iterationCap: number                 // hard cap on test↔implement loop
  contextRefs: string[]
}

Phases & transitions

text
idle → scope → issue → branch → spec → implement → test → commit → qa → pr → review → merge → idle

The transition table lives in engine.ts as a frozen Record<FlowPhase, FlowPhase>. There is exactly one source of truth for forward motion. The iter loop between test and implement is encoded by the host (the agent re-enters implement when test fails); the engine doesn't model that loop directly.

Acceptance checks

At every phase exit, the engine runs the registered acceptance function(s) for that phase. The result is a typed AcceptanceResult:

ts
interface AcceptanceResult {
  phase: FlowPhase            // phase being LEFT (not entered)
  invariant: string           // e.g. "I-3"
  checkId: string             // stable id like "trackingIssueExists"
  pass: boolean
  reason: string              // always populated, even on pass
  at: string                  // ISO 8601
  bypassedBy?: BypassRecord   // set if a bypass converted pass=false → true
}

The engine refuses to advance unless the result is pass=true OR a matching bypass is on file. Either way, the result is appended to state.acceptanceLog and emitted as a flow.acceptance.passed / flow.acceptance.failed event.

Checks per phase

Phase exitChecksMapped invariants
branch
  • trackingIssueExists
  • branchMatchesPrefix
I-3, I-4
spec
  • specFileExistsIfRequired
I-5
commit
  • conventionalCommitFormat
I-7
pr
  • prOpenedBeforeIdle
I-9

Two operating shapes

1. Tick mode (deterministic)

For host applications that already have their own loop — for example, the xCoder autopilot — the engine exposes a plain tick():

ts
import { FlowEngine } from '@xcoder/flow-engine'

const engine = new FlowEngine({
  cwd: process.cwd(),
  integrationBranch: 'main',
})

engine.startTask('build-auth')
engine.patch({ issueNumber: 42, branch: 'feat/build-auth' })

const result = await engine.tick()
// {
//   advanced: true,
//   from: 'branch',
//   to: 'spec',
//   reason: 'Branch "feat/build-auth" matches prefix "feat/".',
// }

Each tick() attempts exactly one phase advance. If the acceptance check passes (or a bypass applies), the engine moves to the next phase and returns advanced: true. If not, the engine stays put, state.lastError is set, and advanced: false with the reason.

2. Compiled mode (LangGraph)

For hosts that want native LangGraph features — checkpointing, interrupts, visualization — the engine compiles into a real StateGraph:

ts
const graph = engine.compile()
const final = await graph.invoke({ phase: 'idle', taskId: null, lastError: null })

Same acceptance discipline; the graph node calls engine.tick() internally. Pick whichever shape your host wants.

Recording bypasses

Three ways:

From code

ts
engine.recordBypass({
  source: 'command',
  identifier: 'xc flow override',
  invariant: 'I-3',
  phase: 'branch',
  reason: 'spike — issue not yet filed',
  at: new Date().toISOString(),
})

From the CLI

bash
xc flow override I-3 --reason "spike — issue not yet filed"

From the shell environment

bash
# one-shot bypass for branch-guard (I-1, I-2)
XCODER_ALLOW_INTEGRATION_EDIT=1 git commit -m "hotfix: ..."

All bypasses log

Every bypass — regardless of source — emits a flow.bypass event with the reason and source. Run xc flow events --type flow.bypass to audit.

Events

The engine writes one append-only JSONL log per repo at .xcoder/flow-events.jsonl. Override path with FLOW_EVENTS_PATH env var (testing).

ts
type SupervisorEventType =
  // Engine
  | 'flow.transition'
  | 'flow.acceptance.passed' | 'flow.acceptance.failed'
  | 'flow.bypass'
  | 'flow.guard.allow' | 'flow.guard.block' | 'flow.guard.bypass'
  | 'flow.tool.invoked' | 'flow.commit.format.violation'
  // Supervisor protocol (Part 2)
  | 'supervisor.question.sent' | 'supervisor.reply.received'
  | 'supervisor.budget.warning' | 'supervisor.budget.halt'
  | 'task.completed' | 'task.failed.scoped-down' | 'task.failed.escalated'
  | 'iteration.cap.hit'
  // Self-improvement
  | 'self-improvement.opportunity'
  | 'self-improvement.regression-detected'

Extension points

The engine exposes four typed registries — drop in custom phases, guards, agent drivers, or notifier adapters:

RegistryPlugin shape
PhaseRegistryPhase { id; description; enter?; accept; prompt? }
GuardRegistryGuard { id; evaluate(GuardInput): GuardResult }
AgentDriverRegistryAgentDriver { id; available; runPhase }
NotifierRegistryNotifierAdapter { id; send(event, body) }

Re-registering an existing id replaces it — that's how a host application overrides defaults.

v1 coverage

11 of 15 invariants in the contract are mechanically enforced today.

  • live I-1, I-2 (no-edit-on-integration policy)
  • live I-3, I-4, I-5, I-7, I-9 (engine acceptance)
  • live I-6 (typecheck-must-pass-before-commit policy)
  • live I-8 (commit-must-reference-issue policy)
  • live I-10 (merge-gate analyzer)
  • live I-13 (flow.bypass emission)
  • partial I-11 (autopilot adapter is observability-only in v1; hard-gate refactor in v2)
  • partial I-12 (track-tool-calls writes a separate JSONL today; flow.tool.invoked wiring is in progress)
  • post-v1 I-14, I-15 (supervisor protocol — non-blocking questions, budget halt-on-exceed)

Next