@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
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
@@ -164,12 +171,6 @@ export function helpText(pluginCommands: readonly string[] = []): string {
164
171
  " --timeout <duration> A task's time limit (30s, 10m), for tasks",
165
172
  ' without their own exec.timeout. A task past it',
166
173
  ' is killed and fails.',
167
- ' --kill-grace <duration> How long a task that ignores SIGTERM gets',
168
- ' before SIGKILL (default 2s).',
169
- " --teardown-timeout <duration> The bound on each plugin's end-of-run flush",
170
- ' (default 3s).',
171
- ' --ready-notice <duration> When vx says a persistent task still waits for',
172
- ' readyWhen (default 10s).',
173
174
  ' --continue[=<mode>] What a failed task takes down: never | deps-ok',
174
175
  ' (default, skips its dependents) | always (bare',
175
176
  ' form; dependents run, never save).',
@@ -183,18 +184,9 @@ export function helpText(pluginCommands: readonly string[] = []): string {
183
184
  'Planning (for run — skips execution):',
184
185
  " --dry[=text|json] Print the task graph and each task's predicted hit",
185
186
  ' or miss.',
186
- ' --graph[=<path>] Emit Graphviz DOT (stdout if no path).',
187
187
  '',
188
188
  'Artifacts (for run):',
189
- ' --format <fmt> pretty (default) | json: print the --summarize',
190
- ' document on stdout when the run ends; every other',
191
- ' line goes to stderr.',
192
- ' --summarize[=<path>] Write per-run JSON to <cacheDir>/runs/<run_id>.json.',
193
189
  ' --profile[=<path>] Write Chrome-trace JSON (default profile.json).',
194
- ' --report[=markdown] Print a markdown run report to stdout after the run.',
195
- ' --report-file <path> Append that report to <path> ($GITHUB_STEP_SUMMARY).',
196
- ' --tag <k=v> Label this invocation (repeatable); recorded on its',
197
- ' history row.',
198
190
  '',
199
191
  'Extensions (plugins):',
200
192
  ' vx core runs tasks in-process and nothing else. A dashboard, remote',
@@ -216,12 +208,11 @@ export function helpText(pluginCommands: readonly string[] = []): string {
216
208
  'Watch mode:',
217
209
  ' vx watch <task> Initial run, then re-run on filesystem changes.',
218
210
  ' Uses the same flags as `vx run` except --dry /',
219
- ' --graph / --summarize / --profile / --report /',
220
- ' --report-file / --verbosity (they plan or report',
221
- ' one run), plus --poll (watch.poll: poll instead of',
222
- ' the OS watcher). Press Ctrl+C to stop. Cache means most',
223
- ' re-runs are near-zero cost (cache hits restore',
224
- ' outputs + replay logs).',
211
+ ' --profile / --verbosity (they plan or report one',
212
+ ' run), plus --poll (watch.poll: poll instead of',
213
+ ' the OS watcher). Press Ctrl+C to stop.',
214
+ ' Cache means most re-runs are near-zero cost',
215
+ ' (cache hits restore outputs + replay logs).',
225
216
  '',
226
217
  'Cache management:',
227
218
  ' vx cache clean --older-than 30d Evict entries last accessed > 30 days ago.',
@@ -395,16 +386,7 @@ export function documentedFlags(verb: string, text = helpText()): string[] {
395
386
  * The `vx run` flags `vx watch` refuses: each plans or reports ONE run
396
387
  * (`watchRefusal` in watch.ts, held to this list by a test).
397
388
  */
398
- export const WATCH_REFUSED_FLAGS: readonly string[] = [
399
- '--dry',
400
- '--graph',
401
- '--summarize',
402
- '--profile',
403
- '--report',
404
- '--report-file',
405
- '--verbosity',
406
- '--format',
407
- ]
389
+ export const WATCH_REFUSED_FLAGS: readonly string[] = ['--dry', '--profile', '--verbosity']
408
390
 
409
391
  /**
410
392
  * The flags one verb accepts: those on its own Usage line, and for a verb
@@ -445,7 +427,9 @@ export function refuse(
445
427
  code: string,
446
428
  ): number {
447
429
  const line = `vx ${verb}: ${message}`
448
- process.stderr.write(`${line}\n`)
430
+ ui.print('err', refusalNotice(`vx ${verb}`, message))
449
431
  if (wantsJson(args)) process.stdout.write(errorDocument(code, maskedLine(line)))
450
432
  return 1
451
433
  }
434
+
435
+ 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
@@ -4,11 +4,12 @@
4
4
  // from the orchestrator's `collectInfo` (doctor.ts); this file parses the flags and renders the rows.
5
5
 
6
6
  import os from 'node:os'
7
- import { collectInfo, type FlakyTask, type InfoFacts } from '../orchestrator/index.js'
7
+ import { collectInfo, type InfoFacts } from '../orchestrator/index.js'
8
+ import { type FlakyTask } from '../history/index.js'
8
9
  import { flagHint, formatValue, refuse, refusedWord, seeHelp } from './help.js'
9
10
  import { namedCacheDir, parseCacheDirFlag, warnToStderr } from './workspace-config.js'
10
11
  import { formatBytes } from './format.js'
11
- import { MIN_BUN } from '../util/index.js'
12
+ import { detectColors, MIN_BUN, ui, type ColorSupport } from '../util/index.js'
12
13
  import { envName } from '../settings.js'
13
14
 
14
15
  interface InfoArgs {
@@ -55,12 +56,14 @@ export async function infoCmd(args: readonly string[]): Promise<number> {
55
56
  warn: warnToStderr,
56
57
  })
57
58
  process.stdout.write(
58
- parsed.format === 'json' ? `${JSON.stringify(facts)}\n` : `${renderInfo(facts)}\n`,
59
+ parsed.format === 'json'
60
+ ? `${JSON.stringify(facts)}\n`
61
+ : `${renderInfo(facts, detectColors(process.stdout))}\n`,
59
62
  )
60
63
  return 0
61
64
  }
62
65
 
63
- export function renderInfo(f: InfoFacts): string {
66
+ export function renderInfo(f: InfoFacts, colors: ColorSupport = { enabled: false }): string {
64
67
  const rows: [string, string][] = [
65
68
  [
66
69
  'vx',
@@ -122,8 +125,7 @@ export function renderInfo(f: InfoFacts): string {
122
125
  ['sandbox', describeSandbox(f.sandbox)],
123
126
  ['projects lock', f.lockfile ? 'yes' : 'no'],
124
127
  ]
125
- const labelW = Math.max(...rows.map(([label]) => label.length))
126
- return rows.map(([label, value]) => `${`${label}:`.padEnd(labelW + 1)} ${value}`).join('\n')
128
+ return ui.report('vx info', rows).lines(colors).join('\n')
127
129
  }
128
130
 
129
131
  /** `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
 
package/src/cli/last.ts CHANGED
@@ -9,22 +9,24 @@ import { Cache } from '../cache/index.js'
9
9
  import { formatBytes } from './format.js'
10
10
  import { flagHint, formatValue, seeHelp } from './help.js'
11
11
  import {
12
- currentBranch,
13
- exitSignal,
14
12
  type FailureMode,
15
13
  getInvocation,
16
14
  getRun,
17
15
  type InvocationDetail,
18
16
  listInvocations,
19
17
  LocalHistoryProvider,
20
- outcomeWord,
21
18
  type RunSummaryRow,
19
+ } from '../history/index.js'
20
+ import {
21
+ currentBranch,
22
+ exitSignal,
22
23
  resolveRunId,
23
24
  runFailures,
24
25
  shortRunId,
25
26
  taskLog,
26
27
  } from '../orchestrator/index.js'
27
- import { formatElapsed, splitTaskId, UserError } from '../util/index.js'
28
+ import { outcomeWord } from '../report/index.js'
29
+ import { formatElapsed, refusal, splitTaskId, UserError, sayNotice, ui } from '../util/index.js'
28
30
  import { findWorkspaceRoot } from '../workspace/index.js'
29
31
  import { cliCacheDir, parseCacheDirFlag } from './workspace-config.js'
30
32
 
@@ -242,7 +244,7 @@ async function printStats(
242
244
  return 0
243
245
  }
244
246
  if (stats.length === 0) {
245
- process.stdout.write('no recorded runs\n')
247
+ ui.print('out', ui.status('info', 'vx last', 'no runs yet'))
246
248
  return 0
247
249
  }
248
250
  const pct = (r: number): string => `${Math.round(r * 100)}%`
@@ -319,6 +321,7 @@ function reasonParts(t: RunSummaryRow): string {
319
321
  } else if (t.status === 'skipped' && t.blockedBy !== null) {
320
322
  parts.push(`after ${t.blockedBy} failed`)
321
323
  }
324
+ if (t.remoteError !== null) parts.push(`remote \u2717 ${t.remoteError}`)
322
325
  return parts.map((p) => ` ${p}`).join('')
323
326
  }
324
327
 
@@ -468,8 +471,10 @@ export async function lastCmd(args: readonly string[]): Promise<number> {
468
471
  return 0
469
472
  }
470
473
  if (log.source === null) {
471
- process.stderr.write(
472
- `vx last: ${log.taskId} ended ${log.status}; vx keeps output only for failed and cached tasks\n`,
474
+ sayNotice(
475
+ 'info',
476
+ 'vx last',
477
+ `${log.taskId} ended ${log.status}. vx keeps output only for failed and cached tasks`,
473
478
  )
474
479
  }
475
480
  process.stdout.write(log.output)
@@ -486,9 +491,8 @@ export async function lastCmd(args: readonly string[]): Promise<number> {
486
491
  return 0
487
492
  }
488
493
  if (invocations.length === 0) {
489
- process.stdout.write(
490
- parsed.failed === true && !noRuns() ? 'no recorded run failed\n' : 'no recorded runs\n',
491
- )
494
+ const none = parsed.failed === true && !noRuns() ? 'no failed runs' : 'no runs yet'
495
+ ui.print('out', ui.status('info', 'vx last', none))
492
496
  return 0
493
497
  }
494
498
  const lines = formatRunList(
@@ -504,6 +508,12 @@ export async function lastCmd(args: readonly string[]): Promise<number> {
504
508
  : parsed.failed === true
505
509
  ? (failedInvocations(db, 1)[0] ?? null)
506
510
  : (listInvocations(db, { limit: 1 })[0] ?? null)
511
+ if ((inv === null || inv === undefined) && noRuns()) {
512
+ throw refusal(
513
+ { tone: 'info', headline: 'no runs yet', fix: 'vx run' },
514
+ { code: 'VX_E_NO_HISTORY', message: 'vx last: no recorded runs yet — run something first' },
515
+ )
516
+ }
507
517
  if (inv === null || inv === undefined) {
508
518
  throw new UserError(
509
519
  noRuns()
package/src/cli/lock.ts CHANGED
@@ -17,11 +17,14 @@ import {
17
17
  } from '../orchestrator/index.js'
18
18
  import { resolveSetting, runRules } from '../settings.js'
19
19
  import {
20
- configErrorFrame,
20
+ refusalNotice,
21
21
  DOT_VX_GITATTRIBUTES,
22
22
  isUserError,
23
23
  relPosix,
24
+ type RefusalParts,
24
25
  secretMask,
26
+ ui,
27
+ sayNotice,
25
28
  } from '../util/index.js'
26
29
  import {
27
30
  findProjectConfigs,
@@ -84,7 +87,7 @@ export async function lockCmd(args: readonly string[]): Promise<number> {
84
87
 
85
88
  /**
86
89
  * Projects with tasks and no vx.config: a plugin's tasks, which load live
87
- * in every run, so both verbs name them — `locked 0 project configs`
90
+ * in every run, so both verbs name them — `0 project configs`
88
91
  * on a turbo() repo read like an audit of something (2026-09-16). One with
89
92
  * no task is left out: solidjs/solid's solid-ssr, which has no script Turbo
90
93
  * runs, read as a project whose tasks come live from the plugin `project` stage.
@@ -100,13 +103,14 @@ function bareProjects(all: readonly ProjectMeta[], tasked: ReadonlySet<string>):
100
103
  export async function lockWorkspace(
101
104
  root: string,
102
105
  warn: (message: string) => void = warnToStderr,
106
+ say: (line: string) => void = (line) => ui.print('out', ui.status('ok', 'vx lock', line)),
103
107
  ): Promise<number> {
104
108
  const check = await audit(root, warn)
105
109
  if (check.broken !== null) {
106
- process.stderr.write(`vx lock: ${LOCKFILE_NAME} not written: ${framed(check.broken)}\n`)
110
+ notWritten(check.broken, check.brokenParts)
107
111
  return 1
108
112
  }
109
- return await writeLock(root, check)
113
+ return await writeLock(root, check, say)
110
114
  }
111
115
 
112
116
  /**
@@ -159,6 +163,7 @@ async function audit(root: string, warn: (message: string) => void): Promise<Gra
159
163
  closure: false,
160
164
  configs: lock,
161
165
  warn,
166
+ lock: true,
162
167
  })
163
168
  for (const entry of projects.values()) resolved.set(entry.name, entry.config)
164
169
  const names = new Set<string>()
@@ -193,7 +198,8 @@ async function audit(root: string, warn: (message: string) => void): Promise<Gra
193
198
  rules,
194
199
  })
195
200
  } catch (err) {
196
- if (isUserError(err)) return { ...out, broken: err.message }
201
+ if (isUserError(err))
202
+ return { ...out, broken: err.message, ...(err.parts ? { brokenParts: err.parts } : {}) }
197
203
  throw err
198
204
  }
199
205
  return out
@@ -202,6 +208,7 @@ async function audit(root: string, warn: (message: string) => void): Promise<Gra
202
208
  interface GraphCheck {
203
209
  /** A graph rule's refusal, or a config that does not load; null when sound. */
204
210
  broken: string | null
211
+ brokenParts?: RefusalParts
205
212
  /** The lock the configs evaluate to here. */
206
213
  fresh: Lockfile
207
214
  /** Every project, as a run discovers them from `fresh`. */
@@ -219,23 +226,25 @@ function configured(all: readonly ProjectMeta[]): ConfiguredMeta[] {
219
226
  }
220
227
 
221
228
  /**
222
- * `vx init`'s last step: the lock, as `vx lock` writes it. A refusal (a
223
- * graph rule, a config that does not load) is printed and leaves the
224
- * written configs in place.
229
+ * `vx init`'s last step: the lock, as `vx lock` writes it, its line
230
+ * returned for the report's `lock` row. A refusal (a graph rule, a config
231
+ * that does not load) is printed and leaves the written configs in place.
225
232
  */
226
- export async function lockAfterMigration(root: string): Promise<number> {
233
+ export async function lockAfterMigration(root: string): Promise<{ code: number; said: string }> {
234
+ let said = ''
227
235
  try {
228
- return await lockWorkspace(root)
236
+ const code = await lockWorkspace(root, warnToStderr, (line) => (said = line))
237
+ return { code, said }
229
238
  } catch (err) {
230
239
  if (!isUserError(err)) throw err
231
- process.stderr.write(`vx lock: ${LOCKFILE_NAME} not written: ${framed(err.message)}\n`)
232
- return 1
240
+ notWritten(err.message, err.parts)
241
+ return { code: 1, said }
233
242
  }
234
243
  }
235
244
 
236
- /** A config refusal as a run used to print it: path:line:col and the source line. */
237
- function framed(message: string): string {
238
- return configErrorFrame(message, process.cwd()) ?? message
245
+ /** The lock refused: the refusal's own parts, or its config line and frame, or its message. */
246
+ function notWritten(message: string, parts: RefusalParts | undefined): void {
247
+ ui.print('err', refusalNotice('vx lock', message, parts, { headline: 'lock not written' }))
239
248
  }
240
249
 
241
250
  /** Names shown per note: a turbo() repo can have hundreds of bare projects. */
@@ -295,7 +304,11 @@ async function writeIfMissing(file: string, text: string): Promise<void> {
295
304
  }
296
305
  }
297
306
 
298
- async function writeLock(root: string, check: GraphCheck): Promise<number> {
307
+ async function writeLock(
308
+ root: string,
309
+ check: GraphCheck,
310
+ say: (line: string) => void,
311
+ ): Promise<number> {
299
312
  const metas = configured(check.all)
300
313
  const bare = bareProjects(check.all, check.tasked)
301
314
  // By project name, as the refusal names them.
@@ -310,11 +323,12 @@ async function writeLock(root: string, check: GraphCheck): Promise<number> {
310
323
  for (const l of secretsIn(Object.fromEntries(reviews.map((r) => [r.name, r.config]))))
311
324
  if (!named.has(l.at)) leaks.push(l)
312
325
  if (leaks.length > 0) {
313
- process.stderr.write(
314
- `vx lock: ${LOCKFILE_NAME} is committed, and these configs evaluated to a secret value:\n` +
315
- leaks.map((l) => ` ${l.at}\n`).join('') +
316
- `let the shell expand it ($${leaks[0]!.name} in the command, the name in exec.env.passThrough) instead of reading process.env in the config\n`,
317
- )
326
+ notWritten('', {
327
+ headline: 'these configs evaluated to a secret value',
328
+ what: leaks.map((l) => l.at),
329
+ why: 'the lock is committed, so the secret would be too',
330
+ fix: `let the shell expand it ($${leaks[0]!.name} in the command, the name in exec.env.passThrough) instead of reading process.env in the config`,
331
+ })
318
332
  return 1
319
333
  }
320
334
  await writeLockfile(root, check.fresh)
@@ -323,9 +337,7 @@ async function writeLock(root: string, check: GraphCheck): Promise<number> {
323
337
  writeIfMissing(path.join(root, '.vx', '.gitattributes'), DOT_VX_GITATTRIBUTES),
324
338
  ])
325
339
  const n = metas.length
326
- process.stdout.write(
327
- `vx lock: locked ${n} project config${n === 1 ? '' : 's'} → ${LOCKFILE_NAME}${bareNote(bare)}\n`,
328
- )
340
+ say(`${n} project config${n === 1 ? '' : 's'} → ${LOCKFILE_NAME}${bareNote(bare)}`)
329
341
  return 0
330
342
  }
331
343
 
@@ -389,7 +401,7 @@ async function checkLock(root: string, json: boolean): Promise<number> {
389
401
  const lock = await readLockfile(root)
390
402
  if (!lock) {
391
403
  const problem = `no ${LOCKFILE_NAME} at ${root} — run \`vx lock\` first`
392
- if (!json) process.stderr.write(`vx lock --check: ${problem}\n`)
404
+ if (!json) sayNotice('error', 'vx lock --check', problem)
393
405
  return answer([problem])
394
406
  }
395
407
  const failures: string[] = []
@@ -451,12 +463,12 @@ async function checkLock(root: string, json: boolean): Promise<number> {
451
463
  }
452
464
  if (json) return answer(failures)
453
465
  if (failures.length > 0) {
454
- for (const f of failures) process.stderr.write(`vx lock --check: ${f}\n`)
466
+ for (const f of failures) sayNotice('error', 'vx lock --check', f)
455
467
  return 1
456
468
  }
457
469
  const n = metas.length
458
470
  const audited = `${n} project${n === 1 ? '' : 's'}`
459
471
  const skipped = bare.length === 0 ? '' : `; not audited, no vx.config: ${bareNames(bare)}`
460
- process.stdout.write(`vx lock --check: up to date (${audited}${skipped})\n`)
472
+ ui.print('out', ui.status('ok', 'vx lock --check', `up to date (${audited}${skipped})`))
461
473
  return 0
462
474
  }