@vzn/vx 0.0.631 → 0.0.633

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 (86) hide show
  1. package/README.md +21 -18
  2. package/docs/caching.md +2247 -0
  3. package/docs/cli.md +3214 -0
  4. package/docs/execution.md +708 -0
  5. package/docs/features.md +172 -0
  6. package/docs/patterns.md +219 -0
  7. package/docs/schema.md +2137 -0
  8. package/docs/security.md +209 -0
  9. package/package.json +14 -5
  10. package/schemas/cache.json +24 -0
  11. package/schemas/docs.json +28 -0
  12. package/schemas/error.json +23 -0
  13. package/schemas/info.json +147 -0
  14. package/schemas/init.json +57 -0
  15. package/schemas/last.json +188 -0
  16. package/schemas/lock.json +21 -0
  17. package/schemas/plan.json +88 -0
  18. package/schemas/show.json +124 -0
  19. package/schemas/summary.json +133 -0
  20. package/schemas/why.json +168 -0
  21. package/skills/vx/SKILL.md +89 -0
  22. package/src/bin.ts +9 -0
  23. package/src/cache/archive.ts +153 -51
  24. package/src/cache/cache.ts +542 -137
  25. package/src/cache/chained-cache.ts +9 -0
  26. package/src/cache/inputs.ts +5 -0
  27. package/src/cache/layer.ts +10 -5
  28. package/src/cache/layered-cache.ts +4 -0
  29. package/src/cache/schema.ts +44 -17
  30. package/src/cache/tar-stream.ts +204 -66
  31. package/src/cache/zstd.ts +1 -1
  32. package/src/cli/cache.ts +14 -14
  33. package/src/cli/docs-corpus.ts +21 -0
  34. package/src/cli/docs-text.d.ts +5 -0
  35. package/src/cli/docs.ts +167 -0
  36. package/src/cli/foreign-flags.ts +1 -1
  37. package/src/cli/help.ts +42 -7
  38. package/src/cli/index.ts +25 -11
  39. package/src/cli/info.ts +38 -9
  40. package/src/cli/init.ts +131 -24
  41. package/src/cli/last.ts +54 -3
  42. package/src/cli/lock.ts +43 -15
  43. package/src/cli/plan-format.ts +23 -1
  44. package/src/cli/plugin-commands.ts +3 -0
  45. package/src/cli/plugin-templates.ts +1 -1
  46. package/src/cli/run.ts +40 -21
  47. package/src/cli/show.ts +10 -7
  48. package/src/cli/upgrade.ts +19 -7
  49. package/src/cli/watch.ts +7 -2
  50. package/src/cli/why.ts +14 -40
  51. package/src/exec/runner.ts +27 -10
  52. package/src/graph/scheduler.ts +36 -0
  53. package/src/graph/task-graph.ts +4 -4
  54. package/src/index.ts +22 -5
  55. package/src/orchestrator/admission.ts +29 -1
  56. package/src/orchestrator/affected-tasks.ts +87 -18
  57. package/src/orchestrator/doctor.ts +14 -1
  58. package/src/orchestrator/execute-task.ts +149 -64
  59. package/src/orchestrator/failure-mode.ts +5 -3
  60. package/src/orchestrator/framed-output.ts +17 -3
  61. package/src/orchestrator/index.ts +11 -0
  62. package/src/orchestrator/logger.ts +57 -6
  63. package/src/orchestrator/metrics.ts +76 -4
  64. package/src/orchestrator/miss-reason.ts +3 -5
  65. package/src/orchestrator/miss-save.ts +6 -5
  66. package/src/orchestrator/options.ts +12 -0
  67. package/src/orchestrator/output-log.ts +14 -1
  68. package/src/orchestrator/path-links.ts +79 -0
  69. package/src/orchestrator/plan.ts +20 -3
  70. package/src/orchestrator/plugin.ts +7 -0
  71. package/src/orchestrator/prepare.ts +14 -4
  72. package/src/orchestrator/projects.ts +2 -2
  73. package/src/orchestrator/run-artifacts.ts +18 -2
  74. package/src/orchestrator/run-failures.ts +192 -0
  75. package/src/orchestrator/run-records.ts +10 -8
  76. package/src/orchestrator/run.ts +87 -22
  77. package/src/orchestrator/task-hash.ts +4 -4
  78. package/src/orchestrator/terminal-signals.ts +16 -0
  79. package/src/orchestrator/upstream.ts +65 -10
  80. package/src/util/errors.ts +55 -1
  81. package/src/util/index.ts +3 -0
  82. package/src/util/secret-mask.ts +13 -1
  83. package/src/util/verbs.ts +1 -0
  84. package/src/workspace/config-schema.ts +10 -8
  85. package/src/workspace/migration.ts +30 -3
  86. package/src/workspace/workspace.ts +1 -0
@@ -0,0 +1,167 @@
1
+ // `vx docs <query> [--limit N] [--format pretty|json]` — search vx's
2
+ // reference offline, for an agent that cannot reach the site: each page is
3
+ // cut at its headings, and the sections holding every query word print
4
+ // whole, best first.
5
+
6
+ import { flagHint, formatValue, refuse, refusedWord, seeHelp } from './help.js'
7
+
8
+ const SITE = 'https://vznjs.github.io/vx/'
9
+ const DEFAULT_LIMIT = 3
10
+
11
+ interface DocsArgs {
12
+ terms: string[]
13
+ limit: number
14
+ json: boolean
15
+ error?: string
16
+ }
17
+
18
+ function parseDocsArgs(args: readonly string[]): DocsArgs {
19
+ const out: DocsArgs = { terms: [], limit: DEFAULT_LIMIT, json: false }
20
+ for (let i = 0; i < args.length; i++) {
21
+ const a = args[i]!
22
+ if (a === '--format' || a.startsWith('--format=')) {
23
+ const fv = formatValue(a === '--format' ? args[++i] : a.slice('--format='.length), 'docs')
24
+ if (typeof fv === 'object') return { ...out, error: fv.error }
25
+ out.json = fv === 'json'
26
+ } else if (a === '--limit' || a.startsWith('--limit=')) {
27
+ const v = a === '--limit' ? args[++i] : a.slice('--limit='.length)
28
+ const n = Number(v)
29
+ if (!Number.isInteger(n) || n < 1) {
30
+ return {
31
+ ...out,
32
+ error: `--limit takes a whole number above 0 (got ${v ?? 'nothing'})${seeHelp('docs')}`,
33
+ }
34
+ }
35
+ out.limit = n
36
+ } else if (a.startsWith('-')) {
37
+ return { ...out, error: `${refusedWord(a)}: ${a}${flagHint('docs', a)}${seeHelp('docs')}` }
38
+ } else out.terms.push(...words(a))
39
+ }
40
+ return out
41
+ }
42
+
43
+ export interface DocsHit {
44
+ page: string
45
+ heading: string
46
+ url: string
47
+ text: string
48
+ }
49
+
50
+ function words(s: string): string[] {
51
+ return s.toLowerCase().match(/[a-z0-9][a-z0-9._-]*/g) ?? []
52
+ }
53
+
54
+ /** GitHub's heading anchor, which Starlight shares: lowercase, punctuation dropped, spaces to `-`. */
55
+ function anchor(heading: string): string {
56
+ return heading
57
+ .toLowerCase()
58
+ .replace(/[^\p{L}\p{N} _-]/gu, '')
59
+ .replace(/ /g, '-')
60
+ }
61
+
62
+ /** A page's URL on the site; `features.md` is served as `all-features/`. */
63
+ function pageUrl(page: string): string {
64
+ return `${SITE}${page === 'features' ? 'all-features' : page}/`
65
+ }
66
+
67
+ interface Section {
68
+ page: string
69
+ heading: string
70
+ text: string
71
+ }
72
+
73
+ /** Cut a page at its headings (outside code fences); the text before the first is the page's own. */
74
+ function sections(page: string, md: string): Section[] {
75
+ const out: Section[] = []
76
+ let heading = ''
77
+ let lines: string[] = []
78
+ let fence = false
79
+ const flush = () => {
80
+ const text = lines.join('\n').trim()
81
+ if (text !== '') out.push({ page, heading, text })
82
+ }
83
+ for (const line of md.split('\n')) {
84
+ if (/^\s*(```|~~~)/.test(line)) fence = !fence
85
+ const h = fence ? null : /^#{1,4} (.+)$/.exec(line)
86
+ if (h !== null) {
87
+ flush()
88
+ heading = h[1]!.replace(/`/g, '').trim()
89
+ lines = [line]
90
+ } else lines.push(line)
91
+ }
92
+ flush()
93
+ return out
94
+ }
95
+
96
+ function count(hay: string, term: string): number {
97
+ let n = 0
98
+ for (let i = hay.indexOf(term); i !== -1; i = hay.indexOf(term, i + term.length)) n++
99
+ return n
100
+ }
101
+
102
+ /** The sections holding every term, best first: a term in the heading outweighs any count in the text. */
103
+ export function searchDocs(
104
+ pages: Readonly<Record<string, string>>,
105
+ terms: readonly string[],
106
+ limit: number,
107
+ ): DocsHit[] {
108
+ const scored: { s: Section; score: number; order: number }[] = []
109
+ let order = 0
110
+ for (const [page, md] of Object.entries(pages)) {
111
+ for (const s of sections(page, md)) {
112
+ order++
113
+ const head = s.heading.toLowerCase()
114
+ const body = s.text.toLowerCase()
115
+ let score = 0
116
+ let all = true
117
+ for (const t of terms) {
118
+ const inBody = count(body, t)
119
+ if (inBody === 0) all = false
120
+ score += (head.includes(t) ? 10 : 0) + Math.min(inBody, 5)
121
+ }
122
+ if (all) scored.push({ s, score, order })
123
+ }
124
+ }
125
+ scored.sort((a, b) => b.score - a.score || a.order - b.order)
126
+ return scored.slice(0, limit).map(({ s }) => ({
127
+ page: s.page,
128
+ heading: s.heading,
129
+ url: s.heading === '' ? pageUrl(s.page) : `${pageUrl(s.page)}#${anchor(s.heading)}`,
130
+ text: s.text,
131
+ }))
132
+ }
133
+
134
+ export async function docsCmd(args: readonly string[]): Promise<number> {
135
+ const parsed = parseDocsArgs(args)
136
+ if (parsed.error) return refuse('docs', args, parsed.error, 'VX_E_USAGE')
137
+ if (parsed.terms.length === 0) {
138
+ return refuse(
139
+ 'docs',
140
+ args,
141
+ `name what to look for, as in \`vx docs cache inputs\`${seeHelp('docs')}`,
142
+ 'VX_E_USAGE',
143
+ )
144
+ }
145
+ const { PAGES } = await import('./docs-corpus.js')
146
+ const hits = searchDocs(PAGES, parsed.terms, parsed.limit)
147
+ const query = parsed.terms.join(' ')
148
+ if (parsed.json) {
149
+ process.stdout.write(`${JSON.stringify({ query, hits })}\n`)
150
+ return 0
151
+ }
152
+ if (hits.length === 0) {
153
+ process.stdout.write(
154
+ `vx docs: no section of ${Object.keys(PAGES).join(', ')} holds every word of "${query}"; try fewer words, or ${SITE}llms.txt\n`,
155
+ )
156
+ return 0
157
+ }
158
+ process.stdout.write(
159
+ hits
160
+ .map(
161
+ (h) =>
162
+ `── ${h.page}.md${h.heading === '' ? '' : ` § ${h.heading}`} · ${h.url}\n\n${h.text}\n`,
163
+ )
164
+ .join('\n'),
165
+ )
166
+ return 0
167
+ }
@@ -361,7 +361,7 @@ export const FOREIGN_FLAGS: readonly ForeignFlag[] = [
361
361
  names: ['--json'],
362
362
  value: false,
363
363
  outcome: 'refuse',
364
- vx: "use `--dry=json` for the plan, `--summarize[=<path>]` for the run's JSON record",
364
+ vx: "use `--format json` for the run's result on stdout, `--dry=json` for the plan",
365
365
  },
366
366
  {
367
367
  runner: 'turbo',
package/src/cli/help.ts CHANGED
@@ -1,5 +1,5 @@
1
1
  import type { ColorSupport, paint as Paint } from '../orchestrator/index.js'
2
- import { CORE_VERBS, nearest } from '../util/index.js'
2
+ import { CORE_VERBS, errorDocument, maskedLine, nearest, wantsJson } from '../util/index.js'
3
3
 
4
4
  export async function printHelp(
5
5
  pluginCommands: readonly string[] = [],
@@ -108,13 +108,14 @@ export function helpText(pluginCommands: readonly string[] = []): string {
108
108
  ' vx run [OPTIONS] [TASK | PKG#TASK ...] [-- forwarded-args...]',
109
109
  ' vx watch [OPTIONS] TASK [-- forwarded-args...]',
110
110
  ' vx cache prune [--older-than <duration>] [--max-size <size>] [--dry-run] [--format pretty|json] [--cache-dir <path>]',
111
- ' vx lock [--check]',
112
- ' vx init [--dry] [--force] [--mjs] [--plugin <seam>]',
111
+ ' vx lock [--check] [--format pretty|json]',
112
+ ' vx init [--dry [--format pretty|json]] [--force] [--mjs] [--native|--keep] [--plugin <seam>]',
113
113
  ' vx upgrade [TAG]',
114
114
  ' vx show [PROJECT[#TASK] | TASK] [--filter <pattern>] [--affected[=<ref>]] [--format pretty|json]',
115
115
  ' vx info [--format pretty|json] [--cache-dir <path>]',
116
116
  ' vx why (TASK | PKG#TASK) [--run RUNID] [--format pretty|json] [--cache-dir <path>]',
117
- ' vx last [RUNID] [--list[=N]] [--failed] [--format pretty|json] [--cache-dir <path>]',
117
+ ' vx last [RUNID] [--list[=N]] [--failed] [--log <task>] [--format pretty|json] [--cache-dir <path>]',
118
+ ' vx docs <query> [--limit N] [--format pretty|json]',
118
119
  ' vx completions bash|zsh|fish',
119
120
  ' vx help [VERB]',
120
121
  ' vx version',
@@ -161,6 +162,8 @@ export function helpText(pluginCommands: readonly string[] = []): string {
161
162
  ' --graph[=<path>] Emit Graphviz DOT (stdout if no path).',
162
163
  '',
163
164
  'Artifacts (for run):',
165
+ ' --format <fmt> pretty (default) | json: print the --summarize document',
166
+ ' on stdout when the run ends; every other line goes to stderr.',
164
167
  ' --summarize[=<path>] Write per-run JSON to <cacheDir>/runs/<run_id>.json.',
165
168
  ' --profile[=<path>] Write Chrome-trace JSON (default profile.json).',
166
169
  ' --report[=markdown] Print a markdown run report to stdout after the run.',
@@ -227,6 +230,8 @@ export function helpText(pluginCommands: readonly string[] = []): string {
227
230
  ' table, failures first. No re-execution, no cache probe.',
228
231
  ' --list[=N] List the N most recent runs (default 10) with run ids.',
229
232
  ' --failed Only failed runs: the latest one, or with --list the list.',
233
+ " --log <task> That task's output in the run (latest that ran it): a failure's",
234
+ ' or the cached log; none for an uncached pass.',
230
235
  ' --format <fmt> pretty (default) | json.',
231
236
  ' info, why and last:',
232
237
  ' --cache-dir <path> Read the cache a run with the same flag uses.',
@@ -235,14 +240,17 @@ export function helpText(pluginCommands: readonly string[] = []): string {
235
240
  ' vx init Generate vx.workspace.ts + one vx.config.ts per package',
236
241
  ' from package.json scripts. Unmappable settings become',
237
242
  ' TODO(vx-migrate) comments. Beside turbo.json or nx.json:',
238
- ' only vx.workspace.ts, declaring turbo() or nx().',
243
+ ' runs @vzn/vx-migrate, which asks native or keep.',
239
244
  ' --dry Print the generated files instead of writing them.',
245
+ ' --format json With --dry: the plan as one JSON document (schemas/init.json).',
240
246
  ' --force Overwrite existing vx.config.* files.',
241
247
  ' --mjs Write .mjs files (no type import) instead of .ts.',
248
+ ' --native Turbo or Nx: write a vx.config.ts per package (no question).',
249
+ ' --keep Turbo or Nx: only vx.workspace.ts, declaring turbo() or nx().',
242
250
  ' --plugin <seam> Instead: write plugins/<seam>.ts, a runnable plugin, and its',
243
251
  ' test (executor, cache, telemetry, schedule, admit, commands,',
244
252
  ' project, graph, key).',
245
- ' From turbo.json or an Nx project graph: `bunx @vzn/vx-migrate` (its own package).',
253
+ ' From turbo.json or an Nx project graph: `vx init` runs `@vzn/vx-migrate` (its own package).',
246
254
  '',
247
255
  'Shell completions:',
248
256
  ' vx completions <shell> Print a completion script for bash, zsh or fish: the',
@@ -250,6 +258,13 @@ export function helpText(pluginCommands: readonly string[] = []): string {
250
258
  ' every flag of each. `eval "$(vx completions bash)"`,',
251
259
  ' or write it where the shell loads completions from.',
252
260
  '',
261
+ 'Docs:',
262
+ ' vx docs <query> Search the reference offline (cli, schema, caching, execution,',
263
+ ' patterns, security, features): the sections holding every',
264
+ ' word of the query print whole, best first, with their URL.',
265
+ ' --limit N How many sections (default 3).',
266
+ ' --format json {query, hits: [{page, heading, url, text}]}.',
267
+ '',
253
268
  'Config lock:',
254
269
  ' vx lock Evaluate every vx.config in the current environment and',
255
270
  ' freeze the resolved objects into vx-lock.json. Plain runs',
@@ -258,7 +273,8 @@ export function helpText(pluginCommands: readonly string[] = []): string {
258
273
  ' vx lock --check Audit the lock: file-changed hash report PLUS a full',
259
274
  ' re-evaluation compared against the frozen objects. Exits 1',
260
275
  ' on drift (e.g. configs that read env vars). Run it before',
261
- ' `vx run --frozen` in CI.',
276
+ ' `vx run --frozen` in CI. With --format json it prints',
277
+ ' {upToDate, audited, notAudited, problems}.',
262
278
  '',
263
279
  ...(pluginCommands.length > 0 ? ['Plugin commands:', ...pluginCommands, ''] : []),
264
280
  ].join('\n')
@@ -353,6 +369,7 @@ export const WATCH_REFUSED_FLAGS: readonly string[] = [
353
369
  '--report',
354
370
  '--report-file',
355
371
  '--verbosity',
372
+ '--format',
356
373
  ]
357
374
 
358
375
  /**
@@ -380,3 +397,21 @@ export function acceptedFlags(verb: string): string[] {
380
397
  }
381
398
 
382
399
  export { CORE_VERBS } from '../util/index.js'
400
+
401
+ /**
402
+ * A refusal a verb prints itself rather than throws: one line on stderr,
403
+ * exit 1, and under `--format json` / `--dry=json` the same as the error
404
+ * document on stdout that `bin.ts` prints for a thrown one (cli.md § Error
405
+ * codes), so an agent reads a code.
406
+ */
407
+ export function refuse(
408
+ verb: string,
409
+ args: readonly string[],
410
+ message: string,
411
+ code: string,
412
+ ): number {
413
+ const line = `vx ${verb}: ${message}`
414
+ process.stderr.write(`${line}\n`)
415
+ if (wantsJson(args)) process.stdout.write(errorDocument(code, maskedLine(line)))
416
+ return 1
417
+ }
package/src/cli/index.ts CHANGED
@@ -4,7 +4,15 @@
4
4
  import { VERSION } from '../version.js'
5
5
  import { CORE_VERBS, flagHint, printHelp, refusedWord, seeHelp } from './help.js'
6
6
  import { FOREIGN_VERBS } from './foreign-flags.js'
7
- import { isUserError, MOVED_VERBS, nearest, UserError } from '../util/index.js'
7
+ import {
8
+ errorDocument,
9
+ isUserError,
10
+ maskedLine,
11
+ MOVED_VERBS,
12
+ nearest,
13
+ UserError,
14
+ wantsJson,
15
+ } from '../util/index.js'
8
16
 
9
17
  // Every verb is imported when invoked, `run` included, and so is the
10
18
  // plugin-verb lookup: each pulls in the orchestrator and the workspace
@@ -101,6 +109,8 @@ export async function run(argv: readonly string[]): Promise<number> {
101
109
  return await (await import('./cache.js')).cacheCmd(rest)
102
110
  case 'lock':
103
111
  return await (await import('./lock.js')).lockCmd(rest)
112
+ case 'docs':
113
+ return await (await import('./docs.js')).docsCmd(rest)
104
114
  case 'init':
105
115
  return await (await import('./init.js')).initCmd(rest)
106
116
  case 'upgrade':
@@ -151,6 +161,14 @@ export async function run(argv: readonly string[]): Promise<number> {
151
161
  // both things: the verb is unknown HERE, and why the lookup could not
152
162
  // finish — a typo still reads as a typo, and a real plugin verb still
153
163
  // points at the file that broke it.
164
+ // Every answer below is "no such verb here": under --format json the
165
+ // agent reads it as one code, as a verb's own refusal gives one.
166
+ const unknown = (text: string, code = 'VX_E_UNKNOWN_COMMAND'): number => {
167
+ process.stderr.write(text)
168
+ if (wantsJson(rest))
169
+ process.stdout.write(errorDocument(code, maskedLine(text.split('\n')[0]!)))
170
+ return 1
171
+ }
154
172
  const loadNote =
155
173
  resolved !== null && 'loadError' in resolved
156
174
  ? `\n (plugin verbs could not be looked up: vx.workspace failed to load: ${resolved.loadError})`
@@ -160,8 +178,7 @@ export async function run(argv: readonly string[]): Promise<number> {
160
178
  // workspace that declares a plugin verb of that name keeps it.
161
179
  const moved = MOVED_VERBS[command] ?? FOREIGN_VERBS[command]
162
180
  if (moved !== undefined) {
163
- process.stderr.write(`${moved}${loadNote}\n`)
164
- return 1
181
+ return unknown(`${moved}${loadNote}\n`)
165
182
  }
166
183
  // A task typed where the verb goes (`turbo build`, `nx build app`),
167
184
  // `turbo dev` included: a repo's own `dev` task beats the no-service note.
@@ -176,8 +193,7 @@ export async function run(argv: readonly string[]): Promise<number> {
176
193
  ).taskVerbHint(command, rest, process.cwd(), guess === '' && !command.startsWith('-'))
177
194
  : null
178
195
  if (task !== null) {
179
- process.stderr.write(`vx: ${task}\n`)
180
- return 1
196
+ return unknown(`vx: ${task}\n`)
181
197
  }
182
198
  if (command === 'serve' || command === 'dev') {
183
199
  // vx core is only a task runner — it has no service layer of its
@@ -186,13 +202,12 @@ export async function run(argv: readonly string[]): Promise<number> {
186
202
  // We keep this neutral hint for the common muscle-memory verbs, but
187
203
  // core names no specific plugin package: any package can provide
188
204
  // these.
189
- process.stderr.write(
205
+ return unknown(
190
206
  `vx: '${command}' is not a vx core command.\n` +
191
207
  ` vx core runs tasks in-process. A dashboard, remote cache, and\n` +
192
208
  ` distributed execution come from plugins — not core. See the plugin\n` +
193
209
  ` guide: https://vznjs.github.io/vx/guides/plugins/\n`,
194
210
  )
195
- return 1
196
211
  }
197
212
  // One line, as a verb's own unknown flag or subcommand gets; the full
198
213
  // help after a typo was a hundred lines past the hint that mattered.
@@ -208,16 +223,15 @@ export async function run(argv: readonly string[]): Promise<number> {
208
223
  if (command.startsWith('-')) {
209
224
  // `-v` / `-V` is another tool's version flag, far from both names.
210
225
  const flag = /^-v$/i.test(command) ? '--version' : nearest(command, ['--help', '--version'])
211
- process.stderr.write(
226
+ return unknown(
212
227
  `vx: unknown flag: ${command}${flag === undefined ? '' : ` (did you mean ${flag}?)`}; a verb's flags follow the verb (see \`vx help\`)\n`,
228
+ 'VX_E_USAGE',
213
229
  )
214
- return 1
215
230
  }
216
- process.stderr.write(
231
+ return unknown(
217
232
  `vx: unknown command: ${command}${guess}${loadNote} (see \`vx help\`)\n` +
218
233
  (guess === '' && loadNote === '' ? verbSourceNote(resolved, declaredVerbs) : ''),
219
234
  )
220
- return 1
221
235
  }
222
236
  }
223
237
  }
package/src/cli/info.ts CHANGED
@@ -4,8 +4,9 @@
4
4
  // from the orchestrator's `collectInfo` (doctor.ts), which `vx mcp` reads
5
5
  // too; this file parses the flags and renders the rows.
6
6
 
7
+ import os from 'node:os'
7
8
  import { collectInfo, type FlakyTask, type InfoFacts } from '../orchestrator/index.js'
8
- import { flagHint, formatValue, refusedWord, seeHelp } from './help.js'
9
+ import { flagHint, formatValue, refuse, refusedWord, seeHelp } from './help.js'
9
10
  import { namedCacheDir, parseCacheDirFlag, warnToStderr } from './workspace-config.js'
10
11
  import { formatBytes } from './format.js'
11
12
  import { MIN_BUN } from '../util/index.js'
@@ -44,10 +45,7 @@ export function parseInfoArgs(args: readonly string[]): InfoArgs {
44
45
 
45
46
  export async function infoCmd(args: readonly string[]): Promise<number> {
46
47
  const parsed = parseInfoArgs(args)
47
- if (parsed.error) {
48
- process.stderr.write(`vx info: ${parsed.error}\n`)
49
- return 1
50
- }
48
+ if (parsed.error) return refuse('info', args, parsed.error, 'VX_E_USAGE')
51
49
  // `collectInfo` takes a resolved directory from `vx mcp` too, where one
52
50
  // not made yet is a workspace that never ran; the flag is the user's,
53
51
  // and a directory it names that is not there is a typo (item 900).
@@ -75,7 +73,7 @@ export function renderInfo(f: InfoFacts): string {
75
73
  ],
76
74
  ['git', f.git ?? '(not found)'],
77
75
  ['git status cache', renderGitStatusCache(f.gitStatusCache)],
78
- ['workspace root', f.workspaceRoot],
76
+ ['workspace root', tildePath(f.workspaceRoot)],
79
77
  ['projects', describeProjects(f)],
80
78
  // Only when a config did not load: the count above says "zero" for
81
79
  // it, and this row says which and why.
@@ -88,10 +86,16 @@ export function renderInfo(f: InfoFacts): string {
88
86
  ['plugins', describePlugins(f.plugins)],
89
87
  ['workers', describeWorkers(f.workers)],
90
88
  ['memory', describeMemory(f.memory)],
91
- ['cache dir', f.cacheDir],
92
- ['cache store', f.cacheStore ?? 'none: the cache dir holds the entries'],
89
+ ['cache dir', tildePath(f.cacheDir)],
90
+ [
91
+ 'cache store',
92
+ f.cacheStore === null ? 'none: the cache dir holds the entries' : tildePath(f.cacheStore),
93
+ ],
93
94
  ['cache versions', `keys ${f.cacheVersion} · index schema ${f.schemaVersion}`],
94
- ['cache entries', `${f.cacheEntries} (${formatBytes(f.cacheBytes)})`],
95
+ ['cache entries', describeEntries(f)],
96
+ ...(f.cacheRetention !== null
97
+ ? ([['cache retention', describeRetention(f.cacheRetention)]] as [string, string][])
98
+ : []),
95
99
  // Only when there is something to say.
96
100
  ...(f.orphans.artifacts > 0
97
101
  ? ([
@@ -118,6 +122,31 @@ export function renderInfo(f: InfoFacts): string {
118
122
  return rows.map(([label, value]) => `${`${label}:`.padEnd(labelW + 1)} ${value}`).join('\n')
119
123
  }
120
124
 
125
+ /** `812 (1.2 GB)`, or against `cacheRetention.maxSize`: `812 (1.2 GB of 10 GB, 12%)`. */
126
+ function describeEntries(f: InfoFacts): string {
127
+ const max = f.cacheRetention?.maxBytes
128
+ const size =
129
+ max === undefined
130
+ ? formatBytes(f.cacheBytes)
131
+ : `${formatBytes(f.cacheBytes)} of ${formatBytes(max)}, ${Math.round((f.cacheBytes / max) * 100)}%`
132
+ return `${f.cacheEntries} (${size})`
133
+ }
134
+
135
+ /** What a run's end evicts: `entries unused for 30d, and the oldest entries past 10G`. */
136
+ function describeRetention(r: NonNullable<InfoFacts['cacheRetention']>): string {
137
+ const parts = [
138
+ ...(r.olderThan !== undefined ? [`entries unused for ${r.olderThan}`] : []),
139
+ ...(r.maxSize !== undefined ? [`the oldest entries past ${r.maxSize}`] : []),
140
+ ]
141
+ return `each run evicts ${parts.join(', and ')}`
142
+ }
143
+
144
+ /** A path under the home directory as `~/…`: shorter, and no user name in a pasted report. */
145
+ function tildePath(p: string): string {
146
+ const home = os.homedir()
147
+ return p === home ? '~' : p.startsWith(`${home}/`) ? `~${p.slice(home.length)}` : p
148
+ }
149
+
121
150
  /**
122
151
  * `available (3 tasks declare exec.sandbox)`, with `, untraced — <why>, so
123
152
  * the reads it denies go unreported` where no strace can trace, or