@vzn/vx 0.0.633 → 0.0.634

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/docs/caching.md CHANGED
@@ -465,7 +465,10 @@ fold, and runtime probes after a writer still leave a key preliminary.
465
465
  The
466
466
  short-circuit never runs under a `LayeredCache` (remote prefetch owns
467
467
  those runs — an up-front `get` there would put remote GETs on the
468
- critical path), never fires with local reads off, and never throws —
468
+ critical path), never fires with local reads off or on a local cache
469
+ that holds no artifact (`Cache.holdsNothing`: every probe would miss,
470
+ and a cold 1,090-package run started its first task ~90 ms sooner
471
+ without it), and never throws —
469
472
  any error degrades to the normal lazy schedule. Measured: −6.6% on a
470
473
  mixed slow-upstream/warm-downstream workload; parity on all-hit warm
471
474
  runs.
package/docs/cli.md CHANGED
@@ -976,6 +976,10 @@ single `file` for a lockfile claim, a manifest edge or a config import),
976
976
  selected its project). `via` is the `dependsOn` chain, nearest first, to
977
977
  the task the change reached. A dependency the kept tasks pull in carries
978
978
  none. The reasons are gathered only for a plan; a run pays nothing.
979
+ `--dry=json` also states `affectedBase`, the ref the diff was taken
980
+ against: a bare `--affected` guesses it (`origin/HEAD`, a trunk branch,
981
+ else `HEAD~1`), so a reason that names no file may come from a commit
982
+ the base reaches, a root `package.json` edit among them.
979
983
 
980
984
  `--graph` prints Graphviz DOT (stdout by default; `--graph=path`
981
985
  writes a file):
@@ -2287,26 +2291,89 @@ refuses `--dry` (use `--dry=json`), a bare `--graph` and
2287
2291
 
2288
2292
  A verb asked for JSON (`--format json`, `--format=json`, `--dry=json`,
2289
2293
  before any `--`) answers a refusal on stdout too, as one line that
2290
- `schemas/error.json` describes: `{"ok":false,"error":{"code","message"}}`.
2291
- The exit code (1) and the prose on stderr do not change. Branch on the
2292
- code; the message may change.
2293
-
2294
- | Code | When |
2295
- | ---------------------- | --------------------------------------------------------------------- |
2296
- | `VX_E_USAGE` | A flag, argument or selection the verb does not take. |
2297
- | `VX_E_UNKNOWN_COMMAND` | No verb of that name here, core's or a declared plugin's. |
2298
- | `VX_E_UNKNOWN_TASK` | No project in scope declares a requested task. |
2299
- | `VX_E_UNKNOWN_PROJECT` | `vx show` names a project the workspace does not have. |
2300
- | `VX_E_NO_HISTORY` | `vx why` / `vx last` find no recorded run for what they were asked. |
2301
- | `VX_E_AMBIGUOUS` | `vx why <task>` matches the task in several projects; name one. |
2302
- | `VX_E_CONFIG` | A `vx.config.*` or `vx.workspace.*` that does not load or is refused. |
2303
- | `VX_E_CYCLE` | The task graph has a cycle. |
2304
- | `VX_E_NO_WORKSPACE` | No workspace root above the working directory. |
2305
- | `VX_E_CACHE_VERSION` | A reading verb met a cache index from another vx version. |
2306
- | `VX_E_REFUSED` | Any other refusal (a plugin's, a check's) with no code of its own. |
2307
- | `VX_E_FS` | The file system refused a write: permissions, a full disk. |
2308
- | `VX_E_FDS` | Out of file descriptors. |
2309
- | `VX_E_INTERNAL` | A defect in vx; stderr carries the stack. |
2294
+ `schemas/error.json` describes: `{"ok":false,"error":{"code","message","docs"}}`.
2295
+ `docs` links the code's section below, which says what to do;
2296
+ `vx docs <code>` prints that section offline. The exit code (1) and the
2297
+ prose on stderr do not change. Branch on the code; the message may change.
2298
+
2299
+ #### `VX_E_USAGE`
2300
+
2301
+ A flag, argument or selection the verb does not take. The message names
2302
+ it, and a close spelling the verb does take when there is one;
2303
+ `vx <verb> --help` lists every flag.
2304
+
2305
+ #### `VX_E_UNKNOWN_COMMAND`
2306
+
2307
+ No verb of that name here, core's or a declared plugin's. `vx --help`
2308
+ lists core's verbs. A plugin's verb exists once its plugin is in
2309
+ `vx.workspace.ts` `plugins`; a task runs as `vx run <task>`.
2310
+
2311
+ #### `VX_E_UNKNOWN_TASK`
2312
+
2313
+ No project in scope declares a requested task. The message lists the
2314
+ tasks it found; `vx show --format json` lists each project's. Check the
2315
+ spelling, and the scope: `--filter`, `--all`, or the project the working
2316
+ directory is in.
2317
+
2318
+ #### `VX_E_UNKNOWN_PROJECT`
2319
+
2320
+ `vx show` names a project the workspace does not have. A project is named
2321
+ by its `package.json` `name`; `vx show --format json` lists them.
2322
+
2323
+ #### `VX_E_NO_HISTORY`
2324
+
2325
+ `vx why` / `vx last` find no recorded run for what they were asked. Run
2326
+ the task once (`vx run <task>`); history is this checkout's own.
2327
+
2328
+ #### `VX_E_AMBIGUOUS`
2329
+
2330
+ `vx why <task>` matches the task in several projects. Name one:
2331
+ `vx why <project>#<task>`.
2332
+
2333
+ #### `VX_E_CONFIG`
2334
+
2335
+ A `vx.config.*` or `vx.workspace.*` that does not load or is refused. The
2336
+ message names the file and the field; fix that field
2337
+ (`vx docs <field>` prints its reference). `vx info --format json` lists
2338
+ every config error at once.
2339
+
2340
+ #### `VX_E_CYCLE`
2341
+
2342
+ The task graph has a cycle. The message names the loop; drop one of its
2343
+ `dependsOn` edges.
2344
+
2345
+ #### `VX_E_NO_WORKSPACE`
2346
+
2347
+ No workspace root above the working directory. Run vx inside the repo,
2348
+ or create a `package.json` and run `vx init`.
2349
+
2350
+ #### `VX_E_CACHE_VERSION`
2351
+
2352
+ A reading verb met a cache index from another vx version. It reads
2353
+ nothing from it and leaves it be; the next `vx run` resets it. Or run the
2354
+ vx version that wrote it.
2355
+
2356
+ #### `VX_E_REFUSED`
2357
+
2358
+ Any other refusal (a plugin's, a check's) with no code of its own. The
2359
+ message is that plugin's or check's: do what it says.
2360
+ `vx info --format json` lists the plugins.
2361
+
2362
+ #### `VX_E_FS`
2363
+
2364
+ The file system refused a write: permissions, a full disk. The message
2365
+ names the path. Free space (`vx cache prune --max-size <size>` trims the
2366
+ cache) or grant the write, then re-run.
2367
+
2368
+ #### `VX_E_FDS`
2369
+
2370
+ Out of file descriptors. Raise the limit (`ulimit -n 4096`), or run fewer
2371
+ tasks at once (`--concurrency`), then re-run.
2372
+
2373
+ #### `VX_E_INTERNAL`
2374
+
2375
+ A defect in vx; stderr carries the stack. Report it with that stack at
2376
+ https://github.com/vznjs/vx/issues.
2310
2377
 
2311
2378
  A plugin can give its refusal a code: `new UserError(message, 'MY_CODE')`.
2312
2379
 
@@ -2786,7 +2853,9 @@ parse error.
2786
2853
  `schema`, `caching`, `execution`, `patterns`, `security` and `features`,
2787
2854
  which ship inside the package and the compiled binary. Each page is cut
2788
2855
  at its headings; a section that holds every word of the query matches,
2789
- and a word in its heading ranks it above any count in its text. The best
2856
+ and a word in its heading ranks it above any count in its text; a
2857
+ one-word query that titles a section, an error code
2858
+ (`vx docs VX_E_CYCLE`), prints that section alone. The best
2790
2859
  `--limit` sections (default 3) print whole, each under its page,
2791
2860
  heading and URL on the site. `--format json` prints
2792
2861
  `{query, hits: [{page, heading, url, text}]}` (`schemas/docs.json`).
package/docs/execution.md CHANGED
@@ -45,7 +45,7 @@ terminal and a task succeeding or failing. Read it alongside
45
45
  │ 3. loadWorkspacePlugins — loadWorkspaceConfig reads the optional
46
46
  │ vx.workspace.{ts,mts,js,mjs,cts,cjs} at the root (concurrency /
47
47
  │ cacheDir / timeout / cacheRetention / affectedBase / cacheScope /
48
- │ rules / plugins),
48
+ │ rules / schedule / plugins),
49
49
  │ then the plugin `config` stage runs on it.
50
50
  │ 4. listProjects — globs every workspace member's package.json,
51
51
  │ finds sibling vx.config.* files, detects duplicate package
package/docs/features.md CHANGED
@@ -28,6 +28,7 @@ config key or an environment variable is missing here.
28
28
  - **Turbo and Nx spellings** (`-t`, `-p`, `--exclude`, `--parallel`, `--base`, `--dry-run`, `--skip-nx-cache`) — accepted, or refused naming the vx spelling. [page](https://vznjs.github.io/vx/features/turbo-nx-flags/) · post: [Flags you already know](https://vznjs.github.io/vx/blog/flags-you-already-know/)
29
29
  - **Ctrl-C leaves nothing running** (`VX_KILL_GRACE_MS`, `VX_TEARDOWN_TIMEOUT_MS`) — the whole process tree stops, then teardown runs. [page](https://vznjs.github.io/vx/features/ctrl-c/) · post: [Ctrl-C leaves nothing running](https://vznjs.github.io/vx/blog/ctrl-c/)
30
30
  - **Longest chain first** — the scheduler starts the critical path first; `@vzn/vx-schedule-history` learns it from past runs. [page](https://vznjs.github.io/vx/features/critical-path/) · post: [Bitsets, popcount, and a scheduler tick](https://vznjs.github.io/vx/blog/bitsets-and-the-scheduler/)
31
+ - **Schedule strategy** (`schedule`) — pick the order with no timings: most work waiting (default), longest chain, most direct dependents, or ready order; plugins still lead. [page](https://vznjs.github.io/vx/features/critical-path/) · post: [Pick the order your graph wants](https://vznjs.github.io/vx/blog/schedule-strategy/)
31
32
  - **No daemon** — every run starts cold and still answers in milliseconds. [page](https://vznjs.github.io/vx/features/no-daemon/) · post: [No daemon, on purpose](https://vznjs.github.io/vx/blog/no-daemon/)
32
33
  - **Group tasks** (`dependsOn` with no `exec`) — a task that only runs its dependencies; `dependsOn: []` is a named no-op. [page](https://vznjs.github.io/vx/features/group-tasks/) · post: [Say exactly which tasks to run](https://vznjs.github.io/vx/blog/pick-your-tasks/)
33
34
  - **Keyed groups** (`cache` with no `exec`) — a key a dependant folds: it re-runs on those inputs, and nothing spawns or counts for the group. [page](https://vznjs.github.io/vx/features/group-tasks/) · post: [A key with nothing to run](https://vznjs.github.io/vx/blog/keyed-groups/)
@@ -68,10 +69,10 @@ config key or an environment variable is missing here.
68
69
  - **vx info** (`vx info`) — workspace doctor: versions, projects, cache size against `cacheRetention.maxSize`. [page](https://vznjs.github.io/vx/features/vx-info/) · post: [One command to know your workspace](https://vznjs.github.io/vx/blog/know-your-workspace/)
69
70
  - **JSON everywhere** (`--format`) — `show`, `info`, `why`, `last` and `cache` print JSON for agents. [page](https://vznjs.github.io/vx/features/json-everywhere/) · post: [Built for the agent at the keyboard](https://vznjs.github.io/vx/blog/built-for-agents/)
70
71
  - **Run analytics in SQLite** — every task's time, CPU, peak memory and cache result, queryable with `sqlite3`. [page](https://vznjs.github.io/vx/features/run-analytics/) · post: [Built for the agent at the keyboard](https://vznjs.github.io/vx/blog/built-for-agents/)
71
- - **Stable error codes** (`VX_E_…`) — under `--format json` a refusal is a JSON line on stdout with a code an agent branches on. [page](https://vznjs.github.io/vx/features/error-codes/) · post: [A refusal an agent can read](https://vznjs.github.io/vx/blog/error-codes/)
72
+ - **Stable error codes** (`VX_E_…`) — under `--format json` a refusal is a JSON line on stdout with a code an agent branches on and a `docs` link to its fix (`vx docs <code>` offline). [page](https://vznjs.github.io/vx/features/error-codes/) · post: [A refusal an agent can read](https://vznjs.github.io/vx/blog/error-codes/)
72
73
  - **Shipped JSON Schemas** (`@vzn/vx/schemas/`) — every `--format json` shape has a schema, and stdout always parses. [page](https://vznjs.github.io/vx/features/json-schemas/) · post: [Built for the agent at the keyboard](https://vznjs.github.io/vx/blog/built-for-agents/)
73
74
  - **Docs for agents** (`llms.txt`, `llms-full.txt`) — the whole site as markdown for coding agents. [page](https://vznjs.github.io/vx/features/llms-txt/) · post: [Built for the agent at the keyboard](https://vznjs.github.io/vx/blog/built-for-agents/)
74
- - **vx docs** (`vx docs`, `--limit`) — search the CLI, config and cache reference offline; it ships inside vx. [page](https://vznjs.github.io/vx/cli/#vx-docs) · post: [The reference, offline](https://vznjs.github.io/vx/blog/vx-docs-offline/)
75
+ - **vx docs** (`vx docs`, `--limit`) — search the CLI, config and cache reference offline; it ships inside vx. [page](https://vznjs.github.io/vx/features/vx-docs/) · post: [The reference, offline](https://vznjs.github.io/vx/blog/vx-docs-offline/)
75
76
  - **Affected reasons** (`--affected --dry`) — each kept task says the changed file or the dependency chain that reached it, text or JSON. [page](https://vznjs.github.io/vx/features/affected-reasons/) · post: [See inside a run](https://vznjs.github.io/vx/blog/see-inside-a-run/)
76
77
  - **Agent skill** (`skills/vx/SKILL.md`) — an installable skill that teaches a coding agent to run, debug and query vx. [page](https://vznjs.github.io/vx/features/agent-skill/) · post: [Built for the agent at the keyboard](https://vznjs.github.io/vx/blog/built-for-agents/)
77
78
 
@@ -170,3 +171,4 @@ config key or an environment variable is missing here.
170
171
  - **Install as a remote action** (`exec.remote: 'only'`) — `node_modules` is built by an action, so stateless workers have it. [page](https://vznjs.github.io/vx/features/remote-install/) · post: [Remote execution without moving the scheduler](https://vznjs.github.io/vx/blog/remote-execution/)
171
172
  - **OTel live export** (`otel({ live })`) — spans and metrics stream as tasks end, so a dashboard follows a CI run live. [page](https://vznjs.github.io/vx/features/otel-live/) · post: [Watch a CI run while it runs](https://vznjs.github.io/vx/blog/otel-live/)
172
173
  - **Memory-aware admission** (`@vzn/vx-schedule-history`) — tasks are packed by the peak memory learned from past runs. [page](https://vznjs.github.io/vx/features/memory-admission/) · post: [A scheduler that learns from your runs](https://vznjs.github.io/vx/blog/a-scheduler-that-learns/)
174
+ - **CI timings file** (`@vzn/vx-schedule-history`, `file`) — one JSON file of learned task times a CI cache carries between runners, so a fresh runner orders by real times. [page](https://vznjs.github.io/vx/features/critical-path/) · post: [Carry the scheduler's memory between CI runners](https://vznjs.github.io/vx/blog/ci-timings-file/)
package/docs/schema.md CHANGED
@@ -1534,6 +1534,7 @@ export default defineWorkspace({
1534
1534
  affectedBase: 'origin/main',
1535
1535
  cacheScope: 'trusted',
1536
1536
  rules: { exclusiveOutputs: true, upfrontKeys: true },
1537
+ schedule: 'most-work',
1537
1538
  plugins: [otel()],
1538
1539
  })
1539
1540
  ```
@@ -1554,6 +1555,8 @@ interface WorkspaceConfig {
1554
1555
  cacheScope?: string
1555
1556
  /** Graph rules checked before anything runs; each on unless set to false. */
1556
1557
  rules?: WorkspaceRules
1558
+ /** Which ready task starts first with no timings: 'most-work' (default), 'critical-path', 'direct-dependents', 'ready-order'. */
1559
+ schedule?: 'most-work' | 'critical-path' | 'direct-dependents' | 'ready-order'
1557
1560
  /** Run-level plugins (cache / executor / telemetry capabilities). */
1558
1561
  plugins?: readonly Plugin[]
1559
1562
  }
@@ -1631,6 +1634,17 @@ run` takes `'read-only'` off CI (`CI` unset, `0` or `false`) and
1631
1634
  boundary: a run holding a write credential can write any key, so only
1632
1635
  a cache server that scopes writes by token can refuse one
1633
1636
  (`docs/security.md` § Cache poisoning). Not folded into any cache key.
1637
+ - **`schedule`** — which ready task starts first when more are ready
1638
+ than workers, with no timings. `most-work` (the default): the one the
1639
+ most tasks wait on, at any depth. `critical-path`: the head of the
1640
+ longest chain of tasks below it. `direct-dependents`: the one the most
1641
+ tasks wait on directly (Nx's rule). `ready-order`: the one that became
1642
+ ready first (Turbo's rule). Each loses some graph shape the others win
1643
+ (`blog/scheduling-strategies`). A `schedule` plugin's weights
1644
+ (`@vzn/vx-schedule-history`) still sort first, and the strategy breaks
1645
+ their ties. The default costs what it did; another strategy is
1646
+ computed only when named. Any other value is refused. Never folded
1647
+ into a cache key.
1634
1648
  - **`rules`** — checks on the task graph, each on unless set to
1635
1649
  `false`. Turning one off allows a shape vx runs correctly but more
1636
1650
  slowly; never folded into a cache key. A value that is not a boolean,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@vzn/vx",
3
- "version": "0.0.633",
3
+ "version": "0.0.634",
4
4
  "description": "The fastest task runner and build cache for JavaScript monorepos.",
5
5
  "type": "module",
6
6
  "exports": {
@@ -21,10 +21,10 @@
21
21
  "node": ">=18"
22
22
  },
23
23
  "optionalDependencies": {
24
- "@vzn/vx-linux-x64": "0.0.633",
25
- "@vzn/vx-linux-arm64": "0.0.633",
26
- "@vzn/vx-darwin-x64": "0.0.633",
27
- "@vzn/vx-darwin-arm64": "0.0.633"
24
+ "@vzn/vx-linux-x64": "0.0.634",
25
+ "@vzn/vx-linux-arm64": "0.0.634",
26
+ "@vzn/vx-darwin-x64": "0.0.634",
27
+ "@vzn/vx-darwin-arm64": "0.0.634"
28
28
  },
29
29
  "dependencies": {
30
30
  "@anthropic-ai/sandbox-runtime": "^0.0.76"
@@ -16,7 +16,11 @@
16
16
  "type": "string",
17
17
  "description": "Stable; branch on it. VX_E_USAGE, VX_E_UNKNOWN_COMMAND, VX_E_UNKNOWN_TASK, VX_E_UNKNOWN_PROJECT, VX_E_NO_HISTORY, VX_E_AMBIGUOUS, VX_E_CONFIG, VX_E_CYCLE, VX_E_NO_WORKSPACE, VX_E_CACHE_VERSION, VX_E_REFUSED, VX_E_FS, VX_E_FDS, VX_E_INTERNAL."
18
18
  },
19
- "message": { "type": "string", "description": "The refusal in prose; may change." }
19
+ "message": { "type": "string", "description": "The refusal in prose; may change." },
20
+ "docs": {
21
+ "type": "string",
22
+ "description": "For a core code (VX_E_…): its section of the CLI reference, which says what to do. A plugin's code has none."
23
+ }
20
24
  }
21
25
  }
22
26
  }
package/schemas/plan.json CHANGED
@@ -6,6 +6,10 @@
6
6
  "additionalProperties": false,
7
7
  "required": ["tasks"],
8
8
  "properties": {
9
+ "affectedBase": {
10
+ "type": "string",
11
+ "description": "under --affected: the ref the diff was taken against (a bare --affected guesses it: origin/HEAD, a trunk branch, else HEAD~1)"
12
+ },
9
13
  "tasks": {
10
14
  "type": "array",
11
15
  "items": {
@@ -21,6 +21,9 @@ vx run build --all --format json # the result as JSON on stdout; logs go to s
21
21
  The exit code is the verdict: `0` every task passed or hit the cache, `1` a
22
22
  task failed or was skipped, or the command was refused. `--format json`
23
23
  prints `schemas/summary.json` (`runId`, `ok`, `exitCode`, a row per task).
24
+ A refusal under `--format json` is one line on stdout,
25
+ `{"ok":false,"error":{"code","message","docs"}}`: branch on `code`;
26
+ `docs` (or `vx docs <code>` offline) says what to do.
24
27
  Without a TTY a missing task name never prompts: vx exits 1 and lists the
25
28
  tasks it found.
26
29
 
@@ -31,7 +34,10 @@ vx show --format json # projects, their tasks and depen
31
34
  vx run test --affected=origin/main --dry=json # what would run, cache hit or miss, and why
32
35
  ```
33
36
 
34
- Under `--affected`, each kept task in `--dry=json` carries `affected`:
37
+ Under `--affected`, `--dry=json` states `affectedBase` (the ref it diffed
38
+ against; name one, `--affected=origin/main`, rather than trust the guess)
39
+ and each task you asked for carries `affected` (a dependency it pulls in
40
+ does not):
35
41
  `{ kind, file?, project?, via? }`, where `kind` is `input` (a changed file
36
42
  is one of its inputs), `project`, `package`, `named` or `selected`, and
37
43
  `via` is the `dependsOn` chain that carried the change.
@@ -122,6 +122,13 @@ import { CACHE_VERSION, foldKey } from './key-fold.js'
122
122
  */
123
123
  const ORPHAN_GRACE_MS = 60 * 60 * 1000
124
124
 
125
+ /**
126
+ * Row-less lookups answered by a stat each before the artifact directory
127
+ * is listed once instead: a warm run's few misses keep their stat, a
128
+ * cold run's thousands share one listing.
129
+ */
130
+ const ADOPT_STATS = 64
131
+
125
132
  /**
126
133
  * A hit renews an entry's `accessed_at` only once it is older than this:
127
134
  * renewing every hit rewrote 2,180 rows at a warm 1,090-package run's
@@ -730,6 +737,10 @@ export class Cache implements CacheLayer {
730
737
  private readonly heldInlineAt = new Map<string, number>()
731
738
  /** Keys the last lookups found an inline artifact for and no row: `adopt`'s to read. */
732
739
  private readonly rowless = new Set<string>()
740
+ /** Row-less lookups that stat'ed their artifact path (`artifactFileMayExist`). */
741
+ private adoptStats = 0
742
+ /** The artifact directory's names, listed once past `ADOPT_STATS`; this process's writes kept in it. */
743
+ private artifactNames: Set<string> | undefined
733
744
  private readonly insertEntryInput: ReturnType<Database['prepare']>
734
745
  /** The per-file (mtime, size) → blob-OID memo behind `hashFile`. */
735
746
  private readonly files: FileHashStore
@@ -1472,20 +1483,21 @@ export class Cache implements CacheLayer {
1472
1483
  * reader reads it, and indexed in one transaction with its bytes.
1473
1484
  */
1474
1485
  private async adopt(hash: string, ctx: CacheGetContext): Promise<boolean> {
1475
- const meta: IngestMeta = {
1486
+ // Built only for an artifact found: its command is masked on read.
1487
+ const meta = (): IngestMeta => ({
1476
1488
  taskId: ctx.taskId,
1477
1489
  command: ctx.command,
1478
1490
  durationMs: 0,
1479
1491
  ...(ctx.outputs !== undefined ? { outputs: ctx.outputs } : {}),
1480
- }
1492
+ })
1481
1493
  // Only a key whose lookup saw a row-less inline artifact asks for it: a
1482
1494
  // statement per miss otherwise, on every cold save.
1483
1495
  const blob = this.rowless.delete(hash)
1484
1496
  ? (this.selectBlob.get(hash) as { bytes: Uint8Array } | null)
1485
1497
  : null
1486
- if (blob !== null) return this.adopted(this.writeArtifactAndIndex(hash, blob.bytes, meta))
1498
+ if (blob !== null) return this.adopted(this.writeArtifactAndIndex(hash, blob.bytes, meta()))
1487
1499
  const finalPath = this.tarPath(hash)
1488
- if (!existsSync(finalPath)) return false
1500
+ if (!this.artifactFileMayExist(hash)) return false
1489
1501
  const tmpPath = this.tempPath(hash)
1490
1502
  try {
1491
1503
  // A second name for the same bytes: the index step renames it over
@@ -1500,7 +1512,28 @@ export class Cache implements CacheLayer {
1500
1512
  } catch {
1501
1513
  return false
1502
1514
  }
1503
- return this.adopted(this.writeArtifactAndIndex(hash, { tmpPath }, meta))
1515
+ return this.adopted(this.writeArtifactAndIndex(hash, { tmpPath }, meta()))
1516
+ }
1517
+
1518
+ /**
1519
+ * Whether `hash` may have a file artifact: a stat for the first
1520
+ * `ADOPT_STATS` asks, then one listing of the directory. A cold
1521
+ * 1,090-package run asked 3,270 times before its first task ran. A
1522
+ * name listed and since removed fails the adopt's link, a miss; a file
1523
+ * another process writes after the listing is missed too, and its own
1524
+ * row serves it.
1525
+ */
1526
+ private artifactFileMayExist(hash: string): boolean {
1527
+ const name = `${hash}.tar.zst`
1528
+ if (this.artifactNames === undefined) {
1529
+ if (++this.adoptStats <= ADOPT_STATS) return existsSync(path.join(this.artifactDir, name))
1530
+ try {
1531
+ this.artifactNames = new Set(readdirSync(this.artifactDir))
1532
+ } catch {
1533
+ this.artifactNames = new Set()
1534
+ }
1535
+ }
1536
+ return this.artifactNames.has(name)
1504
1537
  }
1505
1538
 
1506
1539
  /** An adoption's verdict: an artifact that fails the checks is a miss. */
@@ -1562,6 +1595,27 @@ export class Cache implements CacheLayer {
1562
1595
  return entry
1563
1596
  }
1564
1597
 
1598
+ /**
1599
+ * Whether no lookup here can hit: no artifact, inline or file. Every
1600
+ * entry has one, and a row-less one is still a hit (`adopt`). The listing
1601
+ * is kept for `adopt`.
1602
+ */
1603
+ holdsNothing(): boolean {
1604
+ if (!this.read) return true
1605
+ if (this.guard(() => this.db.query('SELECT 1 FROM artifacts LIMIT 1').get()) != null) {
1606
+ return false
1607
+ }
1608
+ let names: string[]
1609
+ try {
1610
+ names = readdirSync(this.artifactDir)
1611
+ } catch (err) {
1612
+ if ((err as NodeJS.ErrnoException).code !== 'ENOENT') return false
1613
+ names = []
1614
+ }
1615
+ this.artifactNames ??= new Set(names)
1616
+ return !names.some((n) => n.endsWith('.tar.zst'))
1617
+ }
1618
+
1565
1619
  /**
1566
1620
  * `get` for many hashes at once: one `entries` query and one
1567
1621
  * `output_files` query per chunk instead of two per hash, with the
@@ -2391,6 +2445,7 @@ export class Cache implements CacheLayer {
2391
2445
  } else {
2392
2446
  aside = moveAside()
2393
2447
  renameSync(tmpPath!, finalPath)
2448
+ this.artifactNames?.add(path.basename(finalPath))
2394
2449
  renamed = true
2395
2450
  deleteBlob.run(hash)
2396
2451
  }
@@ -219,7 +219,7 @@ export async function resolveInputs(args: ResolveInputsArgs): Promise<ResolvedIn
219
219
  // Dedupe: when the project dir IS the workspace root (or a glob
220
220
  // overlaps), the same absolute path can arrive via both lists —
221
221
  // it must contribute to the key exactly once.
222
- if (wsFiles.length > 0) files = [...new Set([...projectFiles, ...wsFiles])].sort()
222
+ if (wsFiles.length > 0) files = mergeSorted(projectFiles, wsFiles)
223
223
  }
224
224
  // Two awaited empty resolutions per task were ~3 ms of a 1000-task warm
225
225
  // run (profiled 2026-09-09); a task declaring neither skips the fan-out.
@@ -285,7 +285,6 @@ function resolveWorkspaceFiles(args: {
285
285
  }
286
286
  if (positive.length === 0) return undefined
287
287
 
288
- const isExcluded = anyTaskGlob([...ALWAYS_IGNORE, ...asTrees(negative)])
289
288
  // A path the task's own outputs take back with `!` is no output, so it
290
289
  // stays an input (A-44).
291
290
  const ownOutput = outputMatcher(args.ownWorkspaceOutputs)
@@ -310,40 +309,44 @@ function resolveWorkspaceFiles(args: {
310
309
  // The memo is valid for the snapshot it was computed over: a task that
311
310
  // wrote workspace outputs mid-run replaces the partition, and the next
312
311
  // caller sees a different array and scans again.
313
- const memoKey =
314
- args.memo === undefined
315
- ? undefined
316
- : JSON.stringify([
317
- positive,
318
- negative,
319
- args.ownWorkspaceOutputs,
320
- args.ownOutputs,
321
- args.projectRel,
322
- ])
312
+ let isExcluded: ((rel: string) => boolean) | undefined
323
313
  let isPositive: ((rel: string) => boolean) | undefined
324
- const excluded = (rel: string): boolean => isExcluded(rel) || ownOutput(rel) || ownProject(rel)
314
+ const excludedByDeclaration = (rel: string): boolean =>
315
+ (isExcluded ??= anyTaskGlob([...ALWAYS_IGNORE, ...asTrees(negative)]))(rel)
316
+ const own = (rel: string): boolean => ownOutput(rel) || ownProject(rel)
325
317
  const listing: InputListing = {
326
318
  root: args.workspaceRoot,
327
319
  listed: gitFiles,
328
- isInput: (rel) => (isPositive ??= anyTaskGlob(asTrees(positive)))(rel) && !excluded(rel),
320
+ isInput: (rel) =>
321
+ (isPositive ??= anyTaskGlob(asTrees(positive)))(rel) &&
322
+ !excludedByDeclaration(rel) &&
323
+ !own(rel),
329
324
  nested: () => false,
330
325
  ...reachOf(positive),
331
326
  }
332
- if (memoKey !== undefined) {
333
- const hit = args.memo!.get(memoKey)
334
- if (hit !== undefined && hit.snapshot === gitFiles) return { files: hit.result, listing }
327
+ // Two levels: the declaration's files over the snapshot, shared by every
328
+ // task that declares it, then the task's own outputs taken out. A
329
+ // Turbo-mapped repo gives two thousand tasks one 400-entry list, and a
330
+ // memo keyed by the project too scanned, resolved and sorted the 4,000
331
+ // files it matched once per task (astro, 2026-10-09).
332
+ const declared = JSON.stringify([positive, negative])
333
+ let base: Promise<string[]> | undefined
334
+ const hit = args.memo?.get(declared)
335
+ if (hit !== undefined && hit.snapshot === gitFiles) base = hit.result
336
+ if (base === undefined) {
337
+ isPositive ??= anyTaskGlob(asTrees(positive))
338
+ base = resolveWorkspaceFilesOver(args, gitFiles, positive, isPositive, excludedByDeclaration)
339
+ args.memo?.set(declared, { snapshot: gitFiles, result: base })
335
340
  }
336
- isPositive ??= anyTaskGlob(asTrees(positive))
337
- const result = resolveWorkspaceFilesOver(
338
- args,
339
- gitFiles,
340
- positive,
341
- isPositive,
342
- excluded,
343
- undecodable,
344
- )
345
- if (memoKey !== undefined) args.memo!.set(memoKey, { snapshot: gitFiles, result })
346
- return { files: result, listing }
341
+ const ownsAny =
342
+ splitNegations(args.ownWorkspaceOutputs).positive.length > 0 ||
343
+ splitNegations(args.ownOutputs).positive.length > 0
344
+ const files = base.then((all) => {
345
+ const kept = ownsAny ? all.filter((abs) => !own(relPosix(args.workspaceRoot, abs))) : all
346
+ refuseUndecodable(kept, undecodable, args.workspaceRoot, 'workspaceFiles')
347
+ return kept
348
+ })
349
+ return { files, listing }
347
350
  }
348
351
 
349
352
  async function resolveWorkspaceFilesOver(
@@ -352,7 +355,6 @@ async function resolveWorkspaceFilesOver(
352
355
  positive: readonly string[],
353
356
  isPositive: (rel: string) => boolean,
354
357
  excluded: (rel: string) => boolean,
355
- undecodable: ReadonlySet<string> | undefined,
356
358
  ): Promise<string[]> {
357
359
  // Second call site of the literal-input guard. `resolveWorkspaceFiles`
358
360
  // carries its own copy of the filter-over-git-set design, so the same
@@ -371,13 +373,47 @@ async function resolveWorkspaceFilesOver(
371
373
  if (unmatchedLiterals.size > 0) {
372
374
  await assertNoInvisibleLiteralInputs(unmatchedLiterals, args.workspaceRoot, 'workspaceFiles')
373
375
  }
374
- refuseUndecodable(candidates, undecodable, args.workspaceRoot, 'workspaceFiles')
375
376
  // Same OID-trust shortcut as project files: a clean-per-status
376
377
  // tracked file necessarily exists on disk.
377
378
  const oids = args.gitFilesCache?.oidsFor(args.workspaceRoot)
378
379
  return candidates.filter((abs) => oids?.has(abs) === true || isInputOnDisk(abs)).sort()
379
380
  }
380
381
 
382
+ /**
383
+ * The sorted union of two path lists, each once. Both sides arrive sorted
384
+ * and distinct, so one merge does what a Set and a sort of the
385
+ * concatenation did: 3.7 s of an astro plan, where 2,000 tasks each joined
386
+ * a few project files to the same 4,000 workspace files (2026-10-09). A
387
+ * side out of order takes the general path.
388
+ */
389
+ function mergeSorted(a: readonly string[], b: readonly string[]): string[] {
390
+ if (a.length === 0 && isStrictlySorted(b)) return b as string[]
391
+ if (!isStrictlySorted(a) || !isStrictlySorted(b)) return [...new Set([...a, ...b])].sort()
392
+ const out: string[] = []
393
+ let i = 0
394
+ let j = 0
395
+ while (i < a.length && j < b.length) {
396
+ const x = a[i]!
397
+ const y = b[j]!
398
+ if (x <= y) {
399
+ out.push(x)
400
+ i++
401
+ if (x === y) j++
402
+ } else {
403
+ out.push(y)
404
+ j++
405
+ }
406
+ }
407
+ while (i < a.length) out.push(a[i++]!)
408
+ while (j < b.length) out.push(b[j++]!)
409
+ return out
410
+ }
411
+
412
+ function isStrictlySorted(list: readonly string[]): boolean {
413
+ for (let i = 1; i < list.length; i++) if (!(list[i - 1]! < list[i]!)) return false
414
+ return true
415
+ }
416
+
381
417
  /** A project-relative matcher asked of root-relative paths: false outside the project. */
382
418
  function underProject(
383
419
  projectRel: string,
package/src/cli/docs.ts CHANGED
@@ -48,7 +48,7 @@ export interface DocsHit {
48
48
  }
49
49
 
50
50
  function words(s: string): string[] {
51
- return s.toLowerCase().match(/[a-z0-9][a-z0-9._-]*/g) ?? []
51
+ return s.toLowerCase().match(/[a-z0-9][a-z0-9_.-]*/g) ?? []
52
52
  }
53
53
 
54
54
  /** GitHub's heading anchor, which Starlight shares: lowercase, punctuation dropped, spaces to `-`. */
@@ -99,7 +99,11 @@ function count(hay: string, term: string): number {
99
99
  return n
100
100
  }
101
101
 
102
- /** The sections holding every term, best first: a term in the heading outweighs any count in the text. */
102
+ /**
103
+ * The sections holding every term, best first: a term in the heading
104
+ * outweighs any count in the text. A section titled by the one word asked
105
+ * (an error code) is the answer alone.
106
+ */
103
107
  export function searchDocs(
104
108
  pages: Readonly<Record<string, string>>,
105
109
  terms: readonly string[],
@@ -123,7 +127,9 @@ export function searchDocs(
123
127
  }
124
128
  }
125
129
  scored.sort((a, b) => b.score - a.score || a.order - b.order)
126
- return scored.slice(0, limit).map(({ s }) => ({
130
+ const exact =
131
+ terms.length === 1 ? scored.filter(({ s }) => words(s.heading).join(' ') === terms[0]) : []
132
+ return (exact.length > 0 ? exact : scored).slice(0, limit).map(({ s }) => ({
127
133
  page: s.page,
128
134
  heading: s.heading,
129
135
  url: s.heading === '' ? pageUrl(s.page) : `${pageUrl(s.page)}#${anchor(s.heading)}`,
package/src/cli/help.ts CHANGED
@@ -261,7 +261,8 @@ export function helpText(pluginCommands: readonly string[] = []): string {
261
261
  'Docs:',
262
262
  ' vx docs <query> Search the reference offline (cli, schema, caching, execution,',
263
263
  ' patterns, security, features): the sections holding every',
264
- ' word of the query print whole, best first, with their URL.',
264
+ ' word of the query print whole, best first, with their URL;',
265
+ ' an error code (vx docs VX_E_CYCLE) prints its fix alone.',
265
266
  ' --limit N How many sections (default 3).',
266
267
  ' --format json {query, hits: [{page, heading, url, text}]}.',
267
268
  '',
package/src/cli/init.ts CHANGED
@@ -421,12 +421,17 @@ const INSTALL: Record<PackageManager, string> = {
421
421
 
422
422
  const EXEC: Record<PackageManager, string> = { pnpm: 'pnpm', yarn: 'yarn', bun: 'bunx', npm: 'npx' }
423
423
 
424
- /** Install what the workspace file imports, if missing, then run the repo's build. */
424
+ /**
425
+ * Install what the workspace file imports, if missing, then run the repo's
426
+ * build. Pinned to this vx's version: every @vzn package releases in
427
+ * lockstep, and `latest` beside an older vx mismatched them (agent trial 2).
428
+ */
425
429
  export function adoptionNext(
426
430
  root: string,
427
431
  runner: 'turbo' | 'nx',
428
432
  source: string,
429
433
  needs: readonly string[] = ['@vzn/vx', '@vzn/vx-migrate'],
434
+ version: string = VERSION,
430
435
  ): string {
431
436
  const pm = LOCKFILES.find(([f]) => existsSync(path.join(root, f)))?.[1] ?? 'npm'
432
437
  const missing = needs.filter(
@@ -443,7 +448,9 @@ export function adoptionNext(
443
448
  pm === 'yarn' && !readText(path.join(root, 'yarn.lock')).includes('__metadata:')
444
449
  ? `${INSTALL.yarn} -W`
445
450
  : INSTALL[pm]
446
- return `${install} ${missing.join(' ')} && ${run}`
451
+ // A source checkout is 0.0.0, which npm never had.
452
+ const pinned = version === '0.0.0' ? missing : missing.map((p) => `${p}@${version}`)
453
+ return `${install} ${pinned.join(' ')} && ${run}`
447
454
  }
448
455
 
449
456
  function readText(file: string): string {
@@ -145,10 +145,12 @@ export interface PlanTaskJson {
145
145
  affected?: AffectedReason
146
146
  }
147
147
 
148
- export function formatPlanJson(plan: RunPlan): string {
148
+ /** `affectedBase`: the ref a bare `--affected` guessed (or was given), so a reader knows what the diff was against. */
149
+ export function formatPlanJson(plan: RunPlan, affectedBase?: string): string {
149
150
  return (
150
151
  JSON.stringify(
151
152
  {
153
+ ...(affectedBase !== undefined ? { affectedBase } : {}),
152
154
  tasks: plan.tasks.map((t): PlanTaskJson => {
153
155
  const description = shownDescription(t)
154
156
  return {
package/src/cli/run.ts CHANGED
@@ -521,6 +521,8 @@ export async function resolveRunOptions(
521
521
  cwd: string,
522
522
  tasks: readonly string[],
523
523
  verb: 'run' | 'watch' = 'run',
524
+ /** Given, it learns the ref `--affected` diffed from (`--dry=json` states it). */
525
+ scopeOut?: { affectedBase?: string },
524
526
  ): Promise<RunOptions | { error: string } | { nothingSelected: string }> {
525
527
  for (const t of tasks) {
526
528
  const idx = t.indexOf('#')
@@ -536,6 +538,9 @@ export async function resolveRunOptions(
536
538
  const scope = await scopeFilters(parsed, cwd)
537
539
  if ('error' in scope) return scope
538
540
  const { filterStrings, affectedFilter } = scope
541
+ // `...[<base>]`: the base alone.
542
+ if (scopeOut !== undefined && affectedFilter !== undefined)
543
+ scopeOut.affectedBase = affectedFilter.slice(4, -1)
539
544
 
540
545
  // Project scope applies to bare task names only. Anchored entries
541
546
  // (pkg#task) resolve directly to their own project regardless.
@@ -709,7 +714,8 @@ export async function runCmd(args: readonly string[]): Promise<number> {
709
714
  tasks = [picked]
710
715
  }
711
716
 
712
- const resolved = await resolveRunOptions(parsed, cwd, tasks)
717
+ const scopeOut: { affectedBase?: string } = {}
718
+ const resolved = await resolveRunOptions(parsed, cwd, tasks, 'run', scopeOut)
713
719
  if ('error' in resolved) return refuse('run', args, resolved.error, 'VX_E_USAGE')
714
720
  if ('nothingSelected' in resolved) {
715
721
  process.stderr.write(`vx run: ${resolved.nothingSelected}\n`)
@@ -769,7 +775,7 @@ export async function runCmd(args: readonly string[]): Promise<number> {
769
775
  }
770
776
  }
771
777
  } else if (parsed.dry === 'json') {
772
- process.stdout.write(formatPlanJson(plan))
778
+ process.stdout.write(formatPlanJson(plan, scopeOut.affectedBase))
773
779
  } else {
774
780
  process.stdout.write(formatPlanText(plan))
775
781
  }
package/src/config.ts CHANGED
@@ -46,6 +46,15 @@ export interface WorkspaceConfig {
46
46
  * folded into a cache key.
47
47
  */
48
48
  cacheScope?: string
49
+ /**
50
+ * Which ready task starts first when there are more than workers, with no
51
+ * timings: `most-work` (the default) the one the most work waits on at
52
+ * any depth, `critical-path` the head of the longest chain of tasks,
53
+ * `direct-dependents` the one the most tasks wait on directly (Nx's
54
+ * rule), `ready-order` the one that became ready first (Turbo's). A
55
+ * `schedule` plugin's weights still come first; this breaks their ties.
56
+ */
57
+ schedule?: 'most-work' | 'critical-path' | 'direct-dependents' | 'ready-order'
49
58
  /**
50
59
  * Graph rules a run checks before it schedules anything. Each is on unless
51
60
  * set to `false`; turning one off allows a shape vx handles correctly but
@@ -125,20 +125,26 @@ export function computeReverseDepCount(nodes: Map<string, TaskNode>): Map<string
125
125
  }
126
126
 
127
127
  // Reverse-topo sweep: every direct dependent's closure is final
128
- // before its dependency folds it in. closure[i] = bitset over node
129
- // indices of i's transitive dependents.
128
+ // before its dependency folds it in. closure[t] = bitset over topo
129
+ // POSITIONS of the node at position t's transitive dependents. A
130
+ // dependent sits after its dependency, so a closure holds only bits
131
+ // past its own position, and each OR starts at the dependent's word:
132
+ // half the words of indexing by insertion order on a layered graph.
133
+ const pos = new Int32Array(n)
134
+ for (let t = 0; t < tail; t++) pos[topo[t]!] = t
130
135
  const closure = new Uint32Array(n * words)
131
136
  const counts = new Map<string, number>()
132
137
  for (let t = tail - 1; t >= 0; t--) {
133
138
  const i = topo[t]!
134
- const base = i * words
135
- for (const r of directReverse[i]!) {
139
+ const base = t * words
140
+ for (const ri of directReverse[i]!) {
141
+ const r = pos[ri]!
136
142
  closure[base + (r >>> 5)]! |= 1 << (r & 31)
137
143
  const rbase = r * words
138
- for (let w = 0; w < words; w++) closure[base + w]! |= closure[rbase + w]!
144
+ for (let w = r >>> 5; w < words; w++) closure[base + w]! |= closure[rbase + w]!
139
145
  }
140
146
  let count = 0
141
- for (let w = 0; w < words; w++) {
147
+ for (let w = t >>> 5; w < words; w++) {
142
148
  let v = closure[base + w]!
143
149
  v = v - ((v >>> 1) & 0x55555555)
144
150
  v = (v & 0x33333333) + ((v >>> 2) & 0x33333333)
@@ -153,6 +159,66 @@ export function computeReverseDepCount(nodes: Map<string, TaskNode>): Map<string
153
159
  return counts
154
160
  }
155
161
 
162
+ /**
163
+ * The ready-queue baseline a workspace may pick in place of the default
164
+ * (`schedule` in vx.workspace.ts). Each ranks with no timings; a `schedule`
165
+ * plugin's weights still sort above it, and it breaks their ties.
166
+ * - `most-work`: transitive dependents (`computeReverseDepCount`), the default.
167
+ * - `critical-path`: steps on the longest chain of dependents below a task.
168
+ * - `direct-dependents`: direct dependents only, Nx's rule.
169
+ * - `ready-order`: none; the task that became ready first starts first, Turbo's rule.
170
+ */
171
+ export type ScheduleStrategy = 'most-work' | 'critical-path' | 'direct-dependents' | 'ready-order'
172
+
173
+ /** The baseline for a strategy other than `most-work`, over the whole graph. */
174
+ export function strategyPriorities(
175
+ nodes: Map<string, TaskNode>,
176
+ strategy: Exclude<ScheduleStrategy, 'most-work'>,
177
+ ): Map<string, number> {
178
+ const out = new Map<string, number>()
179
+ if (strategy === 'ready-order') {
180
+ for (const id of nodes.keys()) out.set(id, 0)
181
+ return out
182
+ }
183
+ const dependents = new Map<string, string[]>()
184
+ for (const node of nodes.values()) {
185
+ for (const dep of node.deps) {
186
+ if (!nodes.has(dep)) continue
187
+ const list = dependents.get(dep)
188
+ if (list === undefined) dependents.set(dep, [node.id])
189
+ else list.push(node.id)
190
+ }
191
+ }
192
+ if (strategy === 'direct-dependents') {
193
+ for (const id of nodes.keys()) out.set(id, dependents.get(id)?.length ?? 0)
194
+ return out
195
+ }
196
+ // Longest chain below each task, dependents first: a task's depth is one
197
+ // more than its deepest dependent's. Iterative, as a deep chain would
198
+ // overflow a recursive walk.
199
+ const left = new Map<string, number>()
200
+ const ready: string[] = []
201
+ for (const id of nodes.keys()) {
202
+ const n = dependents.get(id)?.length ?? 0
203
+ left.set(id, n)
204
+ if (n === 0) ready.push(id)
205
+ }
206
+ while (ready.length > 0) {
207
+ const id = ready.pop()!
208
+ let depth = 0
209
+ for (const d of dependents.get(id) ?? []) depth = Math.max(depth, out.get(d)! + 1)
210
+ out.set(id, depth)
211
+ for (const dep of nodes.get(id)!.deps) {
212
+ if (!nodes.has(dep)) continue
213
+ const n = left.get(dep)! - 1
214
+ left.set(dep, n)
215
+ if (n === 0) ready.push(dep)
216
+ }
217
+ }
218
+ for (const id of nodes.keys()) if (!out.has(id)) out.set(id, 0)
219
+ return out
220
+ }
221
+
156
222
  export function mergePriorities(
157
223
  baseline: ReadonlyMap<string, number>,
158
224
  overrides: ReadonlyMap<string, number>,
@@ -5,7 +5,13 @@ import {
5
5
  isUserError,
6
6
  OUT_OF_FDS_HINT,
7
7
  } from '../util/index.js'
8
- import { computeReverseDepCount, mergePriorities, tieredReverseDepCount } from './priorities.js'
8
+ import {
9
+ computeReverseDepCount,
10
+ mergePriorities,
11
+ strategyPriorities,
12
+ tieredReverseDepCount,
13
+ type ScheduleStrategy,
14
+ } from './priorities.js'
9
15
  import { isGroupTask, type TaskNode } from './task-graph.js'
10
16
 
11
17
  /**
@@ -278,6 +284,11 @@ export interface ScheduleOptions {
278
284
  * heuristic (the default and historically the only behavior).
279
285
  */
280
286
  priorities?: ReadonlyMap<string, number>
287
+ /**
288
+ * The baseline ranking under `priorities` (`schedule` in vx.workspace.ts).
289
+ * Undefined or `most-work` is the reverse-deps count above.
290
+ */
291
+ strategy?: ScheduleStrategy
281
292
  /**
282
293
  * Restore-tier task ids: confirmed stable-key LOCAL cache hits (the
283
294
  * local short-circuit). A restore-tier task:
@@ -494,9 +505,11 @@ export async function runGraph(options: ScheduleOptions): Promise<Map<string, Ta
494
505
  // use those weights. Falls back to the reverse-deps-count heuristic
495
506
  // for nodes the caller didn't score, so partial coverage works.
496
507
  const baseline =
497
- options.restoreTier !== undefined && options.restoreTier.size > 0
498
- ? tieredReverseDepCount(nodes, options.restoreTier)
499
- : computeReverseDepCount(nodes)
508
+ options.strategy !== undefined && options.strategy !== 'most-work'
509
+ ? strategyPriorities(nodes, options.strategy)
510
+ : options.restoreTier !== undefined && options.restoreTier.size > 0
511
+ ? tieredReverseDepCount(nodes, options.restoreTier)
512
+ : computeReverseDepCount(nodes)
500
513
  const priority: ReadonlyMap<string, number> = options.priorities
501
514
  ? mergePriorities(baseline, options.priorities)
502
515
  : baseline
@@ -1517,6 +1517,7 @@ function detectInputOverlaps(nodes: Map<string, TaskNode>, workspaceRoot?: strin
1517
1517
  const filesWriters: TaskNode[] = []
1518
1518
  let projectReaders = false
1519
1519
  let rootReaders = false
1520
+ const memo: TakeBackMemo = { bySide: new Map(), byList: new Map() }
1520
1521
  const bucket = (name: string): Side[] => {
1521
1522
  let list = byProject.get(name)
1522
1523
  if (list === undefined) byProject.set(name, (list = []))
@@ -1557,7 +1558,7 @@ function detectInputOverlaps(nodes: Map<string, TaskNode>, workspaceRoot?: strin
1557
1558
  (s) => s.globs,
1558
1559
  (s) => s.reads,
1559
1560
  )) {
1560
- readsOutputs(sides[i]!, sides[j]!, 'files')
1561
+ readsOutputs(memo, sides[i]!, sides[j]!, 'files')
1561
1562
  }
1562
1563
  }
1563
1564
  }
@@ -1575,7 +1576,7 @@ function detectInputOverlaps(nodes: Map<string, TaskNode>, workspaceRoot?: strin
1575
1576
  (s) => s.globs,
1576
1577
  (s) => s.reads,
1577
1578
  )) {
1578
- readsOutputs(sides[i]!, sides[j]!, 'files')
1579
+ readsOutputs(memo, sides[i]!, sides[j]!, 'files')
1579
1580
  }
1580
1581
  }
1581
1582
  if (!rootReaders) return
@@ -1593,26 +1594,63 @@ function detectInputOverlaps(nodes: Map<string, TaskNode>, workspaceRoot?: strin
1593
1594
  (s) => s.globs,
1594
1595
  (s) => s.reads,
1595
1596
  )) {
1596
- readsOutputs(rooted[i]!, rooted[j]!, 'workspaceFiles')
1597
+ readsOutputs(memo, rooted[i]!, rooted[j]!, 'workspaceFiles')
1597
1598
  }
1598
1599
  }
1599
1600
 
1600
- function readsOutputs(x: Side, y: Side, field: 'files' | 'workspaceFiles'): void {
1601
+ /**
1602
+ * Whether a reader's take-back list covers an output glob, asked once per
1603
+ * list and glob: a Turbo-mapped repo gives two thousand readers one
1604
+ * 400-entry list, and asking it per reader and writer pair was 2.7 s of an
1605
+ * astro plan (2026-10-09). One per `detectInputOverlaps` call.
1606
+ */
1607
+ interface TakeBack {
1608
+ list: string[]
1609
+ verdicts: Map<string, boolean>
1610
+ }
1611
+ type TakeBackMemo = { bySide: Map<Side, TakeBack>; byList: Map<string, TakeBack> }
1612
+
1613
+ function takenBack(
1614
+ memo: TakeBackMemo,
1615
+ reader: Side,
1616
+ field: 'files' | 'workspaceFiles',
1617
+ go: string,
1618
+ ): boolean {
1619
+ let tb = memo.bySide.get(reader)
1620
+ if (tb === undefined) {
1621
+ const cache = reader.node.config.cache!
1622
+ // A reader rebased to the root (X-135) speaks in its project's terms.
1623
+ const at = reader.rel === undefined || reader.rel === '' ? '' : `${reader.rel}/`
1624
+ // What the reader's key never reads: its `!` entries and its own outputs.
1625
+ const list = [
1626
+ ...splitNegations(
1627
+ field === 'files' ? cache.inputs.files : (cache.inputs.workspaceFiles ?? []),
1628
+ ).negative,
1629
+ ...splitNegations(
1630
+ field === 'files' ? cache.outputs.files : (cache.outputs.workspaceFiles ?? []),
1631
+ ).positive,
1632
+ ].map((g) => at + g)
1633
+ const key = list.join('\0')
1634
+ tb = memo.byList.get(key)
1635
+ if (tb === undefined) memo.byList.set(key, (tb = { list, verdicts: new Map() }))
1636
+ memo.bySide.set(reader, tb)
1637
+ }
1638
+ let verdict = tb.verdicts.get(go)
1639
+ if (verdict === undefined) tb.verdicts.set(go, (verdict = outputTakenBack(go, tb.list)))
1640
+ return verdict
1641
+ }
1642
+
1643
+ function readsOutputs(
1644
+ memo: TakeBackMemo,
1645
+ x: Side,
1646
+ y: Side,
1647
+ field: 'files' | 'workspaceFiles',
1648
+ ): void {
1601
1649
  if (x.reads === y.reads || x.node === y.node) return
1602
1650
  const [reader, writer] = x.reads ? [x, y] : [y, x]
1603
- const cache = reader.node.config.cache!
1604
- // A reader rebased to the root (X-135) speaks in its project's terms.
1605
1651
  const at = reader.rel === undefined || reader.rel === '' ? '' : `${reader.rel}/`
1606
- // What the reader's key never reads: its `!` entries and its own outputs.
1607
- const takeBack = [
1608
- ...splitNegations(field === 'files' ? cache.inputs.files : (cache.inputs.workspaceFiles ?? []))
1609
- .negative,
1610
- ...splitNegations(
1611
- field === 'files' ? cache.outputs.files : (cache.outputs.workspaceFiles ?? []),
1612
- ).positive,
1613
- ].map((g) => at + g)
1614
1652
  for (const go of writer.globs) {
1615
- if (outputTakenBack(go, takeBack)) continue
1653
+ if (takenBack(memo, reader, field, go)) continue
1616
1654
  for (const gi of reader.globs) {
1617
1655
  if (!outputsOverlap(gi, go)) continue
1618
1656
  const shown =
@@ -113,11 +113,17 @@ async function runPrefetch(args: PrefetchArgs): Promise<void> {
113
113
  */
114
114
  export function getContext(node: TaskNode, command: string) {
115
115
  const outputs = node.config.cache?.outputs
116
+ let masked: string | undefined
116
117
  return {
117
118
  taskId: node.id,
118
119
  // Stored in the entry row a remote hit inserts: masked as a save's is,
119
- // or the token a config inlined sat in cache.db (L-27).
120
- command: maskedCommand(command, node.config.exec?.env),
120
+ // or the token a config inlined sat in cache.db (L-27). Masked on the
121
+ // first read: only an adopt or a remote hit reads it, and masking every
122
+ // lookup's command cost a cold 1,090-package run ~20 ms before its
123
+ // first task.
124
+ get command() {
125
+ return (masked ??= maskedCommand(command, node.config.exec?.env))
126
+ },
121
127
  outputs: { files: outputs?.files ?? [], workspaceFiles: outputs?.workspaceFiles ?? [] },
122
128
  }
123
129
  }
@@ -945,7 +945,15 @@ async function runOnBus(
945
945
  // shouldShortCircuit (local reads on, no remote layer); when off, both
946
946
  // maps are empty and the run is byte-identical.
947
947
  let shortCircuit: ShortCircuit = emptyShortCircuit()
948
- if (shouldShortCircuit(nodes, policy, cache)) {
948
+ // An empty local cache answers every probe with a miss: each task
949
+ // derives its key as it starts instead (~100 ms before a cold 1,090-
950
+ // package run's first spawn), and every sandboxed task runs.
951
+ const coldLocal =
952
+ cache === prepared.localCache &&
953
+ shouldShortCircuit(nodes, policy, cache) &&
954
+ prepared.localCache.holdsNothing()
955
+ if (coldLocal) prewarm()
956
+ else if (shouldShortCircuit(nodes, policy, cache)) {
949
957
  shortCircuit = await startLocalShortCircuit({
950
958
  nodes,
951
959
  cache,
@@ -1165,6 +1173,10 @@ async function runOnBus(
1165
1173
  // A `schedule` plugin's weights; the scheduler keeps its structural
1166
1174
  // baseline as the tie-break. Empty map → baseline only.
1167
1175
  ...(prepared.priorities.size > 0 ? { priorities: prepared.priorities } : {}),
1176
+ // The baseline under them; `most-work` when the workspace names none.
1177
+ ...(prepared.workspaceConfig?.schedule !== undefined
1178
+ ? { strategy: prepared.workspaceConfig.schedule }
1179
+ : {}),
1168
1180
  // Local short-circuit: confirmed stable local hits the scheduler
1169
1181
  // runs ahead of their deps as low-priority worker-slot backfill.
1170
1182
  // Empty when the short-circuit didn't fire → byte-identical.
@@ -62,9 +62,16 @@ export function wantsJson(argv: readonly string[]): boolean {
62
62
  return false
63
63
  }
64
64
 
65
- /** The JSON a verb that answers JSON prints for a refusal: one line. */
65
+ /**
66
+ * The JSON a verb that answers JSON prints for a refusal: one line. A
67
+ * core code (`VX_E_…`) links its section of cli.md, which says the fix;
68
+ * `vx docs <code>` prints the same section offline. A plugin's code has none.
69
+ */
66
70
  export function errorDocument(code: string, message: string): string {
67
- return `${JSON.stringify({ ok: false, error: { code, message } })}\n`
71
+ const docs = code.startsWith('VX_E_')
72
+ ? { docs: `https://vznjs.github.io/vx/cli/#${code.toLowerCase()}` }
73
+ : {}
74
+ return `${JSON.stringify({ ok: false, error: { code, message, ...docs } })}\n`
68
75
  }
69
76
 
70
77
  /**
package/src/util/paths.ts CHANGED
@@ -195,6 +195,30 @@ export const EXTGLOB = /[!@+*?]\(/
195
195
  * else: one site that forgot is a route directory that keys nothing.
196
196
  */
197
197
  export function taskGlob(pattern: string): Bun.Glob {
198
+ const memo = memoFor(taskGlobs)
199
+ let glob = memo.get(pattern)
200
+ if (glob === undefined) memo.set(pattern, (glob = compileTaskGlob(pattern)))
201
+ return glob
202
+ }
203
+
204
+ /**
205
+ * Compiled once per pattern: a Turbo-mapped config repeats one 400-entry
206
+ * list across two thousand tasks, and the graph's overlap checks compiled
207
+ * each entry per task pair, 3.3 s of an astro plan (2026-10-09). A pattern
208
+ * comes from a config, so the set is bounded by what the configs say. Kept
209
+ * per `Bun.Glob` in force: the playground and a test swap it, and a glob
210
+ * built by one is not the other's.
211
+ */
212
+ const taskGlobs = new WeakMap<object, Map<string, Bun.Glob>>()
213
+ const anyTaskGlobs = new WeakMap<object, Map<string, (rel: string) => boolean>>()
214
+
215
+ function memoFor<T>(memos: WeakMap<object, Map<string, T>>): Map<string, T> {
216
+ let memo = memos.get(Bun.Glob)
217
+ if (memo === undefined) memos.set(Bun.Glob, (memo = new Map()))
218
+ return memo
219
+ }
220
+
221
+ function compileTaskGlob(pattern: string): Bun.Glob {
198
222
  // A `]` with no `[` before it is already literal to `Bun.Glob`.
199
223
  const source = pattern.includes('[') ? pattern.replace(/(?<!\\)[[\]]/g, '\\$&') : pattern
200
224
  const glob = new Bun.Glob(source)
@@ -228,6 +252,14 @@ export function taskGlob(pattern: string): Bun.Glob {
228
252
  * `src/a.ts` and never `src/deep/a.ts`, and an edit there was a stale hit.
229
253
  */
230
254
  export function anyTaskGlob(rawPatterns: readonly string[]): (rel: string) => boolean {
255
+ const memo = memoFor(anyTaskGlobs)
256
+ const key = rawPatterns.join('\0')
257
+ let any = memo.get(key)
258
+ if (any === undefined) memo.set(key, (any = compileAnyTaskGlob(rawPatterns)))
259
+ return any
260
+ }
261
+
262
+ function compileAnyTaskGlob(rawPatterns: readonly string[]): (rel: string) => boolean {
231
263
  const patterns = rawPatterns.flatMap(braceAlternatives)
232
264
  if (patterns.length === 0) return () => false
233
265
  const sources = patterns.map(regExpSource)
@@ -56,9 +56,18 @@ const WORKSPACE_FIELDS = new Set([
56
56
  'affectedBase',
57
57
  'cacheScope',
58
58
  'rules',
59
+ 'schedule',
59
60
  'plugins',
60
61
  ])
61
62
 
63
+ type Schedule = NonNullable<WorkspaceConfig['schedule']>
64
+ const SCHEDULES = {
65
+ 'most-work': true,
66
+ 'critical-path': true,
67
+ 'direct-dependents': true,
68
+ 'ready-order': true,
69
+ } satisfies Record<Schedule, true>
70
+
62
71
  const RULE_FIELDS = new Set(['exclusiveOutputs', 'upfrontKeys'])
63
72
 
64
73
  function validateRules(rules: unknown, configPath: string): void {
@@ -184,6 +193,13 @@ export function validateWorkspace(config: WorkspaceConfig, configPath: string):
184
193
  throw new UserError(`${configPath}: \`cacheScope\` ${CACHE_SCOPE_RULE}`)
185
194
  }
186
195
  if (config.rules !== undefined) validateRules(config.rules, configPath)
196
+ if (config.schedule !== undefined && !Object.hasOwn(SCHEDULES, config.schedule)) {
197
+ throw new UserError(
198
+ `${configPath}: \`schedule\` must be one of ${Object.keys(SCHEDULES)
199
+ .map((k) => `'${k}'`)
200
+ .join(', ')}`,
201
+ )
202
+ }
187
203
  if (config.plugins !== undefined) {
188
204
  if (!Array.isArray(config.plugins)) {
189
205
  throw new UserError(`${configPath}: \`plugins\` must be an array of plugin objects`)
@@ -36,6 +36,8 @@ export interface LockfileEntry {
36
36
  export interface Lockfile {
37
37
  version: number
38
38
  projects: Record<string, LockfileEntry>
39
+ /** Written by `writeLockfile` from the bytes; never read off the object. */
40
+ validated?: string
39
41
  }
40
42
 
41
43
  export function lockfilePath(root: string): string {
@@ -100,12 +102,33 @@ export async function readLockfile(root: string): Promise<Lockfile | null> {
100
102
  }
101
103
  }
102
104
  lockDigests.set(parsed, xxh3(text).toString(16))
105
+ const stamped = VALIDATED_LINE.exec(text)
106
+ if (stamped !== null && stamped[1] === validatedDigest(text.slice(0, stamped.index) + '\n}')) {
107
+ selfValidated.add(parsed)
108
+ }
103
109
  return parsed as Lockfile
104
110
  }
105
111
 
106
112
  /** The digest of the bytes each lock `readLockfile` returned was parsed from. */
107
113
  const lockDigests = new WeakMap<object, string>()
108
114
 
115
+ /**
116
+ * The lock's own record that this vx validated its entries: `vx lock`
117
+ * evaluates and validates every config, then writes a digest of the bytes
118
+ * it wrote, under vx's version (validation's rules are vx's alone, so Bun's
119
+ * is left out and does not churn a committed lock), as the last field. A lock
120
+ * whose digest matches is validated already, and a cold `--frozen` run
121
+ * skips the ~30 ms of validating 1,090 entries before its first task. An
122
+ * edit, a merge, or another vx version leaves it unmatched, and the
123
+ * entries are validated as before.
124
+ */
125
+ const VALIDATED_LINE = /,\n {2}"validated": "([0-9a-f]+)"\n\}\n?$/
126
+ const selfValidated = new WeakSet<object>()
127
+
128
+ function validatedDigest(body: string): string {
129
+ return xxh3(`vx-lock-valid-v${CONFIG_EVAL_VERSION}\0${VERSION}\0${body}`).toString(16)
130
+ }
131
+
109
132
  export async function writeLockfile(root: string, lock: Lockfile): Promise<void> {
110
133
  // Written beside its name and renamed over it: `Bun.write` truncates in
111
134
  // place, so a `--frozen` run, or `vx watch --frozen` woken by the write
@@ -114,7 +137,8 @@ export async function writeLockfile(root: string, lock: Lockfile): Promise<void>
114
137
  const file = lockfilePath(root)
115
138
  const tmp = `${file}.tmp-${process.pid}-${Math.random().toString(36).slice(2)}`
116
139
  try {
117
- await Bun.write(tmp, `${JSON.stringify(lock, null, 2)}\n`)
140
+ const body = JSON.stringify({ version: lock.version, projects: lock.projects }, null, 2)
141
+ await Bun.write(tmp, `${body.slice(0, -2)},\n "validated": "${validatedDigest(body)}"\n}\n`)
118
142
  await rename(tmp, file)
119
143
  } catch (err) {
120
144
  // Best effort: where the write was refused the temp cannot be named
@@ -187,7 +211,10 @@ export async function frozenProjectConfigs(
187
211
  // asked for 1,090 per-project rows in one `IN` query, ~10 ms of its
188
212
  // config load, where the eval-cache path it replaces is cheaper (X-181).
189
213
  // A scoped run still validates, and remembers, only what it loads.
190
- if (prefix !== undefined && store !== undefined && store.getConfigEval(prefix) !== null) {
214
+ if (
215
+ selfValidated.has(lock) ||
216
+ (prefix !== undefined && store !== undefined && store.getConfigEval(prefix) !== null)
217
+ ) {
191
218
  return entries.map((e) => e.config)
192
219
  }
193
220
  const keys = prefix === undefined ? undefined : metas.map((m) => `${prefix}\0${m.name}`)