@vzn/vx 0.0.632 → 0.0.634

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (48) hide show
  1. package/README.md +14 -10
  2. package/docs/caching.md +2250 -0
  3. package/docs/cli.md +3283 -0
  4. package/docs/execution.md +708 -0
  5. package/docs/features.md +174 -0
  6. package/docs/patterns.md +219 -0
  7. package/docs/schema.md +2151 -0
  8. package/docs/security.md +209 -0
  9. package/package.json +12 -5
  10. package/schemas/docs.json +28 -0
  11. package/schemas/error.json +5 -1
  12. package/schemas/init.json +57 -0
  13. package/schemas/lock.json +21 -0
  14. package/schemas/plan.json +4 -0
  15. package/skills/vx/SKILL.md +13 -3
  16. package/src/cache/cache.ts +556 -122
  17. package/src/cache/chained-cache.ts +9 -0
  18. package/src/cache/inputs.ts +71 -30
  19. package/src/cache/layer.ts +8 -4
  20. package/src/cache/layered-cache.ts +4 -0
  21. package/src/cache/schema.ts +42 -16
  22. package/src/cli/docs-corpus.ts +21 -0
  23. package/src/cli/docs-text.d.ts +5 -0
  24. package/src/cli/docs.ts +173 -0
  25. package/src/cli/help.ts +14 -3
  26. package/src/cli/index.ts +2 -0
  27. package/src/cli/init.ts +44 -8
  28. package/src/cli/lock.ts +43 -15
  29. package/src/cli/plan-format.ts +3 -1
  30. package/src/cli/run.ts +8 -2
  31. package/src/cli/upgrade.ts +18 -6
  32. package/src/config.ts +9 -0
  33. package/src/graph/priorities.ts +72 -6
  34. package/src/graph/scheduler.ts +17 -4
  35. package/src/graph/task-graph.ts +56 -18
  36. package/src/orchestrator/metrics.ts +11 -4
  37. package/src/orchestrator/miss-reason.ts +3 -5
  38. package/src/orchestrator/miss-save.ts +6 -5
  39. package/src/orchestrator/projects.ts +2 -2
  40. package/src/orchestrator/remote-prefetch.ts +8 -2
  41. package/src/orchestrator/run.ts +13 -1
  42. package/src/orchestrator/task-hash.ts +4 -4
  43. package/src/util/errors.ts +9 -2
  44. package/src/util/paths.ts +32 -0
  45. package/src/util/verbs.ts +1 -0
  46. package/src/workspace/config-schema.ts +26 -8
  47. package/src/workspace/lockfile.ts +29 -2
  48. package/src/workspace/migration.ts +30 -3
@@ -12,6 +12,7 @@
12
12
  // (item 1020). A save that fails in EVERY layer still throws, so the
13
13
  // caller's own "cache save failed" line stays the one word on it.
14
14
 
15
+ import { statSync } from 'node:fs'
15
16
  import type {
16
17
  Cache,
17
18
  CacheEntry,
@@ -248,6 +249,14 @@ export class ChainedCache implements CacheLayer {
248
249
  return this.owner(hash).outputsPath(hash)
249
250
  }
250
251
 
252
+ /** A layer that cannot say keeps a file at `outputsPath`, or nothing. */
253
+ artifactSize(hash: string): number | undefined {
254
+ const owner = this.owner(hash)
255
+ return owner.artifactSize !== undefined
256
+ ? owner.artifactSize(hash)
257
+ : statSync(owner.outputsPath(hash), { throwIfNoEntry: false })?.size
258
+ }
259
+
251
260
  prune(options: PruneOptions): Promise<PruneResult> {
252
261
  return this.layers[0]!.prune(options)
253
262
  }
@@ -92,6 +92,11 @@ const ALWAYS_IGNORE = [
92
92
  // extracts the downloaded runtime into, `<cwd>/.<16 hex>-<8 hex>.tmp/`,
93
93
  // 60 MB mid-write under the same race (2026-09-29).
94
94
  '**/.????????????????-????????.tmp/**',
95
+ // vx's own restore temp (archive.ts), live only while an artifact lands
96
+ // beside its target. A key taken meanwhile listed it, the rename removed
97
+ // it, and every task reading that directory was withheld its save
98
+ // (withastro/astro: 25 of 33 builds re-ran on a restore, X-209).
99
+ '**/.vx-tmp-*',
95
100
  ]
96
101
 
97
102
  const DEFAULT_FILE_GLOBS: readonly string[] = ['**/*']
@@ -214,7 +219,7 @@ export async function resolveInputs(args: ResolveInputsArgs): Promise<ResolvedIn
214
219
  // Dedupe: when the project dir IS the workspace root (or a glob
215
220
  // overlaps), the same absolute path can arrive via both lists —
216
221
  // it must contribute to the key exactly once.
217
- if (wsFiles.length > 0) files = [...new Set([...projectFiles, ...wsFiles])].sort()
222
+ if (wsFiles.length > 0) files = mergeSorted(projectFiles, wsFiles)
218
223
  }
219
224
  // Two awaited empty resolutions per task were ~3 ms of a 1000-task warm
220
225
  // run (profiled 2026-09-09); a task declaring neither skips the fan-out.
@@ -280,7 +285,6 @@ function resolveWorkspaceFiles(args: {
280
285
  }
281
286
  if (positive.length === 0) return undefined
282
287
 
283
- const isExcluded = anyTaskGlob([...ALWAYS_IGNORE, ...asTrees(negative)])
284
288
  // A path the task's own outputs take back with `!` is no output, so it
285
289
  // stays an input (A-44).
286
290
  const ownOutput = outputMatcher(args.ownWorkspaceOutputs)
@@ -305,40 +309,44 @@ function resolveWorkspaceFiles(args: {
305
309
  // The memo is valid for the snapshot it was computed over: a task that
306
310
  // wrote workspace outputs mid-run replaces the partition, and the next
307
311
  // caller sees a different array and scans again.
308
- const memoKey =
309
- args.memo === undefined
310
- ? undefined
311
- : JSON.stringify([
312
- positive,
313
- negative,
314
- args.ownWorkspaceOutputs,
315
- args.ownOutputs,
316
- args.projectRel,
317
- ])
312
+ let isExcluded: ((rel: string) => boolean) | undefined
318
313
  let isPositive: ((rel: string) => boolean) | undefined
319
- const excluded = (rel: string): boolean => isExcluded(rel) || ownOutput(rel) || ownProject(rel)
314
+ const excludedByDeclaration = (rel: string): boolean =>
315
+ (isExcluded ??= anyTaskGlob([...ALWAYS_IGNORE, ...asTrees(negative)]))(rel)
316
+ const own = (rel: string): boolean => ownOutput(rel) || ownProject(rel)
320
317
  const listing: InputListing = {
321
318
  root: args.workspaceRoot,
322
319
  listed: gitFiles,
323
- isInput: (rel) => (isPositive ??= anyTaskGlob(asTrees(positive)))(rel) && !excluded(rel),
320
+ isInput: (rel) =>
321
+ (isPositive ??= anyTaskGlob(asTrees(positive)))(rel) &&
322
+ !excludedByDeclaration(rel) &&
323
+ !own(rel),
324
324
  nested: () => false,
325
325
  ...reachOf(positive),
326
326
  }
327
- if (memoKey !== undefined) {
328
- const hit = args.memo!.get(memoKey)
329
- if (hit !== undefined && hit.snapshot === gitFiles) return { files: hit.result, listing }
327
+ // Two levels: the declaration's files over the snapshot, shared by every
328
+ // task that declares it, then the task's own outputs taken out. A
329
+ // Turbo-mapped repo gives two thousand tasks one 400-entry list, and a
330
+ // memo keyed by the project too scanned, resolved and sorted the 4,000
331
+ // files it matched once per task (astro, 2026-10-09).
332
+ const declared = JSON.stringify([positive, negative])
333
+ let base: Promise<string[]> | undefined
334
+ const hit = args.memo?.get(declared)
335
+ if (hit !== undefined && hit.snapshot === gitFiles) base = hit.result
336
+ if (base === undefined) {
337
+ isPositive ??= anyTaskGlob(asTrees(positive))
338
+ base = resolveWorkspaceFilesOver(args, gitFiles, positive, isPositive, excludedByDeclaration)
339
+ args.memo?.set(declared, { snapshot: gitFiles, result: base })
330
340
  }
331
- isPositive ??= anyTaskGlob(asTrees(positive))
332
- const result = resolveWorkspaceFilesOver(
333
- args,
334
- gitFiles,
335
- positive,
336
- isPositive,
337
- excluded,
338
- undecodable,
339
- )
340
- if (memoKey !== undefined) args.memo!.set(memoKey, { snapshot: gitFiles, result })
341
- return { files: result, listing }
341
+ const ownsAny =
342
+ splitNegations(args.ownWorkspaceOutputs).positive.length > 0 ||
343
+ splitNegations(args.ownOutputs).positive.length > 0
344
+ const files = base.then((all) => {
345
+ const kept = ownsAny ? all.filter((abs) => !own(relPosix(args.workspaceRoot, abs))) : all
346
+ refuseUndecodable(kept, undecodable, args.workspaceRoot, 'workspaceFiles')
347
+ return kept
348
+ })
349
+ return { files, listing }
342
350
  }
343
351
 
344
352
  async function resolveWorkspaceFilesOver(
@@ -347,7 +355,6 @@ async function resolveWorkspaceFilesOver(
347
355
  positive: readonly string[],
348
356
  isPositive: (rel: string) => boolean,
349
357
  excluded: (rel: string) => boolean,
350
- undecodable: ReadonlySet<string> | undefined,
351
358
  ): Promise<string[]> {
352
359
  // Second call site of the literal-input guard. `resolveWorkspaceFiles`
353
360
  // carries its own copy of the filter-over-git-set design, so the same
@@ -366,13 +373,47 @@ async function resolveWorkspaceFilesOver(
366
373
  if (unmatchedLiterals.size > 0) {
367
374
  await assertNoInvisibleLiteralInputs(unmatchedLiterals, args.workspaceRoot, 'workspaceFiles')
368
375
  }
369
- refuseUndecodable(candidates, undecodable, args.workspaceRoot, 'workspaceFiles')
370
376
  // Same OID-trust shortcut as project files: a clean-per-status
371
377
  // tracked file necessarily exists on disk.
372
378
  const oids = args.gitFilesCache?.oidsFor(args.workspaceRoot)
373
379
  return candidates.filter((abs) => oids?.has(abs) === true || isInputOnDisk(abs)).sort()
374
380
  }
375
381
 
382
+ /**
383
+ * The sorted union of two path lists, each once. Both sides arrive sorted
384
+ * and distinct, so one merge does what a Set and a sort of the
385
+ * concatenation did: 3.7 s of an astro plan, where 2,000 tasks each joined
386
+ * a few project files to the same 4,000 workspace files (2026-10-09). A
387
+ * side out of order takes the general path.
388
+ */
389
+ function mergeSorted(a: readonly string[], b: readonly string[]): string[] {
390
+ if (a.length === 0 && isStrictlySorted(b)) return b as string[]
391
+ if (!isStrictlySorted(a) || !isStrictlySorted(b)) return [...new Set([...a, ...b])].sort()
392
+ const out: string[] = []
393
+ let i = 0
394
+ let j = 0
395
+ while (i < a.length && j < b.length) {
396
+ const x = a[i]!
397
+ const y = b[j]!
398
+ if (x <= y) {
399
+ out.push(x)
400
+ i++
401
+ if (x === y) j++
402
+ } else {
403
+ out.push(y)
404
+ j++
405
+ }
406
+ }
407
+ while (i < a.length) out.push(a[i++]!)
408
+ while (j < b.length) out.push(b[j++]!)
409
+ return out
410
+ }
411
+
412
+ function isStrictlySorted(list: readonly string[]): boolean {
413
+ for (let i = 1; i < list.length; i++) if (!(list[i - 1]! < list[i]!)) return false
414
+ return true
415
+ }
416
+
376
417
  /** A project-relative matcher asked of root-relative paths: false outside the project. */
377
418
  function underProject(
378
419
  projectRel: string,
@@ -267,7 +267,7 @@ export interface InvocationRecord {
267
267
  }
268
268
 
269
269
  /**
270
- * One cache-key component row for the `entry_inputs` table — keyed by
270
+ * One cache-key component of an `entry_inputs` row (v34: one row per entry) — keyed by
271
271
  * the cache-entry HASH it belongs to, not a run. Written inside the
272
272
  * entry-save transaction (`writeArtifactAndIndex`) on a miss/save; a
273
273
  * cache HIT does not save, so it persists nothing (warm runs are free).
@@ -730,12 +730,16 @@ export interface CacheLayer {
730
730
  */
731
731
  hashFile(filePath: string): Promise<string>
732
732
  /**
733
- * Absolute path to the on-disk outputs artifact for a hash —
733
+ * Absolute path a file-backed artifact for a hash has —
734
734
  * `<cacheDir>/<hash>.tar.zst`. Returns the path whether or
735
- * not the artifact exists. Exposed for telemetry / dashboards;
736
- * `restoreOutputs` is the canonical way to materialize the bytes.
735
+ * not the artifact exists: a small artifact lives in the index, not
736
+ * in a file (`artifactSize` is its size). Exposed for telemetry /
737
+ * dashboards; `restoreOutputs` is the canonical way to materialize
738
+ * the bytes.
737
739
  */
738
740
  outputsPath(hash: string): string
741
+ /** The stored artifact's compressed size, wherever it lives; undefined when none is stored. */
742
+ artifactSize?(hash: string): number | undefined
739
743
  prune(options: PruneOptions): Promise<PruneResult>
740
744
  close(): void
741
745
  }
@@ -529,6 +529,10 @@ export class LayeredCache implements CacheLayer {
529
529
  return this.local.outputsPath(hash)
530
530
  }
531
531
 
532
+ artifactSize(hash: string): number | undefined {
533
+ return this.local.artifactSize(hash)
534
+ }
535
+
532
536
  hashFile(filePath: string): Promise<string> {
533
537
  return this.local.hashFile(filePath)
534
538
  }
@@ -1,7 +1,8 @@
1
1
  // The index's tables, one concern: what a cache.db holds. `Cache` opens the
2
2
  // file, checks `SCHEMA_VERSION` and creates these; every statement against
3
3
  // them lives in the store that owns the table (file-hashes, config-evals,
4
- // output-index, history) or in cache.ts for `entries` and `entry_inputs`.
4
+ // output-index, history) or in cache.ts for `entries`, `entry_inputs` and
5
+ // `artifacts`.
5
6
 
6
7
  import type { Database, SQLQueryBindings } from 'bun:sqlite'
7
8
 
@@ -255,22 +256,18 @@ export function createTables(db: Database, store: 'main' | 'store' = 'main'): vo
255
256
  CREATE INDEX IF NOT EXISTS main.invocations_started ON invocations(started_at);
256
257
  CREATE INDEX IF NOT EXISTS main.invocations_branch ON invocations(branch);
257
258
  CREATE INDEX IF NOT EXISTS main.invocations_ci ON invocations(ci);
258
- -- v22 (Tier 3): the input-fingerprint moat. One row per cache-key
259
- -- component, keyed by the cache-ENTRY hash it belongs to (NOT a
260
- -- run id). Written inside the entry-save transaction — only on a
261
- -- miss/save, never on a hit — so a warm all-cache-hit run writes
262
- -- nothing here (the warm path does zero extra work). The
263
- -- why-did-this-re-run diff reads two entry hashes (this run's
264
- -- runs.hash and the previous run's) and anti-joins their rows in
265
- -- SQL over (kind,name,hash); a JSON blob would force an app-side
266
- -- parse + compare on every probe. ON DELETE CASCADE keeps these
267
- -- rows in sync with entries (a prune sweeps them automatically).
259
+ -- v22 (Tier 3): the input-fingerprint moat: each cache ENTRY's key
260
+ -- components (NOT a run's). Written inside the entry-save transaction,
261
+ -- only on a miss/save, so a warm all-cache-hit run writes nothing
262
+ -- here. v34: one row per entry, the components a JSON array of
263
+ -- [kind, name, hash] in (kind, name) code-unit order. A row per
264
+ -- component cost a b-tree insert each, and a task with 50 input
265
+ -- files spent ~0.5 ms a save on them; every reader loads the whole
266
+ -- set by entry hash and diffs it app-side anyway. ON DELETE CASCADE
267
+ -- keeps it in sync with entries (a prune sweeps it automatically).
268
268
  CREATE TABLE IF NOT EXISTS ${store}.entry_inputs (
269
- entry_hash TEXT NOT NULL,
270
- kind TEXT NOT NULL,
271
- name TEXT NOT NULL,
272
- hash TEXT NOT NULL,
273
- PRIMARY KEY (entry_hash, kind, name),
269
+ entry_hash TEXT PRIMARY KEY,
270
+ components TEXT NOT NULL,
274
271
  FOREIGN KEY (entry_hash) REFERENCES entries(hash) ON DELETE CASCADE
275
272
  );
276
273
  -- v32: what belongs to the entries rather than to one workspace: the
@@ -280,9 +277,38 @@ export function createTables(db: Database, store: 'main' | 'store' = 'main'): vo
280
277
  key TEXT PRIMARY KEY,
281
278
  value TEXT NOT NULL
282
279
  );
280
+ ${artifactTables(store)}
283
281
  `)
284
282
  }
285
283
 
284
+ /**
285
+ * v33: an artifact of at most `INLINE_MAX` compressed bytes lives here, its
286
+ * exact `<hash>.tar.zst` bytes, written in the transaction that writes its
287
+ * rows. No foreign key and outside every drop a `SCHEMA_VERSION` reset makes:
288
+ * the artifact is the record and the entries only its inventory, so a reset
289
+ * drops the rows and keeps these, as it keeps the files. `artifacts_meta`
290
+ * holds the table's own layout version (`'layout'`), the one thing that may
291
+ * drop it. `at` is the last use a row-less sweep judges, as a file's mtime;
292
+ * it sits before `bytes` so reading it never walks a blob's overflow pages.
293
+ */
294
+ export function createArtifactTables(db: Database, store: 'main' | 'store'): void {
295
+ db.exec(artifactTables(store))
296
+ }
297
+
298
+ // In the entry tables' exec, so an open runs no statement more for them.
299
+ function artifactTables(store: 'main' | 'store'): string {
300
+ return `
301
+ CREATE TABLE IF NOT EXISTS ${store}.artifacts (
302
+ hash TEXT PRIMARY KEY,
303
+ at INTEGER NOT NULL,
304
+ bytes BLOB NOT NULL
305
+ ) WITHOUT ROWID;
306
+ CREATE TABLE IF NOT EXISTS ${store}.artifacts_meta (
307
+ key TEXT PRIMARY KEY,
308
+ value TEXT NOT NULL
309
+ );`
310
+ }
311
+
286
312
  /**
287
313
  * A `WHERE <column> …` test against many hashes, as one statement whatever
288
314
  * their number. A list of `?` is a new statement per length, compiled each
@@ -0,0 +1,21 @@
1
+ // The pages `vx docs` searches: core's reference, imported as text so the
2
+ // compiled binary carries them and an npm install reads them from the
3
+ // package (package.json `files`). Loaded only by `vx docs`.
4
+ import caching from '../../docs/caching.md' with { type: 'text' }
5
+ import cli from '../../docs/cli.md' with { type: 'text' }
6
+ import execution from '../../docs/execution.md' with { type: 'text' }
7
+ import features from '../../docs/features.md' with { type: 'text' }
8
+ import patterns from '../../docs/patterns.md' with { type: 'text' }
9
+ import schema from '../../docs/schema.md' with { type: 'text' }
10
+ import security from '../../docs/security.md' with { type: 'text' }
11
+
12
+ /** Page name (its file, no `.md`) → markdown. */
13
+ export const PAGES: Readonly<Record<string, string>> = {
14
+ cli,
15
+ schema,
16
+ caching,
17
+ execution,
18
+ patterns,
19
+ security,
20
+ features,
21
+ }
@@ -0,0 +1,5 @@
1
+ // A markdown page imported `with { type: 'text' }` is its text (docs-corpus.ts).
2
+ declare module '*.md' {
3
+ const text: string
4
+ export default text
5
+ }
@@ -0,0 +1,173 @@
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
+ /**
103
+ * The sections holding every term, best first: a term in the heading
104
+ * outweighs any count in the text. A section titled by the one word asked
105
+ * (an error code) is the answer alone.
106
+ */
107
+ export function searchDocs(
108
+ pages: Readonly<Record<string, string>>,
109
+ terms: readonly string[],
110
+ limit: number,
111
+ ): DocsHit[] {
112
+ const scored: { s: Section; score: number; order: number }[] = []
113
+ let order = 0
114
+ for (const [page, md] of Object.entries(pages)) {
115
+ for (const s of sections(page, md)) {
116
+ order++
117
+ const head = s.heading.toLowerCase()
118
+ const body = s.text.toLowerCase()
119
+ let score = 0
120
+ let all = true
121
+ for (const t of terms) {
122
+ const inBody = count(body, t)
123
+ if (inBody === 0) all = false
124
+ score += (head.includes(t) ? 10 : 0) + Math.min(inBody, 5)
125
+ }
126
+ if (all) scored.push({ s, score, order })
127
+ }
128
+ }
129
+ scored.sort((a, b) => b.score - a.score || a.order - b.order)
130
+ const exact =
131
+ terms.length === 1 ? scored.filter(({ s }) => words(s.heading).join(' ') === terms[0]) : []
132
+ return (exact.length > 0 ? exact : scored).slice(0, limit).map(({ s }) => ({
133
+ page: s.page,
134
+ heading: s.heading,
135
+ url: s.heading === '' ? pageUrl(s.page) : `${pageUrl(s.page)}#${anchor(s.heading)}`,
136
+ text: s.text,
137
+ }))
138
+ }
139
+
140
+ export async function docsCmd(args: readonly string[]): Promise<number> {
141
+ const parsed = parseDocsArgs(args)
142
+ if (parsed.error) return refuse('docs', args, parsed.error, 'VX_E_USAGE')
143
+ if (parsed.terms.length === 0) {
144
+ return refuse(
145
+ 'docs',
146
+ args,
147
+ `name what to look for, as in \`vx docs cache inputs\`${seeHelp('docs')}`,
148
+ 'VX_E_USAGE',
149
+ )
150
+ }
151
+ const { PAGES } = await import('./docs-corpus.js')
152
+ const hits = searchDocs(PAGES, parsed.terms, parsed.limit)
153
+ const query = parsed.terms.join(' ')
154
+ if (parsed.json) {
155
+ process.stdout.write(`${JSON.stringify({ query, hits })}\n`)
156
+ return 0
157
+ }
158
+ if (hits.length === 0) {
159
+ process.stdout.write(
160
+ `vx docs: no section of ${Object.keys(PAGES).join(', ')} holds every word of "${query}"; try fewer words, or ${SITE}llms.txt\n`,
161
+ )
162
+ return 0
163
+ }
164
+ process.stdout.write(
165
+ hits
166
+ .map(
167
+ (h) =>
168
+ `── ${h.page}.md${h.heading === '' ? '' : ` § ${h.heading}`} · ${h.url}\n\n${h.text}\n`,
169
+ )
170
+ .join('\n'),
171
+ )
172
+ return 0
173
+ }
package/src/cli/help.ts CHANGED
@@ -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] [--native|--keep] [--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
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',
@@ -241,6 +242,7 @@ export function helpText(pluginCommands: readonly string[] = []): string {
241
242
  ' TODO(vx-migrate) comments. Beside turbo.json or nx.json:',
242
243
  ' runs @vzn/vx-migrate, which asks native or keep.',
243
244
  ' --dry Print the generated files instead of writing them.',
245
+ ' --format json With --dry: the plan as one JSON document (schemas/init.json).',
244
246
  ' --force Overwrite existing vx.config.* files.',
245
247
  ' --mjs Write .mjs files (no type import) instead of .ts.',
246
248
  ' --native Turbo or Nx: write a vx.config.ts per package (no question).',
@@ -256,6 +258,14 @@ export function helpText(pluginCommands: readonly string[] = []): string {
256
258
  ' every flag of each. `eval "$(vx completions bash)"`,',
257
259
  ' or write it where the shell loads completions from.',
258
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
+ ' an error code (vx docs VX_E_CYCLE) prints its fix alone.',
266
+ ' --limit N How many sections (default 3).',
267
+ ' --format json {query, hits: [{page, heading, url, text}]}.',
268
+ '',
259
269
  'Config lock:',
260
270
  ' vx lock Evaluate every vx.config in the current environment and',
261
271
  ' freeze the resolved objects into vx-lock.json. Plain runs',
@@ -264,7 +274,8 @@ export function helpText(pluginCommands: readonly string[] = []): string {
264
274
  ' vx lock --check Audit the lock: file-changed hash report PLUS a full',
265
275
  ' re-evaluation compared against the frozen objects. Exits 1',
266
276
  ' on drift (e.g. configs that read env vars). Run it before',
267
- ' `vx run --frozen` in CI.',
277
+ ' `vx run --frozen` in CI. With --format json it prints',
278
+ ' {upToDate, audited, notAudited, problems}.',
268
279
  '',
269
280
  ...(pluginCommands.length > 0 ? ['Plugin commands:', ...pluginCommands, ''] : []),
270
281
  ].join('\n')
package/src/cli/index.ts CHANGED
@@ -109,6 +109,8 @@ export async function run(argv: readonly string[]): Promise<number> {
109
109
  return await (await import('./cache.js')).cacheCmd(rest)
110
110
  case 'lock':
111
111
  return await (await import('./lock.js')).lockCmd(rest)
112
+ case 'docs':
113
+ return await (await import('./docs.js')).docsCmd(rest)
112
114
  case 'init':
113
115
  return await (await import('./init.js')).initCmd(rest)
114
116
  case 'upgrade':