@vzn/vx 0.0.21 → 0.0.23

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.
Files changed (123) hide show
  1. package/README.md +21 -18
  2. package/package.json +6 -6
  3. package/src/bin.ts +52 -3
  4. package/src/cache/archive.ts +170 -21
  5. package/src/cache/cache.ts +667 -288
  6. package/src/cache/chained-cache.ts +14 -2
  7. package/src/cache/config-evals.ts +29 -6
  8. package/src/cache/file-hashes.ts +63 -35
  9. package/src/cache/git-inputs.ts +398 -69
  10. package/src/cache/index.ts +8 -1
  11. package/src/cache/inputs.ts +403 -102
  12. package/src/cache/key-fold.ts +232 -0
  13. package/src/cache/layer.ts +81 -11
  14. package/src/cache/layered-cache.ts +199 -32
  15. package/src/cache/output-index.ts +109 -10
  16. package/src/cache/run-history.ts +8 -3
  17. package/src/cache/zstd.ts +53 -30
  18. package/src/cli/cache.ts +51 -21
  19. package/src/cli/format.ts +4 -15
  20. package/src/cli/help.ts +13 -3
  21. package/src/cli/index.ts +35 -9
  22. package/src/cli/info.ts +49 -7
  23. package/src/cli/init.ts +17 -5
  24. package/src/cli/last.ts +96 -21
  25. package/src/cli/lock.ts +34 -17
  26. package/src/cli/plan-format.ts +4 -4
  27. package/src/cli/plugin-commands.ts +20 -5
  28. package/src/cli/run.ts +55 -12
  29. package/src/cli/select.ts +23 -13
  30. package/src/cli/show.ts +9 -4
  31. package/src/cli/upgrade.ts +134 -14
  32. package/src/cli/watch.ts +622 -102
  33. package/src/cli/why.ts +8 -16
  34. package/src/cli/workspace-config.ts +21 -9
  35. package/src/config.ts +46 -9
  36. package/src/exec/executor.ts +51 -5
  37. package/src/exec/index.ts +10 -0
  38. package/src/exec/kill-tree.ts +281 -0
  39. package/src/exec/local-executor.ts +1 -1
  40. package/src/exec/runner.ts +327 -82
  41. package/src/exec/sandbox-binds.ts +34 -5
  42. package/src/exec/sandbox-paths.ts +22 -2
  43. package/src/exec/sandbox-runtime.ts +627 -102
  44. package/src/exec/sandbox-violations.ts +36 -23
  45. package/src/graph/dependency-spec.ts +14 -14
  46. package/src/graph/index.ts +12 -2
  47. package/src/graph/priorities.ts +130 -0
  48. package/src/graph/scheduler.ts +182 -134
  49. package/src/graph/task-graph.ts +552 -188
  50. package/src/index.ts +16 -2
  51. package/src/orchestrator/admission.ts +41 -17
  52. package/src/orchestrator/colors.ts +8 -3
  53. package/src/orchestrator/deferred-outputs.ts +22 -7
  54. package/src/orchestrator/doctor.ts +122 -14
  55. package/src/orchestrator/events.ts +71 -2
  56. package/src/orchestrator/excluded-keys.ts +155 -0
  57. package/src/orchestrator/execute-task.ts +331 -37
  58. package/src/orchestrator/failure-recap.ts +121 -0
  59. package/src/orchestrator/fingerprint-watch.ts +89 -0
  60. package/src/orchestrator/framed-output.ts +81 -14
  61. package/src/orchestrator/history.ts +6 -7
  62. package/src/orchestrator/hit-restore.ts +82 -18
  63. package/src/orchestrator/index.ts +7 -1
  64. package/src/orchestrator/keyed-projects.ts +89 -0
  65. package/src/orchestrator/local-shortcircuit.ts +83 -23
  66. package/src/orchestrator/lockfile-claim.ts +152 -32
  67. package/src/orchestrator/logger.ts +116 -20
  68. package/src/orchestrator/metrics.ts +156 -60
  69. package/src/orchestrator/miss-save.ts +106 -43
  70. package/src/orchestrator/options.ts +30 -4
  71. package/src/orchestrator/persistent.ts +41 -21
  72. package/src/orchestrator/placement.ts +55 -25
  73. package/src/orchestrator/plan.ts +24 -11
  74. package/src/orchestrator/plugin-host.ts +17 -23
  75. package/src/orchestrator/plugin.ts +31 -8
  76. package/src/orchestrator/prepare.ts +99 -13
  77. package/src/orchestrator/projects.ts +19 -8
  78. package/src/orchestrator/remote-prefetch.ts +21 -1
  79. package/src/orchestrator/run-artifacts.ts +9 -0
  80. package/src/orchestrator/run-context.ts +19 -4
  81. package/src/orchestrator/run-lock.ts +349 -0
  82. package/src/orchestrator/run-records.ts +6 -23
  83. package/src/orchestrator/run-report.ts +4 -2
  84. package/src/orchestrator/run.ts +264 -57
  85. package/src/orchestrator/sandbox-request.ts +388 -62
  86. package/src/orchestrator/save-lane.ts +4 -7
  87. package/src/orchestrator/shell-verdict.ts +117 -0
  88. package/src/orchestrator/signals.ts +86 -30
  89. package/src/orchestrator/stable-keys.ts +202 -10
  90. package/src/orchestrator/status-line.ts +33 -1
  91. package/src/orchestrator/summary.ts +49 -2
  92. package/src/orchestrator/task-hash.ts +87 -7
  93. package/src/orchestrator/task-log-buffer.ts +7 -7
  94. package/src/orchestrator/telemetry-host.ts +16 -7
  95. package/src/orchestrator/telemetry.ts +69 -28
  96. package/src/orchestrator/upstream.ts +91 -45
  97. package/src/util/bun-version.ts +43 -0
  98. package/src/util/errors.ts +86 -0
  99. package/src/util/hash.ts +10 -1
  100. package/src/util/index.ts +32 -3
  101. package/src/util/paths.ts +147 -5
  102. package/src/util/procfs.ts +20 -0
  103. package/src/util/settle.ts +4 -2
  104. package/src/util/size.ts +33 -0
  105. package/src/util/task-id.ts +20 -0
  106. package/src/util/timing.ts +11 -1
  107. package/src/util/which.ts +48 -0
  108. package/src/workspace/affected.ts +272 -64
  109. package/src/workspace/config-cache.ts +262 -32
  110. package/src/workspace/config-eval.ts +49 -12
  111. package/src/workspace/config-imports.ts +126 -5
  112. package/src/workspace/config-schema.ts +237 -41
  113. package/src/workspace/filter.ts +33 -18
  114. package/src/workspace/fingerprint.ts +29 -11
  115. package/src/workspace/index.ts +7 -0
  116. package/src/workspace/json-data.ts +90 -0
  117. package/src/workspace/load-reads.ts +32 -0
  118. package/src/workspace/lockfile.ts +33 -11
  119. package/src/workspace/migrate-scripts.ts +60 -7
  120. package/src/workspace/migration.ts +40 -1
  121. package/src/workspace/package-graph.ts +154 -16
  122. package/src/workspace/project-loader.ts +227 -77
  123. package/src/workspace/workspace.ts +173 -23
package/src/cache/zstd.ts CHANGED
@@ -8,9 +8,10 @@ import { CorruptArtifactError } from './layer.js'
8
8
  * A decompressed artifact above this is refused as a zstd bomb rather than
9
9
  * expanded into memory. 2 GiB comfortably exceeds any real build output while
10
10
  * bounding a malicious/compromised remote's ability to OOM a victim who takes
11
- * a cache hit.
11
+ * a cache hit. The default of `Cache`'s `artifactCeiling`, the one seam that
12
+ * lowers it (for a test: no test can produce 2 GiB of output).
12
13
  */
13
- const MAX_DECOMPRESSED_ARTIFACT_BYTES = 2 * 1024 * 1024 * 1024
14
+ export const MAX_DECOMPRESSED_ARTIFACT_BYTES = 2 * 1024 * 1024 * 1024
14
15
 
15
16
  /**
16
17
  * Read a zstd frame's declared Frame_Content_Size (RFC 8878 §3.1.1) WITHOUT
@@ -49,31 +50,43 @@ export function zstdContentSize(b: Uint8Array): bigint | null {
49
50
  /**
50
51
  * Decompress a zstd artifact that DECLARES its content size, with a hard
51
52
  * output ceiling: refused before a byte is allocated when the declaration
52
- * is over the cap, and again on the actual length. A frame with no
53
+ * is over the cap. The re-check on the actual length below is a backstop
54
+ * against a decoder that stops validating the declaration, NOT a second
55
+ * live layer — see its comment. A frame with no
53
56
  * declaration (a streamed producer's — vx's own, above 4 MiB) never comes
54
57
  * here: `decodedTar` decodes it as a stream under the running count, so
55
58
  * a sizeless bomb has nowhere to expand.
56
59
  */
57
- async function zstdDecompressBounded(compressed: Uint8Array, hash: string): Promise<Uint8Array> {
58
- assertDeclaredSize(compressed, hash)
60
+ async function zstdDecompressBounded(
61
+ compressed: Uint8Array,
62
+ hash: string,
63
+ cap: number = MAX_DECOMPRESSED_ARTIFACT_BYTES,
64
+ ): Promise<Uint8Array> {
65
+ assertDeclaredSize(compressed, hash, cap)
59
66
  const out = await Bun.zstdDecompress(compressed)
60
- if (out.length > MAX_DECOMPRESSED_ARTIFACT_BYTES) {
61
- throw new CorruptArtifactError(
62
- hash,
63
- `decompressed to ${out.length} bytes (> ${MAX_DECOMPRESSED_ARTIFACT_BYTES} cap)`,
64
- )
67
+ // UNREACHABLE as written, and kept deliberately. `assertDeclaredSize`
68
+ // above refuses any frame DECLARING more than the cap, and Bun refuses a
69
+ // frame whose declaration disagrees with its body ("Decompression
70
+ // failed" on a forged Frame_Content_Size, measured item 487) — so a frame
71
+ // that gets here declared <= cap and produced exactly that. The only way
72
+ // this fires again is a decoder that stops validating the declaration.
73
+ // That is what it is for; it is not a second live layer, and no test can
74
+ // reach it.
75
+ if (out.length > cap) {
76
+ throw new CorruptArtifactError(hash, `decompressed to ${out.length} bytes (> ${cap} cap)`)
65
77
  }
66
78
  return out
67
79
  }
68
80
 
69
81
  /** The pre-decompress half of the ceiling: the frame header's own claim, when it makes one. */
70
- function assertDeclaredSize(compressed: Uint8Array, hash: string): bigint | null {
82
+ function assertDeclaredSize(
83
+ compressed: Uint8Array,
84
+ hash: string,
85
+ cap: number = MAX_DECOMPRESSED_ARTIFACT_BYTES,
86
+ ): bigint | null {
71
87
  const declared = zstdContentSize(compressed)
72
- if (declared !== null && declared > BigInt(MAX_DECOMPRESSED_ARTIFACT_BYTES)) {
73
- throw new CorruptArtifactError(
74
- hash,
75
- `declares ${declared} decompressed bytes (> ${MAX_DECOMPRESSED_ARTIFACT_BYTES} cap)`,
76
- )
88
+ if (declared !== null && declared > BigInt(cap)) {
89
+ throw new CorruptArtifactError(hash, `declares ${declared} decompressed bytes (> ${cap} cap)`)
77
90
  }
78
91
  return declared
79
92
  }
@@ -115,18 +128,29 @@ const oneChunk = (bytes: Uint8Array): ReadableStream<Uint8Array> =>
115
128
  export async function decodedTar(
116
129
  source: Uint8Array | Bun.BunFile,
117
130
  hash: string,
131
+ /**
132
+ * The decompression ceiling: `Cache` passes its `artifactCeiling`, whose
133
+ * default IS this module's. Reaching the real one needs an artifact that
134
+ * expands past 2 GiB, which no test can produce — so before item 487 the
135
+ * sizeless-bomb defense this module's comments promise had nothing
136
+ * asserting it, while the declared-size half was pinned by a forged
137
+ * header costing 20 bytes.
138
+ */
139
+ cap: number = MAX_DECOMPRESSED_ARTIFACT_BYTES,
118
140
  ): Promise<ReadableStream<Uint8Array>> {
119
141
  if (source instanceof Uint8Array) {
120
- if (assertDeclaredSize(source, hash) === null) return zstdDecodeStream(new Blob([source]), hash)
121
- return oneChunk(await zstdDecompressBounded(source, hash))
142
+ if (assertDeclaredSize(source, hash, cap) === null) {
143
+ return zstdDecodeStream(new Blob([source]), hash, cap)
144
+ }
145
+ return oneChunk(await zstdDecompressBounded(source, hash, cap))
122
146
  }
123
147
  if (source.size <= STREAM_DECODE_FROM) {
124
148
  const bytes = await source.bytes()
125
- if (assertDeclaredSize(bytes, hash) === null) return zstdDecodeStream(source, hash)
126
- return oneChunk(await zstdDecompressBounded(bytes, hash))
149
+ if (assertDeclaredSize(bytes, hash, cap) === null) return zstdDecodeStream(source, hash, cap)
150
+ return oneChunk(await zstdDecompressBounded(bytes, hash, cap))
127
151
  }
128
- assertDeclaredSize(await source.slice(0, 32).bytes(), hash)
129
- return zstdDecodeStream(source, hash)
152
+ assertDeclaredSize(await source.slice(0, 32).bytes(), hash, cap)
153
+ return zstdDecodeStream(source, hash, cap)
130
154
  }
131
155
 
132
156
  /**
@@ -135,7 +159,11 @@ export async function decodedTar(
135
159
  * past the cap here either — the count runs as bytes are produced, before
136
160
  * any of them reach the reader's next entry.
137
161
  */
138
- function zstdDecodeStream(source: Blob, hash: string): ReadableStream<Uint8Array> {
162
+ function zstdDecodeStream(
163
+ source: Blob,
164
+ hash: string,
165
+ cap: number = MAX_DECOMPRESSED_ARTIFACT_BYTES,
166
+ ): ReadableStream<Uint8Array> {
139
167
  let total = 0
140
168
  return source
141
169
  .stream()
@@ -144,13 +172,8 @@ function zstdDecodeStream(source: Blob, hash: string): ReadableStream<Uint8Array
144
172
  new TransformStream<Uint8Array, Uint8Array>({
145
173
  transform(chunk, controller) {
146
174
  total += chunk.byteLength
147
- if (total > MAX_DECOMPRESSED_ARTIFACT_BYTES) {
148
- controller.error(
149
- new CorruptArtifactError(
150
- hash,
151
- `decompresses past ${MAX_DECOMPRESSED_ARTIFACT_BYTES} bytes (cap)`,
152
- ),
153
- )
175
+ if (total > cap) {
176
+ controller.error(new CorruptArtifactError(hash, `decompresses past ${cap} bytes (cap)`))
154
177
  return
155
178
  }
156
179
  controller.enqueue(chunk)
package/src/cli/cache.ts CHANGED
@@ -1,13 +1,42 @@
1
1
  import { Cache, noteSchemaReset } from '../cache/index.js'
2
2
  import { seeHelp } from './help.js'
3
- import { parseDecimalInt, parseSize } from '../util/index.js'
3
+ import { nearest, parseDuration, parseSize } from '../util/index.js'
4
+ import { acquireRunLock } from '../orchestrator/index.js'
4
5
  import { findWorkspaceRoot } from '../workspace/index.js'
5
6
  import { cliCacheDir, parseCacheDirFlag, warnToStderr } from './workspace-config.js'
6
7
  import { formatBytes } from './format.js'
7
8
 
8
9
  // parseSize moved to `util` (the orchestrator's resource resolver needs it
9
10
  // and can't import cli); re-exported here so existing callers are unchanged.
10
- export { parseSize } from '../util/index.js'
11
+ export { parseDuration, parseSize } from '../util/index.js'
12
+
13
+ /**
14
+ * `vx cache stats` is a habit from the other runners, and vx's answer is a
15
+ * different VERB rather than a missing one — saying so where the user typed
16
+ * it beats sending them to a help screen that lists only `prune` (walked the
17
+ * first-run path, 2026-09-20). A typo of `prune` itself gets the same
18
+ * nearest-neighbour hint a task, flag or project name gets.
19
+ */
20
+ const CACHE_ELSEWHERE: Record<string, string> = {
21
+ stats: '`vx info` reports the cache directory, its entry count and its size',
22
+ status: '`vx info` reports the cache directory, its entry count and its size',
23
+ size: '`vx info` reports the cache directory, its entry count and its size',
24
+ entries: '`vx info` reports the cache directory, its entry count and its size',
25
+ dir: '`vx info` prints the cache directory this workspace uses',
26
+ path: '`vx info` prints the cache directory this workspace uses',
27
+ clean: '`vx cache prune` is the eviction verb (`--older-than`, `--max-size`)',
28
+ clear: '`vx cache prune` is the eviction verb (`--older-than`, `--max-size`)',
29
+ rm: '`vx cache prune` is the eviction verb (`--older-than`, `--max-size`)',
30
+ delete: '`vx cache prune` is the eviction verb (`--older-than`, `--max-size`)',
31
+ evict: '`vx cache prune` is the eviction verb (`--older-than`, `--max-size`)',
32
+ }
33
+
34
+ function cacheSubHint(sub: string): string {
35
+ const elsewhere = CACHE_ELSEWHERE[sub]
36
+ if (elsewhere !== undefined) return ` — ${elsewhere}`
37
+ const best = nearest(sub, ['prune'])
38
+ return best === undefined ? '' : `. Did you mean ${best}?`
39
+ }
11
40
 
12
41
  export async function cacheCmd(args: readonly string[]): Promise<number> {
13
42
  const [sub, ...rest] = args
@@ -18,7 +47,9 @@ export async function cacheCmd(args: readonly string[]): Promise<number> {
18
47
  process.stderr.write('vx cache: missing subcommand. Try `vx cache prune`.\n')
19
48
  return 1
20
49
  default:
21
- process.stderr.write(`vx cache: unknown subcommand: ${sub}${seeHelp('cache')}\n`)
50
+ process.stderr.write(
51
+ `vx cache: unknown subcommand: ${sub}${cacheSubHint(sub)}${seeHelp('cache')}\n`,
52
+ )
22
53
  return 1
23
54
  }
24
55
  }
@@ -110,8 +141,23 @@ async function pruneCmd(args: readonly string[]): Promise<number> {
110
141
  // `config` plugin's edit of it — `vx run` and `vx cache prune` must
111
142
  // operate on the same directory or prune silently no-ops against the
112
143
  // wrong path.
113
- const cache = new Cache(await cliCacheDir(root, parsed.cacheDir))
144
+ // A dry run only reads: it never resets the index (item 896).
145
+ const dir = await cliCacheDir(root, parsed.cacheDir)
146
+ const cache = parsed.dryRun === true ? Cache.inspect(dir) : new Cache(dir)
147
+ // A prune deletes rows and artifacts; a cache this user cannot write is
148
+ // refused up front with the directory named, as a run refuses it, rather
149
+ // than dying in the first DELETE with SQLite's "readonly database" and a
150
+ // stack (an unprivileged user on a root-owned `.vx`, 2026-09-16). A dry
151
+ // run only reads, and reads a read-only cache fine.
152
+ if (!parsed.dryRun) cache.assertWritable()
114
153
  noteSchemaReset(cache, warnToStderr)
154
+ // A prune beside a run on this workspace evicts what the run has just
155
+ // probed as hits, whose `accessed_at` bumps it has not flushed yet; the
156
+ // run then re-runs those tasks (upstream survey, nx#36688). So a prune
157
+ // takes the run's lock and waits its turn. A dry run deletes nothing.
158
+ const releaseRunLock = parsed.dryRun
159
+ ? async (): Promise<void> => {}
160
+ : await acquireRunLock(root, { log: warnToStderr })
115
161
  try {
116
162
  const opts: { olderThanMs?: number; maxBytes?: number; dryRun?: boolean } = {}
117
163
  if (parsed.olderThanMs !== undefined) opts.olderThanMs = parsed.olderThanMs
@@ -128,23 +174,7 @@ async function pruneCmd(args: readonly string[]): Promise<number> {
128
174
  )
129
175
  } finally {
130
176
  cache.close()
177
+ await releaseRunLock()
131
178
  }
132
179
  return 0
133
180
  }
134
-
135
- /**
136
- * Parse `<n><unit>` where unit is s/m/h/d, case-insensitively (`parseSize`
137
- * has always been case-insensitive; this matched only lowercase, so `30D`
138
- * was rejected while `1GB` and `1gb` both worked).
139
- */
140
- export function parseDuration(input: string): number | null {
141
- const m = input.match(/^(\d+)([smhd])$/i)
142
- if (!m) return null
143
- const n = parseDecimalInt(m[1]!)
144
- if (n === null) return null
145
- // Lowercase before the switch: with the /i flag an uppercase `M` would
146
- // otherwise fall through to the days branch.
147
- const unit = m[2]!.toLowerCase()
148
- const mult = unit === 's' ? 1000 : unit === 'm' ? 60_000 : unit === 'h' ? 3_600_000 : 86_400_000
149
- return n * mult
150
- }
package/src/cli/format.ts CHANGED
@@ -1,15 +1,4 @@
1
- /**
2
- * Human-readable byte size with KB/MB/GB/TB/PB suffixes and one
3
- * decimal of precision below 10 of any unit. Used by `vx stats` and
4
- * `vx cache prune` output.
5
- */
6
- export function formatBytes(n: number): string {
7
- if (n < 1024) return `${n} B`
8
- const units = ['KB', 'MB', 'GB', 'TB']
9
- let v = n / 1024
10
- for (const u of units) {
11
- if (v < 1024) return `${v.toFixed(v < 10 ? 1 : 0)} ${u}`
12
- v /= 1024
13
- }
14
- return `${v.toFixed(0)} PB`
15
- }
1
+ // `formatBytes` moved to util/size.ts beside the parsers (item 658): a run
2
+ // names what the workspace's `cacheRetention` evicted, and the orchestrator
3
+ // may not import the CLI.
4
+ export { formatBytes } from '../util/index.js'
package/src/cli/help.ts CHANGED
@@ -11,8 +11,12 @@ export function printHelp(pluginCommands: readonly string[] = [], verb?: string)
11
11
  export function verbHelpText(verb: string): string {
12
12
  const blocks = helpText().split('\n\n')
13
13
  const title = blocks[0]!
14
+ // The verb goes into a RegExp, so it is escaped: `vx 'r.n' --help` is an
15
+ // unknown verb, not a pattern that matches `vx run` and answers with a
16
+ // help cut for a verb that does not exist.
17
+ const quoted = verb.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
14
18
  const names = (line: string): boolean =>
15
- new RegExp(`^\\s*vx ${verb}( |$)`).test(line) ||
19
+ new RegExp(`^\\s*vx ${quoted}( |$)`).test(line) ||
16
20
  line.includes(`(for ${verb})`) ||
17
21
  line.includes(`(for ${verb} `)
18
22
  const kept: string[] = []
@@ -54,7 +58,7 @@ export function helpText(pluginCommands: readonly string[] = []): string {
54
58
  ' --filter <pat> pnpm-style filter (repeatable). Examples:',
55
59
  ' foo, @scope/*, ./packages/foo, foo..., ...foo,',
56
60
  ' foo^..., !foo, [<since>]',
57
- ' --affected[=<base>] Sugar for --filter "[<base>]". Default base =',
61
+ ' --affected[=<base>] Sugar for --filter "...[<base>]". Default base =',
58
62
  ' origin/HEAD (falls back to HEAD~1).',
59
63
  " pkg#task Run a specific project's task directly.",
60
64
  '',
@@ -76,10 +80,12 @@ export function helpText(pluginCommands: readonly string[] = []): string {
76
80
  ' --timeout <ms> Default per-task timeout for tasks without their own exec.timeout',
77
81
  ' (above VX_TASK_TIMEOUT env + workspace timeout). Kills + fails',
78
82
  ' a runaway task. Per-task exec.timeout always wins.',
83
+ ' --continue[=<mode>] What a failed task takes down: never | deps-ok (default, skips',
84
+ ' its dependents) | always (bare form; dependents run, never save).',
79
85
  ' --frozen Load configs from vx-lock.json (CI; pair with vx lock --check).',
80
86
  ' --output-logs <mode> full | errors-only | hash-only | none — overrides the flow default',
81
- ' --download <mode> all (default) | toplevel | none — where remote outputs land',
82
87
  ' (focused without --all/--filter/--affected, broad with, full in CI).',
88
+ ' --download <mode> all (default) | toplevel | none — where remote outputs land.',
83
89
  ' --verbosity <n> 0 (default) or 1+: print a per-task summary table after the run.',
84
90
  '',
85
91
  'Planning (for run — skips execution):',
@@ -89,6 +95,9 @@ export function helpText(pluginCommands: readonly string[] = []): string {
89
95
  'Artifacts (for run):',
90
96
  ' --summarize[=<path>] Write per-run JSON to <cacheDir>/runs/<run_id>.json.',
91
97
  ' --profile[=<path>] Write Chrome-trace JSON (default profile.json).',
98
+ ' --report[=markdown] Print a markdown run report to stdout after the run.',
99
+ ' --report-file <path> Append that report to <path> (for $GITHUB_STEP_SUMMARY).',
100
+ ' --tag <k=v> Label this invocation (repeatable); recorded on its history row.',
92
101
  '',
93
102
  'Extensions (plugins):',
94
103
  ' vx core runs tasks in-process and nothing else. A dashboard, remote',
@@ -115,6 +124,7 @@ export function helpText(pluginCommands: readonly string[] = []): string {
115
124
  ' --cache-dir <path> The cache a run with the same flag uses (why, last, info too).',
116
125
  '',
117
126
  ' Duration units: s, m, h, d. Size units: K, M, G, T (powers of 1024).',
127
+ ' Cache statistics — directory, entry count, size — are part of `vx info`.',
118
128
  '',
119
129
  'Introspection:',
120
130
  ' vx show List every project: name, dir, declared task count.',
package/src/cli/index.ts CHANGED
@@ -67,7 +67,7 @@ export async function run(argv: readonly string[]): Promise<number> {
67
67
  // may own it (`VxPlugin.commands`). Core verbs were matched above, so
68
68
  // nothing here can shadow them.
69
69
  const resolved = await resolvePluginCommand(command)
70
- if (resolved !== null && !('loadError' in resolved)) {
70
+ if (resolved !== null && !('loadError' in resolved) && !('declaredVerbs' in resolved)) {
71
71
  const code = await resolved.command.run(rest, resolved.ctx)
72
72
  // A plugin is a boundary: a JS-authored verb that resolves nothing
73
73
  // would reach `process.exit(undefined)` and read as SUCCESS. A verb
@@ -110,8 +110,21 @@ export async function run(argv: readonly string[]): Promise<number> {
110
110
  )
111
111
  return 1
112
112
  }
113
- process.stderr.write(`vx: unknown command: ${command}${didYouMeanVerb(command)}${loadNote}\n`)
114
- printHelp()
113
+ // One line, as a verb's own unknown flag or subcommand gets; the full
114
+ // help after a typo was a hundred lines past the hint that mattered.
115
+ // The verbs this workspace's plugins declare are verbs HERE, so they
116
+ // join the "did you mean" set, and when nothing is close the second
117
+ // line says where a verb can come from: `vx mpc` in a workspace
118
+ // declaring `mcp` read as a plain unknown command, and `vx mcp`
119
+ // before the plugin was declared said nothing about the file that
120
+ // would declare it (2026-09-20).
121
+ const declaredVerbs =
122
+ resolved !== null && 'declaredVerbs' in resolved ? resolved.declaredVerbs : []
123
+ const guess = didYouMeanVerb(command, declaredVerbs)
124
+ process.stderr.write(
125
+ `vx: unknown command: ${command}${guess}${loadNote} (see \`vx help\`)\n` +
126
+ (guess === '' && loadNote === '' ? verbSourceNote(resolved, declaredVerbs) : ''),
127
+ )
115
128
  return 1
116
129
  }
117
130
  }
@@ -145,11 +158,24 @@ function wantsHelp(command: string, rest: readonly string[]): boolean {
145
158
  return own.includes('--help') || own.includes('-h')
146
159
  }
147
160
 
148
- /** ` Did you mean run?` for a verb within two edits of a core one — the same
149
- * hint a task or flag typo gets. Plugin verbs are not listed: resolving
150
- * them loads the workspace, and this path is reached only when that
151
- * lookup found nothing. */
152
- function didYouMeanVerb(verb: string): string {
153
- const best = nearest(verb, CORE_VERBS)
161
+ /** ` Did you mean run?` for a verb within two edits of a core one, or of a
162
+ * verb this workspace's plugins declare — the same hint a task or flag typo
163
+ * gets. The plugin verbs come from the lookup that just failed, so they
164
+ * cost no second load. */
165
+ function didYouMeanVerb(verb: string, pluginVerbsHere: readonly string[] = []): string {
166
+ const best = nearest(verb, [...CORE_VERBS, ...pluginVerbsHere])
154
167
  return best === undefined ? '' : `. Did you mean ${best}?`
155
168
  }
169
+
170
+ /**
171
+ * Where a verb that is neither core nor declared COULD come from. Only when
172
+ * nothing is close enough to guess: after a plain typo the guess is the
173
+ * answer, and a second line would bury it.
174
+ */
175
+ function verbSourceNote(resolved: unknown, declaredVerbs: readonly string[]): string {
176
+ if (declaredVerbs.length > 0) {
177
+ return ` This workspace's plugins declare: ${[...declaredVerbs].sort().join(', ')}.\n`
178
+ }
179
+ const here = resolved === null ? 'there is no workspace here' : 'this workspace declares none'
180
+ return ` A plugin declared in vx.workspace.ts can add verbs; ${here}.\n`
181
+ }
package/src/cli/info.ts CHANGED
@@ -6,8 +6,9 @@
6
6
 
7
7
  import { collectInfo, type FlakyTask, type InfoFacts } from '../orchestrator/index.js'
8
8
  import { seeHelp } from './help.js'
9
- import { parseCacheDirFlag, warnToStderr } from './workspace-config.js'
9
+ import { namedCacheDir, parseCacheDirFlag, warnToStderr } from './workspace-config.js'
10
10
  import { formatBytes } from './format.js'
11
+ import { MIN_BUN } from '../util/index.js'
11
12
 
12
13
  export interface InfoArgs {
13
14
  format: 'pretty' | 'json'
@@ -16,7 +17,7 @@ export interface InfoArgs {
16
17
  error?: string
17
18
  }
18
19
 
19
- export function parseInfoArgs(args: readonly string[]): InfoArgs {
20
+ function parseInfoArgs(args: readonly string[]): InfoArgs {
20
21
  const out: InfoArgs = { format: 'pretty' }
21
22
  for (let i = 0; i < args.length; i++) {
22
23
  const a = args[i]
@@ -46,6 +47,10 @@ export async function infoCmd(args: readonly string[]): Promise<number> {
46
47
  process.stderr.write(`vx info: ${parsed.error}\n`)
47
48
  return 1
48
49
  }
50
+ // `collectInfo` takes a resolved directory from `vx mcp` too, where one
51
+ // not made yet is a workspace that never ran; the flag is the user's,
52
+ // and a directory it names that is not there is a typo (item 900).
53
+ if (parsed.cacheDir !== undefined) namedCacheDir(parsed.cacheDir)
49
54
  const facts = await collectInfo(process.cwd(), {
50
55
  ...(parsed.cacheDir !== undefined ? { cacheDir: parsed.cacheDir } : {}),
51
56
  warn: warnToStderr,
@@ -59,11 +64,26 @@ export async function infoCmd(args: readonly string[]): Promise<number> {
59
64
  export function renderInfo(f: InfoFacts): string {
60
65
  const rows: [string, string][] = [
61
66
  ['vx', f.vx],
62
- ['bun', f.bun],
67
+ // The prose stays in the rendered row; `f.bun` itself is the bare
68
+ // version a script reads (doctor.ts says why).
69
+ [
70
+ 'bun',
71
+ f.bunSupported
72
+ ? f.bun
73
+ : `${f.bun} — unsupported, vx needs >= ${MIN_BUN.join('.')}; answers may be wrong`,
74
+ ],
63
75
  ['git', f.git ?? '(not found)'],
64
76
  ['git status cache', renderGitStatusCache(f.gitStatusCache)],
65
77
  ['workspace root', f.workspaceRoot],
66
- ['projects', `${f.projects} (${f.tasks} task${f.tasks === 1 ? '' : 's'})`],
78
+ ['projects', describeProjects(f)],
79
+ // Only when a config did not load: the count above says "zero" for
80
+ // it, and this row says which and why.
81
+ ...(f.configErrors.length > 0
82
+ ? ([['config errors', f.configErrors.map((e) => `${e.path}: ${e.message}`).join('; ')]] as [
83
+ string,
84
+ string,
85
+ ][])
86
+ : []),
67
87
  ['plugins', describePlugins(f.plugins)],
68
88
  ['workers', describeWorkers(f.workers)],
69
89
  ['memory', describeMemory(f.memory)],
@@ -79,14 +99,29 @@ export function renderInfo(f: InfoFacts): string {
79
99
  ],
80
100
  ] as [string, string][])
81
101
  : []),
82
- ['runs (24h)', `${f.runs24h} (${f.hits24h} cache hits)`],
102
+ // Task runs, not invocations (which `vx last` calls runs): the count
103
+ // the hits are a share of.
104
+ ['task runs (24h)', `${f.runs24h} (${f.hits24h} cache hits)`],
83
105
  ['flaky tasks', describeFlakyTasks(f.flakyTasks)],
106
+ ['sandbox', describeSandbox(f.sandbox)],
84
107
  ['vx-lock.json', f.lockfile ? 'yes' : 'no'],
85
108
  ]
86
109
  const labelW = Math.max(...rows.map(([label]) => label.length))
87
110
  return rows.map(([label, value]) => `${`${label}:`.padEnd(labelW + 1)} ${value}`).join('\n')
88
111
  }
89
112
 
113
+ /**
114
+ * `available (3 tasks declare exec.sandbox)`, or `unavailable — <why>; 3
115
+ * tasks declare exec.sandbox and will fail`: a declared sandbox whose
116
+ * runtime cannot start fails the task, so the doctor names it before a run
117
+ * does.
118
+ */
119
+ function describeSandbox(s: InfoFacts['sandbox']): string {
120
+ const declared = `${s.declared} task${s.declared === 1 ? '' : 's'} declare${s.declared === 1 ? 's' : ''} exec.sandbox`
121
+ if (s.available) return `available (${declared})`
122
+ return `unavailable — ${s.reason}; ${declared}${s.declared > 0 ? ' and will fail' : ''}`
123
+ }
124
+
90
125
  /** `4 — the CPU count`, `2 — cgroup CPU quota 2 of 8 cores`, `8 — vx.workspace.ts (4 cores)`. */
91
126
  export function describeWorkers(w: InfoFacts['workers']): string {
92
127
  const quota = w.cpuQuota === null ? '' : `, cgroup CPU quota ${trimCores(w.cpuQuota)}`
@@ -109,7 +144,14 @@ function trimCores(n: number): string {
109
144
  }
110
145
 
111
146
  /** `2 — app#test (3 of 7 runs failed on unchanged inputs); api#e2e (1 of 4)`, or `none`. */
112
- export function describeFlakyTasks(tasks: readonly FlakyTask[]): string {
147
+ function describeProjects(f: InfoFacts): string {
148
+ const tasks = `${f.tasks} task${f.tasks === 1 ? '' : 's'}`
149
+ const n = f.configErrors.length
150
+ if (n === 0) return `${f.projects} (${tasks})`
151
+ return `${f.projects} (${tasks} · ${n} config${n === 1 ? '' : 's'} did not load)`
152
+ }
153
+
154
+ function describeFlakyTasks(tasks: readonly FlakyTask[]): string {
113
155
  if (tasks.length === 0) return 'none'
114
156
  const parts = tasks.map(
115
157
  (t, i) =>
@@ -118,7 +160,7 @@ export function describeFlakyTasks(tasks: readonly FlakyTask[]): string {
118
160
  return `${tasks.length} — ${parts.join('; ')}`
119
161
  }
120
162
 
121
- export function describePlugins(
163
+ function describePlugins(
122
164
  plugins: ReadonlyArray<{ name: string; seams: readonly string[] }>,
123
165
  ): string {
124
166
  if (plugins.length === 0) return 'none'
package/src/cli/init.ts CHANGED
@@ -8,6 +8,7 @@ import { seeHelp } from './help.js'
8
8
  import {
9
9
  applyMigration,
10
10
  findWorkspaceRoot,
11
+ type LoadReads,
11
12
  listProjects,
12
13
  loadWorkspace,
13
14
  migrateScripts,
@@ -39,16 +40,26 @@ export async function initCmd(args: readonly string[]): Promise<number> {
39
40
  process.stderr.write(`vx init: ${parsed.error}\n`)
40
41
  return 1
41
42
  }
42
- const root = await findWorkspaceRoot(process.cwd())
43
- const metas = await listProjects(await loadWorkspace(root))
43
+ const reads: LoadReads = new Map()
44
+ const root = await findWorkspaceRoot(process.cwd(), reads)
45
+ const metas = await listProjects(await loadWorkspace(root, reads))
44
46
  // `init` reads scripts only; a runner's own config beside them is the
45
47
  // richer source (dependsOn, inputs, outputs) and was ignored without a
46
48
  // word — the walkthrough on a Turbo repo (2026-09-09) got the scripts'
47
49
  // TODOs and none of the edges turbo.json already declared.
48
50
  const notes: string[] = []
49
- if (await Bun.file(path.join(root, 'turbo.json')).exists()) {
51
+ // Turbo 2.5+ reads `turbo.jsonc` as well (item 938).
52
+ let turbo: string | undefined
53
+ for (const name of ['turbo.json', 'turbo.jsonc']) {
54
+ if (await Bun.file(path.join(root, name)).exists()) {
55
+ turbo = name
56
+ break
57
+ }
58
+ }
59
+ if (turbo !== undefined) {
50
60
  notes.push(
51
- 'turbo.json found and not read — `bunx @vzn/vx-migrate` maps it (dependsOn, inputs, ' +
61
+ `${turbo} found and not read — ` +
62
+ '`bunx @vzn/vx-migrate` maps it (dependsOn, inputs, ' +
52
63
  'outputs), or `plugins: [turbo()]` from @vzn/vx-migrate runs it with nothing written',
53
64
  )
54
65
  } else if (
@@ -57,7 +68,8 @@ export async function initCmd(args: readonly string[]): Promise<number> {
57
68
  ) {
58
69
  notes.push(
59
70
  'an Nx workspace found and not read — `bunx @vzn/vx-migrate --from nx` maps its ' +
60
- 'exported project graph',
71
+ 'exported project graph, or `plugins: [nx()]` from @vzn/vx-migrate runs it with ' +
72
+ 'nothing written',
61
73
  )
62
74
  }
63
75
  return applyMigration({