@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
@@ -11,6 +11,11 @@ import {
11
11
  type CacheLayer,
12
12
  type CachePolicy,
13
13
  cachesNothing,
14
+ formatCachePolicy,
15
+ packRunOutputs,
16
+ resolveOutputs,
17
+ resolveWorkspaceOutputs,
18
+ runEntryName,
14
19
  stopRuntimeProbes,
15
20
  } from '../cache/index.js'
16
21
  import { VERSION } from '../version.js'
@@ -48,6 +53,9 @@ import {
48
53
  secretMask,
49
54
  isFsRefusal,
50
55
  isUserError,
56
+ detectColors,
57
+ relPosix,
58
+ type ColorSupport,
51
59
  } from '../util/index.js'
52
60
  import { keyedProjects } from './keyed-projects.js'
53
61
  import { isDefaultBuild } from './projects.js'
@@ -58,14 +66,14 @@ import { unkeyedGroupOutcome, type ExecuteArgs } from './execute-task.js'
58
66
  import { excludedTaint } from './excluded-keys.js'
59
67
  import { keyUpstream } from './upstream.js'
60
68
  import { busLogger, createEventBus, terminalSubscriber, type EventBus } from './events.js'
61
- import { installPlugins, PluginSetupError } from './plugin.js'
69
+ import { installPlugins, PluginSetupError, type RunReport, type RunReportTask } from './plugin.js'
70
+ import { decodeOutputLog } from './output-log.js'
62
71
  import { buildAdmission, executorLabel, resolveExecutors, teardownPlugins } from './plugin-host.js'
63
72
  import { subscribeTelemetry, type TelemetryHandle } from './telemetry-host.js'
64
73
  import { claimOutput, guardedOutput } from './output-host.js'
65
74
  import { assembleRunSummary, isPassStatus } from './telemetry.js'
66
75
  import type { RunContextRecord } from './telemetry.js'
67
76
  import { defaultLogger, type DefaultLogger, resolveOutputView, type Logger } from './logger.js'
68
- import { detectColors, type ColorSupport } from './colors.js'
69
77
  import { plainOutput } from './plain-output.js'
70
78
  import { formatPersistentList } from './framed-output.js'
71
79
  import { createForecast } from './forecast.js'
@@ -138,18 +146,19 @@ const emptyShortCircuit = (): ShortCircuit => ({
138
146
  groupKeys: new Map(),
139
147
  })
140
148
 
141
- /**
142
- * Compact the 4-axis cache policy into the `invocations.cache_policy`
143
- * column string. Each enabled axis contributes its flag; a fully-on
144
- * policy reads `'lR,lW,rR,rW'`. Pure presentation — never affects the key.
145
- */
146
- function compactCachePolicy(p: CachePolicy): string {
147
- const parts: string[] = []
148
- if (p.localRead) parts.push('lR')
149
- if (p.localWrite) parts.push('lW')
150
- if (p.remoteRead) parts.push('rR')
151
- if (p.remoteWrite) parts.push('rW')
152
- return parts.join(',')
149
+ /** The files `node`'s declared outputs match on disk now, workspace-relative and sorted. */
150
+ async function outputsLeft(
151
+ node: TaskNode,
152
+ workspaceRoot: string,
153
+ nestedProjectDirs: string[],
154
+ ): Promise<string[]> {
155
+ const declared = node.config.cache?.outputs
156
+ if (declared === undefined) return []
157
+ const [own, ws] = await Promise.all([
158
+ resolveOutputs({ projectDir: node.projectDir, outputs: declared.files, nestedProjectDirs }),
159
+ resolveWorkspaceOutputs({ workspaceRoot, outputs: declared.workspaceFiles ?? [] }),
160
+ ])
161
+ return [...new Set([...own, ...ws].map((p) => relPosix(workspaceRoot, p)))].sort()
153
162
  }
154
163
 
155
164
  /**
@@ -405,11 +414,11 @@ async function runOnBus(
405
414
  // per cycle under `vx watch`. A plugin whose own setup threw set up
406
415
  // nothing, and is the one left out.
407
416
  let tornDown = false
408
- const teardown = async (except?: unknown): Promise<void> => {
417
+ const teardown = async (except?: unknown, report?: RunReport): Promise<void> => {
409
418
  if (tornDown) return
410
419
  tornDown = true
411
420
  const plugins = prepared.plugins.filter((p) => p !== except)
412
- await teardownPlugins(plugins, (m) => log.status(m))
421
+ await teardownPlugins(plugins, (m) => log.status(m), report)
413
422
  }
414
423
  // A task whose command re-enters `vx run` in the workspace running it
415
424
  // is refused. When the inner run reaches this task again it forks a run
@@ -437,7 +446,7 @@ async function runOnBus(
437
446
  // A refusal is the caller's to print (on stderr, as `vx run:`): as a
438
447
  // status line it went to stdout, into whatever a script piped there.
439
448
  if (prepared.unresolvedTasks.length > 0) {
440
- const refused = `no projects declare task(s): ${prepared.unresolvedTasks.join(', ')}.${elsewhereHint(prepared.declaredElsewhere)}${didYouMean(prepared.unresolvedTasks, prepared.hintProjects, prepared.declaredElsewhere)}${await initHint(prepared)}`
449
+ const refused = `no projects declare task(s): ${prepared.unresolvedTasks.join(', ')}.${await unresolvedHint(prepared)}`
441
450
  await teardown()
442
451
  prepared.cache.close()
443
452
  return { ok: false, outcomes: [], refused }
@@ -467,6 +476,13 @@ async function runOnBus(
467
476
  // the plugin. A plugin without `setup` subscribes nothing.
468
477
  let disposePlugins: (() => void) | undefined
469
478
  let telemetry: TelemetryHandle | undefined
479
+ // One run-id per `vx run` invocation. Every task in the resulting graph
480
+ // carries it so analytics queries can group by invocation; plugins get it
481
+ // in their teardown's `RunReport`.
482
+ const runId = ulid()
483
+ const head = captureGitContext(prepared.workspaceRoot)
484
+ const ciContext = detectCi(process.env)
485
+ const keepRunLog = prepared.keepRunEntry !== undefined
470
486
  try {
471
487
  disposePlugins = await installPlugins({
472
488
  plugins: prepared.plugins,
@@ -474,6 +490,9 @@ async function runOnBus(
474
490
  workspaceRoot: prepared.workspaceRoot,
475
491
  cacheDir: prepared.cacheDir,
476
492
  warn: (m) => log.status(m),
493
+ cache: prepared.cacheFor,
494
+ ci: ciContext.ci,
495
+ branch: prepared.branch(),
477
496
  })
478
497
  } catch (err) {
479
498
  disposePlugins?.()
@@ -735,9 +754,6 @@ async function runOnBus(
735
754
  }),
736
755
  )
737
756
  try {
738
- // One run-id per `vx run` invocation. Every task in the resulting
739
- // graph carries it so analytics queries can group by invocation.
740
- const runId = ulid()
741
757
  const runStartHrTimeNs = process.hrtime.bigint()
742
758
  const endedAtMsAtStart = Date.now()
743
759
  const remoteCacheEnabled = prepared.hasRemoteLayer
@@ -768,10 +784,10 @@ async function runOnBus(
768
784
  // second status spawn.
769
785
  // `run.branch` names the branch over git's (`prepared.branch`).
770
786
  const gitContext = {
771
- ...captureGitContext(workspaceRoot, gitFilesCache.worktreeDirty),
787
+ ...head,
788
+ dirty: gitFilesCache.worktreeDirty,
772
789
  branch: prepared.branch(),
773
790
  }
774
- const ciContext = detectCi(process.env)
775
791
  const hostContext = captureHostContext()
776
792
 
777
793
  // The canonical run-context record — the same git/CI/host data the
@@ -809,7 +825,7 @@ async function runOnBus(
809
825
  invocationCommand(options.command?.split(' ') ?? process.argv.slice(1)),
810
826
  ),
811
827
  requestedTasks: [...options.tasks],
812
- cachePolicy: compactCachePolicy(policy),
828
+ cachePolicy: formatCachePolicy(policy),
813
829
  concurrency,
814
830
  flow: options.flow ?? null,
815
831
  commitSha: gitContext.commitSha,
@@ -912,6 +928,13 @@ async function runOnBus(
912
928
  version: VERSION,
913
929
  packageCount: packagesInScope.size,
914
930
  remoteCacheEnabled,
931
+ cache: {
932
+ local: policy.localRead || policy.localWrite,
933
+ localWrite: policy.localWrite,
934
+ remote: remoteCacheEnabled && (policy.remoteRead || policy.remoteWrite),
935
+ uploads: prepared.localCache.uploads,
936
+ notDownloaded: () => deferredOutputs.pending().length,
937
+ },
915
938
  concurrency,
916
939
  workspaceProjectCount,
917
940
  }
@@ -1107,6 +1130,7 @@ async function runOnBus(
1107
1130
  if (upfrontKey !== undefined) a.upfrontKey = upfrontKey
1108
1131
  if (upfrontGroupKey !== undefined) a.upfrontGroupKey = upfrontGroupKey
1109
1132
  if (tainted) a.taintedUpstream = true
1133
+ if (keepRunLog) a.keepRunLog = true
1110
1134
  if (!dependedOn.has(node.id)) a.noDependants = true
1111
1135
  if (holders.has(node.id)) a.terminal = true
1112
1136
  if (armSandbox !== undefined) a.armSandbox = armSandbox
@@ -1324,7 +1348,7 @@ async function runOnBus(
1324
1348
  const stillDeferred = deferredOutputs.pending()
1325
1349
  if (stillDeferred.length > 0) {
1326
1350
  log.status(
1327
- ` Deferred: ${stillDeferred.length} task(s) left outputs remote (--cache-download=none): ${stillDeferred.join(', ')}`,
1351
+ ` Deferred: left outputs remote (--cache-download=none): ${stillDeferred.join(', ')}`,
1328
1352
  )
1329
1353
  }
1330
1354
 
@@ -1332,6 +1356,55 @@ async function runOnBus(
1332
1356
  // change the run's exit code — the run already happened.
1333
1357
  // Written again after the keep-alive wait: a kept server's crash or a
1334
1358
  // Ctrl-C there is the process's exit, and the first write said ok.
1359
+ // The run's own bytes, kept in a remote that takes its writes (owner
1360
+ // 2026-10-15): a failed task's log and the files its outputs left, an
1361
+ // uncached task's log. Then the report every plugin's teardown reads.
1362
+ const keepRunBytes = async (final: readonly TaskOutcome[]): Promise<RunReport> => {
1363
+ const keep = prepared.keepRunEntry
1364
+ const tasks: RunReportTask[] = []
1365
+ const work: (() => Promise<void>)[] = []
1366
+ for (const o of final) {
1367
+ if (isGroupTask(o.node)) continue
1368
+ const task: { -readonly [K in keyof RunReportTask]: RunReportTask[K] } = {
1369
+ id: o.node.id,
1370
+ status: o.status,
1371
+ durationMs: Math.round(o.durationMs),
1372
+ ...(o.hash !== undefined ? { key: o.hash } : {}),
1373
+ }
1374
+ tasks.push(task)
1375
+ if (keep === undefined) continue
1376
+ const runLog = o.runLog
1377
+ if (runLog !== undefined) {
1378
+ work.push(async () => {
1379
+ const text = decodeOutputLog(runLog)
1380
+ .map((c) => c.text)
1381
+ .join('')
1382
+ if (text.length === 0) return
1383
+ const name = runEntryName(runId, o.node.id, 'log')
1384
+ if (await keep(name, new TextEncoder().encode(text))) task.log = name
1385
+ })
1386
+ }
1387
+ if (o.status === 'failed' && o.where === undefined) {
1388
+ work.push(async () => {
1389
+ const bytes = await packRunOutputs(
1390
+ prepared.workspaceRoot,
1391
+ await outputsLeft(
1392
+ o.node,
1393
+ prepared.workspaceRoot,
1394
+ prepared.nestedDirsByProject.get(o.node.projectName) ?? [],
1395
+ ),
1396
+ )
1397
+ if (bytes === null) return
1398
+ const name = runEntryName(runId, o.node.id, 'outputs')
1399
+ if (await keep(name, bytes)) task.outputs = name
1400
+ })
1401
+ }
1402
+ }
1403
+ for (let i = 0; i < work.length; i += 8) {
1404
+ await Promise.all(work.slice(i, i + 8).map((w) => w()))
1405
+ }
1406
+ return { runId, commit: head.commitSha, ciRun: ciContext, ok, tasks }
1407
+ }
1335
1408
  let json: RunSummaryJson | undefined
1336
1409
  const summarize = async (runOk: boolean, final = list): Promise<void> => {
1337
1410
  if (options.json === true) {
@@ -1405,7 +1478,7 @@ async function runOnBus(
1405
1478
  // own output masks it, so cache.db holds no plaintext value.
1406
1479
  command: maskInvocation(options.command ?? process.argv.slice(1).join(' ')),
1407
1480
  requestedTasks: options.tasks,
1408
- cachePolicy: compactCachePolicy(policy),
1481
+ cachePolicy: formatCachePolicy(policy),
1409
1482
  concurrency,
1410
1483
  flow: options.flow ?? null,
1411
1484
  forwardArgs: options.forwardArgs,
@@ -1470,6 +1543,7 @@ async function runOnBus(
1470
1543
  // drain of the lane stood here until item 634; deleting it changed
1471
1544
  // nothing the suite could see, because this gate is the rule.
1472
1545
  await cache.drainUploads?.()
1546
+ const report = await keepRunBytes(list)
1473
1547
  // Hand the per-run summary to the telemetry sinks + drain them. Only
1474
1548
  // when a sink is active (telemetry !== undefined) — otherwise this
1475
1549
  // whole block is skipped and the run is byte-identical to before.
@@ -1528,7 +1602,7 @@ async function runOnBus(
1528
1602
  // whole keep-alive wait below (C-66). Idempotent; the finally's stay.
1529
1603
  disposePlugins?.()
1530
1604
  telemetry?.dispose()
1531
- await teardown()
1605
+ await teardown(undefined, report)
1532
1606
  await closeCache()
1533
1607
  mark('close')
1534
1608
  printTimings()
@@ -1761,7 +1835,7 @@ export async function planRun(options: RunOptions): Promise<RunPlan> {
1761
1835
  return {
1762
1836
  tasks: [],
1763
1837
  unresolvedTasks: prepared.unresolvedTasks,
1764
- unresolvedHint: `${elsewhereHint(prepared.declaredElsewhere)}${didYouMean(prepared.unresolvedTasks, prepared.hintProjects, prepared.declaredElsewhere)}${await initHint(prepared)}`,
1838
+ unresolvedHint: await unresolvedHint(prepared),
1765
1839
  }
1766
1840
  }
1767
1841
  if (prepared.empty === 'no-tasks-declared' && prepared.declaredElsewhere.length > 0) {
@@ -1865,6 +1939,31 @@ function elsewhereHint(names: readonly string[]): string {
1865
1939
  return ` Only projects outside the selection declare ${names.join(', ')} — pass --all, or --filter to pick them.`
1866
1940
  }
1867
1941
 
1942
+ /**
1943
+ * What follows "no projects declare task(s): …": where the name is, its
1944
+ * nearest spelling, and, with neither, the lock a run reads its configs
1945
+ * from: a task just added to a vx.config is not in it until `vx lock`.
1946
+ */
1947
+ async function unresolvedHint(prepared: {
1948
+ unresolvedTasks: readonly string[]
1949
+ hintProjects: ReadonlyMap<string, ProjectEntry>
1950
+ declaredElsewhere: readonly string[]
1951
+ anyProjectConfig: boolean
1952
+ }): Promise<string> {
1953
+ const elsewhere = elsewhereHint(prepared.declaredElsewhere)
1954
+ const near = didYouMean(
1955
+ prepared.unresolvedTasks,
1956
+ prepared.hintProjects,
1957
+ prepared.declaredElsewhere,
1958
+ )
1959
+ const init = await initHint(prepared)
1960
+ const lock =
1961
+ init === '' && elsewhere === '' && !near.startsWith(' Did you mean')
1962
+ ? ' A run reads the lock: after editing a vx.config, run `vx lock`.'
1963
+ : ''
1964
+ return `${elsewhere}${near}${init}${lock}`
1965
+ }
1966
+
1868
1967
  /**
1869
1968
  * The first-run case, told apart from a typo: no project in the workspace
1870
1969
  * has a `vx.config.*` at all, so no name could have resolved. Reached only
@@ -5,7 +5,7 @@
5
5
  // ordinary write erases the status display first, writes its content,
6
6
  // then redraws, so the display can never interleave with task output.
7
7
 
8
- import { paint, type ColorSupport } from './colors.js'
8
+ import { paint, type ColorSupport } from '../util/index.js'
9
9
  import { formatTaskRow, paintIdParts, TIME_COL } from './framed-output.js'
10
10
 
11
11
  const NO_COLOR: ColorSupport = { enabled: false }
@@ -4,57 +4,31 @@
4
4
  // aren't real work and would inflate "N total" misleadingly.
5
5
 
6
6
  import type { TaskOutcome } from '../graph/index.js'
7
- import { paint, type ColorSupport } from './colors.js'
8
7
  import { tallyOutcomes } from './tally.js'
9
8
  import { isCacheHit } from './telemetry.js'
10
9
  import { outcomeLabel, projectOutcome, ranNoCache } from './events.js'
11
10
  import { isGroupTask } from '../graph/index.js'
12
- import { formatElapsed } from '../util/index.js'
11
+ import {
12
+ ERROR,
13
+ formatElapsed,
14
+ gradientRule,
15
+ paint,
16
+ SUCCESS,
17
+ WARN,
18
+ type ColorSupport,
19
+ } from '../util/index.js'
13
20
 
14
21
  const NO_COLOR: ColorSupport = { enabled: false }
15
22
 
16
- const SUCCESS = '#22c55e'
17
- const WARN = '#eab308'
18
23
  // Cache provenance hues (owner): local light blue, remote dark blue —
19
24
  // yellow is skipped's color on both meters.
20
25
  const LOCAL = '#38bdf8' // sky-400
21
26
  const REMOTE = '#2563eb' // blue-600
22
- const ERROR = '#ef4444'
23
27
  const ABORTED = '#f97316' // orange-500: red's neighbour, not a failure
24
28
  const NOT_RUN = '#a855f7' // purple-500: stopped before it began, apart from every other state
25
29
  const ACCENT = '#06b6d4' // cyan-500: the live region's running word
26
30
 
27
31
  const BAR_WIDTH = 50
28
- // Brand gradient for the rule: identity violet → pink (the project /
29
- // task hues), faded across the dash run. Plain dashes when colors
30
- // are off.
31
- const GRADIENT_FROM = [0xa7, 0x8b, 0xfa] as const
32
- const GRADIENT_TO = [0xf4, 0x72, 0xb6] as const
33
- // Rule ends flush with the bars: 2 indent + 8 label + 2 gap + 50 cells.
34
- const RULE_DASHES = 57
35
-
36
- /** Total visible rule width: `\u2500 <mark> ` + dashes. Shared by summary + header. */
37
- function gradientRule(colors: ColorSupport, mark = 'vx'): string {
38
- const head = `\u2500 `
39
- const dashes = '\u2500'.repeat(Math.max(8, RULE_DASHES + 2 - mark.length))
40
- if (!colors.enabled) return `${head}${mark} ${dashes}`
41
- const chunks = 8
42
- const per = Math.ceil(RULE_DASHES / chunks)
43
- let out = ''
44
- for (let i = 0; i < chunks; i++) {
45
- const seg = dashes.slice(i * per, (i + 1) * per)
46
- if (seg.length === 0) break
47
- const f = i / (chunks - 1)
48
- const hex = `#${GRADIENT_FROM.map((c, j) =>
49
- Math.round(c + (GRADIENT_TO[j]! - c) * f)
50
- .toString(16)
51
- .padStart(2, '0'),
52
- ).join('')}`
53
- out += paint(hex, seg, colors)
54
- }
55
- return `${paint(`#${GRADIENT_FROM.map((c) => c.toString(16).padStart(2, '0')).join('')}`, head, colors)}${paint('', mark, colors, { bold: true })} ${out}`
56
- }
57
-
58
32
  /**
59
33
  * Stacked state meter: one ▰ run per bucket, proportional cells via
60
34
  * largest-remainder, every non-zero bucket guaranteed at least one
@@ -111,6 +85,10 @@ export interface SummaryStats {
111
85
  upToDate: number
112
86
  restoredLocal: number
113
87
  restoredRemote: number
88
+ /** Ran here and saved to the cache: the legend's `↑`. */
89
+ saved?: number
90
+ /** Ran here with a `cache` block, succeeded, and saved nothing. */
91
+ notSaved?: number
114
92
  /** Tasks that executed (success + failed) with a `cache` block — the misses. */
115
93
  miss: number
116
94
  /** Executed with no `cache` block: never consulted the cache, not a miss. */
@@ -143,6 +121,21 @@ export interface RunContext {
143
121
  /** Worker-pool size for this run; shown on the `info` row. */
144
122
  concurrency?: number
145
123
  remoteCacheEnabled: boolean
124
+ /**
125
+ * The cache layers the run used, so the legend reads `local 1= 2↓ 4↑ ·
126
+ * remote 3↓ 4↑`. Absent: the plain `N miss` legend.
127
+ */
128
+ cache?: {
129
+ /** The run's policy reads or writes the local layer. */
130
+ local: boolean
131
+ localWrite: boolean
132
+ /** A remote layer exists and the policy reads or writes it. */
133
+ remote: boolean
134
+ /** The run's uploads, filled as each lands (a live ref). */
135
+ uploads: { count: number; failed: number }
136
+ /** Tasks whose outputs are still in the remote store. */
137
+ notDownloaded: () => number
138
+ }
146
139
  /** Total projects discovery found — the bar's denominator. */
147
140
  workspaceProjectCount?: number
148
141
  }
@@ -234,8 +227,8 @@ export function formatSummarySection(
234
227
  if (taskParts.length > 0) lines.push(legend(taskParts))
235
228
  }
236
229
 
237
- // Cache meter + numbers, fixed order: miss · up-to-date · local ·
238
- // remote.
230
+ // Cache meter + numbers: per layer when the run context names its
231
+ // layers, else miss · no-cache · up-to-date · local · remote.
239
232
  if (miss + noCache + hits + left + stats.skipped > 0) {
240
233
  const cacheBar = segmentBar(
241
234
  [
@@ -250,13 +243,38 @@ export function formatSummarySection(
250
243
  colors,
251
244
  )
252
245
  const cacheParts: string[] = []
253
- if (miss > 0) cacheParts.push(paint(ERROR, `${miss} miss`, colors))
254
- if (noCache > 0) cacheParts.push(paint('', `${noCache} no-cache`, colors, { dim: true }))
255
- if (stats.upToDate > 0) cacheParts.push(paint(SUCCESS, `${stats.upToDate} up-to-date`, colors))
256
- if (stats.restoredLocal > 0)
257
- cacheParts.push(paint(LOCAL, `${stats.restoredLocal} local`, colors))
258
- if (stats.restoredRemote > 0)
259
- cacheParts.push(paint(REMOTE, `${stats.restoredRemote} remote`, colors))
246
+ const layers = context?.cache
247
+ if (layers !== undefined) {
248
+ // Per layer (M, 2026-10-15): `●` fresh (outputs already on disk, from
249
+ // either layer) and `○` no cache, both local; `↓` restored from a
250
+ // layer, `↑` saved to it.
251
+ const n = (count: number, glyph: string): string[] => (count > 0 ? [`${count}${glyph}`] : [])
252
+ const local = [
253
+ ...(layers.local ? n(stats.upToDate, '\u25cf') : []),
254
+ ...n(noCache, '\u25cb'),
255
+ ...(layers.local ? n(stats.restoredLocal, '\u2193') : []),
256
+ ...(layers.localWrite ? n(stats.saved ?? 0, '\u2191') : []),
257
+ ]
258
+ if (local.length > 0) cacheParts.push(paint(LOCAL, `local ${local.join(' ')}`, colors))
259
+ const remote = [...n(stats.restoredRemote, '\u2193'), ...n(layers.uploads.count, '\u2191')]
260
+ if (layers.remote && remote.length > 0)
261
+ cacheParts.push(paint(REMOTE, `remote ${remote.join(' ')}`, colors))
262
+ if (layers.uploads.failed > 0)
263
+ cacheParts.push(paint(ERROR, `${layers.uploads.failed} \u2191 failed`, colors))
264
+ if ((stats.notSaved ?? 0) > 0)
265
+ cacheParts.push(paint(WARN, `${stats.notSaved} not saved`, colors))
266
+ const notDownloaded = layers.notDownloaded()
267
+ if (notDownloaded > 0) cacheParts.push(paint(WARN, `${notDownloaded} not downloaded`, colors))
268
+ } else {
269
+ if (miss > 0) cacheParts.push(paint(ERROR, `${miss} miss`, colors))
270
+ if (noCache > 0) cacheParts.push(paint('', `${noCache} no-cache`, colors, { dim: true }))
271
+ if (stats.upToDate > 0)
272
+ cacheParts.push(paint(SUCCESS, `${stats.upToDate} up-to-date`, colors))
273
+ if (stats.restoredLocal > 0)
274
+ cacheParts.push(paint(LOCAL, `${stats.restoredLocal} local`, colors))
275
+ if (stats.restoredRemote > 0)
276
+ cacheParts.push(paint(REMOTE, `${stats.restoredRemote} remote`, colors))
277
+ }
260
278
  // Skipped tasks ride the bar, yellow like the tasks meter, so the two
261
279
  // bars fill alike; their count is the tasks legend's alone (the footer
262
280
  // never says a fact twice, M, 2026-10-14).
@@ -364,6 +382,11 @@ export function formatOutcomeTable(outcomes: readonly TaskOutcome[]): string[] {
364
382
 
365
383
  // Local byte formatter — the orchestrator can't import cli/format.ts
366
384
  // (module boundary), and the footer only needs one compact form.
385
+ /** Ran here with a `cache` block, succeeded, and its result was not saved. */
386
+ export function ranUnsaved(o: TaskOutcome): boolean {
387
+ return o.status === 'success' && !ranNoCache(o) && o.saved !== true && o.where === undefined
388
+ }
389
+
367
390
  export function formatRunSummary(
368
391
  outcomes: readonly TaskOutcome[],
369
392
  totalMs: number,
@@ -402,6 +425,8 @@ export function formatRunSummary(
402
425
  upToDate: t.upToDate,
403
426
  restoredLocal: t.restoredLocal,
404
427
  restoredRemote: t.restoredRemote,
428
+ saved: outcomes.filter((o) => o.saved === true && !isGroupTask(o.node)).length,
429
+ notSaved: outcomes.filter((o) => !isGroupTask(o.node) && ranUnsaved(o)).length,
405
430
  miss: t.total - t.skipped - (t.cachedLocal + t.cachedRemote) - noCache,
406
431
  noCache,
407
432
  savedMs,
@@ -117,7 +117,7 @@ export interface RunContextRecord {
117
117
  /** The invocation command line (process.argv-derived), what follows `--` counted, not quoted. */
118
118
  command: string
119
119
  requestedTasks: readonly string[]
120
- /** Compact cache-policy flags, e.g. `'lR,lW,rR,rW'`. */
120
+ /** The `--cache-policy` spec, e.g. `'local:rw,remote:r'`. */
121
121
  cachePolicy: string
122
122
  concurrency: number
123
123
  flow: 'focused' | 'broad' | null
package/src/settings.ts CHANGED
@@ -36,6 +36,8 @@ export interface Setting {
36
36
  readonly task?: string
37
37
  /** False: read before `.vx/workspace` loads, so flag and env only. */
38
38
  readonly config?: false
39
+ /** False: `.vx/workspace` only, no variable. */
40
+ readonly env?: false
39
41
  }
40
42
 
41
43
  const SCHEDULES = ['most-work', 'critical-path', 'direct-dependents', 'ready-order'] as const
@@ -193,6 +195,7 @@ export const SETTINGS: readonly Setting[] = [
193
195
  default: 'true',
194
196
  doc: "Remove a task's declared outputs before it runs on a miss, for tasks without their own.",
195
197
  task: 'cache.outputs.clean',
198
+ env: false,
196
199
  },
197
200
  {
198
201
  path: 'lock.reviewFiles',
@@ -221,6 +224,13 @@ export const SETTINGS: readonly Setting[] = [
221
224
  doc: "Print the run's stage timings to stderr.",
222
225
  config: false,
223
226
  },
227
+ {
228
+ path: 'debug.watch',
229
+ kind: { type: 'bool' },
230
+ default: 'false',
231
+ doc: '`vx watch`: say on stderr each time the loop arms, with the projects it watches.',
232
+ config: false,
233
+ },
224
234
  {
225
235
  path: 'debug.dumpable',
226
236
  kind: { type: 'bool' },
@@ -356,7 +366,8 @@ export function settingRefusal(
356
366
 
357
367
  /** A setting's environment value, parsed; undefined when unset or empty. */
358
368
  export function settingEnv(path: string, env: NodeJS.ProcessEnv = process.env): unknown {
359
- const { kind } = setting(path)
369
+ const { kind, env: read } = setting(path)
370
+ if (read === false) return undefined
360
371
  const name = envName(path)
361
372
  const raw = env[name]
362
373
  if (raw === undefined || raw === '') return undefined
@@ -13,6 +13,11 @@ const RESET = '\x1b[0m'
13
13
  const BOLD = '\x1b[1m'
14
14
  const DIM = '\x1b[2m'
15
15
 
16
+ // State hues every report shares with the run summary.
17
+ export const SUCCESS = '#22c55e'
18
+ export const WARN = '#eab308'
19
+ export const ERROR = '#ef4444'
20
+
16
21
  export interface ColorSupport {
17
22
  enabled: boolean
18
23
  }
@@ -7,8 +7,9 @@
7
7
  import { readFileSync } from 'node:fs'
8
8
  import path from 'node:path'
9
9
 
10
- // `<file>: tasks.<path>[:] …`, or a top-level `<file> has unknown field …`.
11
- const HEAD = /^(\/[^\n]*?\/vx\.config\.[cm]?[jt]s)(?:: (tasks\.[^\s:]+)(:?))? /
10
+ // `<file>: tasks.<path>[:] …`, or a top-level `<file> has unknown field …`,
11
+ // from a project's vx.config or the workspace's .vx/workspace.ts.
12
+ const HEAD = /^(\/[^\n]*?\/(?:vx\.config|\.vx\/workspace)\.[cm]?[jt]s)(?:: (tasks\.[^\s:]+)(:?))? /
12
13
 
13
14
  /**
14
15
  * The message with its file named at `line:col`, relative to `cwd`, and a
@@ -88,3 +89,31 @@ function locate(
88
89
  function escapeRe(s: string): string {
89
90
  return s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
90
91
  }
92
+
93
+ /** A config refusal laid out for `ui.notice`: where, what, the allowed set, the fix, the frame. */
94
+ export interface ConfigRefusal {
95
+ where: string
96
+ problem: string
97
+ allowed?: string
98
+ fix?: string
99
+ frame: string
100
+ }
101
+
102
+ /** `configErrorFrame`'s answer in parts, or undefined when it has none. */
103
+ export function configRefusal(message: string, cwd: string): ConfigRefusal | undefined {
104
+ const framed = configErrorFrame(message, cwd)
105
+ if (framed === undefined) return undefined
106
+ const cut = framed.indexOf('\n\n')
107
+ const line = framed.slice(0, cut)
108
+ const frame = framed.slice(cut + 2)
109
+ const at = /^(\S+?:\d+:\d+):? (.*)$/s.exec(line)
110
+ if (at === null) return { where: line, problem: '', frame }
111
+ const said = /^(.*?)(?: \(allowed: ([^)]*)\))?(?: — did you mean ([^?]+)\?)?$/s.exec(at[2]!)!
112
+ return {
113
+ where: at[1]!,
114
+ problem: said[1]!.replace(/^has /, ''),
115
+ ...(said[2] !== undefined ? { allowed: said[2] } : {}),
116
+ ...(said[3] !== undefined ? { fix: `use ${said[3]}` } : {}),
117
+ frame,
118
+ }
119
+ }
@@ -13,6 +13,8 @@ export class UserError extends Error {
13
13
  * does not. `VX_E_REFUSED` is any refusal without a code of its own.
14
14
  */
15
15
  readonly code: string
16
+ /** The refusal laid out for a terminal (`refusal`); the message is the same in one line. */
17
+ parts?: RefusalParts
16
18
  // Two signatures, not an optional parameter: the package-api record
17
19
  // keeps the one-argument form every plugin already calls.
18
20
  constructor(message: string)
@@ -24,6 +26,36 @@ export class UserError extends Error {
24
26
  }
25
27
  }
26
28
 
29
+ /** A refusal in parts, as `ui.notice` draws it: one fact a line, why, and the fix. */
30
+ export interface RefusalParts {
31
+ /** `info`: nothing is wrong, there is just nothing to show yet. Default `error`. */
32
+ readonly tone?: 'info'
33
+ readonly headline: string
34
+ readonly what?: readonly string[]
35
+ readonly why?: string
36
+ readonly fix?: string
37
+ }
38
+
39
+ /**
40
+ * A UserError whose terminal form is `parts`. Its message, what JSON and
41
+ * logs carry, is `message` when given, else the parts in one line.
42
+ */
43
+ export function refusal(
44
+ parts: RefusalParts,
45
+ opts: { code?: string; message?: string } = {},
46
+ ): UserError {
47
+ const message =
48
+ opts.message ??
49
+ [
50
+ [parts.headline, ...(parts.what ?? [])].join(': '),
51
+ ...(parts.why !== undefined ? [parts.why] : []),
52
+ ...(parts.fix !== undefined ? [parts.fix] : []),
53
+ ].join('; ')
54
+ const err = new UserError(message, opts.code ?? 'VX_E_REFUSED')
55
+ err.parts = parts
56
+ return err
57
+ }
58
+
27
59
  // A refusal that opens with the config that caused it: the schema's
28
60
  // `<file>: tasks.<name> …` and the loader's `<file> has unknown field …`.
29
61
  const CONFIG_REFUSAL = /^\/\S*\/(?:vx\.config|\.vx\/workspace)\.[cm]?[jt]s\b/
package/src/util/index.ts CHANGED
@@ -13,6 +13,8 @@ export {
13
13
  isPermissionError,
14
14
  isUserError,
15
15
  PERMISSION_HINT,
16
+ refusal,
17
+ type RefusalParts,
16
18
  UserError,
17
19
  gitSpawnRefusal,
18
20
  isExecutableMissing,
@@ -21,7 +23,12 @@ export {
21
23
  OUT_OF_FDS_HINT,
22
24
  wantsJson,
23
25
  } from './errors.js'
24
- export { configErrorFrame } from './config-frame.js'
26
+ export { configErrorFrame, configRefusal } from './config-frame.js'
27
+ export { detectColors, ERROR, paint, SUCCESS, WARN, type ColorSupport } from './colors.js'
28
+ export { gradientRule } from './report.js'
29
+ import * as ui from './ui.js'
30
+ export { refusalNotice, sayLine, sayNotice } from './refusal-notice.js'
31
+ export { ui }
25
32
  export { xxh3, xxh3hex } from './hash.js'
26
33
  export {
27
34
  beginRun,