Skip to content
vxvx
GitHubBlueskydev.toRSS

The pipeline — plugin API v2 (2026-09)

Status: shipped — phases 1–3 landed 2026-09-03 (config, project, graph, commands, key, schedule); phase 4’s docs/site rewrite followed. The verb move-out happened DIFFERENTLY from the plan below and is finished: vx migrate went to @vzn/vx-migrate (2026-09-11), vx prune was removed outright rather than moved, vx upgrade stayed in core, and @vzn/vx-cli-extras was never created. Read § What moves out of core as the plan it was; the moved verbs left no pointer behind (2026-10-14).

The owner’s direction (2026-09-02): vx is the Vite of task orchestration. Core is a pipeline; plugins decide what happens at each stage. Today core has three seams — executor, cache, telemetry — plus a raw event-bus setup hook and a deprecated eventSink. That is enough to move a task to a remote worker and to export a run, and not enough to add a task, reshape the graph, fold extra material into a key, choose an order, or add a verb. Each of those is something a workspace has had to hand-write in every config, or something core had to grow a flag for.

The test of the design: every feature core removed on 2026-09-02 — predictive scheduling, vx mcp — must be expressible as a plugin, and the features that remain in core must not need a special case for any one consumer.

One VxPlugin object, hooks named by the pipeline stage they run in, in pipeline order:

Stage Hook Runs Can change
workspace config(ws, ctx) once, before discovery the workspace config (concurrency, cacheDir, …)
discover discover(ctx) once, after core’s discovery which directories are projects (added only), and project edges (deps, reach)
project project(config, meta, ctx) once per loaded project the project’s tasks (add, remove, edit)
graph graph(nodes, ctx) once, after the task graph edges, requested
key key(task, ctx) once per task, at hash extra key material (folded, never replaces)
fingerprint fingerprint: { files, affected } claim, static; affected at --affected takes named lockfiles out of the workspace fingerprint; key folds their meaning per project
measures measures(nodes) once, before schedule what a plugin times that is no task (id → definition), timed and shared as tasks are
schedule schedule(nodes, ctx) once, before scheduling per-task priorities (the two-tier scheduler input)
admit admit(task, ctx) at every local dispatch whether a ready task starts now beside what runs here (a plugin’s own reservations)
execute executor(ctx) once per run WHERE one task’s command runs (existing)
store cache(ctx) once per run WHERE artifacts live (existing)
observe telemetry(ctx) once per run nothing — records out (existing)
render output(ctx) once per run who draws the terminal: a Logger in place of core’s (2026-10-14)
observe setup(ctx) / teardown() once per run nothing — raw bus subscription (existing)
cli commands on an unknown verb which verbs exist

Rules that keep it a pipeline and not a soup:

  1. Order is declaration order, everywhere. plugins: [a, b] means a.project runs before b.project, a.executor is asked before b.executor. No priorities, no enforce: 'pre'. (Vite needed enforce because its plugins come from many ecosystems; vx’s are declared by one hand in one file.)
  2. A transform hook mutates in place and returns nothing. project receives the validated config object and may edit it; core re-validates after the last plugin, so a plugin cannot produce a config the loader would refuse from a user. Same for graph, whose edited graph is also checked as vx lock checks one (overlapping outputs, an input reading another task’s outputs), in a run too: a hook may edit only the graph a run sees.
  3. Everything a hook changes reaches the cache key by construction. Resolved-config hashing (principle #4) hashes the task config after project ran, so an injected task or edited command re-keys exactly like a hand edit. key material is folded as one more part. graph edits change dependsOn closure, which changes upstream folding. Once a project’s own project hooks have run, the run reads a copy of what they left; once the graph stage ends, each task’s config is frozen and the node’s config cannot be swapped. A plugin that kept the object and wrote it later, from admit, ran a command its key never saw.
  4. Observe hooks cannot change behaviour. telemetry keeps its handle-free contract. setup gets the bus, read-only. Until 2026-11-07 “read-only” held for the handle, not the data: a handler got core’s own outcome, and setting a failed one’s status to success passed the run. Now everything a plugin is handed to observe is read-only (orchestrator/read-only.ts): bus events through proxies whose writes throw (a handler, a subscriber, an output Logger), telemetry records, the summary and the RunReport frozen once, and a project hook’s ctx.projects frozen, entries too (a transform hook edits its own object, never the list it reads). What a stage reads and must not edit is read-only too: discover’s ctx.projects, the task key and admit get, the nodes schedule gets, a plugin executor’s outputs, envDefine and forwardArgs. A write throws in the plugin’s own call, so the crash rule below names it.
  5. No hook is applied by default. A workspace with no executor or cache still fails before any task runs, naming the fix. SUPERSEDED. Running here and caching here became core’s FLOOR rather than plugins: plugin-host.ts pushes localExecutor() as the TAIL of every executor list and the local store sits at the end of every cache chain, so a workspace with no .vx/workspace.ts runs and caches, and a plugin that declines a task hands it back to this machine. The half of the rule that still holds is the half that matters: core NAMES no plugin, and a capability a plugin must supply (a remote, a wire) is declared or it does not exist. This is principle #7 in CLAUDE.md, and the sentence above is the shape that was rejected — the same one plugin-executor-reapi-2026-08.md describes with localExecutorPlugin() under src/plugins/, a directory a test now asserts does not exist.
  6. Zero cost when absent. No plugin declares project ⇒ no loop, no re-validation. Same for every stage. The warm path is measured.
  7. Crash isolation is per stage. Load-bearing stages (config, project, graph, key, schedule, executor, cache) abort the run with a UserError naming plugin and hook. Observe stages warn and disable the plugin for the run.
interface PluginContext {
readonly workspaceRoot: string
readonly cacheDir: string
warn(message: string): void
}
interface ProjectContext extends PluginContext {
readonly name: string // package name
readonly dir: string // absolute
readonly packageJson: Record<string, unknown>
readonly projects: readonly ProjectMeta[] // every package core discovered, config file or not
}
interface GraphContext extends PluginContext {
readonly requested: readonly string[] // task ids the user asked for
}
interface KeyContext extends PluginContext {
readonly task: TaskNode
}

schedule returns ReadonlyMap<string, number> (task id → priority); higher runs first among ready tasks; merged over the structural baseline exactly as runGraph’s existing priorities input.

commands is Record<string, { description: string; run(argv: readonly string[], ctx: CommandContext): number | Promise<number> }> — the description is what vx help prints, and a run resolving anything but an integer fails the verb naming the plugin. The CLI dispatcher tries core verbs first; on an unknown verb it loads the workspace config and asks each plugin, in order. vx --help lists plugin verbs after core’s when a workspace is present.

  • vx migrate, vx prune, vx upgrade → @vzn/vx-cli-extras (or one package each). Core’s verb list becomes: run, watch, show, why, last, info, cache, lock.
  • An MCP server → @vzn/vx-mcp (commands: { mcp }), reading the same run-history queries vx why / vx last use through the public API.
  • Predictive scheduling → a schedule plugin reading LocalHistoryProvider.
  • The three existing seams keep their contracts; @vzn/vx-reapi, @vzn/vx-otel, @vzn/vx-ci run unmodified.
  • eventSink (deprecated) is removed; setup(ctx) with the bus is the raw-event path, telemetry is the export path.
  • Config evaluation caching is unaffected: the cache stores the config as the user wrote it; project transforms apply after the load, live.
  1. Remove eventSink; add config, project, graph. Pins: an injected task runs and keys like a hand-written one; the graph hook can add an edge; a hook throwing is a named UserError; no hook ⇒ the loader and graph builder are byte-identical (a probe that counts validations).
  2. commands. Move migrate, prune, upgrade out behind it.
  3. schedule, key. Ship a @vzn/vx-history reference plugin (the old predictive mode) to prove the seam.
  4. Docs + site: the plugin guide is rewritten around the stage table.
Turborepo, Nx and other product names are trademarks of their owners. vx is not affiliated with or endorsed by them.