@vzn/vx 0.0.637 → 0.0.639

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 (126) hide show
  1. package/docs/caching.md +84 -28
  2. package/docs/cli.md +273 -408
  3. package/docs/execution.md +19 -44
  4. package/docs/features.md +13 -15
  5. package/docs/patterns.md +5 -7
  6. package/docs/schema.md +68 -61
  7. package/package.json +5 -5
  8. package/schemas/last.json +7 -4
  9. package/schemas/plan.json +2 -2
  10. package/skills/vx/SKILL.md +19 -6
  11. package/src/bin.ts +13 -7
  12. package/src/cache/cache.ts +60 -11
  13. package/src/cache/git-inputs.ts +6 -0
  14. package/src/cache/index.ts +10 -2
  15. package/src/cache/inputs.ts +0 -24
  16. package/src/cache/layer.ts +32 -12
  17. package/src/cache/layered-cache.ts +112 -315
  18. package/src/cache/plugin-cache.ts +240 -0
  19. package/src/cache/policy.ts +9 -0
  20. package/src/cache/remote-lane.ts +383 -0
  21. package/src/cache/run-history.ts +92 -8
  22. package/src/cache/run-store.ts +77 -0
  23. package/src/cache/schema.ts +44 -13
  24. package/src/cache/scoped-cache.ts +22 -2
  25. package/src/cli/cache.ts +33 -16
  26. package/src/cli/completions.ts +11 -7
  27. package/src/cli/foreign-flags.ts +10 -22
  28. package/src/cli/help.ts +22 -38
  29. package/src/cli/index.ts +25 -11
  30. package/src/cli/info.ts +8 -6
  31. package/src/cli/init.ts +14 -15
  32. package/src/cli/last.ts +20 -10
  33. package/src/cli/lock.ts +39 -27
  34. package/src/cli/plan-format.ts +10 -61
  35. package/src/cli/plugin-commands.ts +77 -6
  36. package/src/cli/plugin-templates.ts +1 -1
  37. package/src/cli/run.ts +31 -191
  38. package/src/cli/select.ts +18 -9
  39. package/src/cli/show.ts +72 -40
  40. package/src/cli/task-verb.ts +4 -4
  41. package/src/cli/upgrade.ts +132 -39
  42. package/src/cli/watch-cycle.ts +2 -1
  43. package/src/cli/watch-filter.ts +7 -2
  44. package/src/cli/watch-judge.ts +6 -1
  45. package/src/cli/watch.ts +73 -34
  46. package/src/cli/why.ts +14 -5
  47. package/src/cli/workspace-config.ts +2 -2
  48. package/src/config.ts +0 -6
  49. package/src/exec/executor.ts +1 -1
  50. package/src/exec/sandbox-runtime.ts +1 -1
  51. package/src/exec/sandbox-violations.ts +3 -1
  52. package/src/graph/index.ts +9 -1
  53. package/src/graph/priorities.ts +1 -1
  54. package/src/graph/scheduler.ts +12 -16
  55. package/src/graph/task-graph.ts +119 -21
  56. package/src/graph/task-status.ts +95 -0
  57. package/src/{orchestrator → history}/failure-mode.ts +20 -11
  58. package/src/{orchestrator → history}/history.ts +61 -5
  59. package/src/history/index.ts +48 -0
  60. package/src/{orchestrator → history}/metrics.ts +14 -23
  61. package/src/{orchestrator → history}/miss-reason.ts +1 -1
  62. package/src/index.ts +21 -29
  63. package/src/orchestrator/affected-tasks.ts +8 -3
  64. package/src/orchestrator/doctor.ts +2 -2
  65. package/src/orchestrator/execute-args.ts +80 -0
  66. package/src/orchestrator/execute-task.ts +47 -429
  67. package/src/orchestrator/fingerprint-watch.ts +2 -2
  68. package/src/orchestrator/index.ts +3 -74
  69. package/src/orchestrator/miss-save.ts +33 -3
  70. package/src/orchestrator/options.ts +1 -36
  71. package/src/orchestrator/output-host.ts +4 -3
  72. package/src/orchestrator/persistent-task.ts +321 -0
  73. package/src/orchestrator/persistent.ts +130 -3
  74. package/src/orchestrator/placement.ts +3 -3
  75. package/src/orchestrator/plan.ts +18 -5
  76. package/src/orchestrator/plugin-host.ts +77 -47
  77. package/src/orchestrator/plugin.ts +86 -14
  78. package/src/orchestrator/prepare.ts +74 -31
  79. package/src/orchestrator/projects.ts +3 -0
  80. package/src/orchestrator/remote-prefetch.ts +23 -6
  81. package/src/orchestrator/run-artifacts.ts +5 -177
  82. package/src/orchestrator/run-context.ts +55 -0
  83. package/src/orchestrator/run-failures.ts +15 -3
  84. package/src/orchestrator/run-records.ts +36 -4
  85. package/src/orchestrator/run-report.ts +81 -179
  86. package/src/orchestrator/run-settings.ts +1 -16
  87. package/src/orchestrator/run-stop.ts +153 -0
  88. package/src/orchestrator/run.ts +313 -555
  89. package/src/orchestrator/sandbox-fix.ts +0 -16
  90. package/src/orchestrator/signals.ts +14 -6
  91. package/src/orchestrator/stable-keys.ts +107 -3
  92. package/src/orchestrator/steps.ts +50 -0
  93. package/src/orchestrator/task-hash.ts +3 -16
  94. package/src/orchestrator/task-log-buffer.ts +1 -1
  95. package/src/orchestrator/task-process.ts +94 -0
  96. package/src/orchestrator/telemetry-host.ts +1 -1
  97. package/src/orchestrator/telemetry.ts +6 -83
  98. package/src/{orchestrator → report}/events.ts +41 -8
  99. package/src/{orchestrator → report}/forecast.ts +35 -2
  100. package/src/{orchestrator → report}/framed-output.ts +39 -34
  101. package/src/report/index.ts +36 -0
  102. package/src/{orchestrator → report}/logger.ts +166 -21
  103. package/src/{orchestrator → report}/status-line.ts +84 -7
  104. package/src/{orchestrator → report}/summary.ts +179 -63
  105. package/src/report/tally.ts +83 -0
  106. package/src/settings.ts +13 -23
  107. package/src/{orchestrator → util}/colors.ts +49 -4
  108. package/src/util/config-frame.ts +31 -2
  109. package/src/util/errors.ts +32 -0
  110. package/src/util/git-ignored.ts +143 -0
  111. package/src/util/index.ts +25 -3
  112. package/src/util/refusal-notice.ts +111 -0
  113. package/src/util/report.ts +39 -0
  114. package/src/util/settle.ts +14 -39
  115. package/src/util/tail.ts +1 -1
  116. package/src/util/ui.ts +197 -0
  117. package/src/workspace/affected.ts +20 -23
  118. package/src/workspace/dependency-rules.ts +12 -4
  119. package/src/workspace/migration.ts +87 -91
  120. package/src/workspace/workspace.ts +5 -4
  121. package/schemas/summary.json +0 -133
  122. package/src/cache/chained-cache.ts +0 -276
  123. package/src/orchestrator/tally.ts +0 -112
  124. /package/src/{orchestrator → report}/path-links.ts +0 -0
  125. /package/src/{orchestrator → report}/plain-output.ts +0 -0
  126. /package/src/{orchestrator → report}/terminal-signals.ts +0 -0
@@ -1,4 +1,4 @@
1
- // Formatters for `--dry-run` (human / JSON) and `--graph` (DOT).
1
+ // Formatters for `--dry` (human / JSON).
2
2
 
3
3
  import type { AffectedReason, CacheStatus, RunPlan } from '../orchestrator/index.js'
4
4
  import { formatElapsed, maskedCommand } from '../util/index.js'
@@ -9,7 +9,7 @@ import { formatElapsed, maskedCommand } from '../util/index.js'
9
9
  * task with recorded history also shows its typical duration (~p50).
10
10
  *
11
11
  * would run:
12
- * ◉ @vzn/vx#lint cache hit (local) abc12345
12
+ * ✓ @vzn/vx#lint cache hit (local) abc12345
13
13
  * ↓ @vzn/vx#test cache hit (remote) def67890
14
14
  * ▶ @vzn/vx#build cache miss — would exec fedcba98 ~1.20s
15
15
  *
@@ -33,7 +33,7 @@ export function formatPlanText(plan: RunPlan): string {
33
33
  for (const t of real) {
34
34
  // `@noop`: the run succeeds it without running, whatever its key says.
35
35
  const skipped = t.executor === 'noop'
36
- const sym = skipped ? '∅' : symbolFor(t.cacheStatus)
36
+ const sym = skipped ? '·' : symbolFor(t.cacheStatus)
37
37
  const desc = skipped ? 'noop — would not run' : describe(t.cacheStatus)
38
38
  const shortHash = t.hash ? t.hash.slice(0, 8) : ''
39
39
  const executes = !skipped && (t.cacheStatus === 'miss' || t.cacheStatus === 'no-cache')
@@ -118,9 +118,11 @@ function describeReason(r: AffectedReason): string {
118
118
  ? `a ^ edge passes changed package ${r.project}`
119
119
  : r.kind === 'input'
120
120
  ? `${r.file} changed (an input)`
121
- : r.file !== undefined
122
- ? `${r.file} changed (in its project)`
123
- : 'its project changed as a whole'
121
+ : r.kind === 'workspace'
122
+ ? `${r.file} changed (workspace-wide)`
123
+ : r.file !== undefined
124
+ ? `${r.file} changed (in its project)`
125
+ : 'its project changed as a whole'
124
126
  return r.via === undefined ? cause : `${cause}, via ${r.via.join(' ← ')}`
125
127
  }
126
128
 
@@ -184,52 +186,16 @@ export function formatPlanJson(plan: RunPlan, affectedBase?: string): string {
184
186
  )
185
187
  }
186
188
 
187
- /**
188
- * Render the task graph as a Graphviz DOT document. Includes group
189
- * nodes — they're real nodes in the graph (just not real units of
190
- * work), and a `--graph` consumer wants to see the whole structure.
191
- *
192
- * Layout: `rankdir=LR`. Node fillcolor varies by predicted cache
193
- * status so a quick visual scan tells you which boxes get to skip
194
- * actual work.
195
- */
196
- export function formatGraphDot(plan: RunPlan): string {
197
- const lines: string[] = ['digraph TaskGraph {', ' rankdir=LR;', ' node [shape=box];']
198
- for (const t of plan.tasks) {
199
- const color = dotColor(t.cacheStatus)
200
- lines.push(
201
- ` ${dotString(t.node.id)} [label=${dotString(`${t.node.id}\n${t.hash.slice(0, 8)}`)}, style="filled", fillcolor="${color}"];`,
202
- )
203
- }
204
- for (const t of plan.tasks) {
205
- for (const dep of t.deps) {
206
- lines.push(` ${dotString(dep)} -> ${dotString(t.node.id)};`)
207
- }
208
- }
209
- lines.push('}')
210
- return lines.join('\n') + '\n'
211
- }
212
-
213
- /**
214
- * A DOT double-quoted ID. A task name is any object key in a config, so
215
- * `"`, `\` and a newline are all reachable; unescaped they end the
216
- * string early and the document stops parsing. `\n` is DOT's own line
217
- * break inside a label.
218
- */
219
- function dotString(s: string): string {
220
- return `"${s.replace(/\\/g, '\\\\').replace(/"/g, '\\"').replace(/\r?\n/g, '\\n')}"`
221
- }
222
-
223
189
  function symbolFor(s: CacheStatus): string {
224
190
  switch (s) {
225
191
  case 'hit-local':
226
- return '◉'
192
+ return '✓'
227
193
  case 'hit-remote':
228
194
  return '↓'
229
195
  case 'miss':
230
196
  return '▶'
231
197
  case 'no-cache':
232
- return '·'
198
+ return '∅'
233
199
  case 'group':
234
200
  return '○'
235
201
  case 'not-started':
@@ -253,20 +219,3 @@ function describe(s: CacheStatus): string {
253
219
  return 'not started (unneeded)'
254
220
  }
255
221
  }
256
-
257
- function dotColor(s: CacheStatus): string {
258
- switch (s) {
259
- case 'hit-local':
260
- return '#bbf7d0' // green-200
261
- case 'hit-remote':
262
- return '#bae6fd' // sky-200
263
- case 'miss':
264
- return '#fed7aa' // orange-200
265
- case 'no-cache':
266
- return '#e5e7eb' // gray-200
267
- case 'group':
268
- return '#f5d0fe' // fuchsia-200
269
- case 'not-started':
270
- return '#ffffff'
271
- }
272
- }
@@ -7,8 +7,10 @@
7
7
  // reports an unknown command as it always did. Core's verbs are matched
8
8
  // before this is asked, so a plugin cannot shadow `run`.
9
9
 
10
+ import type { CachePolicy, PluginCache, ResolvedRemote } from '../cache/index.js'
11
+ import type { WorkspaceConfig } from '../config.js'
10
12
  import type { VxPlugin, PluginCommand, CommandContext } from '../orchestrator/index.js'
11
- import { findWorkspaceRoot } from '../workspace/index.js'
13
+ import { cacheScopeEnvError, findWorkspaceRoot, isCacheScope } from '../workspace/index.js'
12
14
  import { machineParallelism, maskedLine } from '../util/index.js'
13
15
  import { resolveSetting } from '../settings.js'
14
16
 
@@ -39,10 +41,15 @@ export interface UnresolvedPluginCommand {
39
41
  loadError: string
40
42
  }
41
43
 
42
- async function workspacePlugins(
43
- cwd: string,
44
- ): Promise<
45
- | { workspaceRoot: string; cacheDir: string; concurrency: number; plugins: readonly VxPlugin[] }
44
+ async function workspacePlugins(cwd: string): Promise<
45
+ | {
46
+ workspaceRoot: string
47
+ cacheDir: string
48
+ storeRoot: Promise<string | null>
49
+ workspaceConfig: WorkspaceConfig | null
50
+ concurrency: number
51
+ plugins: readonly VxPlugin[]
52
+ }
46
53
  | UnresolvedPluginCommand
47
54
  | null
48
55
  > {
@@ -62,6 +69,8 @@ async function workspacePlugins(
62
69
  return {
63
70
  workspaceRoot,
64
71
  cacheDir: ws.cacheDir,
72
+ storeRoot: ws.storeRoot,
73
+ workspaceConfig: ws.workspaceConfig,
65
74
  // What run.ts takes when no --concurrency is given.
66
75
  concurrency:
67
76
  (resolveSetting('run.concurrency', ws.workspaceConfig).value as number | undefined) ??
@@ -98,13 +107,75 @@ export async function resolvePluginCommand(
98
107
  vx: (await import('./upgrade.js')).isCompiledBinary()
99
108
  ? [process.execPath]
100
109
  : [process.execPath, Bun.main],
101
- warn: (m) => process.stderr.write(`${maskedLine(m)}\n`),
110
+ warn,
111
+ cache: lazyCache(() => verbCache(ws, plugin.name)),
102
112
  },
103
113
  }
104
114
  }
105
115
  return { declaredVerbs: [...declaredVerbs] }
106
116
  }
107
117
 
118
+ const warn = (m: string): void => {
119
+ process.stderr.write(`${maskedLine(m)}\n`)
120
+ }
121
+
122
+ /** Read-only: a verb reads what runs stored, and only a run's policy may write. */
123
+ const VERB_POLICY: CachePolicy = {
124
+ localRead: true,
125
+ localWrite: false,
126
+ remoteRead: true,
127
+ remoteWrite: false,
128
+ }
129
+
130
+ /**
131
+ * The verb's `ctx.cache`: the plugin's entries in the local store, then each
132
+ * remote the workspace's cache plugins supply, under the run's scope. Opened
133
+ * on first use, so a verb that never reads it opens nothing.
134
+ */
135
+ async function verbCache(
136
+ ws: Exclude<Awaited<ReturnType<typeof workspacePlugins>>, UnresolvedPluginCommand | null>,
137
+ plugin: string,
138
+ ): Promise<PluginCache> {
139
+ const [{ Cache, pluginCache }, { resolveCache }] = await Promise.all([
140
+ import('../cache/index.js'),
141
+ import('../orchestrator/index.js'),
142
+ ])
143
+ const envScope = process.env['VX_CACHE_SCOPE'] || undefined
144
+ if (envScope !== undefined && !isCacheScope(envScope)) throw cacheScopeEnvError()
145
+ const scope = envScope ?? ws.workspaceConfig?.cache?.scope
146
+ const localCache = new Cache(
147
+ ws.cacheDir,
148
+ { read: true, write: false },
149
+ ws.workspaceRoot,
150
+ undefined,
151
+ 'open',
152
+ await ws.storeRoot,
153
+ )
154
+ const remotes: ResolvedRemote[] = []
155
+ await resolveCache(ws.plugins, {
156
+ workspaceRoot: ws.workspaceRoot,
157
+ cacheDir: ws.cacheDir,
158
+ warn,
159
+ scope,
160
+ localCache,
161
+ policy: VERB_POLICY,
162
+ remotes,
163
+ })
164
+ return pluginCache(plugin, { db: localCache.dbHandle(), policy: VERB_POLICY, remotes, scope })
165
+ }
166
+
167
+ function lazyCache(open: () => Promise<PluginCache>): PluginCache {
168
+ let opened: Promise<PluginCache> | undefined
169
+ const cache = (): Promise<PluginCache> => (opened ??= open())
170
+ return {
171
+ get: async (key) => (await cache()).get(key),
172
+ put: async (key, bytes) => (await cache()).put(key, bytes),
173
+ has: async (key) => (await cache()).has(key),
174
+ hasMany: async (keys) => (await cache()).hasMany(keys),
175
+ getRun: async (name) => (await cache()).getRun(name),
176
+ }
177
+ }
178
+
108
179
  /** The plugin verbs the workspace around `cwd` declares, in declaration order; none when it does not load. */
109
180
  export async function pluginVerbs(cwd = process.cwd()): Promise<string[]> {
110
181
  const ws = await workspacePlugins(cwd).catch(() => null)
@@ -16,7 +16,7 @@ export const PLUGIN_TEMPLATES: Readonly<Record<string, { plugin: string; test: s
16
16
  commands: {
17
17
  plugin:
18
18
  "// `commands`: a CLI verb. `vx cache-dir` prints where this workspace's cache\n// lives. The verb's return value is the exit code.\nimport { definePlugin, type VxPlugin } from '@vzn/vx'\n\nexport function cacheDirVerb(): VxPlugin {\n return definePlugin(import.meta, {\n commands: {\n 'cache-dir': {\n description: \"print this workspace's cache directory\",\n run(argv, ctx) {\n if (argv.length > 0) {\n console.error(`vx cache-dir: takes no arguments, got ${argv.join(' ')}`)\n return 2\n }\n console.log(ctx.cacheDir)\n return 0\n },\n },\n },\n })\n}\n",
19
- test: "// A test for the example `commands` plugin beside it: the verb is called\n// the way vx calls it, with the context vx hands it. `bun test` runs it.\nimport { expect, it, spyOn } from 'bun:test'\nimport { cacheDirVerb } from './commands.ts'\n\nconst verb = cacheDirVerb().commands!['cache-dir']!\nconst ctx = {\n workspaceRoot: '/ws',\n cacheDir: '/ws/.vx/cache',\n warn() {},\n concurrency: 1,\n vx: ['vx'],\n}\n\nit('prints the cache directory; an argument is exit 2', async () => {\n const out = spyOn(console, 'log').mockImplementation(() => {})\n const err = spyOn(console, 'error').mockImplementation(() => {})\n try {\n expect(await verb.run([], ctx)).toBe(0)\n expect(out.mock.calls).toEqual([['/ws/.vx/cache']])\n expect(await verb.run(['x'], ctx)).toBe(2)\n expect(err.mock.calls).toEqual([['vx cache-dir: takes no arguments, got x']])\n } finally {\n out.mockRestore()\n err.mockRestore()\n }\n})\n",
19
+ test: "// A test for the example `commands` plugin beside it: the verb is called\n// the way vx calls it, with the context vx hands it. `bun test` runs it.\nimport { expect, it, spyOn } from 'bun:test'\nimport { cacheDirVerb } from './commands.ts'\n\nconst verb = cacheDirVerb().commands!['cache-dir']!\nconst ctx = {\n workspaceRoot: '/ws',\n cacheDir: '/ws/.vx/cache',\n warn() {},\n concurrency: 1,\n vx: ['vx'],\n cache: {\n get: async () => null,\n put: async () => {},\n has: async () => null,\n hasMany: async () => null,\n getRun: async () => null,\n },\n}\n\nit('prints the cache directory; an argument is exit 2', async () => {\n const out = spyOn(console, 'log').mockImplementation(() => {})\n const err = spyOn(console, 'error').mockImplementation(() => {})\n try {\n expect(await verb.run([], ctx)).toBe(0)\n expect(out.mock.calls).toEqual([['/ws/.vx/cache']])\n expect(await verb.run(['x'], ctx)).toBe(2)\n expect(err.mock.calls).toEqual([['vx cache-dir: takes no arguments, got x']])\n } finally {\n out.mockRestore()\n err.mockRestore()\n }\n})\n",
20
20
  },
21
21
  executor: {
22
22
  plugin:
package/src/cli/run.ts CHANGED
@@ -1,18 +1,14 @@
1
- import { appendFileSync, mkdirSync } from 'node:fs'
2
- import path from 'node:path'
3
1
  import { isatty } from 'node:tty'
4
2
  import { cacheScopeError, findWorkspaceRoot, isCacheScope } from '../workspace/index.js'
5
3
  import { translateForeign } from './foreign-flags.js'
6
- import { flagHint, formatValue, refuse, seeHelp } from './help.js'
4
+ import { flagHint, refuse, seeHelp } from './help.js'
7
5
  import {
8
6
  planRun,
9
- formatRunReportMarkdown,
10
- projectOutcome,
11
7
  run as runOrchestrator,
12
8
  shellQuote,
13
9
  type RunOptions,
14
10
  } from '../orchestrator/index.js'
15
- import type { ContinueMode, TaskOutcome } from '../graph/index.js'
11
+ import type { ContinueMode } from '../graph/index.js'
16
12
  import {
17
13
  applyCacheFlags,
18
14
  type CacheFlags,
@@ -23,9 +19,9 @@ import {
23
19
  } from '../cache/index.js'
24
20
  import { affectedFilterFor, findCwdSelection, pickTask, resolveFilters } from './select.js'
25
21
  import { nxTargetHint, taskNamesHere } from './task-verb.js'
26
- import { parseDecimalInt } from '../util/index.js'
22
+ import { parseDecimalInt, sayNotice, sayLine } from '../util/index.js'
27
23
  import { kindHint, parseSettingValue, type Setting, SETTINGS, settingRefusal } from '../settings.js'
28
- import { formatGraphDot, formatPlanJson, formatPlanText } from './plan-format.js'
24
+ import { formatPlanJson, formatPlanText } from './plan-format.js'
29
25
 
30
26
  export interface RunArgs {
31
27
  /** Failure propagation (`--continue[=never|deps-ok|always]`). */
@@ -71,10 +67,6 @@ export interface RunArgs {
71
67
  forwardArgs: string[]
72
68
  verbosity: number
73
69
  dry: 'text' | 'json' | undefined
74
- /** `--format json`: the run's result document on stdout, every other line on stderr. */
75
- format: 'pretty' | 'json'
76
- graph: string | undefined
77
- summarize: string | undefined
78
70
  profile: string | undefined
79
71
  /**
80
72
  * `--affected[=<base>]`. When undefined the flag wasn't passed.
@@ -82,16 +74,6 @@ export interface RunArgs {
82
74
  * `defaultAffectedBase`). Any other string is an explicit git ref.
83
75
  */
84
76
  affected: string | undefined
85
- /** `--tag k=v` pairs (repeatable). Empty key is a parse error. */
86
- tags: Record<string, string>
87
- /** `--report[=markdown]`. Undefined when the flag wasn't passed. */
88
- report: 'markdown' | undefined
89
- /**
90
- * `--report-file=<path>`. Undefined when the flag wasn't passed. Writes
91
- * the same markdown as `--report`, but to a file — because redirecting
92
- * vx's stdout captures the whole run log, not just the report.
93
- */
94
- reportFile: string | undefined
95
77
  error?: string
96
78
  }
97
79
 
@@ -130,9 +112,6 @@ const SETTING_OPTIONS = [
130
112
  ['run.timeout', 'timeout'],
131
113
  ['run.retries', 'retries'],
132
114
  ['run.outputLogs', 'outputLogs'],
133
- ['run.killGrace', 'killGrace'],
134
- ['run.teardownTimeout', 'teardownTimeout'],
135
- ['run.readyNotice', 'readyNotice'],
136
115
  ['cache.download', 'download'],
137
116
  ] as const satisfies readonly (readonly [string, keyof RunOptions])[]
138
117
 
@@ -150,14 +129,8 @@ export function parseRunArgs(rawArgs: readonly string[], verb: 'run' | 'watch' =
150
129
  forwardArgs: [],
151
130
  verbosity: 0,
152
131
  dry: undefined,
153
- format: 'pretty',
154
- graph: undefined,
155
- summarize: undefined,
156
132
  profile: undefined,
157
133
  affected: undefined,
158
- tags: {},
159
- report: undefined,
160
- reportFile: undefined,
161
134
  }
162
135
 
163
136
  // A Turbo or Nx spelling becomes vx's, or a refusal naming vx's.
@@ -301,58 +274,18 @@ export function parseRunArgs(rawArgs: readonly string[], verb: 'run' | 'watch' =
301
274
  return { ...out, error: `--dry must be text or json (got ${fmt})` }
302
275
  }
303
276
  out.dry = fmt
304
- } else if (a === '--format' || a?.startsWith('--format=')) {
305
- const f = formatValue(a === '--format' ? before[++i] : a.slice('--format='.length), verb)
306
- if (typeof f !== 'string') return { ...out, error: f.error }
307
- out.format = f
308
- } else if (a === '--graph') {
309
- out.graph = ''
310
- } else if (a?.startsWith('--graph=')) {
311
- out.graph = a.slice('--graph='.length)
312
- } else if (a === '--summarize') {
313
- out.summarize = ''
314
- } else if (a?.startsWith('--summarize=')) {
315
- out.summarize = a.slice('--summarize='.length)
316
277
  } else if (a === '--profile') {
317
278
  out.profile = 'profile.json'
318
279
  } else if (a?.startsWith('--profile=')) {
319
280
  // An optional-value flag: an empty `=` means "no value given", so it
320
281
  // takes the same documented default as the bare form. Resolving ''
321
282
  // against cwd would name the cwd DIRECTORY and die with EISDIR after
322
- // the whole run — and `--summarize=` already degrades to its default.
283
+ // the whole run.
323
284
  out.profile = a.slice('--profile='.length) || 'profile.json'
324
285
  } else if (a === '--affected') {
325
286
  out.affected = ''
326
287
  } else if (a?.startsWith('--affected=')) {
327
288
  out.affected = a.slice('--affected='.length)
328
- } else if (a === '--tag' || a?.startsWith('--tag=')) {
329
- const raw = a === '--tag' ? before[++i] : a.slice('--tag='.length)
330
- if (raw === undefined) return { ...out, error: `${a} requires a value (k=v)` }
331
- // Split on the FIRST `=` so values may contain `=` (e.g. a URL).
332
- const eq = raw.indexOf('=')
333
- if (eq <= 0) return { ...out, error: `--tag must be k=v (got ${raw})` }
334
- out.tags[raw.slice(0, eq)] = raw.slice(eq + 1)
335
- } else if (a === '--report-file' || a?.startsWith('--report-file=')) {
336
- const v = a === '--report-file' ? before[++i] : a.slice('--report-file='.length)
337
- if (v === undefined || v === '') return { ...out, error: `--report-file requires a path` }
338
- // Same reasoning as --cache-dir: a path is an arbitrary string, so
339
- // nothing about its shape rejects a swallowed flag. A path starting
340
- // with `-` needs the `=` form.
341
- if (a === '--report-file' && v.startsWith('-')) {
342
- return {
343
- ...out,
344
- error: `--report-file requires a path (got flag ${v}; a path that starts with - needs --report-file=${v})`,
345
- }
346
- }
347
- out.reportFile = v
348
- } else if (a === '--report') {
349
- out.report = 'markdown'
350
- } else if (a?.startsWith('--report=')) {
351
- const fmt = a.slice('--report='.length)
352
- if (fmt !== 'markdown') {
353
- return { ...out, error: `--report must be markdown (got ${fmt})` }
354
- }
355
- out.report = fmt
356
289
  } else if (verb === 'watch' && a === '--poll') {
357
290
  out.poll = true
358
291
  } else if (a !== undefined && settingFlagOf(a) !== undefined) {
@@ -400,39 +333,10 @@ export function parseRunArgs(rawArgs: readonly string[], verb: 'run' | 'watch' =
400
333
  out.cache = applyCacheFlags(cachePolicy, flags)
401
334
  if (!cacheFlag && envSpec === undefined) out.cacheFlags = flags
402
335
 
403
- if (out.dry !== undefined && out.graph !== undefined) {
404
- return { ...out, error: '--dry and --graph are mutually exclusive' }
405
- }
406
- // Every flag that promises a file or a section about the run: the
407
- // planning branch returns before any of them is written, and
408
- // `--report-file="$GITHUB_STEP_SUMMARY" --dry` exited 0 having written
409
- // nothing (item 992).
410
- const needsRun =
411
- out.summarize !== undefined ||
412
- out.profile !== undefined ||
413
- out.report !== undefined ||
414
- out.reportFile !== undefined
415
- const runOnly = '--summarize / --profile / --report / --report-file need a real run'
416
- if (out.dry !== undefined && needsRun) {
417
- return { ...out, error: `--dry skips execution; ${runOnly}` }
418
- }
419
- if (out.graph !== undefined && needsRun) {
420
- return { ...out, error: `--graph skips execution; ${runOnly}` }
421
- }
422
- if (out.format === 'json') {
423
- // Each of these prints something else on stdout, which the document owns.
424
- if (out.dry !== undefined)
425
- return { ...out, error: '--format json runs the tasks; --dry=json prints the plan' }
426
- if (out.graph === '')
427
- return {
428
- ...out,
429
- error: '--format json and --graph both print on stdout; give --graph a file',
430
- }
431
- if (out.report === 'markdown')
432
- return {
433
- ...out,
434
- error: '--format json and --report=markdown both print on stdout; use --report-file',
435
- }
336
+ // A profile is about a run: the planning branch returns before it is
337
+ // written (item 992).
338
+ if (out.dry !== undefined && out.profile !== undefined) {
339
+ return { ...out, error: '--dry skips execution; --profile needs a real run' }
436
340
  }
437
341
  return out
438
342
  }
@@ -556,7 +460,7 @@ export async function resolveRunOptions(
556
460
  // scope. An empty project list keeps the bare tasks unselected
557
461
  // while the anchored ones still run.
558
462
  if (anchoredTasks.length === 0) return { nothingSelected: resolved.empty }
559
- process.stderr.write(`vx: ${resolved.empty} — running ${anchoredTasks.join(', ')} only\n`)
463
+ sayNotice('info', 'vx run', `${resolved.empty} — running ${anchoredTasks.join(', ')} only`)
560
464
  projects = []
561
465
  } else {
562
466
  projects = resolved.names
@@ -619,10 +523,7 @@ export async function resolveRunOptions(
619
523
  for (const [path, field] of SETTING_OPTIONS) {
620
524
  if (parsed.settings[path] !== undefined) Object.assign(opts, { [field]: parsed.settings[path] })
621
525
  }
622
- if (parsed.summarize !== undefined) opts.summarize = parsed.summarize
623
- if (parsed.format === 'json') opts.json = true
624
526
  if (parsed.profile !== undefined) opts.profile = parsed.profile
625
- if (Object.keys(parsed.tags).length > 0) opts.tags = parsed.tags
626
527
 
627
528
  return opts
628
529
  }
@@ -646,7 +547,7 @@ export async function runCmd(args: readonly string[]): Promise<number> {
646
547
  if (tasks.length === 0) {
647
548
  // A JSON answer is for a program, which cannot pick from a menu: an
648
549
  // agent on a pty waited at the picker forever.
649
- const forProgram = parsed.format === 'json' || parsed.dry === 'json'
550
+ const forProgram = parsed.dry === 'json'
650
551
  if (!process.stdin.isTTY || forProgram) {
651
552
  const names = await taskNamesHere(cwd)
652
553
  const shown = (names ?? []).slice(0, TASKS_SHOWN)
@@ -665,7 +566,7 @@ export async function runCmd(args: readonly string[]): Promise<number> {
665
566
  // Every run needs git: refused here, not after the user has chosen.
666
567
  const refusal = gitRefusal(await findWorkspaceRoot(cwd))
667
568
  if (refusal !== undefined) {
668
- process.stderr.write(`${refusal.message}\n`)
569
+ sayLine('error', refusal.message, 'vx run')
669
570
  return 1
670
571
  }
671
572
  const load = parsed.cacheDir !== undefined ? { cacheDir: parsed.cacheDir } : {}
@@ -679,7 +580,7 @@ export async function runCmd(args: readonly string[]): Promise<number> {
679
580
  if ('error' in selected)
680
581
  return refuse('run', args, selected.error, selected.code ?? 'VX_E_USAGE')
681
582
  if ('empty' in selected) {
682
- process.stderr.write(`vx run: ${selected.empty}\n`)
583
+ sayNotice('info', 'vx run', selected.empty)
683
584
  return 0
684
585
  }
685
586
  only = new Set(selected.names)
@@ -695,7 +596,7 @@ export async function runCmd(args: readonly string[]): Promise<number> {
695
596
  const resolved = await resolveRunOptions(parsed, cwd, tasks, 'run', scopeOut)
696
597
  if ('error' in resolved) return refuse('run', args, resolved.error, resolved.code ?? 'VX_E_USAGE')
697
598
  if ('nothingSelected' in resolved) {
698
- process.stderr.write(`vx run: ${resolved.nothingSelected}\n`)
599
+ sayNotice('info', 'vx run', resolved.nothingSelected)
699
600
  return 0
700
601
  }
701
602
  const opts = resolved
@@ -710,48 +611,37 @@ export async function runCmd(args: readonly string[]): Promise<number> {
710
611
  ...args.map(shellQuote),
711
612
  ].join(' ')
712
613
 
713
- // Planning paths short-circuit execution. Both build the full task
714
- // graph + probe the cache; the difference is just the formatter.
715
- if (parsed.dry !== undefined || parsed.graph !== undefined) {
614
+ // Planning short-circuits execution: the full task graph + a cache probe.
615
+ if (parsed.dry !== undefined) {
716
616
  const plan = await planRun(opts)
617
+ // Refused as a run refuses: `--dry=json` gets the error document.
717
618
  if (plan.unresolvedTasks !== undefined && plan.unresolvedTasks.length > 0) {
718
- process.stderr.write(
719
- `vx run: no projects declare task(s): ${plan.unresolvedTasks.join(', ')}.${plan.unresolvedHint ?? ''}\n`,
619
+ return refuse(
620
+ 'run',
621
+ args,
622
+ `no projects declare task(s): ${plan.unresolvedTasks.join(', ')}.${plan.unresolvedHint ?? ''}`,
623
+ 'VX_E_UNKNOWN_TASK',
720
624
  )
721
- return 1
722
625
  }
723
626
  if (plan.tasks.length === 0) {
724
627
  // The run's own line: a dry run that guessed from the scope alone
725
628
  // said no affected project declared a task one did (X-141).
726
629
  if (plan.noneAffected !== undefined) {
727
- process.stderr.write(`${plan.noneAffected}\n`)
630
+ sayLine('info', plan.noneAffected, 'vx run')
728
631
  return 0
729
632
  }
730
633
  if (opts.selectedByDiff === true) {
731
- process.stderr.write(`vx run: no affected project declares task(s): ${tasks.join(', ')}.\n`)
634
+ sayNotice('info', 'vx run', `no affected project declares task(s): ${tasks.join(', ')}`)
732
635
  return 0
733
636
  }
734
- process.stderr.write(`vx run: no projects declare task(s): ${tasks.join(', ')}.\n`)
735
- return 1
637
+ return refuse(
638
+ 'run',
639
+ args,
640
+ `no projects declare task(s): ${tasks.join(', ')}`,
641
+ 'VX_E_UNKNOWN_TASK',
642
+ )
736
643
  }
737
- if (parsed.graph !== undefined) {
738
- const out = formatGraphDot(plan)
739
- if (parsed.graph === '') {
740
- process.stdout.write(out)
741
- } else {
742
- // The graph is the command's one product, so a path it cannot write
743
- // is exit 1, said in one line as the other output paths say theirs:
744
- // a directory or an unreachable path printed a raw stack (item 993).
745
- const target = path.resolve(cwd, parsed.graph)
746
- try {
747
- await Bun.write(target, out)
748
- } catch (err) {
749
- const message = err instanceof Error ? err.message : String(err)
750
- process.stderr.write(`vx run: failed to write graph to ${target}: ${message}\n`)
751
- return 1
752
- }
753
- }
754
- } else if (parsed.dry === 'json') {
644
+ if (parsed.dry === 'json') {
755
645
  process.stdout.write(formatPlanJson(plan, scopeOut.affectedBase))
756
646
  } else {
757
647
  process.stdout.write(formatPlanText(plan))
@@ -759,25 +649,6 @@ export async function runCmd(args: readonly string[]): Promise<number> {
759
649
  return 0
760
650
  }
761
651
 
762
- // The report prints above the footer: nothing prints below it (owner).
763
- // Rendered once, however many sinks asked for it; a kept server's crash
764
- // after the footer re-renders the file's copy (C-53).
765
- const wantsReport = parsed.report === 'markdown' || parsed.reportFile !== undefined
766
- let reported: { outcomes: readonly TaskOutcome[]; md: string } | undefined
767
- if (wantsReport) {
768
- opts.beforeFooter = (outcomes, ok) => {
769
- reported = {
770
- outcomes,
771
- md: formatRunReportMarkdown({ ok, outcomes: outcomes.map(projectOutcome) }),
772
- }
773
- // `--report` writes stdout. The report ITSELF is machine-clean, but
774
- // stdout is not vx's alone — the status logger writes there too, so
775
- // `--report=markdown >> "$GITHUB_STEP_SUMMARY"` captures every frame,
776
- // meter bar and `::group::` command above the table. `--report-file`
777
- // is the redirect-free form.
778
- return parsed.report === 'markdown' ? reported.md : ''
779
- }
780
- }
781
652
  // A run executes in THIS process, always. Where an individual task's
782
653
  // command runs is the `executor` capability's business (per task, with
783
654
  // the scheduler, cache, retries and telemetry unchanged above it); there
@@ -787,37 +658,6 @@ export async function runCmd(args: readonly string[]): Promise<number> {
787
658
  const unknown = summary.refused.startsWith('no projects declare')
788
659
  return refuse('run', args, summary.refused, unknown ? 'VX_E_UNKNOWN_TASK' : 'VX_E_REFUSED')
789
660
  }
790
- if (wantsReport) {
791
- const md =
792
- reported?.outcomes === summary.outcomes
793
- ? reported.md
794
- : formatRunReportMarkdown({
795
- ok: summary.ok,
796
- outcomes: summary.outcomes.map(projectOutcome),
797
- })
798
- if (parsed.reportFile !== undefined) {
799
- const target = path.resolve(cwd, parsed.reportFile)
800
- try {
801
- // APPEND, not overwrite: the flag exists for `$GITHUB_STEP_SUMMARY`,
802
- // which GitHub documents as append-only and which other steps in the
803
- // same job also write to. Truncating it would silently destroy their
804
- // content, and would make replacing the documented `>>` recipe a
805
- // behaviour change rather than a drop-in.
806
- // Its directory made first, as `--summarize`, `--profile` and
807
- // `--graph` make theirs: `nr/r.md` failed alone of the four (item 993).
808
- // Sync: on a Ctrl-C the signal path exits once stdout drains, and
809
- // an awaited write lost the race every time (E-23).
810
- mkdirSync(path.dirname(target), { recursive: true })
811
- appendFileSync(target, md)
812
- } catch (err) {
813
- // Same contract as --summarize / --profile: the run already
814
- // happened, so a write failure is reported, not fatal.
815
- const message = err instanceof Error ? err.message : String(err)
816
- process.stderr.write(`vx: failed to write report to ${target}: ${message}\n`)
817
- }
818
- }
819
- }
820
- if (summary.json !== undefined) process.stdout.write(`${JSON.stringify(summary.json)}\n`)
821
661
  return summary.ok ? 0 : 1
822
662
  }
823
663