@vzn/vx 0.0.135 → 0.0.137

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/README.md CHANGED
@@ -5,8 +5,8 @@
5
5
  <h1 align="center">vx</h1>
6
6
 
7
7
  <p align="center">
8
- <strong>A fast, correct task runner and build cache for JavaScript monorepos.</strong><br>
9
- One binary. No daemon, no cloud, no account.
8
+ <strong>A faster runner for your Turborepo or Nx repo.</strong><br>
9
+ Keep <code>turbo.json</code> or <code>nx.json</code> and run it unchanged.
10
10
  </p>
11
11
 
12
12
  <p align="center">
@@ -16,29 +16,52 @@
16
16
  <a href="https://vznjs.github.io/vx/"><img src="https://img.shields.io/badge/docs-vznjs.github.io%2Fvx-c6f84e" alt="Documentation"></a>
17
17
  </p>
18
18
 
19
- <p align="center">
20
- <picture>
21
- <source media="(prefers-color-scheme: dark)" srcset="https://vznjs.github.io/vx/bench-dark.svg">
22
- <img src="https://vznjs.github.io/vx/bench-light.svg" width="760" alt="Bar chart: vx against Turborepo and Nx on a 3,270-task graph, cold and fully cached.">
23
- </picture>
24
- </p>
19
+ <!-- bench:start — generated by packages/vx-bench/update-site.ts from results.json; do not hand-edit -->
25
20
 
26
- <p align="center">
27
- <em>Same graph, same commands, same concurrency. <a href="https://vznjs.github.io/vx/benchmarks/">Methodology and how to reproduce it</a>.</em>
28
- </p>
21
+ | 1,090 packages, 3,270 tasks | vx | Turborepo | Nx |
22
+ | -------------------------------- | ---------- | ---------- | -------- |
23
+ | Cold build: time the runner adds | **8 s** | 1 min 35 s | 31 min |
24
+ | Cold build: CPU burned | **35 s** | 1 min 13 s | 114 min |
25
+ | Fully cached run | **510 ms** | 760 ms | 3.59 s |
26
+ | Overhead per package | **8 ms** | 88 ms | 1,712 ms |
29
27
 
30
- <!-- bench:start — generated by packages/vx-bench/update-site.ts from results.json; do not hand-edit -->
28
+ Time added is the wall time over the tasks' own ideal schedule (3 min 38 s).
29
+ Same graph, commands and concurrency: [how it is measured](https://vznjs.github.io/vx/benchmarks/).
31
30
 
32
- The runner adds seconds to a cold build where others add minutes. On a
33
- 1,090-package graph of 3,270 tasks whose ideal schedule is 3m 38s,
34
- vx finishes in 3m 46s (+0:08), Turborepo in 5m 13s (+1:35) and Nx in
35
- 34m 44s (+31:06) — 8 ms of overhead per package against 88 ms and
36
- 1,712 ms, so the graph can grow and the runner stays in seconds. The cold build
37
- burns 35 s of CPU in vx, 73 s in Turborepo and 114 minutes in Nx; a
38
- fully cached run replays the graph in 510 ms.
39
31
  <!-- bench:end -->
40
32
 
41
- Measured on fourteen real Turborepo, Nx, moon, wireit and lage repos too: [tried on real repos](#tried-on-real-repos).
33
+ ## Try it on your repo
34
+
35
+ In a Turborepo or Nx workspace, add one file:
36
+
37
+ ```ts
38
+ // vx.workspace.ts
39
+ import { defineWorkspace } from '@vzn/vx'
40
+ import { turbo } from '@vzn/vx-migrate' // Nx: import { nx } and use nx()
41
+
42
+ export default defineWorkspace({ plugins: [turbo()] })
43
+ ```
44
+
45
+ Then:
46
+
47
+ ```sh
48
+ npm install -D @vzn/vx @vzn/vx-migrate
49
+ npx vx run build --all # your turbo.json or Nx targets, run by vx
50
+ npx vx run build --all # again: up-to-date in milliseconds
51
+ ```
52
+
53
+ Nothing is rewritten. `@vzn/vx-migrate` is not on npm yet (first publish
54
+ pending); [`examples/turbo`](examples/turbo) runs this setup on every
55
+ commit.
56
+
57
+ ## Why it is faster
58
+
59
+ - **Nothing boots per task.** No daemon, no Node process per task: vx
60
+ spawns your command directly.
61
+ - **Hashing without reading.** On a clean tree, cache keys come from
62
+ git's index; no file is read.
63
+ - **One pass over the repo.** One git walk per run; each stat and
64
+ lookup happens once.
42
65
 
43
66
  ## Install
44
67
 
@@ -93,48 +116,20 @@ runs on every commit. More: [Quickstart](https://vznjs.github.io/vx/quickstart/)
93
116
  [CLI](packages/vx/docs/cli.md) · [every config field](packages/vx/docs/schema.md) ·
94
117
  [playground](https://vznjs.github.io/vx/playground/) (the planner, in your browser).
95
118
 
96
- ## Already on Turborepo, Nx or moon?
97
-
98
- Keep your `turbo.json`, Nx project graph or `.moon/`. Two lines run it
99
- under vx, with nothing rewritten:
100
-
101
- ```ts
102
- // vx.workspace.ts
103
- import { defineWorkspace } from '@vzn/vx'
104
- import { turbo } from '@vzn/vx-migrate' // or: nx, moon
119
+ ## Configs of your own
105
120
 
106
- export default defineWorkspace({ plugins: [turbo()] })
107
- ```
108
-
109
- When you want configs of your own, one command writes them:
110
-
111
- ```sh
112
- bunx @vzn/vx-migrate # vx.config.ts per package, from turbo.json, an Nx graph or .moon/
113
- ```
114
-
115
- The written configs derive the same cache keys, so what `turbo()` cached
116
- still hits: [`examples/turbo`](examples/turbo) runs both steps on every
117
- commit.
118
-
119
- `turboCache()` and `nxCache()` keep the remote cache you have (Turbo's
120
- `/v8/artifacts` API, Vercel's included, or Nx's `/v1/cache`). What each
121
- Turbo and Nx feature is in vx, pinned by a test:
122
- [parity map](packages/vx/docs/parity.md) ·
121
+ When you want them, `bunx @vzn/vx-migrate` writes a `vx.config.ts` per
122
+ package from `turbo.json` or the Nx graph. They derive the same cache
123
+ keys, so what `turbo()` cached still hits. `turboCache()` and `nxCache()`
124
+ keep your Turbo or Nx remote cache. [Parity map](packages/vx/docs/parity.md) ·
123
125
  [migration guide](https://vznjs.github.io/vx/guides/migrate/).
124
126
 
125
- > The plugin packages (`@vzn/vx-migrate` and the rest below) are not on
126
- > npm yet; only `@vzn/vx` is.
127
-
128
- ## Why vx
127
+ ## More than speed
129
128
 
130
129
  - **A cache you can trust.** Configs are TypeScript, evaluated before
131
130
  hashing, so imports and computed values are part of the key. Declared
132
131
  outputs are wiped before every run and every restore: no stale file
133
- survives. On a clean tree keys come from git's index, with no file
134
- read.
135
- - **Fast where it counts.** Milliseconds of overhead per package, no
136
- daemon, one git walk per run. Every optimization is recorded with
137
- the invariant that keeps it valid ([ledger](packages/vx/docs/optimizations.md)).
132
+ survives.
138
133
  - **Sandboxed tasks.** Opt in per task: a read the task did not
139
134
  declare fails the build instead of poisoning the cache.
140
135
  - **Clean exits.** Ctrl-C reaps every child. No orphaned dev servers.
@@ -145,27 +140,23 @@ Turbo and Nx feature is in vx, pinned by a test:
145
140
 
146
141
  ## Tried on real repos
147
142
 
148
- Fourteen public monorepos, each run with its own `turbo.json`, Nx graph, moon, wireit or lage config
149
- and nothing rewritten, against the tool it ships with. Both tools on the same machine and
150
- tasks per row, medians; vx first, theirs second. Where the other tool
151
- wins, the row says so.
152
-
153
- | Repo | Against | Cold build | Restore outputs | Nothing to do |
154
- | ----------------------------------------------------------------------- | ------- | --------------------- | --------------- | ------------------- |
155
- | [solidjs/solid](https://github.com/solidjs/solid) | Turbo | 40.6 s / 45.5 s | 66 ms / 127 ms | 51 ms / 95 ms |
156
- | [withastro/astro](https://github.com/withastro/astro) | Turbo | 48.0 s / 57.9 s | 478 ms / 1.43 s | 249 ms / 1.38 s |
157
- | [payloadcms/payload](https://github.com/payloadcms/payload) | Turbo | 126.9 s / 127.5 s | 3.44 s / 3.46 s | 256 ms / **237 ms** |
158
- | [medusajs/medusa](https://github.com/medusajs/medusa) | Turbo | 308 s / 315 s | 3.86 s / 7.16 s | 947 ms / 3.29 s |
159
- | [n8n-io/n8n](https://github.com/n8n-io/n8n) | Turbo | 137.2 s / **133.7 s** | 8.27 s / 12.5 s | 842 ms / 1.36 s |
160
- | [calcom/cal.com](https://github.com/calcom/cal.com) | Turbo | 245.7 s / 250.7 s | 17.4 s / 19.9 s | 14.7 s / 18.5 s |
161
- | [TanStack/query](https://github.com/TanStack/query) | Nx | 47.4 s / 55.1 s | 656 ms / 1.98 s | 174 ms / 1.88 s |
162
- | [strapi/strapi](https://github.com/strapi/strapi) | Nx | 238.2 s / 245.7 s | 1.61 s / 3.94 s | 341 ms / 4.02 s |
163
- | [novuhq/novu](https://github.com/novuhq/novu) | Nx | 291.2 s / 299.4 s | 3.10 s / 9.05 s | 655 ms / 8.55 s |
164
- | [TanStack/router](https://github.com/TanStack/router) | Nx | 199.9 s / 226.7 s | 1.00 s / 3.33 s | 505 ms / 3.21 s |
165
- | [refinedev/refine](https://github.com/refinedev/refine) | Nx | 96.2 s / 105.9 s | 1.06 s / 1.19 s | 303 ms / 1.16 s |
166
- | [kindspells/astro-shield](https://github.com/kindspells/astro-shield) | moon | 2.64 s / 3.78 s | 125 ms / 2.46 s | 111 ms / 2.37 s |
167
- | [FormidableLabs/spectacle](https://github.com/FormidableLabs/spectacle) | wireit | 21.5 s / 22.2 s | 394 ms / 574 ms | 387 ms / 593 ms |
168
- | [microsoft/lage](https://github.com/microsoft/lage) | lage | 32.4 s / **29.4 s** | 730 ms / 1.70 s | 284 ms / 1.56 s |
143
+ Eleven public monorepos, each run with its own `turbo.json` or Nx graph
144
+ and nothing rewritten, against the tool it ships with. Same machine and
145
+ tasks per row, medians; vx first, theirs second.
146
+
147
+ | Repo | Against | Cold build | Restore outputs | Nothing to do |
148
+ | ----------------------------------------------------------- | ------- | --------------------- | --------------- | ------------------- |
149
+ | [solidjs/solid](https://github.com/solidjs/solid) | Turbo | 40.6 s / 45.5 s | 66 ms / 127 ms | 51 ms / 95 ms |
150
+ | [withastro/astro](https://github.com/withastro/astro) | Turbo | 48.0 s / 57.9 s | 478 ms / 1.43 s | 249 ms / 1.38 s |
151
+ | [payloadcms/payload](https://github.com/payloadcms/payload) | Turbo | 126.9 s / 127.5 s | 3.44 s / 3.46 s | 256 ms / **237 ms** |
152
+ | [medusajs/medusa](https://github.com/medusajs/medusa) | Turbo | 308 s / 315 s | 3.86 s / 7.16 s | 947 ms / 3.29 s |
153
+ | [n8n-io/n8n](https://github.com/n8n-io/n8n) | Turbo | 137.2 s / **133.7 s** | 8.27 s / 12.5 s | 842 ms / 1.36 s |
154
+ | [calcom/cal.com](https://github.com/calcom/cal.com) | Turbo | 245.7 s / 250.7 s | 17.4 s / 19.9 s | 14.7 s / 18.5 s |
155
+ | [TanStack/query](https://github.com/TanStack/query) | Nx | 47.4 s / 55.1 s | 656 ms / 1.98 s | 174 ms / 1.88 s |
156
+ | [strapi/strapi](https://github.com/strapi/strapi) | Nx | 238.2 s / 245.7 s | 1.61 s / 3.94 s | 341 ms / 4.02 s |
157
+ | [novuhq/novu](https://github.com/novuhq/novu) | Nx | 291.2 s / 299.4 s | 3.10 s / 9.05 s | 655 ms / 8.55 s |
158
+ | [TanStack/router](https://github.com/TanStack/router) | Nx | 199.9 s / 226.7 s | 1.00 s / 3.33 s | 505 ms / 3.21 s |
159
+ | [refinedev/refine](https://github.com/refinedev/refine) | Nx | 96.2 s / 105.9 s | 1.06 s / 1.19 s | 303 ms / 1.16 s |
169
160
 
170
161
  **Bold** marks the other tool winning. Versions, revisions, scopes and
171
162
  harness per repo: [benchmarks](packages/vx/docs/benchmarks.md) (astro
@@ -173,46 +164,35 @@ and refine as re-measured on 2026-09-27, the rest 2026-09-10 and -11).
173
164
 
174
165
  ## How it compares
175
166
 
176
- | | vx | Turborepo | Nx | moon² |
177
- | ------------------------- | ----------------------------------------- | ------------------------------ | ---------------- | ------------------------------ |
178
- | Fully cached, 1,090 pkgs¹ | **510 ms** | 760 ms | 3.59 s | not measured |
179
- | Config | TypeScript, evaluated into the cache key | JSON (static) | JSON (static) | YAML, Pkl and others (static) |
180
- | Output ownership | **Strict**: wiped before exec and restore | Additive (stale files survive) | Additive | Glob outputs pruned on restore |
181
- | Clean-tree hashing | **Zero reads** (git index OIDs) | git OIDs | re-hash / daemon | `git hash-object` |
182
- | Daemon required for speed | **No** | Optional | Yes | Optional (experimental) |
183
- | Per-task sandbox | **Yes**: kernel-level, opt-in | No | No | No |
184
- | Plugin API | **Yes**: every pipeline stage | No | Yes (TS-tied) | WASM toolchains, extensions |
185
- | OTel CI/CD spans | **Yes**: `otel()` plugin, no OTel SDK | No | Paid | No |
186
- | Install | **Single binary**; no Bun, Node optional | npm + Node | npm + Node | Single binary |
187
-
188
- ¹ Warm, nothing to rebuild, the graph charted above. Every gap, and
189
- when another tool is the better pick:
167
+ | | vx | Turborepo | Nx |
168
+ | ------------------------- | ----------------------------------------- | ------------------------------ | ---------------- |
169
+ | Config | TypeScript, evaluated into the cache key | JSON (static) | JSON (static) |
170
+ | Output ownership | **Strict**: wiped before exec and restore | Additive (stale files survive) | Additive |
171
+ | Clean-tree hashing | **Zero reads** (git index OIDs) | git OIDs | re-hash / daemon |
172
+ | Daemon required for speed | **No** | Optional | Yes |
173
+ | Per-task sandbox | **Yes**: kernel-level, opt-in | No | No |
174
+ | Plugin API | **Yes**: every pipeline stage | No | Yes (TS-tied) |
175
+ | OTel CI/CD spans | **Yes**: `otel()` plugin, no OTel SDK | No | Paid |
176
+ | Install | **Single binary**; no Bun, Node optional | npm + Node | npm + Node |
177
+
178
+ Every gap, and when another tool is the better pick:
190
179
  [comparison](packages/vx/docs/comparison.md) ·
191
180
  [vx, Turborepo, Nx, Bazel](https://vznjs.github.io/vx/compare/).
192
181
 
193
- ² From moon's own docs (2026-09-28): [config](https://github.com/moonrepo/moon/blob/master/website/docs/config/overview.mdx),
194
- [outputs](https://github.com/moonrepo/moon/blob/master/website/docs/config/project.mdx),
195
- [hashing](https://github.com/moonrepo/moon/blob/master/website/docs/guides/debug-task.mdx),
196
- [daemon](https://github.com/moonrepo/moon/blob/master/website/docs/guides/daemon.mdx),
197
- [plugins](https://github.com/moonrepo/moon/blob/master/website/docs/guides/wasm-plugins.mdx),
198
- [install](https://github.com/moonrepo/moon/blob/master/website/docs/install.mdx);
199
- they describe no per-task sandbox and no OpenTelemetry export. Already on
200
- moon: `moon()` runs it unchanged.
201
-
202
182
  ## Plugins
203
183
 
204
184
  Declared in `vx.workspace.ts`. Without one, vx runs and caches on this
205
185
  machine.
206
186
 
207
- | Package | What it adds |
208
- | ---------------------------------------------------------- | ------------------------------------------------------------------------------- |
209
- | [`@vzn/vx-migrate`](packages/vx-migrate) | Run a Turbo, Nx or moon repo unchanged; write configs; keep a Turbo or Nx cache |
210
- | [`@vzn/vx-reapi`](packages/vx-reapi) | Remote cache and remote execution over Bazel's REAPI (NativeLink, BuildBuddy) |
211
- | [`@vzn/vx-lockfile`](packages/vx-lockfile) | Key each project on its own lockfile slice: pnpm, bun, npm, yarn |
212
- | [`@vzn/vx-otel`](packages/vx-otel) | OpenTelemetry traces, metrics and logs, no SDK |
213
- | [`@vzn/vx-github`](packages/vx-github) | GitHub Actions job summary and Checks API |
214
- | [`@vzn/vx-mcp`](packages/vx-mcp) | `vx mcp`: a read-only MCP server for AI coding agents |
215
- | [`@vzn/vx-schedule-history`](packages/vx-schedule-history) | Schedule by the critical path learned from past runs |
187
+ | Package | What it adds |
188
+ | ---------------------------------------------------------- | ----------------------------------------------------------------------------- |
189
+ | [`@vzn/vx-migrate`](packages/vx-migrate) | Run a Turbo or Nx repo unchanged; write configs; keep its remote cache |
190
+ | [`@vzn/vx-reapi`](packages/vx-reapi) | Remote cache and remote execution over Bazel's REAPI (NativeLink, BuildBuddy) |
191
+ | [`@vzn/vx-lockfile`](packages/vx-lockfile) | Key each project on its own lockfile slice: pnpm, bun, npm, yarn |
192
+ | [`@vzn/vx-otel`](packages/vx-otel) | OpenTelemetry traces, metrics and logs, no SDK |
193
+ | [`@vzn/vx-github`](packages/vx-github) | GitHub Actions job summary and Checks API |
194
+ | [`@vzn/vx-mcp`](packages/vx-mcp) | `vx mcp`: a read-only MCP server for AI coding agents |
195
+ | [`@vzn/vx-schedule-history`](packages/vx-schedule-history) | Schedule by the critical path learned from past runs |
216
196
 
217
197
  Write your own: [plugin guide](https://vznjs.github.io/vx/guides/plugins/) ·
218
198
  [architecture](packages/vx/docs/architecture.md).
package/launcher.mjs CHANGED
@@ -105,7 +105,7 @@ const source = join(here, sourceEntry)
105
105
  if (bin !== undefined) {
106
106
  run(bin, args)
107
107
  } else if (existsSync(source) && hasBun()) {
108
- run('bun', [source, ...args])
108
+ run('bun', ['--no-env-file', '--no-install', source, ...args])
109
109
  } else {
110
110
  const supported = SUPPORTED.join(', ')
111
111
  process.stderr.write(
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@vzn/vx",
3
- "version": "0.0.135",
3
+ "version": "0.0.137",
4
4
  "description": "A fast, correct task runner and build cache for JavaScript monorepos.",
5
5
  "type": "module",
6
6
  "exports": {
@@ -17,10 +17,10 @@
17
17
  "node": ">=18"
18
18
  },
19
19
  "optionalDependencies": {
20
- "@vzn/vx-linux-x64": "0.0.135",
21
- "@vzn/vx-linux-arm64": "0.0.135",
22
- "@vzn/vx-darwin-x64": "0.0.135",
23
- "@vzn/vx-darwin-arm64": "0.0.135"
20
+ "@vzn/vx-linux-x64": "0.0.137",
21
+ "@vzn/vx-linux-arm64": "0.0.137",
22
+ "@vzn/vx-darwin-x64": "0.0.137",
23
+ "@vzn/vx-darwin-arm64": "0.0.137"
24
24
  },
25
25
  "dependencies": {
26
26
  "@anthropic-ai/sandbox-runtime": "^0.0.76"
package/src/bin.ts CHANGED
@@ -1,4 +1,4 @@
1
- #!/usr/bin/env -S bun --no-env-file
1
+ #!/usr/bin/env -S bun --no-env-file --no-install
2
2
  import { registerCoreAlias, run } from './cli/index.js'
3
3
  import {
4
4
  fsRefusalHint,
@@ -20,7 +20,14 @@ import {
20
20
  WORKSPACE_OUTPUT_PREFIX,
21
21
  } from '../cache/index.js'
22
22
  import type { TaskOutcome } from '../graph/index.js'
23
- import { asTrees, secretMask, span, taskGlob, wholeSubtreePrefixes } from '../util/index.js'
23
+ import {
24
+ asTrees,
25
+ secretMask,
26
+ span,
27
+ splitNegations,
28
+ taskGlob,
29
+ wholeSubtreePrefixes,
30
+ } from '../util/index.js'
24
31
  import type { ExecuteArgs } from './execute-task.js'
25
32
  import { entryHolds } from './miss-save.js'
26
33
 
@@ -51,7 +58,10 @@ export async function restoreHit(restore: RestoreHitArgs): Promise<TaskOutcome>
51
58
  const outputs = cacheCfg?.outputs.files ?? []
52
59
  const wsOutputs = cacheCfg?.outputs.workspaceFiles ?? []
53
60
  const anyOutputs = outputs.length > 0 || wsOutputs.length > 0
54
- const dirPrefixes = wholeSubtreePrefixes(outputs)
61
+ // The directories the positive globs cover whole. A `!` entry inside one
62
+ // leaves the snapshot sound: a file added or removed there still bumps a
63
+ // recorded directory, and the walk that follows applies the `!` (A-46).
64
+ const dirPrefixes = wholeSubtreePrefixes(splitNegations(outputs).positive)
55
65
  const cleanArgs = {
56
66
  projectDir: node.projectDir,
57
67
  outputs,
@@ -15,7 +15,7 @@
15
15
 
16
16
  import path from 'node:path'
17
17
  import { isGroupTask, type TaskOutcome } from '../graph/index.js'
18
- import { tallyOutcomes } from './tally.js'
18
+ import { tallyOutcomes, type Tally } from './tally.js'
19
19
  import type { FlakyFinding } from './failure-mode.js'
20
20
 
21
21
  export interface SummarizeArgs {
@@ -36,8 +36,45 @@ export interface SummarizeArgs {
36
36
  flaky?: readonly FlakyFinding[]
37
37
  }
38
38
 
39
+ /** One task of `--summarize`: the wire, stated by `schemas/summary.json`. */
40
+ export interface SummaryTaskJson {
41
+ id: string
42
+ project: string
43
+ task: string
44
+ status: TaskOutcome['status']
45
+ exitCode: number
46
+ durationMs: number
47
+ hash: string | null
48
+ noCache?: true
49
+ flaky?: { passes: number; failures: number; attempts: number }
50
+ cpuMs?: number
51
+ peakRssBytes?: number
52
+ storedCpuMs?: number
53
+ storedPeakRssBytes?: number
54
+ admissionHeldMs?: number
55
+ blockedBy?: string
56
+ timedOut?: true
57
+ sandboxViolations?: number
58
+ notReady?: 'timeout' | 'exited' | 'spawn'
59
+ wallclockStartNs?: string
60
+ wallclockEndNs?: string
61
+ }
62
+
63
+ /** The `--summarize` document. */
64
+ export interface RunSummaryJson {
65
+ runId: string
66
+ ok: boolean
67
+ exitCode: number
68
+ startedAt: string
69
+ endedAt: string
70
+ totalMs: number
71
+ tasks: SummaryTaskJson[]
72
+ aborted: SummaryTaskJson[]
73
+ summary: Tally
74
+ }
75
+
39
76
  /** One task's entry. Shared by `tasks` and `aborted` so they read alike. */
40
- function taskEntry(o: TaskOutcome, flaky?: FlakyFinding): Record<string, unknown> {
77
+ function taskEntry(o: TaskOutcome, flaky?: FlakyFinding): SummaryTaskJson {
41
78
  return {
42
79
  id: o.node.id,
43
80
  project: o.node.projectName,
@@ -99,7 +136,7 @@ export async function writeRunSummary(args: SummarizeArgs): Promise<string> {
99
136
  const aborted = args.outcomes.filter((o) => !isGroupTask(o.node) && o.status === 'aborted')
100
137
  const flakyById = new Map((args.flaky ?? []).map((f) => [f.taskId, f]))
101
138
  const exitCode = args.exitCode ?? (args.ok ? 0 : 1)
102
- const payload = {
139
+ const payload: RunSummaryJson = {
103
140
  runId: args.runId,
104
141
  // The run-level verdict, first: a consumer gating on this artifact must
105
142
  // not have to re-derive it by re-implementing the bucket rules.
@@ -113,9 +113,16 @@ function fmtDuration(ms: number): string {
113
113
  * unvalidated names and shipped WITHOUT this escape, so a `|` in a task name
114
114
  * or an output path shifted its columns. One definition, so the two
115
115
  * cannot disagree about what a cell may contain.
116
+ *
117
+ * A pipe splits the row unless an ODD run of backslashes precedes it (GFM
118
+ * reads `\\` as one escaped backslash), so a pipe after an odd run is already
119
+ * escaped and one more backslash would free it: `a\|b` became `a\\|b`, two
120
+ * cells. A lone `\r` ends a line too.
116
121
  */
117
122
  export function escapeMarkdownCell(value: string): string {
118
- return value.replace(/\|/g, '\\|').replace(/\r?\n/g, ' ')
123
+ return value
124
+ .replace(/(\\*)\|/g, (pipe, run: string) => (run.length % 2 === 0 ? `${run}\\|` : pipe))
125
+ .replace(/\r\n|\r|\n/g, ' ')
119
126
  }
120
127
 
121
128
  const cell = escapeMarkdownCell
@@ -5,6 +5,7 @@
5
5
  // tool wrote it. Core knows no source format here: a mapper returns a
6
6
  // `MigrationPlan` and this file does the rest.
7
7
 
8
+ import { existsSync } from 'node:fs'
8
9
  import { unlink } from 'node:fs/promises'
9
10
  import path from 'node:path'
10
11
  import { relPosix, UserError } from '../util/index.js'
@@ -304,11 +305,40 @@ export async function applyMigration(args: ApplyMigrationArgs): Promise<number>
304
305
  if (!cached && todos.has(CACHE_TODO)) {
305
306
  report.push('', 'no task caches yet: add the cache block a TODO shows, and a second run hits')
306
307
  }
307
- report.push('', `next: vx run ${firstTask} --all`)
308
+ const installed = existsSync(path.join(root, 'node_modules', '@vzn', 'vx', 'package.json'))
309
+ report.push(
310
+ '',
311
+ `next: ${vxInvocation(process.env['npm_config_user_agent'], installed)} run ${firstTask} --all`,
312
+ )
308
313
  process.stdout.write(`${report.join('\n')}\n`)
309
314
  return 0
310
315
  }
311
316
 
317
+ /**
318
+ * How the user runs vx, for the report's `next:` line. After `npx vx init`
319
+ * or `bunx @vzn/vx init` a bare `vx` is on no PATH (the first-five-minutes
320
+ * walk, 2026-09-28), so the line names the runner that started this
321
+ * process — npx, pnpm, yarn and Bun each set `npm_config_user_agent` — and
322
+ * the spec it resolves: the installed bin, else the package, since a bare
323
+ * `bunx vx` or `npx vx` would fetch an unrelated package named `vx`. No
324
+ * runner (a global install, the compiled binary): `vx`.
325
+ */
326
+ export function vxInvocation(userAgent: string | undefined, installed: boolean): string {
327
+ const runner = /^(npm|pnpm|yarn|bun)\//.exec(userAgent ?? '')?.[1]
328
+ switch (runner) {
329
+ case 'npm':
330
+ return installed ? 'npx vx' : 'npx @vzn/vx'
331
+ case 'bun':
332
+ return installed ? 'bunx vx' : 'bunx @vzn/vx'
333
+ case 'pnpm':
334
+ return installed ? 'pnpm vx' : 'pnpm dlx @vzn/vx'
335
+ case 'yarn':
336
+ return installed ? 'yarn vx' : 'yarn dlx @vzn/vx'
337
+ default:
338
+ return 'vx'
339
+ }
340
+ }
341
+
312
342
  /** Ids named per shared TODO; the files carry each one (sveltejs/kit: 92 persistent tasks). */
313
343
  const TODO_IDS_SHOWN = 5
314
344