Package
Ships separately as @xcoder/flow-engine so non-xCoder hosts can adopt it without inheriting xCoder's CLI.
# in the monorepo
pnpm --filter @xcoder/flow-engine build
pnpm --filter @xcoder/flow-engine testFlowState — 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.
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
idle → scope → issue → branch → spec → implement → test → commit → qa → pr → review → merge → idleThe 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:
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 exit | Checks | Mapped invariants |
|---|---|---|
branch |
| I-3, I-4 |
spec |
| I-5 |
commit |
| I-7 |
pr |
| 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():
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:
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
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
xc flow override I-3 --reason "spike — issue not yet filed"From the shell environment
# 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).
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:
| Registry | Plugin shape |
|---|---|
| PhaseRegistry | Phase { id; description; enter?; accept; prompt? } |
| GuardRegistry | Guard { id; evaluate(GuardInput): GuardResult } |
| AgentDriverRegistry | AgentDriver { id; available; runPhase } |
| NotifierRegistry | NotifierAdapter { 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)