@vzn/vx 0.0.637 → 0.0.638

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (76) hide show
  1. package/docs/caching.md +41 -14
  2. package/docs/cli.md +115 -73
  3. package/docs/execution.md +1 -1
  4. package/docs/features.md +6 -5
  5. package/docs/schema.md +24 -15
  6. package/package.json +5 -5
  7. package/schemas/plan.json +2 -2
  8. package/skills/vx/SKILL.md +13 -2
  9. package/src/bin.ts +13 -7
  10. package/src/cache/cache.ts +21 -2
  11. package/src/cache/chained-cache.ts +4 -0
  12. package/src/cache/index.ts +9 -1
  13. package/src/cache/inputs.ts +0 -24
  14. package/src/cache/layer.ts +7 -1
  15. package/src/cache/layered-cache.ts +4 -0
  16. package/src/cache/plugin-cache.ts +248 -0
  17. package/src/cache/policy.ts +9 -0
  18. package/src/cache/run-history.ts +68 -0
  19. package/src/cache/run-store.ts +77 -0
  20. package/src/cache/schema.ts +38 -9
  21. package/src/cache/scoped-cache.ts +15 -2
  22. package/src/cli/cache.ts +33 -16
  23. package/src/cli/completions.ts +9 -5
  24. package/src/cli/help.ts +16 -7
  25. package/src/cli/index.ts +25 -11
  26. package/src/cli/info.ts +6 -5
  27. package/src/cli/init.ts +14 -15
  28. package/src/cli/last.ts +14 -7
  29. package/src/cli/lock.ts +39 -27
  30. package/src/cli/plan-format.ts +5 -3
  31. package/src/cli/plugin-commands.ts +77 -6
  32. package/src/cli/plugin-templates.ts +1 -1
  33. package/src/cli/run.ts +14 -12
  34. package/src/cli/select.ts +18 -9
  35. package/src/cli/show.ts +66 -40
  36. package/src/cli/task-verb.ts +4 -4
  37. package/src/cli/upgrade.ts +20 -11
  38. package/src/cli/watch-filter.ts +7 -2
  39. package/src/cli/watch-judge.ts +6 -1
  40. package/src/cli/watch.ts +44 -20
  41. package/src/cli/why.ts +10 -1
  42. package/src/cli/workspace-config.ts +2 -2
  43. package/src/graph/scheduler.ts +8 -0
  44. package/src/graph/task-graph.ts +32 -4
  45. package/src/index.ts +11 -1
  46. package/src/orchestrator/affected-tasks.ts +8 -3
  47. package/src/orchestrator/doctor.ts +1 -1
  48. package/src/orchestrator/execute-task.ts +26 -43
  49. package/src/orchestrator/failure-mode.ts +19 -10
  50. package/src/orchestrator/framed-output.ts +1 -2
  51. package/src/orchestrator/history.ts +60 -4
  52. package/src/orchestrator/index.ts +2 -1
  53. package/src/orchestrator/logger.ts +9 -2
  54. package/src/orchestrator/metrics.ts +7 -3
  55. package/src/orchestrator/plugin-host.ts +14 -4
  56. package/src/orchestrator/plugin.ts +80 -8
  57. package/src/orchestrator/prepare.ts +51 -7
  58. package/src/orchestrator/projects.ts +3 -0
  59. package/src/orchestrator/remote-prefetch.ts +9 -5
  60. package/src/orchestrator/run-records.ts +1 -1
  61. package/src/orchestrator/run.ts +126 -27
  62. package/src/orchestrator/status-line.ts +1 -1
  63. package/src/orchestrator/summary.ts +69 -44
  64. package/src/orchestrator/telemetry.ts +1 -1
  65. package/src/settings.ts +12 -1
  66. package/src/{orchestrator → util}/colors.ts +5 -0
  67. package/src/util/config-frame.ts +31 -2
  68. package/src/util/errors.ts +32 -0
  69. package/src/util/index.ts +8 -1
  70. package/src/util/refusal-notice.ts +111 -0
  71. package/src/util/report.ts +31 -0
  72. package/src/util/ui.ts +186 -0
  73. package/src/workspace/affected.ts +20 -23
  74. package/src/workspace/dependency-rules.ts +12 -4
  75. package/src/workspace/migration.ts +87 -91
  76. package/src/workspace/workspace.ts +5 -4
package/src/cli/show.ts CHANGED
@@ -11,7 +11,17 @@ import type { ProjectConfig, TaskConfig } from '../config.js'
11
11
  import { declaredTask } from '../graph/index.js'
12
12
  import { affectedTaskProjects, isDefaultBuild } from '../orchestrator/index.js'
13
13
  import { flagHint, formatValue, refuse, seeHelp } from './help.js'
14
- import { listed, nearMatches, relPosix, secretMask, UserError } from '../util/index.js'
14
+ import {
15
+ type ColorSupport,
16
+ detectColors,
17
+ listed,
18
+ nearMatches,
19
+ relPosix,
20
+ secretMask,
21
+ UserError,
22
+ sayNotice,
23
+ ui,
24
+ } from '../util/index.js'
15
25
  import { affectedFilterFor, resolveFilters } from './select.js'
16
26
  import {
17
27
  discoverCliProjects,
@@ -149,7 +159,7 @@ export async function showCmd(args: readonly string[]): Promise<number> {
149
159
  }
150
160
  const r = await resolveFilters(process.cwd(), raw, {}, affected)
151
161
  if ('error' in r) throw new UserError(`vx show: ${r.error}`, r.code ?? 'VX_E_USAGE')
152
- if ('empty' in r) process.stderr.write(`vx show: ${r.empty}\n`)
162
+ if ('empty' in r) sayNotice('info', 'vx show', r.empty)
153
163
  selected = new Set('empty' in r ? [] : r.names)
154
164
  if ('names' in r && r.affected !== undefined)
155
165
  byDiff = { changes: r.affected, outright: r.outright }
@@ -160,7 +170,7 @@ export async function showCmd(args: readonly string[]): Promise<number> {
160
170
 
161
171
  if (parsed.target === undefined) {
162
172
  const shown = metas.filter((m) => inSelection(m.name))
163
- process.stdout.write(renderList(root, shown, projects, parsed.format))
173
+ process.stdout.write(renderList(root, shown, projects, parsed.format, colorsOut()))
164
174
  return 0
165
175
  }
166
176
 
@@ -204,7 +214,7 @@ export async function showCmd(args: readonly string[]): Promise<number> {
204
214
  )
205
215
  shown = shown.filter((p) => kept.has(p.name))
206
216
  }
207
- process.stdout.write(renderTaskAcross(root, shown, projectName!, parsed.format))
217
+ process.stdout.write(renderTaskAcross(root, shown, projectName!, parsed.format, colorsOut()))
208
218
  return 0
209
219
  }
210
220
 
@@ -215,7 +225,7 @@ export async function showCmd(args: readonly string[]): Promise<number> {
215
225
 
216
226
  if (taskName === undefined) {
217
227
  process.stdout.write(
218
- renderProject(meta.name, dir, config, meta.configPath !== null, parsed.format),
228
+ renderProject(meta.name, dir, config, meta.configPath !== null, parsed.format, colorsOut()),
219
229
  )
220
230
  return 0
221
231
  }
@@ -227,7 +237,7 @@ export async function showCmd(args: readonly string[]): Promise<number> {
227
237
  'VX_E_UNKNOWN_TASK',
228
238
  )
229
239
  }
230
- process.stdout.write(renderTask(meta.name, dir, taskName, task, parsed.format))
240
+ process.stdout.write(renderTask(meta.name, dir, taskName, task, parsed.format, colorsOut()))
231
241
  return 0
232
242
  }
233
243
 
@@ -284,11 +294,20 @@ function shownConfig(config: ProjectConfig | null): ProjectConfig | null {
284
294
  }
285
295
  }
286
296
 
297
+ const colorsOut = (): ColorSupport => detectColors(process.stdout)
298
+ const PLAIN: ColorSupport = { enabled: false }
299
+
300
+ /** One block of several, with no blank line between them. */
301
+ const joined = (blocks: readonly ui.Block[]): ui.Block => ({
302
+ lines: (c) => blocks.flatMap((b) => b.lines(c)),
303
+ })
304
+
287
305
  function renderList(
288
306
  root: string,
289
307
  metas: readonly ProjectMeta[],
290
308
  projects: ReadonlyMap<string, ProjectEntry>,
291
309
  format: 'pretty' | 'json',
310
+ colors: ColorSupport = PLAIN,
292
311
  ): string {
293
312
  const rows = metas.map((meta) => {
294
313
  const tasks = projects.get(meta.name)?.config.tasks ?? {}
@@ -309,9 +328,8 @@ function renderList(
309
328
  )}\n`
310
329
  }
311
330
  if (rows.length === 0) return ''
312
- const nameW = Math.max(...rows.map((r) => r.name.length))
313
- const dirW = Math.max(...rows.map((r) => r.dir.length))
314
- const lines = rows.map((r) => {
331
+ const anyTags = rows.some((r) => r.tags.length > 0)
332
+ const cells = rows.map((r) => {
315
333
  const n = r.tasks.length
316
334
  const count = `${n} task${n === 1 ? '' : 's'}`
317
335
  // A package with no config file has only tasks a plugin gave it, or the
@@ -319,14 +337,14 @@ function renderList(
319
337
  const tasks = r.configured
320
338
  ? count
321
339
  : n === 0
322
- ? '(no vx config)'
340
+ ? 'no vx config'
323
341
  : r.onlyDefault
324
- ? `${count} (no vx config; default build)`
325
- : `${count} (no vx config; from plugins)`
326
- const tags = r.tags.length > 0 ? ` [${r.tags.join(', ')}]` : ''
327
- return `${r.name.padEnd(nameW)} ${r.dir.padEnd(dirW)} ${tasks}${tags}`
342
+ ? `${count}, default build (no vx config)`
343
+ : `${count} from plugins (no vx config)`
344
+ return [ui.id(r.name), r.dir, tasks, ...(anyTags ? [r.tags.join(', ')] : [])]
328
345
  })
329
- return `${lines.join('\n')}\n`
346
+ const columns = ['project', 'dir', 'tasks', ...(anyTags ? ['tags'] : [])]
347
+ return ui.render([ui.table(columns, cells)], colors)
330
348
  }
331
349
 
332
350
  function renderProject(
@@ -335,6 +353,7 @@ function renderProject(
335
353
  config: ProjectConfig | null,
336
354
  hasConfigFile: boolean,
337
355
  format: 'pretty' | 'json',
356
+ colors: ColorSupport = PLAIN,
338
357
  ): string {
339
358
  if (format === 'json') {
340
359
  // Round-trip so the printed object is exactly the JSON form of the
@@ -344,14 +363,17 @@ function renderProject(
344
363
  }
345
364
  const tags = config?.tags ?? []
346
365
  const tasks = Object.entries(config?.tasks ?? {})
347
- const head = `${name} — ${dir}${tags.length > 0 ? `\n tags: ${tags.join(', ')}` : ''}${
366
+ const head: ui.FieldRow[] = [
367
+ ['dir', dir],
368
+ ...(tags.length > 0 ? [['tags', tags.join(', ')] as const] : []),
348
369
  // A config-less package a dependant reaches has the default `build` (X-9).
349
- hasConfigFile || tasks.length === 0 ? '' : '\n (no vx config)'
350
- }`
351
- if (tasks.length === 0)
352
- return `${head}\n ${hasConfigFile ? '(no tasks declared)' : '(no vx config)'}\n`
353
- const blocks = tasks.map(([taskName, task]) => taskBlock(taskName, task))
354
- return `${head}\n\n${blocks.join('\n')}`
370
+ ...(hasConfigFile ? [] : [['config', 'none'] as const]),
371
+ ...(tasks.length === 0 && hasConfigFile ? [['tasks', 'none declared'] as const] : []),
372
+ ]
373
+ const blocks = tasks.map(([taskName, task]) =>
374
+ joined([ui.text(ui.id(`${name}#${taskName}`)), ui.fields(taskRows(task))]),
375
+ )
376
+ return ui.render([ui.report(name, head), ...blocks], colors)
355
377
  }
356
378
 
357
379
  function renderTask(
@@ -360,12 +382,13 @@ function renderTask(
360
382
  taskName: string,
361
383
  task: TaskConfig,
362
384
  format: 'pretty' | 'json',
385
+ colors: ColorSupport = PLAIN,
363
386
  ): string {
364
387
  if (format === 'json') {
365
388
  const obj = { name, dir, task: taskName, config: shownTask(task) }
366
389
  return `${JSON.stringify(obj)}\n`
367
390
  }
368
- return `${name} — ${dir}\n\n${taskBlock(taskName, task)}`
391
+ return ui.render([taskReport(name, dir, taskName, task)], colors)
369
392
  }
370
393
 
371
394
  function renderTaskAcross(
@@ -373,6 +396,7 @@ function renderTaskAcross(
373
396
  declaring: readonly ProjectEntry[],
374
397
  taskName: string,
375
398
  format: 'pretty' | 'json',
399
+ colors: ColorSupport,
376
400
  ): string {
377
401
  if (format === 'json') {
378
402
  const list = declaring.map((p) => ({
@@ -383,18 +407,28 @@ function renderTaskAcross(
383
407
  }))
384
408
  return `${JSON.stringify(list)}\n`
385
409
  }
386
- return declaring
387
- .map(
388
- (p) =>
389
- `${p.name} — ${relPosix(root, p.dir) || '.'}\n\n${taskBlock(taskName, p.config.tasks![taskName]!)}`,
390
- )
391
- .join('\n')
410
+ if (declaring.length === 0) return ''
411
+ return ui.render(
412
+ [
413
+ joined(
414
+ declaring.map((p) =>
415
+ taskReport(p.name, relPosix(root, p.dir) || '.', taskName, p.config.tasks![taskName]!),
416
+ ),
417
+ ),
418
+ ],
419
+ colors,
420
+ )
421
+ }
422
+
423
+ /** `─ app#build ───`, the project's dir, then every field the run reads. */
424
+ function taskReport(name: string, dir: string, taskName: string, task: TaskConfig): ui.Block {
425
+ return ui.report(`${name}#${taskName}`, [['dir', dir], ...taskRows(task)])
392
426
  }
393
427
 
394
428
  /** Every field the run reads, in the order the schema declares them. */
395
- function taskBlock(taskName: string, raw: TaskConfig): string {
429
+ function taskRows(raw: TaskConfig): ui.FieldRow[] {
396
430
  const task = shownTask(raw)
397
- const rows: [string, string][] = []
431
+ const rows: ui.FieldRow[] = []
398
432
  const list = (xs: readonly string[] | undefined): string | undefined =>
399
433
  xs === undefined ? undefined : xs.join(', ')
400
434
  const add = (label: string, value: string | number | boolean | undefined): void => {
@@ -437,13 +471,5 @@ function taskBlock(taskName: string, raw: TaskConfig): string {
437
471
  add('outputs.files', list(cache.outputs.files))
438
472
  add('outputs.workspaceFiles', list(cache.outputs.workspaceFiles))
439
473
  }
440
- const labelW = Math.max(...rows.map(([label]) => label.length))
441
- // A multi-line value (a generated `vx_script` wrapper, a heredoc)
442
- // continues under its first line, not at column 0 where it reads as
443
- // the next task's header.
444
- const indent = `\n${' '.repeat(labelW + 4)}`
445
- const body = rows.map(
446
- ([label, value]) => ` ${`${label}:`.padEnd(labelW + 1)} ${value.replaceAll('\n', indent)}`,
447
- )
448
- return `${taskName}\n${body.join('\n')}\n`
474
+ return rows
449
475
  }
@@ -21,7 +21,7 @@ export async function taskVerbHint(
21
21
  cwd: string,
22
22
  typo = false,
23
23
  ): Promise<string | null> {
24
- if (command.includes('#')) return `\`${command}\` is a task: vx run ${command}`
24
+ if (command.includes('#')) return `\`${command}\` is a task (did you mean vx run ${command}?)`
25
25
  const projects = await workspaceProjects(cwd)
26
26
  if (projects === null) return null
27
27
  const tasks = new Set<string>()
@@ -40,8 +40,8 @@ export async function taskVerbHint(
40
40
  ? `vx run ${task} --all`
41
41
  : `vx run ${task}`
42
42
  return task === command
43
- ? `\`${command}\` is a task here, not a command: ${run}`
44
- : `unknown command: ${command}; did you mean the task \`${task}\`? ${run}`
43
+ ? `\`${command}\` is a task here, not a command (did you mean ${run}?)`
44
+ : `unknown command: ${command}, a typo of the task \`${task}\` (did you mean ${run}?)`
45
45
  }
46
46
 
47
47
  /**
@@ -54,7 +54,7 @@ export async function nxTargetHint(tasks: readonly string[], cwd: string): Promi
54
54
  if (projects === null) return null
55
55
  const specs = tasks.map((t) => nxProjectTarget(t, projects))
56
56
  if (specs.some((s) => s === undefined)) return null
57
- return `\`${tasks.join(' ')}\` is Nx's project:target: vx run ${specs.join(' ')}`
57
+ return `\`${tasks.join(' ')}\` is Nx's project:target (did you mean vx run ${specs.join(' ')}?)`
58
58
  }
59
59
 
60
60
  /**
@@ -18,7 +18,7 @@ import { createPublicKey, verify } from 'node:crypto'
18
18
  import { access, chmod, chown, link, mkdtemp, rename, rm, stat } from 'node:fs/promises'
19
19
  import path from 'node:path'
20
20
  import { flagHint, seeHelp } from './help.js'
21
- import { UserError } from '../util/index.js'
21
+ import { UserError, sayNotice, ui } from '../util/index.js'
22
22
  import { VERSION } from '../version.js'
23
23
 
24
24
  const REGISTRY = 'https://registry.npmjs.org'
@@ -522,7 +522,7 @@ async function targetVersion(tag: string | undefined): Promise<string> {
522
522
  async function upgradeManaged(install: ManagedInstall, tag: string | undefined): Promise<number> {
523
523
  const version = await targetVersion(tag)
524
524
  if (version === VERSION) {
525
- process.stdout.write(`vx upgrade: already at ${VERSION} — nothing to do\n`)
525
+ ui.print('out', ui.status('info', 'vx upgrade', `already at ${VERSION}`))
526
526
  return 0
527
527
  }
528
528
  const argv = upgradeArgv(install, version)
@@ -532,8 +532,10 @@ async function upgradeManaged(install: ManagedInstall, tag: string | undefined):
532
532
  `vx upgrade: this vx was installed by ${argv[0]}, which is not on PATH — vx does not install package managers; run it yourself: ${argv.join(' ')} (in ${install.cwd})`,
533
533
  )
534
534
  }
535
- process.stdout.write(
536
- `vx upgrade: ${VERSION} → ${version}: running ${argv.join(' ')} (in ${install.cwd})\n`,
535
+ ui.print(
536
+ 'out',
537
+ ui.status('info', 'vx upgrade', `${VERSION} → ${version}`),
538
+ ui.text([ui.cmd(argv.join(' ')), ui.dim(` in ${install.cwd}`)]),
537
539
  )
538
540
  const proc = Bun.spawnSync({
539
541
  cmd: [bin, ...argv.slice(1)],
@@ -551,8 +553,10 @@ async function upgradeManaged(install: ManagedInstall, tag: string | undefined):
551
553
  export async function upgradeCmd(args: readonly string[]): Promise<number> {
552
554
  const unknown = args.find((a) => a.startsWith('-'))
553
555
  if (unknown !== undefined) {
554
- process.stderr.write(
555
- `vx upgrade: unknown flag: ${unknown}${flagHint('upgrade', unknown)}${seeHelp('upgrade')}\n`,
556
+ sayNotice(
557
+ 'error',
558
+ 'vx upgrade',
559
+ `unknown flag: ${unknown}${flagHint('upgrade', unknown)}${seeHelp('upgrade')}`,
556
560
  )
557
561
  return 1
558
562
  }
@@ -560,7 +564,7 @@ export async function upgradeCmd(args: readonly string[]): Promise<number> {
560
564
  // the first one went in (`vx upgrade v0.0.22 v0.0.23`).
561
565
  const [tag, extra] = args
562
566
  if (extra !== undefined) {
563
- process.stderr.write(`vx upgrade: unexpected argument: ${extra}${seeHelp('upgrade')}\n`)
567
+ sayNotice('error', 'vx upgrade', `unexpected argument: ${extra}${seeHelp('upgrade')}`)
564
568
  return 1
565
569
  }
566
570
  if (!isCompiledBinary()) {
@@ -579,7 +583,11 @@ export async function upgradeCmd(args: readonly string[]): Promise<number> {
579
583
  dest,
580
584
  () =>
581
585
  releaseBinary(pkg, spec, (version) =>
582
- process.stdout.write(`vx upgrade: ${VERSION} → ${version} (${dest})\n`),
586
+ ui.print(
587
+ 'out',
588
+ ui.status('info', 'vx upgrade', `${VERSION} → ${version}`),
589
+ ui.text(ui.dim(dest)),
590
+ ),
583
591
  ),
584
592
  // The replaced binary's own version: the new build speaks for itself
585
593
  // rather than this process guessing, and one that cannot is rolled back.
@@ -588,10 +596,11 @@ export async function upgradeCmd(args: readonly string[]): Promise<number> {
588
596
  return installed !== ''
589
597
  },
590
598
  )
591
- process.stdout.write(
599
+ ui.print(
600
+ 'out',
592
601
  replaced
593
- ? `vx upgrade: installed ${installed}\n`
594
- : `vx upgrade: already at ${VERSION} — nothing to do\n`,
602
+ ? ui.status('ok', 'vx upgrade', `installed ${installed.replace(/^vx /, '')}`)
603
+ : ui.status('info', 'vx upgrade', `already at ${VERSION}`),
595
604
  )
596
605
  return 0
597
606
  }
@@ -17,7 +17,7 @@ import { asTrees } from '../cache/index.js'
17
17
  import { PROJECT_CONFIG_FILENAMES, WORKSPACE_CONFIG_FILENAMES } from '../workspace/index.js'
18
18
  import { isIgnoredDir } from './watch-fs.js'
19
19
 
20
- const IGNORED_SUFFIXES = ['.tsbuildinfo', '~']
20
+ const IGNORED_SUFFIXES = ['~']
21
21
 
22
22
  /**
23
23
  * True for a path the ignore filter drops. Every project dir is watched
@@ -32,8 +32,13 @@ export function isIgnoredWatchPath(rel: string): boolean {
32
32
  const segments = rel.split(path.sep)
33
33
  for (let i = 1; i <= segments.length; i++) if (isIgnoredDir(segments.slice(0, i))) return true
34
34
  if (IGNORED_SUFFIXES.some((suf) => rel.endsWith(suf))) return true
35
+ const name = segments[segments.length - 1]!
36
+ // A restore's own temporary file, never an input (`ALWAYS_IGNORE`):
37
+ // renamed away before the judgement, it read as a deletion and ran one
38
+ // more cycle where an uncached task makes ignored paths count.
39
+ if (name.startsWith('.vx-tmp-')) return true
35
40
  // `vx lock`'s review copy: no input, so a re-lock is no cycle.
36
- return segments[segments.length - 1] === 'vx.lock.json'
41
+ return name === 'vx.lock.json'
37
42
  }
38
43
 
39
44
  /**
@@ -161,7 +161,12 @@ export class ChangeJudge {
161
161
  spell !== undefined && this.ctx.held() && this.ctx.trackedAtArm?.has(spell(abs)) !== true
162
162
  if (this.lastCycle === undefined && !open) return false
163
163
  try {
164
- const m = fs.statSync(abs).mtimeMs
164
+ // The later of the two clocks: a cache hit restores an output with
165
+ // the mtime it was saved with, and read by mtime alone the restore
166
+ // was the user's edit and ran one more cycle (solid, M 2026-10-15).
167
+ // The restore's write moves the ctime.
168
+ const st = fs.statSync(abs)
169
+ const m = Math.max(st.mtimeMs, st.ctimeMs)
165
170
  const start = this.lastCycle?.start ?? this.ctx.armedAt
166
171
  return m >= start && (open || m <= this.lastCycle!.end)
167
172
  } catch {
package/src/cli/watch.ts CHANGED
@@ -38,12 +38,12 @@ import {
38
38
  import { CLOSED, fsClockNow, type WatchHandle, WatcherPool } from './watch-fs.js'
39
39
  import { ChangeJudge } from './watch-judge.js'
40
40
  import { type WatchCycle, watchCycle } from './watch-cycle.js'
41
- import { hangupIgnored, maskedLine, restartTimings } from '../util/index.js'
41
+ import { hangupIgnored, maskedLine, restartTimings, sayNotice, sayLine } from '../util/index.js'
42
42
  import { sweepConfigs, watchedProjects } from './watch-set.js'
43
43
 
44
44
  /** One line for a watcher or re-read the OS refused; the loop goes on without it. */
45
45
  function sayCannot(what: string, err: unknown): void {
46
- process.stderr.write(`vx watch: ${what}: ${err instanceof Error ? err.message : String(err)}\n`)
46
+ sayNotice('warn', 'vx watch', `${what}: ${err instanceof Error ? err.message : String(err)}`)
47
47
  }
48
48
 
49
49
  /** Wait this long after the last filesystem event before re-running. */
@@ -84,18 +84,20 @@ export function watchRefusal(parsed: RunArgs): string | null {
84
84
  export async function watchCmd(args: readonly string[]): Promise<number> {
85
85
  const parsed = parseRunArgs(args, 'watch')
86
86
  if (parsed.error) {
87
- process.stderr.write(`vx watch: ${parsed.error}${seeHelp('watch')}\n`)
87
+ sayNotice('error', 'vx watch', `${parsed.error}${seeHelp('watch')}`)
88
88
  return 1
89
89
  }
90
90
 
91
91
  const refused = watchRefusal(parsed)
92
92
  if (refused !== null) {
93
- process.stderr.write(`${refused}\n`)
93
+ sayLine('error', refused, 'vx watch')
94
94
  return 1
95
95
  }
96
96
  if (parsed.tasks.length === 0) {
97
- process.stderr.write(
98
- `vx watch: missing task name (vx watch <task>, e.g. vx watch build)${seeHelp('watch')}\n`,
97
+ sayNotice(
98
+ 'error',
99
+ 'vx watch',
100
+ `missing task name (vx watch <task>, e.g. vx watch build)${seeHelp('watch')}`,
99
101
  )
100
102
  return 1
101
103
  }
@@ -103,11 +105,11 @@ export async function watchCmd(args: readonly string[]): Promise<number> {
103
105
  const cwd = process.cwd()
104
106
  const resolved = await resolveRunOptions(parsed, cwd, parsed.tasks, 'watch')
105
107
  if ('error' in resolved) {
106
- process.stderr.write(`vx watch: ${resolved.error}\n`)
108
+ sayNotice('error', 'vx watch', resolved.error)
107
109
  return 1
108
110
  }
109
111
  if ('nothingSelected' in resolved) {
110
- process.stderr.write(`vx watch: ${resolved.nothingSelected}\n`)
112
+ sayNotice('info', 'vx watch', resolved.nothingSelected)
111
113
  return 0
112
114
  }
113
115
  // The watch loop owns SIGINT/SIGTERM for its whole lifetime. A cycle's
@@ -216,7 +218,7 @@ export async function watchCmd(args: readonly string[]): Promise<number> {
216
218
  let refusedToStart = false
217
219
  initial.held = runOrchestrator(initial.cycle.opts).then(
218
220
  (run) => {
219
- if (run.refused !== undefined) process.stderr.write(`vx watch: ${run.refused}\n`)
221
+ if (run.refused !== undefined) sayNotice('error', 'vx watch', run.refused)
220
222
  // A run that failed having run nothing refused to start: a requested
221
223
  // name no project declares (run() says which, with a "did you mean").
222
224
  // `vx run` exits 1 on it; `vx watch buidl` watched on, re-running the
@@ -227,8 +229,10 @@ export async function watchCmd(args: readonly string[]): Promise<number> {
227
229
  return run.persistent
228
230
  },
229
231
  (err: unknown) => {
230
- process.stderr.write(
231
- `vx watch: cycle failed: ${err instanceof Error ? err.message : String(err)}\n`,
232
+ sayNotice(
233
+ 'error',
234
+ 'vx watch',
235
+ `cycle failed: ${err instanceof Error ? err.message : String(err)}`,
232
236
  )
233
237
  return undefined
234
238
  },
@@ -247,7 +251,7 @@ export async function watchCmd(args: readonly string[]): Promise<number> {
247
251
  // run's own refusal and its "did you mean".
248
252
  if (scope.length === 0 && !unlocked) {
249
253
  await held?.stop()
250
- process.stderr.write(`vx watch: no projects in scope\n`)
254
+ sayNotice('error', 'vx watch', 'no projects in scope')
251
255
  return 1
252
256
  }
253
257
  await armLoop(undefined, held)
@@ -491,7 +495,7 @@ async function runWatchLoop(args: WatchLoopArgs): Promise<void> {
491
495
  held = cycle.persistent
492
496
  // What this cycle did not take (a server whose task did not run).
493
497
  await before?.stop()
494
- if (cycle.refused !== undefined) process.stderr.write(`vx watch: ${cycle.refused}\n`)
498
+ if (cycle.refused !== undefined) sayNotice('error', 'vx watch', cycle.refused)
495
499
  changes.lastCycle = { start, end: Date.now() }
496
500
  } catch (err) {
497
501
  // A re-run can fail catastrophically when the workspace
@@ -503,7 +507,7 @@ async function runWatchLoop(args: WatchLoopArgs): Promise<void> {
503
507
  // is the canonical exit; we don't unilaterally abort here.
504
508
  // A config's own throw or a plugin's failure may quote a secret (L-11).
505
509
  const message = maskedLine(err instanceof Error ? err.message : String(err))
506
- process.stderr.write(`vx watch: cycle failed: ${message}\n`)
510
+ sayNotice('error', 'vx watch', `cycle failed: ${message}`)
507
511
  }
508
512
  // After a failed cycle too: a package added with a config that does
509
513
  // not load yet fails its cycle, and unarmed, the fix to that config
@@ -797,12 +801,18 @@ async function runWatchLoop(args: WatchLoopArgs): Promise<void> {
797
801
  armAncestors()
798
802
  }
799
803
 
800
- // Said once the loop is armed, and again after a cycle that re-read the
801
- // watched set (a member came or went, the lock or a plugin changed).
804
+ // Said once, when the loop first arms: a re-arm repeated it after every
805
+ // cycle that re-read the watched set, between task rows. `debug.watch`
806
+ // says each arm on stderr, for whoever needs to see the set change.
802
807
  let lastTasks: number | undefined
808
+ const plural = (n: number, noun: string): string => `${n} ${noun}${n === 1 ? '' : 's'}`
803
809
  const sayWatching = (count: number): void => {
804
- const tasks = lastTasks === undefined ? '' : ` · ${lastTasks} task${lastTasks === 1 ? '' : 's'}`
805
- process.stdout.write(`watching ${count} project${count === 1 ? '' : 's'}${tasks}\n`)
810
+ const tasks = lastTasks === undefined ? '' : ` · ${plural(lastTasks, 'task')}`
811
+ process.stdout.write(`watching ${plural(count, 'project')}${tasks}\n`)
812
+ }
813
+ const debugArms = resolveSetting('debug.watch', undefined).value === true
814
+ const sayArmed = (count: number): void => {
815
+ if (debugArms) process.stderr.write(`vx watch: armed ${plural(count, 'project')}\n`)
806
816
  }
807
817
 
808
818
  const rearm = async (say = true): Promise<void> => {
@@ -854,7 +864,7 @@ async function runWatchLoop(args: WatchLoopArgs): Promise<void> {
854
864
  // A new arm proves delivery like the first ones: an edit in the new
855
865
  // project right after this cycle is seen, not lost in the gap.
856
866
  await pool.proved()
857
- if (say) sayWatching(next.projects.length)
867
+ if (say) sayArmed(next.projects.length)
858
868
  }
859
869
 
860
870
  armMode(projects)
@@ -872,10 +882,18 @@ async function runWatchLoop(args: WatchLoopArgs): Promise<void> {
872
882
  // never heard `packages/` removed under `packages/a` (inotify did).
873
883
  const ancestorArms = new Map<string, WatchHandle>()
874
884
  let ancestorNames = new Map<string, Set<string>>()
885
+ /** Each project directory below the root as these arms first saw it (`inodeOf`). */
886
+ let heldDirs = new Map<string, string>()
875
887
  const armAncestors = (): void => {
876
888
  ancestorNames = new Map()
877
889
  const rootDir = path.resolve(workspaceRoot)
878
890
  const below = projectDirs.filter((d) => path.resolve(d) !== rootDir)
891
+ heldDirs = new Map(
892
+ below.flatMap((d) => {
893
+ const id = inodeOf(d)
894
+ return id === undefined ? [] : [[d, id]]
895
+ }),
896
+ )
879
897
  for (const base of [...below, ...importNames.keys()]) {
880
898
  let dir = path.dirname(base)
881
899
  let name = path.basename(base)
@@ -907,8 +925,13 @@ async function runWatchLoop(args: WatchLoopArgs): Promise<void> {
907
925
  arm(dir, false, (filename) => {
908
926
  // A project's parent's `.gitignore` shapes what its keys read, as the root's does.
909
927
  if (!ancestorNames.get(dir)?.has(filename) && filename !== '.gitignore') return
910
- reread = true
911
928
  const abs = path.join(dir, filename)
929
+ // A project directory still the one its arm holds is no change:
930
+ // its arm judges what is in it. macOS reports the directory's
931
+ // mtime here on every write at its top level, and a cycle's own
932
+ // write started the next (solid's `types`, M 2026-10-15).
933
+ if (heldDirs.get(abs) === (inodeOf(abs) ?? '')) return
934
+ reread = true
912
935
  trigger(path.relative(workspaceRoot, abs), abs)
913
936
  }),
914
937
  )
@@ -937,6 +960,7 @@ async function runWatchLoop(args: WatchLoopArgs): Promise<void> {
937
960
 
938
961
  lastTasks = args.tasks()
939
962
  sayWatching(projects.length)
963
+ sayArmed(projects.length)
940
964
 
941
965
  return await new Promise<void>((resolve) => {
942
966
  const cleanup = async (): Promise<void> => {
package/src/cli/why.ts CHANGED
@@ -16,7 +16,7 @@ import {
16
16
  whyDidThisRerunQuery as whyDidThisRerun,
17
17
  resolveRunId,
18
18
  } from '../orchestrator/index.js'
19
- import { MASKED, nearMatches, printable, UserError } from '../util/index.js'
19
+ import { MASKED, nearMatches, printable, refusal, UserError } from '../util/index.js'
20
20
  import { findWorkspaceRoot } from '../workspace/index.js'
21
21
  import { findCwdProject } from './select.js'
22
22
  import { cliCacheDir, discoverCliProjects, parseCacheDirFlag } from './workspace-config.js'
@@ -145,6 +145,15 @@ async function resolveTarget(
145
145
  .all() as Array<{ project: string; task: string }>
146
146
  const ids = pairs.map((p) => `${p.project}#${p.task}`)
147
147
  const invoked = ids.length === 0 && db.query('SELECT 1 FROM invocations LIMIT 1').get() !== null
148
+ if (ids.length === 0 && !invoked) {
149
+ throw refusal(
150
+ { tone: 'info', headline: `no runs of ${target} yet`, fix: `vx run ${target}` },
151
+ {
152
+ code: 'VX_E_NO_HISTORY',
153
+ message: `vx why: no recorded runs for "${target}"${suggest(target, ids, invoked)}`,
154
+ },
155
+ )
156
+ }
148
157
  if (target.includes('#')) {
149
158
  throw new UserError(
150
159
  `vx why: no recorded runs for "${target}"${suggest(target, ids, invoked)}`,
@@ -7,7 +7,7 @@
7
7
  import { existsSync } from 'node:fs'
8
8
  import path from 'node:path'
9
9
  import type { WorkspaceConfig } from '../config.js'
10
- import { UserError } from '../util/index.js'
10
+ import { UserError, sayLine } from '../util/index.js'
11
11
  import {
12
12
  discoverProjects,
13
13
  loadProjects,
@@ -33,7 +33,7 @@ interface CliWorkspace {
33
33
  }
34
34
 
35
35
  export const warnToStderr = (message: string): void => {
36
- process.stderr.write(`${message}\n`)
36
+ sayLine('warn', message)
37
37
  }
38
38
 
39
39
  export async function loadCliWorkspace(workspaceRoot: string): Promise<CliWorkspace> {
@@ -152,6 +152,12 @@ export interface TaskOutcome {
152
152
  * bounded to its first 8 KiB and last 56 KiB. The run's history keeps it.
153
153
  */
154
154
  failedOutput?: string
155
+ /**
156
+ * When the run keeps its bytes in a remote, a task that ran and saved no
157
+ * entry (failed, or uncached): its whole output as `encodeOutputLog`
158
+ * stores it, secrets masked, at the entry's bounds.
159
+ */
160
+ runLog?: string
155
161
  /**
156
162
  * On a failed persistent task: why it never became ready — the readiness
157
163
  * deadline fired, the child exited first (`exitCode` is then its own), or
@@ -171,6 +177,8 @@ export interface TaskOutcome {
171
177
  * (`--cache-download=none`) instead of landing on this machine. Absent for
172
178
  * every ordinary outcome. Telemetry-only. */
173
179
  outputs?: 'deferred'
180
+ /** The task ran here and its result went to the cache. The footer's `↑`. */
181
+ saved?: true
174
182
  /**
175
183
  * v11 analytics: hrtime span relative to the parent run's t=0.
176
184
  * Lets downstream analytics reconstruct the actual parallel timeline