@vzn/vx 0.0.637 → 0.0.638

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 (76) hide show
  1. package/docs/caching.md +41 -14
  2. package/docs/cli.md +115 -73
  3. package/docs/execution.md +1 -1
  4. package/docs/features.md +6 -5
  5. package/docs/schema.md +24 -15
  6. package/package.json +5 -5
  7. package/schemas/plan.json +2 -2
  8. package/skills/vx/SKILL.md +13 -2
  9. package/src/bin.ts +13 -7
  10. package/src/cache/cache.ts +21 -2
  11. package/src/cache/chained-cache.ts +4 -0
  12. package/src/cache/index.ts +9 -1
  13. package/src/cache/inputs.ts +0 -24
  14. package/src/cache/layer.ts +7 -1
  15. package/src/cache/layered-cache.ts +4 -0
  16. package/src/cache/plugin-cache.ts +248 -0
  17. package/src/cache/policy.ts +9 -0
  18. package/src/cache/run-history.ts +68 -0
  19. package/src/cache/run-store.ts +77 -0
  20. package/src/cache/schema.ts +38 -9
  21. package/src/cache/scoped-cache.ts +15 -2
  22. package/src/cli/cache.ts +33 -16
  23. package/src/cli/completions.ts +9 -5
  24. package/src/cli/help.ts +16 -7
  25. package/src/cli/index.ts +25 -11
  26. package/src/cli/info.ts +6 -5
  27. package/src/cli/init.ts +14 -15
  28. package/src/cli/last.ts +14 -7
  29. package/src/cli/lock.ts +39 -27
  30. package/src/cli/plan-format.ts +5 -3
  31. package/src/cli/plugin-commands.ts +77 -6
  32. package/src/cli/plugin-templates.ts +1 -1
  33. package/src/cli/run.ts +14 -12
  34. package/src/cli/select.ts +18 -9
  35. package/src/cli/show.ts +66 -40
  36. package/src/cli/task-verb.ts +4 -4
  37. package/src/cli/upgrade.ts +20 -11
  38. package/src/cli/watch-filter.ts +7 -2
  39. package/src/cli/watch-judge.ts +6 -1
  40. package/src/cli/watch.ts +44 -20
  41. package/src/cli/why.ts +10 -1
  42. package/src/cli/workspace-config.ts +2 -2
  43. package/src/graph/scheduler.ts +8 -0
  44. package/src/graph/task-graph.ts +32 -4
  45. package/src/index.ts +11 -1
  46. package/src/orchestrator/affected-tasks.ts +8 -3
  47. package/src/orchestrator/doctor.ts +1 -1
  48. package/src/orchestrator/execute-task.ts +26 -43
  49. package/src/orchestrator/failure-mode.ts +19 -10
  50. package/src/orchestrator/framed-output.ts +1 -2
  51. package/src/orchestrator/history.ts +60 -4
  52. package/src/orchestrator/index.ts +2 -1
  53. package/src/orchestrator/logger.ts +9 -2
  54. package/src/orchestrator/metrics.ts +7 -3
  55. package/src/orchestrator/plugin-host.ts +14 -4
  56. package/src/orchestrator/plugin.ts +80 -8
  57. package/src/orchestrator/prepare.ts +51 -7
  58. package/src/orchestrator/projects.ts +3 -0
  59. package/src/orchestrator/remote-prefetch.ts +9 -5
  60. package/src/orchestrator/run-records.ts +1 -1
  61. package/src/orchestrator/run.ts +126 -27
  62. package/src/orchestrator/status-line.ts +1 -1
  63. package/src/orchestrator/summary.ts +69 -44
  64. package/src/orchestrator/telemetry.ts +1 -1
  65. package/src/settings.ts +12 -1
  66. package/src/{orchestrator → util}/colors.ts +5 -0
  67. package/src/util/config-frame.ts +31 -2
  68. package/src/util/errors.ts +32 -0
  69. package/src/util/index.ts +8 -1
  70. package/src/util/refusal-notice.ts +111 -0
  71. package/src/util/report.ts +31 -0
  72. package/src/util/ui.ts +186 -0
  73. package/src/workspace/affected.ts +20 -23
  74. package/src/workspace/dependency-rules.ts +12 -4
  75. package/src/workspace/migration.ts +87 -91
  76. package/src/workspace/workspace.ts +5 -4
@@ -34,6 +34,31 @@ const RUNS_PER_INSERT = 40
34
34
  /** Rows per `task_durations` INSERT: 6 variables each, under 999. */
35
35
  const DURATIONS_PER_INSERT = 160
36
36
 
37
+ /** Rows per `task_outcomes` INSERT: 6 variables each, under 999. */
38
+ const OUTCOMES_PER_INSERT = 160
39
+
40
+ /**
41
+ * Whether a run says anything of its key, so the flaky record may keep it:
42
+ * keyed, cached, and not run behind a failure (its bytes were not the key's).
43
+ */
44
+ const keyed = (r: RunRecord): boolean =>
45
+ r.hash !== undefined && r.hash !== '' && r.cached !== false && r.blockedBy == null
46
+
47
+ /** A hit, as the orchestrator marks one (`isCacheHit`, run-records.ts). */
48
+ const isHit = (r: RunRecord): boolean => r.cacheHit === true
49
+
50
+ /**
51
+ * Of a JSON array of hashes, those the record holds a pass of: one index
52
+ * probe each (0.6 ms for 1,000; a join reading every row of each key was
53
+ * 5.7 ms at 20 runs of history). A hit's row is kept only when its key has
54
+ * none: a hit replays a pass, and the first is what a later failure
55
+ * relapses from (an entry adopted from its artifact, or older than the
56
+ * record, has no executed row). By hash alone, as the entry it replays is.
57
+ */
58
+ const PASSED_HASHES = `
59
+ SELECT j.value AS hash FROM json_each(?) AS j
60
+ WHERE EXISTS (SELECT 1 FROM task_outcomes AS o WHERE o.hash = j.value AND o.status <> 'failed')`
61
+
37
62
  /** A local run's `task_durations.source`: its branch, `''` when it had none. */
38
63
  export const branchSource = (branch: string | null): string => `branch:${branch ?? ''}`
39
64
 
@@ -75,6 +100,7 @@ export class RunHistory {
75
100
  .transaction(() => {
76
101
  this.insertRuns(runs)
77
102
  this.insertDurations(runs, branchSource(null), null)
103
+ this.insertOutcomes(runs)
78
104
  })
79
105
  .immediate()
80
106
  }
@@ -94,6 +120,45 @@ export class RunHistory {
94
120
  }
95
121
  }
96
122
 
123
+ /**
124
+ * The flaky record's rows: every executed outcome, and a key's first hit,
125
+ * in the order given (a batch may hold one key's history).
126
+ */
127
+ private insertOutcomes(runs: readonly RunRecord[]): void {
128
+ const rows = runs.filter(
129
+ (r) => keyed(r) && (isHit(r) || r.status === 'success' || r.status === 'failed'),
130
+ )
131
+ const hits = rows.filter(isHit)
132
+ let passed: Set<string> | undefined
133
+ if (hits.length > 0) {
134
+ const found = this.db.query(PASSED_HASHES).all(JSON.stringify(hits.map((r) => r.hash))) as {
135
+ hash: string
136
+ }[]
137
+ passed = new Set(found.map((f) => f.hash))
138
+ }
139
+ const kept: RunRecord[] = []
140
+ for (const r of rows) {
141
+ if (isHit(r)) {
142
+ if (passed!.has(r.hash!)) continue
143
+ passed!.add(r.hash!)
144
+ } else if (r.status === 'success') {
145
+ passed?.add(r.hash!)
146
+ }
147
+ kept.push(r)
148
+ }
149
+ for (let i = 0; i < kept.length; i += OUTCOMES_PER_INSERT) {
150
+ const block = kept.slice(i, i + OUTCOMES_PER_INSERT)
151
+ const params: SQLQueryBindings[] = []
152
+ for (const r of block)
153
+ params.push(r.project, r.task, r.hash!, r.status, r.attempts ?? null, r.startedAt)
154
+ this.db
155
+ .query(
156
+ `INSERT INTO task_outcomes(project, task, hash, status, attempts, recorded_at) VALUES ${Array(block.length).fill('(?, ?, ?, ?, ?, ?)').join(', ')}`,
157
+ )
158
+ .run(...params)
159
+ }
160
+ }
161
+
97
162
  /** `runs` as few INSERTs; each distinct forward-args list digested once. */
98
163
  private insertRuns(runs: readonly RunRecord[]): void {
99
164
  const digests = new Map<string, string>()
@@ -134,6 +199,7 @@ export class RunHistory {
134
199
  branchSource(bundle.invocation.branch),
135
200
  bundle.invocation.commitSha,
136
201
  )
202
+ this.insertOutcomes(bundle.runs)
137
203
  })
138
204
  .immediate()
139
205
  }
@@ -149,6 +215,8 @@ export class RunHistory {
149
215
  this.db.prepare('DELETE FROM runs WHERE started_at < ?').run(cutoff)
150
216
  this.db.prepare('DELETE FROM invocations WHERE started_at < ?').run(cutoff)
151
217
  this.db.prepare('DELETE FROM task_durations WHERE recorded_at < ?').run(cutoff)
218
+ this.db.prepare('DELETE FROM task_outcomes WHERE recorded_at < ?').run(cutoff)
219
+ this.db.prepare('DELETE FROM plugin_blobs WHERE at < ?').run(cutoff)
152
220
  }
153
221
  }
154
222
 
@@ -0,0 +1,77 @@
1
+ // What core keeps of a run that writes a remote (owner 2026-10-15): a failed
2
+ // task's log and the files its outputs left, an uncached task's log. Names
3
+ // come from the run id and task (`run/<id>/<task>/log`); each key is derived
4
+ // in a namespace no task key or plugin entry shares. Remote only: locally
5
+ // `vx last --log` and the working tree already hold them. No size cap (M:
6
+ // the cache is free). Plugins read them by name (`ctx.cache.getRun`).
7
+
8
+ import { xxh3hex } from '../util/index.js'
9
+ import type { PluginCacheRemote } from './plugin-cache.js'
10
+ import { scopedKey } from './scoped-cache.js'
11
+ import { tarPackInto, tarSize } from './tar-stream.js'
12
+
13
+ /** The name core stores a task's `part` of run `runId` under. */
14
+ export function runEntryName(runId: string, taskId: string, part: 'log' | 'outputs'): string {
15
+ return `run/${runId}/${taskId}/${part}`
16
+ }
17
+
18
+ const runKey = (name: string): string => xxh3hex(`vx-run\0${name}`)
19
+
20
+ /** The keys `name` is read under: the default, then the scope's, as tasks read. */
21
+ export function runReadKeys(name: string, scope: string | undefined): string[] {
22
+ const key = runKey(name)
23
+ return scope === undefined ? [key] : [key, scopedKey(scope, key)]
24
+ }
25
+
26
+ /** Whether some remote takes this run's writes: only then does core keep run bytes. */
27
+ export function writesRunStore(remotes: readonly PluginCacheRemote[]): boolean {
28
+ return remotes.some((r) => r.write)
29
+ }
30
+
31
+ /**
32
+ * Store `bytes` under `name` in every remote the policy writes. An entry is
33
+ * written once; a remote's failure leaves the others theirs, silently: what
34
+ * a run keeps here is a record, never its verdict.
35
+ */
36
+ export async function putRunEntry(
37
+ remotes: readonly PluginCacheRemote[],
38
+ scope: string | undefined,
39
+ name: string,
40
+ bytes: Uint8Array,
41
+ ): Promise<boolean> {
42
+ const key = scope === undefined ? runKey(name) : scopedKey(scope, runKey(name))
43
+ let kept = false
44
+ for (const { remote, write } of remotes) {
45
+ if (!write) continue
46
+ try {
47
+ if (!(await remote.has(key))) await remote.put(key, new Blob([bytes]), { durationMs: 0 })
48
+ kept = true
49
+ } catch {
50
+ // the other remotes still keep it
51
+ }
52
+ }
53
+ return kept
54
+ }
55
+
56
+ /**
57
+ * The files at `paths` (workspace-relative) as a plain ustar archive, names
58
+ * as given, so any `tar` reads it; null when none is left to read.
59
+ */
60
+ export async function packRunOutputs(
61
+ workspaceRoot: string,
62
+ paths: readonly string[],
63
+ ): Promise<Uint8Array | null> {
64
+ const inputs: { name: string; size: number; body: Uint8Array }[] = []
65
+ for (const name of paths) {
66
+ try {
67
+ const body = await Bun.file(`${workspaceRoot}/${name}`).bytes()
68
+ inputs.push({ name, size: body.byteLength, body })
69
+ } catch {
70
+ // gone since the glob: nothing to keep
71
+ }
72
+ }
73
+ if (inputs.length === 0) return null
74
+ const out = new Uint8Array(tarSize(inputs))
75
+ tarPackInto(inputs, out)
76
+ return out
77
+ }
@@ -23,7 +23,8 @@ export function lazyStatement(db: Database, sql: string): ReturnType<Database['p
23
23
  /**
24
24
  * Create every table. `store` names the schema holding the ENTRY tables
25
25
  * (`entries`, `entry_stdout`, `output_files`, `entry_inputs`, `store_meta`)
26
- * and the task timings (`task_durations`):
26
+ * the task timings (`task_durations`), the flaky record (`task_outcomes`),
27
+ * the blob sizes (`blob_sizes`) and the plugins' blobs (`plugin_blobs`):
27
28
  * `main` when one `cache.db` holds everything (a `cacheDir`), `store` when
28
29
  * they live in the shared store attached beside it (v32). The rest is the
29
30
  * workspace's own: its history, its memos, and what its disk looked like
@@ -114,17 +115,14 @@ export function createTables(db: Database, store: 'main' | 'store' = 'main'): vo
114
115
  -- runs_ended (2026-09-09) served only the retention DELETE, which
115
116
  -- prunes on started_at now (a row ends after it starts, so the
116
117
  -- 30-day window moves by at most one task's duration).
118
+ -- runs_failed (v38) served the flakiness probe, which reads the
119
+ -- store's task_outcomes now.
117
120
  DROP INDEX IF EXISTS main.runs_hash;
118
121
  DROP INDEX IF EXISTS main.runs_project;
119
122
  DROP INDEX IF EXISTS main.runs_ended;
123
+ DROP INDEX IF EXISTS main.runs_failed;
120
124
  CREATE INDEX IF NOT EXISTS main.runs_started_at ON runs(started_at);
121
125
  CREATE INDEX IF NOT EXISTS main.runs_run_id ON runs(run_id);
122
- -- The one keyed index, PARTIAL over failed rows: a green run's
123
- -- inserts only evaluate its predicate, so the append-only cost above
124
- -- holds, and the flakiness probe after a miss (failure-mode.ts,
125
- -- "did this key ever fail?") reads a handful of leaves instead of
126
- -- scanning the table (2026-09-10: 10–95 ms at 170k rows without it).
127
- CREATE INDEX IF NOT EXISTS main.runs_failed ON runs(hash) WHERE status = 'failed';
128
126
  -- Per-file (mtime, size, content_hash) cache. Lets Cache.key()
129
127
  -- skip the content-hash on inputs whose stat hasn't changed
130
128
  -- since the last run. Pure performance optimization; the stored
@@ -142,8 +140,9 @@ export function createTables(db: Database, store: 'main' | 'store' = 'main'): vo
142
140
  -- The size of each index blob the enumeration checked against the
143
141
  -- worktree size git recorded (A-60). Fixed for its OID, so a warm
144
142
  -- run asks git for none; swept with file_hashes by seen_at, the
145
- -- time the row was written.
146
- CREATE TABLE IF NOT EXISTS main.blob_sizes (
143
+ -- time the row was written. v38: in the store, so a worktree reads
144
+ -- what another learned (owner, 2026-10-14).
145
+ CREATE TABLE IF NOT EXISTS ${store}.blob_sizes (
147
146
  oid TEXT PRIMARY KEY,
148
147
  size INTEGER NOT NULL,
149
148
  seen_at INTEGER NOT NULL
@@ -275,6 +274,36 @@ export function createTables(db: Database, store: 'main' | 'store' = 'main'): vo
275
274
  );
276
275
  CREATE INDEX IF NOT EXISTS ${store}.task_durations_task
277
276
  ON task_durations(project, task, source, id);
277
+ -- v37: a 'remote' row is a default-branch commit's timing a plugin
278
+ -- imported (\`sha\` that commit): one per task, so a repeat import is a
279
+ -- no-op under INSERT OR IGNORE.
280
+ CREATE UNIQUE INDEX IF NOT EXISTS ${store}.task_durations_remote
281
+ ON task_durations(sha, project, task) WHERE source = 'remote';
282
+ -- v38: the flaky record (orchestrator/failure-mode.ts): each keyed,
283
+ -- cached, unblocked outcome that observed its key, shared by every
284
+ -- worktree so a key that passed in one and failed in another is flaky
285
+ -- in both (owner, 2026-10-14). Every executed success or failure, and
286
+ -- a key's first hit (run-history.ts): a later hit replays a pass the
287
+ -- record holds, so a warm run on keys that passed writes no row.
288
+ -- Append-only, ordered by id, pruned by age with the run history.
289
+ CREATE TABLE IF NOT EXISTS ${store}.task_outcomes (
290
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
291
+ project TEXT NOT NULL,
292
+ task TEXT NOT NULL,
293
+ hash TEXT NOT NULL,
294
+ status TEXT NOT NULL,
295
+ attempts INTEGER,
296
+ recorded_at INTEGER NOT NULL
297
+ );
298
+ CREATE INDEX IF NOT EXISTS ${store}.task_outcomes_hash ON task_outcomes(hash, status);
299
+ -- v37: what a plugin keeps through \`ctx.cache\` (plugin-cache.ts), by
300
+ -- its namespaced key. Written once, never updated; pruned by age with
301
+ -- the run history.
302
+ CREATE TABLE IF NOT EXISTS ${store}.plugin_blobs (
303
+ key TEXT PRIMARY KEY,
304
+ bytes BLOB NOT NULL,
305
+ at INTEGER NOT NULL
306
+ );
278
307
  ${artifactTables(store)}
279
308
  `)
280
309
  }
@@ -16,7 +16,7 @@ import type {
16
16
  } from './layer.js'
17
17
 
18
18
  /** The key a scope stores `hash` under: task-key shaped, 16 hex, so every wire stores it unchanged. */
19
- function scopedKey(scope: string, hash: string): string {
19
+ export function scopedKey(scope: string, hash: string): string {
20
20
  return xxh3hex(`vx-cache-scope\0${scope}\0${hash}`)
21
21
  }
22
22
 
@@ -75,6 +75,13 @@ export class ScopedCache implements CacheLayer {
75
75
  return out
76
76
  }
77
77
 
78
+ /** A task key the store holds under the default key or the scope's. */
79
+ async localHasMany(hashes: readonly string[]): Promise<Set<string>> {
80
+ const held = await this.inner.localHasMany?.([...hashes, ...hashes.map((h) => this.own(h))])
81
+ if (held === undefined) return new Set()
82
+ return new Set(hashes.filter((h) => held.has(h) || held.has(this.own(h))))
83
+ }
84
+
78
85
  private async scopedHasMany(hashes: readonly string[]): Promise<Set<string> | null> {
79
86
  const scoped = hashes.map((h) => this.own(h))
80
87
  const [plain, own] = await Promise.all([
@@ -105,7 +112,13 @@ export class ScopedCache implements CacheLayer {
105
112
  }
106
113
 
107
114
  async get(hash: string, ctx?: CacheGetContext): Promise<CacheEntry | null> {
108
- for (const key of [hash, this.own(hash)]) {
115
+ // The scope's key first when only it is local: the default key's
116
+ // remote GET would cost a round-trip for the same inputs.
117
+ const own = this.own(hash)
118
+ const keys = (await this.inner.localHasMany?.([hash, own]))?.has(own)
119
+ ? [own, hash]
120
+ : [hash, own]
121
+ for (const key of keys) {
109
122
  const entry = await this.inner.get(key, ctx)
110
123
  if (entry === null) continue
111
124
  this.served.set(hash, key)
package/src/cli/cache.ts CHANGED
@@ -3,7 +3,15 @@ import { homedir } from 'node:os'
3
3
  import path from 'node:path'
4
4
  import { Cache, vxCacheEntries } from '../cache/index.js'
5
5
  import { flagHint, formatValue, refuse, refusedWord, seeHelp } from './help.js'
6
- import { errorCode, nearest, parseDuration, parseSize, relPosix, treeBytes } from '../util/index.js'
6
+ import {
7
+ errorCode,
8
+ nearest,
9
+ parseDuration,
10
+ parseSize,
11
+ relPosix,
12
+ treeBytes,
13
+ ui,
14
+ } from '../util/index.js'
7
15
  import { acquireRunLock } from '../orchestrator/index.js'
8
16
  import { findWorkspaceRoot } from '../workspace/index.js'
9
17
  import {
@@ -254,15 +262,28 @@ function printPruned(
254
262
  )
255
263
  return
256
264
  }
257
- const orphans =
258
- result.orphans > 0
259
- ? `, ${dry ? 'would reap' : 'reaped'} ${result.orphans} orphaned artifact${result.orphans === 1 ? '' : 's'} (${formatBytes(result.orphanBytes)})`
260
- : ''
261
- process.stdout.write(
262
- `${dry ? 'Would prune' : 'Pruned'} ${result.evicted} entr${result.evicted === 1 ? 'y' : 'ies'} (${formatBytes(result.bytesFreed)}${dry ? '' : ' freed'})${orphans}\n`,
265
+ const plural = (n: number, one: string, many: string): string => `${n} ${n === 1 ? one : many}`
266
+ const facts = [
267
+ ...(result.evicted > 0 ? [plural(result.evicted, 'entry', 'entries')] : []),
268
+ ...(result.orphans > 0
269
+ ? [plural(result.orphans, 'orphaned artifact', 'orphaned artifacts')]
270
+ : []),
271
+ ]
272
+ ui.print(
273
+ 'out',
274
+ freedStatus(dry, result.bytesFreed + result.orphanBytes, facts.length > 0),
275
+ ...(facts.length > 0 ? [ui.text(facts.join(' · '))] : []),
263
276
  )
264
277
  }
265
278
 
279
+ /** `✓ vx cache clean freed 1.3 GB`, `› … would free 1.3 GB (dry run)`, or nothing to free. */
280
+ function freedStatus(dry: boolean, bytes: number, any: boolean): ui.Block {
281
+ if (!any) return ui.status('info', 'vx cache clean', `nothing to free${dry ? ' (dry run)' : ''}`)
282
+ return dry
283
+ ? ui.status('info', 'vx cache clean', `would free ${formatBytes(bytes)} (dry run)`)
284
+ : ui.status('ok', 'vx cache clean', `freed ${formatBytes(bytes)}`)
285
+ }
286
+
266
287
  /** `vx cache clean --format json` with no bound (schemas/cache.json `clear`). */
267
288
  export interface ClearResult {
268
289
  dryRun: boolean
@@ -341,16 +362,12 @@ async function clear(
341
362
  if (parsed.format === 'json') {
342
363
  const result: ClearResult = { dryRun: dry, cleared: shown, bytesFreed: bytes }
343
364
  process.stdout.write(`${JSON.stringify(result)}\n`)
344
- } else if (shown.length === 0) process.stdout.write('Nothing to clear: no cache here\n')
345
- else {
346
- const scope =
347
- parsed.global === true
348
- ? "every repository's store and this workspace's run history"
349
- : "this repository's store, shared by its checkouts, and this workspace's run history"
350
- process.stdout.write(
351
- `${dry ? 'Would clear' : 'Cleared'} ${scope} (${formatBytes(bytes)}${dry ? '' : ' freed'}):\n${shown.map((t) => ` ${t}\n`).join('')}`,
365
+ } else
366
+ ui.print(
367
+ 'out',
368
+ freedStatus(dry, bytes, shown.length > 0),
369
+ ...(shown.length > 0 ? [ui.text(...shown)] : []),
352
370
  )
353
- }
354
371
  return 0
355
372
  }
356
373
 
@@ -6,7 +6,7 @@
6
6
  // Tab is the wrong price).
7
7
 
8
8
  import { acceptedFlags, CORE_VERBS, flagHint, seeHelp } from './help.js'
9
- import { nearest } from '../util/index.js'
9
+ import { nearest, sayNotice } from '../util/index.js'
10
10
 
11
11
  /** A flag whose value is one of a fixed set; the tests hold each to its parser. */
12
12
  export const FLAG_VALUES: Readonly<Record<string, readonly string[]>> = {
@@ -139,8 +139,10 @@ export async function completionsCmd(
139
139
  if (args.length !== 1 || !(SHELLS as readonly string[]).includes(shell ?? '')) {
140
140
  const flag = args.find((a) => a.startsWith('-'))
141
141
  if (flag !== undefined) {
142
- process.stderr.write(
143
- `vx completions: unknown flag: ${flag}${flagHint('completions', flag)}${seeHelp('completions')}\n`,
142
+ sayNotice(
143
+ 'error',
144
+ 'vx completions',
145
+ `unknown flag: ${flag}${flagHint('completions', flag)}${seeHelp('completions')}`,
144
146
  )
145
147
  return 1
146
148
  }
@@ -148,8 +150,10 @@ export async function completionsCmd(
148
150
  // One edit: shell names are three or four letters, so two edits took
149
151
  // `tcsh` to `bash`, a different shell.
150
152
  const best = one ? nearest(shell, SHELLS, 1) : undefined
151
- process.stderr.write(
152
- `vx completions: expected one shell — bash, zsh or fish${one ? ` (got ${shell})` : ''}${best === undefined ? '' : `. Did you mean ${best}?`}${seeHelp('completions')}\n`,
153
+ sayNotice(
154
+ 'error',
155
+ 'vx completions',
156
+ `expected one shell — bash, zsh or fish${one ? ` (got ${shell})` : ''}${best === undefined ? '' : `. Did you mean ${best}?`}${seeHelp('completions')}`,
153
157
  )
154
158
  return 1
155
159
  }
package/src/cli/help.ts CHANGED
@@ -1,14 +1,21 @@
1
- import type { ColorSupport, paint as Paint } from '../orchestrator/index.js'
2
- import { CORE_VERBS, errorDocument, maskedLine, nearest, wantsJson } from '../util/index.js'
1
+ import {
2
+ CORE_VERBS,
3
+ detectColors,
4
+ errorDocument,
5
+ maskedLine,
6
+ nearest,
7
+ paint,
8
+ refusalNotice,
9
+ ui,
10
+ wantsJson,
11
+ type ColorSupport,
12
+ } from '../util/index.js'
3
13
 
4
14
  export async function printHelp(
5
15
  pluginCommands: readonly string[] = [],
6
16
  verb?: string,
7
17
  ): Promise<void> {
8
18
  const text = verb === undefined ? helpText(pluginCommands) : verbHelpText(verb)
9
- // The orchestrator is already loaded by the plugin-verb lookup every
10
- // caller awaits first, so this import costs `vx --version` nothing.
11
- const { detectColors, paint } = await import('../orchestrator/index.js')
12
19
  process.stdout.write(paintHelp(text, helpColors(process.stdout, detectColors), paint))
13
20
  }
14
21
 
@@ -33,7 +40,7 @@ const TERM = '#22d3ee' // cyan-400 — a flag, a selector, an example
33
40
  * `(for <verb>)` dim), `vx <verb>` bold cyan, and the term an option or
34
41
  * example line opens with cyan. Prose and continuation lines stay plain.
35
42
  */
36
- export function paintHelp(text: string, colors: ColorSupport, paint: typeof Paint): string {
43
+ export function paintHelp(text: string, colors: ColorSupport, paint: Paint): string {
37
44
  if (!colors.enabled) return text
38
45
  const lines = text.split('\n')
39
46
  return lines
@@ -445,7 +452,9 @@ export function refuse(
445
452
  code: string,
446
453
  ): number {
447
454
  const line = `vx ${verb}: ${message}`
448
- process.stderr.write(`${line}\n`)
455
+ ui.print('err', refusalNotice(`vx ${verb}`, message))
449
456
  if (wantsJson(args)) process.stdout.write(errorDocument(code, maskedLine(line)))
450
457
  return 1
451
458
  }
459
+
460
+ type Paint = typeof paint
package/src/cli/index.ts CHANGED
@@ -8,11 +8,14 @@ import { FOREIGN_VERBS } from './foreign-flags.js'
8
8
  import {
9
9
  errorDocument,
10
10
  hideEnvFromTasks,
11
+ sayNotice,
11
12
  isFsRefusal,
12
13
  isOutOfFds,
13
14
  isUserError,
14
15
  maskedLine,
15
16
  nearest,
17
+ refusalNotice,
18
+ ui,
16
19
  UserError,
17
20
  wantsJson,
18
21
  } from '../util/index.js'
@@ -74,7 +77,8 @@ async function dispatch(command: string | undefined, rest: string[]): Promise<nu
74
77
  await findWorkspaceRoot(process.cwd())
75
78
  } catch (err) {
76
79
  if (!isUserError(err)) throw err
77
- process.stderr.write(`vx: ${err.message}; \`vx help\` lists the verbs\n`)
80
+ const parts = err.parts ?? { headline: err.message }
81
+ ui.print('err', refusalNotice('vx', err.message, { ...parts, why: 'see vx help' }))
78
82
  return 1
79
83
  }
80
84
  await printHelp(await (await plugins()).pluginCommandHelp())
@@ -97,19 +101,23 @@ async function dispatch(command: string | undefined, rest: string[]): Promise<nu
97
101
  const unknown = resolved === null || 'declaredVerbs' in resolved
98
102
  const pointer = FOREIGN_VERBS[verb]
99
103
  if (unknown && pointer !== undefined) {
100
- process.stderr.write(`${pointer}\n`)
104
+ sayNotice('info', 'vx', pointer)
101
105
  return 1
102
106
  }
103
107
  if (unknown && verb.startsWith('-')) {
104
- process.stderr.write(
105
- `vx help: unknown flag: ${verb}${flagHint('help', verb)} (see \`vx help\`)\n`,
108
+ sayNotice(
109
+ 'error',
110
+ 'vx help',
111
+ `unknown flag: ${verb}${flagHint('help', verb)} (see \`vx help\`)`,
106
112
  )
107
113
  return 1
108
114
  }
109
115
  if (unknown) {
110
116
  const declared = resolved === null ? [] : resolved.declaredVerbs
111
- process.stderr.write(
112
- `vx help: unknown command: ${verb}${didYouMeanVerb(verb, declared)} (see \`vx help\`)\n`,
117
+ sayNotice(
118
+ 'error',
119
+ 'vx help',
120
+ `unknown command: ${verb}${didYouMeanVerb(verb, declared)} (see \`vx help\`)`,
113
121
  )
114
122
  return 1
115
123
  }
@@ -123,8 +131,10 @@ async function dispatch(command: string | undefined, rest: string[]): Promise<nu
123
131
  // takes no more of is refused, as every other verb refuses one.
124
132
  const extra = rest[0]
125
133
  if (extra !== undefined) {
126
- process.stderr.write(
127
- `vx version: ${refusedWord(extra)}: ${extra}${flagHint('version', extra)}${seeHelp('version')}\n`,
134
+ sayNotice(
135
+ 'error',
136
+ 'vx version',
137
+ `${refusedWord(extra)}: ${extra}${flagHint('version', extra)}${seeHelp('version')}`,
128
138
  )
129
139
  return 1
130
140
  }
@@ -193,8 +203,12 @@ async function dispatch(command: string | undefined, rest: string[]): Promise<nu
193
203
  // points at the file that broke it.
194
204
  // Every answer below is "no such verb here": under --format json the
195
205
  // agent reads it as one code, as a verb's own refusal gives one.
196
- const unknown = (text: string, code = 'VX_E_UNKNOWN_COMMAND'): number => {
197
- process.stderr.write(text)
206
+ const unknown = (
207
+ text: string,
208
+ code = 'VX_E_UNKNOWN_COMMAND',
209
+ tone: 'error' | 'info' = 'error',
210
+ ): number => {
211
+ sayNotice(tone, 'vx', text.replace(/^vx: /, '').trimEnd())
198
212
  if (wantsJson(rest))
199
213
  process.stdout.write(errorDocument(code, maskedLine(text.split('\n')[0]!)))
200
214
  return 1
@@ -207,7 +221,7 @@ async function dispatch(command: string | undefined, rest: string[]): Promise<nu
207
221
  // their chance — a workspace that declares a plugin verb of that name keeps it.
208
222
  const moved = FOREIGN_VERBS[command]
209
223
  if (moved !== undefined) {
210
- return unknown(`${moved}${loadNote}\n`)
224
+ return unknown(`${moved}${loadNote}\n`, undefined, 'info')
211
225
  }
212
226
  // A task typed where the verb goes (`turbo build`, `nx build app`),
213
227
  // `turbo dev` included: a repo's own `dev` task beats the no-service note.
package/src/cli/info.ts CHANGED
@@ -8,7 +8,7 @@ import { collectInfo, type FlakyTask, type InfoFacts } from '../orchestrator/ind
8
8
  import { flagHint, formatValue, refuse, refusedWord, seeHelp } from './help.js'
9
9
  import { namedCacheDir, parseCacheDirFlag, warnToStderr } from './workspace-config.js'
10
10
  import { formatBytes } from './format.js'
11
- import { MIN_BUN } from '../util/index.js'
11
+ import { detectColors, MIN_BUN, ui, type ColorSupport } from '../util/index.js'
12
12
  import { envName } from '../settings.js'
13
13
 
14
14
  interface InfoArgs {
@@ -55,12 +55,14 @@ export async function infoCmd(args: readonly string[]): Promise<number> {
55
55
  warn: warnToStderr,
56
56
  })
57
57
  process.stdout.write(
58
- parsed.format === 'json' ? `${JSON.stringify(facts)}\n` : `${renderInfo(facts)}\n`,
58
+ parsed.format === 'json'
59
+ ? `${JSON.stringify(facts)}\n`
60
+ : `${renderInfo(facts, detectColors(process.stdout))}\n`,
59
61
  )
60
62
  return 0
61
63
  }
62
64
 
63
- export function renderInfo(f: InfoFacts): string {
65
+ export function renderInfo(f: InfoFacts, colors: ColorSupport = { enabled: false }): string {
64
66
  const rows: [string, string][] = [
65
67
  [
66
68
  'vx',
@@ -122,8 +124,7 @@ export function renderInfo(f: InfoFacts): string {
122
124
  ['sandbox', describeSandbox(f.sandbox)],
123
125
  ['projects lock', f.lockfile ? 'yes' : 'no'],
124
126
  ]
125
- const labelW = Math.max(...rows.map(([label]) => label.length))
126
- return rows.map(([label, value]) => `${`${label}:`.padEnd(labelW + 1)} ${value}`).join('\n')
127
+ return ui.report('vx info', rows).lines(colors).join('\n')
127
128
  }
128
129
 
129
130
  /** `812 (1.2 GB)`, or against `cacheRetention.maxSize`: `812 (1.2 GB of 10 GB, 12%)`. */
package/src/cli/init.ts CHANGED
@@ -15,7 +15,7 @@ import { lockAfterMigration } from './lock.js'
15
15
  import { PLUGIN_TEMPLATES } from './plugin-templates.js'
16
16
  import { isCompiledBinary } from './upgrade.js'
17
17
  import { VERSION } from '../version.js'
18
- import { DOT_VX_GITATTRIBUTES, DOT_VX_GITIGNORE, UserError } from '../util/index.js'
18
+ import { DOT_VX_GITATTRIBUTES, DOT_VX_GITIGNORE, ui, UserError, sayNotice } from '../util/index.js'
19
19
  import {
20
20
  findWorkspaceRoot,
21
21
  PROJECT_CONFIG_FILENAMES,
@@ -125,7 +125,7 @@ async function scaffoldPlugin(seam: string, root: string, args: InitArgs): Promi
125
125
  if (!args.force) {
126
126
  for (const [rel] of files) {
127
127
  if (await Bun.file(path.join(root, rel)).exists()) {
128
- process.stderr.write(`vx init: ${rel} exists; --force overwrites it\n`)
128
+ sayNotice('error', 'vx init', `${rel} exists. --force overwrites it`)
129
129
  return 1
130
130
  }
131
131
  }
@@ -193,27 +193,26 @@ async function starter(root: string, noGit: boolean, args: InitArgs): Promise<nu
193
193
  )
194
194
  return 0
195
195
  }
196
- const report = [
197
- 'vx init: no package.json, so no scripts to map: a workspace with one project, the root.',
198
- ]
199
- const named = files.map((f) => f.path).join(', ')
200
- if (files.length > 0)
201
- report.push(args.dry ? `would write ${named} (dry run, nothing written).` : `wrote ${named}.`)
202
- if (kept.length > 0) report.push(`kept ${kept.join(', ')}.`)
203
- if (replaced.length > 0)
204
- report.push(`${args.dry ? 'would replace' : 'replaced'} ${replaced.join(', ')}.`)
196
+ const rows: ui.FieldRow[] = [['from', 'no package.json: one project, the root']]
197
+ if (args.dry) rows.push(['dry run', 'nothing written'])
198
+ if (files.length > 0) rows.push(['files', ...files.map((f) => f.path)])
199
+ if (kept.length > 0) rows.push(['kept', ...kept])
200
+ if (replaced.length > 0) rows.push(['replaced', ...replaced])
205
201
  if (args.dry) {
206
202
  for (const f of files) process.stdout.write(`── ${f.path} ──\n${f.contents}\n`)
207
- process.stdout.write(`${report.join('\n')}\n\nnext: ${next}\n`)
203
+ ui.print('out', ui.report(`vx init ${VERSION}`, [...rows, null, ['next', next]]))
208
204
  return 0
209
205
  }
210
206
  for (const f of files) await Bun.write(path.join(root, f.path), f.contents)
211
207
  for (const f of replaced) await unlink(path.join(root, f))
212
208
  // The lock that follows loads the workspace file just written.
213
209
  workspaceFileWritten()
214
- process.stdout.write(`${report.join('\n')}\n\n`)
215
- const locked = (await lockAfterMigration(root)) === 0
216
- process.stdout.write(`\nnext: ${locked ? next : `fix the above and run vx lock, then ${next}`}\n`)
210
+ const lock = await lockAfterMigration(root)
211
+ rows.push(['lock', lock.code === 0 ? lock.said.replace(/^locked /, '') : 'not written'], null, [
212
+ 'next',
213
+ lock.code === 0 ? next : `fix the above, then vx lock and ${next}`,
214
+ ])
215
+ ui.print('out', ui.report(`vx init ${VERSION}`, rows))
217
216
  return 0
218
217
  }
219
218