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. src/util/verbs.ts is
the list that ships. Read § What moves out of core as the plan it was;
MOVED_VERBS is what became of it.
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, …) |
| 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 |
| 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) |
| 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:
- Order is declaration order, everywhere.
plugins: [a, b]meansa.projectruns beforeb.project,a.executoris asked beforeb.executor. No priorities, noenforce: 'pre'. (Vite neededenforcebecause its plugins come from many ecosystems; vx’s are declared by one hand in one file.) - A transform hook mutates in place and returns nothing.
projectreceives 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 forgraph. - Everything a hook changes reaches the cache key by construction.
Resolved-config hashing (principle #4) hashes the task config after
projectran, so an injected task or edited command re-keys exactly like a hand edit.keymaterial is folded as one more part.graphedits changedependsOnclosure, which changes upstream folding. - Observe hooks cannot change behaviour.
telemetrykeeps its handle-free contract.setupgets the bus, read-only. - No hook is applied by default.
A workspace with noSUPERSEDED. Running here and caching here became core’s FLOOR rather than plugins:executororcachestill fails before any task runs, naming the fix.plugin-host.tspusheslocalExecutor()as the TAIL of every executor list and the local store sits at the end of every cache chain, so a workspace with novx.workspace.tsruns 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 inCLAUDE.md, and the sentence above is the shape that was rejected — the same oneplugin-executor-reapi-2026-08.mddescribes withlocalExecutorPlugin()undersrc/plugins/, a directory a test now asserts does not exist. - Zero cost when absent. No plugin declares
project⇒ no loop, no re-validation. Same for every stage. The warm path is measured. - Crash isolation is per stage. Load-bearing stages (
config,project,graph,key,schedule,executor,cache) abort the run with aUserErrornaming plugin and hook. Observe stages warn and disable the plugin for the run.
Contexts
Section titled “Contexts”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.
What moves out of core once this lands
Section titled “What moves out of core once this lands”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 queriesvx why/vx lastuse through the public API. - Predictive scheduling → a
scheduleplugin readingLocalHistoryProvider.
What does NOT change
Section titled “What does NOT change”- The three existing seams keep their contracts;
@vzn/vx-reapi,@vzn/vx-otel,@vzn/vx-githubrun unmodified. eventSink(deprecated) is removed;setup(ctx)with the bus is the raw-event path,telemetryis the export path.- Config evaluation caching is unaffected: the cache stores the config as
the user wrote it;
projecttransforms apply after the load, live.
Phases
Section titled “Phases”- Remove
eventSink; addconfig,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 namedUserError; no hook ⇒ the loader and graph builder are byte-identical (a probe that counts validations). commands. Movemigrate,prune,upgradeout behind it.schedule,key. Ship a@vzn/vx-schedule-historyreference plugin (the old predictive mode) to prove the seam.- Docs + site: the plugin guide is rewritten around the stage table.