@vzn/vx 0.0.645 → 0.0.648

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 (186) hide show
  1. package/README.md +39 -50
  2. package/launcher.cjs +9 -2
  3. package/launcher.sh +2 -2
  4. package/package.json +5 -5
  5. package/schemas/cache.json +5 -5
  6. package/schemas/error.json +1 -1
  7. package/schemas/info.json +16 -2
  8. package/schemas/last.json +127 -5
  9. package/schemas/plan.json +28 -2
  10. package/schemas/show.json +10 -1
  11. package/skills/vx/SKILL.md +19 -3
  12. package/src/bin.ts +54 -13
  13. package/src/cache/archive.ts +324 -23
  14. package/src/cache/cache.ts +330 -65
  15. package/src/cache/git-inputs.ts +274 -97
  16. package/src/cache/index.ts +7 -2
  17. package/src/cache/inputs.ts +515 -83
  18. package/src/cache/key-fold.ts +15 -6
  19. package/src/cache/layer.ts +74 -11
  20. package/src/cache/layered-cache.ts +154 -59
  21. package/src/cache/linked-inputs.ts +1 -1
  22. package/src/cache/output-index.ts +44 -14
  23. package/src/cache/plugin-cache.ts +49 -23
  24. package/src/cache/remote-hits.ts +72 -0
  25. package/src/cache/remote-lane.ts +100 -15
  26. package/src/cache/run-history.ts +77 -23
  27. package/src/cache/run-store.ts +11 -10
  28. package/src/cache/schema.ts +42 -6
  29. package/src/cache/tar-stream.ts +5 -0
  30. package/src/cache/umask.ts +1 -2
  31. package/src/cli/cache.ts +58 -27
  32. package/src/cli/completions.ts +12 -5
  33. package/src/cli/core-alias.ts +5 -2
  34. package/src/cli/docs.ts +42 -16
  35. package/src/cli/help.ts +62 -25
  36. package/src/cli/index.ts +79 -45
  37. package/src/cli/info.ts +12 -6
  38. package/src/cli/init.ts +222 -34
  39. package/src/cli/last.ts +443 -47
  40. package/src/cli/lock.ts +197 -52
  41. package/src/cli/plan-format.ts +91 -49
  42. package/src/cli/plugin-commands.ts +160 -11
  43. package/src/cli/plugin-templates.ts +13 -13
  44. package/src/cli/registry.ts +209 -0
  45. package/src/cli/run.ts +111 -41
  46. package/src/cli/select.ts +148 -49
  47. package/src/cli/show.ts +59 -16
  48. package/src/cli/task-verb.ts +16 -6
  49. package/src/cli/upgrade.ts +155 -54
  50. package/src/cli/watch-cycle.ts +10 -5
  51. package/src/cli/watch-filter.ts +71 -3
  52. package/src/cli/watch-fs.ts +22 -6
  53. package/src/cli/watch-judge.ts +48 -6
  54. package/src/cli/watch.ts +163 -42
  55. package/src/cli/why.ts +211 -73
  56. package/src/cli/workspace-config.ts +54 -15
  57. package/src/config.ts +13 -1
  58. package/src/exec/env.ts +7 -2
  59. package/src/exec/executor.ts +262 -15
  60. package/src/exec/index.ts +8 -2
  61. package/src/exec/kill-tree.ts +15 -0
  62. package/src/exec/local-executor.ts +1 -1
  63. package/src/exec/proc-sample.ts +70 -6
  64. package/src/exec/runner.ts +256 -53
  65. package/src/exec/sandbox-binds.ts +7 -3
  66. package/src/exec/sandbox-paths.ts +1 -1
  67. package/src/exec/sandbox-runtime.ts +222 -28
  68. package/src/exec/sandbox-violations.ts +52 -20
  69. package/src/graph/critical-path.ts +79 -0
  70. package/src/graph/default-build.ts +19 -0
  71. package/src/graph/definition.ts +38 -0
  72. package/src/graph/folds.ts +38 -5
  73. package/src/graph/index.ts +4 -1
  74. package/src/graph/priorities.ts +74 -8
  75. package/src/graph/scheduler.ts +326 -65
  76. package/src/graph/task-graph.ts +228 -35
  77. package/src/handoff.ts +97 -9
  78. package/src/history/failure-mode.ts +6 -3
  79. package/src/history/history.ts +268 -50
  80. package/src/history/index.ts +2 -0
  81. package/src/history/metrics.ts +142 -45
  82. package/src/index.ts +42 -10
  83. package/src/orchestrator/admission.ts +10 -6
  84. package/src/orchestrator/affected-tasks.ts +11 -6
  85. package/src/orchestrator/deferred-outputs.ts +2 -0
  86. package/src/orchestrator/doctor.ts +134 -42
  87. package/src/orchestrator/execute-args.ts +6 -0
  88. package/src/orchestrator/execute-task.ts +155 -42
  89. package/src/orchestrator/fingerprint-watch.ts +1 -1
  90. package/src/orchestrator/graph-memo.ts +140 -0
  91. package/src/orchestrator/graph-stage.ts +18 -1
  92. package/src/orchestrator/held-servers.ts +86 -0
  93. package/src/orchestrator/hit-restore.ts +227 -122
  94. package/src/orchestrator/index.ts +20 -3
  95. package/src/orchestrator/local-floor.ts +198 -0
  96. package/src/orchestrator/local-shortcircuit.ts +51 -8
  97. package/src/orchestrator/lockfile-claim.ts +96 -16
  98. package/src/orchestrator/miss-save.ts +52 -12
  99. package/src/orchestrator/options.ts +8 -6
  100. package/src/orchestrator/output-host.ts +43 -14
  101. package/src/orchestrator/output-log.ts +90 -10
  102. package/src/orchestrator/persistent-task.ts +97 -28
  103. package/src/orchestrator/persistent.ts +16 -4
  104. package/src/orchestrator/placement.ts +257 -14
  105. package/src/orchestrator/plan.ts +64 -7
  106. package/src/orchestrator/plugin-host.ts +230 -40
  107. package/src/orchestrator/plugin.ts +150 -37
  108. package/src/orchestrator/prepare.ts +178 -32
  109. package/src/orchestrator/projects.ts +65 -40
  110. package/src/orchestrator/remote-prefetch.ts +119 -25
  111. package/src/orchestrator/run-artifacts.ts +95 -32
  112. package/src/orchestrator/run-context.ts +39 -41
  113. package/src/orchestrator/run-failures.ts +65 -23
  114. package/src/orchestrator/run-id.ts +6 -3
  115. package/src/orchestrator/run-keys.ts +300 -0
  116. package/src/orchestrator/run-live.ts +294 -0
  117. package/src/orchestrator/run-lock.ts +13 -7
  118. package/src/orchestrator/run-records.ts +35 -23
  119. package/src/orchestrator/run-report.ts +3 -3
  120. package/src/orchestrator/run-steps.ts +58 -22
  121. package/src/orchestrator/run-stop.ts +22 -5
  122. package/src/orchestrator/run.ts +409 -79
  123. package/src/orchestrator/sandbox-fix.ts +7 -5
  124. package/src/orchestrator/sandbox-request.ts +78 -22
  125. package/src/orchestrator/shell-verdict.ts +5 -5
  126. package/src/orchestrator/signals.ts +26 -1
  127. package/src/orchestrator/stable-keys.ts +92 -8
  128. package/src/orchestrator/steps.ts +2 -2
  129. package/src/orchestrator/task-artifacts.ts +206 -0
  130. package/src/orchestrator/task-hash.ts +89 -14
  131. package/src/orchestrator/task-log-buffer.ts +72 -36
  132. package/src/orchestrator/task-process.ts +22 -0
  133. package/src/orchestrator/telemetry-host.ts +24 -3
  134. package/src/orchestrator/telemetry.ts +113 -38
  135. package/src/orchestrator/upstream.ts +15 -0
  136. package/src/report/events.ts +32 -13
  137. package/src/report/forecast.ts +36 -35
  138. package/src/report/framed-output.ts +173 -46
  139. package/src/report/index.ts +1 -0
  140. package/src/report/logger.ts +217 -186
  141. package/src/report/path-links.ts +12 -4
  142. package/src/report/plain-output.ts +13 -5
  143. package/src/report/status-line.ts +94 -46
  144. package/src/report/summary.ts +174 -38
  145. package/src/report/terminal-signals.ts +13 -2
  146. package/src/settings.ts +14 -6
  147. package/src/util/bun-version.ts +4 -1
  148. package/src/util/colors.ts +20 -3
  149. package/src/util/config-frame.ts +41 -4
  150. package/src/{graph → util}/dependency-spec.ts +22 -13
  151. package/src/util/edit-distance.ts +13 -1
  152. package/src/util/env-flag.ts +9 -0
  153. package/src/util/errors.ts +59 -3
  154. package/src/util/essential-env.ts +57 -0
  155. package/src/util/git-ignored.ts +1 -1
  156. package/src/util/hash.ts +35 -0
  157. package/src/util/hide-env.ts +45 -5
  158. package/src/util/index.ts +22 -5
  159. package/src/util/num.ts +8 -0
  160. package/src/util/paths.ts +121 -21
  161. package/src/util/report.ts +6 -1
  162. package/src/util/secret-mask.ts +62 -1
  163. package/src/util/secrets.ts +7 -2
  164. package/src/util/size.ts +11 -3
  165. package/src/util/tail.ts +6 -1
  166. package/src/util/timing.ts +13 -1
  167. package/src/util/ui.ts +65 -4
  168. package/src/util/which.ts +1 -1
  169. package/src/util/write-file.ts +67 -0
  170. package/src/workspace/affected.ts +710 -107
  171. package/src/workspace/bracket-literal.ts +84 -0
  172. package/src/workspace/canonical-dir.ts +37 -0
  173. package/src/workspace/config-bytecode.ts +282 -0
  174. package/src/workspace/config-eval.ts +29 -8
  175. package/src/workspace/config-imports.ts +199 -58
  176. package/src/workspace/config-schema.ts +242 -39
  177. package/src/workspace/filter.ts +37 -8
  178. package/src/workspace/index.ts +11 -2
  179. package/src/workspace/json-data.ts +3 -4
  180. package/src/workspace/lockfile.ts +57 -8
  181. package/src/workspace/migration.ts +72 -49
  182. package/src/workspace/package-graph.ts +5 -4
  183. package/src/workspace/project-loader.ts +314 -47
  184. package/src/workspace/repo-id.ts +1 -1
  185. package/src/workspace/workspace.ts +176 -33
  186. package/src/cli/foreign-flags.ts +0 -727
package/src/cli/docs.ts CHANGED
@@ -1,10 +1,11 @@
1
1
  // `vx docs <query> [--limit N] [--format pretty|json]` — search vx's
2
2
  // reference as this version wrote it: packages/vx/docs at its git tag on
3
- // GitHub, read online (M, 2026-10-17: nothing is embedded). Each page is
3
+ // GitHub, read online (2026-10-17: nothing is embedded). Each page is
4
4
  // cut at its headings, and the sections holding every query word print
5
5
  // whole, best first.
6
6
 
7
7
  import { VERSION } from '../version.js'
8
+ import { detectColors, paint, parseDecimalInt, ui } from '../util/index.js'
8
9
  import { flagHint, formatValue, refuse, refusedWord, seeHelp } from './help.js'
9
10
 
10
11
  const SITE = 'https://vznjs.github.io/vx/'
@@ -19,7 +20,7 @@ interface DocsArgs {
19
20
  error?: string
20
21
  }
21
22
 
22
- function parseDocsArgs(args: readonly string[]): DocsArgs {
23
+ export function parseDocsArgs(args: readonly string[]): DocsArgs {
23
24
  const out: DocsArgs = { terms: [], limit: DEFAULT_LIMIT, json: false }
24
25
  for (let i = 0; i < args.length; i++) {
25
26
  const a = args[i]!
@@ -29,11 +30,17 @@ function parseDocsArgs(args: readonly string[]): DocsArgs {
29
30
  out.json = fv === 'json'
30
31
  } else if (a === '--limit' || a.startsWith('--limit=')) {
31
32
  const v = a === '--limit' ? args[++i] : a.slice('--limit='.length)
32
- const n = Number(v)
33
- if (!Number.isInteger(n) || n < 1) {
33
+ if (v === undefined || v === '') {
34
34
  return {
35
35
  ...out,
36
- error: `--limit takes a whole number above 0 (got ${v ?? 'nothing'})${seeHelp('docs')}`,
36
+ error: `--limit requires a value: a whole number above 0${seeHelp('docs')}`,
37
+ }
38
+ }
39
+ const n = parseDecimalInt(v)
40
+ if (n === null || n < 1) {
41
+ return {
42
+ ...out,
43
+ error: `--limit takes a whole number above 0 (got ${v})${seeHelp('docs')}`,
37
44
  }
38
45
  }
39
46
  out.limit = n
@@ -52,7 +59,7 @@ export interface DocsHit {
52
59
  }
53
60
 
54
61
  function words(s: string): string[] {
55
- return s.toLowerCase().match(/[a-z0-9][a-z0-9_.-]*/g) ?? []
62
+ return s.toLowerCase().match(/[\p{L}\p{N}][\p{L}\p{N}_.-]*/gu) ?? []
56
63
  }
57
64
 
58
65
  /** GitHub's heading anchor, which Starlight shares: lowercase, punctuation dropped, spaces to `-`. */
@@ -63,7 +70,7 @@ function anchor(heading: string): string {
63
70
  .replace(/ /g, '-')
64
71
  }
65
72
 
66
- /** A page's URL on the site, as packages/vx-docs/scripts/import-docs.ts serves it. */
73
+ /** A page's URL on the site, as private/vx-docs/scripts/import-docs.ts serves it. */
67
74
  function pageUrl(page: string): string {
68
75
  const served: Record<string, string> = {
69
76
  README: 'overview',
@@ -117,6 +124,8 @@ export async function loadPages(
117
124
  interface Section {
118
125
  page: string
119
126
  heading: string
127
+ /** The heading's id on the site; absent for the page's opening. */
128
+ id?: string
120
129
  text: string
121
130
  }
122
131
 
@@ -124,18 +133,31 @@ interface Section {
124
133
  function sections(page: string, md: string): Section[] {
125
134
  const out: Section[] = []
126
135
  let heading = ''
136
+ let id: string | undefined
127
137
  let lines: string[] = []
128
138
  let fence = false
139
+ // The site numbers a repeated id (`usage`, `usage-1`) over every `##` to
140
+ // `######` heading of the page, as import-docs.ts's `headingIds` does:
141
+ // cli.md repeats Usage per verb, and each linked the first.
142
+ const seen = new Map<string, number>()
129
143
  const flush = () => {
130
144
  const text = lines.join('\n').trim()
131
- if (text !== '') out.push({ page, heading, text })
145
+ if (text !== '') out.push({ page, heading, ...(id !== undefined ? { id } : {}), text })
132
146
  }
133
147
  for (const line of md.split('\n')) {
134
148
  if (/^\s*(```|~~~)/.test(line)) fence = !fence
135
- const h = fence ? null : /^#{1,4} (.+)$/.exec(line)
136
- if (h !== null) {
149
+ const h = fence ? null : /^(#{1,6}) (.+)$/.exec(line)
150
+ let slug: string | undefined
151
+ if (h !== null && h[1]!.length > 1) {
152
+ const base = anchor(h[2]!.replace(/`/g, '').trim())
153
+ const n = seen.get(base) ?? 0
154
+ seen.set(base, n + 1)
155
+ slug = n === 0 ? base : `${base}-${n}`
156
+ }
157
+ if (h !== null && h[1]!.length <= 4) {
137
158
  flush()
138
- heading = h[1]!.replace(/`/g, '').trim()
159
+ heading = h[2]!.replace(/`/g, '').trim()
160
+ id = slug ?? anchor(heading)
139
161
  lines = [line]
140
162
  } else lines.push(line)
141
163
  }
@@ -182,7 +204,7 @@ export function searchDocs(
182
204
  return (exact.length > 0 ? exact : scored).slice(0, limit).map(({ s }) => ({
183
205
  page: s.page,
184
206
  heading: s.heading,
185
- url: s.heading === '' ? pageUrl(s.page) : `${pageUrl(s.page)}#${anchor(s.heading)}`,
207
+ url: s.id === undefined ? pageUrl(s.page) : `${pageUrl(s.page)}#${s.id}`,
186
208
  text: s.text,
187
209
  }))
188
210
  }
@@ -220,16 +242,20 @@ export async function docsCmd(
220
242
  return 0
221
243
  }
222
244
  if (hits.length === 0) {
223
- process.stdout.write(
245
+ ui.say(
246
+ 'warn',
224
247
  `vx docs: no section of the docs at ${ref} holds every word of "${query}"; try fewer words, or ${SITE}llms.txt\n`,
248
+ 'out',
225
249
  )
226
250
  return 0
227
251
  }
252
+ const c = detectColors(process.stdout)
228
253
  process.stdout.write(
229
254
  hits
230
- .map(
231
- (h) =>
232
- `── ${h.page}.md${h.heading === '' ? '' : ` § ${h.heading}`} · ${h.url}\n\n${h.text}\n`,
255
+ .map((h) =>
256
+ c.enabled
257
+ ? `${ui.render([ui.excerpt(`${h.page}.md${h.heading === '' ? '' : ` § ${h.heading}`}`, h.text)], c)}${paint('', h.url, c, { dim: true })}\n`
258
+ : `── ${h.page}.md${h.heading === '' ? '' : ` § ${h.heading}`} · ${h.url}\n\n${h.text}\n`,
233
259
  )
234
260
  .join('\n'),
235
261
  )
package/src/cli/help.ts CHANGED
@@ -114,14 +114,14 @@ export function helpText(pluginCommands: readonly string[] = []): string {
114
114
  'Usage:',
115
115
  ' vx run [OPTIONS] [TASK | PKG#TASK ...] [-- forwarded-args...]',
116
116
  ' vx watch [OPTIONS] [--poll] TASK [-- forwarded-args...]',
117
- ' vx cache clean [--older-than <duration>] [--max-size <size>] [--global] [--dry-run] [--format pretty|json] [--cache-dir <path>]',
117
+ ' vx cache clean [--older-than <duration>] [--max-size <size>] [--global] [--dry] [--format pretty|json] [--cache-dir <path>]',
118
118
  ' vx lock [--check] [--format pretty|json]',
119
- ' vx init [--dry [--format pretty|json]] [--force] [--mjs] [--native|--keep] [--plugin <seam>]',
119
+ ' vx init [--dry [--format pretty|json]] [--force] [--mjs] [--native|--keep] [--plugin <hook>]',
120
120
  ' vx upgrade [TAG] [-g|--global]',
121
121
  ' vx show [PROJECT[#TASK] | TASK] [--filter <pattern>] [--affected[=<ref>]] [--format pretty|json]',
122
122
  ' vx info [--format pretty|json] [--cache-dir <path>]',
123
123
  ' vx why (TASK | PKG#TASK) [--run RUNID] [--format pretty|json] [--cache-dir <path>]',
124
- ' vx last [RUNID] [--list[=N]] [--failed] [--log <task>] [--stats [<task>] [--branch <name>]] [--format pretty|json] [--cache-dir <path>]',
124
+ ' vx last [RUNID] [--list[=N]] [--failed] [--log <task>] [--stats [<task>] [--branch <name>]] [--sql <query>] [--live [--follow]] [--format pretty|json] [--cache-dir <path>]',
125
125
  ' vx docs <query> [--limit N] [--format pretty|json]',
126
126
  ' vx completions bash|zsh|fish',
127
127
  ' vx help [VERB]',
@@ -186,7 +186,7 @@ export function helpText(pluginCommands: readonly string[] = []): string {
186
186
  ' summary table after the run.',
187
187
  '',
188
188
  'Planning (for run — skips execution):',
189
- " --dry[=text|json] Print the task graph and each task's predicted hit",
189
+ " --dry[=pretty|json] Print the task graph and each task's predicted hit",
190
190
  ' or miss.',
191
191
  '',
192
192
  'Artifacts (for run):',
@@ -197,13 +197,7 @@ export function helpText(pluginCommands: readonly string[] = []): string {
197
197
  ' cache, distributed execution, and telemetry export are provided by',
198
198
  ' PLUGINS declared in .vx/workspace.ts — first-party or your own. Core',
199
199
  ' depends on none of them; a plain `vx run` never requires a plugin.',
200
- ' See the plugin guide: https://vznjs.github.io/vx/guides/plugins/',
201
- '',
202
- 'Turbo and Nx spellings (for run):',
203
- ' What turbo run / nx run-many / nx affected take works here, or is refused',
204
- ' naming the vx spelling: -t build,test, -p app, --exclude docs, --parallel 4,',
205
- ' and --base main, --dry-run, --skip-nx-cache. `vx build` names `vx run build`.',
206
- ' Every flag: https://vznjs.github.io/vx/cli/#turbo-and-nx-flags',
200
+ ' See the plugin guide: https://vznjs.github.io/vx/docs/plugins/',
207
201
  '',
208
202
  'Argument forwarding (for run):',
209
203
  " Anything after `--` is forwarded (shell-quoted) to the task's exec",
@@ -221,11 +215,13 @@ export function helpText(pluginCommands: readonly string[] = []): string {
221
215
  'Cache management:',
222
216
  ' vx cache clean --older-than 30d Evict entries last accessed > 30 days ago.',
223
217
  ' vx cache clean --max-size 1G Keep total cache under 1 GB (LRU eviction).',
224
- " vx cache clean Clear .vx/cache and this repo's store ~/.vx/<id>/cache.",
218
+ " vx cache clean Clear .vx/cache and this repo's store",
219
+ ' ~/.vx/<id>/cache.',
225
220
  " --global Clear every repo's store under ~/.vx.",
226
- ' --dry-run Say what the policy would reap; delete nothing.',
221
+ ' --dry Say what the policy would reap; delete nothing.',
227
222
  ' --format <fmt> pretty (default) | json.',
228
- ' --cache-dir <path> The cache a run with the same flag uses (why, last, info too).',
223
+ ' --cache-dir <path> The cache a run with the same flag uses (why,',
224
+ ' last, info too).',
229
225
  '',
230
226
  ' Duration units: ms, s, m, h, d. Size units: K, M, G, T (powers of 1024).',
231
227
  ' Cache statistics — directory, entry count, size — are part of `vx info`.',
@@ -259,6 +255,10 @@ export function helpText(pluginCommands: readonly string[] = []): string {
259
255
  ' --stats [<task>] Per-task p50/p99, pass and hit rate, failure mode, peak',
260
256
  ' RSS and CPU over the last 50 runs; a task id or name narrows.',
261
257
  " --branch <name> The branch --stats' p50 forecasts from (default: the run's).",
258
+ ' --sql <query> One read-only SELECT over the history (runs, invocations,',
259
+ ' entries, task_outcomes, ...): filter, group, trend.',
260
+ ' --live The run in progress: running, ended, waiting tasks, warnings.',
261
+ " --follow With --live, the run's output and events until it ends.",
262
262
  ' --format <fmt> pretty (default) | json.',
263
263
  ' info, why and last:',
264
264
  ' --cache-dir <path> Read the cache a run with the same flag uses.',
@@ -274,7 +274,7 @@ export function helpText(pluginCommands: readonly string[] = []): string {
274
274
  ' --mjs Write .mjs files (no type import) instead of .ts.',
275
275
  ' --native Turbo or Nx: write a vx.config.ts per package (no question).',
276
276
  ' --keep Turbo or Nx: only .vx/workspace.ts, declaring turbo() or nx().',
277
- ' --plugin <seam> Instead: write plugins/<seam>.ts, a runnable plugin, and its',
277
+ ' --plugin <hook> Instead: write plugins/<hook>.ts, a runnable plugin, and its',
278
278
  ' test (executor, cache, telemetry, schedule, admit, commands,',
279
279
  ' project, graph, key).',
280
280
  '',
@@ -304,11 +304,11 @@ export function helpText(pluginCommands: readonly string[] = []): string {
304
304
  ' into .vx/projects.lock.json, the only place a run reads configs',
305
305
  ' from. A run never evaluates one, never checks the rules',
306
306
  ' and never writes the lock: with none it fails.',
307
- ' vx lock --check Audit the lock: file-changed hash report PLUS a full',
308
- ' re-evaluation compared against the frozen objects, and the',
309
- ' graph rules. Exits 1 on drift (e.g. configs that read env',
310
- ' vars). Run it before the run in CI. With --format json it prints',
311
- ' {upToDate, audited, notAudited, problems}.',
307
+ ' vx lock --check Audit the lock: re-evaluate every config here and compare',
308
+ ' it with the frozen object, find a config added or gone, and',
309
+ ' check the graph rules. Exits 1 on drift (e.g. configs that',
310
+ ' read env vars). Run it before the run in CI. With --format json',
311
+ ' it prints {upToDate, audited, notAudited, problems}.',
312
312
  '',
313
313
  ...(pluginCommands.length > 0 ? ['Plugin commands:', ...pluginCommands, ''] : []),
314
314
  ].join('\n')
@@ -323,6 +323,27 @@ export function seeHelp(verb: string): string {
323
323
  return ` (see \`vx ${verb} --help\`)`
324
324
  }
325
325
 
326
+ /**
327
+ * The refusal for a `project#task` spec with an empty half, or undefined.
328
+ * An empty task wins over an empty project: `#` names no task to run in
329
+ * every project, so `bare(task)` would print a hint with no task in it.
330
+ */
331
+ export function anchoredSpecError(
332
+ spec: string,
333
+ bare: (task: string) => string,
334
+ ): string | undefined {
335
+ const idx = spec.indexOf('#')
336
+ if (idx < 0) return undefined
337
+ const project = spec.slice(0, idx)
338
+ const task = spec.slice(idx + 1)
339
+ if (!task) {
340
+ const list = project ? `vx show ${project}` : 'vx show'
341
+ return `missing task name after '#' in "${spec}" (${list} lists the tasks)`
342
+ }
343
+ if (!project) return `missing project name before '#' in "${spec}" (${bare(task)})`
344
+ return undefined
345
+ }
346
+
326
347
  /** `--format`'s value, or the one refusal every verb gives for a bad one. */
327
348
  export function formatValue(
328
349
  v: string | undefined,
@@ -343,8 +364,8 @@ export function refusedWord(arg: string): string {
343
364
  }
344
365
 
345
366
  /**
346
- * `(did you mean --concurrency?)` for a flag within two edits of one `verb`
347
- * accepts; empty for anything else (a positional included). A third edit
367
+ * `(did you mean --concurrency?)` for a flag within two edits (one, for a
368
+ * name of four letters or fewer) of one `verb` accepts; empty for anything else (a positional included). A third edit
348
369
  * is allowed only between flags that share their first five characters,
349
370
  * so `--timeouts` reaches `--timeout` while `--zzz` does not reach `--all`.
350
371
  */
@@ -356,9 +377,25 @@ export function flagHint(verb: string, arg: string): string {
356
377
  // `--json` is gh's and Nx's spelling; a verb with `--format` reads it
357
378
  // there, and no edit budget reaches `--format json` from `--json`.
358
379
  if (name === '--json' && flags.includes('--format')) return ' (did you mean --format json?)'
359
- // A flag cut short at a word (`--dry` for prune's `--dry-run`, which
360
- // `vx run` spells `--dry`) is too many edits away for either budget.
361
- const cut = flags.filter((f) => f.startsWith(`${name}-`))
380
+ // `vx run` prints no JSON of its own, and edit distance reached `--force`
381
+ // from `--format`: a run with its cache reads turned off.
382
+ if ((name === '--format' || name === '--json') && flags.includes('--dry'))
383
+ return ' (the run as JSON is `vx last --format json` after it; the plan is --dry=json)'
384
+ // Watch takes run's flags but --dry, so the branch above missed it.
385
+ if ((name === '--format' || name === '--json') && verb === 'watch')
386
+ return " (vx watch prints no JSON; each cycle's run is `vx last --format json` after it)"
387
+ // A bare flag handed a value (`--check=yes`) is no edit from itself.
388
+ if (name !== arg && flags.includes(name)) return ` (did you mean ${name}?)`
389
+ // Another verb's own flag is no typo: `vx run --poll` was told "did you
390
+ // mean --all?". Named only when one other verb takes it.
391
+ const owners = CORE_VERBS.filter(
392
+ (v) => v !== verb.split(' ')[0] && acceptedFlags(v).includes(name),
393
+ )
394
+ if (owners.length === 1)
395
+ return `. \`vx ${owners[0] === 'cache' ? 'cache clean' : owners[0]}\` takes it`
396
+ // A flag one word too long (`--dry-run` for `--dry`) is too many edits
397
+ // away for either budget, and so is one cut short at a word.
398
+ const cut = flags.filter((f) => f.startsWith(`${name}-`) || name.startsWith(`${f}-`))
362
399
  const best =
363
400
  nearest(name, flags) ??
364
401
  nearest(
package/src/cli/index.ts CHANGED
@@ -3,9 +3,9 @@
3
3
 
4
4
  import { VERSION } from '../version.js'
5
5
  import { checkSettingsEnv } from '../settings.js'
6
- import { CORE_VERBS, flagHint, printHelp, refusedWord, seeHelp } from './help.js'
7
- import { FOREIGN_VERBS } from './foreign-flags.js'
6
+ import { CORE_VERBS, flagHint, printHelp, refuse, refusedWord, seeHelp } from './help.js'
8
7
  import {
8
+ detectColors,
9
9
  errorDocument,
10
10
  hideEnvFromTasks,
11
11
  sayNotice,
@@ -15,6 +15,7 @@ import {
15
15
  maskedLine,
16
16
  nearest,
17
17
  refusalNotice,
18
+ thrownMessage,
18
19
  ui,
19
20
  UserError,
20
21
  wantsJson,
@@ -29,6 +30,7 @@ import {
29
30
  const plugins = () => import('./plugin-commands.js')
30
31
 
31
32
  export async function run(argv: readonly string[]): Promise<number> {
33
+ refuseLossyArgs(argv)
32
34
  const [command, ...rest] = argv
33
35
  await hideEnvFromTasks()
34
36
 
@@ -42,8 +44,11 @@ export async function run(argv: readonly string[]): Promise<number> {
42
44
  return 0
43
45
  }
44
46
  // Each setting's variable once, before any verb: a renamed one is named,
45
- // and a deep reader trusts what it reads.
46
- checkSettingsEnv()
47
+ // and a deep reader trusts what it reads. Not for help, as `vx run
48
+ // --help` above is not: help is where a setting's values are read. Nor
49
+ // for `vx info`, which reports a refused one (doctor.ts).
50
+ if (command !== 'help' && command !== '--help' && command !== '-h' && command !== 'info')
51
+ checkSettingsEnv()
47
52
 
48
53
  try {
49
54
  return await dispatch(command, rest)
@@ -52,9 +57,23 @@ export async function run(argv: readonly string[]): Promise<number> {
52
57
  }
53
58
  }
54
59
 
60
+ /**
61
+ * Bun decodes argv as lossy UTF-8 and exposes no raw bytes on macOS, so
62
+ * `-- $'a\xfe'` and `-- $'a\xff'` both became `a` U+FFFD: the task got
63
+ * bytes nobody typed and the two runs shared a key (a stale hit). A real
64
+ * U+FFFD cannot be told from a replaced byte, so any arg holding one is refused.
65
+ */
66
+ function refuseLossyArgs(argv: readonly string[]): void {
67
+ const at = argv.findIndex((a) => a.includes('�'))
68
+ if (at === -1) return
69
+ throw new UserError(
70
+ `argument ${at + 1} (${JSON.stringify(argv[at])}) is not valid UTF-8, or holds U+FFFD; vx cannot pass it on byte for byte`,
71
+ )
72
+ }
73
+
55
74
  /**
56
75
  * A crash from a hand-broken lock entry is that entry's fault: runs validate
57
- * no entry, so it surfaced as a raw TypeError (M, 2026-10-14). The lock is
76
+ * no entry, so it surfaced as a raw TypeError (2026-10-14). The lock is
58
77
  * read only on the crash path, so a normal run pays nothing.
59
78
  */
60
79
  async function brokenLockNamed(err: unknown): Promise<unknown> {
@@ -62,7 +81,8 @@ async function brokenLockNamed(err: unknown): Promise<unknown> {
62
81
  try {
63
82
  const { findWorkspaceRoot, LOCK_INVALID, lockEntryBroken } =
64
83
  await import('../workspace/index.js')
65
- if (lockEntryBroken(await findWorkspaceRoot(process.cwd()))) return new UserError(LOCK_INVALID)
84
+ if (lockEntryBroken(await findWorkspaceRoot(process.cwd())))
85
+ return new UserError(LOCK_INVALID, 'VX_E_LOCK')
66
86
  } catch {}
67
87
  return err
68
88
  }
@@ -86,41 +106,45 @@ async function dispatch(command: string | undefined, rest: string[]): Promise<nu
86
106
  }
87
107
  case '--help':
88
108
  case '-h':
109
+ if (rest.length > 0) return dispatch('help', rest)
89
110
  await printHelp(await (await plugins()).pluginCommandHelp())
90
111
  return 0
91
112
  case 'help': {
92
- // `vx help run` is the same question as `vx run --help`. A plugin verb
93
- // owns its own help, so anything but a core verb gets the whole
94
- // reference, which lists the plugin verbs.
95
- const verb = rest[0]
113
+ // `vx help run` is the same question as `vx run --help`, and so is
114
+ // `vx help <plugin verb>`: a plugin verb owns its own help.
115
+ const [verb, extra] = rest
116
+ if (extra !== undefined) {
117
+ return refuse(
118
+ 'help',
119
+ rest,
120
+ `${refusedWord(extra)}: ${extra}${flagHint('help', extra)} (see \`vx help\`)`,
121
+ 'VX_E_USAGE',
122
+ )
123
+ }
96
124
  const core = verb !== undefined && (CORE_VERBS as readonly string[]).includes(verb)
97
125
  if (verb !== undefined && !core) {
98
126
  // A name that is no verb here is the typo `vx rnu` is, not a request
99
127
  // for the whole reference: it printed 145 lines and no hint.
100
128
  const resolved = await (await plugins()).resolvePluginCommand(verb)
101
129
  const unknown = resolved === null || 'declaredVerbs' in resolved
102
- const pointer = FOREIGN_VERBS[verb]
103
- if (unknown && pointer !== undefined) {
104
- sayNotice('info', 'vx', pointer)
105
- return 1
106
- }
107
130
  if (unknown && verb.startsWith('-')) {
108
- sayNotice(
109
- 'error',
110
- 'vx help',
131
+ return refuse(
132
+ 'help',
133
+ rest,
111
134
  `unknown flag: ${verb}${flagHint('help', verb)} (see \`vx help\`)`,
135
+ 'VX_E_USAGE',
112
136
  )
113
- return 1
114
137
  }
115
138
  if (unknown) {
116
139
  const declared = resolved === null ? [] : resolved.declaredVerbs
117
- sayNotice(
118
- 'error',
119
- 'vx help',
140
+ return refuse(
141
+ 'help',
142
+ rest,
120
143
  `unknown command: ${verb}${didYouMeanVerb(verb, declared)} (see \`vx help\`)`,
144
+ 'VX_E_UNKNOWN_COMMAND',
121
145
  )
122
- return 1
123
146
  }
147
+ if (resolved !== null && !('loadError' in resolved)) return dispatch(verb, ['--help'])
124
148
  }
125
149
  await printHelp(await (await plugins()).pluginCommandHelp(), core ? verb : undefined)
126
150
  return 0
@@ -131,14 +155,17 @@ async function dispatch(command: string | undefined, rest: string[]): Promise<nu
131
155
  // takes no more of is refused, as every other verb refuses one.
132
156
  const extra = rest[0]
133
157
  if (extra !== undefined) {
134
- sayNotice(
135
- 'error',
136
- 'vx version',
158
+ return refuse(
159
+ 'version',
160
+ rest,
137
161
  `${refusedWord(extra)}: ${extra}${flagHint('version', extra)}${seeHelp('version')}`,
162
+ 'VX_E_USAGE',
138
163
  )
139
- return 1
140
164
  }
141
- process.stdout.write(`vx ${VERSION}\n`)
165
+ // Painted, and the same words piped: a script reads `vx <version>`.
166
+ process.stdout.write(
167
+ `${ui.inline([ui.cmd('vx'), ' ', ui.tone('info', VERSION)], detectColors(process.stdout))}\n`,
168
+ )
142
169
  return 0
143
170
  }
144
171
  case 'run':
@@ -180,7 +207,7 @@ async function dispatch(command: string | undefined, rest: string[]): Promise<nu
180
207
  // A verb's own refusal is its one line; anything else is the
181
208
  // plugin's crash, named as every other stage names one.
182
209
  if (isUserError(err)) throw err
183
- const message = err instanceof Error ? err.message : String(err)
210
+ const message = thrownMessage(err)
184
211
  throw new UserError(
185
212
  `plugin '${resolved.plugin.name}' failed in command '${command}': ${message}`,
186
213
  )
@@ -207,22 +234,18 @@ async function dispatch(command: string | undefined, rest: string[]): Promise<nu
207
234
  text: string,
208
235
  code = 'VX_E_UNKNOWN_COMMAND',
209
236
  tone: 'error' | 'info' = 'error',
237
+ json = text.split('\n')[0]!,
210
238
  ): number => {
211
239
  sayNotice(tone, 'vx', text.replace(/^vx: /, '').trimEnd())
212
- if (wantsJson(rest))
213
- process.stdout.write(errorDocument(code, maskedLine(text.split('\n')[0]!)))
240
+ // `vx --format json` puts the flag where the verb goes.
241
+ if (wantsJson([command, ...rest]))
242
+ process.stdout.write(errorDocument(code, maskedLine(json)))
214
243
  return 1
215
244
  }
216
245
  const loadNote =
217
246
  resolved !== null && 'loadError' in resolved
218
247
  ? `\n (plugin verbs could not be looked up: .vx/workspace failed to load: ${resolved.loadError})`
219
248
  : ''
220
- // Another tool's verb: the pointer, but only after the plugins had
221
- // their chance — a workspace that declares a plugin verb of that name keeps it.
222
- const moved = FOREIGN_VERBS[command]
223
- if (moved !== undefined) {
224
- return unknown(`${moved}${loadNote}\n`, undefined, 'info')
225
- }
226
249
  // A task typed where the verb goes (`turbo build`, `nx build app`),
227
250
  // `turbo dev` included: a repo's own `dev` task beats the no-service note.
228
251
  const declaredVerbs =
@@ -244,12 +267,13 @@ async function dispatch(command: string | undefined, rest: string[]): Promise<nu
244
267
  // provided by PLUGINS (declared in .vx/workspace.ts), never by core.
245
268
  // We keep this neutral hint for the common muscle-memory verbs, but
246
269
  // core names no specific plugin package: any package can provide
247
- // these.
270
+ // these. The notice prints a line per fact, so each line is a
271
+ // whole sentence.
248
272
  return unknown(
249
- `vx: '${command}' is not a vx core command.\n` +
250
- ` vx core runs tasks in-process. A dashboard, remote cache, and\n` +
251
- ` distributed execution come from plugins — not core. See the plugin\n` +
252
- ` guide: https://vznjs.github.io/vx/guides/plugins/\n`,
273
+ `vx: \`${command}\` is not a vx core command.\n` +
274
+ ` vx core runs tasks in-process.\n` +
275
+ ` A dashboard, remote cache and distributed execution come from plugins, not core.\n` +
276
+ ` See the plugin guide: https://vznjs.github.io/vx/docs/plugins/\n`,
253
277
  )
254
278
  }
255
279
  // One line, as a verb's own unknown flag or subcommand gets; the full
@@ -271,9 +295,19 @@ async function dispatch(command: string | undefined, rest: string[]): Promise<nu
271
295
  'VX_E_USAGE',
272
296
  )
273
297
  }
298
+ // A workspace that does not load is the refusal to fix first: the
299
+ // verb may be its own plugin's.
300
+ if (resolved !== null && 'loadError' in resolved) {
301
+ return unknown(
302
+ `vx: unknown command: ${command}${guess}${loadNote} (see \`vx help\`)\n`,
303
+ 'VX_E_CONFIG',
304
+ 'error',
305
+ `vx: unknown command: ${command}; .vx/workspace failed to load: ${resolved.loadError}`,
306
+ )
307
+ }
274
308
  return unknown(
275
- `vx: unknown command: ${command}${guess}${loadNote} (see \`vx help\`)\n` +
276
- (guess === '' && loadNote === '' ? verbSourceNote(resolved, declaredVerbs) : ''),
309
+ `vx: unknown command: ${command}${guess} (see \`vx help\`)\n` +
310
+ (guess === '' ? verbSourceNote(resolved, declaredVerbs) : ''),
277
311
  )
278
312
  }
279
313
  }
@@ -310,6 +344,6 @@ function verbSourceNote(resolved: unknown, declaredVerbs: readonly string[]): st
310
344
  if (declaredVerbs.length > 0) {
311
345
  return ` This workspace's plugins declare: ${[...declaredVerbs].sort().join(', ')}.\n`
312
346
  }
313
- const here = resolved === null ? 'there is no workspace here' : 'this workspace declares none'
347
+ const here = resolved === null ? 'there is no workspace here' : 'no plugin here adds one'
314
348
  return ` A plugin declared in .vx/workspace.ts can add verbs; ${here}.\n`
315
349
  }
package/src/cli/info.ts CHANGED
@@ -113,20 +113,24 @@ export function renderInfo(f: InfoFacts, colors: ColorSupport = { enabled: false
113
113
  // the hits are a share of.
114
114
  [
115
115
  'task runs (24h)',
116
- `${f.runs24h} (${f.hits24h} cache hits` +
116
+ `${f.runs24h} (${f.hits24h} cache hit${f.hits24h === 1 ? '' : 's'}` +
117
117
  (f.hits24h > 0
118
118
  ? `: ${f.hits24h - f.restored24h} up-to-date, ${f.restored24h} restored)`
119
119
  : ')'),
120
120
  ],
121
121
  ['flaky tasks', describeFlakyTasks(f.flakyTasks)],
122
122
  ['sandbox', describeSandbox(f.sandbox)],
123
- ['projects lock', f.lockfile ? 'yes' : 'no'],
123
+ [
124
+ 'projects lock',
125
+ typeof f.lockError === 'string' ? `refused: ${f.lockError}` : f.lockfile ? 'yes' : 'no',
126
+ ],
124
127
  ]
125
128
  return ui.report('vx info', rows).lines(colors).join('\n')
126
129
  }
127
130
 
128
- /** `812 (1.2 GB)`. */
131
+ /** `812 (1.2 GB)`, or why the index cannot be read and its fix. */
129
132
  function describeEntries(f: InfoFacts): string {
133
+ if (f.cacheIndexError !== null) return `unreadable: ${f.cacheIndexError}`
130
134
  return `${f.cacheEntries} (${formatBytes(f.cacheBytes)})`
131
135
  }
132
136
 
@@ -139,7 +143,7 @@ function describeSettings(settings: InfoFacts['settings']): string {
139
143
  .filter(([, s]) => s.source !== 'default')
140
144
  .map(
141
145
  ([path, s]) =>
142
- `${path} ${String(s.value)} (${s.source === 'env' ? envName(path) : '.vx/workspace'})`,
146
+ `${path} ${String(s.value)} (${s.source === 'env' ? envName(path) : '.vx/workspace'}${s.refused === undefined ? '' : `, refused: ${s.refused}`})`,
143
147
  )
144
148
  .join(', ')
145
149
  }
@@ -169,9 +173,11 @@ function describeSandbox(s: InfoFacts['sandbox']): string {
169
173
  /** `4 — the CPU count`, `2 — cgroup CPU quota 2 of 8 cores`, `8 — .vx/workspace.ts (4 cores)`. */
170
174
  export function describeWorkers(w: InfoFacts['workers']): string {
171
175
  const quota = w.cpuQuota === null ? '' : `, cgroup CPU quota ${trimCores(w.cpuQuota)}`
172
- if (w.source === 'workspace') return `${w.count} — .vx/workspace.ts (${w.cores} cores${quota})`
176
+ const cores = `${w.cores} core${w.cores === 1 ? '' : 's'}`
177
+ if (w.source === 'workspace') return `${w.count} — .vx/workspace.ts (${cores}${quota})`
178
+ if (w.source === 'env') return `${w.count} — ${envName('run.concurrency')} (${cores}${quota})`
173
179
  if (w.source === 'cgroup')
174
- return `${w.count} — cgroup CPU quota ${trimCores(w.cpuQuota ?? w.count)} of ${w.cores} cores`
180
+ return `${w.count} — cgroup CPU quota ${trimCores(w.cpuQuota ?? w.count)} of ${cores}`
175
181
  return `${w.count} — the CPU count${quota}`
176
182
  }
177
183