@vzn/vx 0.0.645 → 0.0.648

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 (186) hide show
  1. package/README.md +39 -50
  2. package/launcher.cjs +9 -2
  3. package/launcher.sh +2 -2
  4. package/package.json +5 -5
  5. package/schemas/cache.json +5 -5
  6. package/schemas/error.json +1 -1
  7. package/schemas/info.json +16 -2
  8. package/schemas/last.json +127 -5
  9. package/schemas/plan.json +28 -2
  10. package/schemas/show.json +10 -1
  11. package/skills/vx/SKILL.md +19 -3
  12. package/src/bin.ts +54 -13
  13. package/src/cache/archive.ts +324 -23
  14. package/src/cache/cache.ts +330 -65
  15. package/src/cache/git-inputs.ts +274 -97
  16. package/src/cache/index.ts +7 -2
  17. package/src/cache/inputs.ts +515 -83
  18. package/src/cache/key-fold.ts +15 -6
  19. package/src/cache/layer.ts +74 -11
  20. package/src/cache/layered-cache.ts +154 -59
  21. package/src/cache/linked-inputs.ts +1 -1
  22. package/src/cache/output-index.ts +44 -14
  23. package/src/cache/plugin-cache.ts +49 -23
  24. package/src/cache/remote-hits.ts +72 -0
  25. package/src/cache/remote-lane.ts +100 -15
  26. package/src/cache/run-history.ts +77 -23
  27. package/src/cache/run-store.ts +11 -10
  28. package/src/cache/schema.ts +42 -6
  29. package/src/cache/tar-stream.ts +5 -0
  30. package/src/cache/umask.ts +1 -2
  31. package/src/cli/cache.ts +58 -27
  32. package/src/cli/completions.ts +12 -5
  33. package/src/cli/core-alias.ts +5 -2
  34. package/src/cli/docs.ts +42 -16
  35. package/src/cli/help.ts +62 -25
  36. package/src/cli/index.ts +79 -45
  37. package/src/cli/info.ts +12 -6
  38. package/src/cli/init.ts +222 -34
  39. package/src/cli/last.ts +443 -47
  40. package/src/cli/lock.ts +197 -52
  41. package/src/cli/plan-format.ts +91 -49
  42. package/src/cli/plugin-commands.ts +160 -11
  43. package/src/cli/plugin-templates.ts +13 -13
  44. package/src/cli/registry.ts +209 -0
  45. package/src/cli/run.ts +111 -41
  46. package/src/cli/select.ts +148 -49
  47. package/src/cli/show.ts +59 -16
  48. package/src/cli/task-verb.ts +16 -6
  49. package/src/cli/upgrade.ts +155 -54
  50. package/src/cli/watch-cycle.ts +10 -5
  51. package/src/cli/watch-filter.ts +71 -3
  52. package/src/cli/watch-fs.ts +22 -6
  53. package/src/cli/watch-judge.ts +48 -6
  54. package/src/cli/watch.ts +163 -42
  55. package/src/cli/why.ts +211 -73
  56. package/src/cli/workspace-config.ts +54 -15
  57. package/src/config.ts +13 -1
  58. package/src/exec/env.ts +7 -2
  59. package/src/exec/executor.ts +262 -15
  60. package/src/exec/index.ts +8 -2
  61. package/src/exec/kill-tree.ts +15 -0
  62. package/src/exec/local-executor.ts +1 -1
  63. package/src/exec/proc-sample.ts +70 -6
  64. package/src/exec/runner.ts +256 -53
  65. package/src/exec/sandbox-binds.ts +7 -3
  66. package/src/exec/sandbox-paths.ts +1 -1
  67. package/src/exec/sandbox-runtime.ts +222 -28
  68. package/src/exec/sandbox-violations.ts +52 -20
  69. package/src/graph/critical-path.ts +79 -0
  70. package/src/graph/default-build.ts +19 -0
  71. package/src/graph/definition.ts +38 -0
  72. package/src/graph/folds.ts +38 -5
  73. package/src/graph/index.ts +4 -1
  74. package/src/graph/priorities.ts +74 -8
  75. package/src/graph/scheduler.ts +326 -65
  76. package/src/graph/task-graph.ts +228 -35
  77. package/src/handoff.ts +97 -9
  78. package/src/history/failure-mode.ts +6 -3
  79. package/src/history/history.ts +268 -50
  80. package/src/history/index.ts +2 -0
  81. package/src/history/metrics.ts +142 -45
  82. package/src/index.ts +42 -10
  83. package/src/orchestrator/admission.ts +10 -6
  84. package/src/orchestrator/affected-tasks.ts +11 -6
  85. package/src/orchestrator/deferred-outputs.ts +2 -0
  86. package/src/orchestrator/doctor.ts +134 -42
  87. package/src/orchestrator/execute-args.ts +6 -0
  88. package/src/orchestrator/execute-task.ts +155 -42
  89. package/src/orchestrator/fingerprint-watch.ts +1 -1
  90. package/src/orchestrator/graph-memo.ts +140 -0
  91. package/src/orchestrator/graph-stage.ts +18 -1
  92. package/src/orchestrator/held-servers.ts +86 -0
  93. package/src/orchestrator/hit-restore.ts +227 -122
  94. package/src/orchestrator/index.ts +20 -3
  95. package/src/orchestrator/local-floor.ts +198 -0
  96. package/src/orchestrator/local-shortcircuit.ts +51 -8
  97. package/src/orchestrator/lockfile-claim.ts +96 -16
  98. package/src/orchestrator/miss-save.ts +52 -12
  99. package/src/orchestrator/options.ts +8 -6
  100. package/src/orchestrator/output-host.ts +43 -14
  101. package/src/orchestrator/output-log.ts +90 -10
  102. package/src/orchestrator/persistent-task.ts +97 -28
  103. package/src/orchestrator/persistent.ts +16 -4
  104. package/src/orchestrator/placement.ts +257 -14
  105. package/src/orchestrator/plan.ts +64 -7
  106. package/src/orchestrator/plugin-host.ts +230 -40
  107. package/src/orchestrator/plugin.ts +150 -37
  108. package/src/orchestrator/prepare.ts +178 -32
  109. package/src/orchestrator/projects.ts +65 -40
  110. package/src/orchestrator/remote-prefetch.ts +119 -25
  111. package/src/orchestrator/run-artifacts.ts +95 -32
  112. package/src/orchestrator/run-context.ts +39 -41
  113. package/src/orchestrator/run-failures.ts +65 -23
  114. package/src/orchestrator/run-id.ts +6 -3
  115. package/src/orchestrator/run-keys.ts +300 -0
  116. package/src/orchestrator/run-live.ts +294 -0
  117. package/src/orchestrator/run-lock.ts +13 -7
  118. package/src/orchestrator/run-records.ts +35 -23
  119. package/src/orchestrator/run-report.ts +3 -3
  120. package/src/orchestrator/run-steps.ts +58 -22
  121. package/src/orchestrator/run-stop.ts +22 -5
  122. package/src/orchestrator/run.ts +409 -79
  123. package/src/orchestrator/sandbox-fix.ts +7 -5
  124. package/src/orchestrator/sandbox-request.ts +78 -22
  125. package/src/orchestrator/shell-verdict.ts +5 -5
  126. package/src/orchestrator/signals.ts +26 -1
  127. package/src/orchestrator/stable-keys.ts +92 -8
  128. package/src/orchestrator/steps.ts +2 -2
  129. package/src/orchestrator/task-artifacts.ts +206 -0
  130. package/src/orchestrator/task-hash.ts +89 -14
  131. package/src/orchestrator/task-log-buffer.ts +72 -36
  132. package/src/orchestrator/task-process.ts +22 -0
  133. package/src/orchestrator/telemetry-host.ts +24 -3
  134. package/src/orchestrator/telemetry.ts +113 -38
  135. package/src/orchestrator/upstream.ts +15 -0
  136. package/src/report/events.ts +32 -13
  137. package/src/report/forecast.ts +36 -35
  138. package/src/report/framed-output.ts +173 -46
  139. package/src/report/index.ts +1 -0
  140. package/src/report/logger.ts +217 -186
  141. package/src/report/path-links.ts +12 -4
  142. package/src/report/plain-output.ts +13 -5
  143. package/src/report/status-line.ts +94 -46
  144. package/src/report/summary.ts +174 -38
  145. package/src/report/terminal-signals.ts +13 -2
  146. package/src/settings.ts +14 -6
  147. package/src/util/bun-version.ts +4 -1
  148. package/src/util/colors.ts +20 -3
  149. package/src/util/config-frame.ts +41 -4
  150. package/src/{graph → util}/dependency-spec.ts +22 -13
  151. package/src/util/edit-distance.ts +13 -1
  152. package/src/util/env-flag.ts +9 -0
  153. package/src/util/errors.ts +59 -3
  154. package/src/util/essential-env.ts +57 -0
  155. package/src/util/git-ignored.ts +1 -1
  156. package/src/util/hash.ts +35 -0
  157. package/src/util/hide-env.ts +45 -5
  158. package/src/util/index.ts +22 -5
  159. package/src/util/num.ts +8 -0
  160. package/src/util/paths.ts +121 -21
  161. package/src/util/report.ts +6 -1
  162. package/src/util/secret-mask.ts +62 -1
  163. package/src/util/secrets.ts +7 -2
  164. package/src/util/size.ts +11 -3
  165. package/src/util/tail.ts +6 -1
  166. package/src/util/timing.ts +13 -1
  167. package/src/util/ui.ts +65 -4
  168. package/src/util/which.ts +1 -1
  169. package/src/util/write-file.ts +67 -0
  170. package/src/workspace/affected.ts +710 -107
  171. package/src/workspace/bracket-literal.ts +84 -0
  172. package/src/workspace/canonical-dir.ts +37 -0
  173. package/src/workspace/config-bytecode.ts +282 -0
  174. package/src/workspace/config-eval.ts +29 -8
  175. package/src/workspace/config-imports.ts +199 -58
  176. package/src/workspace/config-schema.ts +242 -39
  177. package/src/workspace/filter.ts +37 -8
  178. package/src/workspace/index.ts +11 -2
  179. package/src/workspace/json-data.ts +3 -4
  180. package/src/workspace/lockfile.ts +57 -8
  181. package/src/workspace/migration.ts +72 -49
  182. package/src/workspace/package-graph.ts +5 -4
  183. package/src/workspace/project-loader.ts +314 -47
  184. package/src/workspace/repo-id.ts +1 -1
  185. package/src/workspace/workspace.ts +176 -33
  186. package/src/cli/foreign-flags.ts +0 -727
@@ -1,7 +1,17 @@
1
1
  // Formatters for `--dry` (human / JSON).
2
2
 
3
3
  import type { AffectedReason, CacheStatus, RunPlan } from '../orchestrator/index.js'
4
- import { formatBytes, formatElapsed, maskedCommand } from '../util/index.js'
4
+ import { skippedReason } from '../report/index.js'
5
+ import {
6
+ formatBytes,
7
+ formatElapsed,
8
+ maskedCommand,
9
+ paint,
10
+ SUCCESS,
11
+ ui,
12
+ WARN,
13
+ type ColorSupport,
14
+ } from '../util/index.js'
5
15
 
6
16
  /**
7
17
  * Human-readable preview. One line per real task (groups hidden, same
@@ -16,25 +26,36 @@ import { formatBytes, formatElapsed, maskedCommand } from '../util/index.js'
16
26
  * 3 task(s) planned, 2 cache hits (1 local, 1 remote), 1 would run.
17
27
  * predicted: ~1.20s wall · ~1.20s total execution
18
28
  */
19
- export function formatPlanText(plan: RunPlan): string {
20
- const real = plan.tasks.filter((t) => t.cacheStatus !== 'group')
21
- if (real.length === 0) return 'No tasks planned.\n'
29
+ export function formatPlanText(plan: RunPlan, c: ColorSupport = { enabled: false }): string {
30
+ // A group the platform skip reaches is still a group: it runs nothing.
31
+ const real = plan.tasks.filter(
32
+ (t) =>
33
+ t.cacheStatus !== 'group' &&
34
+ (t.cacheStatus !== 'skipped' || t.node.config.exec !== undefined),
35
+ )
36
+ const dim = (s: string): string => paint('', s, c, { dim: true })
37
+ if (real.length === 0) return `${dim('No tasks planned.')}\n`
22
38
 
23
39
  const idWidth = Math.max(...real.map((t) => t.node.id.length))
24
40
  const tagWidth = 24
25
41
 
26
- const lines: string[] = ['would run:']
42
+ const lines: string[] = [paint('', 'would run:', c, { bold: true })]
27
43
  let local = 0
28
44
  let remote = 0
29
45
  let miss = 0
30
46
  let nocache = 0
31
47
  let noop = 0
32
48
  let idle = 0
49
+ let platform = 0
33
50
  for (const t of real) {
34
51
  // `@noop`: the run succeeds it without running, whatever its key says.
35
- const skipped = t.executor === 'noop'
52
+ const skipped = t.executor === 'noop' && t.cacheStatus !== 'skipped'
36
53
  const sym = skipped ? '·' : symbolFor(t.cacheStatus)
37
- const desc = skipped ? 'noop — would not run' : describe(t.cacheStatus)
54
+ const desc = skipped
55
+ ? 'noop — would not run'
56
+ : t.cacheStatus === 'skipped'
57
+ ? `skipped — ${skippedReason(t)}`
58
+ : describe(t.cacheStatus)
38
59
  const shortHash = t.hash ? t.hash.slice(0, 8) : ''
39
60
  const executes = !skipped && (t.cacheStatus === 'miss' || t.cacheStatus === 'no-cache')
40
61
  const eta = executes && t.p50Ms !== undefined ? ` ~${formatElapsed(t.p50Ms)}` : ''
@@ -43,17 +64,18 @@ export function formatPlanText(plan: RunPlan): string {
43
64
  // tier, and a task placed on a named executor is what the reader can
44
65
  // act on. Present only when the workspace declared a choice.
45
66
  const where = t.executor !== undefined ? ` @${t.executor}` : ''
67
+ const hue = skipped ? '' : HUE[t.cacheStatus]
46
68
  lines.push(
47
- ` ${sym} ${t.node.id.padEnd(idWidth)} ${desc.padEnd(tagWidth)} ${shortHash}${eta}${where}`,
69
+ ` ${paint(hue, sym, c, { dim: hue === '' })} ${ui.inline(ui.id(t.node.id), c)}${' '.repeat(idWidth - t.node.id.length)} ${paint(hue, desc.padEnd(tagWidth), c, { dim: hue === '' })} ${dim(shortHash)}${dim(eta)}${where}`,
48
70
  )
49
71
  // Optional one-line description from the task config, indented
50
72
  // under the id so the eye picks up the task → blurb mapping.
51
73
  const taskDesc = shownDescription(t)
52
74
  if (taskDesc) {
53
- lines.push(` ${' '.repeat(idWidth)} ${taskDesc}`)
75
+ lines.push(` ${' '.repeat(idWidth)} ${dim(taskDesc)}`)
54
76
  }
55
77
  if (t.affected !== undefined) {
56
- lines.push(` ${' '.repeat(idWidth)} affected: ${describeReason(t.affected)}`)
78
+ lines.push(` ${' '.repeat(idWidth)} ${dim(`affected: ${describeReason(t.affected)}`)}`)
57
79
  }
58
80
  if (skipped) noop++
59
81
  else if (t.cacheStatus === 'hit-local') local++
@@ -61,6 +83,7 @@ export function formatPlanText(plan: RunPlan): string {
61
83
  else if (t.cacheStatus === 'miss') miss++
62
84
  else if (t.cacheStatus === 'no-cache') nocache++
63
85
  else if (t.cacheStatus === 'not-started') idle++
86
+ else if (t.cacheStatus === 'skipped') platform++
64
87
  }
65
88
 
66
89
  const hitParts: string[] = []
@@ -77,8 +100,9 @@ export function formatPlanText(plan: RunPlan): string {
77
100
  if (nocache > 0) summary.push(`${nocache} no-cache`)
78
101
  if (noop > 0) summary.push(`${noop} noop`)
79
102
  if (idle > 0) summary.push(`${idle} not started`)
103
+ if (platform > 0) summary.push(`${platform} skipped`)
80
104
  lines.push('')
81
- lines.push(summary.join(', ') + '.')
105
+ lines.push(dim(summary.join(', ') + '.'))
82
106
 
83
107
  // `--cache-download` is invisible without this: the eligibility gate silently
84
108
  // keeps producers eager, so a user who asked for `none` and got no
@@ -89,11 +113,14 @@ export function formatPlanText(plan: RunPlan): string {
89
113
  if (deferred.length > 0 || downgrades.length > 0) {
90
114
  lines.push('')
91
115
  lines.push(
92
- `download: ${deferred.length} task(s) would keep outputs remote` +
93
- (downgrades.length > 0 ? `, ${downgrades.length} kept eager:` : '.'),
116
+ dim(
117
+ `download: ${deferred.length} task(s) would keep outputs remote` +
118
+ (downgrades.length > 0 ? `, ${downgrades.length} kept eager:` : '.'),
119
+ ),
94
120
  )
95
- for (const d of downgrades.slice(0, 3)) lines.push(` ${d.taskId} — ${d.reason}`)
96
- if (downgrades.length > 3) lines.push(` …and ${downgrades.length - 3} more`)
121
+ for (const d of downgrades.slice(0, 3))
122
+ lines.push(` ${ui.inline(ui.id(d.taskId), c)} ${dim(`— ${d.reason}`)}`)
123
+ if (downgrades.length > 3) lines.push(dim(` …and ${downgrades.length - 3} more`))
97
124
  }
98
125
 
99
126
  // Time prediction — only when history gave us something to say: at least
@@ -106,7 +133,7 @@ export function formatPlanText(plan: RunPlan): string {
106
133
  if (p.unknownCount > 0) {
107
134
  parts.push(`${p.unknownCount} task${p.unknownCount === 1 ? '' : 's'} without history (+?)`)
108
135
  }
109
- lines.push(parts.join(' · '))
136
+ lines.push(dim(parts.join(' · ')))
110
137
  }
111
138
  return lines.join('\n') + '\n'
112
139
  }
@@ -149,6 +176,8 @@ export interface PlanTaskJson {
149
176
  download?: 'deferred'
150
177
  description?: string
151
178
  affected?: AffectedReason
179
+ otherPlatform?: { readonly needs: readonly string[]; readonly host: string }
180
+ blockedBy?: string
152
181
  }
153
182
 
154
183
  /** `affectedBase`: the ref a bare `--affected` guessed (or was given), so a reader knows what the diff was against. */
@@ -157,42 +186,51 @@ export function formatPlanJson(
157
186
  affectedBase?: string,
158
187
  ): string {
159
188
  return (
160
- JSON.stringify(
161
- {
162
- ...(affectedBase !== undefined ? { affectedBase } : {}),
163
- tasks: plan.tasks.map((t): PlanTaskJson => {
164
- const description = shownDescription(t)
165
- return {
166
- id: t.node.id,
167
- project: t.node.projectName,
168
- task: t.node.taskName,
169
- hash: t.hash,
170
- cacheStatus: t.cacheStatus,
171
- deps: t.deps,
172
- ...(t.p50Ms !== undefined ? { p50Ms: t.p50Ms } : {}),
173
- ...(t.executor !== undefined ? { executor: t.executor } : {}),
174
- ...(t.download !== undefined ? { download: t.download } : {}),
175
- ...(description !== undefined ? { description } : {}),
176
- ...(t.affected !== undefined ? { affected: t.affected } : {}),
177
- }
178
- }),
179
- ...(plan.predicted !== undefined ? { predicted: plan.predicted } : {}),
180
- // The gate's refusals belong on the SCRIPTING surface too: a CI job
181
- // asking "did --cache-download=none actually defer anything, and if not
182
- // why" reads this, not the human table. (This object enumerates its
183
- // fields deliberately — the plan's internal shape is not the wire —
184
- // which is exactly why a new PlannedTask field does not appear here
185
- // for free, and why `download` above had to be added by hand.)
186
- ...(plan.downloadDowngrades !== undefined
187
- ? { downloadDowngrades: plan.downloadDowngrades }
188
- : {}),
189
- },
190
- null,
191
- 2,
192
- ) + '\n'
189
+ JSON.stringify({
190
+ ...(affectedBase !== undefined ? { affectedBase } : {}),
191
+ tasks: plan.tasks.map((t): PlanTaskJson => {
192
+ const description = shownDescription(t)
193
+ return {
194
+ id: t.node.id,
195
+ project: t.node.projectName,
196
+ task: t.node.taskName,
197
+ hash: t.hash,
198
+ cacheStatus: t.cacheStatus,
199
+ deps: t.deps,
200
+ ...(t.p50Ms !== undefined ? { p50Ms: t.p50Ms } : {}),
201
+ ...(t.executor !== undefined ? { executor: t.executor } : {}),
202
+ ...(t.download !== undefined ? { download: t.download } : {}),
203
+ ...(description !== undefined ? { description } : {}),
204
+ ...(t.affected !== undefined ? { affected: t.affected } : {}),
205
+ ...(t.otherPlatform !== undefined ? { otherPlatform: t.otherPlatform } : {}),
206
+ ...(t.blockedBy !== undefined ? { blockedBy: t.blockedBy } : {}),
207
+ }
208
+ }),
209
+ ...(plan.predicted !== undefined ? { predicted: plan.predicted } : {}),
210
+ // The gate's refusals belong on the SCRIPTING surface too: a CI job
211
+ // asking "did --cache-download=none actually defer anything, and if not
212
+ // why" reads this, not the human table. (This object enumerates its
213
+ // fields deliberately — the plan's internal shape is not the wire —
214
+ // which is exactly why a new PlannedTask field does not appear here
215
+ // for free, and why `download` above had to be added by hand.)
216
+ ...(plan.downloadDowngrades !== undefined
217
+ ? { downloadDowngrades: plan.downloadDowngrades }
218
+ : {}),
219
+ }) + '\n'
193
220
  )
194
221
  }
195
222
 
223
+ /** A hit is green, a task that would run is yellow, the rest dim. */
224
+ const HUE: Record<CacheStatus, string> = {
225
+ 'hit-local': SUCCESS,
226
+ 'hit-remote': SUCCESS,
227
+ miss: WARN,
228
+ 'no-cache': WARN,
229
+ group: '',
230
+ 'not-started': '',
231
+ skipped: '',
232
+ }
233
+
196
234
  function symbolFor(s: CacheStatus): string {
197
235
  switch (s) {
198
236
  case 'hit-local':
@@ -207,6 +245,8 @@ function symbolFor(s: CacheStatus): string {
207
245
  return '○'
208
246
  case 'not-started':
209
247
  return '−'
248
+ case 'skipped':
249
+ return '·'
210
250
  }
211
251
  }
212
252
 
@@ -224,5 +264,7 @@ function describe(s: CacheStatus): string {
224
264
  return 'group task'
225
265
  case 'not-started':
226
266
  return 'not started (unneeded)'
267
+ case 'skipped':
268
+ return 'skipped'
227
269
  }
228
270
  }
@@ -9,9 +9,23 @@
9
9
 
10
10
  import type { CachePolicy, PluginCache, ResolvedRemote } from '../cache/index.js'
11
11
  import type { WorkspaceConfig } from '../config.js'
12
- import type { BranchFacts, VxPlugin, PluginCommand, CommandContext } from '../orchestrator/index.js'
12
+ import type {
13
+ BranchFacts,
14
+ VxPlugin,
15
+ PluginCommand,
16
+ CommandContext,
17
+ CacheFacts,
18
+ TaskArtifacts,
19
+ } from '../orchestrator/index.js'
13
20
  import { configuredBaseBranch, findWorkspaceRoot } from '../workspace/index.js'
14
- import { machineParallelism, maskedLine } from '../util/index.js'
21
+ import {
22
+ machineParallelism,
23
+ maskedLine,
24
+ SecretBroker,
25
+ thrownMessage,
26
+ ui,
27
+ UserError,
28
+ } from '../util/index.js'
15
29
  import { resolveSetting } from '../settings.js'
16
30
 
17
31
  export interface ResolvedPluginCommand {
@@ -45,7 +59,7 @@ async function workspacePlugins(cwd: string): Promise<
45
59
  | {
46
60
  workspaceRoot: string
47
61
  cacheDir: string
48
- storeRoot: Promise<string | null>
62
+ storeRoot: () => Promise<string | null>
49
63
  workspaceConfig: WorkspaceConfig | null
50
64
  concurrency: number
51
65
  plugins: readonly VxPlugin[]
@@ -65,7 +79,7 @@ async function workspacePlugins(cwd: string): Promise<
65
79
  const { loadCliWorkspace } = await import('./workspace-config.js')
66
80
  ws = await loadCliWorkspace(workspaceRoot)
67
81
  } catch (err) {
68
- return { loadError: err instanceof Error ? err.message : String(err) }
82
+ return { loadError: thrownMessage(err) }
69
83
  }
70
84
  return {
71
85
  workspaceRoot,
@@ -97,8 +111,10 @@ export async function resolvePluginCommand(
97
111
  const declaredVerbs = new Set<string>()
98
112
  for (const plugin of ws.plugins) {
99
113
  for (const declared of Object.keys(plugin.commands ?? {})) declaredVerbs.add(declared)
100
- const command = plugin.commands?.[verb]
101
- if (command === undefined) continue
114
+ // Own keys only: `vx toString` found Object.prototype's and crashed.
115
+ const commands = plugin.commands
116
+ if (commands === undefined || !Object.hasOwn(commands, verb)) continue
117
+ const command = commands[verb]!
102
118
  return {
103
119
  plugin,
104
120
  command,
@@ -111,6 +127,8 @@ export async function resolvePluginCommand(
111
127
  : [process.execPath, Bun.main],
112
128
  warn,
113
129
  cache: lazyCache(() => verbCache(ws, plugin.name)),
130
+ artifacts: lazyArtifacts(() => verbArtifacts(ws)),
131
+ artifactsFor: (facts) => lazyArtifacts(() => verbArtifacts(ws, facts)),
114
132
  },
115
133
  }
116
134
  }
@@ -118,7 +136,7 @@ export async function resolvePluginCommand(
118
136
  }
119
137
 
120
138
  const warn = (m: string): void => {
121
- process.stderr.write(`${maskedLine(m)}\n`)
139
+ ui.say('warn', maskedLine(m))
122
140
  }
123
141
 
124
142
  /** Read-only: a verb reads what runs stored, and only a run's policy may write. */
@@ -138,7 +156,7 @@ async function verbCache(
138
156
  ws: Exclude<Awaited<ReturnType<typeof workspacePlugins>>, UnresolvedPluginCommand | null>,
139
157
  plugin: string,
140
158
  ): Promise<PluginCache> {
141
- const [{ Cache, pluginCache }, { cacheRefs, resolveCache, resolveCacheScope }] =
159
+ const [{ Cache, pluginCache }, { cacheRefs, openRemotes, resolveCache, resolveCacheScope }] =
142
160
  await Promise.all([import('../cache/index.js'), import('../orchestrator/index.js')])
143
161
  const scope = resolveCacheScope(
144
162
  ws.workspaceRoot,
@@ -152,7 +170,7 @@ async function verbCache(
152
170
  ws.workspaceRoot,
153
171
  undefined,
154
172
  'open',
155
- await ws.storeRoot,
173
+ await ws.storeRoot(),
156
174
  )
157
175
  const remotes: ResolvedRemote[] = []
158
176
  await resolveCache(ws.plugins, {
@@ -160,14 +178,123 @@ async function verbCache(
160
178
  cacheDir: ws.cacheDir,
161
179
  warn,
162
180
  scope,
163
- refs: cacheRefs(ws.workspaceRoot, configuredBaseBranch(ws.workspaceConfig)),
181
+ refs: cacheRefs(ws.workspaceRoot, configuredBaseBranch(ws.workspaceConfig), ws.branch.refs),
164
182
  localCache,
165
183
  policy: VERB_POLICY,
166
184
  remotes,
167
185
  })
186
+ await openVerbRemotes(remotes, openRemotes)
168
187
  return pluginCache(plugin, { db: localCache.dbHandle(), policy: VERB_POLICY, remotes, scope })
169
188
  }
170
189
 
190
+ /**
191
+ * The verb's `ctx.artifacts`: the workspace's cache as a run opens it, its
192
+ * policy and scope, a laptop writing no remote unless the policy names it.
193
+ * With `facts` (`ctx.artifactsFor`), under another run's refs and lent access.
194
+ */
195
+ async function verbArtifacts(
196
+ ws: Exclude<Awaited<ReturnType<typeof workspacePlugins>>, UnresolvedPluginCommand | null>,
197
+ facts?: () => CacheFacts | undefined,
198
+ ): Promise<TaskArtifacts> {
199
+ const [
200
+ { Cache, FULL_CACHE_POLICY, parseCachePolicy },
201
+ {
202
+ cacheRefs,
203
+ namesRemote,
204
+ openRemotes,
205
+ resolveCache,
206
+ resolveCacheScope,
207
+ taskArtifacts,
208
+ workspaceCachePolicy,
209
+ },
210
+ { onCI },
211
+ ] = await Promise.all([
212
+ import('../cache/index.js'),
213
+ import('../orchestrator/index.js'),
214
+ import('./run.js'),
215
+ ])
216
+ // `VX_CACHE_POLICY` beats the workspace's `cache.policy`, as it does for a
217
+ // run, refused as `parseRunArgs` refuses it (cli/run.ts).
218
+ const envSpec = process.env['VX_CACHE_POLICY'] || undefined
219
+ let asked: CachePolicy
220
+ if (envSpec === undefined) {
221
+ asked = workspaceCachePolicy(ws.workspaceConfig) ?? FULL_CACHE_POLICY
222
+ } else {
223
+ if (!envSpec.split(',').some((seg) => seg.trim().length > 0))
224
+ throw new UserError(
225
+ 'VX_CACHE_POLICY needs a spec like local:r, local:rw, remote:, or local:,remote:rw',
226
+ 'VX_E_USAGE',
227
+ )
228
+ try {
229
+ asked = parseCachePolicy(envSpec)
230
+ } catch (err) {
231
+ throw new UserError(
232
+ `VX_CACHE_POLICY: ${err instanceof Error ? err.message : String(err)}`,
233
+ 'VX_E_USAGE',
234
+ )
235
+ }
236
+ }
237
+ const policy: CachePolicy =
238
+ onCI(process.env['CI']) || namesRemote(envSpec ?? ws.workspaceConfig?.cache?.policy)
239
+ ? asked
240
+ : { ...asked, remoteWrite: false }
241
+ const scope = resolveCacheScope(
242
+ ws.workspaceRoot,
243
+ ws.branch.trusted === true ? null : ws.branch.scope,
244
+ ws.plugins.some((p) => p.cache !== undefined),
245
+ configuredBaseBranch(ws.workspaceConfig),
246
+ )
247
+ const localCache = new Cache(
248
+ ws.cacheDir,
249
+ { read: policy.localRead, write: policy.localWrite },
250
+ ws.workspaceRoot,
251
+ undefined,
252
+ 'open',
253
+ await ws.storeRoot(),
254
+ )
255
+ const remotes: ResolvedRemote[] = []
256
+ const cache = await resolveCache(ws.plugins, {
257
+ workspaceRoot: ws.workspaceRoot,
258
+ cacheDir: ws.cacheDir,
259
+ warn,
260
+ scope,
261
+ refs:
262
+ facts?.()?.refs ??
263
+ cacheRefs(ws.workspaceRoot, configuredBaseBranch(ws.workspaceConfig), ws.branch.refs),
264
+ localCache,
265
+ policy,
266
+ remotes,
267
+ ...(facts === undefined ? {} : { lentBy: () => facts()?.lent }),
268
+ })
269
+ await openVerbRemotes(remotes, openRemotes)
270
+ return taskArtifacts(cache, ws.workspaceRoot)
271
+ }
272
+
273
+ /**
274
+ * Each remote's `open`, as a run's: a remote that takes its token there
275
+ * (turboCache's OIDC exchange, a lent token) had none, so an agent's
276
+ * uploads went nowhere and its run could not restore them.
277
+ */
278
+ async function openVerbRemotes(
279
+ remotes: readonly ResolvedRemote[],
280
+ open: (typeof import('../orchestrator/index.js'))['openRemotes'],
281
+ ): Promise<void> {
282
+ const ci = (await import('./run.js')).onCI(process.env['CI'])
283
+ await open(remotes, ci, new SecretBroker(ci, warn))
284
+ }
285
+
286
+ function lazyArtifacts(open: () => Promise<TaskArtifacts>): TaskArtifacts {
287
+ let opened: Promise<TaskArtifacts> | undefined
288
+ const artifacts = (): Promise<TaskArtifacts> => (opened ??= open())
289
+ return {
290
+ save: async (key, task, run) => (await artifacts()).save(key, task, run),
291
+ restore: async (key, task) => (await artifacts()).restore(key, task),
292
+ output: async (key) => (await artifacts()).output(key),
293
+ share: async (key) => (await artifacts()).share(key),
294
+ resend: async (key) => (await artifacts()).resend(key),
295
+ }
296
+ }
297
+
171
298
  function lazyCache(open: () => Promise<PluginCache>): PluginCache {
172
299
  let opened: Promise<PluginCache> | undefined
173
300
  const cache = (): Promise<PluginCache> => (opened ??= open())
@@ -202,8 +329,30 @@ export async function pluginCommandHelp(cwd = process.cwd()): Promise<string[]>
202
329
  for (const [verb, command] of Object.entries(plugin.commands ?? {})) {
203
330
  if (seen.has(verb)) continue
204
331
  seen.add(verb)
205
- lines.push(` vx ${verb.padEnd(17)} ${command.description} (${plugin.name})`)
332
+ lines.push(
333
+ ...helpLines(` vx ${verb.padEnd(17)} `, `${command.description} (${plugin.name})`),
334
+ )
206
335
  }
207
336
  }
208
337
  return lines
209
338
  }
339
+
340
+ /**
341
+ * One help entry: `head` then `text` wrapped at word breaks under the
342
+ * column `head` ends at, at core's reference width, so a plugin's long
343
+ * description reads as core's own entries do.
344
+ */
345
+ function helpLines(head: string, text: string, width = 88): string[] {
346
+ const indent = ' '.repeat(head.length)
347
+ const lines: string[] = []
348
+ let line = head
349
+ for (const word of text.split(/\s+/)) {
350
+ const fresh = line === head || line === indent
351
+ if (!fresh && line.length + 1 + word.length > width) {
352
+ lines.push(line)
353
+ line = indent + word
354
+ } else line += fresh ? word : ` ${word}`
355
+ }
356
+ lines.push(line)
357
+ return lines
358
+ }