@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/util/ui.ts ADDED
@@ -0,0 +1,197 @@
1
+ // vx's output components (org/devex/log-design.md). A verb or a plugin
2
+ // composes these instead of writing raw lines: each component owns its
3
+ // color, indent and spacing, so every surface shares one look. Colors
4
+ // are decided at print time, per stream.
5
+
6
+ import {
7
+ detectColors,
8
+ ERROR,
9
+ identityColor,
10
+ paint,
11
+ SUCCESS,
12
+ WARN,
13
+ type ColorSupport,
14
+ } from './colors.js'
15
+ import { gradientRule } from './report.js'
16
+
17
+ export type Tone = 'ok' | 'warn' | 'error' | 'info'
18
+
19
+ const INFO = '#06b6d4'
20
+ const HUE: Record<Tone, string> = { ok: SUCCESS, warn: WARN, error: ERROR, info: INFO }
21
+ const SYMBOL: Record<Tone, string> = { ok: '✓', warn: '!', error: '✗', info: '›' }
22
+ const INDENT = ' '
23
+ const LABEL_MIN = 8
24
+
25
+ type Style = Tone | 'dim' | 'bold' | 'id'
26
+
27
+ /** Styled text inside a component: a plain string, a token, or a run of them. */
28
+ export type Inline = string | { readonly style: Style; readonly text: string } | readonly Inline[]
29
+
30
+ /** A laid-out piece of output; `render` and `print` space blocks apart. */
31
+ export interface Block {
32
+ readonly lines: (colors: ColorSupport) => string[]
33
+ }
34
+
35
+ /** A `project` or `project#task` id, each half in its name's hue, the `#` dim. */
36
+ export const id = (text: string): Inline => ({ style: 'id', text })
37
+ /** A path, glob or file: bold. */
38
+ export const path = (text: string): Inline => ({ style: 'bold', text })
39
+ /** A command to type: bold. */
40
+ export const cmd = (text: string): Inline => ({ style: 'bold', text })
41
+ /** A secondary fact. */
42
+ export const dim = (text: string): Inline => ({ style: 'dim', text })
43
+ /** Text in a tone's color. */
44
+ export const tone = (t: Tone, text: string): Inline => ({ style: t, text })
45
+
46
+ function inline(x: Inline, c: ColorSupport): string {
47
+ if (typeof x === 'string') return x
48
+ if (Array.isArray(x)) return (x as readonly Inline[]).map((p) => inline(p, c)).join('')
49
+ const { style, text } = x as { style: Style; text: string }
50
+ switch (style) {
51
+ case 'dim':
52
+ return paint('', text, c, { dim: true })
53
+ case 'bold':
54
+ return paint('', text, c, { bold: true })
55
+ case 'id': {
56
+ const hash = text.lastIndexOf('#')
57
+ if (hash < 0) return paint(identityColor(text), text, c)
58
+ const task = text.slice(hash + 1)
59
+ return (
60
+ paint(identityColor(text.slice(0, hash)), text.slice(0, hash), c) +
61
+ paint('', '#', c, { dim: true }) +
62
+ paint(identityColor(task), task, c)
63
+ )
64
+ }
65
+ default:
66
+ return paint(HUE[style], text, c)
67
+ }
68
+ }
69
+
70
+ const block = (lines: (c: ColorSupport) => string[]): Block => ({ lines })
71
+ const lines = (x: Inline, c: ColorSupport): string[] => inline(x, c).split('\n')
72
+ const visible = (s: string): number => Bun.stringWidth(s)
73
+
74
+ /** `✗ vx lock lock not written`: the tone's symbol, the bold verb, the headline. */
75
+ export function status(t: Tone, verb: string, headline: Inline): Block {
76
+ return block((c) => [
77
+ `${paint(HUE[t], SYMBOL[t], c)} ${paint('', verb, c, { bold: true })} ${inline(headline, c)}`,
78
+ ])
79
+ }
80
+
81
+ /** Indented short lines: what is wrong, or what happened. */
82
+ export function text(...items: Inline[]): Block {
83
+ return block((c) => items.flatMap((i) => lines(i, c).map((l) => `${INDENT}${l}`)))
84
+ }
85
+
86
+ /** An indented dim line: why it matters. */
87
+ export function why(line: Inline): Block {
88
+ return block((c) => lines(line, c).map((l) => `${INDENT}${paint('', l, c, { dim: true })}`))
89
+ }
90
+
91
+ /** A label and its values; values after the first sit under it. `null` is a blank line. */
92
+ export type FieldRow = readonly [label: string, ...values: Inline[]] | null
93
+
94
+ /** A dim label column with the values aligned beside it; `next` and `fix` are bold. */
95
+ export function fields(rows: readonly FieldRow[]): Block {
96
+ return block((c) => {
97
+ const width = Math.max(LABEL_MIN, ...rows.map((r) => (r === null ? 0 : r[0].length)))
98
+ const out: string[] = []
99
+ for (const row of rows) {
100
+ if (row === null) {
101
+ out.push('')
102
+ continue
103
+ }
104
+ const [label, ...values] = row
105
+ const strong = label === 'next' || label === 'fix'
106
+ values
107
+ .flatMap((v) => lines(v, c))
108
+ .forEach((v, i) => {
109
+ const head =
110
+ i === 0 ? paint('', label.padEnd(width), c, { dim: true }) : ' '.repeat(width)
111
+ const value = strong ? paint('', v, c, { bold: true }) : v
112
+ out.push(`${INDENT}${head} ${value}`.trimEnd())
113
+ })
114
+ }
115
+ return out
116
+ })
117
+ }
118
+
119
+ /** `fix <action>`: what to do about it. */
120
+ export function fix(action: Inline): Block {
121
+ return labelled('fix', action)
122
+ }
123
+
124
+ /** `next <command>`: the command to type next. */
125
+ export function next(command: Inline): Block {
126
+ return labelled('next', command)
127
+ }
128
+
129
+ function labelled(label: string, value: Inline): Block {
130
+ return block((c) => [
131
+ `${INDENT}${paint('', label, c, { dim: true })} ${paint('', inline(value, c), c, { bold: true })}`,
132
+ ])
133
+ }
134
+
135
+ /** A report: a blank line, the brand rule naming `mark`, then its fields. */
136
+ export function report(mark: string, rows: readonly FieldRow[]): Block {
137
+ return block((c) => ['', gradientRule(c, mark), ...fields(rows).lines(c)])
138
+ }
139
+
140
+ /** Aligned columns under a dim header. */
141
+ export function table(columns: readonly string[], rows: readonly (readonly Inline[])[]): Block {
142
+ return block((c) => {
143
+ const cells = rows.map((r) => r.map((x) => inline(x, c)))
144
+ const widths = columns.map((h, i) =>
145
+ Math.max(h.length, ...cells.map((r) => visible(r[i] ?? ''))),
146
+ )
147
+ const pad = (s: string, i: number): string =>
148
+ i === columns.length - 1 ? s : s + ' '.repeat(widths[i]! - visible(s))
149
+ return [
150
+ `${INDENT}${paint('', columns.map((h, i) => pad(h, i)).join(' '), c, { dim: true })}`.trimEnd(),
151
+ ...cells.map((r) => `${INDENT}${r.map(pad).join(' ')}`.trimEnd()),
152
+ ]
153
+ })
154
+ }
155
+
156
+ /** A source frame (`> 1 | …` and its caret), indented. */
157
+ export function code(frame: string): Block {
158
+ return block(() => frame.split('\n').map((l) => (l === '' ? '' : `${INDENT}${l}`)))
159
+ }
160
+
161
+ export interface NoticeParts {
162
+ tone: Tone
163
+ verb: string
164
+ headline: Inline
165
+ /** What is wrong, one fact a line. */
166
+ what?: readonly Inline[]
167
+ why?: Inline
168
+ /** A source frame, below the facts. */
169
+ code?: string
170
+ fix?: Inline
171
+ }
172
+
173
+ /** A refusal or a warning: status, then what and why, the frame, and the fix, a blank line apart. */
174
+ export function notice(p: NoticeParts): Block {
175
+ return block((c) => {
176
+ const parts: Block[] = [status(p.tone, p.verb, p.headline)]
177
+ const body = [
178
+ ...(p.what !== undefined ? [text(...p.what)] : []),
179
+ ...(p.why !== undefined ? [why(p.why)] : []),
180
+ ]
181
+ if (body.length > 0) parts.push(block((cc) => body.flatMap((b) => b.lines(cc))))
182
+ if (p.code !== undefined) parts.push(code(p.code))
183
+ if (p.fix !== undefined) parts.push(fix(p.fix))
184
+ return parts.flatMap((b, i) => (i === 0 ? b.lines(c) : ['', ...b.lines(c)]))
185
+ })
186
+ }
187
+
188
+ /** The blocks as text, a blank line apart, ending in a newline. */
189
+ export function render(blocks: readonly Block[], colors: ColorSupport): string {
190
+ return `${blocks.map((b) => b.lines(colors).join('\n')).join('\n\n')}\n`
191
+ }
192
+
193
+ /** Write the blocks to stdout (`out`) or stderr (`err`), colored for that stream. */
194
+ export function print(stream: 'out' | 'err', ...blocks: Block[]): void {
195
+ const s = stream === 'out' ? process.stdout : process.stderr
196
+ s.write(render(blocks, detectColors(s)))
197
+ }
@@ -143,6 +143,8 @@ export interface AffectedChanges {
143
143
  /** Project → its changed paths, relative to the project's directory. */
144
144
  paths: ReadonlyMap<string, readonly string[]>
145
145
  whole: ReadonlySet<string>
146
+ /** A whole project → the workspace-wide file that made it so (a workspace config, a claimed root file). */
147
+ wholeBy?: ReadonlyMap<string, string>
146
148
  /**
147
149
  * The changed paths that are nested repositories (a submodule, an
148
150
  * embedded repository), workspace-relative: each stands for every file
@@ -269,11 +271,17 @@ export async function affectedChanges(
269
271
  // itself, so an unclaimed one is a path like any other.
270
272
  //
271
273
  // Selection is not hashed, so widening it here changes no cache key.
272
- const everything = (): AffectedChanges => {
274
+ const everything = (file: string): AffectedChanges => {
273
275
  const all = new Set(args.projects.map((p) => p.name))
274
- return { projects: all, changed, paths: new Map(), whole: all }
276
+ const wholeBy = new Map([...all].map((n) => [n, file]))
277
+ return { projects: all, changed, paths: new Map(), whole: all, wholeBy }
275
278
  }
276
- if (workspaceConfigChanged(changed)) return everything()
279
+ // The workspace config's plugins shape every project's resolved config,
280
+ // so its edit can re-key any task and selection cannot tell which (item
281
+ // 953). No key folds the file; selection is not hashed, so widening here
282
+ // changes no key.
283
+ const wsConfig = WORKSPACE_CONFIG_FILENAMES.find((n) => changed.includes(n))
284
+ if (wsConfig !== undefined) return everything(wsConfig)
277
285
  // Loading the workspace file costs an evaluation; only a changed ROOT
278
286
  // name can be claimed, so a diff inside the projects never loads it.
279
287
  let claims: FingerprintClaims | undefined
@@ -287,12 +295,13 @@ export async function affectedChanges(
287
295
  const lock = await bytesOrNull(path.join(args.workspaceRoot, 'bun.lock'))
288
296
  if (lock !== null) {
289
297
  const patches = new Set(bunPatchFiles(lock))
290
- if (changed.some((p) => patches.has(p)) && (await loadClaims())?.files.has('bun.lock')) {
291
- return everything()
298
+ const patch = changed.find((p) => patches.has(p))
299
+ if (patch !== undefined && (await loadClaims())?.files.has('bun.lock')) {
300
+ return everything(patch)
292
301
  }
293
302
  }
294
303
  }
295
- const claimedOwned = new Set<string>()
304
+ const claimedOwned = new Map<string, string>()
296
305
  const rootNamesChanged = changed.filter((p) => !p.includes('/'))
297
306
  const claimed = rootNamesChanged.length > 0 ? await loadClaims() : undefined
298
307
  for (const file of rootNamesChanged) {
@@ -302,8 +311,8 @@ export async function affectedChanges(
302
311
  before: await gitBytesAt(args.workspaceRoot, base, file),
303
312
  after: await bytesOrNull(path.join(args.workspaceRoot, file)),
304
313
  })
305
- if (answer === undefined) return everything()
306
- for (const name of answer) claimedOwned.add(name)
314
+ if (answer === undefined) return everything(file)
315
+ for (const name of answer) if (!claimedOwned.has(name)) claimedOwned.set(name, file)
307
316
  }
308
317
 
309
318
  const realDirs = new Map(
@@ -312,7 +321,7 @@ export async function affectedChanges(
312
321
  ),
313
322
  )
314
323
  const paths = new Map<string, string[]>()
315
- const whole = new Set<string>(claimedOwned)
324
+ const whole = new Set<string>(claimedOwned.keys())
316
325
  const owned = projectsContaining(
317
326
  args.workspaceRoot,
318
327
  changed,
@@ -330,7 +339,7 @@ export async function affectedChanges(
330
339
  }
331
340
  : undefined,
332
341
  )
333
- for (const name of claimedOwned) owned.add(name)
342
+ for (const name of claimedOwned.keys()) owned.add(name)
334
343
 
335
344
  // A config that makes an existing directory a project took files from
336
345
  // the project above it.
@@ -367,6 +376,7 @@ export async function affectedChanges(
367
376
  changed,
368
377
  paths,
369
378
  whole,
379
+ ...(claimedOwned.size > 0 ? { wholeBy: claimedOwned } : {}),
370
380
  ...(nestedDirs.length > 0 ? { nested: nestedDirs } : {}),
371
381
  })
372
382
  if (changed.length === 0 || args.workspaceGlobOwners === undefined) return done()
@@ -462,19 +472,6 @@ async function gitBlobsAt(
462
472
  return blobs
463
473
  }
464
474
 
465
- /**
466
- * Whether `changed` holds the workspace config. Its plugins' `config` and
467
- * `project` stages shape every project's resolved config, so such an edit
468
- * can re-key any task, and selection cannot tell which: an edit that
469
- * changed every key selected nothing (item 953). No key folds the file
470
- * (its placement plugins must not split a cache between machines); a
471
- * stage it installs re-keys through the resolved configs instead.
472
- * Selection is not hashed, so widening here changes no key.
473
- */
474
- function workspaceConfigChanged(changed: readonly string[]): boolean {
475
- return WORKSPACE_CONFIG_FILENAMES.some((n) => changed.includes(n))
476
- }
477
-
478
475
  /**
479
476
  * A root file's bytes at `ref`, or null when the ref has no such file.
480
477
  * `./` anchors the path to the cwd (the workspace root) rather than the
@@ -1,5 +1,5 @@
1
1
  import type { DependencyRule } from '../config.js'
2
- import { UserError } from '../util/index.js'
2
+ import { refusal } from '../util/index.js'
3
3
  import { compileNameGlob } from './filter.js'
4
4
  import type { PackageGraph } from './package-graph.js'
5
5
 
@@ -40,9 +40,17 @@ export function checkDependencyRules(
40
40
  }
41
41
  }
42
42
  if (broken.length === 0) return
43
- throw new UserError(
44
- `${broken.length === 1 ? '1 dependency breaks' : `${broken.length} dependencies break`} run.rules.dependencies:\n${broken.map((b) => ` ${b}`).join('\n')}\nDrop the dependency, or change the project's tags or the rule.`,
45
- 'VX_E_DEPENDENCY_RULE',
43
+ const headline = `${broken.length === 1 ? '1 dependency breaks' : `${broken.length} dependencies break`} run.rules.dependencies`
44
+ throw refusal(
45
+ {
46
+ headline,
47
+ what: broken,
48
+ fix: "drop the dependency, or change the project's tags or the rule",
49
+ },
50
+ {
51
+ code: 'VX_E_DEPENDENCY_RULE',
52
+ message: `${headline}:\n${broken.map((b) => ` ${b}`).join('\n')}\nDrop the dependency, or change the project's tags or the rule.`,
53
+ },
46
54
  )
47
55
  }
48
56
 
@@ -9,7 +9,8 @@ import { existsSync, readFileSync } from 'node:fs'
9
9
  import { unlink } from 'node:fs/promises'
10
10
  import path from 'node:path'
11
11
  import { workspaceFileWritten } from '../handoff.js'
12
- import { DOT_VX_GITIGNORE, executablePath, gitEnv, relPosix, UserError } from '../util/index.js'
12
+ import { DOT_VX_GITIGNORE, executablePath, gitEnv, relPosix, UserError, ui } from '../util/index.js'
13
+ import { VERSION } from '../version.js'
13
14
  import { WORKSPACE_CONFIG_FILENAMES } from './project-loader.js'
14
15
 
15
16
  /**
@@ -136,6 +137,8 @@ export interface GeneratedProject {
136
137
  importLines: string[]
137
138
  /** The project's `tags` (an Nx project's); none written when empty. */
138
139
  tags?: readonly string[]
140
+ /** Writes `name`: no manifest names the project, so its path would. */
141
+ writeName?: boolean
139
142
  tasks: GeneratedTask[]
140
143
  }
141
144
 
@@ -177,7 +180,7 @@ export interface ApplyMigrationArgs {
177
180
  * migration with nothing to convert is an error. Default false.
178
181
  */
179
182
  init?: boolean
180
- /** Report lines printed under the source line (e.g. "turbo.json found and not read"). */
183
+ /** The report's `note` lines (e.g. "turbo.json found and not read"). */
181
184
  notes?: readonly string[]
182
185
  /**
183
186
  * Scripts exist but no project can carry them (a member without a
@@ -205,11 +208,14 @@ export interface ApplyMigrationArgs {
205
208
  /**
206
209
  * Run after the files are written (not under `dry`): `vx lock`'s step, so
207
210
  * the migrated workspace has its .vx/projects.lock.json and its graph rules checked
208
- * (owner, 2026-10-14). Prints its own line. A refusal (a graph rule, a
209
- * plugin not installed yet) is printed and the migration still succeeds:
210
- * its files are written, and `vx lock` is the step to re-run.
211
+ * (owner, 2026-10-14). On success `said` is its line, shown as the
212
+ * report's `lock` row; a refusal (a graph rule, a plugin not installed
213
+ * yet) prints above the report and the migration still succeeds: its
214
+ * files are written, and `vx lock` is the step to re-run.
211
215
  */
212
- lock?: (root: string) => Promise<number>
216
+ lock?: (root: string) => Promise<{ code: number; said: string }>
217
+ /** Rows of what the tool did before the write, e.g. `['ran', 'bun add -d …']`. */
218
+ steps?: readonly (readonly [label: string, value: string])[]
213
219
  /** What the written workspace file declares, e.g. `pnpm()` from `@vzn/vx-lockfile`. */
214
220
  plugins?: readonly WorkspacePlugin[]
215
221
  }
@@ -363,89 +369,20 @@ export async function applyMigration(args: ApplyMigrationArgs): Promise<number>
363
369
  )
364
370
  const ignoredNote = ignored.length === 0 ? [] : [ignoreAdvice(root, ignored, dry)]
365
371
  const todos = new Map<string, string[]>()
366
- let todoCount = 0
367
372
  let clean = 0
368
- let cached = false
373
+ let todo = 0
369
374
  for (const p of projects) {
370
375
  for (const t of p.tasks) {
371
376
  if (t.todos.length === 0 && t.task !== null) clean++
372
- if (t.task !== null && 'cache' in t.task) cached = true
377
+ if (t.todos.length > 0) todo++
373
378
  for (const reason of t.todos) {
374
- todoCount++
375
379
  const ids = todos.get(reason) ?? []
376
380
  ids.push(`${p.name}#${t.name}`)
377
381
  todos.set(reason, ids)
378
382
  }
379
383
  }
380
384
  }
381
- const report: string[] = []
382
- const emptyReason = empty ? args.emptyReason : undefined
383
- if (emptyReason !== undefined) {
384
- report.push(
385
- `${verb}: ${emptyReason}`,
386
- hasWorkspaceFile
387
- ? `${workspaceName} already exists.`
388
- : dry
389
- ? `would write ${workspaceName} (dry run, nothing written).`
390
- : `wrote ${workspaceName}.`,
391
- )
392
- } else if (empty) {
393
- report.push(
394
- args.unmapped === true
395
- ? `${verb}: no package.json scripts became tasks.`
396
- : `${verb}: no package.json scripts to turn into tasks.`,
397
- ...[...(args.notes ?? []), ...ignoredNote].map((n) => `note: ${n}`),
398
- hasWorkspaceFile
399
- ? `${workspaceName} already exists.`
400
- : dry
401
- ? `would write ${workspaceName} (dry run, nothing written).`
402
- : `wrote ${workspaceName}.`,
403
- `Declare tasks in a ${configName} beside a package.json — your own command, for example:`,
404
- '',
405
- ...exampleConfig(format)
406
- .trimEnd()
407
- .split('\n')
408
- .map((l) => ` ${l}`),
409
- )
410
- } else {
411
- report.push(`${verb}: ${source} → ${[...new Set(names.values())].join(', ') || configName}`)
412
- for (const n of [...(args.notes ?? []), ...ignoredNote, ...formatted]) report.push(`note: ${n}`)
413
- for (const n of plan.headerNotes) report.push(`note: ${n}`)
414
- report.push(
415
- '',
416
- `${clean} task${clean === 1 ? '' : 's'} migrated clean, ` +
417
- `${todoCount} TODO${todoCount === 1 ? '' : 's'}${todoCount > 0 ? ':' : ''}`,
418
- )
419
- for (const [reason, ids] of todos) {
420
- if (ids.length === 1) report.push(` ${ids[0]}: ${reason}`)
421
- else {
422
- const shown = ids.slice(0, TODO_IDS_SHOWN).join(', ')
423
- const more = ids.length - TODO_IDS_SHOWN
424
- report.push(` ${shown}${more > 0 ? ` and ${more} more` : ''}:`, ` ${reason}`)
425
- }
426
- }
427
- report.push(...plan.notes)
428
- if (files.length === 0) report.push(dry ? 'no files (dry run)' : 'no files written')
429
- else report.push(dry ? 'files (dry run, nothing written):' : 'files written:')
430
- report.push(...fileLines(files.map((f) => f.relPath)))
431
- if (kept.length > 0) {
432
- report.push('kept (each already has a vx config):')
433
- for (const f of kept) report.push(` ${f}`)
434
- }
435
- if (replaced.length > 0) {
436
- report.push(dry ? 'would replace (dry run):' : 'replaced:')
437
- for (const f of replaced) report.push(` ${relPosix(root, f)}`)
438
- }
439
- }
440
385
  const firstTask = nextTask(plan.projects.flatMap((p) => p.tasks))
441
- // A run of tasks none of which caches is never a hit, and a first try
442
- // that runs twice to see the cache work saw it run twice.
443
- if (!cached && [...todos.keys()].some(isCacheTodo)) {
444
- report.push(
445
- '',
446
- 'no task caches yet: uncomment the cache line in each TODO task with its real globs, and a second run hits',
447
- )
448
- }
449
386
  const installed = existsSync(path.join(root, 'node_modules', '@vzn', 'vx', 'package.json'))
450
387
  // The line is the next thing to type, so it names what that run would
451
388
  // refuse without: a git work tree (vx keys inputs on git's view) and,
@@ -460,6 +397,14 @@ export async function applyMigration(args: ApplyMigrationArgs): Promise<number>
460
397
  ...(!dry && args.lock === undefined ? [`${vx} lock`] : []),
461
398
  ]
462
399
  const next = steps.length === 0 ? run : `${steps.join(', ')}, then ${run}`
400
+ const emptyReason = empty ? args.emptyReason : undefined
401
+ const notes = [
402
+ ...(args.notes ?? []),
403
+ ...ignoredNote,
404
+ ...formatted,
405
+ ...plan.headerNotes,
406
+ ...plan.notes,
407
+ ]
463
408
  if (args.json === true) {
464
409
  process.stdout.write(
465
410
  `${JSON.stringify({
@@ -471,24 +416,75 @@ export async function applyMigration(args: ApplyMigrationArgs): Promise<number>
471
416
  todos: [...todos].map(([reason, tasks]) => ({ reason, tasks })),
472
417
  notes: [
473
418
  ...(emptyReason === undefined ? [] : [emptyReason]),
474
- ...(args.notes ?? []),
475
- ...ignoredNote,
476
- ...plan.headerNotes,
477
- ...plan.notes,
419
+ ...(args.steps ?? []).map(([label, value]) => `${label}: ${value}`),
420
+ ...notes,
478
421
  ],
479
422
  next,
480
423
  })}\n`,
481
424
  )
482
425
  return 0
483
426
  }
484
- if (dry || args.lock === undefined) {
485
- report.push('', `next: ${next}`)
486
- process.stdout.write(`${report.join('\n')}\n`)
487
- return 0
427
+
428
+ // The lock's own words become a row; a refusal prints above the report.
429
+ const lock = dry || args.lock === undefined ? undefined : await args.lock(root)
430
+ if (lock !== undefined && lock.code !== 0) process.stderr.write(lock.said)
431
+
432
+ const rows: ui.FieldRow[] = [['from', source]]
433
+ if (dry) rows.push(['dry run', 'nothing written'])
434
+ for (const [label, value] of args.steps ?? []) rows.push([label, value])
435
+ if (empty) {
436
+ const why =
437
+ emptyReason ??
438
+ (args.unmapped === true
439
+ ? 'no package.json script became a task'
440
+ : 'no package.json scripts to turn into tasks')
441
+ rows.push(['tasks', [ui.dim('none'), `: ${why}`]])
442
+ } else {
443
+ const parts = [
444
+ ...(clean > 0 ? [ui.tone('ok', `${clean} clean`)] : []),
445
+ ...(todo > 0 ? [ui.tone('warn', `${todo} todo`)] : []),
446
+ ]
447
+ if (parts.length > 0)
448
+ rows.push(['tasks', parts.flatMap((p, i) => (i === 0 ? [p] : [ui.dim(' · '), p]))])
449
+ const todoLines: string[] = []
450
+ for (const [reason, ids] of todos) {
451
+ if (ids.length === 1) todoLines.push(`${ids[0]}: ${reason}`)
452
+ else {
453
+ const shown = ids.slice(0, TODO_IDS_SHOWN).join(', ')
454
+ const more = ids.length - TODO_IDS_SHOWN
455
+ todoLines.push(`${shown}${more > 0 ? ` and ${more} more` : ''}:`, ` ${reason}`)
456
+ }
457
+ }
458
+ if (todoLines.length > 0) rows.push(['todo', ...todoLines])
459
+ }
460
+ if (files.length > 0) rows.push(['files', ...fileLines(files.map((f) => f.relPath))])
461
+ const keptLines = [
462
+ ...kept.map((f) => `${f} (has a vx config)`),
463
+ ...(empty && hasWorkspaceFile ? [workspaceName] : []),
464
+ ]
465
+ if (keptLines.length > 0) rows.push(['kept', ...keptLines])
466
+ if (replaced.length > 0) rows.push(['replaced', ...replaced.map((f) => relPosix(root, f))])
467
+ if (lock !== undefined) {
468
+ rows.push([
469
+ 'lock',
470
+ lock.code === 0
471
+ ? lock.said.trim().replace(/^✓ vx lock\s+/, '')
472
+ : ui.tone('error', 'not written'),
473
+ ])
474
+ }
475
+ if (notes.length > 0) rows.push(['note', ...notes])
476
+ if (empty && emptyReason === undefined) {
477
+ rows.push([
478
+ 'example',
479
+ `a ${configName} beside a package.json, with your own command:`,
480
+ '',
481
+ exampleConfig(format).trimEnd(),
482
+ ])
488
483
  }
489
- process.stdout.write(`${report.join('\n')}\n\n`)
490
- const locked = (await args.lock(root)) === 0
491
- process.stdout.write(`\nnext: ${locked ? next : `fix the above and run vx lock, then ${next}`}\n`)
484
+ const said =
485
+ lock === undefined || lock.code === 0 ? next : `fix the above, then vx lock and ${next}`
486
+ rows.push(null, ['next', said])
487
+ ui.print('out', ui.report(`${verb} ${VERSION}`, rows))
492
488
  return 0
493
489
  }
494
490
 
@@ -615,10 +611,9 @@ export function vxInvocation(userAgent: string | undefined, installed: boolean):
615
611
  const FILES_SHOWN = 10
616
612
 
617
613
  function fileLines(paths: readonly string[]): string[] {
618
- if (paths.length <= FILES_SHOWN) return paths.map((p) => ` ${p}`)
614
+ if (paths.length <= FILES_SHOWN) return [...paths]
619
615
  const root = paths.filter((p) => !p.includes('/') || p.startsWith('.vx/'))
620
- const more = paths.length - root.length
621
- return [...root.map((p) => ` ${p}`), ` and ${more} more in package directories`]
616
+ return [...root, `and ${paths.length - root.length} more in package directories`]
622
617
  }
623
618
 
624
619
  /** Ids named per shared TODO; the files carry each one (sveltejs/kit: 92 persistent tasks). */
@@ -691,6 +686,7 @@ function renderConfigFile(p: GeneratedProject, format: MigrationFormat): string
691
686
  if (p.importLines.length > 0) lines.push(...p.importLines)
692
687
  if (lines.length > 0) lines.push('')
693
688
  lines.push('export default {')
689
+ if (p.writeName === true) lines.push(` name: ${quoteTsLiteral(p.name)},`)
694
690
  if (p.tags !== undefined && p.tags.length > 0) lines.push(` tags: ${renderValue(p.tags, ' ')},`)
695
691
  lines.push(p.tasks.length === 0 ? ' tasks: {},' : ' tasks: {')
696
692
  for (const t of p.tasks) {
@@ -15,6 +15,7 @@ import {
15
15
  gitSpawnRefusal,
16
16
  isExecutableMissing,
17
17
  isOutOfFds,
18
+ refusal,
18
19
  relPosix,
19
20
  UserError,
20
21
  } from '../util/index.js'
@@ -75,10 +76,10 @@ export async function findWorkspaceRoot(
75
76
  start = path.resolve(start)
76
77
  const file = await workspaceFile(start)
77
78
  if (file === null) {
78
- throw new UserError(
79
- `no .vx/workspace.ts in ${start} or a parent${(await gitTop(start)) === null ? '' : ' up to the git root'} — ` +
80
- 'run `vx init` to create one',
81
- 'VX_E_NO_WORKSPACE',
79
+ const headline = `no .vx/workspace.ts in ${start} or a parent${(await gitTop(start)) === null ? '' : ' up to the git root'}`
80
+ throw refusal(
81
+ { headline, fix: 'vx init' },
82
+ { code: 'VX_E_NO_WORKSPACE', message: `${headline} — run \`vx init\` to create one` },
82
83
  )
83
84
  }
84
85
  const root = workspaceRootOf(file)