Skip to content
vxvx
GitHubBlueskydev.toRSS

Config schema

Complete reference for every field accepted by vx.config.{ts,mts,js,mjs,cts,cjs} and the workspace-level .vx/workspace.{ts,mts,js,mjs,cts,cjs}, which is required: it marks the workspace root (cli.md § The workspace root). The authoritative TypeScript definitions live in src/config.ts and are re-exported from @vzn/vx.

Page Covers
exec exec
exec.sandbox exec.sandbox
dependsOn dependsOn
inputs inputs
outputs outputs
cache cache
.vx/workspace.ts Workspace config
<workspaceRoot>/
├── vx.config.ts (optional: the root project, named `//`)
├── .vx/
│ ├── workspace.ts (required: marks the root; workspace-wide settings)
│ ├── projects.lock.json (`vx lock`, minified; committed)
│ ├── .gitattributes (`projects.lock.json linguist-generated=true`; written when missing)
│ ├── .gitignore (`/cache`; written by `vx init` and the cache when missing)
│ └── cache/ (machine-local state; the only part git ignores)
└── <any>/<dir>/
├── vx.config.ts (this directory is a project; its tasks)
└── vx.lock.json (`vx lock`: its resolved config, pretty, for review)

A project is a directory holding a vx.config.*, in any language; core reads no package.json and no package-manager member list. vx lock finds them (git’s tracked and unignored files; a config at or under a nested .vx/workspace.*, ignored or not, is that workspace’s) and records each by directory, and each nested workspace root too: every outer project’s outputs stop there as at a nested project (its inputs do not). A run takes its projects from the lock, so a config added since runs once vx lock records it. A discover plugin may add directories with no config (@vzn/vx-lockfile adds the package manager’s members); one whose config git ignores is refused, naming the plugin and the file. The hook runs under vx lock only: the lock records the projects, names and edges it returned, and a run uses that record.

A project’s name is its config’s name, else the name a discover plugin gives (@vzn/vx-lockfile gives package.json#name), else its root-relative path (apps/web); the root is //. Two projects of one name are refused, naming both directories.

import { defineProject } from '@vzn/vx/config'
export default defineProject({
name: 'web',
tags: ['scope:web', 'type:app'],
tasks: {
<taskName>: TaskConfig,
...
},
})
interface ProjectConfig {
name?: string // what `<name>#<task>`, filters and `dependsOn` address; no "#"
tags?: readonly string[] // labels for `--filter tag:<name>`
tasks?: Record<string, TaskConfig> // keyed by task name
}

defineProject is an identity function — it exists purely so TypeScript narrows literal types in your config (clean autocomplete, strict validation against the schema). It has zero runtime effect. The same key checking without a runtime import is import type { ProjectConfig } from '@vzn/vx/config' plus export default { … } satisfies ProjectConfig (it does not check dependsOn names against the task keys) — the form vx init and @vzn/vx-migrate generate, because Bun erases the type import and the file then loads in a workspace that runs the vx binary without the package installed. Either form is fine; the object is what vx reads. @vzn/vx/config is the schema alone (src/config.ts, which imports nothing): your own tsc checks that one file, where @vzn/vx would walk core’s Bun-only sources and fail without @types/bun.

The object is JSON data. The cache key folds JSON.stringify of each task’s config, vx lock stores the same JSON, and vx watch re-reads a config through a worker that hands it back as JSON, so a value JSON cannot carry would be dropped, rewritten or refused depending on the path. vx refuses it on every path, before the schema: a function, a symbol, a bigint, NaN or ±Infinity, undefined inside an array (JSON writes null), a cycle, a getter or setter (code that each read may answer differently; vx never calls it), a symbol key or a non-enumerable property (JSON drops each), and any object that is not a plain object or an array (a Date, Map, Set, RegExp or class instance), and nesting deeper than 256 levels. An undefined property is fine: JSON drops it and the schema reads it as absent, which is what a preset’s optional field (description: opts.description) relies on. A config is evaluated once, by vx lock, so a branch on process.env (CI) is fixed by whoever locked it, and vx lock --check under the other value fails. The workspace config is not held to this: its plugins are objects of functions.

vx lock evaluates a project config with the workspace root as process.cwd() and PWD, from whichever directory it runs, so the lock does not depend on where you ran it; import.meta.dir is the config’s own directory, an absolute path: in a command it locks this checkout’s path, so another checkout fails vx lock --check and keys apart (vx lock warns). A command runs in its project’s directory: name a script by a path relative to it, or $VX_RUN_WORKSPACE/scripts/gen.ts from the workspace root. What it prints goes to stderr, a child it spawns included: a verb’s stdout is its output.

tasks is a Record<string, TaskConfig>. Task names are strings referenced by dependsOn, by inputs.tasks, and by the CLI (vx run <taskName> or vx run <pkg>#<taskName>), so a name those could not spell is refused at load: an empty one, one with surrounding whitespace, one holding # (the project separator) or * (a pattern), and one starting with ^ (dependencies’ tasks), ! (a negation) or - (a CLI flag). A task or project name holding a control character (ESC, \r) is refused too: vx prints names raw, and one could retitle the terminal.

name is refused at load when dependsOn or --filter could not spell it: surrounding whitespace, a #, a * (a pattern), a leading ^ (dependencies’ tasks) or ! (an exclusion), a leading tag:, a leading or trailing ... (a dependency walk), a trailing [<ref>] (a git range), or a directory form (./x, {x}, x{dir}). A name a plugin gives and a project’s path name are held to the same rule.

tags label the project for selection: --filter tag:<name> selects the projects carrying one (cli.md § Filter DSL), as Nx’s tag: does. Each is a non-empty string a tag filter can name, or it is refused at load: no surrounding whitespace, no * (a pattern), no control character (vx show prints tags raw), and no ending in ... (a dependency walk) or [<ref>] (a git range). A project plugin may set or edit them (nx() gives each project its Nx tags unless the vx.config has its own). A tag is in no cache key: it changes no task’s behaviour, so editing one re-runs nothing.

interface TaskConfig {
description?: string // one-line blurb for the picker / --dry view
exec?: ExecConfig // omit to declare a group task
dependsOn?: readonly string[] // Turbo/Nx micro-syntax
inputs?: InputsConfig // what the task reads: its key, cached or not
outputs?: TaskOutputs // what it writes, cached or not
cache?: boolean // caching is opt-in; needs `exec`, `inputs` and `outputs`
}

A task either has an exec (it does work) or omits exec and declares dependsOn or inputs (it’s a group task: an aggregator, or a key). The loader rejects a task that has none of them — a no-op standalone task is almost always a config mistake. dependsOn: [] is the deliberate form: an explicit empty group, for a package that wants a task by that name to exist and do nothing.

A project whose config and plugins declare no build gets one (2026-10-04): a group with dependsOn: ['^build'] keyed on every file of the project (inputs.files: ['**'], no outputs). It runs nothing, but a dependant behind ^build folds its key, so a package consumed as source moves its dependants’ keys and reaches them under --affected. A config can declare a keyed group too (Group tasks, below). A bare vx run build does not select it: run where no selected project declares build, it is refused as any undeclared name is, while lib#build names it and a dependant’s ^build reaches it. vx show and vx info count it; vx show build and the vx build hint, which name declared tasks, do not. Its ^build needs an edge source as any ^ does: with no plugin returning deps, running it is refused.

description?: string

A short one-line blurb describing what the task does. No effect on scheduling or execution, but it does participate in the cache key — the key hashes the whole resolved task config (see caching.md, step 5), and carving exceptions out of that object is what invites stale hits. Editing a description therefore costs one re-run. Surfaced in three places:

  • The interactive task picker (vx run with no positional in a TTY) — printed to the right of each pkg#task id.
  • The --dry text preview — printed on a second indented line under the cache-status row.
  • The --dry=json output — description lives on each task entry.
test: {
description: 'bun test against the tests/ tree',
exec: { command: 'bun test' },
inputs: { files: ['src/**', 'tests/**'] },
outputs: { files: [] },
cache: true,
}

A task with no exec and a dependsOn is a group task — a pure aggregator. Running a group is equivalent to running its dependencies; nothing else happens (no spawn, no I/O, no cache read/write). An empty dependsOn: [] is an explicit no-op group: it exists to be named — by a dependant’s ^build, by vx run build --all — and runs nothing. A project with no build gets a keyed one (above), which a bare name does not select.

A group with inputs is a keyed group: its inputs are a key and nothing runs. A task that depends on it folds that key, so an edit to those inputs re-runs the dependant, and --affected reaches it. It takes no cache (a group writes nothing), and it needs no dependsOn. It spawns nothing, writes no cache entry, and the run summary does not count it. @vzn/vx-migrate writes one for each Nx ^production-style input and each Turbo transit node.

// `test` re-runs when a file under fixtures/ changes; nothing spawns for it
fixtures: { inputs: { files: ['fixtures/**'] } },
test: { exec: { command: 'bun test' }, dependsOn: ['fixtures'], cache: true /* … */ },

Bare --exclude-dependencies keeps a group’s edges for the same reason: vx run ci --exclude-dependencies runs ci’s members without their own dependencies. A name list (--exclude-dependencies=lint.oxfmt) drops a member it names, like any edge.

// `vx run install --all` → fans out to `build` in every workspace dep
install: {
description: 'build everything in workspace dependency order',
dependsOn: ['^build'],
}
// `vx run ci` → runs format-check + lint + test in the cwd project
ci: {
description: 'format-check + lint + test (CI gate)',
dependsOn: ['format-check', 'lint', 'test'],
}

Group tasks:

  • Are not counted in the run summary. “3 tasks, 2 cached” reflects real executable tasks — including a group in the count is confusing (“3 of 4 cached” when one was a group that ran nothing).
  • Are not recorded in the runs table. Analytics queries that aggregate runtime won’t see them.
  • Render no framed block in the live output.
  • DO contribute a stable hash to downstream tasks that filter inputs.tasks to include the group. The hash is rolled up from the group’s upstream outcomes (sorted, joined, hashed). So any change beneath the group cascades correctly.

The loader rejects:

  • A task with no exec, no dependsOn and no inputs (literal no-op).
  • A group with cache (a group writes nothing).
import { defineProject, defineWorkspace } from '@vzn/vx/config'
// Identity functions; their purpose is type inference.
defineProject<const T extends ProjectConfig>(config: T): T
defineWorkspace<T extends WorkspaceConfig>(config: T): T

Use them so TypeScript narrows literal types in your config — autocomplete for task names in dependsOn against your declared tasks, strict validation against the schema, errors at edit time rather than at vx run time.

They cost one runtime import of @vzn/vx/config per config file: src/config.ts, which imports nothing, so it is cheap, but it must resolve from the config’s directory. The type-only form checks the same keys with no runtime import, but not dependsOn names (a typo is a plain string), and is what vx init / @vzn/vx-migrate write:

import type { ProjectConfig, WorkspaceConfig } from '@vzn/vx/config'
export default { tasks: { … } } satisfies ProjectConfig
export default { plugins: [] } satisfies WorkspaceConfig

You can skip the helpers and write export default { tasks: { … } }, but the IDE experience is strictly worse.

import { defineProject } from '@vzn/vx/config'
export default defineProject({
tasks: {
// Real cached task with file inputs, env tracking, and outputs.
build: {
description: 'compile TypeScript to dist/',
// NODE_ENV is a cache input below, so it must reach the child
// too — the exec env is isolated, not inherited.
exec: { command: 'tsc -b', env: { passThrough: ['NODE_ENV'] } },
dependsOn: ['^build'],
inputs: {
files: ['src/**', '!**/*.test.ts', 'tsconfig.json'],
env: ['NODE_ENV'],
},
outputs: { files: ['dist/**', '*.tsbuildinfo'] },
cache: true,
},
// Cached, depends on upstream build; CI is in passThrough only
// (CI=1 shouldn't bust the cache — it's true on every CI run).
test: {
description: 'run unit tests',
exec: { command: 'bun test', env: { passThrough: ['CI'] } },
dependsOn: ['build'],
inputs: { files: ['src/**', 'tests/**'] },
outputs: { files: [] },
cache: true,
},
// Cross-project edges. The pkg.tgz from one project's build is
// cached, and depends on builds in every workspace dep.
package: {
description: 'pack the npm tarball',
exec: { command: 'rm -rf pkg && npm pack --pack-destination ./pkg' },
dependsOn: ['build', 'test', '^build'],
inputs: {
files: ['package.json'],
tasks: ['build', '^build'], // hash these upstream too
},
outputs: { files: ['pkg/*.tgz'] },
cache: true,
},
// Persistent dev server. Downstream tasks unblock on the
// "Local:" line, not on exit (which never comes).
dev: {
description: 'vite dev server',
exec: {
command: 'vite',
timeout: '30s',
persistent: { readyWhen: 'Local:' },
env: { passThrough: ['VITE_API_URL'] },
},
},
// E2E test gated on the dev server. Together: vx run e2e runs
// dev → e2e; e2e exits → orchestrator SIGTERMs dev → run ends.
e2e: {
description: 'end-to-end tests against dev server',
exec: { command: 'playwright test' },
dependsOn: ['dev'],
},
// Two cached checks with no outputs: the verdict is the exit code.
// A name with a hyphen is quoted, as any object key is.
lint: {
description: 'oxlint',
exec: { command: 'oxlint .' },
inputs: { files: ['src/**', '.oxlintrc.json'] },
outputs: { files: [] },
cache: true,
},
'format-check': {
description: 'oxfmt --check',
exec: { command: 'oxfmt --check .' },
inputs: { files: ['src/**', '.oxfmtrc.json'] },
outputs: { files: [] },
cache: true,
},
// Pure group task — `vx run ci` fans out, the group itself is
// silent in the run output. `dependsOn` is typed against the task
// names above: a name declared nowhere is a type error, not a
// silent no-op.
ci: {
description: 'format-check + lint + test',
dependsOn: ['format-check', 'lint', 'test'],
},
},
})

The rest of the task fields, in one config:

import { defineProject } from '@vzn/vx/config'
export default defineProject({
tasks: {
// Prompts on the terminal, so it runs alone and is never cached.
'db:push': { exec: { command: 'drizzle-kit push', interactive: true } },
// Talks to this machine's Docker daemon: never sent to a remote executor.
'docker:build': { exec: { command: 'docker build -t app .', remote: false } },
e2e: {
exec: {
// Playwright removes its output directory: one inside the grant.
command: 'playwright test --output test-results/run',
env: {
define: { BASE_URL: 'http://localhost:3000' },
passThrough: ['E2E_TOKEN'],
secret: ['E2E_TOKEN'],
},
sandbox: {
allow: {
read: ['.'],
write: ['test-results/'],
localBinding: [3000],
unixSockets: true,
pty: true,
gitConfig: false,
systemInfo: ['hw.memsize'],
machLookup: ['com.apple.SecurityServer'],
},
weakerNetworkIsolation: false,
weakerWhenNested: false,
},
},
inputs: {
// The app playwright's webServer starts: a page edit makes e2e run again.
files: ['e2e/**', 'src/**', 'playwright.config.ts'],
runtime: ['playwright --version'],
workspaceRuntime: ['node -v'],
},
outputs: { files: ['test-results/**'] },
cache: true,
},
},
})

TypeScript composition is the mechanism (named-input schema machinery was deliberately rejected — the language already does this). Plain TS arrays:

import { defineProject } from '@vzn/vx/config'
const srcInputs = ['src/**', 'tsconfig.json']
export default defineProject({
tasks: {
build: {
exec: { command: 'tsc' },
inputs: { files: srcInputs },
outputs: { files: ['dist/**'] },
cache: true,
},
test: {
exec: { command: 'bun test' },
inputs: { files: [...srcInputs, 'tests/**'] },
outputs: { files: [] },
cache: true,
},
},
})

A preset is a TypeScript function that returns a TaskConfig. defineProject checks dependsOn names written inline; a preset’s dependsOn is string[], so its names are not checked:

presets/ts-build.ts
import type { TaskConfig } from '@vzn/vx/config'
export function tsBuild(opts?: { tsconfig?: string }): TaskConfig {
return {
description: 'compile TypeScript',
exec: { command: `tsc -b ${opts?.tsconfig ?? ''}`.trim() },
inputs: { files: ['src/**', opts?.tsconfig ?? 'tsconfig.json'] },
outputs: { files: ['dist/**', '*.tsbuildinfo'] },
cache: true,
}
}
packages/app/vx.config.ts
import { defineProject } from '@vzn/vx/config'
import { tsBuild } from '../../presets/ts-build.ts'
export default defineProject({
tasks: {
build: tsBuild(),
test: {
exec: { command: 'bun test' },
dependsOn: ['build'],
inputs: { files: ['src/**'] },
outputs: { files: [] },
cache: true,
},
},
})

When the preset is consumed at config-load time, it should resolve to .ts source — see architecture.md § Config-time imports for the bootstrap-cycle tradeoff.

When a dependsOn exists for ordering only, and the upstream’s output doesn’t influence this task’s output. Common case: an integration test that needs a database container up first, whose image the test’s own inputs already pin.

'test:integration': {
dependsOn: ['db:up'], // docker compose up -d postgres
exec: { command: 'bun test integration/' },
inputs: {
files: ['src/**', 'integration/**', 'compose.yaml'],
tasks: [], // db:up only orders; compose.yaml pins what it starts
},
cache: true,
}

Not for a dev server: it serves the app’s source, so e2e must fold its key (the persistent example above), or a broken page replays a green e2e.

When to use --no-cache instead of removing cache

Section titled “When to use --no-cache instead of removing cache”

--no-cache is a runtime flag, not a config change. Reach for it when:

  • You suspect cache corruption and want a clean run.
  • You’re benchmarking and need fresh timings.
  • You’re forwarding one-off args and don’t want to pollute the cache with a one-shot key (though note: forwarded args are already in the key, so they form their own entry anyway).

It applies to the whole run; per-task opt-out belongs in config (omit cache: true).

The loader (src/workspace/project-loader.ts) validates at load time and surfaces UserError (clean output, no stack). Every field’s accepted values and exact refusal text are recorded in tests/contract/config-schema.json, which a test regenerates from the validator and compares, so a change to either is deliberate (design/versioning-1.0.md § How the contract is held).

When the field a refusal names is in the file, the CLI prints the file at its line and column, relative to the current directory (a terminal opens it on click), and the lines up to it with a caret under the field (util/config-frame.ts):

✗ vx lock lock not written
packages/b/vx.config.ts:4:15
tasks.build.exec has unknown field "comand"
allowed: …
2 | tasks: {
3 | build: {
> 4 | exec: { comand: 'mkdir -p dist' },
| ^
fix use command

A value the config imports (test: testTask, ...lib()) is followed to the file that declares it, a relative import or a workspace package’s, and that file is framed, with the configs it reaches as used by …: a typo in a shared preset prints once, not once per config. An import of a name its module does not export is framed at that import. A field no file holds (a required one missing, or one a plugin set) prints at its file, without the frame. Every config that does not load is named in one run: the first in full, then up to four more as ✗ vx lock also refused, then a count of the rest. The table below lists the messages a user meets most:

Symptom Cause
did not export a default object Forgot export default (said so, framed at a named export), or exported a non-object; a function (Vite’s defineConfig(() => …) shape) adds that it is one and to export what it returns (D-110).
tasks must be an object keyed by task name tasks is not an object — an ARRAY included.
<path> is <what> — a config must be JSON data, because the cache key is built from its JSON A value JSON cannot carry, anywhere in the config: a function, a symbol, a bigint, NaN / ±Infinity, undefined in an array, a cycle, a getter, a symbol key, a non-enumerable property, an object that is not plain (Date, Map, RegExp, a class instance), or nesting deeper than 256 levels.
<level> has unknown field "<key>" Typo’d / unsupported key (see below).
<level> must be an object (fields: <fields>), not an array An array where an object goes — outputs: ['dist/**'] (Turbo’s spelling) is outputs: { files: ['dist/**'] }, and the message says so.
cannot find '<name>' — no node_modules above the config provides it; install the workspace's dependencies first A bare import nothing installed serves — a fresh clone before its install, or a typo. Refused before the config is evaluated, so Bun never auto-installs it from the registry (it would, when no node_modules exists above).
<file> imports '<name>', which no node_modules above it provides; install the workspace's dependencies first The same, in a file the config imports by relative path (a preset), looked up from that file.
cannot find '<name>' — Yarn Plug'n'Play installed the workspace's dependencies into .pnp.cjs, which Bun does not read; set <remedy> The same, with a .pnp.cjs at or above the config: the install is there and Bun cannot read it, so the remedy is nodeLinker: node-modules in .yarnrc.yml and yarn install (D-109).
tasks.<name> must be an object The task value is null / a string / etc.; a string (package.json’s name: 'command') adds the { exec: { command } } it goes in.
exec must be an object with a command string exec is malformed.
exec.command must be a non-empty string Forgot command, or an empty or whitespace-only string.
exec.command holds a NUL, which no command line can carry A \0 in the command (a template slip); the spawn refused it as exit 127, “not on this task’s PATH”, with the NUL printed as a space.
exec.persistent must be an object (or omitted) Wrong shape.
exec.persistent.readyWhen must be a string regex Non-string readyWhen.
exec.persistent.readyWhen is not a valid regex (<error>) A readyWhen the runner could not compile ((); it failed the task as an internal error at run time.
cache is not allowed on a persistent task persistent + cache combined.
exec.interactive must be a boolean (or omitted) Wrong shape.
cache is not allowed on an interactive task interactive + cache combined: what it does depends on what is typed.
sandbox is not allowed on an interactive task interactive + sandbox combined.
persistent.readyWhen is not allowed on an interactive task vx reads none of an interactive task’s output.
a task with no exec must declare dependsOn Group task with no edges.
cache needs exec — a task with no command produces nothing to cache cache on a group: a task with no command writes nothing to cache.
dependsOn must be an array of strings Wrong shape.
cache needs inputs — the key is the task's inputs Forgot inputs on a cached task.
inputs must be an object Present but not an object: a string (outputs: 'dist'). An array is the row above.
inputs.files must be an array Wrong shape.
inputs.runtime must be an array of non-empty shell command strings with no NUL Non-string / blank entry (whitespace alone runs as a no-op), or one holding a NUL.
inputs.workspaceRuntime must be an array of non-empty shell command strings with no NUL Non-string / blank entry (whitespace alone runs as a no-op), or one holding a NUL.
inputs.tasks must be an array of non-empty strings Non-string / empty entry, or a bare string.
inputs.tasks: "<name>" names no task in <task>.dependsOn An exact entry no dependsOn entry of its form names.
inputs.tasks: every entry is a negation, which selects NOTHING Only ! entries: a ! entry only takes back what a positive entry selected.
invalid inputs.tasks "<entry>": <reason> A malformed entry (^, a#, #x, !, ^a#b), refused at load with the parser’s reason.
cache must be true or false cache: {...} or 'yes'; caching is a switch.
cache needs outputs — what the task produces, outputs: { files: [...] } ([] for none) Forgot outputs on a cached task.
outputs needs exec — a task with no command writes nothing outputs on a group task.
inputs.stability needs exec — a task with no command runs nothing that could write its inputs inputs.stability on a group task.
inputs.stability must be an object A bare string (stability: 'mutable').
inputs.stability.strategy must be 'immutable', 'mutable' or 'unchecked' Any other value ('content', false).
inputs.stability.severity must be 'warn' or 'error' Any other value ('fail', true).
outputs must be an object Present but not an object: a string (outputs: 'dist'). An array is the row above.
outputs.files must be an array Wrong shape.
inputs.files: every entry is a negation, which selects NOTHING Only ! globs — nothing to subtract from.
inputs.files: "<file>" is taken back by "!<glob>" A literal input a ! entry covers: the negation subtracts wherever it sits, so the file never entered the key.
outputs.files: every entry is a negation, which selects NOTHING Only ! globs: a ! entry only takes back what a positive glob selected (A-44).
outputs.files: "<file>" is taken back by "!<glob>" A literal output a ! entry covers: it is never saved, so a hit left it missing and said up-to-date.
outputs.files: "<glob>" covers the project's own <file> An output glob that matches the project’s own vx.config.* (**, *.ts): the clean before a run would delete it. A ! entry that takes the file back (!vx.config.ts) lets it load.
outputs.files: "<glob>" is under workspace-outputs/ A project output named into the artifact’s namespace for outputs.workspaceFiles: every hit read it back as a workspace output.
inputs.files: '!!' is not a double negation !!x inverts the set — it folds only x.
exec.timeout of <value> exceeds the maximum timer delay Past 2^31-1 ms a timer fires at once, not never.
description must be a string Non-string description.
tag "<tag>" <why> — --filter tag:<name> could not name it A tag with surrounding whitespace or a *, or one ending in ... or [<ref>], which a filter reads as a pattern, a dependency walk or a git range.

Unknown fields are rejected, not ignored, at every object level — the project’s top level (tasks), the task itself, exec, exec.env, exec.persistent, exec.sandbox and its allow / deny / ignore blocks, cache, inputs, and outputs (tests/schema-unknown-keys.test.ts walks every one) — and at the top of .vx/workspace.ts, where plugin: (singular) would otherwise declare no plugins and run the workspace bare. A silently-dropped workspaceFile (singular) or timeoutMs would make the task hash as though the field had never been written, so vx would replay an artifact built from different inputs; a silently-dropped env: { set: … } would run the task without the variables it was written to have. The error names the offending key, lists what that level accepts, and adds the nearest accepted spelling when one is within two edits: tasks.build.exec.env has unknown field "passthrough", then allowed: define, passThrough, secret and fix use passThrough. A field another runner spells on the task names vx’s home for it instead (D-37): Turbo’s outputs, inputs, env, passThroughEnv, persistent, outputLogs, interactive (exec.interactive) and with, Nx’s target executor, options, continuous (D-49), cwd, parallelism and configurations (D-89), and a command (cmd, script) on the task or cmd on exec, and on exec Nx run-commands’ cwd, args, commands, parallel and shell (D-100), and in cache: true wireit’s files / output, env, dependencies, enabled, and globs / include / patterns / exclude / ignore under inputs or outputs (D-118). So outputs on a task ends — vx spells it outputs.files in code quotes. A field of exec, inputs or cache written on the task itself (retries, timeout, files) names the level it belongs to (exec.retries). A cache that is no object (Turbo’s cache: false) and a persistent that is none (true) name the shape to write.

Project-discovery errors (src/workspace/workspace.ts, src/orchestrator/projects.ts):

Symptom Cause
two projects are named "<name>": <dir> and <dir> — give one another \name` in its vx.config` Two directories got one name: a config’s name, a plugin’s, or the path.
<dir>: a project is named by its path unless named, and this one holds "#" … A path no task spec can address: set name in its config.
<dir>: the directory's name is not valid UTF-8, which no project path may hold — rename it A project directory’s name has bytes UTF-8 cannot decode, shown as \xNN.
<dir>: "//" names the workspace root — set another \name`` A config away from the root says name: '//'.
.vx/projects.lock.json locks <dir>, but its <file> is gone — run \vx lock`` A locked project’s config was deleted or renamed (X-144).
.vx/projects.lock.json locks <dir>, but its <file> is not readable by this user (<code>) This user may not search the project’s directory (D-132).
.vx/projects.lock.json has unsupported version <n> (this vx expects 6) — re-run \vx lock`` A lock an earlier vx wrote.
.vx/projects.lock.json was written by a newer vx (version <n>; this vx reads 6) — upgrade vx A lock a later vx wrote: re-locking would write the older version.
<dir> is outside this sparse checkout, so vx lock cannot read its <file> — run \git sparse-checkout add <dir>`` vx lock or --check where a sparse checkout left a config out.
.vx/projects.lock.json: no <pool> <n> in its pool — re-run \vx lock`` A hand-edited lock names a pooled value it does not hold.
plugin '<name>' adds project <p>, but git ignores <file>, so \vx lock` cannot read it — unignore it or delete it` A discover plugin’s directory holds a config git ignores: vx lock, --check and a run naming its task.
git ls-files failed (exit <n>): <why> vx lock could not list the configs (a torn index).

Core reads no package.json or pnpm-workspace.yaml; the errors reading them are @vzn/vx-migrate’s (its README, “Errors reading the members”).

Workspace-config errors:

Symptom Cause
<setting> must be <what its kind takes> (got <value>) A setting of the wrong type or out of range: run.concurrency: 0, cache.dir: 42, run.schedule: 'fastest'. The settings table says what each takes; a count is a number and a switch a boolean, never a string.
<setting> takes a duration with its unit, like '<n>ms' or '<n>s' (got <n>) A bare number for a duration: run.timeout: 30000. Durations carry a unit, so 30000 cannot be read as seconds by mistake.
<setting> takes a size with its unit, like '<n>KiB' or '<n>B' (got <n>) A bare number for a size: cache.store.embeddings: '32'. Sizes carry a unit, so 32 cannot turn the store off as 32 bytes; 0 is refused naming false.
<setting> of <value> exceeds the maximum timer delay A timer past 2^31-1 ms fires at once, not never. Omit run.timeout for no limit.
<group> must be an object (fields: <fields>) run, watch, run.rules or cache.retention that is not an object.
cache must be an object like { dir, policy }; a task caches through its own cache in its project's vx.config Not an object: Turbo’s and Nx’s workspace cache: true is a task’s own cache in vx.
cache.dir holds a NUL, which no path can carry No path can hold a NUL; the cache directory’s error blamed the workspace’s permissions.
baseBranch must be a branch name like 'main' Not a string, empty, holding whitespace, a NUL, .. or one of ~^:?*[\, or starting with -, which git would read as an option.
plugins must be an array of plugin objects Wrong shape.
plugins[<i>] must be an object A non-object entry in plugins; a string (Nx’s '@nx/vite/plugin') adds that a plugin is what its package’s function returns, not a module name (D-49).
plugins[<i>] must come from definePlugin(import.meta, { … }) A plain object where a plugin was expected: a plugin’s name is its package name, and only definePlugin sets it.
plugins[<i>] is a Promise — await the factory that returns the plugin An async plugin factory called without await (plugins: [later()]); the refusal said the plugin did not come from definePlugin (D-159).
plugins[<i>] is plugins[<j>] again (plugin '<name>') — declare a plugin once One plugin instance listed twice (two presets sharing it): its setup, its handlers and its telemetry sinks ran twice. Two instances of one package stay two plugins.
plugins[<i>].name overrides the package name A name set over definePlugin’s result; drop the field.
plugins[<i>].<capability> of plugin '<name>' must be a function A capability key holding something that is not callable.
plugins[<i>] (plugin '<name>') must contribute at least one of config/discover/project/graph/key/fingerprint/measures/schedule/admit/executor/cache/telemetry/output/setup/commands/teardown A plugin object with no capability.
plugin '<name>' declares '<key>', which is no plugin hook A key on a plugin that names no hook (excutor, setUp): it was never called. The nearest hook is hinted.
<file> has unknown field "<key>" Typo’d / unsupported top-level key (plugin); the hint names the nearest spelling, or where vx keeps a Turbo or Nx key (pipeline, remoteCache, parallel, cacheDirectory, defaultBase, tasksRunnerOptions, globalPassThroughEnv; D-38, D-49; pnpm-workspace.yaml’s, Lerna’s and package.json’s packages, workspaces, catalog, npmClient, and ignore, cache, remote, env, D-98; in a project’s vx.config, targets, extends, implicitDependencies and name, D-56; scripts, pipeline, namedInputs, root, sourceRoot, projectType, and a task’s dependsOn, cache, exec, inputs, outputs, env one level too high, D-99).
plugin '<name>' declares command '<verb>', a core verb — core verbs cannot be shadowed A plugin verb the dispatcher matches first; it could never run.
plugin '<name>' declares command '<verb>', which is not a word — a verb is a letter or digit, then letters, digits, ':', '.', '_' or '-' A verb like --version, '' or a b: core reads a flag first, an empty word is no verb, and vx completions writes verbs unquoted into a shell script.
plugins '<a>' and '<b>' both declare command '<verb>' — a verb has one owner Two packages on one verb; the first would win and hide the second. Plugins of one package are one owner, and the first declared runs.
plugins[<i>].fingerprint of plugin '<name>' must be { files: [name, …], affected: function, extraFiles?: function, digests?: function } A fingerprint claim without its file list or its affected answer, or with an extraFiles or digests that is not a function.
plugin '<name>' claims fingerprint file "<file>", which is not a file name at the workspace root A claim names a path, or no name: a claim is a bare file at the root (vx watch’s root arm is not recursive).
plugins '<a>' and '<b>' both claim fingerprint file '<file>' — a file has one claimant Two plugins keying the same lockfile; the key would fold both and --affected could ask only one.
plugin '<name>' claims fingerprint file '<file>' twice (plugins[<i>] and plugins[<j>]) — a file has one claimant; declare the plugin once One plugin package declared twice (plugins: [bun(), bun()]); the message named it as two plugins (D-158).
Turborepo, Nx and other product names are trademarks of their owners. vx is not affiliated with or endorsed by them.